From ba5acc83e7ab4cc4c67be559addd1fddee1ce226 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Tue, 23 Dec 2025 18:58:06 +0100 Subject: [PATCH 01/29] cleanup save operation and move view update out --- bin/all-o-stasis/src/passport.rs | 2 - bin/all-o-stasis/src/routes/api.rs | 1 - bin/all-o-stasis/src/storage.rs | 96 ++++++++++-------------------- 3 files changed, 31 insertions(+), 68 deletions(-) diff --git a/bin/all-o-stasis/src/passport.rs b/bin/all-o-stasis/src/passport.rs index 88189f4..bf66c48 100644 --- a/bin/all-o-stasis/src/passport.rs +++ b/bin/all-o-stasis/src/passport.rs @@ -361,7 +361,6 @@ async fn confirm_passport( snapshot.revision_id, String::from(""), // TODO fine? [op].to_vec(), - false, ) .await?; @@ -426,7 +425,6 @@ async fn await_passport_confirmation( revision_id, String::from(""), // TODO fine? [op].to_vec(), - false, ) .await?; diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index 6e107dd..7f4ad11 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -354,7 +354,6 @@ async fn patch_object( payload.revision_id, created_by, payload.operations, - false, ) .await?; diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index 70300c3..6fa7140 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -390,11 +390,7 @@ pub async fn apply_object_updates( rev_id: RevId, // TODO this is what? first is 0? author: ObjectId, operations: Vec, - skip_validation: bool, ) -> Result, AppError> { - // first check that the object exists. We'll need its metadata later - // let id = base_id(&obj_id); - // the 'Snapshot' against which the submitted operations were created // this only contains patches until base_snapshot.revision_id tracing::debug!("looking up base_snapshot@rev{rev_id}"); @@ -403,13 +399,13 @@ pub async fn apply_object_updates( // if there are any patches which the client doesn't know about we need // to let her know - // TODO cant we have patches that are not applied above but are now missing? let previous_patches = patches_after_revision(state, gym, &obj_id, rev_id).await?; let latest_snapshot = apply_patches(&base_snapshot, &previous_patches)?; let mut patches = Vec::::new(); + let mut final_snapshot = latest_snapshot.clone(); for op in operations { - let patch = save_operation( + let saved = save_operation( state, gym, obj_id.clone(), @@ -418,61 +414,37 @@ pub async fn apply_object_updates( &latest_snapshot, &previous_patches, op, - !skip_validation, ) - .await; // TODO await all? does not matter that much probably? + .await; - match patch { - Ok(Some(val)) => patches.push(val), - Ok(None) => (), // TODO push nones? + match saved { Err(e) => return Err(e), + Ok(Some(saved)) => { + patches.push(saved.patch); + final_snapshot = saved.snapshot + } + Ok(None) => (), // skip } } - // TODO update boulder/account view here? to make queries possible? - // so in Avers we had generic Views that provided this interface - // - // viewObjectTransformer :: obj -> Avers (Maybe a) - // (here this would just be serde trying to parse the Json) - // - // and concrete types implemented this transform to store concrete queriable - // data in the database: - // - // FIXME why using validate here? validation and view update is the same? - // unless novalidate $ do - // FIXME this is the wrong snapshot - we dont return the one with the op applied - // update_boulder_view(state, gym, &latest_snapshot).await?; + update_view( + state, + gym, + &final_snapshot.object_id, + &final_snapshot.content, + ) + .await?; Ok(Json(PatchObjectResponse::new( previous_patches, patches.len(), patches, ))) +} - // FIXME async in closure - can we separate this out? we only need async for actually storing - // the patch and snapshot in the database? - // let patches = operations.iter().map(|&op| { - // save_operation( - // &state, - // &gym, - // obj_id.clone(), - // author.clone(), - // (base_snapshot.content).clone(), - // &latest_snapshot, - // previous_patches.clone(), - // op, - // !skip_validation, - // ) - // }); - // - // let concret_patches = patches.await?; - // let ps = concret_patches - // .filter_map(|p| match p { - // Ok(Some(val)) => Some(val), - // Ok(None) => None, - // Err(_e) => None, // Some(Err(e)), FIXME handle err? - // }) - // .collect::>(); +struct SaveOp { + patch: Patch, + snapshot: Snapshot, } /// try rebase and then apply the operation to get a new snapshot (or return the old) @@ -486,9 +458,8 @@ async fn save_operation( snapshot: &Snapshot, previous_patches: &[Patch], op: Operation, - validate: bool, -) -> Result, AppError> { - let new_op = match rebase( +) -> Result, AppError> { + let rebased_op = match rebase( base_content, op, previous_patches.iter().map(|p| &p.operation), @@ -504,16 +475,12 @@ async fn save_operation( } }; - // tracing::debug!("save_operation: {snapshot}, op={new_op}"); - // FIXME clone? - let new_content = new_op.apply_to(snapshot.content.to_owned())?; + // TODO clone? + let new_content = rebased_op.apply_to(snapshot.content.to_owned())?; if new_content == snapshot.content { tracing::debug!("skipping save operation: content did not change"); return Ok(None); } - if validate { - // TODO: validateWithType psObjectType newContent - } let rev_id = snapshot.revision_id + 1; // now we know that the patch can be applied cleanly, so we can store both @@ -523,21 +490,20 @@ async fn save_operation( content: new_content, }; let s: Option = store!(state, gym, &new_snapshot, SNAPSHOTS_COLLECTION); - s.ok_or(AppError::Query("storing snapshot failed".to_string()))?; - - // FIXME moved to here but we should probably only do that for the final snapshot? - update_view(state, gym, &new_snapshot.object_id, &new_snapshot.content).await?; + let s = s.ok_or(AppError::Query("storing snapshot failed".to_string()))?; let patch = Patch { object_id, revision_id: rev_id, author_id, created_at: None, - operation: new_op.to_owned(), + operation: rebased_op.to_owned(), }; let p: Option = store!(state, gym, &patch, PATCHES_COLLECTION); - p.ok_or(AppError::Query("storing patch failed".to_string()))?; + let p = p.ok_or(AppError::Query("storing patch failed".to_string()))?; - // TODO maybe await here? or return futures? - Ok(Some(patch)) + Ok(Some(SaveOp { + patch: p, + snapshot: s, + })) } From 1d180b214e64073e229512b2b04fed51cf19e6c4 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Tue, 23 Dec 2025 19:43:32 +0100 Subject: [PATCH 02/29] helpers on snapshot and patch to create new revision --- bin/all-o-stasis/src/storage.rs | 51 ++++++++++++--------------------- bin/all-o-stasis/src/types.rs | 43 ++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 34 deletions(-) diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index 6fa7140..184730c 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -447,7 +447,9 @@ struct SaveOp { snapshot: Snapshot, } -/// try rebase and then apply the operation to get a new snapshot (or return the old) +/// Rebase and then apply the operation to the snapshot to get a new snapshot +/// Returns `None` if the rebasing fails or applying the (rebased) operation yields the same +/// snapshot. #[allow(clippy::too_many_arguments)] async fn save_operation( state: &AppState, @@ -464,9 +466,10 @@ async fn save_operation( op, previous_patches.iter().map(|p| &p.operation), ) { - Ok(Some(new_op)) => new_op, + Ok(Some(rebased_op)) => rebased_op, Ok(None) => { - tracing::warn!("rebase had a conflicting patch"); + // TODO better error, log op, base_content + tracing::warn!("rebase failed due to a conflict"); return Ok(None); } Err(e) => { @@ -475,35 +478,17 @@ async fn save_operation( } }; - // TODO clone? - let new_content = rebased_op.apply_to(snapshot.content.to_owned())?; - if new_content == snapshot.content { - tracing::debug!("skipping save operation: content did not change"); - return Ok(None); + match snapshot.new_revision(object_id, author_id, rebased_op)? { + None => Ok(None), + Some((new_snapshot, patch)) => { + let s: Option = store!(state, gym, &new_snapshot, SNAPSHOTS_COLLECTION); + let s = s.ok_or(AppError::Query("storing snapshot failed".to_string()))?; + let p: Option = store!(state, gym, &patch, PATCHES_COLLECTION); + let p = p.ok_or(AppError::Query("storing patch failed".to_string()))?; + Ok(Some(SaveOp { + patch: p, + snapshot: s, + })) + } } - - let rev_id = snapshot.revision_id + 1; - // now we know that the patch can be applied cleanly, so we can store both - let new_snapshot = Snapshot { - object_id: snapshot.object_id.to_owned(), - revision_id: rev_id, - content: new_content, - }; - let s: Option = store!(state, gym, &new_snapshot, SNAPSHOTS_COLLECTION); - let s = s.ok_or(AppError::Query("storing snapshot failed".to_string()))?; - - let patch = Patch { - object_id, - revision_id: rev_id, - author_id, - created_at: None, - operation: rebased_op.to_owned(), - }; - let p: Option = store!(state, gym, &patch, PATCHES_COLLECTION); - let p = p.ok_or(AppError::Query("storing patch failed".to_string()))?; - - Ok(Some(SaveOp { - patch: p, - snapshot: s, - })) } diff --git a/bin/all-o-stasis/src/types.rs b/bin/all-o-stasis/src/types.rs index 2653eb3..a626e0b 100644 --- a/bin/all-o-stasis/src/types.rs +++ b/bin/all-o-stasis/src/types.rs @@ -1,7 +1,7 @@ use std::fmt; use chrono::{DateTime, Utc}; -use otp::{ObjectId, Operation, RevId}; +use otp::{ObjectId, Operation, OtError, RevId}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -180,6 +180,21 @@ impl Patch { operation: op, } } + + pub fn new_revision( + revision_id: RevId, + object_id: ObjectId, + author_id: String, + operation: Operation, + ) -> Self { + Self { + object_id, + revision_id, + author_id, + created_at: None, + operation, + } + } } #[derive(Serialize, Deserialize, Clone)] @@ -199,6 +214,32 @@ impl Snapshot { content: json!({}), } } + + pub fn new_revision( + &self, + object_id: ObjectId, + author_id: ObjectId, + operation: Operation, + ) -> Result, OtError> { + assert_eq!(object_id, self.object_id); + + let content = operation.apply_to(self.content.to_owned())?; + if content == self.content { + tracing::debug!("skipping save operation: content did not change"); + return Ok(None); + } + + let revision_id = self.revision_id + 1; + let patch = Patch::new_revision(revision_id, object_id.clone(), author_id, operation); + Ok(Some(( + Self { + object_id, + revision_id, + content, + }, + patch, + ))) + } } impl fmt::Display for Snapshot { From 2b221f3c3d2494e2c9088642d8a257003e46ceac Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Tue, 23 Dec 2025 20:35:20 +0100 Subject: [PATCH 03/29] move more into snapshot type --- bin/all-o-stasis/src/storage.rs | 30 ++++-------------------------- bin/all-o-stasis/src/types.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index 184730c..5cb5633 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -251,7 +251,7 @@ pub(crate) async fn lookup_latest_snapshot( let patches = patches_after_revision(state, gym, obj_id, latest_snapshot.revision_id).await?; // apply those patches to the snapshot - apply_patches(&latest_snapshot, &patches) + latest_snapshot.apply_patches(&patches) } /// get or create a snapshot between low and high (inclusive) @@ -322,7 +322,7 @@ async fn lookup_snapshot( .collect(); // apply those patches to the snapshot - apply_patches(&latest_snapshot, &patches) + latest_snapshot.apply_patches(&patches) } async fn patches_after_revision( @@ -361,33 +361,11 @@ async fn patches_after_revision( Ok(patches) } -fn apply_patch_to_snapshot(snapshot: &Snapshot, patch: &Patch) -> Result { - let s = Snapshot { - object_id: snapshot.object_id.to_owned(), - revision_id: patch.revision_id, - content: patch.operation.apply_to(snapshot.content.clone())?, - }; - tracing::debug!("applying patch={patch} to {snapshot} results in snapshot={s}"); - Ok(s) -} - -fn apply_patches(snapshot: &Snapshot, patches: &Vec) -> Result { - let mut s = snapshot.clone(); - for patch in patches { - s = apply_patch_to_snapshot(&s, patch)?; - } - // Ok(patches.iter().fold(snapshot.clone(), |snapshot, patch| { - // apply_patch_to_snapshot(&snapshot, &patch)? - // })) - - Ok(s) -} - pub async fn apply_object_updates( state: &AppState, gym: &String, obj_id: ObjectId, - rev_id: RevId, // TODO this is what? first is 0? + rev_id: RevId, author: ObjectId, operations: Vec, ) -> Result, AppError> { @@ -400,7 +378,7 @@ pub async fn apply_object_updates( // if there are any patches which the client doesn't know about we need // to let her know let previous_patches = patches_after_revision(state, gym, &obj_id, rev_id).await?; - let latest_snapshot = apply_patches(&base_snapshot, &previous_patches)?; + let latest_snapshot = base_snapshot.apply_patches(&previous_patches)?; let mut patches = Vec::::new(); let mut final_snapshot = latest_snapshot.clone(); diff --git a/bin/all-o-stasis/src/types.rs b/bin/all-o-stasis/src/types.rs index a626e0b..2df7393 100644 --- a/bin/all-o-stasis/src/types.rs +++ b/bin/all-o-stasis/src/types.rs @@ -5,6 +5,8 @@ use otp::{ObjectId, Operation, OtError, RevId}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use crate::AppError; + // TODO implement Arbitrary for types #[derive(Serialize, Deserialize, Clone, PartialEq)] @@ -240,6 +242,24 @@ impl Snapshot { patch, ))) } + + fn apply_patch(&self, patch: &Patch) -> Result { + // tracing::debug!("applying patch={patch} to {snapshot} results in snapshot={s}"); + Ok(Self { + object_id: self.object_id.to_owned(), + revision_id: patch.revision_id, + content: patch.operation.apply_to(self.content.clone())?, + }) + } + + pub fn apply_patches(&self, patches: &Vec) -> Result { + let mut s = self.clone(); + for patch in patches { + s = s.apply_patch(patch)?; + } + + Ok(s) + } } impl fmt::Display for Snapshot { From 3bd2e1d9ba7c52c00d1359372df292ca13188634 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Wed, 24 Dec 2025 11:17:37 +0100 Subject: [PATCH 04/29] store on snapshot and patch --- bin/all-o-stasis/src/routes/api.rs | 6 +-- bin/all-o-stasis/src/storage.rs | 31 +++++---------- bin/all-o-stasis/src/types.rs | 64 +++++++++++++++++++++++++++++- bin/all-o-stasis/src/ws.rs | 5 +-- 4 files changed, 78 insertions(+), 28 deletions(-) diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index 7f4ad11..4df7655 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -3,8 +3,8 @@ use std::net::SocketAddr; use crate::passport::Session; use crate::session::{account_role, author_from_session}; use crate::storage::{ - BOULDERS_VIEW_COLLECTION, OBJECTS_COLLECTION, PATCHES_COLLECTION, SESSIONS_COLLECTION, - apply_object_updates, create_object, lookup_object_, + BOULDERS_VIEW_COLLECTION, OBJECTS_COLLECTION, SESSIONS_COLLECTION, apply_object_updates, + create_object, lookup_object_, }; use crate::types::{AccountRole, Boulder, Object, ObjectDoc, ObjectType, Patch}; use crate::ws::handle_socket; @@ -369,7 +369,7 @@ async fn lookup_patch( .db .fluent() .select() - .from(PATCHES_COLLECTION) + .from(Patch::COLLECTION) .parent(&parent_path) .filter(|q| { q.for_all([ diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index 5cb5633..0d88b74 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -14,9 +14,7 @@ use serde_json::{Value, from_value}; pub const ACCOUNTS_VIEW_COLLECTION: &str = "accounts_view"; pub const BOULDERS_VIEW_COLLECTION: &str = "boulders_view"; pub const OBJECTS_COLLECTION: &str = "objects"; -pub const PATCHES_COLLECTION: &str = "patches"; pub const SESSIONS_COLLECTION: &str = "sessions"; -pub const SNAPSHOTS_COLLECTION: &str = "snapshots"; macro_rules! store { ($state:expr, $gym:expr, $entity:expr, $collection:expr) => {{ @@ -89,12 +87,9 @@ pub(crate) async fn create_object( .try_into() .map_err(|e| AppError::Query(format!("create_object: {e}")))?; - let patch = Patch::new(obj.id.clone(), author_id, value); - let patch: Option = store!(state, gym, &patch, PATCHES_COLLECTION); - let _ = patch.ok_or(AppError::Query( - "create_object: failed to store patch".to_string(), - ))?; - + let _ = Patch::new(obj.id.clone(), author_id, value) + .store(state, gym) + .await?; update_view(state, gym, &obj.id, value).await?; Ok(obj) @@ -213,7 +208,7 @@ pub(crate) async fn lookup_latest_snapshot( .db .fluent() .select() - .from(SNAPSHOTS_COLLECTION) + .from(Snapshot::COLLECTION) .parent(&parent_path) .filter(|q| { q.for_all([ @@ -240,9 +235,7 @@ pub(crate) async fn lookup_latest_snapshot( None => { tracing::debug!("no snapshot found"); // XXX we could already create the first snapshot on object creation? - let snapshot = Snapshot::new(obj_id.clone()); - let _: Option = store!(state, gym, &snapshot, SNAPSHOTS_COLLECTION); - snapshot + Snapshot::new(obj_id.clone()).store(state, gym).await? } }; @@ -267,7 +260,7 @@ async fn lookup_snapshot_between( .db .fluent() .select() - .from(SNAPSHOTS_COLLECTION) + .from(Snapshot::COLLECTION) .parent(&parent_path) .filter(|q| { q.for_all([ @@ -297,9 +290,7 @@ async fn lookup_snapshot_between( None => { // TODO we could already create the first snapshot on object creation? // TODO why is initial snapshot rev = -1? - let snapshot = Snapshot::new(obj_id.clone()); - let _: Option = store!(state, gym, &snapshot, SNAPSHOTS_COLLECTION); - Ok(snapshot) + Ok(Snapshot::new(obj_id.clone()).store(state, gym).await?) } } } @@ -336,7 +327,7 @@ async fn patches_after_revision( .db .fluent() .select() - .from(PATCHES_COLLECTION) + .from(Patch::COLLECTION) .parent(&parent_path) .filter(|q| { q.for_all([ @@ -459,10 +450,8 @@ async fn save_operation( match snapshot.new_revision(object_id, author_id, rebased_op)? { None => Ok(None), Some((new_snapshot, patch)) => { - let s: Option = store!(state, gym, &new_snapshot, SNAPSHOTS_COLLECTION); - let s = s.ok_or(AppError::Query("storing snapshot failed".to_string()))?; - let p: Option = store!(state, gym, &patch, PATCHES_COLLECTION); - let p = p.ok_or(AppError::Query("storing patch failed".to_string()))?; + let s = new_snapshot.store(state, gym).await?; + let p = patch.store(state, gym).await?; Ok(Some(SaveOp { patch: p, snapshot: s, diff --git a/bin/all-o-stasis/src/types.rs b/bin/all-o-stasis/src/types.rs index 2df7393..c8e01a0 100644 --- a/bin/all-o-stasis/src/types.rs +++ b/bin/all-o-stasis/src/types.rs @@ -5,7 +5,30 @@ use otp::{ObjectId, Operation, OtError, RevId}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use crate::AppError; +use crate::{AppError, AppState}; + +macro_rules! store { + ($state:expr, $gym:expr, $entity:expr, $collection:expr) => {{ + let parent_path = $state.db.parent_path("gyms", $gym)?; + let result = $state + .db + .fluent() + .insert() + .into($collection) + .generate_document_id() + .parent(&parent_path) + .object($entity) + .execute() + .await?; + + match &result { + Some(r) => tracing::debug!("storing: {r}"), + None => tracing::warn!("failed to store: {}", $entity), + } + + result + }}; +} // TODO implement Arbitrary for types @@ -172,6 +195,8 @@ impl fmt::Display for Patch { } impl Patch { + pub const COLLECTION: &str = "patches"; + pub fn new(object_id: ObjectId, author_id: String, value: &Value) -> Self { let op = Operation::new_set(otp::ROOT_PATH.to_owned(), value.to_owned()); Self { @@ -197,6 +222,11 @@ impl Patch { operation, } } + + pub async fn store(&self, state: &AppState, gym: &String) -> Result { + let s: Option = store!(state, gym, self, Self::COLLECTION); + s.ok_or(AppError::Query("storing patch failed".to_string())) + } } #[derive(Serialize, Deserialize, Clone)] @@ -208,6 +238,8 @@ pub struct Snapshot { } impl Snapshot { + pub const COLLECTION: &str = "snapshots"; + pub fn new(object_id: ObjectId) -> Self { Self { object_id, @@ -260,6 +292,36 @@ impl Snapshot { Ok(s) } + + pub async fn store(&self, state: &AppState, gym: &String) -> Result { + let s: Option = store!(state, gym, self, Self::COLLECTION); + s.ok_or(AppError::Query("storing snapshot failed".to_string())) + + // let parent_path = state.db.parent_path("gyms", gym)?; + // let result: Option = state + // .db + // .fluent() + // .insert() + // .into(Self::COLLECTION) + // .generate_document_id() + // .parent(&parent_path) + // .object(self) + // .execute() + // .await?; + // + // // TODO logging? + // result.ok_or(AppError::Query("storing snapshot failed".to_string())) + // match &result { + // Some(r) => { + // tracing::debug!("storing: {r}"); + // Ok(r) + // }, + // None => { + // tracing::warn!("failed to store: {}", self); + // Err(AppError + // }, + // } + } } impl fmt::Display for Snapshot { diff --git a/bin/all-o-stasis/src/ws.rs b/bin/all-o-stasis/src/ws.rs index 2d6e436..20ae052 100644 --- a/bin/all-o-stasis/src/ws.rs +++ b/bin/all-o-stasis/src/ws.rs @@ -17,7 +17,6 @@ use std::net::SocketAddr; use std::sync::Arc; use tokio::sync::{Mutex, mpsc, mpsc::Receiver, mpsc::Sender}; -use crate::storage::PATCHES_COLLECTION; use crate::{AppError, AppState}; #[derive(Serialize)] @@ -69,7 +68,7 @@ async fn patch_listener( .db .fluent() .select() - .from(PATCHES_COLLECTION) + .from(Patch::COLLECTION) .parent(parent_path) .listen() .add_target(listener_id, &mut listener); @@ -175,7 +174,7 @@ async fn drain_channel( } Message::Ping(bytes) => Message::Ping(bytes), t => { - tracing::error!("received unexpected message from on ws_send: {t:?}"); + tracing::error!("received unexpected message on ws_send: {t:?}"); continue; } }; From c4d9596c4c168a45e4510a97b105d280022623c9 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Wed, 24 Dec 2025 11:24:58 +0100 Subject: [PATCH 05/29] more store on types --- bin/all-o-stasis/src/passport.rs | 10 +++++--- bin/all-o-stasis/src/routes/api.rs | 9 ++++--- bin/all-o-stasis/src/session.rs | 4 ++-- bin/all-o-stasis/src/storage.rs | 38 ++++-------------------------- bin/all-o-stasis/src/types.rs | 7 ++++++ 5 files changed, 24 insertions(+), 44 deletions(-) diff --git a/bin/all-o-stasis/src/passport.rs b/bin/all-o-stasis/src/passport.rs index bf66c48..fa6a616 100644 --- a/bin/all-o-stasis/src/passport.rs +++ b/bin/all-o-stasis/src/passport.rs @@ -18,8 +18,8 @@ use serde::{Deserialize, Serialize}; use crate::{ AppError, AppState, storage::{ - ACCOUNTS_VIEW_COLLECTION, SESSIONS_COLLECTION, apply_object_updates, create_object, - lookup_latest_snapshot, save_session, + ACCOUNTS_VIEW_COLLECTION, apply_object_updates, create_object, lookup_latest_snapshot, + save_session, }, types::{Account, AccountRole, ObjectType}, word_list::make_security_code, @@ -143,6 +143,10 @@ impl fmt::Display for Session { } } +impl Session { + pub const COLLECTION: &str = "sessions"; +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Passport { @@ -434,7 +438,7 @@ async fn await_passport_confirmation( .db .fluent() .select() - .from(SESSIONS_COLLECTION) + .from(Session::COLLECTION) .parent(&parent_path) .filter(|q| { q.for_all([q diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index 4df7655..9419174 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -3,8 +3,7 @@ use std::net::SocketAddr; use crate::passport::Session; use crate::session::{account_role, author_from_session}; use crate::storage::{ - BOULDERS_VIEW_COLLECTION, OBJECTS_COLLECTION, SESSIONS_COLLECTION, apply_object_updates, - create_object, lookup_object_, + BOULDERS_VIEW_COLLECTION, apply_object_updates, create_object, lookup_object_, }; use crate::types::{AccountRole, Boulder, Object, ObjectDoc, ObjectType, Patch}; use crate::ws::handle_socket; @@ -103,7 +102,7 @@ async fn object_type( .db .fluent() .select() - .by_id_in(OBJECTS_COLLECTION) + .by_id_in(ObjectDoc::COLLECTION) .parent(&parent_path) .obj() .one(&object_id) @@ -171,7 +170,7 @@ async fn delete_session( .db .fluent() .delete() - .from(SESSIONS_COLLECTION) + .from(Session::COLLECTION) .parent(&parent_path) .document_id(&session_id) .execute() @@ -202,7 +201,7 @@ async fn lookup_session( .db .fluent() .select() - .by_id_in(SESSIONS_COLLECTION) + .by_id_in(Session::COLLECTION) .parent(&parent_path) .obj() .one(&session_id) diff --git a/bin/all-o-stasis/src/session.rs b/bin/all-o-stasis/src/session.rs index 28bfc9f..6a08e9a 100644 --- a/bin/all-o-stasis/src/session.rs +++ b/bin/all-o-stasis/src/session.rs @@ -1,5 +1,5 @@ use crate::passport::Session; -use crate::storage::{ACCOUNTS_VIEW_COLLECTION, SESSIONS_COLLECTION}; +use crate::storage::ACCOUNTS_VIEW_COLLECTION; use crate::types::{Account, AccountRole}; use crate::{AppError, AppState}; use axum_extra::extract::cookie::Cookie; @@ -54,7 +54,7 @@ pub(crate) async fn author_from_session( .db .fluent() .select() - .by_id_in(SESSIONS_COLLECTION) + .by_id_in(Session::COLLECTION) .parent(&parent_path) .obj() .one(&session_id) diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index 0d88b74..056b8ff 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -13,31 +13,6 @@ use serde_json::{Value, from_value}; pub const ACCOUNTS_VIEW_COLLECTION: &str = "accounts_view"; pub const BOULDERS_VIEW_COLLECTION: &str = "boulders_view"; -pub const OBJECTS_COLLECTION: &str = "objects"; -pub const SESSIONS_COLLECTION: &str = "sessions"; - -macro_rules! store { - ($state:expr, $gym:expr, $entity:expr, $collection:expr) => {{ - let parent_path = $state.db.parent_path("gyms", $gym)?; - let result = $state - .db - .fluent() - .insert() - .into($collection) - .generate_document_id() - .parent(&parent_path) - .object($entity) - .execute() - .await?; - - match &result { - Some(r) => tracing::debug!("storing: {r}"), - None => tracing::warn!("failed to store: {}", $entity), - } - - result - }}; -} // TODO only diff here is that we provide an id and update pub(crate) async fn save_session( @@ -51,7 +26,7 @@ pub(crate) async fn save_session( .db .fluent() .update() - .in_col(SESSIONS_COLLECTION) + .in_col(Session::COLLECTION) .document_id(session_id) .parent(&parent_path) .object(session) @@ -77,12 +52,7 @@ pub(crate) async fn create_object( object_type: ObjectType, value: &Value, ) -> Result { - let obj_doc = ObjectDoc::new(object_type); - let obj_doc: Option = store!(state, gym, &obj_doc, OBJECTS_COLLECTION); - let obj_doc = obj_doc.ok_or(AppError::Query( - "create_object: failed to create object".to_string(), - ))?; - + let obj_doc = ObjectDoc::new(object_type).store(state, gym).await?; let obj: Object = obj_doc .try_into() .map_err(|e| AppError::Query(format!("create_object: {e}")))?; @@ -108,7 +78,7 @@ pub(crate) async fn update_view( .db .fluent() .select() - .by_id_in(OBJECTS_COLLECTION) + .by_id_in(ObjectDoc::COLLECTION) .parent(&parent_path) .obj() .one(&object_id) @@ -171,7 +141,7 @@ pub(crate) async fn lookup_object_( .db .fluent() .select() - .by_id_in(OBJECTS_COLLECTION) + .by_id_in(ObjectDoc::COLLECTION) .parent(&parent_path) .obj() .one(&id) diff --git a/bin/all-o-stasis/src/types.rs b/bin/all-o-stasis/src/types.rs index c8e01a0..61ff29f 100644 --- a/bin/all-o-stasis/src/types.rs +++ b/bin/all-o-stasis/src/types.rs @@ -105,6 +105,8 @@ pub struct ObjectDoc { } impl ObjectDoc { + pub const COLLECTION: &str = "objects"; + pub fn new(object_type: ObjectType) -> Self { Self { id: None, @@ -114,6 +116,11 @@ impl ObjectDoc { deleted: None, } } + + pub async fn store(&self, state: &AppState, gym: &String) -> Result { + let s: Option = store!(state, gym, self, Self::COLLECTION); + s.ok_or(AppError::Query("storing object failed".to_string())) + } } impl fmt::Display for ObjectDoc { From d52fb8b90b7cc31548596211d8bcaf65f4851aad Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Wed, 24 Dec 2025 11:30:11 +0100 Subject: [PATCH 06/29] views as types --- bin/all-o-stasis/src/passport.rs | 10 ++-------- bin/all-o-stasis/src/routes/api.rs | 8 +++----- bin/all-o-stasis/src/routes/collection.rs | 14 +++++++------- bin/all-o-stasis/src/routes/stats.rs | 5 ++--- bin/all-o-stasis/src/session.rs | 5 ++--- bin/all-o-stasis/src/storage.rs | 14 +++++++------- bin/all-o-stasis/src/types.rs | 12 ++++++++++++ 7 files changed, 35 insertions(+), 33 deletions(-) diff --git a/bin/all-o-stasis/src/passport.rs b/bin/all-o-stasis/src/passport.rs index fa6a616..19ad343 100644 --- a/bin/all-o-stasis/src/passport.rs +++ b/bin/all-o-stasis/src/passport.rs @@ -16,13 +16,7 @@ use rand::Rng; use serde::{Deserialize, Serialize}; use crate::{ - AppError, AppState, - storage::{ - ACCOUNTS_VIEW_COLLECTION, apply_object_updates, create_object, lookup_latest_snapshot, - save_session, - }, - types::{Account, AccountRole, ObjectType}, - word_list::make_security_code, + storage::{apply_object_updates, create_object, lookup_latest_snapshot, save_session}, types::{Account, AccountRole, AccountsView, ObjectType}, word_list::make_security_code, AppError, AppState }; mod maileroo { @@ -252,7 +246,7 @@ async fn create_passport( .db .fluent() .select() - .from(ACCOUNTS_VIEW_COLLECTION) + .from(AccountsView::COLLECTION) .parent(&parent_path) .filter(|q| { q.for_all([q diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index 9419174..17aa711 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -2,10 +2,8 @@ use std::net::SocketAddr; use crate::passport::Session; use crate::session::{account_role, author_from_session}; -use crate::storage::{ - BOULDERS_VIEW_COLLECTION, apply_object_updates, create_object, lookup_object_, -}; -use crate::types::{AccountRole, Boulder, Object, ObjectDoc, ObjectType, Patch}; +use crate::storage::{apply_object_updates, create_object, lookup_object_}; +use crate::types::{AccountRole, Boulder, BouldersView, Object, ObjectDoc, ObjectType, Patch}; use crate::ws::handle_socket; use crate::{AppError, AppState}; use axum::{ @@ -128,7 +126,7 @@ async fn lookup_boulder( .db .fluent() .select() - .by_id_in(BOULDERS_VIEW_COLLECTION) + .by_id_in(BouldersView::COLLECTION) .parent(&parent_path) .obj() .one(&object_id) diff --git a/bin/all-o-stasis/src/routes/collection.rs b/bin/all-o-stasis/src/routes/collection.rs index 3efd39b..e69727c 100644 --- a/bin/all-o-stasis/src/routes/collection.rs +++ b/bin/all-o-stasis/src/routes/collection.rs @@ -13,8 +13,8 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::session::author_from_session; -use crate::storage::{ACCOUNTS_VIEW_COLLECTION, BOULDERS_VIEW_COLLECTION, lookup_latest_snapshot}; -use crate::types::{Account, AccountRole, Boulder}; +use crate::storage::lookup_latest_snapshot; +use crate::types::{Account, AccountRole, AccountsView, Boulder, BouldersView}; use crate::{AppError, AppState}; #[derive(Serialize, Deserialize, Debug)] @@ -76,7 +76,7 @@ async fn active_boulders( .db .fluent() .select() - .from(BOULDERS_VIEW_COLLECTION) + .from(BouldersView::COLLECTION) .parent(&parent_path) .filter(|q| { q.for_all([ @@ -113,7 +113,7 @@ async fn draft_boulders( .db .fluent() .select() - .from(BOULDERS_VIEW_COLLECTION) + .from(BouldersView::COLLECTION) .parent(&parent_path) .filter(|q| { q.for_all([ @@ -151,7 +151,7 @@ async fn own_boulders( .db .fluent() .select() - .from(BOULDERS_VIEW_COLLECTION) + .from(BouldersView::COLLECTION) .parent(&parent_path) .filter(|q| q.for_all([q.field(path_camel_case!(Boulder::id)).eq(own.to_owned())])) .obj() @@ -176,7 +176,7 @@ async fn accounts( .db .fluent() .select() - .from(ACCOUNTS_VIEW_COLLECTION) + .from(AccountsView::COLLECTION) .parent(&parent_path) .obj() .stream_query_with_errors() @@ -200,7 +200,7 @@ async fn admin_accounts( .db .fluent() .select() - .from(ACCOUNTS_VIEW_COLLECTION) + .from(AccountsView::COLLECTION) .parent(&parent_path) .filter(|q| { q.for_all([q diff --git a/bin/all-o-stasis/src/routes/stats.rs b/bin/all-o-stasis/src/routes/stats.rs index e0e5a3c..b46da52 100644 --- a/bin/all-o-stasis/src/routes/stats.rs +++ b/bin/all-o-stasis/src/routes/stats.rs @@ -8,8 +8,7 @@ use futures::TryStreamExt; use futures::stream::BoxStream; use serde::{Deserialize, Serialize}; -use crate::storage::BOULDERS_VIEW_COLLECTION; -use crate::types::Boulder; +use crate::types::{Boulder, BouldersView}; use crate::{AppError, AppState}; #[derive(Serialize, Deserialize, Debug)] @@ -41,7 +40,7 @@ async fn stats_boulders( .db .fluent() .select() - .from(BOULDERS_VIEW_COLLECTION) + .from(BouldersView::COLLECTION) .parent(&parent_path) .filter(|q| q.for_all([q.field(path_camel_case!(Boulder::is_draft)).eq(0)])) .obj() diff --git a/bin/all-o-stasis/src/session.rs b/bin/all-o-stasis/src/session.rs index 6a08e9a..423fe99 100644 --- a/bin/all-o-stasis/src/session.rs +++ b/bin/all-o-stasis/src/session.rs @@ -1,6 +1,5 @@ use crate::passport::Session; -use crate::storage::ACCOUNTS_VIEW_COLLECTION; -use crate::types::{Account, AccountRole}; +use crate::types::{Account, AccountRole, AccountsView}; use crate::{AppError, AppState}; use axum_extra::extract::cookie::Cookie; use otp::ObjectId; @@ -77,7 +76,7 @@ pub(crate) async fn account_role( .db .fluent() .select() - .by_id_in(ACCOUNTS_VIEW_COLLECTION) + .by_id_in(AccountsView::COLLECTION) .parent(&parent_path) .obj() .one(object_id) diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index 056b8ff..1793923 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -1,8 +1,11 @@ use crate::{ + AppError, AppState, passport::Session, routes::{LookupObjectResponse, PatchObjectResponse}, - types::{Account, Boulder, Object, ObjectDoc, ObjectType, Patch, Snapshot}, - {AppError, AppState}, + types::{ + Account, AccountsView, Boulder, BouldersView, Object, ObjectDoc, ObjectType, Patch, + Snapshot, + }, }; use axum::Json; use firestore::{FirestoreQueryDirection, FirestoreResult, path_camel_case}; @@ -11,9 +14,6 @@ use futures::stream::BoxStream; use otp::{ObjectId, Operation, RevId, ZERO_REV_ID, rebase}; use serde_json::{Value, from_value}; -pub const ACCOUNTS_VIEW_COLLECTION: &str = "accounts_view"; -pub const BOULDERS_VIEW_COLLECTION: &str = "boulders_view"; - // TODO only diff here is that we provide an id and update pub(crate) async fn save_session( state: &AppState, @@ -100,7 +100,7 @@ pub(crate) async fn update_view( .db .fluent() .update() - .in_col(ACCOUNTS_VIEW_COLLECTION) + .in_col(AccountsView::COLLECTION) .document_id(object_id.clone()) .parent(&parent_path) .object(&account) @@ -115,7 +115,7 @@ pub(crate) async fn update_view( .db .fluent() .update() - .in_col(BOULDERS_VIEW_COLLECTION) + .in_col(BouldersView::COLLECTION) .document_id(object_id.clone()) .parent(&parent_path) .object(&boulder) diff --git a/bin/all-o-stasis/src/types.rs b/bin/all-o-stasis/src/types.rs index 61ff29f..1d01b9b 100644 --- a/bin/all-o-stasis/src/types.rs +++ b/bin/all-o-stasis/src/types.rs @@ -340,3 +340,15 @@ impl fmt::Display for Snapshot { ) } } + +pub struct AccountsView {} + +impl AccountsView { + pub const COLLECTION: &str = "accounts_view"; +} + +pub struct BouldersView {} + +impl BouldersView { + pub const COLLECTION: &str = "boulders_view"; +} From cac53e1d2a1789f3c0e2d13f8ae03d6504bc042f Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Wed, 24 Dec 2025 11:44:10 +0100 Subject: [PATCH 07/29] store session --- bin/all-o-stasis/src/passport.rs | 53 ++++++++++++++++++++++++-------- bin/all-o-stasis/src/storage.rs | 32 ------------------- 2 files changed, 41 insertions(+), 44 deletions(-) diff --git a/bin/all-o-stasis/src/passport.rs b/bin/all-o-stasis/src/passport.rs index 19ad343..2bbe7a2 100644 --- a/bin/all-o-stasis/src/passport.rs +++ b/bin/all-o-stasis/src/passport.rs @@ -16,7 +16,10 @@ use rand::Rng; use serde::{Deserialize, Serialize}; use crate::{ - storage::{apply_object_updates, create_object, lookup_latest_snapshot, save_session}, types::{Account, AccountRole, AccountsView, ObjectType}, word_list::make_security_code, AppError, AppState + AppError, AppState, + storage::{apply_object_updates, create_object, lookup_latest_snapshot}, + types::{Account, AccountRole, AccountsView, ObjectType}, + word_list::make_security_code, }; mod maileroo { @@ -139,6 +142,36 @@ impl fmt::Display for Session { impl Session { pub const COLLECTION: &str = "sessions"; + + pub async fn store( + &self, + state: &AppState, + gym: &String, + session_id: &str, + ) -> Result { + let parent_path = state.db.parent_path("gyms", gym)?; + let p: Option = state + .db + .fluent() + .update() + .in_col(Session::COLLECTION) + .document_id(session_id) + .parent(&parent_path) + .object(self) + .execute() + .await?; + + match p { + Some(p) => { + tracing::debug!("storing session: {p}"); + Ok(p) + } + None => { + tracing::warn!("failed to update session: {self} (no such object exists"); + Err(AppError::NoSession()) + } + } + } } #[derive(Serialize, Deserialize)] @@ -334,17 +367,13 @@ async fn confirm_passport( Err(AppError::NotAuthorized()) } else { // create a new session for the account in the Passport object - let session = save_session( - &state, - &gym, - &Session { - id: None, - obj_id: passport.account_id, - created_at: None, - last_accessed_at: chrono::offset::Utc::now(), - }, - &new_id(80), - ) + let session = Session { + id: None, + obj_id: passport.account_id, + created_at: None, + last_accessed_at: chrono::offset::Utc::now(), + } + .store(&state, &gym, &new_id(80)) .await?; // mark as valid diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index 1793923..f6bf8bd 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -1,6 +1,5 @@ use crate::{ AppError, AppState, - passport::Session, routes::{LookupObjectResponse, PatchObjectResponse}, types::{ Account, AccountsView, Boulder, BouldersView, Object, ObjectDoc, ObjectType, Patch, @@ -14,37 +13,6 @@ use futures::stream::BoxStream; use otp::{ObjectId, Operation, RevId, ZERO_REV_ID, rebase}; use serde_json::{Value, from_value}; -// TODO only diff here is that we provide an id and update -pub(crate) async fn save_session( - state: &AppState, - gym: &String, - session: &Session, - session_id: &str, -) -> Result { - let parent_path = state.db.parent_path("gyms", gym)?; - let p: Option = state - .db - .fluent() - .update() - .in_col(Session::COLLECTION) - .document_id(session_id) - .parent(&parent_path) - .object(session) - .execute() - .await?; - - match p { - Some(p) => { - tracing::debug!("storing session: {p}"); - Ok(p) - } - None => { - tracing::warn!("failed to update session: {session} (no such object exists"); - Err(AppError::NoSession()) - } - } -} - pub(crate) async fn create_object( state: &AppState, gym: &String, From e0308a333d6a8aea381e13bd2a74d3b9d68dc288 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Wed, 24 Dec 2025 11:53:42 +0100 Subject: [PATCH 08/29] move accounts view insert --- bin/all-o-stasis/src/storage.rs | 39 +++---------------------- bin/all-o-stasis/src/types.rs | 50 ++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 36 deletions(-) diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index f6bf8bd..2bcd08d 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -1,17 +1,14 @@ use crate::{ AppError, AppState, routes::{LookupObjectResponse, PatchObjectResponse}, - types::{ - Account, AccountsView, Boulder, BouldersView, Object, ObjectDoc, ObjectType, Patch, - Snapshot, - }, + types::{AccountsView, BouldersView, Object, ObjectDoc, ObjectType, Patch, Snapshot}, }; use axum::Json; use firestore::{FirestoreQueryDirection, FirestoreResult, path_camel_case}; use futures::TryStreamExt; use futures::stream::BoxStream; use otp::{ObjectId, Operation, RevId, ZERO_REV_ID, rebase}; -use serde_json::{Value, from_value}; +use serde_json::Value; pub(crate) async fn create_object( state: &AppState, @@ -60,36 +57,8 @@ pub(crate) async fn update_view( .map_err(|e| AppError::Query(format!("update_view: {e}")))?; match obj.object_type { - ObjectType::Account => { - let account = from_value::(content.clone()) - .map_err(|e| AppError::ParseError(format!("{e} in: {content}")))?; - - let _: Option = state - .db - .fluent() - .update() - .in_col(AccountsView::COLLECTION) - .document_id(object_id.clone()) - .parent(&parent_path) - .object(&account) - .execute() - .await?; - } - ObjectType::Boulder => { - let boulder = from_value::(content.clone()) - .map_err(|e| AppError::ParseError(format!("{e} in: {content}")))?; - - let _: Option = state - .db - .fluent() - .update() - .in_col(BouldersView::COLLECTION) - .document_id(object_id.clone()) - .parent(&parent_path) - .object(&boulder) - .execute() - .await?; - } + ObjectType::Account => AccountsView::store(state, gym, object_id, content).await?, + ObjectType::Boulder => BouldersView::store(state, gym, object_id, content).await?, ObjectType::Passport => { // no view table } diff --git a/bin/all-o-stasis/src/types.rs b/bin/all-o-stasis/src/types.rs index 1d01b9b..53241c3 100644 --- a/bin/all-o-stasis/src/types.rs +++ b/bin/all-o-stasis/src/types.rs @@ -3,7 +3,7 @@ use std::fmt; use chrono::{DateTime, Utc}; use otp::{ObjectId, Operation, OtError, RevId}; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::{Value, from_value, json}; use crate::{AppError, AppState}; @@ -345,10 +345,58 @@ pub struct AccountsView {} impl AccountsView { pub const COLLECTION: &str = "accounts_view"; + + pub async fn store( + state: &AppState, + gym: &String, + object_id: &ObjectId, + content: &Value, + ) -> Result<(), AppError> { + let parent_path = state.db.parent_path("gyms", gym)?; + let account = from_value::(content.clone()) + .map_err(|e| AppError::ParseError(format!("{e} in: {content}")))?; + + let _: Option = state + .db + .fluent() + .update() + .in_col(Self::COLLECTION) + .document_id(object_id.clone()) + .parent(parent_path) + .object(&account) + .execute() + .await?; + + Ok(()) + } } pub struct BouldersView {} impl BouldersView { pub const COLLECTION: &str = "boulders_view"; + + pub async fn store( + state: &AppState, + gym: &String, + object_id: &ObjectId, + content: &Value, + ) -> Result<(), AppError> { + let parent_path = state.db.parent_path("gyms", gym)?; + let boulder = from_value::(content.clone()) + .map_err(|e| AppError::ParseError(format!("{e} in: {content}")))?; + + let _: Option = state + .db + .fluent() + .update() + .in_col(Self::COLLECTION) + .document_id(object_id.clone()) + .parent(parent_path) + .object(&boulder) + .execute() + .await?; + + Ok(()) + } } From 4305961a5314b4b21f60a231a39205ef092ba21d Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Wed, 24 Dec 2025 13:21:06 +0100 Subject: [PATCH 09/29] use AppError when converting to Object --- bin/all-o-stasis/src/main.rs | 4 ++-- bin/all-o-stasis/src/routes/api.rs | 4 +--- bin/all-o-stasis/src/storage.rs | 12 +++--------- bin/all-o-stasis/src/types.rs | 10 +++++++--- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/bin/all-o-stasis/src/main.rs b/bin/all-o-stasis/src/main.rs index 44847a7..60ad771 100644 --- a/bin/all-o-stasis/src/main.rs +++ b/bin/all-o-stasis/src/main.rs @@ -31,13 +31,13 @@ struct AppState { // The kinds of errors we can hit in our application. #[derive(Debug)] -enum AppError { +pub enum AppError { // Ot operations fail Ot(OtError), // firestore db errors Firestore(FirestoreError), // query error - Query(String), + Query(String), // TODO split and more meaningful name // unable to parse json content into type ParseError(String), // No session found diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index 17aa711..7d18f7f 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -107,9 +107,7 @@ async fn object_type( .await?; if let Some(doc) = object_doc { - let object: Object = doc - .try_into() - .map_err(|e| AppError::Query(format!("lookup_object_type: {e}")))?; + let object: Object = doc.try_into()?; Ok(object.object_type) } else { Err(AppError::NotAuthorized()) diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index 2bcd08d..245dfc7 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -18,9 +18,7 @@ pub(crate) async fn create_object( value: &Value, ) -> Result { let obj_doc = ObjectDoc::new(object_type).store(state, gym).await?; - let obj: Object = obj_doc - .try_into() - .map_err(|e| AppError::Query(format!("create_object: {e}")))?; + let obj: Object = obj_doc.try_into()?; let _ = Patch::new(obj.id.clone(), author_id, value) .store(state, gym) @@ -52,9 +50,7 @@ pub(crate) async fn update_view( "update_view: failed to update view for {object_id}" )))?; - let obj: Object = obj - .try_into() - .map_err(|e| AppError::Query(format!("update_view: {e}")))?; + let obj: Object = obj.try_into()?; match obj.object_type { ObjectType::Account => AccountsView::store(state, gym, object_id, content).await?, @@ -87,9 +83,7 @@ pub(crate) async fn lookup_object_( "lookup_object: failed to get object {id}" )))?; - let obj: Object = obj - .try_into() - .map_err(|e| AppError::Query(format!("lookup_object: {e}")))?; + let obj: Object = obj.try_into()?; tracing::debug!("looking up last snapshot for obj={id}"); let snapshot = lookup_latest_snapshot(state, gym, &id.clone()).await?; diff --git a/bin/all-o-stasis/src/types.rs b/bin/all-o-stasis/src/types.rs index 53241c3..95856d7 100644 --- a/bin/all-o-stasis/src/types.rs +++ b/bin/all-o-stasis/src/types.rs @@ -142,12 +142,16 @@ pub struct Object { } impl TryFrom for Object { - type Error = &'static str; + type Error = AppError; fn try_from(doc: ObjectDoc) -> Result { Ok(Object { - id: doc.id.ok_or("Object missing id")?, - created_at: doc.created_at.ok_or("Object missing created_at")?, + id: doc + .id + .ok_or(AppError::Query("object doc is missing an id".to_string()))?, + created_at: doc.created_at.ok_or(AppError::Query( + "object doc is missing created_at".to_string(), + ))?, object_type: doc.object_type, created_by: doc.created_by, deleted: doc.deleted.unwrap_or(false), From ba12c5855e51ab517dbf0fbc56c8f112dcbbbd87 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Wed, 24 Dec 2025 13:33:08 +0100 Subject: [PATCH 10/29] construct Object directly --- bin/all-o-stasis/src/storage.rs | 17 ++++++++++++----- bin/all-o-stasis/src/types.rs | 26 ++++++++++++++++++++------ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index 245dfc7..e90199f 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -17,13 +17,11 @@ pub(crate) async fn create_object( object_type: ObjectType, value: &Value, ) -> Result { - let obj_doc = ObjectDoc::new(object_type).store(state, gym).await?; - let obj: Object = obj_doc.try_into()?; - + let obj = Object::new(state, gym, &object_type).await?; let _ = Patch::new(obj.id.clone(), author_id, value) .store(state, gym) .await?; - update_view(state, gym, &obj.id, value).await?; + update_view_typed(state, gym, &obj.id, &object_type, value).await?; Ok(obj) } @@ -51,8 +49,17 @@ pub(crate) async fn update_view( )))?; let obj: Object = obj.try_into()?; + update_view_typed(state, gym, object_id, &obj.object_type, content).await +} - match obj.object_type { +pub(crate) async fn update_view_typed( + state: &AppState, + gym: &String, + object_id: &ObjectId, + object_type: &ObjectType, + content: &Value, +) -> Result<(), AppError> { + match object_type { ObjectType::Account => AccountsView::store(state, gym, object_id, content).await?, ObjectType::Boulder => BouldersView::store(state, gym, object_id, content).await?, ObjectType::Passport => { diff --git a/bin/all-o-stasis/src/types.rs b/bin/all-o-stasis/src/types.rs index 95856d7..3d9f8bc 100644 --- a/bin/all-o-stasis/src/types.rs +++ b/bin/all-o-stasis/src/types.rs @@ -96,18 +96,18 @@ impl Boulder { #[serde(rename_all = "camelCase")] pub struct ObjectDoc { #[serde(alias = "_firestore_id")] - pub id: Option, + id: Option, #[serde(alias = "_firestore_created")] - pub created_at: Option>, - pub object_type: ObjectType, - pub created_by: ObjectId, - pub deleted: Option, + created_at: Option>, + object_type: ObjectType, + created_by: ObjectId, + deleted: Option, } impl ObjectDoc { pub const COLLECTION: &str = "objects"; - pub fn new(object_type: ObjectType) -> Self { + fn new(object_type: ObjectType) -> Self { Self { id: None, object_type, @@ -159,6 +159,20 @@ impl TryFrom for Object { } } +impl Object { + pub async fn new( + state: &AppState, + gym: &String, + object_type: &ObjectType, + ) -> Result { + let obj_doc = ObjectDoc::new(object_type.clone()) + .store(state, gym) + .await?; + let obj: Object = obj_doc.try_into()?; + Ok(obj) + } +} + impl fmt::Display for Object { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Object: {} {}", self.id, self.object_type) From d7f2cafb61ed203fabc9156344043e4c2dc4447d Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Thu, 25 Dec 2025 07:46:01 +0100 Subject: [PATCH 11/29] lookup on types --- bin/all-o-stasis/src/passport.rs | 8 +- bin/all-o-stasis/src/routes.rs | 2 +- bin/all-o-stasis/src/routes/api.rs | 55 +++--- bin/all-o-stasis/src/routes/collection.rs | 5 +- bin/all-o-stasis/src/storage.rs | 201 +------------------ bin/all-o-stasis/src/types.rs | 226 +++++++++++++++++++++- 6 files changed, 258 insertions(+), 239 deletions(-) diff --git a/bin/all-o-stasis/src/passport.rs b/bin/all-o-stasis/src/passport.rs index 2bbe7a2..a5729fe 100644 --- a/bin/all-o-stasis/src/passport.rs +++ b/bin/all-o-stasis/src/passport.rs @@ -17,8 +17,8 @@ use serde::{Deserialize, Serialize}; use crate::{ AppError, AppState, - storage::{apply_object_updates, create_object, lookup_latest_snapshot}, - types::{Account, AccountRole, AccountsView, ObjectType}, + storage::{apply_object_updates, create_object}, + types::{Account, AccountRole, AccountsView, ObjectType, Snapshot}, word_list::make_security_code, }; @@ -358,7 +358,7 @@ async fn confirm_passport( Query(pport): Query, jar: CookieJar, ) -> Result { - let snapshot = lookup_latest_snapshot(&state, &gym, &pport.passport_id.clone()).await?; + let snapshot = Snapshot::lookup_latest(&state, &gym, &pport.passport_id.clone()).await?; let passport: Passport = serde_json::from_value(snapshot.content).or(Err( AppError::ParseError("failed to parse object into Passport".to_string()), ))?; @@ -422,7 +422,7 @@ async fn await_passport_confirmation( jar: CookieJar, ) -> Result { let (account_id, revision_id) = loop { - let snapshot = lookup_latest_snapshot(&state, &gym, &pport.passport_id.clone()).await?; + let snapshot = Snapshot::lookup_latest(&state, &gym, &pport.passport_id.clone()).await?; let passport: Passport = serde_json::from_value(snapshot.content).or(Err( AppError::ParseError("failed to parse object into Passport".to_string()), ))?; diff --git a/bin/all-o-stasis/src/routes.rs b/bin/all-o-stasis/src/routes.rs index c2f30e4..de0d2e7 100644 --- a/bin/all-o-stasis/src/routes.rs +++ b/bin/all-o-stasis/src/routes.rs @@ -7,7 +7,7 @@ mod api; mod collection; mod stats; -pub use api::{LookupObjectResponse, PatchObjectResponse}; +pub use api::PatchObjectResponse; mod built_info { include!(concat!(env!("OUT_DIR"), "/built.rs")); diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index 7d18f7f..078d149 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -2,8 +2,10 @@ use std::net::SocketAddr; use crate::passport::Session; use crate::session::{account_role, author_from_session}; -use crate::storage::{apply_object_updates, create_object, lookup_object_}; -use crate::types::{AccountRole, Boulder, BouldersView, Object, ObjectDoc, ObjectType, Patch}; +use crate::storage::{apply_object_updates, create_object}; +use crate::types::{ + AccountRole, Boulder, BouldersView, Object, ObjectDoc, ObjectType, Patch, Snapshot, +}; use crate::ws::handle_socket; use crate::{AppError, AppState}; use axum::{ @@ -18,9 +20,6 @@ use axum_extra::extract::{CookieJar, cookie::Cookie}; use axum_extra::headers::UserAgent; use chrono::{DateTime, Utc}; use cookie::time::Duration; -use firestore::{FirestoreResult, path_camel_case}; -use futures::TryStreamExt; -use futures::stream::BoxStream; use otp::{ObjectId, Operation, RevId}; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; @@ -54,6 +53,22 @@ pub struct LookupObjectResponse { pub content: Value, } +impl LookupObjectResponse { + pub async fn build(state: &AppState, gym: &String, id: ObjectId) -> Result { + let obj = Object::lookup(state, gym, &id).await?; + let snapshot = Snapshot::lookup_latest(state, gym, &id.clone()).await?; + + Ok(LookupObjectResponse { + id, + ot_type: obj.object_type, + created_at: obj.created_at, + created_by: obj.created_by, + revision_id: snapshot.revision_id, + content: snapshot.content, + }) + } +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct PatchObjectBody { @@ -150,6 +165,7 @@ pub fn routes() -> Router { .route("/{gym}/feed", any(feed)) } +// TODO maybe impl FromRequestParts into a state/path context async fn delete_session( State(state): State, Path(gym): Path, @@ -258,7 +274,7 @@ async fn lookup_object( Path((gym, id)): Path<(String, String)>, jar: CookieJar, ) -> Result, AppError> { - let response = lookup_object_(&state, &gym, id).await?; + let response = Json(LookupObjectResponse::build(&state, &gym, id).await?); // anyone can lookup boulders if response.ot_type == ObjectType::Boulder { @@ -359,32 +375,7 @@ async fn lookup_patch( State(state): State, Path((gym, id, rev_id)): Path<(String, String, i64)>, ) -> Result, AppError> { - let parent_path = state.db.parent_path("gyms", gym)?; - let patch_stream: BoxStream> = state - .db - .fluent() - .select() - .from(Patch::COLLECTION) - .parent(&parent_path) - .filter(|q| { - q.for_all([ - q.field(path_camel_case!(Patch::object_id)).eq(id.clone()), - q.field(path_camel_case!(Patch::revision_id)).eq(rev_id), - ]) - }) - .limit(1) - .obj() - .stream_query_with_errors() - .await?; - - let mut patches: Vec = patch_stream.try_collect().await?; - if patches.len() != 1 { - return Err(AppError::Query(format!( - "lookup_patch found {} patches, expecting only 1", - patches.len() - ))); - } - let patch = patches.pop().unwrap(); + let patch = Patch::lookup(&state, &gym, &id, rev_id).await?; Ok(Json(patch)) } diff --git a/bin/all-o-stasis/src/routes/collection.rs b/bin/all-o-stasis/src/routes/collection.rs index e69727c..9240d70 100644 --- a/bin/all-o-stasis/src/routes/collection.rs +++ b/bin/all-o-stasis/src/routes/collection.rs @@ -13,8 +13,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::session::author_from_session; -use crate::storage::lookup_latest_snapshot; -use crate::types::{Account, AccountRole, AccountsView, Boulder, BouldersView}; +use crate::types::{Account, AccountRole, AccountsView, Boulder, BouldersView, Snapshot}; use crate::{AppError, AppState}; #[derive(Serialize, Deserialize, Debug)] @@ -46,7 +45,7 @@ async fn public_profile( State(state): State, Path((gym, id)): Path<(String, String)>, ) -> Result, AppError> { - let snapshot = lookup_latest_snapshot(&state, &gym, &id).await?; + let snapshot = Snapshot::lookup_latest(&state, &gym, &id).await?; let account: Account = serde_json::from_value(snapshot.content).or(Err( AppError::ParseError("failed to parse object".to_string()), ))?; diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index e90199f..f89902e 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -1,13 +1,10 @@ use crate::{ AppError, AppState, - routes::{LookupObjectResponse, PatchObjectResponse}, + routes::PatchObjectResponse, types::{AccountsView, BouldersView, Object, ObjectDoc, ObjectType, Patch, Snapshot}, }; use axum::Json; -use firestore::{FirestoreQueryDirection, FirestoreResult, path_camel_case}; -use futures::TryStreamExt; -use futures::stream::BoxStream; -use otp::{ObjectId, Operation, RevId, ZERO_REV_ID, rebase}; +use otp::{ObjectId, Operation, RevId, rebase}; use serde_json::Value; pub(crate) async fn create_object( @@ -70,196 +67,6 @@ pub(crate) async fn update_view_typed( Ok(()) } -/// generic object lookup in `gym` with `id` -pub(crate) async fn lookup_object_( - state: &AppState, - gym: &String, - id: ObjectId, -) -> Result, AppError> { - let parent_path = state.db.parent_path("gyms", gym)?; - let obj: ObjectDoc = state - .db - .fluent() - .select() - .by_id_in(ObjectDoc::COLLECTION) - .parent(&parent_path) - .obj() - .one(&id) - .await? - .ok_or(AppError::Query(format!( - "lookup_object: failed to get object {id}" - )))?; - - let obj: Object = obj.try_into()?; - - tracing::debug!("looking up last snapshot for obj={id}"); - let snapshot = lookup_latest_snapshot(state, gym, &id.clone()).await?; - - Ok(Json(LookupObjectResponse { - id, - ot_type: obj.object_type, - created_at: obj.created_at, - created_by: obj.created_by, - revision_id: snapshot.revision_id, - content: snapshot.content, - })) -} - -pub(crate) async fn lookup_latest_snapshot( - state: &AppState, - gym: &String, - obj_id: &ObjectId, -) -> Result { - // same as lookup_snapshot but not with upper bound - let parent_path = state.db.parent_path("gyms", gym)?; - let object_stream: BoxStream> = state - .db - .fluent() - .select() - .from(Snapshot::COLLECTION) - .parent(&parent_path) - .filter(|q| { - q.for_all([ - q.field(path_camel_case!(Snapshot::object_id)).eq(obj_id), - q.field(path_camel_case!(Snapshot::revision_id)) - .greater_than_or_equal(ZERO_REV_ID), - ]) - }) - .limit(1) - .order_by([( - path_camel_case!(Snapshot::revision_id), - FirestoreQueryDirection::Descending, - )]) - .obj() - .stream_query_with_errors() - .await?; - - let snapshots: Vec = object_stream.try_collect().await?; - let latest_snapshot: Snapshot = match snapshots.first() { - Some(snapshot) => { - tracing::debug!("found {snapshot}"); - snapshot.clone() - } - None => { - tracing::debug!("no snapshot found"); - // XXX we could already create the first snapshot on object creation? - Snapshot::new(obj_id.clone()).store(state, gym).await? - } - }; - - // get all patches which we need to apply on top of the snapshot to - // arrive at the desired revision - let patches = patches_after_revision(state, gym, obj_id, latest_snapshot.revision_id).await?; - - // apply those patches to the snapshot - latest_snapshot.apply_patches(&patches) -} - -/// get or create a snapshot between low and high (inclusive) -async fn lookup_snapshot_between( - state: &AppState, - gym: &String, - obj_id: &ObjectId, - low: RevId, - high: RevId, -) -> Result { - let parent_path = state.db.parent_path("gyms", gym)?; - let object_stream: BoxStream> = state - .db - .fluent() - .select() - .from(Snapshot::COLLECTION) - .parent(&parent_path) - .filter(|q| { - q.for_all([ - q.field(path_camel_case!(Snapshot::object_id)).eq(obj_id), - q.field(path_camel_case!(Snapshot::revision_id)) - .greater_than_or_equal(low), - q.field(path_camel_case!(Snapshot::revision_id)) - .less_than_or_equal(high), - ]) - }) - .limit(1) - .order_by([( - path_camel_case!(Snapshot::revision_id), - FirestoreQueryDirection::Descending, - )]) - .obj() - .stream_query_with_errors() - .await?; - - let snapshots: Vec = object_stream.try_collect().await?; - tracing::debug!( - "snapshots ({low} <= s <= {high}): {} snapshots, obj={obj_id}", - snapshots.len(), - ); - match snapshots.first() { - Some(snapshot) => Ok(snapshot.clone()), - None => { - // TODO we could already create the first snapshot on object creation? - // TODO why is initial snapshot rev = -1? - Ok(Snapshot::new(obj_id.clone()).store(state, gym).await?) - } - } -} - -async fn lookup_snapshot( - state: &AppState, - gym: &String, - obj_id: &ObjectId, - rev_id: RevId, // inclusive -) -> Result { - let latest_snapshot = lookup_snapshot_between(state, gym, obj_id, ZERO_REV_ID, rev_id).await?; - - // get all patches which we need to apply on top of the snapshot to - // arrive at the desired revision - let patches: Vec = - patches_after_revision(state, gym, obj_id, latest_snapshot.revision_id) - .await? - .into_iter() - .filter(|p| p.revision_id <= rev_id) - .collect(); - - // apply those patches to the snapshot - latest_snapshot.apply_patches(&patches) -} - -async fn patches_after_revision( - state: &AppState, - gym: &String, - obj_id: &ObjectId, - rev_id: RevId, -) -> Result, AppError> { - let parent_path = state.db.parent_path("gyms", gym)?; - let object_stream: BoxStream> = state - .db - .fluent() - .select() - .from(Patch::COLLECTION) - .parent(&parent_path) - .filter(|q| { - q.for_all([ - q.field(path_camel_case!(Patch::object_id)).eq(obj_id), - q.field(path_camel_case!(Patch::revision_id)) - .greater_than(rev_id), - ]) - }) - .order_by([( - path_camel_case!(Snapshot::revision_id), - FirestoreQueryDirection::Ascending, - )]) - .obj() - .stream_query_with_errors() - .await?; - - let patches: Vec = object_stream.try_collect().await?; - tracing::debug!( - "patches after rev ({rev_id}): {}, obj = {obj_id}", - patches.len() - ); - Ok(patches) -} - pub async fn apply_object_updates( state: &AppState, gym: &String, @@ -271,12 +78,12 @@ pub async fn apply_object_updates( // the 'Snapshot' against which the submitted operations were created // this only contains patches until base_snapshot.revision_id tracing::debug!("looking up base_snapshot@rev{rev_id}"); - let base_snapshot = lookup_snapshot(state, gym, &obj_id, rev_id).await?; + let base_snapshot = Snapshot::lookup(state, gym, &obj_id, rev_id).await?; tracing::debug!("base_snapshot={base_snapshot}"); // if there are any patches which the client doesn't know about we need // to let her know - let previous_patches = patches_after_revision(state, gym, &obj_id, rev_id).await?; + let previous_patches = Patch::after_revision(state, gym, &obj_id, rev_id).await?; let latest_snapshot = base_snapshot.apply_patches(&previous_patches)?; let mut patches = Vec::::new(); diff --git a/bin/all-o-stasis/src/types.rs b/bin/all-o-stasis/src/types.rs index 3d9f8bc..ee2b094 100644 --- a/bin/all-o-stasis/src/types.rs +++ b/bin/all-o-stasis/src/types.rs @@ -1,7 +1,9 @@ use std::fmt; use chrono::{DateTime, Utc}; -use otp::{ObjectId, Operation, OtError, RevId}; +use firestore::{FirestoreQueryDirection, FirestoreResult, path_camel_case}; +use futures::{TryStreamExt, stream::BoxStream}; +use otp::{ObjectId, Operation, OtError, RevId, ZERO_REV_ID}; use serde::{Deserialize, Serialize}; use serde_json::{Value, from_value, json}; @@ -117,6 +119,22 @@ impl ObjectDoc { } } + async fn lookup(state: &AppState, gym: &String, object_id: ObjectId) -> Result { + let parent_path = state.db.parent_path("gyms", gym)?; + state + .db + .fluent() + .select() + .by_id_in(ObjectDoc::COLLECTION) + .parent(&parent_path) + .obj() + .one(&object_id) + .await? + .ok_or(AppError::Query(format!( + "lookup_object: failed to get object {object_id}" + ))) + } + pub async fn store(&self, state: &AppState, gym: &String) -> Result { let s: Option = store!(state, gym, self, Self::COLLECTION); s.ok_or(AppError::Query("storing object failed".to_string())) @@ -171,6 +189,16 @@ impl Object { let obj: Object = obj_doc.try_into()?; Ok(obj) } + + pub async fn lookup( + state: &AppState, + gym: &String, + object_id: &ObjectId, + ) -> Result { + let obj_doc = ObjectDoc::lookup(state, gym, object_id.clone()).await?; + let obj: Object = obj_doc.try_into()?; + Ok(obj) + } } impl fmt::Display for Object { @@ -252,6 +280,79 @@ impl Patch { let s: Option = store!(state, gym, self, Self::COLLECTION); s.ok_or(AppError::Query("storing patch failed".to_string())) } + + /// lookup a patch with rev_id + pub async fn lookup( + state: &AppState, + gym: &String, + object_id: &ObjectId, + rev_id: RevId, // inclusive + ) -> Result { + let parent_path = state.db.parent_path("gyms", gym)?; + let patch_stream: BoxStream> = state + .db + .fluent() + .select() + .from(Self::COLLECTION) + .parent(&parent_path) + .filter(|q| { + q.for_all([ + q.field(path_camel_case!(Patch::object_id)) + .eq(object_id.clone()), + q.field(path_camel_case!(Patch::revision_id)).eq(rev_id), + ]) + }) + .limit(1) + .obj() + .stream_query_with_errors() + .await?; + + let mut patches: Vec = patch_stream.try_collect().await?; + if patches.len() != 1 { + return Err(AppError::Query(format!( + "lookup_patch found {} patches, expecting only 1", + patches.len() + ))); + } + let patch = patches.pop().unwrap(); + Ok(patch) + } + + pub async fn after_revision( + state: &AppState, + gym: &String, + obj_id: &ObjectId, + rev_id: RevId, + ) -> Result, AppError> { + let parent_path = state.db.parent_path("gyms", gym)?; + let object_stream: BoxStream> = state + .db + .fluent() + .select() + .from(Patch::COLLECTION) + .parent(&parent_path) + .filter(|q| { + q.for_all([ + q.field(path_camel_case!(Patch::object_id)).eq(obj_id), + q.field(path_camel_case!(Patch::revision_id)) + .greater_than(rev_id), + ]) + }) + .order_by([( + path_camel_case!(Snapshot::revision_id), + FirestoreQueryDirection::Ascending, + )]) + .obj() + .stream_query_with_errors() + .await?; + + let patches: Vec = object_stream.try_collect().await?; + tracing::debug!( + "patches after rev ({rev_id}): {}, obj = {obj_id}", + patches.len() + ); + Ok(patches) + } } #[derive(Serialize, Deserialize, Clone)] @@ -263,7 +364,7 @@ pub struct Snapshot { } impl Snapshot { - pub const COLLECTION: &str = "snapshots"; + const COLLECTION: &str = "snapshots"; pub fn new(object_id: ObjectId) -> Self { Self { @@ -347,6 +448,127 @@ impl Snapshot { // }, // } } + + /// lookup a snapshot with rev_id or lower and apply patches if necessary + pub async fn lookup( + state: &AppState, + gym: &String, + obj_id: &ObjectId, + rev_id: RevId, // inclusive + ) -> Result { + let latest_snapshot = Self::lookup_between(state, gym, obj_id, ZERO_REV_ID, rev_id).await?; + + // get all patches which we need to apply on top of the snapshot to + // arrive at the desired revision + let patches: Vec = + Patch::after_revision(state, gym, obj_id, latest_snapshot.revision_id) + .await? + .into_iter() + .filter(|p| p.revision_id <= rev_id) + .collect(); + + // apply those patches to the snapshot + latest_snapshot.apply_patches(&patches) + } + + /// get or create a latest snapshot between low and high (inclusive) + async fn lookup_between( + state: &AppState, + gym: &String, + obj_id: &ObjectId, + low: RevId, + high: RevId, + ) -> Result { + let parent_path = state.db.parent_path("gyms", gym)?; + let object_stream: BoxStream> = state + .db + .fluent() + .select() + .from(Snapshot::COLLECTION) + .parent(&parent_path) + .filter(|q| { + q.for_all([ + q.field(path_camel_case!(Snapshot::object_id)).eq(obj_id), + q.field(path_camel_case!(Snapshot::revision_id)) + .greater_than_or_equal(low), + q.field(path_camel_case!(Snapshot::revision_id)) + .less_than_or_equal(high), + ]) + }) + .limit(1) + .order_by([( + path_camel_case!(Snapshot::revision_id), + FirestoreQueryDirection::Descending, + )]) + .obj() + .stream_query_with_errors() + .await?; + + let snapshots: Vec = object_stream.try_collect().await?; + tracing::debug!( + "snapshots ({low} <= s <= {high}): {} snapshots, obj={obj_id}", + snapshots.len(), + ); + match snapshots.first() { + Some(snapshot) => Ok(snapshot.clone()), + None => { + // TODO we could already create the first snapshot on object creation? + Ok(Snapshot::new(obj_id.clone()).store(state, gym).await?) + } + } + } + + /// get latest available snapshot with object_id or create a new snapshot. apply unapplied + /// patches to get to the latest revision. + pub async fn lookup_latest( + state: &AppState, + gym: &String, + object_id: &ObjectId, + ) -> Result { + let parent_path = state.db.parent_path("gyms", gym)?; + let object_stream: BoxStream> = state + .db + .fluent() + .select() + .from(Snapshot::COLLECTION) + .parent(&parent_path) + .filter(|q| { + q.for_all([ + q.field(path_camel_case!(Snapshot::object_id)).eq(object_id), + q.field(path_camel_case!(Snapshot::revision_id)) + .greater_than_or_equal(ZERO_REV_ID), + ]) + }) + .limit(1) + .order_by([( + path_camel_case!(Snapshot::revision_id), + FirestoreQueryDirection::Descending, + )]) + .obj() + .stream_query_with_errors() + .await?; + + let snapshots: Vec = object_stream.try_collect().await?; + let latest_snapshot: Snapshot = match snapshots.first() { + Some(snapshot) => { + tracing::debug!("found {snapshot}"); + snapshot.clone() + } + None => { + tracing::debug!("no snapshot found"); + // XXX we could already create the first snapshot on object creation? + Snapshot::new(object_id.clone()).store(state, gym).await? + } + }; + + // get all patches which we need to apply on top of the snapshot to + // arrive at the desired revision + let patches = + Patch::after_revision(state, gym, object_id, latest_snapshot.revision_id).await?; + + // apply those patches to the snapshot + latest_snapshot.apply_patches(&patches) + } } impl fmt::Display for Snapshot { From 7dcd71af0b0dc2d5dbc0af6da184ea89820eb140 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Thu, 25 Dec 2025 07:55:44 +0100 Subject: [PATCH 12/29] consolidate snapshot lookups --- bin/all-o-stasis/src/types.rs | 106 +++++++++++++--------------------- 1 file changed, 41 insertions(+), 65 deletions(-) diff --git a/bin/all-o-stasis/src/types.rs b/bin/all-o-stasis/src/types.rs index ee2b094..1c733b1 100644 --- a/bin/all-o-stasis/src/types.rs +++ b/bin/all-o-stasis/src/types.rs @@ -456,7 +456,8 @@ impl Snapshot { obj_id: &ObjectId, rev_id: RevId, // inclusive ) -> Result { - let latest_snapshot = Self::lookup_between(state, gym, obj_id, ZERO_REV_ID, rev_id).await?; + let latest_snapshot = + Self::lookup_between(state, gym, obj_id, ZERO_REV_ID, Some(rev_id)).await?; // get all patches which we need to apply on top of the snapshot to // arrive at the desired revision @@ -471,29 +472,56 @@ impl Snapshot { latest_snapshot.apply_patches(&patches) } + /// get latest available snapshot with object_id or create a new snapshot. apply unapplied + /// patches to get to the latest revision. + pub async fn lookup_latest( + state: &AppState, + gym: &String, + object_id: &ObjectId, + ) -> Result { + let latest_snapshot = + Snapshot::lookup_between(state, gym, object_id, ZERO_REV_ID, None).await?; + + // get all patches which we need to apply on top of the snapshot to + // arrive at the desired revision + let patches = + Patch::after_revision(state, gym, object_id, latest_snapshot.revision_id).await?; + + // apply those patches to the snapshot + latest_snapshot.apply_patches(&patches) + } + /// get or create a latest snapshot between low and high (inclusive) async fn lookup_between( state: &AppState, gym: &String, - obj_id: &ObjectId, + object_id: &ObjectId, low: RevId, - high: RevId, + high: Option, ) -> Result { let parent_path = state.db.parent_path("gyms", gym)?; let object_stream: BoxStream> = state .db .fluent() .select() - .from(Snapshot::COLLECTION) + .from(Self::COLLECTION) .parent(&parent_path) .filter(|q| { - q.for_all([ - q.field(path_camel_case!(Snapshot::object_id)).eq(obj_id), - q.field(path_camel_case!(Snapshot::revision_id)) - .greater_than_or_equal(low), - q.field(path_camel_case!(Snapshot::revision_id)) - .less_than_or_equal(high), - ]) + q.for_all( + [ + Some(q.field(path_camel_case!(Snapshot::object_id)).eq(object_id)), + Some( + q.field(path_camel_case!(Snapshot::revision_id)) + .greater_than_or_equal(low), + ), + high.map(|h| { + q.field(path_camel_case!(Snapshot::revision_id)) + .less_than_or_equal(h) + }), + ] + .into_iter() + .flatten(), + ) }) .limit(1) .order_by([( @@ -506,69 +534,17 @@ impl Snapshot { let snapshots: Vec = object_stream.try_collect().await?; tracing::debug!( - "snapshots ({low} <= s <= {high}): {} snapshots, obj={obj_id}", + "snapshots ({low} <= s <= {high:?}): {} snapshots, obj={object_id}", snapshots.len(), ); match snapshots.first() { Some(snapshot) => Ok(snapshot.clone()), None => { // TODO we could already create the first snapshot on object creation? - Ok(Snapshot::new(obj_id.clone()).store(state, gym).await?) + Ok(Snapshot::new(object_id.clone()).store(state, gym).await?) } } } - - /// get latest available snapshot with object_id or create a new snapshot. apply unapplied - /// patches to get to the latest revision. - pub async fn lookup_latest( - state: &AppState, - gym: &String, - object_id: &ObjectId, - ) -> Result { - let parent_path = state.db.parent_path("gyms", gym)?; - let object_stream: BoxStream> = state - .db - .fluent() - .select() - .from(Snapshot::COLLECTION) - .parent(&parent_path) - .filter(|q| { - q.for_all([ - q.field(path_camel_case!(Snapshot::object_id)).eq(object_id), - q.field(path_camel_case!(Snapshot::revision_id)) - .greater_than_or_equal(ZERO_REV_ID), - ]) - }) - .limit(1) - .order_by([( - path_camel_case!(Snapshot::revision_id), - FirestoreQueryDirection::Descending, - )]) - .obj() - .stream_query_with_errors() - .await?; - - let snapshots: Vec = object_stream.try_collect().await?; - let latest_snapshot: Snapshot = match snapshots.first() { - Some(snapshot) => { - tracing::debug!("found {snapshot}"); - snapshot.clone() - } - None => { - tracing::debug!("no snapshot found"); - // XXX we could already create the first snapshot on object creation? - Snapshot::new(object_id.clone()).store(state, gym).await? - } - }; - - // get all patches which we need to apply on top of the snapshot to - // arrive at the desired revision - let patches = - Patch::after_revision(state, gym, object_id, latest_snapshot.revision_id).await?; - - // apply those patches to the snapshot - latest_snapshot.apply_patches(&patches) - } } impl fmt::Display for Snapshot { From 0d9569bd9afa5cca3eb1379c9f9152da76a88d66 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Thu, 25 Dec 2025 07:58:47 +0100 Subject: [PATCH 13/29] cosmetics --- bin/all-o-stasis/src/types.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/bin/all-o-stasis/src/types.rs b/bin/all-o-stasis/src/types.rs index 1c733b1..6de9d08 100644 --- a/bin/all-o-stasis/src/types.rs +++ b/bin/all-o-stasis/src/types.rs @@ -449,7 +449,7 @@ impl Snapshot { // } } - /// lookup a snapshot with rev_id or lower and apply patches if necessary + /// lookup a snapshot with rev_id or lower and apply patches with revision <= rev_id if necessary pub async fn lookup( state: &AppState, gym: &String, @@ -457,7 +457,7 @@ impl Snapshot { rev_id: RevId, // inclusive ) -> Result { let latest_snapshot = - Self::lookup_between(state, gym, obj_id, ZERO_REV_ID, Some(rev_id)).await?; + Self::lookup_between(state, gym, obj_id, (ZERO_REV_ID, Some(rev_id))).await?; // get all patches which we need to apply on top of the snapshot to // arrive at the desired revision @@ -473,14 +473,14 @@ impl Snapshot { } /// get latest available snapshot with object_id or create a new snapshot. apply unapplied - /// patches to get to the latest revision. + /// patches to get to the latest possible revision. pub async fn lookup_latest( state: &AppState, gym: &String, object_id: &ObjectId, ) -> Result { let latest_snapshot = - Snapshot::lookup_between(state, gym, object_id, ZERO_REV_ID, None).await?; + Snapshot::lookup_between(state, gym, object_id, (ZERO_REV_ID, None)).await?; // get all patches which we need to apply on top of the snapshot to // arrive at the desired revision @@ -496,8 +496,7 @@ impl Snapshot { state: &AppState, gym: &String, object_id: &ObjectId, - low: RevId, - high: Option, + range: (RevId, Option), ) -> Result { let parent_path = state.db.parent_path("gyms", gym)?; let object_stream: BoxStream> = state @@ -512,9 +511,9 @@ impl Snapshot { Some(q.field(path_camel_case!(Snapshot::object_id)).eq(object_id)), Some( q.field(path_camel_case!(Snapshot::revision_id)) - .greater_than_or_equal(low), + .greater_than_or_equal(range.0), ), - high.map(|h| { + range.1.map(|h| { q.field(path_camel_case!(Snapshot::revision_id)) .less_than_or_equal(h) }), @@ -534,7 +533,9 @@ impl Snapshot { let snapshots: Vec = object_stream.try_collect().await?; tracing::debug!( - "snapshots ({low} <= s <= {high:?}): {} snapshots, obj={object_id}", + "snapshots ({} <= s <= {:?}): {} snapshots, obj={object_id}", + range.0, + range.1, snapshots.len(), ); match snapshots.first() { From 115e8930b9f05d5b243750cbc4675ab376e1b795 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Thu, 25 Dec 2025 07:59:43 +0100 Subject: [PATCH 14/29] cleanup --- bin/all-o-stasis/src/types.rs | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/bin/all-o-stasis/src/types.rs b/bin/all-o-stasis/src/types.rs index 6de9d08..51983bd 100644 --- a/bin/all-o-stasis/src/types.rs +++ b/bin/all-o-stasis/src/types.rs @@ -422,31 +422,6 @@ impl Snapshot { pub async fn store(&self, state: &AppState, gym: &String) -> Result { let s: Option = store!(state, gym, self, Self::COLLECTION); s.ok_or(AppError::Query("storing snapshot failed".to_string())) - - // let parent_path = state.db.parent_path("gyms", gym)?; - // let result: Option = state - // .db - // .fluent() - // .insert() - // .into(Self::COLLECTION) - // .generate_document_id() - // .parent(&parent_path) - // .object(self) - // .execute() - // .await?; - // - // // TODO logging? - // result.ok_or(AppError::Query("storing snapshot failed".to_string())) - // match &result { - // Some(r) => { - // tracing::debug!("storing: {r}"); - // Ok(r) - // }, - // None => { - // tracing::warn!("failed to store: {}", self); - // Err(AppError - // }, - // } } /// lookup a snapshot with rev_id or lower and apply patches with revision <= rev_id if necessary From 0ef80b2936a70ae46c7b71dcdd181f5ed1c42801 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Thu, 25 Dec 2025 08:50:17 +0100 Subject: [PATCH 15/29] split types --- bin/all-o-stasis/src/types.rs | 594 ------------------------- bin/all-o-stasis/src/types/mod.rs | 152 +++++++ bin/all-o-stasis/src/types/object.rs | 144 ++++++ bin/all-o-stasis/src/types/patch.rs | 162 +++++++ bin/all-o-stasis/src/types/snapshot.rs | 210 +++++++++ 5 files changed, 668 insertions(+), 594 deletions(-) delete mode 100644 bin/all-o-stasis/src/types.rs create mode 100644 bin/all-o-stasis/src/types/mod.rs create mode 100644 bin/all-o-stasis/src/types/object.rs create mode 100644 bin/all-o-stasis/src/types/patch.rs create mode 100644 bin/all-o-stasis/src/types/snapshot.rs diff --git a/bin/all-o-stasis/src/types.rs b/bin/all-o-stasis/src/types.rs deleted file mode 100644 index 51983bd..0000000 --- a/bin/all-o-stasis/src/types.rs +++ /dev/null @@ -1,594 +0,0 @@ -use std::fmt; - -use chrono::{DateTime, Utc}; -use firestore::{FirestoreQueryDirection, FirestoreResult, path_camel_case}; -use futures::{TryStreamExt, stream::BoxStream}; -use otp::{ObjectId, Operation, OtError, RevId, ZERO_REV_ID}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, from_value, json}; - -use crate::{AppError, AppState}; - -macro_rules! store { - ($state:expr, $gym:expr, $entity:expr, $collection:expr) => {{ - let parent_path = $state.db.parent_path("gyms", $gym)?; - let result = $state - .db - .fluent() - .insert() - .into($collection) - .generate_document_id() - .parent(&parent_path) - .object($entity) - .execute() - .await?; - - match &result { - Some(r) => tracing::debug!("storing: {r}"), - None => tracing::warn!("failed to store: {}", $entity), - } - - result - }}; -} - -// TODO implement Arbitrary for types - -#[derive(Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -pub enum AccountRole { - User, - Setter, - Admin, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Account { - #[serde(alias = "_firestore_id")] - pub id: Option, - // TODO this is not used - remove - pub login: String, - pub role: AccountRole, - pub email: String, - #[serde(with = "firestore::serialize_as_null")] - pub name: Option, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Boulder { - #[serde(alias = "_firestore_id")] - pub id: Option, - pub setter: Vec, - pub sector: String, - pub grade: String, - grade_nr: u32, - /// set date as epoch timestamp in millis - pub set_date: usize, - // #[serde(with = "firestore::serialize_as_null")] - // pub removed: Option, - /// removed date as epoch timestamp in millis, 0 means not removed yet - pub removed: usize, - // #[serde(with = "firestore::serialize_as_null")] - // pub is_draft: Option, - pub is_draft: usize, - name: String, - // name: Option, -} - -impl fmt::Display for Boulder { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "Boulder: {}", - serde_json::to_string_pretty(self).expect("serialisation should not fail") - ) - } -} - -impl Boulder { - pub fn in_setter(&self, setter: &ObjectId) -> bool { - self.setter.contains(setter) - } -} - -// Object storage representation - used for Firestore serialization -#[derive(Serialize, Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct ObjectDoc { - #[serde(alias = "_firestore_id")] - id: Option, - #[serde(alias = "_firestore_created")] - created_at: Option>, - object_type: ObjectType, - created_by: ObjectId, - deleted: Option, -} - -impl ObjectDoc { - pub const COLLECTION: &str = "objects"; - - fn new(object_type: ObjectType) -> Self { - Self { - id: None, - object_type, - created_at: None, - created_by: otp::ROOT_OBJ_ID.to_owned(), - deleted: None, - } - } - - async fn lookup(state: &AppState, gym: &String, object_id: ObjectId) -> Result { - let parent_path = state.db.parent_path("gyms", gym)?; - state - .db - .fluent() - .select() - .by_id_in(ObjectDoc::COLLECTION) - .parent(&parent_path) - .obj() - .one(&object_id) - .await? - .ok_or(AppError::Query(format!( - "lookup_object: failed to get object {object_id}" - ))) - } - - pub async fn store(&self, state: &AppState, gym: &String) -> Result { - let s: Option = store!(state, gym, self, Self::COLLECTION); - s.ok_or(AppError::Query("storing object failed".to_string())) - } -} - -impl fmt::Display for ObjectDoc { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match &self.id { - None => write!(f, "ObjectDoc: no id {}", self.object_type), - Some(id) => write!(f, "ObjectDoc: {id} {}", self.object_type), - } - } -} - -pub struct Object { - pub id: ObjectId, - pub created_at: DateTime, - pub object_type: ObjectType, - pub created_by: ObjectId, - #[allow(dead_code)] - pub deleted: bool, -} - -impl TryFrom for Object { - type Error = AppError; - - fn try_from(doc: ObjectDoc) -> Result { - Ok(Object { - id: doc - .id - .ok_or(AppError::Query("object doc is missing an id".to_string()))?, - created_at: doc.created_at.ok_or(AppError::Query( - "object doc is missing created_at".to_string(), - ))?, - object_type: doc.object_type, - created_by: doc.created_by, - deleted: doc.deleted.unwrap_or(false), - }) - } -} - -impl Object { - pub async fn new( - state: &AppState, - gym: &String, - object_type: &ObjectType, - ) -> Result { - let obj_doc = ObjectDoc::new(object_type.clone()) - .store(state, gym) - .await?; - let obj: Object = obj_doc.try_into()?; - Ok(obj) - } - - pub async fn lookup( - state: &AppState, - gym: &String, - object_id: &ObjectId, - ) -> Result { - let obj_doc = ObjectDoc::lookup(state, gym, object_id.clone()).await?; - let obj: Object = obj_doc.try_into()?; - Ok(obj) - } -} - -impl fmt::Display for Object { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Object: {} {}", self.id, self.object_type) - } -} - -// here we fix types to those instead of doing a generic str to type "cast" -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -#[serde(rename_all = "camelCase")] -pub enum ObjectType { - Account, - Boulder, - Passport, -} - -impl fmt::Display for ObjectType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ObjectType::Account => write!(f, "type=account"), - ObjectType::Boulder => write!(f, "type=boulder"), - ObjectType::Passport => write!(f, "type=passport"), - } - } -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Patch { - pub object_id: ObjectId, - pub revision_id: RevId, - pub author_id: ObjectId, - #[serde(alias = "_firestore_created")] - pub created_at: Option>, //Option, - pub operation: Operation, -} - -impl fmt::Display for Patch { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "Patch: {}@{} ops={}", - self.object_id, self.revision_id, self.operation - ) - } -} - -impl Patch { - pub const COLLECTION: &str = "patches"; - - pub fn new(object_id: ObjectId, author_id: String, value: &Value) -> Self { - let op = Operation::new_set(otp::ROOT_PATH.to_owned(), value.to_owned()); - Self { - object_id, - revision_id: otp::ZERO_REV_ID, - author_id, - created_at: None, - operation: op, - } - } - - pub fn new_revision( - revision_id: RevId, - object_id: ObjectId, - author_id: String, - operation: Operation, - ) -> Self { - Self { - object_id, - revision_id, - author_id, - created_at: None, - operation, - } - } - - pub async fn store(&self, state: &AppState, gym: &String) -> Result { - let s: Option = store!(state, gym, self, Self::COLLECTION); - s.ok_or(AppError::Query("storing patch failed".to_string())) - } - - /// lookup a patch with rev_id - pub async fn lookup( - state: &AppState, - gym: &String, - object_id: &ObjectId, - rev_id: RevId, // inclusive - ) -> Result { - let parent_path = state.db.parent_path("gyms", gym)?; - let patch_stream: BoxStream> = state - .db - .fluent() - .select() - .from(Self::COLLECTION) - .parent(&parent_path) - .filter(|q| { - q.for_all([ - q.field(path_camel_case!(Patch::object_id)) - .eq(object_id.clone()), - q.field(path_camel_case!(Patch::revision_id)).eq(rev_id), - ]) - }) - .limit(1) - .obj() - .stream_query_with_errors() - .await?; - - let mut patches: Vec = patch_stream.try_collect().await?; - if patches.len() != 1 { - return Err(AppError::Query(format!( - "lookup_patch found {} patches, expecting only 1", - patches.len() - ))); - } - let patch = patches.pop().unwrap(); - Ok(patch) - } - - pub async fn after_revision( - state: &AppState, - gym: &String, - obj_id: &ObjectId, - rev_id: RevId, - ) -> Result, AppError> { - let parent_path = state.db.parent_path("gyms", gym)?; - let object_stream: BoxStream> = state - .db - .fluent() - .select() - .from(Patch::COLLECTION) - .parent(&parent_path) - .filter(|q| { - q.for_all([ - q.field(path_camel_case!(Patch::object_id)).eq(obj_id), - q.field(path_camel_case!(Patch::revision_id)) - .greater_than(rev_id), - ]) - }) - .order_by([( - path_camel_case!(Snapshot::revision_id), - FirestoreQueryDirection::Ascending, - )]) - .obj() - .stream_query_with_errors() - .await?; - - let patches: Vec = object_stream.try_collect().await?; - tracing::debug!( - "patches after rev ({rev_id}): {}, obj = {obj_id}", - patches.len() - ); - Ok(patches) - } -} - -#[derive(Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Snapshot { - pub object_id: ObjectId, - pub revision_id: RevId, - pub content: Value, -} - -impl Snapshot { - const COLLECTION: &str = "snapshots"; - - pub fn new(object_id: ObjectId) -> Self { - Self { - object_id, - // FIXME why is this not ZERO_REV_ID? - revision_id: -1, - content: json!({}), - } - } - - pub fn new_revision( - &self, - object_id: ObjectId, - author_id: ObjectId, - operation: Operation, - ) -> Result, OtError> { - assert_eq!(object_id, self.object_id); - - let content = operation.apply_to(self.content.to_owned())?; - if content == self.content { - tracing::debug!("skipping save operation: content did not change"); - return Ok(None); - } - - let revision_id = self.revision_id + 1; - let patch = Patch::new_revision(revision_id, object_id.clone(), author_id, operation); - Ok(Some(( - Self { - object_id, - revision_id, - content, - }, - patch, - ))) - } - - fn apply_patch(&self, patch: &Patch) -> Result { - // tracing::debug!("applying patch={patch} to {snapshot} results in snapshot={s}"); - Ok(Self { - object_id: self.object_id.to_owned(), - revision_id: patch.revision_id, - content: patch.operation.apply_to(self.content.clone())?, - }) - } - - pub fn apply_patches(&self, patches: &Vec) -> Result { - let mut s = self.clone(); - for patch in patches { - s = s.apply_patch(patch)?; - } - - Ok(s) - } - - pub async fn store(&self, state: &AppState, gym: &String) -> Result { - let s: Option = store!(state, gym, self, Self::COLLECTION); - s.ok_or(AppError::Query("storing snapshot failed".to_string())) - } - - /// lookup a snapshot with rev_id or lower and apply patches with revision <= rev_id if necessary - pub async fn lookup( - state: &AppState, - gym: &String, - obj_id: &ObjectId, - rev_id: RevId, // inclusive - ) -> Result { - let latest_snapshot = - Self::lookup_between(state, gym, obj_id, (ZERO_REV_ID, Some(rev_id))).await?; - - // get all patches which we need to apply on top of the snapshot to - // arrive at the desired revision - let patches: Vec = - Patch::after_revision(state, gym, obj_id, latest_snapshot.revision_id) - .await? - .into_iter() - .filter(|p| p.revision_id <= rev_id) - .collect(); - - // apply those patches to the snapshot - latest_snapshot.apply_patches(&patches) - } - - /// get latest available snapshot with object_id or create a new snapshot. apply unapplied - /// patches to get to the latest possible revision. - pub async fn lookup_latest( - state: &AppState, - gym: &String, - object_id: &ObjectId, - ) -> Result { - let latest_snapshot = - Snapshot::lookup_between(state, gym, object_id, (ZERO_REV_ID, None)).await?; - - // get all patches which we need to apply on top of the snapshot to - // arrive at the desired revision - let patches = - Patch::after_revision(state, gym, object_id, latest_snapshot.revision_id).await?; - - // apply those patches to the snapshot - latest_snapshot.apply_patches(&patches) - } - - /// get or create a latest snapshot between low and high (inclusive) - async fn lookup_between( - state: &AppState, - gym: &String, - object_id: &ObjectId, - range: (RevId, Option), - ) -> Result { - let parent_path = state.db.parent_path("gyms", gym)?; - let object_stream: BoxStream> = state - .db - .fluent() - .select() - .from(Self::COLLECTION) - .parent(&parent_path) - .filter(|q| { - q.for_all( - [ - Some(q.field(path_camel_case!(Snapshot::object_id)).eq(object_id)), - Some( - q.field(path_camel_case!(Snapshot::revision_id)) - .greater_than_or_equal(range.0), - ), - range.1.map(|h| { - q.field(path_camel_case!(Snapshot::revision_id)) - .less_than_or_equal(h) - }), - ] - .into_iter() - .flatten(), - ) - }) - .limit(1) - .order_by([( - path_camel_case!(Snapshot::revision_id), - FirestoreQueryDirection::Descending, - )]) - .obj() - .stream_query_with_errors() - .await?; - - let snapshots: Vec = object_stream.try_collect().await?; - tracing::debug!( - "snapshots ({} <= s <= {:?}): {} snapshots, obj={object_id}", - range.0, - range.1, - snapshots.len(), - ); - match snapshots.first() { - Some(snapshot) => Ok(snapshot.clone()), - None => { - // TODO we could already create the first snapshot on object creation? - Ok(Snapshot::new(object_id.clone()).store(state, gym).await?) - } - } - } -} - -impl fmt::Display for Snapshot { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "Snapshot: {}@{} content={}", - self.object_id, self.revision_id, self.content - ) - } -} - -pub struct AccountsView {} - -impl AccountsView { - pub const COLLECTION: &str = "accounts_view"; - - pub async fn store( - state: &AppState, - gym: &String, - object_id: &ObjectId, - content: &Value, - ) -> Result<(), AppError> { - let parent_path = state.db.parent_path("gyms", gym)?; - let account = from_value::(content.clone()) - .map_err(|e| AppError::ParseError(format!("{e} in: {content}")))?; - - let _: Option = state - .db - .fluent() - .update() - .in_col(Self::COLLECTION) - .document_id(object_id.clone()) - .parent(parent_path) - .object(&account) - .execute() - .await?; - - Ok(()) - } -} - -pub struct BouldersView {} - -impl BouldersView { - pub const COLLECTION: &str = "boulders_view"; - - pub async fn store( - state: &AppState, - gym: &String, - object_id: &ObjectId, - content: &Value, - ) -> Result<(), AppError> { - let parent_path = state.db.parent_path("gyms", gym)?; - let boulder = from_value::(content.clone()) - .map_err(|e| AppError::ParseError(format!("{e} in: {content}")))?; - - let _: Option = state - .db - .fluent() - .update() - .in_col(Self::COLLECTION) - .document_id(object_id.clone()) - .parent(parent_path) - .object(&boulder) - .execute() - .await?; - - Ok(()) - } -} diff --git a/bin/all-o-stasis/src/types/mod.rs b/bin/all-o-stasis/src/types/mod.rs new file mode 100644 index 0000000..83183a3 --- /dev/null +++ b/bin/all-o-stasis/src/types/mod.rs @@ -0,0 +1,152 @@ +use std::fmt; + +use otp::ObjectId; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, from_value}; + +use crate::{AppError, AppState}; + +pub mod object; +pub mod patch; +pub mod snapshot; + +pub use object::{Object, ObjectDoc}; +pub use patch::Patch; +pub use snapshot::Snapshot; + +#[derive(Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum AccountRole { + User, + Setter, + Admin, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum ObjectType { + Account, + Boulder, + Passport, +} + +impl fmt::Display for ObjectType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ObjectType::Account => write!(f, "type=account"), + ObjectType::Boulder => write!(f, "type=boulder"), + ObjectType::Passport => write!(f, "type=passport"), + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Account { + #[serde(alias = "_firestore_id")] + pub id: Option, + // TODO this is not used - remove + pub login: String, + pub role: AccountRole, + pub email: String, + #[serde(with = "firestore::serialize_as_null")] + pub name: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Boulder { + #[serde(alias = "_firestore_id")] + pub id: Option, + pub setter: Vec, + pub sector: String, + pub grade: String, + grade_nr: u32, + /// set date as epoch timestamp in millis + pub set_date: usize, + // #[serde(with = "firestore::serialize_as_null")] + // pub removed: Option, + /// removed date as epoch timestamp in millis, 0 means not removed yet + pub removed: usize, + // #[serde(with = "firestore::serialize_as_null")] + // pub is_draft: Option, + pub is_draft: usize, + name: String, + // name: Option, +} + +impl fmt::Display for Boulder { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Boulder: {}", + serde_json::to_string_pretty(self).expect("serialisation should not fail") + ) + } +} + +impl Boulder { + pub fn in_setter(&self, setter: &ObjectId) -> bool { + self.setter.contains(setter) + } +} + +pub struct AccountsView {} + +impl AccountsView { + pub const COLLECTION: &str = "accounts_view"; + + pub async fn store( + state: &AppState, + gym: &String, + object_id: &ObjectId, + content: &Value, + ) -> Result<(), AppError> { + let parent_path = state.db.parent_path("gyms", gym)?; + let account = from_value::(content.clone()) + .map_err(|e| AppError::ParseError(format!("{e} in: {content}")))?; + + let _: Option = state + .db + .fluent() + .update() + .in_col(Self::COLLECTION) + .document_id(object_id.clone()) + .parent(parent_path) + .object(&account) + .execute() + .await?; + + Ok(()) + } +} + +pub struct BouldersView {} + +impl BouldersView { + pub const COLLECTION: &str = "boulders_view"; + + pub async fn store( + state: &AppState, + gym: &String, + object_id: &ObjectId, + content: &Value, + ) -> Result<(), AppError> { + let parent_path = state.db.parent_path("gyms", gym)?; + let boulder = from_value::(content.clone()) + .map_err(|e| AppError::ParseError(format!("{e} in: {content}")))?; + + let _: Option = state + .db + .fluent() + .update() + .in_col(Self::COLLECTION) + .document_id(object_id.clone()) + .parent(parent_path) + .object(&boulder) + .execute() + .await?; + + Ok(()) + } +} diff --git a/bin/all-o-stasis/src/types/object.rs b/bin/all-o-stasis/src/types/object.rs new file mode 100644 index 0000000..8f738ea --- /dev/null +++ b/bin/all-o-stasis/src/types/object.rs @@ -0,0 +1,144 @@ +use std::fmt; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{AppError, AppState, types::ObjectType}; +use otp::ObjectId; + +macro_rules! store { + ($state:expr, $gym:expr, $entity:expr, $collection:expr) => {{ + let parent_path = $state.db.parent_path("gyms", $gym)?; + let result = $state + .db + .fluent() + .insert() + .into($collection) + .generate_document_id() + .parent(&parent_path) + .object($entity) + .execute() + .await?; + + match &result { + Some(r) => tracing::debug!("storing: {r}"), + None => tracing::warn!("failed to store: {}", $entity), + } + + result + }}; +} + +// Object storage representation - used for Firestore serialization +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ObjectDoc { + #[serde(alias = "_firestore_id")] + id: Option, + #[serde(alias = "_firestore_created")] + created_at: Option>, + object_type: ObjectType, + created_by: ObjectId, + deleted: Option, +} + +impl ObjectDoc { + pub const COLLECTION: &str = "objects"; + + fn new(object_type: ObjectType) -> Self { + Self { + id: None, + object_type, + created_at: None, + created_by: otp::ROOT_OBJ_ID.to_owned(), + deleted: None, + } + } + + async fn lookup(state: &AppState, gym: &String, object_id: ObjectId) -> Result { + let parent_path = state.db.parent_path("gyms", gym)?; + state + .db + .fluent() + .select() + .by_id_in(ObjectDoc::COLLECTION) + .parent(&parent_path) + .obj() + .one(&object_id) + .await? + .ok_or(AppError::Query(format!( + "lookup_object: failed to get object {object_id}" + ))) + } + + pub async fn store(&self, state: &AppState, gym: &String) -> Result { + let s: Option = store!(state, gym, self, Self::COLLECTION); + s.ok_or(AppError::Query("storing object failed".to_string())) + } +} + +impl fmt::Display for ObjectDoc { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.id { + None => write!(f, "ObjectDoc: no id {}", self.object_type), + Some(id) => write!(f, "ObjectDoc: {id} {}", self.object_type), + } + } +} + +pub struct Object { + pub id: ObjectId, + pub created_at: DateTime, + pub object_type: ObjectType, + pub created_by: ObjectId, + #[allow(dead_code)] + pub deleted: bool, +} + +impl TryFrom for Object { + type Error = AppError; + + fn try_from(doc: ObjectDoc) -> Result { + Ok(Object { + id: doc + .id + .ok_or(AppError::Query("object doc is missing an id".to_string()))?, + created_at: doc.created_at.ok_or(AppError::Query( + "object doc is missing created_at".to_string(), + ))?, + object_type: doc.object_type, + created_by: doc.created_by, + deleted: doc.deleted.unwrap_or(false), + }) + } +} + +impl Object { + pub async fn new( + state: &AppState, + gym: &String, + object_type: &ObjectType, + ) -> Result { + let obj_doc = ObjectDoc::new(object_type.clone()) + .store(state, gym) + .await?; + let obj: Object = obj_doc.try_into()?; + Ok(obj) + } + + pub async fn lookup( + state: &AppState, + gym: &String, + object_id: &ObjectId, + ) -> Result { + let obj_doc = ObjectDoc::lookup(state, gym, object_id.clone()).await?; + let obj: Object = obj_doc.try_into()?; + Ok(obj) + } +} + +impl fmt::Display for Object { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Object: {} {}", self.id, self.object_type) + } +} diff --git a/bin/all-o-stasis/src/types/patch.rs b/bin/all-o-stasis/src/types/patch.rs new file mode 100644 index 0000000..93724b9 --- /dev/null +++ b/bin/all-o-stasis/src/types/patch.rs @@ -0,0 +1,162 @@ +use std::fmt; + +use chrono::{DateTime, Utc}; +use firestore::{FirestoreQueryDirection, FirestoreResult, path_camel_case}; +use futures::{TryStreamExt, stream::BoxStream}; +use otp::{ObjectId, Operation, RevId}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{AppError, AppState, types::Snapshot}; + +macro_rules! store { + ($state:expr, $gym:expr, $entity:expr, $collection:expr) => {{ + let parent_path = $state.db.parent_path("gyms", $gym)?; + let result = $state + .db + .fluent() + .insert() + .into($collection) + .generate_document_id() + .parent(&parent_path) + .object($entity) + .execute() + .await?; + + match &result { + Some(r) => tracing::debug!("storing: {r}"), + None => tracing::warn!("failed to store: {}", $entity), + } + + result + }}; +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Patch { + pub object_id: ObjectId, + pub revision_id: RevId, + pub author_id: ObjectId, + #[serde(alias = "_firestore_created")] + pub created_at: Option>, //Option, + pub operation: Operation, +} + +impl fmt::Display for Patch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Patch: {}@{} ops={}", + self.object_id, self.revision_id, self.operation + ) + } +} + +impl Patch { + pub const COLLECTION: &str = "patches"; + + pub fn new(object_id: ObjectId, author_id: String, value: &Value) -> Self { + let op = Operation::new_set(otp::ROOT_PATH.to_owned(), value.to_owned()); + Self { + object_id, + revision_id: otp::ZERO_REV_ID, + author_id, + created_at: None, + operation: op, + } + } + + pub fn new_revision( + revision_id: RevId, + object_id: ObjectId, + author_id: String, + operation: Operation, + ) -> Self { + Self { + object_id, + revision_id, + author_id, + created_at: None, + operation, + } + } + + pub async fn store(&self, state: &AppState, gym: &String) -> Result { + let s: Option = store!(state, gym, self, Self::COLLECTION); + s.ok_or(AppError::Query("storing patch failed".to_string())) + } + + /// lookup a patch with rev_id + pub async fn lookup( + state: &AppState, + gym: &String, + object_id: &ObjectId, + rev_id: RevId, // inclusive + ) -> Result { + let parent_path = state.db.parent_path("gyms", gym)?; + let patch_stream: BoxStream> = state + .db + .fluent() + .select() + .from(Self::COLLECTION) + .parent(&parent_path) + .filter(|q| { + q.for_all([ + q.field(path_camel_case!(Patch::object_id)) + .eq(object_id.clone()), + q.field(path_camel_case!(Patch::revision_id)).eq(rev_id), + ]) + }) + .limit(1) + .obj() + .stream_query_with_errors() + .await?; + + let mut patches: Vec = patch_stream.try_collect().await?; + if patches.len() != 1 { + return Err(AppError::Query(format!( + "lookup_patch found {} patches, expecting only 1", + patches.len() + ))); + } + let patch = patches.pop().unwrap(); + Ok(patch) + } + + pub async fn after_revision( + state: &AppState, + gym: &String, + obj_id: &ObjectId, + rev_id: RevId, + ) -> Result, AppError> { + let parent_path = state.db.parent_path("gyms", gym)?; + let object_stream: BoxStream> = state + .db + .fluent() + .select() + .from(Patch::COLLECTION) + .parent(&parent_path) + .filter(|q| { + q.for_all([ + q.field(path_camel_case!(Patch::object_id)).eq(obj_id), + q.field(path_camel_case!(Patch::revision_id)) + .greater_than(rev_id), + ]) + }) + .order_by([( + path_camel_case!(Snapshot::revision_id), + FirestoreQueryDirection::Ascending, + )]) + .obj() + .stream_query_with_errors() + .await?; + + let patches: Vec = object_stream.try_collect().await?; + tracing::debug!( + "patches after rev ({rev_id}): {}, obj = {obj_id}", + patches.len() + ); + Ok(patches) + } +} diff --git a/bin/all-o-stasis/src/types/snapshot.rs b/bin/all-o-stasis/src/types/snapshot.rs new file mode 100644 index 0000000..b1aca04 --- /dev/null +++ b/bin/all-o-stasis/src/types/snapshot.rs @@ -0,0 +1,210 @@ +use std::fmt; + +use firestore::{FirestoreQueryDirection, FirestoreResult, path_camel_case}; +use futures::{TryStreamExt, stream::BoxStream}; +use otp::{ObjectId, Operation, OtError, RevId, ZERO_REV_ID}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{AppError, AppState, types::patch::Patch}; + +macro_rules! store { + ($state:expr, $gym:expr, $entity:expr, $collection:expr) => {{ + let parent_path = $state.db.parent_path("gyms", $gym)?; + let result = $state + .db + .fluent() + .insert() + .into($collection) + .generate_document_id() + .parent(&parent_path) + .object($entity) + .execute() + .await?; + + match &result { + Some(r) => tracing::debug!("storing: {r}"), + None => tracing::warn!("failed to store: {}", $entity), + } + + result + }}; +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Snapshot { + pub object_id: ObjectId, + pub revision_id: RevId, + pub content: Value, +} + +impl Snapshot { + const COLLECTION: &str = "snapshots"; + + pub fn new(object_id: ObjectId) -> Self { + Self { + object_id, + // FIXME why is this not ZERO_REV_ID? + revision_id: -1, + content: json!({}), + } + } + + pub fn new_revision( + &self, + object_id: ObjectId, + author_id: ObjectId, + operation: Operation, + ) -> Result, OtError> { + assert_eq!(object_id, self.object_id); + + let content = operation.apply_to(self.content.to_owned())?; + if content == self.content { + tracing::debug!("skipping save operation: content did not change"); + return Ok(None); + } + + let revision_id = self.revision_id + 1; + let patch = Patch::new_revision(revision_id, object_id.clone(), author_id, operation); + Ok(Some(( + Self { + object_id, + revision_id, + content, + }, + patch, + ))) + } + + fn apply_patch(&self, patch: &Patch) -> Result { + // tracing::debug!("applying patch={patch} to {snapshot} results in snapshot={s}"); + Ok(Self { + object_id: self.object_id.to_owned(), + revision_id: patch.revision_id, + content: patch.operation.apply_to(self.content.clone())?, + }) + } + + pub fn apply_patches(&self, patches: &Vec) -> Result { + let mut s = self.clone(); + for patch in patches { + s = s.apply_patch(patch)?; + } + + Ok(s) + } + + pub async fn store(&self, state: &AppState, gym: &String) -> Result { + let s: Option = store!(state, gym, self, Self::COLLECTION); + s.ok_or(AppError::Query("storing snapshot failed".to_string())) + } + + /// lookup a snapshot with rev_id or lower and apply patches with revision <= rev_id if necessary + pub async fn lookup( + state: &AppState, + gym: &String, + obj_id: &ObjectId, + rev_id: RevId, // inclusive + ) -> Result { + let latest_snapshot = + Self::lookup_between(state, gym, obj_id, (ZERO_REV_ID, Some(rev_id))).await?; + + // get all patches which we need to apply on top of the snapshot to + // arrive at the desired revision + let patches: Vec = + Patch::after_revision(state, gym, obj_id, latest_snapshot.revision_id) + .await? + .into_iter() + .filter(|p| p.revision_id <= rev_id) + .collect(); + + // apply those patches to the snapshot + latest_snapshot.apply_patches(&patches) + } + + /// get latest available snapshot with object_id or create a new snapshot. apply unapplied + /// patches to get to the latest possible revision. + pub async fn lookup_latest( + state: &AppState, + gym: &String, + object_id: &ObjectId, + ) -> Result { + let latest_snapshot = + Snapshot::lookup_between(state, gym, object_id, (ZERO_REV_ID, None)).await?; + + // get all patches which we need to apply on top of the snapshot to + // arrive at the desired revision + let patches = + Patch::after_revision(state, gym, object_id, latest_snapshot.revision_id).await?; + + // apply those patches to the snapshot + latest_snapshot.apply_patches(&patches) + } + + /// get or create a latest snapshot between low and high (inclusive) + async fn lookup_between( + state: &AppState, + gym: &String, + object_id: &ObjectId, + range: (RevId, Option), + ) -> Result { + let parent_path = state.db.parent_path("gyms", gym)?; + let object_stream: BoxStream> = state + .db + .fluent() + .select() + .from(Self::COLLECTION) + .parent(&parent_path) + .filter(|q| { + q.for_all( + [ + Some(q.field(path_camel_case!(Snapshot::object_id)).eq(object_id)), + Some( + q.field(path_camel_case!(Snapshot::revision_id)) + .greater_than_or_equal(range.0), + ), + range.1.map(|h| { + q.field(path_camel_case!(Snapshot::revision_id)) + .less_than_or_equal(h) + }), + ] + .into_iter() + .flatten(), + ) + }) + .limit(1) + .order_by([( + path_camel_case!(Snapshot::revision_id), + FirestoreQueryDirection::Descending, + )]) + .obj() + .stream_query_with_errors() + .await?; + + let snapshots: Vec = object_stream.try_collect().await?; + tracing::debug!( + "snapshots ({} <= s <= {:?}): {} snapshots, obj={object_id}", + range.0, + range.1, + snapshots.len(), + ); + match snapshots.first() { + Some(snapshot) => Ok(snapshot.clone()), + None => { + // TODO we could already create the first snapshot on object creation? + Ok(Snapshot::new(object_id.clone()).store(state, gym).await?) + } + } + } +} + +impl fmt::Display for Snapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Snapshot: {}@{} content={}", + self.object_id, self.revision_id, self.content + ) + } +} From a957266c802686703aea40e155a05391ade6bcb5 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Thu, 25 Dec 2025 16:58:27 +0100 Subject: [PATCH 16/29] move listener --- bin/all-o-stasis/src/types/patch.rs | 58 ++++++++++++++++++++++++++++- bin/all-o-stasis/src/ws.rs | 58 +---------------------------- 2 files changed, 59 insertions(+), 57 deletions(-) diff --git a/bin/all-o-stasis/src/types/patch.rs b/bin/all-o-stasis/src/types/patch.rs index 93724b9..a129dd0 100644 --- a/bin/all-o-stasis/src/types/patch.rs +++ b/bin/all-o-stasis/src/types/patch.rs @@ -1,6 +1,13 @@ +use std::collections::hash_map::DefaultHasher; use std::fmt; +use std::hash::{Hash, Hasher}; +use std::net::SocketAddr; use chrono::{DateTime, Utc}; +use firestore::{ + FirestoreDb, FirestoreListener, FirestoreListenerTarget, FirestoreMemListenStateStorage, + ParentPathBuilder, +}; use firestore::{FirestoreQueryDirection, FirestoreResult, path_camel_case}; use futures::{TryStreamExt, stream::BoxStream}; use otp::{ObjectId, Operation, RevId}; @@ -32,6 +39,24 @@ macro_rules! store { }}; } +fn hash_addr(addr: &SocketAddr) -> u64 { + let mut hasher = DefaultHasher::new(); + // TODO hash addr.ip()? + + match addr { + SocketAddr::V4(v4) => { + v4.ip().octets().hash(&mut hasher); + v4.port().hash(&mut hasher); + } + SocketAddr::V6(v6) => { + v6.ip().octets().hash(&mut hasher); + v6.port().hash(&mut hasher); + } + } + + hasher.finish() +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Patch { @@ -54,7 +79,7 @@ impl fmt::Display for Patch { } impl Patch { - pub const COLLECTION: &str = "patches"; + const COLLECTION: &str = "patches"; pub fn new(object_id: ObjectId, author_id: String, value: &Value) -> Self { let op = Operation::new_set(otp::ROOT_PATH.to_owned(), value.to_owned()); @@ -159,4 +184,35 @@ impl Patch { ); Ok(patches) } + + pub async fn listener( + state: &AppState, + parent_path: &ParentPathBuilder, + who: SocketAddr, + ) -> Option> { + let client_id = hash_addr(&who) as u32; + let listener_id: FirestoreListenerTarget = FirestoreListenerTarget::new(client_id); + tracing::debug!("connection {who} gets firestore listener id: {client_id:?}"); + + // now start streaming patches using firestore listeners: https://github.com/abdolence/firestore-rs/blob/master/examples/listen-changes.rs + let mut listener = match state + .db + .create_listener(FirestoreMemListenStateStorage::new()) + .await + { + Ok(l) => l, + Err(..) => return None, + }; + + let _ = state + .db + .fluent() + .select() + .from(Patch::COLLECTION) + .parent(parent_path) + .listen() + .add_target(listener_id, &mut listener); + + Some(listener) + } } diff --git a/bin/all-o-stasis/src/ws.rs b/bin/all-o-stasis/src/ws.rs index 20ae052..4dd72a7 100644 --- a/bin/all-o-stasis/src/ws.rs +++ b/bin/all-o-stasis/src/ws.rs @@ -2,17 +2,12 @@ use crate::types::Patch; use axum::body::Bytes; use axum::extract::ws::{Message, Utf8Bytes, WebSocket}; use chrono::{DateTime, Utc}; -use firestore::{ - FirestoreDb, FirestoreListenEvent, FirestoreListener, FirestoreListenerTarget, - FirestoreMemListenStateStorage, ParentPathBuilder, -}; +use firestore::{FirestoreDb, FirestoreListenEvent, ParentPathBuilder}; use futures::stream::{SplitSink, SplitStream}; use futures::{SinkExt, StreamExt, TryStreamExt}; use otp::ObjectId; use serde::Serialize; -use std::collections::hash_map::DefaultHasher; use std::error::Error; -use std::hash::{Hash, Hasher}; use std::net::SocketAddr; use std::sync::Arc; use tokio::sync::{Mutex, mpsc, mpsc::Receiver, mpsc::Sender}; @@ -27,55 +22,6 @@ struct WsPatchResponse { ot_type: String, } -fn hash_addr(addr: &SocketAddr) -> u64 { - let mut hasher = DefaultHasher::new(); - - match addr { - SocketAddr::V4(v4) => { - v4.ip().octets().hash(&mut hasher); - v4.port().hash(&mut hasher); - } - SocketAddr::V6(v6) => { - v6.ip().octets().hash(&mut hasher); - v6.port().hash(&mut hasher); - } - } - - hasher.finish() -} - -async fn patch_listener( - state: AppState, - parent_path: ParentPathBuilder, - who: SocketAddr, -) -> Option> { - let client_id = hash_addr(&who) as u32; - let listener_id: FirestoreListenerTarget = FirestoreListenerTarget::new(client_id); - tracing::debug!("connection {who} gets firestore listener id: {client_id:?}"); - - // now start streaming patches using firestore listeners: https://github.com/abdolence/firestore-rs/blob/master/examples/listen-changes.rs - // do we have enough mem? - let mut listener = match state - .db - .create_listener(FirestoreMemListenStateStorage::new()) - .await - { - Ok(l) => l, - Err(..) => return None, - }; - - let _ = state - .db - .fluent() - .select() - .from(Patch::COLLECTION) - .parent(parent_path) - .listen() - .add_target(listener_id, &mut listener); - - Some(listener) -} - async fn handle_listener_event( event: FirestoreListenEvent, send_tx_patch: Sender, @@ -259,7 +205,7 @@ pub(crate) async fn handle_socket( // collect all objects ids the client wants to get notified about changes let subscriptions: Arc>> = Arc::new(Mutex::new(Vec::new())); - let mut listener = match patch_listener(state, parent_path, who).await { + let mut listener = match Patch::listener(&state, &parent_path, who).await { Some(listener) => listener, None => return, }; From 34ffbe3ce85a9cb3f625ad70bd3828592605e2eb Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Thu, 25 Dec 2025 17:09:19 +0100 Subject: [PATCH 17/29] dont expose ObjectDoc --- bin/all-o-stasis/src/routes/api.rs | 30 +++------------------------- bin/all-o-stasis/src/storage.rs | 20 ++----------------- bin/all-o-stasis/src/types/mod.rs | 2 +- bin/all-o-stasis/src/types/object.rs | 2 +- 4 files changed, 7 insertions(+), 47 deletions(-) diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index 078d149..2d3074c 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -4,7 +4,7 @@ use crate::passport::Session; use crate::session::{account_role, author_from_session}; use crate::storage::{apply_object_updates, create_object}; use crate::types::{ - AccountRole, Boulder, BouldersView, Object, ObjectDoc, ObjectType, Patch, Snapshot, + AccountRole, Boulder, BouldersView, Object, ObjectType, Patch, Snapshot, }; use crate::ws::handle_socket; use crate::{AppError, AppState}; @@ -105,30 +105,6 @@ struct LookupSessionResponse { obj_id: ObjectId, } -async fn object_type( - state: &AppState, - gym: &String, - object_id: ObjectId, -) -> Result { - let parent_path = state.db.parent_path("gyms", gym)?; - let object_doc: Option = state - .db - .fluent() - .select() - .by_id_in(ObjectDoc::COLLECTION) - .parent(&parent_path) - .obj() - .one(&object_id) - .await?; - - if let Some(doc) = object_doc { - let object: Object = doc.try_into()?; - Ok(object.object_type) - } else { - Err(AppError::NotAuthorized()) - } -} - async fn lookup_boulder( state: &AppState, gym: &String, @@ -314,8 +290,8 @@ async fn patch_object( return Err(AppError::NotAuthorized()); } - let ot_type = object_type(&state, &gym, id.clone()).await?; - match ot_type { + let object = Object::lookup(&state, &gym, &id).await?; + match object.object_type { ObjectType::Account => { if role == AccountRole::Setter { // only admins can change the role of an Account diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index f89902e..64ae70c 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -1,7 +1,7 @@ use crate::{ AppError, AppState, routes::PatchObjectResponse, - types::{AccountsView, BouldersView, Object, ObjectDoc, ObjectType, Patch, Snapshot}, + types::{AccountsView, BouldersView, Object, ObjectType, Patch, Snapshot}, }; use axum::Json; use otp::{ObjectId, Operation, RevId, rebase}; @@ -29,23 +29,7 @@ pub(crate) async fn update_view( object_id: &ObjectId, content: &Value, ) -> Result<(), AppError> { - let parent_path = state.db.parent_path("gyms", gym)?; - - // lookup object to find out what type it is - let obj: ObjectDoc = state - .db - .fluent() - .select() - .by_id_in(ObjectDoc::COLLECTION) - .parent(&parent_path) - .obj() - .one(&object_id) - .await? - .ok_or(AppError::Query(format!( - "update_view: failed to update view for {object_id}" - )))?; - - let obj: Object = obj.try_into()?; + let obj = Object::lookup(state, gym, object_id).await?; update_view_typed(state, gym, object_id, &obj.object_type, content).await } diff --git a/bin/all-o-stasis/src/types/mod.rs b/bin/all-o-stasis/src/types/mod.rs index 83183a3..f547a2b 100644 --- a/bin/all-o-stasis/src/types/mod.rs +++ b/bin/all-o-stasis/src/types/mod.rs @@ -10,7 +10,7 @@ pub mod object; pub mod patch; pub mod snapshot; -pub use object::{Object, ObjectDoc}; +pub use object::Object; pub use patch::Patch; pub use snapshot::Snapshot; diff --git a/bin/all-o-stasis/src/types/object.rs b/bin/all-o-stasis/src/types/object.rs index 8f738ea..7116d28 100644 --- a/bin/all-o-stasis/src/types/object.rs +++ b/bin/all-o-stasis/src/types/object.rs @@ -43,7 +43,7 @@ pub struct ObjectDoc { } impl ObjectDoc { - pub const COLLECTION: &str = "objects"; + const COLLECTION: &str = "objects"; fn new(object_type: ObjectType) -> Self { Self { From a3b3af1cb3ce728efced3c82355e94a3998f08e3 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Fri, 26 Dec 2025 07:21:35 +0100 Subject: [PATCH 18/29] store macro in mod --- bin/all-o-stasis/src/types/mod.rs | 44 ++++++++++++++++++++++++++ bin/all-o-stasis/src/types/object.rs | 28 +++------------- bin/all-o-stasis/src/types/patch.rs | 24 +------------- bin/all-o-stasis/src/types/snapshot.rs | 28 +++------------- 4 files changed, 53 insertions(+), 71 deletions(-) diff --git a/bin/all-o-stasis/src/types/mod.rs b/bin/all-o-stasis/src/types/mod.rs index f547a2b..46c9fbf 100644 --- a/bin/all-o-stasis/src/types/mod.rs +++ b/bin/all-o-stasis/src/types/mod.rs @@ -14,6 +14,30 @@ pub use object::Object; pub use patch::Patch; pub use snapshot::Snapshot; +macro_rules! store { + ($state:expr, $gym:expr, $entity:expr, $collection:expr) => {{ + let parent_path = $state.db.parent_path("gyms", $gym)?; + let result = $state + .db + .fluent() + .insert() + .into($collection) + .generate_document_id() + .parent(&parent_path) + .object($entity) + .execute() + .await?; + + match &result { + Some(r) => tracing::debug!("storing: {r}"), + None => tracing::warn!("failed to store: {}", $entity), + } + + result + }}; +} +pub(crate) use store; + #[derive(Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub enum AccountRole { @@ -89,6 +113,26 @@ impl Boulder { pub fn in_setter(&self, setter: &ObjectId) -> bool { self.setter.contains(setter) } + + pub async fn lookup( + state: &AppState, + gym: &String, + object_id: &ObjectId, + ) -> Result { + let parent_path = state.db.parent_path("gyms", gym)?; + state + .db + .fluent() + .select() + .by_id_in(BouldersView::COLLECTION) + .parent(&parent_path) + .obj() + .one(&object_id) + .await? + .ok_or(AppError::Query(format!( + "lookup_boulder: failed to get boulder {object_id}" + ))) + } } pub struct AccountsView {} diff --git a/bin/all-o-stasis/src/types/object.rs b/bin/all-o-stasis/src/types/object.rs index 7116d28..b4121a8 100644 --- a/bin/all-o-stasis/src/types/object.rs +++ b/bin/all-o-stasis/src/types/object.rs @@ -3,32 +3,12 @@ use std::fmt; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::{AppError, AppState, types::ObjectType}; +use crate::{ + AppError, AppState, + types::{ObjectType, store}, +}; use otp::ObjectId; -macro_rules! store { - ($state:expr, $gym:expr, $entity:expr, $collection:expr) => {{ - let parent_path = $state.db.parent_path("gyms", $gym)?; - let result = $state - .db - .fluent() - .insert() - .into($collection) - .generate_document_id() - .parent(&parent_path) - .object($entity) - .execute() - .await?; - - match &result { - Some(r) => tracing::debug!("storing: {r}"), - None => tracing::warn!("failed to store: {}", $entity), - } - - result - }}; -} - // Object storage representation - used for Firestore serialization #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] diff --git a/bin/all-o-stasis/src/types/patch.rs b/bin/all-o-stasis/src/types/patch.rs index a129dd0..6b42b3d 100644 --- a/bin/all-o-stasis/src/types/patch.rs +++ b/bin/all-o-stasis/src/types/patch.rs @@ -14,31 +14,9 @@ use otp::{ObjectId, Operation, RevId}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::types::store; use crate::{AppError, AppState, types::Snapshot}; -macro_rules! store { - ($state:expr, $gym:expr, $entity:expr, $collection:expr) => {{ - let parent_path = $state.db.parent_path("gyms", $gym)?; - let result = $state - .db - .fluent() - .insert() - .into($collection) - .generate_document_id() - .parent(&parent_path) - .object($entity) - .execute() - .await?; - - match &result { - Some(r) => tracing::debug!("storing: {r}"), - None => tracing::warn!("failed to store: {}", $entity), - } - - result - }}; -} - fn hash_addr(addr: &SocketAddr) -> u64 { let mut hasher = DefaultHasher::new(); // TODO hash addr.ip()? diff --git a/bin/all-o-stasis/src/types/snapshot.rs b/bin/all-o-stasis/src/types/snapshot.rs index b1aca04..b3c5cfa 100644 --- a/bin/all-o-stasis/src/types/snapshot.rs +++ b/bin/all-o-stasis/src/types/snapshot.rs @@ -6,30 +6,10 @@ use otp::{ObjectId, Operation, OtError, RevId, ZERO_REV_ID}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use crate::{AppError, AppState, types::patch::Patch}; - -macro_rules! store { - ($state:expr, $gym:expr, $entity:expr, $collection:expr) => {{ - let parent_path = $state.db.parent_path("gyms", $gym)?; - let result = $state - .db - .fluent() - .insert() - .into($collection) - .generate_document_id() - .parent(&parent_path) - .object($entity) - .execute() - .await?; - - match &result { - Some(r) => tracing::debug!("storing: {r}"), - None => tracing::warn!("failed to store: {}", $entity), - } - - result - }}; -} +use crate::{ + AppError, AppState, + types::{patch::Patch, store}, +}; #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] From 3eb1cbafa2a463ced0a77ef9a737493bd467561c Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Fri, 26 Dec 2025 07:44:01 +0100 Subject: [PATCH 19/29] queries on boulder view --- bin/all-o-stasis/src/routes/api.rs | 29 +------ bin/all-o-stasis/src/routes/collection.rs | 65 ++------------- bin/all-o-stasis/src/routes/stats.rs | 20 +---- bin/all-o-stasis/src/types/mod.rs | 99 ++++++++++++++++++++++- 4 files changed, 108 insertions(+), 105 deletions(-) diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index 2d3074c..a30c89f 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -3,9 +3,7 @@ use std::net::SocketAddr; use crate::passport::Session; use crate::session::{account_role, author_from_session}; use crate::storage::{apply_object_updates, create_object}; -use crate::types::{ - AccountRole, Boulder, BouldersView, Object, ObjectType, Patch, Snapshot, -}; +use crate::types::{AccountRole, Boulder, Object, ObjectType, Patch, Snapshot}; use crate::ws::handle_socket; use crate::{AppError, AppState}; use axum::{ @@ -105,29 +103,6 @@ struct LookupSessionResponse { obj_id: ObjectId, } -async fn lookup_boulder( - state: &AppState, - gym: &String, - object_id: &ObjectId, -) -> Result { - let parent_path = state.db.parent_path("gyms", gym)?; - let boulder: Option = state - .db - .fluent() - .select() - .by_id_in(BouldersView::COLLECTION) - .parent(&parent_path) - .obj() - .one(&object_id) - .await?; - - if let Some(boulder) = boulder { - Ok(boulder) - } else { - Err(AppError::NotAuthorized()) - } -} - pub fn routes() -> Router { Router::new() .route("/{gym}/session", get(lookup_session)) @@ -311,7 +286,7 @@ async fn patch_object( } } ObjectType::Boulder => { - let boulder = lookup_boulder(&state, &gym, &id).await?; + let boulder = Boulder::lookup(&state, &gym, &id).await?; if boulder.is_draft > 0 { // drafts can be edited by any admin/setter } else { diff --git a/bin/all-o-stasis/src/routes/collection.rs b/bin/all-o-stasis/src/routes/collection.rs index 9240d70..4a4ed20 100644 --- a/bin/all-o-stasis/src/routes/collection.rs +++ b/bin/all-o-stasis/src/routes/collection.rs @@ -5,7 +5,7 @@ use axum::{ extract::{Path, State}, }; use axum_extra::extract::CookieJar; -use firestore::{FirestoreQueryDirection, FirestoreResult, path_camel_case}; +use firestore::{FirestoreResult, path_camel_case}; use futures::TryStreamExt; use futures::stream::BoxStream; use otp::ObjectId; @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::session::author_from_session; -use crate::types::{Account, AccountRole, AccountsView, Boulder, BouldersView, Snapshot}; +use crate::types::{Account, AccountRole, AccountsView, BouldersView, Snapshot}; use crate::{AppError, AppState}; #[derive(Serialize, Deserialize, Debug)] @@ -70,30 +70,9 @@ async fn active_boulders( State(state): State, Path(gym): Path, ) -> Result>, AppError> { - let parent_path = state.db.parent_path("gyms", gym)?; - let object_stream: BoxStream> = state - .db - .fluent() - .select() - .from(BouldersView::COLLECTION) - .parent(&parent_path) - .filter(|q| { - q.for_all([ - q.field(path_camel_case!(Boulder::removed)).eq(0), - q.field(path_camel_case!(Boulder::is_draft)).eq(0), - ]) - }) - .order_by([( - path_camel_case!(Boulder::set_date), - FirestoreQueryDirection::Descending, - )]) - .obj() - .stream_query_with_errors() - .await?; - - let as_vec: Vec = object_stream.try_collect().await?; + let boulders = BouldersView::active(&state, &gym).await?; Ok(Json( - as_vec + boulders .into_iter() .map(|b| b.id.expect("object in view has no id")) // TODO no panic .collect(), @@ -104,27 +83,7 @@ async fn draft_boulders( State(state): State, Path(gym): Path, ) -> Result>, AppError> { - let parent_path = state.db.parent_path("gyms", gym)?; - // XXX we used to have a separate collection for draft boulders but never used it in the (old) - // code. Here we choose to follow the old implementation and do not add a collection for draft - // boulders. - let object_stream: BoxStream> = state - .db - .fluent() - .select() - .from(BouldersView::COLLECTION) - .parent(&parent_path) - .filter(|q| { - q.for_all([ - q.field(path_camel_case!(Boulder::removed)).eq(0), - q.field(path_camel_case!(Boulder::is_draft)).neq(0), - ]) - }) - .obj() - .stream_query_with_errors() - .await?; - - let as_vec: Vec = object_stream.try_collect().await?; + let as_vec = BouldersView::drafts(&state, &gym).await?; Ok(Json( as_vec .into_iter() @@ -145,19 +104,7 @@ async fn own_boulders( // return Ok(Json(Vec::new())); // } - let parent_path = state.db.parent_path("gyms", gym)?; - let object_stream: BoxStream> = state - .db - .fluent() - .select() - .from(BouldersView::COLLECTION) - .parent(&parent_path) - .filter(|q| q.for_all([q.field(path_camel_case!(Boulder::id)).eq(own.to_owned())])) - .obj() - .stream_query_with_errors() - .await?; - - let as_vec: Vec = object_stream.try_collect().await?; + let as_vec = BouldersView::with_id(&state, &gym, own).await?; Ok(Json( as_vec .into_iter() diff --git a/bin/all-o-stasis/src/routes/stats.rs b/bin/all-o-stasis/src/routes/stats.rs index b46da52..ecbb832 100644 --- a/bin/all-o-stasis/src/routes/stats.rs +++ b/bin/all-o-stasis/src/routes/stats.rs @@ -3,12 +3,9 @@ use axum::extract::{Path, State}; use axum::response::Json; use axum::routing::get; use chrono::DateTime; -use firestore::{FirestoreResult, path_camel_case}; -use futures::TryStreamExt; -use futures::stream::BoxStream; use serde::{Deserialize, Serialize}; -use crate::types::{Boulder, BouldersView}; +use crate::types::BouldersView; use crate::{AppError, AppState}; #[derive(Serialize, Deserialize, Debug)] @@ -34,20 +31,7 @@ async fn stats_boulders( State(state): State, Path(gym): Path, ) -> Result>, AppError> { - let parent_path = state.db.parent_path("gyms", gym)?; - // TODO this is too expensive: we read all records to compute the stats - let object_stream: BoxStream> = state - .db - .fluent() - .select() - .from(BouldersView::COLLECTION) - .parent(&parent_path) - .filter(|q| q.for_all([q.field(path_camel_case!(Boulder::is_draft)).eq(0)])) - .obj() - .stream_query_with_errors() - .await?; - - let as_vec: Vec = object_stream.try_collect().await?; + let as_vec = BouldersView::stats(&state, &gym).await?; let stats: Vec = as_vec .into_iter() .map(|b| BoulderStat { diff --git a/bin/all-o-stasis/src/types/mod.rs b/bin/all-o-stasis/src/types/mod.rs index 46c9fbf..f68cb1f 100644 --- a/bin/all-o-stasis/src/types/mod.rs +++ b/bin/all-o-stasis/src/types/mod.rs @@ -1,5 +1,8 @@ use std::fmt; +use firestore::{FirestoreQueryDirection, FirestoreResult, path_camel_case}; +use futures::TryStreamExt; +use futures::stream::BoxStream; use otp::ObjectId; use serde::{Deserialize, Serialize}; use serde_json::{Value, from_value}; @@ -168,7 +171,7 @@ impl AccountsView { pub struct BouldersView {} impl BouldersView { - pub const COLLECTION: &str = "boulders_view"; + const COLLECTION: &str = "boulders_view"; pub async fn store( state: &AppState, @@ -193,4 +196,98 @@ impl BouldersView { Ok(()) } + + pub async fn active(state: &AppState, gym: &String) -> Result, AppError> { + let parent_path = state.db.parent_path("gyms", gym)?; + let object_stream: BoxStream> = state + .db + .fluent() + .select() + .from(Self::COLLECTION) + .parent(&parent_path) + .filter(|q| { + q.for_all([ + q.field(path_camel_case!(Boulder::removed)).eq(0), + q.field(path_camel_case!(Boulder::is_draft)).eq(0), + ]) + }) + .order_by([( + path_camel_case!(Boulder::set_date), + FirestoreQueryDirection::Descending, + )]) + .obj() + .stream_query_with_errors() + .await?; + + let boulders: Vec = object_stream.try_collect().await?; + Ok(boulders) + } + + pub async fn with_id( + state: &AppState, + gym: &String, + object_id: ObjectId, + ) -> Result, AppError> { + let parent_path = state.db.parent_path("gyms", gym)?; + let object_stream: BoxStream> = state + .db + .fluent() + .select() + .from(Self::COLLECTION) + .parent(&parent_path) + .filter(|q| { + q.for_all([q + .field(path_camel_case!(Boulder::id)) + .eq(object_id.to_owned())]) + }) + .obj() + .stream_query_with_errors() + .await?; + + let as_vec: Vec = object_stream.try_collect().await?; + Ok(as_vec) + } + + pub async fn drafts(state: &AppState, gym: &String) -> Result, AppError> { + let parent_path = state.db.parent_path("gyms", gym)?; + // XXX we used to have a separate collection for draft boulders but never used it in the (old) + // code. Here we choose to follow the old implementation and do not add a collection for draft + // boulders. + let object_stream: BoxStream> = state + .db + .fluent() + .select() + .from(Self::COLLECTION) + .parent(&parent_path) + .filter(|q| { + q.for_all([ + q.field(path_camel_case!(Boulder::removed)).eq(0), + q.field(path_camel_case!(Boulder::is_draft)).neq(0), + ]) + }) + .obj() + .stream_query_with_errors() + .await?; + + let as_vec: Vec = object_stream.try_collect().await?; + Ok(as_vec) + } + + pub async fn stats(state: &AppState, gym: &String) -> Result, AppError> { + let parent_path = state.db.parent_path("gyms", gym)?; + // TODO this is too expensive: we read all records to compute the stats + let object_stream: BoxStream> = state + .db + .fluent() + .select() + .from(BouldersView::COLLECTION) + .parent(&parent_path) + .filter(|q| q.for_all([q.field(path_camel_case!(Boulder::is_draft)).eq(0)])) + .obj() + .stream_query_with_errors() + .await?; + + let as_vec: Vec = object_stream.try_collect().await?; + Ok(as_vec) + } } From 291ab122ef6373ec7af6e19a9f1122ab998cf2f9 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Fri, 26 Dec 2025 08:05:01 +0100 Subject: [PATCH 20/29] move account queries --- bin/all-o-stasis/src/passport.rs | 22 +---- bin/all-o-stasis/src/routes/collection.rs | 36 +------ bin/all-o-stasis/src/session.rs | 47 +-------- bin/all-o-stasis/src/types/mod.rs | 113 +++++++++++++++++++++- 4 files changed, 119 insertions(+), 99 deletions(-) diff --git a/bin/all-o-stasis/src/passport.rs b/bin/all-o-stasis/src/passport.rs index a5729fe..7a13cbf 100644 --- a/bin/all-o-stasis/src/passport.rs +++ b/bin/all-o-stasis/src/passport.rs @@ -272,27 +272,9 @@ async fn create_passport( Path(gym): Path, Json(payload): axum::extract::Json, ) -> Result, AppError> { - let parent_path = state.db.parent_path("gyms", gym.clone())?; - // 1. Lookup account by email. If no such account exists, create a new one - let account_stream: BoxStream> = state - .db - .fluent() - .select() - .from(AccountsView::COLLECTION) - .parent(&parent_path) - .filter(|q| { - q.for_all([q - .field(path_camel_case!(Account::email)) - .eq(payload.email.clone())]) - }) - .limit(1) - .obj() - .stream_query_with_errors() - .await?; - - let accounts: Vec = account_stream.try_collect().await?; - let maybe_account_id: Result = match accounts.first() { + let account = AccountsView::with_email(&state, gym.clone(), payload.email.clone()).await?; + let maybe_account_id: Result = match account { Some(account) => Ok(account.id.clone().expect("object has no id")), None => { let account = Account { diff --git a/bin/all-o-stasis/src/routes/collection.rs b/bin/all-o-stasis/src/routes/collection.rs index 4a4ed20..8f042b5 100644 --- a/bin/all-o-stasis/src/routes/collection.rs +++ b/bin/all-o-stasis/src/routes/collection.rs @@ -5,15 +5,12 @@ use axum::{ extract::{Path, State}, }; use axum_extra::extract::CookieJar; -use firestore::{FirestoreResult, path_camel_case}; -use futures::TryStreamExt; -use futures::stream::BoxStream; use otp::ObjectId; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::session::author_from_session; -use crate::types::{Account, AccountRole, AccountsView, BouldersView, Snapshot}; +use crate::types::{Account, AccountsView, BouldersView, Snapshot}; use crate::{AppError, AppState}; #[derive(Serialize, Deserialize, Debug)] @@ -117,18 +114,7 @@ async fn accounts( State(state): State, Path(gym): Path, ) -> Result>, AppError> { - let parent_path = state.db.parent_path("gyms", gym)?; - let object_stream: BoxStream> = state - .db - .fluent() - .select() - .from(AccountsView::COLLECTION) - .parent(&parent_path) - .obj() - .stream_query_with_errors() - .await?; - - let as_vec: Vec = object_stream.try_collect().await?; + let as_vec = AccountsView::all(&state, &gym).await?; Ok(Json( as_vec .into_iter() @@ -141,23 +127,7 @@ async fn admin_accounts( State(state): State, Path(gym): Path, ) -> Result>, AppError> { - let parent_path = state.db.parent_path("gyms", gym)?; - let object_stream: BoxStream> = state - .db - .fluent() - .select() - .from(AccountsView::COLLECTION) - .parent(&parent_path) - .filter(|q| { - q.for_all([q - .field(path_camel_case!(Account::role)) - .neq(AccountRole::User)]) - }) - .obj() - .stream_query_with_errors() - .await?; - - let as_vec: Vec = object_stream.try_collect().await?; + let as_vec = AccountsView::admins(&state, &gym).await?; Ok(Json( as_vec .into_iter() diff --git a/bin/all-o-stasis/src/session.rs b/bin/all-o-stasis/src/session.rs index 423fe99..9178447 100644 --- a/bin/all-o-stasis/src/session.rs +++ b/bin/all-o-stasis/src/session.rs @@ -1,5 +1,5 @@ use crate::passport::Session; -use crate::types::{Account, AccountRole, AccountsView}; +use crate::types::{AccountRole, AccountsView}; use crate::{AppError, AppState}; use axum_extra::extract::cookie::Cookie; use otp::ObjectId; @@ -71,47 +71,6 @@ pub(crate) async fn account_role( gym: &String, object_id: &ObjectId, ) -> Result { - let parent_path = state.db.parent_path("gyms", gym)?; - let account: Option = state - .db - .fluent() - .select() - .by_id_in(AccountsView::COLLECTION) - .parent(&parent_path) - .obj() - .one(object_id) - .await?; - - if let Some(account) = account { - Ok(account.role) - } else { - Err(AppError::NotAuthorized()) - } + let account = AccountsView::with_id(state, gym, object_id.clone()).await?; + Ok(account.role) } - -// pub(crate) async fn account_role( -// state: &AppState, -// gym: &String, -// object_id: ObjectId, -// ) -> Result { -// let parent_path = state.db.parent_path("gyms", gym)?; -// if let Some(object_id) = object_id { -// let account: Option = state -// .db -// .fluent() -// .select() -// .by_id_in(ACCOUNTS_VIEW_COLLECTION) -// .parent(&parent_path) -// .obj() -// .one(object_id) -// .await?; -// -// if let Some(account) = account { -// Ok(account.role) -// } else { -// Err(AppError::NotAuthorized()) -// } -// } else { -// return Ok(AccountRole::User); -// } -// } diff --git a/bin/all-o-stasis/src/types/mod.rs b/bin/all-o-stasis/src/types/mod.rs index f68cb1f..dc4dc5a 100644 --- a/bin/all-o-stasis/src/types/mod.rs +++ b/bin/all-o-stasis/src/types/mod.rs @@ -141,7 +141,7 @@ impl Boulder { pub struct AccountsView {} impl AccountsView { - pub const COLLECTION: &str = "accounts_view"; + const COLLECTION: &str = "accounts_view"; pub async fn store( state: &AppState, @@ -166,6 +166,84 @@ impl AccountsView { Ok(()) } + + pub async fn all(state: &AppState, gym: &String) -> Result, AppError> { + let parent_path = state.db.parent_path("gyms", gym)?; + let object_stream: BoxStream> = state + .db + .fluent() + .select() + .from(Self::COLLECTION) + .parent(&parent_path) + .obj() + .stream_query_with_errors() + .await?; + let as_vec = object_stream.try_collect().await?; + Ok(as_vec) + } + + pub async fn admins(state: &AppState, gym: &String) -> Result, AppError> { + let parent_path = state.db.parent_path("gyms", gym)?; + let object_stream: BoxStream> = state + .db + .fluent() + .select() + .from(AccountsView::COLLECTION) + .parent(&parent_path) + .filter(|q| { + q.for_all([q + .field(path_camel_case!(Account::role)) + .neq(AccountRole::User)]) + }) + .obj() + .stream_query_with_errors() + .await?; + let as_vec = object_stream.try_collect().await?; + Ok(as_vec) + } + + pub async fn with_email( + state: &AppState, + gym: String, + email: String, + ) -> Result, AppError> { + let parent_path = state.db.parent_path("gyms", gym.clone())?; + + let account_stream: BoxStream> = state + .db + .fluent() + .select() + .from(Self::COLLECTION) + .parent(&parent_path) + .filter(|q| q.for_all([q.field(path_camel_case!(Account::email)).eq(email.clone())])) + .limit(1) + .obj() + .stream_query_with_errors() + .await?; + + let mut accounts: Vec = account_stream.try_collect().await?; + Ok(accounts.pop()) + } + + pub async fn with_id( + state: &AppState, + gym: &String, + object_id: ObjectId, + ) -> Result { + let parent_path = state.db.parent_path("gyms", gym)?; + state + .db + .fluent() + .select() + .by_id_in(Self::COLLECTION) + .parent(&parent_path) + .obj() + .one(object_id.clone()) + .await? + .ok_or(AppError::Query(format!( + "lookup accounts view: failed to get object {object_id}" + ))) + } } pub struct BouldersView {} @@ -280,7 +358,7 @@ impl BouldersView { .db .fluent() .select() - .from(BouldersView::COLLECTION) + .from(Self::COLLECTION) .parent(&parent_path) .filter(|q| q.for_all([q.field(path_camel_case!(Boulder::is_draft)).eq(0)])) .obj() @@ -290,4 +368,35 @@ impl BouldersView { let as_vec: Vec = object_stream.try_collect().await?; Ok(as_vec) } + + // pub async fn collect( + // state: &AppState, + // gym: &String, + // removed: Option, + // is_draft: Option, + // ) -> Result, AppError> { + // let parent_path = state.db.parent_path("gyms", gym)?; + // let object_stream: BoxStream> = state + // .db + // .fluent() + // .select() + // .from(Self::COLLECTION) + // .parent(&parent_path) + // .filter(|q| { + // q.for_all( + // [ + // removed.map(|r| q.field(path_camel_case!(Boulder::removed)).eq(r)), + // is_draft.map(|d| q.field(path_camel_case!(Boulder::is_draft)).eq(d)), + // ] + // .into_iter() + // .flatten(), + // ) + // }) + // .obj() + // .stream_query_with_errors() + // .await?; + // + // let as_vec: Vec = object_stream.try_collect().await?; + // Ok(as_vec) + // } } From 0a28022d71ab53c31b17940ea8da5856816c09ab Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Fri, 26 Dec 2025 08:32:23 +0100 Subject: [PATCH 21/29] move session queries --- bin/all-o-stasis/src/passport.rs | 34 ++++++++++++- bin/all-o-stasis/src/routes/api.rs | 32 +++--------- bin/all-o-stasis/src/routes/collection.rs | 2 +- bin/all-o-stasis/src/session.rs | 60 ++--------------------- 4 files changed, 44 insertions(+), 84 deletions(-) diff --git a/bin/all-o-stasis/src/passport.rs b/bin/all-o-stasis/src/passport.rs index 7a13cbf..498743b 100644 --- a/bin/all-o-stasis/src/passport.rs +++ b/bin/all-o-stasis/src/passport.rs @@ -141,7 +141,25 @@ impl fmt::Display for Session { } impl Session { - pub const COLLECTION: &str = "sessions"; + const COLLECTION: &str = "sessions"; + + pub async fn lookup( + state: &AppState, + gym: &String, + object_id: ObjectId, + ) -> Result { + let parent_path = state.db.parent_path("gyms", gym)?; + state + .db + .fluent() + .select() + .by_id_in(Self::COLLECTION) + .parent(&parent_path) + .obj() + .one(&object_id) + .await? + .ok_or(AppError::NoSession()) + } pub async fn store( &self, @@ -172,6 +190,20 @@ impl Session { } } } + + pub async fn delete(state: &AppState, gym: &String, session_id: &str) -> Result<(), AppError> { + let parent_path = state.db.parent_path("gyms", gym)?; + state + .db + .fluent() + .delete() + .from(Self::COLLECTION) + .parent(&parent_path) + .document_id(session_id) + .execute() + .await?; + Ok(()) + } } #[derive(Serialize, Deserialize)] diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index a30c89f..f02c3bc 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -122,24 +122,14 @@ async fn delete_session( Path(gym): Path, jar: CookieJar, ) -> Result { - let parent_path = state.db.parent_path("gyms", gym)?; let session_id = jar .get("session") .ok_or(AppError::NoSession())? .value() .to_owned(); + Session::delete(&state, &gym, &session_id).await?; - state - .db - .fluent() - .delete() - .from(Session::COLLECTION) - .parent(&parent_path) - .document_id(&session_id) - .execute() - .await?; - - let cookie = Cookie::build(("session", session_id.clone())) + let cookie = Cookie::build(("session", session_id)) .path("/") .max_age(Duration::seconds(0)) .secure(true) @@ -153,23 +143,13 @@ async fn lookup_session( Path(gym): Path, jar: CookieJar, ) -> Result { - let parent_path = state.db.parent_path("gyms", gym)?; // TODO NoSession correct here? let session_id = jar .get("session") .ok_or(AppError::NoSession())? .value() .to_owned(); - let session: Session = state - .db - .fluent() - .select() - .by_id_in(Session::COLLECTION) - .parent(&parent_path) - .obj() - .one(&session_id) - .await? - .ok_or(AppError::NoSession())?; + let session = Session::lookup(&state, &gym, session_id.clone()).await?; let cookie = Cookie::build(("session", session_id.clone())) .path("/") @@ -192,7 +172,7 @@ async fn new_object( jar: CookieJar, Json(payload): axum::extract::Json, ) -> Result, AppError> { - let session_id = jar.get("session"); + let session_id = jar.get("session").ok_or(AppError::NoSession())?; let created_by = author_from_session(&state, &gym, session_id).await?; // unauthorized users should be able to create accounts @@ -232,7 +212,7 @@ async fn lookup_object( return Ok(response); } - let session_id = jar.get("session"); + let session_id = jar.get("session").ok_or(AppError::NoSession())?; let created_by = author_from_session(&state, &gym, session_id).await?; // otherwise just object the owner owns @@ -255,7 +235,7 @@ async fn patch_object( jar: CookieJar, Json(payload): axum::extract::Json, ) -> Result, AppError> { - let session_id = jar.get("session"); + let session_id = jar.get("session").ok_or(AppError::NoSession())?; let created_by = author_from_session(&state, &gym, session_id).await?; // users cant patch atm diff --git a/bin/all-o-stasis/src/routes/collection.rs b/bin/all-o-stasis/src/routes/collection.rs index 8f042b5..12b68af 100644 --- a/bin/all-o-stasis/src/routes/collection.rs +++ b/bin/all-o-stasis/src/routes/collection.rs @@ -94,7 +94,7 @@ async fn own_boulders( Path(gym): Path, jar: CookieJar, ) -> Result>, AppError> { - let session_id = jar.get("session"); + let session_id = jar.get("session").ok_or(AppError::NoSession())?; let own = author_from_session(&state, &gym, session_id).await?; // TODO not sure if it is okay to return NotAuthorized // if own == ROOT_OBJ_ID { diff --git a/bin/all-o-stasis/src/session.rs b/bin/all-o-stasis/src/session.rs index 9178447..aa819e7 100644 --- a/bin/all-o-stasis/src/session.rs +++ b/bin/all-o-stasis/src/session.rs @@ -4,66 +4,14 @@ use crate::{AppError, AppState}; use axum_extra::extract::cookie::Cookie; use otp::ObjectId; -// pub(crate) async fn author_from_session( -// state: &AppState, -// gym: &String, -// session_id: Option<&Cookie<'static>>, -// ) -> Result, AppError> { -// let session_id = if let Some(session_id) = session_id { -// session_id.value().to_owned() -// } else { -// return Ok(None); -// // FIXME why do we allow this? -// // return Ok(String::from("")); -// }; -// -// let parent_path = state.db.parent_path("gyms", gym)?; -// let session: Option = state -// .db -// .fluent() -// .select() -// .by_id_in(SESSIONS_COLLECTION) -// .parent(&parent_path) -// .obj() -// .one(&session_id) -// .await?; -// -// if let Some(session) = session { -// Ok(Some(session.obj_id)) -// } else { -// Err(AppError::NotAuthorized()) -// } -// } - pub(crate) async fn author_from_session( state: &AppState, gym: &String, - session_id: Option<&Cookie<'static>>, + session_id: &Cookie<'static>, ) -> Result { - let session_id = if let Some(session_id) = session_id { - session_id.value().to_owned() - } else { - // FIXME why do we allow this? - // should be option? - return Ok(String::from("")); - }; - - let parent_path = state.db.parent_path("gyms", gym)?; - let session: Option = state - .db - .fluent() - .select() - .by_id_in(Session::COLLECTION) - .parent(&parent_path) - .obj() - .one(&session_id) - .await?; - - if let Some(session) = session { - Ok(session.obj_id) - } else { - Err(AppError::NotAuthorized()) - } + let session_id = session_id.value().to_owned(); + let session = Session::lookup(state, gym, session_id).await?; + Ok(session.obj_id) } pub(crate) async fn account_role( From 66af0a61966389368743a5a97281486ca9383083 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Fri, 26 Dec 2025 15:23:25 +0100 Subject: [PATCH 22/29] cleanup --- bin/all-o-stasis/src/routes/api.rs | 8 ++------ bin/all-o-stasis/src/storage.rs | 6 +----- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index f02c3bc..1dd4272 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -83,14 +83,10 @@ pub struct PatchObjectResponse { } impl PatchObjectResponse { - pub fn new( - previous_patches: Vec, - num_processed_operations: usize, - resulting_patches: Vec, - ) -> Self { + pub fn new(previous_patches: Vec, resulting_patches: Vec) -> Self { Self { previous_patches, - num_processed_operations, + num_processed_operations: resulting_patches.len(), resulting_patches, } } diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index 64ae70c..a520539 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -103,11 +103,7 @@ pub async fn apply_object_updates( ) .await?; - Ok(Json(PatchObjectResponse::new( - previous_patches, - patches.len(), - patches, - ))) + Ok(Json(PatchObjectResponse::new(previous_patches, patches))) } struct SaveOp { From 05f364eaf10782e775453849912b8303e693d788 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Fri, 26 Dec 2025 15:29:27 +0100 Subject: [PATCH 23/29] remove session --- bin/all-o-stasis/src/main.rs | 1 - bin/all-o-stasis/src/passport.rs | 10 ++++++++++ bin/all-o-stasis/src/routes/api.rs | 14 ++++++++++--- bin/all-o-stasis/src/routes/collection.rs | 2 +- bin/all-o-stasis/src/session.rs | 24 ----------------------- 5 files changed, 22 insertions(+), 29 deletions(-) delete mode 100644 bin/all-o-stasis/src/session.rs diff --git a/bin/all-o-stasis/src/main.rs b/bin/all-o-stasis/src/main.rs index 60ad771..71b2f6f 100644 --- a/bin/all-o-stasis/src/main.rs +++ b/bin/all-o-stasis/src/main.rs @@ -12,7 +12,6 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod passport; mod routes; -mod session; mod storage; mod types; mod word_list; diff --git a/bin/all-o-stasis/src/passport.rs b/bin/all-o-stasis/src/passport.rs index 498743b..86a1207 100644 --- a/bin/all-o-stasis/src/passport.rs +++ b/bin/all-o-stasis/src/passport.rs @@ -118,6 +118,16 @@ mod maileroo { pub type SessionId = String; +pub(crate) async fn author_from_session( + state: &AppState, + gym: &String, + session_id: &Cookie<'static>, +) -> Result { + let session_id = session_id.value().to_owned(); + let session = Session::lookup(state, gym, session_id).await?; + Ok(session.obj_id) +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct Session { diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index 1dd4272..4ba8d13 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -1,9 +1,8 @@ use std::net::SocketAddr; -use crate::passport::Session; -use crate::session::{account_role, author_from_session}; +use crate::passport::{Session, author_from_session}; use crate::storage::{apply_object_updates, create_object}; -use crate::types::{AccountRole, Boulder, Object, ObjectType, Patch, Snapshot}; +use crate::types::{AccountRole, AccountsView, Boulder, Object, ObjectType, Patch, Snapshot}; use crate::ws::handle_socket; use crate::{AppError, AppState}; use axum::{ @@ -99,6 +98,15 @@ struct LookupSessionResponse { obj_id: ObjectId, } +async fn account_role( + state: &AppState, + gym: &String, + object_id: &ObjectId, +) -> Result { + let account = AccountsView::with_id(state, gym, object_id.clone()).await?; + Ok(account.role) +} + pub fn routes() -> Router { Router::new() .route("/{gym}/session", get(lookup_session)) diff --git a/bin/all-o-stasis/src/routes/collection.rs b/bin/all-o-stasis/src/routes/collection.rs index 12b68af..a0e0924 100644 --- a/bin/all-o-stasis/src/routes/collection.rs +++ b/bin/all-o-stasis/src/routes/collection.rs @@ -9,7 +9,7 @@ use otp::ObjectId; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::session::author_from_session; +use crate::passport::author_from_session; use crate::types::{Account, AccountsView, BouldersView, Snapshot}; use crate::{AppError, AppState}; diff --git a/bin/all-o-stasis/src/session.rs b/bin/all-o-stasis/src/session.rs deleted file mode 100644 index aa819e7..0000000 --- a/bin/all-o-stasis/src/session.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::passport::Session; -use crate::types::{AccountRole, AccountsView}; -use crate::{AppError, AppState}; -use axum_extra::extract::cookie::Cookie; -use otp::ObjectId; - -pub(crate) async fn author_from_session( - state: &AppState, - gym: &String, - session_id: &Cookie<'static>, -) -> Result { - let session_id = session_id.value().to_owned(); - let session = Session::lookup(state, gym, session_id).await?; - Ok(session.obj_id) -} - -pub(crate) async fn account_role( - state: &AppState, - gym: &String, - object_id: &ObjectId, -) -> Result { - let account = AccountsView::with_id(state, gym, object_id.clone()).await?; - Ok(account.role) -} From 15fb42d49638a3bd39d520e69452df2672cd4705 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Fri, 26 Dec 2025 16:08:02 +0100 Subject: [PATCH 24/29] cleanup --- bin/all-o-stasis/src/passport.rs | 14 ++++---------- bin/all-o-stasis/src/routes/api.rs | 2 +- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/bin/all-o-stasis/src/passport.rs b/bin/all-o-stasis/src/passport.rs index 86a1207..0d34891 100644 --- a/bin/all-o-stasis/src/passport.rs +++ b/bin/all-o-stasis/src/passport.rs @@ -317,7 +317,7 @@ async fn create_passport( // 1. Lookup account by email. If no such account exists, create a new one let account = AccountsView::with_email(&state, gym.clone(), payload.email.clone()).await?; let maybe_account_id: Result = match account { - Some(account) => Ok(account.id.clone().expect("object has no id")), + Some(account) => Ok(account.id.clone().expect("existing accounts have an id")), None => { let account = Account { id: None, @@ -327,15 +327,9 @@ async fn create_passport( name: None, }; let value = serde_json::to_value(account).expect("serialising account"); - let obj = create_object( - &state, - &gym, - String::from(""), // TODO fine? - ObjectType::Account, - &value, - ) - .await?; - + // TODO: author? root? + let obj = + create_object(&state, &gym, String::from(""), ObjectType::Account, &value).await?; Ok(obj.id.clone()) } }; diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index 4ba8d13..2290e86 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -198,7 +198,7 @@ async fn new_object( let obj = create_object(&state, &gym, created_by, ot_type.clone(), &content).await?; Ok(Json(CreateObjectResponse { - id: obj.id.clone(), + id: obj.id, ot_type, content, })) From 2dbb9063836608f188f98e62f5ac7970d127e3b1 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Sat, 27 Dec 2025 06:45:43 +0100 Subject: [PATCH 25/29] move object from value --- bin/all-o-stasis/src/passport.rs | 9 +++++---- bin/all-o-stasis/src/routes/api.rs | 4 ++-- bin/all-o-stasis/src/storage.rs | 16 ---------------- bin/all-o-stasis/src/types/object.rs | 20 +++++++++++++++++++- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/bin/all-o-stasis/src/passport.rs b/bin/all-o-stasis/src/passport.rs index 0d34891..152a968 100644 --- a/bin/all-o-stasis/src/passport.rs +++ b/bin/all-o-stasis/src/passport.rs @@ -17,8 +17,8 @@ use serde::{Deserialize, Serialize}; use crate::{ AppError, AppState, - storage::{apply_object_updates, create_object}, - types::{Account, AccountRole, AccountsView, ObjectType, Snapshot}, + storage::apply_object_updates, + types::{Account, AccountRole, AccountsView, Object, ObjectType, Snapshot}, word_list::make_security_code, }; @@ -329,7 +329,8 @@ async fn create_passport( let value = serde_json::to_value(account).expect("serialising account"); // TODO: author? root? let obj = - create_object(&state, &gym, String::from(""), ObjectType::Account, &value).await?; + Object::from_value(&state, &gym, String::from(""), ObjectType::Account, &value) + .await?; Ok(obj.id.clone()) } }; @@ -348,7 +349,7 @@ async fn create_passport( validity: PassportValidity::Unconfirmed, }; let value = serde_json::to_value(passport).expect("serialising passport"); - let obj = create_object(&state, &gym, account_id, ObjectType::Passport, &value).await?; + let obj = Object::from_value(&state, &gym, account_id, ObjectType::Passport, &value).await?; let passport_id = obj.id.clone(); diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index 2290e86..df5c732 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -1,7 +1,7 @@ use std::net::SocketAddr; use crate::passport::{Session, author_from_session}; -use crate::storage::{apply_object_updates, create_object}; +use crate::storage::apply_object_updates; use crate::types::{AccountRole, AccountsView, Boulder, Object, ObjectType, Patch, Snapshot}; use crate::ws::handle_socket; use crate::{AppError, AppState}; @@ -195,7 +195,7 @@ async fn new_object( let ot_type = payload.ot_type; let content = payload.content; // changing this to also add the object to the view - let obj = create_object(&state, &gym, created_by, ot_type.clone(), &content).await?; + let obj = Object::from_value(&state, &gym, created_by, ot_type.clone(), &content).await?; Ok(Json(CreateObjectResponse { id: obj.id, diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index a520539..bae69fb 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -7,22 +7,6 @@ use axum::Json; use otp::{ObjectId, Operation, RevId, rebase}; use serde_json::Value; -pub(crate) async fn create_object( - state: &AppState, - gym: &String, - author_id: ObjectId, - object_type: ObjectType, - value: &Value, -) -> Result { - let obj = Object::new(state, gym, &object_type).await?; - let _ = Patch::new(obj.id.clone(), author_id, value) - .store(state, gym) - .await?; - update_view_typed(state, gym, &obj.id, &object_type, value).await?; - - Ok(obj) -} - pub(crate) async fn update_view( state: &AppState, gym: &String, diff --git a/bin/all-o-stasis/src/types/object.rs b/bin/all-o-stasis/src/types/object.rs index b4121a8..12d65e0 100644 --- a/bin/all-o-stasis/src/types/object.rs +++ b/bin/all-o-stasis/src/types/object.rs @@ -2,10 +2,12 @@ use std::fmt; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use crate::{ AppError, AppState, - types::{ObjectType, store}, + storage::update_view_typed, + types::{ObjectType, Patch, store}, }; use otp::ObjectId; @@ -115,6 +117,22 @@ impl Object { let obj: Object = obj_doc.try_into()?; Ok(obj) } + + pub async fn from_value( + state: &AppState, + gym: &String, + author_id: String, + object_type: ObjectType, + value: &Value, + ) -> Result { + let obj = Object::new(state, gym, &object_type).await?; + let _ = Patch::new(obj.id.clone(), author_id.clone(), value) + .store(state, gym) + .await?; + update_view_typed(state, gym, &obj.id, &object_type, value).await?; + + Ok(obj) + } } impl fmt::Display for Object { From 87962203ea70765cb702d732d8bffaee47f86515 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Sat, 27 Dec 2025 06:58:45 +0100 Subject: [PATCH 26/29] cleanup --- bin/all-o-stasis/src/storage.rs | 16 ++++++------ bin/all-o-stasis/src/types/object.rs | 2 +- bin/all-o-stasis/src/types/patch.rs | 1 + bin/all-o-stasis/src/types/snapshot.rs | 34 +++++++++++++------------- 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index bae69fb..daf3a72 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -7,6 +7,11 @@ use axum::Json; use otp::{ObjectId, Operation, RevId, rebase}; use serde_json::Value; +struct SaveOp { + patch: Patch, + snapshot: Snapshot, +} + pub(crate) async fn update_view( state: &AppState, gym: &String, @@ -60,7 +65,6 @@ pub async fn apply_object_updates( let saved = save_operation( state, gym, - obj_id.clone(), author.clone(), (base_snapshot.content).clone(), &latest_snapshot, @@ -90,19 +94,12 @@ pub async fn apply_object_updates( Ok(Json(PatchObjectResponse::new(previous_patches, patches))) } -struct SaveOp { - patch: Patch, - snapshot: Snapshot, -} - /// Rebase and then apply the operation to the snapshot to get a new snapshot /// Returns `None` if the rebasing fails or applying the (rebased) operation yields the same /// snapshot. -#[allow(clippy::too_many_arguments)] async fn save_operation( state: &AppState, gym: &String, - object_id: ObjectId, author_id: ObjectId, base_content: Value, snapshot: &Snapshot, @@ -122,11 +119,12 @@ async fn save_operation( } Err(e) => { tracing::error!("rebase failed with error: {e}"); + // TODO error? or skip? return Ok(None); } }; - match snapshot.new_revision(object_id, author_id, rebased_op)? { + match snapshot.new_revision(author_id, rebased_op)? { None => Ok(None), Some((new_snapshot, patch)) => { let s = new_snapshot.store(state, gym).await?; diff --git a/bin/all-o-stasis/src/types/object.rs b/bin/all-o-stasis/src/types/object.rs index 12d65e0..b24bc95 100644 --- a/bin/all-o-stasis/src/types/object.rs +++ b/bin/all-o-stasis/src/types/object.rs @@ -126,7 +126,7 @@ impl Object { value: &Value, ) -> Result { let obj = Object::new(state, gym, &object_type).await?; - let _ = Patch::new(obj.id.clone(), author_id.clone(), value) + let _ = Patch::new(obj.id.clone(), author_id, value) .store(state, gym) .await?; update_view_typed(state, gym, &obj.id, &object_type, value).await?; diff --git a/bin/all-o-stasis/src/types/patch.rs b/bin/all-o-stasis/src/types/patch.rs index 6b42b3d..10bea89 100644 --- a/bin/all-o-stasis/src/types/patch.rs +++ b/bin/all-o-stasis/src/types/patch.rs @@ -127,6 +127,7 @@ impl Patch { Ok(patch) } + /// get all patches for an object with revision id > rev_id pub async fn after_revision( state: &AppState, gym: &String, diff --git a/bin/all-o-stasis/src/types/snapshot.rs b/bin/all-o-stasis/src/types/snapshot.rs index b3c5cfa..af5e2fb 100644 --- a/bin/all-o-stasis/src/types/snapshot.rs +++ b/bin/all-o-stasis/src/types/snapshot.rs @@ -19,26 +19,36 @@ pub struct Snapshot { pub content: Value, } +impl fmt::Display for Snapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Snapshot: {}@{} content={}", + self.object_id, self.revision_id, self.content + ) + } +} + impl Snapshot { const COLLECTION: &str = "snapshots"; + // TODO why is this not ZERO_REV_ID? + /// create a new empty snapshot with revision id -1 pub fn new(object_id: ObjectId) -> Self { Self { object_id, - // FIXME why is this not ZERO_REV_ID? revision_id: -1, content: json!({}), } } + /// apply the operation to the snapshot and create a new revision returning the new snapshot + /// and the patch pub fn new_revision( &self, - object_id: ObjectId, author_id: ObjectId, operation: Operation, ) -> Result, OtError> { - assert_eq!(object_id, self.object_id); - let content = operation.apply_to(self.content.to_owned())?; if content == self.content { tracing::debug!("skipping save operation: content did not change"); @@ -46,10 +56,10 @@ impl Snapshot { } let revision_id = self.revision_id + 1; - let patch = Patch::new_revision(revision_id, object_id.clone(), author_id, operation); + let patch = Patch::new_revision(revision_id, self.object_id.clone(), author_id, operation); Ok(Some(( Self { - object_id, + object_id: self.object_id.clone(), revision_id, content, }, @@ -58,7 +68,6 @@ impl Snapshot { } fn apply_patch(&self, patch: &Patch) -> Result { - // tracing::debug!("applying patch={patch} to {snapshot} results in snapshot={s}"); Ok(Self { object_id: self.object_id.to_owned(), revision_id: patch.revision_id, @@ -66,6 +75,7 @@ impl Snapshot { }) } + /// return a new snapshot with all patches applied pub fn apply_patches(&self, patches: &Vec) -> Result { let mut s = self.clone(); for patch in patches { @@ -178,13 +188,3 @@ impl Snapshot { } } } - -impl fmt::Display for Snapshot { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "Snapshot: {}@{} content={}", - self.object_id, self.revision_id, self.content - ) - } -} From d2e6bdc83f549687ef4b59c3aecd5e3e6022bb24 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Sun, 28 Dec 2025 13:23:37 +0100 Subject: [PATCH 27/29] storage: apply op to latest snapshot --- bin/all-o-stasis/src/storage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index daf3a72..d0ab9a8 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -67,7 +67,7 @@ pub async fn apply_object_updates( gym, author.clone(), (base_snapshot.content).clone(), - &latest_snapshot, + &final_snapshot, &previous_patches, op, ) From fc8171d7debdaea9cea8be30bf76f2f16cb184db Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Sun, 28 Dec 2025 15:53:12 +0100 Subject: [PATCH 28/29] storage: cleanup apply_object_updates --- bin/all-o-stasis/src/storage.rs | 54 +++++++++++++++------------------ 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index d0ab9a8..3e4d6f9 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -50,46 +50,40 @@ pub async fn apply_object_updates( ) -> Result, AppError> { // the 'Snapshot' against which the submitted operations were created // this only contains patches until base_snapshot.revision_id - tracing::debug!("looking up base_snapshot@rev{rev_id}"); let base_snapshot = Snapshot::lookup(state, gym, &obj_id, rev_id).await?; - tracing::debug!("base_snapshot={base_snapshot}"); // if there are any patches which the client doesn't know about we need // to let her know let previous_patches = Patch::after_revision(state, gym, &obj_id, rev_id).await?; let latest_snapshot = base_snapshot.apply_patches(&previous_patches)?; - let mut patches = Vec::::new(); - let mut final_snapshot = latest_snapshot.clone(); - for op in operations { - let saved = save_operation( - state, - gym, - author.clone(), - (base_snapshot.content).clone(), - &final_snapshot, - &previous_patches, - op, - ) - .await; - - match saved { - Err(e) => return Err(e), - Ok(Some(saved)) => { - patches.push(saved.patch); - final_snapshot = saved.snapshot + let (patches, snapshot) = { + let mut patches = Vec::::new(); + let mut snapshot = latest_snapshot; + for op in operations { + match save_operation( + state, + gym, + author.clone(), + (base_snapshot.content).clone(), + &snapshot, + &previous_patches, + op, + ) + .await + { + Err(e) => return Err(e), + Ok(None) => (), // skip + Ok(Some(saved)) => { + patches.push(saved.patch); + snapshot = saved.snapshot + } } - Ok(None) => (), // skip } - } + (patches, snapshot) + }; - update_view( - state, - gym, - &final_snapshot.object_id, - &final_snapshot.content, - ) - .await?; + update_view(state, gym, &snapshot.object_id, &snapshot.content).await?; Ok(Json(PatchObjectResponse::new(previous_patches, patches))) } From 068bc7c9b339e52224a7d125df320b043d11d7e8 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Sun, 28 Dec 2025 16:13:04 +0100 Subject: [PATCH 29/29] storage: fix patch accumulation that we pass to save_operation --- bin/all-o-stasis/src/routes/api.rs | 8 ++++++-- bin/all-o-stasis/src/storage.rs | 31 ++++++++++++++++-------------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/bin/all-o-stasis/src/routes/api.rs b/bin/all-o-stasis/src/routes/api.rs index df5c732..d0924b5 100644 --- a/bin/all-o-stasis/src/routes/api.rs +++ b/bin/all-o-stasis/src/routes/api.rs @@ -82,10 +82,14 @@ pub struct PatchObjectResponse { } impl PatchObjectResponse { - pub fn new(previous_patches: Vec, resulting_patches: Vec) -> Self { + pub fn new( + previous_patches: Vec, + num_processed_operations: usize, + resulting_patches: Vec, + ) -> Self { Self { previous_patches, - num_processed_operations: resulting_patches.len(), + num_processed_operations, resulting_patches, } } diff --git a/bin/all-o-stasis/src/storage.rs b/bin/all-o-stasis/src/storage.rs index 3e4d6f9..ec4e344 100644 --- a/bin/all-o-stasis/src/storage.rs +++ b/bin/all-o-stasis/src/storage.rs @@ -57,8 +57,9 @@ pub async fn apply_object_updates( let previous_patches = Patch::after_revision(state, gym, &obj_id, rev_id).await?; let latest_snapshot = base_snapshot.apply_patches(&previous_patches)?; - let (patches, snapshot) = { + let (patches, num_processed, snapshot) = { let mut patches = Vec::::new(); + let mut num_processed = 0; let mut snapshot = latest_snapshot; for op in operations { match save_operation( @@ -67,25 +68,31 @@ pub async fn apply_object_updates( author.clone(), (base_snapshot.content).clone(), &snapshot, - &previous_patches, + previous_patches.iter().chain(patches.iter()), op, ) .await { Err(e) => return Err(e), - Ok(None) => (), // skip - Ok(Some(saved)) => { - patches.push(saved.patch); - snapshot = saved.snapshot + Ok(saved) => { + num_processed += 1; + if let Some(saved) = saved { + patches.push(saved.patch); + snapshot = saved.snapshot; + } } } } - (patches, snapshot) + (patches, num_processed, snapshot) }; update_view(state, gym, &snapshot.object_id, &snapshot.content).await?; - Ok(Json(PatchObjectResponse::new(previous_patches, patches))) + Ok(Json(PatchObjectResponse::new( + previous_patches, + num_processed, + patches, + ))) } /// Rebase and then apply the operation to the snapshot to get a new snapshot @@ -97,14 +104,10 @@ async fn save_operation( author_id: ObjectId, base_content: Value, snapshot: &Snapshot, - previous_patches: &[Patch], + previous_patches: impl Iterator, op: Operation, ) -> Result, AppError> { - let rebased_op = match rebase( - base_content, - op, - previous_patches.iter().map(|p| &p.operation), - ) { + let rebased_op = match rebase(base_content, op, previous_patches.map(|p| &p.operation)) { Ok(Some(rebased_op)) => rebased_op, Ok(None) => { // TODO better error, log op, base_content