diff --git a/docs/rfcs/0013-favourites-api.md b/docs/rfcs/0013-favourites-api.md new file mode 100644 index 00000000..8ffad800 --- /dev/null +++ b/docs/rfcs/0013-favourites-api.md @@ -0,0 +1,78 @@ +--- +title: "Favourites API" +owner: "@filippovecchiato" +--- + +# RFC 0013 — Favourites API + +## Summary + +Products can query, add, and remove bookmarked apps from the host's local product catalogue. The host exposes a subscription for the bookmarked-product list and two mutations for adding/removing entries. + +## Motivation + +Hosts maintain a local catalogue of products the user has bookmarked, but this data is inaccessible to products. Discovery surfaces like Browse cannot show which apps are already bookmarked or let the user add new ones without this API. + +## Detailed Design + +### Data Model + +```rust +struct FavouriteProduct { + product_id: String, + source: FavouriteProductSource, + created_at: u64, + updated_at: u64, +} + +enum FavouriteProductSource { + Remote, + Local, +} +``` + +`product_id` is a DotNS identifier. `source` distinguishes on-chain registry discoveries (`Remote`) from sideloaded entries (`Local`). Timestamps are Unix seconds. + +### API + +Three methods on a `Favourites` trait: + +**`subscribe`** — streams the full bookmarked-product list on each change. Hosts MAY debounce. + +**`add`** — upserts a product with `source: Remote`, setting `created_at` on first add and `updated_at` on every call. Returns the resulting record. + +**`forget`** — removes the product from the catalogue. + +### Error Handling + +Subscription errors and mutation errors use `CallError` with a domain enum: + +```rust +enum HostFavouritesSubscribeError { + Unknown { reason: String }, +} + +enum HostFavouritesAddError { + Unknown { reason: String }, +} + +enum HostFavouritesForgetError { + NotFound, + Unknown { reason: String }, +} +``` + +Permission denial and unsupported-host cases are handled by `CallError::Denied` and `CallError::Unsupported`. + +### Permission Model + +The host SHOULD prompt the user before granting a product access to the favourites catalogue. The host MAY grant implicit access to Browse (the built-in discovery product) without prompting. + +### Host Behaviour + +Favourites are local to the host instance. Cross-host sync is out of scope. + +## Drawbacks + +- **Full-list delivery.** No pagination or filtered subscriptions. Acceptable for typical catalogue sizes (tens to low hundreds). +- **Browse coupling.** Implicit privilege for Browse assumes a well-known product identity. diff --git a/rust/crates/truapi/src/api/favourites.rs b/rust/crates/truapi/src/api/favourites.rs new file mode 100644 index 00000000..da8ff787 --- /dev/null +++ b/rust/crates/truapi/src/api/favourites.rs @@ -0,0 +1,73 @@ +//! Unified [`Favourites`] trait. + +use crate::versioned::favourites::{ + HostFavouritesAddError, HostFavouritesAddRequest, HostFavouritesAddResponse, + HostFavouritesForgetError, HostFavouritesForgetRequest, HostFavouritesSubscribeError, + HostFavouritesSubscribeItem, +}; +use crate::wire; +use crate::{CallContext, CallError, Subscription}; + +/// Bookmarked-product catalogue methods. +pub trait Favourites: Send + Sync { + /// Subscribe to the user's bookmarked products. + /// + /// ```ts + /// import { firstValueFrom, from } from "rxjs"; + /// + /// const favourites = await firstValueFrom( + /// from(truapi.favourites.subscribe()), + /// ); + /// console.log("favourites:", favourites); + /// ``` + #[wire(start_id = 164)] + async fn subscribe( + &self, + _cx: &CallContext, + ) -> Result< + Subscription, + CallError, + > { + Err(CallError::unavailable()) + } + + /// Add a product to the favourites catalogue. + /// + /// ```ts + /// const result = await truapi.favourites.add({ + /// productId: "some-product.dot", + /// }); + /// result.match( + /// (value) => console.log("added:", value), + /// (error) => console.error("add failed:", error), + /// ); + /// ``` + #[wire(request_id = 168)] + async fn add( + &self, + _cx: &CallContext, + _request: HostFavouritesAddRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Remove a product from the favourites catalogue. + /// + /// ```ts + /// const result = await truapi.favourites.forget({ + /// productId: "some-product.dot", + /// }); + /// result.match( + /// () => console.log("forgotten"), + /// (error) => console.error("forget failed:", error), + /// ); + /// ``` + #[wire(request_id = 170)] + async fn forget( + &self, + _cx: &CallContext, + _request: HostFavouritesForgetRequest, + ) -> Result<(), CallError> { + Err(CallError::unavailable()) + } +} diff --git a/rust/crates/truapi/src/api/mod.rs b/rust/crates/truapi/src/api/mod.rs index 957509e4..b0c2040c 100644 --- a/rust/crates/truapi/src/api/mod.rs +++ b/rust/crates/truapi/src/api/mod.rs @@ -5,6 +5,7 @@ pub mod chain; pub mod chat; pub mod coin_payment; pub mod entropy; +pub mod favourites; pub mod local_storage; pub mod notifications; pub mod payment; @@ -21,6 +22,7 @@ pub use chain::Chain; pub use chat::Chat; pub use coin_payment::CoinPayment; pub use entropy::Entropy; +pub use favourites::Favourites; pub use local_storage::LocalStorage; pub use notifications::Notifications; pub use payment::Payment; @@ -39,6 +41,7 @@ pub trait TrUApi: + Chat + CoinPayment + Entropy + + Favourites + LocalStorage + Notifications + Payment @@ -60,6 +63,7 @@ impl TrUApi for T where + Chat + CoinPayment + Entropy + + Favourites + LocalStorage + Notifications + Payment diff --git a/rust/crates/truapi/src/v01/favourites.rs b/rust/crates/truapi/src/v01/favourites.rs new file mode 100644 index 00000000..97786a40 --- /dev/null +++ b/rust/crates/truapi/src/v01/favourites.rs @@ -0,0 +1,70 @@ +use parity_scale_codec::{Decode, Encode}; + +/// How a product entered the favourites catalogue. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum FavouriteProductSource { + /// Discovered via the on-chain registry. + Remote, + /// Sideloaded or manually added. + Local, +} + +/// A bookmarked product in the host's local catalogue. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct FavouriteProduct { + /// DotNS identifier of the product. + pub product_id: String, + /// How the product was added. + pub source: FavouriteProductSource, + /// Unix timestamp (seconds) when first bookmarked. + pub created_at: u64, + /// Unix timestamp (seconds) of the most recent update. + pub updated_at: u64, +} + +/// Request to add a product to the favourites catalogue. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostFavouritesAddRequest { + /// DotNS identifier of the product to add. + pub product_id: String, +} + +/// Response after adding a product to favourites. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostFavouritesAddResponse { + /// The resulting catalogue entry. + pub product: FavouriteProduct, +} + +/// Request to remove a product from the favourites catalogue. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostFavouritesForgetRequest { + /// DotNS identifier of the product to remove. + pub product_id: String, +} + +/// Error from [`crate::api::Favourites::subscribe`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostFavouritesSubscribeError { + /// Catch-all. + Unknown { reason: String }, +} + +/// Error from [`crate::api::Favourites::add`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostFavouritesAddError { + /// Catch-all. + Unknown { reason: String }, +} + +/// Error from [`crate::api::Favourites::forget`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostFavouritesForgetError { + /// The product was not in the catalogue. + NotFound, + /// Catch-all. + Unknown { reason: String }, +} + +/// Item pushed to favourites subscribers: the full list of bookmarked products. +pub type HostFavouritesSubscribeItem = Vec; diff --git a/rust/crates/truapi/src/v01/mod.rs b/rust/crates/truapi/src/v01/mod.rs index 8b34df5a..9150a8ea 100644 --- a/rust/crates/truapi/src/v01/mod.rs +++ b/rust/crates/truapi/src/v01/mod.rs @@ -6,6 +6,7 @@ mod chat; mod coin_payment; mod common; mod entropy; +mod favourites; mod local_storage; mod notifications; mod payment; @@ -24,6 +25,7 @@ pub use chat::*; pub use coin_payment::*; pub use common::*; pub use entropy::*; +pub use favourites::*; pub use local_storage::*; pub use notifications::*; pub use payment::*; diff --git a/rust/crates/truapi/src/versioned/favourites.rs b/rust/crates/truapi/src/versioned/favourites.rs new file mode 100644 index 00000000..28bd37d6 --- /dev/null +++ b/rust/crates/truapi/src/versioned/favourites.rs @@ -0,0 +1,31 @@ +//! Versioned wrappers for [`Favourites`](crate::api::Favourites) methods. + +use crate::v01; + +truapi_macros::versioned_type! { + pub enum HostFavouritesSubscribeItem { V1 => v01::HostFavouritesSubscribeItem } +} + +truapi_macros::versioned_type! { + pub enum HostFavouritesSubscribeError { V1 => v01::HostFavouritesSubscribeError } +} + +truapi_macros::versioned_type! { + pub enum HostFavouritesAddRequest { V1 => v01::HostFavouritesAddRequest } +} + +truapi_macros::versioned_type! { + pub enum HostFavouritesAddResponse { V1 => v01::HostFavouritesAddResponse } +} + +truapi_macros::versioned_type! { + pub enum HostFavouritesAddError { V1 => v01::HostFavouritesAddError } +} + +truapi_macros::versioned_type! { + pub enum HostFavouritesForgetRequest { V1 => v01::HostFavouritesForgetRequest } +} + +truapi_macros::versioned_type! { + pub enum HostFavouritesForgetError { V1 => v01::HostFavouritesForgetError } +} diff --git a/rust/crates/truapi/src/versioned/mod.rs b/rust/crates/truapi/src/versioned/mod.rs index 9da72067..ff0e4569 100644 --- a/rust/crates/truapi/src/versioned/mod.rs +++ b/rust/crates/truapi/src/versioned/mod.rs @@ -35,6 +35,7 @@ pub mod chain; pub mod chat; pub mod coin_payment; pub mod entropy; +pub mod favourites; pub mod local_storage; pub mod notifications; pub mod payment;