feat(sdk): wire device_id through messaging and client APIs
Add device_id parameter to fetch, fetch_wait, ack, receive_messages, and receive_messages_wait SDK functions. QpqClient gains device_id field with register_device/list_devices/revoke_device convenience methods. Client REPL passes empty device_id for backwards compat.
This commit is contained in:
@@ -14,8 +14,8 @@ use quicproquo_core::{
|
||||
};
|
||||
use quicproquo_proto::method_ids;
|
||||
use quicproquo_proto::qpq::v1::{
|
||||
BatchEnqueueRequest, BatchEnqueueResponse, EnqueueRequest, EnqueueResponse, FetchRequest,
|
||||
FetchResponse, FetchWaitRequest, FetchWaitResponse,
|
||||
AckRequest, AckResponse, BatchEnqueueRequest, BatchEnqueueResponse, EnqueueRequest,
|
||||
EnqueueResponse, FetchRequest, FetchResponse, FetchWaitRequest, FetchWaitResponse,
|
||||
};
|
||||
use quicproquo_rpc::client::RpcClient;
|
||||
|
||||
@@ -110,8 +110,9 @@ pub async fn receive_messages(
|
||||
my_identity_key: &[u8],
|
||||
hybrid_kp: Option<&HybridKeypair>,
|
||||
channel_id: &[u8],
|
||||
device_id: &[u8],
|
||||
) -> Result<Vec<ReceivedPlaintext>, SdkError> {
|
||||
let payloads = fetch(rpc, my_identity_key, channel_id, 0).await?;
|
||||
let payloads = fetch(rpc, my_identity_key, channel_id, 0, device_id).await?;
|
||||
process_payloads(member, hybrid_kp, payloads)
|
||||
}
|
||||
|
||||
@@ -126,8 +127,9 @@ pub async fn receive_messages_wait(
|
||||
hybrid_kp: Option<&HybridKeypair>,
|
||||
channel_id: &[u8],
|
||||
timeout_ms: u64,
|
||||
device_id: &[u8],
|
||||
) -> Result<Vec<ReceivedPlaintext>, SdkError> {
|
||||
let payloads = fetch_wait(rpc, my_identity_key, channel_id, timeout_ms).await?;
|
||||
let payloads = fetch_wait(rpc, my_identity_key, channel_id, timeout_ms, device_id).await?;
|
||||
process_payloads(member, hybrid_kp, payloads)
|
||||
}
|
||||
|
||||
@@ -248,6 +250,47 @@ fn try_unseal_and_parse(seq: u64, plaintext: &[u8]) -> Option<ReceivedPlaintext>
|
||||
})
|
||||
}
|
||||
|
||||
// ── Gap Detection ────────────────────────────────────────────────────────────
|
||||
|
||||
/// A gap detected in server-side sequence numbers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SeqGap {
|
||||
/// The expected next sequence number.
|
||||
pub expected_seq: u64,
|
||||
/// The sequence number that was actually received.
|
||||
pub received_seq: u64,
|
||||
}
|
||||
|
||||
/// Detect gaps in a sorted list of `(seq, payload)` pairs relative to the
|
||||
/// last known sequence number. Returns a list of gaps and the new highest seq.
|
||||
///
|
||||
/// Callers should update their stored `last_seen_seq` to the returned value
|
||||
/// and emit `ClientEvent::MessageGap` for each gap.
|
||||
pub fn detect_gaps(last_seen_seq: u64, payloads: &[(u64, Vec<u8>)]) -> (Vec<SeqGap>, u64) {
|
||||
if payloads.is_empty() {
|
||||
return (Vec::new(), last_seen_seq);
|
||||
}
|
||||
|
||||
let mut gaps = Vec::new();
|
||||
let mut expected = last_seen_seq + 1;
|
||||
|
||||
for &(seq, _) in payloads {
|
||||
if seq > expected {
|
||||
gaps.push(SeqGap {
|
||||
expected_seq: expected,
|
||||
received_seq: seq,
|
||||
});
|
||||
}
|
||||
if seq >= expected {
|
||||
expected = seq + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// The new last_seen_seq is the highest seq we received.
|
||||
let new_last_seen = payloads.iter().map(|(s, _)| *s).max().unwrap_or(last_seen_seq);
|
||||
(gaps, new_last_seen)
|
||||
}
|
||||
|
||||
// ── RPC Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Enqueue a single payload to one recipient via RPC.
|
||||
@@ -265,6 +308,7 @@ pub async fn enqueue(
|
||||
payload: payload.to_vec(),
|
||||
channel_id: channel_id.to_vec(),
|
||||
ttl_secs,
|
||||
message_id: Vec::new(),
|
||||
};
|
||||
|
||||
let resp_bytes = rpc
|
||||
@@ -292,6 +336,7 @@ pub async fn batch_enqueue(
|
||||
payload: payload.to_vec(),
|
||||
channel_id: channel_id.to_vec(),
|
||||
ttl_secs,
|
||||
message_id: Vec::new(),
|
||||
};
|
||||
|
||||
let resp_bytes = rpc
|
||||
@@ -309,17 +354,22 @@ pub async fn batch_enqueue(
|
||||
|
||||
/// Fetch messages from server (destructive — removes from queue).
|
||||
///
|
||||
/// When `device_id` is non-empty, the server scopes the fetch to the
|
||||
/// device-specific queue (identity_key + device_id).
|
||||
///
|
||||
/// Returns `(seq, payload)` pairs sorted by sequence number.
|
||||
pub async fn fetch(
|
||||
rpc: &RpcClient,
|
||||
my_identity_key: &[u8],
|
||||
channel_id: &[u8],
|
||||
limit: u32,
|
||||
device_id: &[u8],
|
||||
) -> Result<Vec<(u64, Vec<u8>)>, SdkError> {
|
||||
let req = FetchRequest {
|
||||
recipient_key: my_identity_key.to_vec(),
|
||||
channel_id: channel_id.to_vec(),
|
||||
limit,
|
||||
device_id: device_id.to_vec(),
|
||||
};
|
||||
|
||||
let resp_bytes = rpc
|
||||
@@ -341,18 +391,23 @@ pub async fn fetch(
|
||||
|
||||
/// Long-poll fetch: blocks server-side until messages arrive or timeout expires.
|
||||
///
|
||||
/// When `device_id` is non-empty, the server scopes the fetch to the
|
||||
/// device-specific queue (identity_key + device_id).
|
||||
///
|
||||
/// Returns `(seq, payload)` pairs sorted by sequence number.
|
||||
async fn fetch_wait(
|
||||
rpc: &RpcClient,
|
||||
my_identity_key: &[u8],
|
||||
channel_id: &[u8],
|
||||
timeout_ms: u64,
|
||||
device_id: &[u8],
|
||||
) -> Result<Vec<(u64, Vec<u8>)>, SdkError> {
|
||||
let req = FetchWaitRequest {
|
||||
recipient_key: my_identity_key.to_vec(),
|
||||
channel_id: channel_id.to_vec(),
|
||||
timeout_ms,
|
||||
limit: 0, // fetch all
|
||||
device_id: device_id.to_vec(),
|
||||
};
|
||||
|
||||
let resp_bytes = rpc
|
||||
@@ -371,3 +426,151 @@ async fn fetch_wait(
|
||||
payloads.sort_by_key(|(seq, _)| *seq);
|
||||
Ok(payloads)
|
||||
}
|
||||
|
||||
// ── Device-aware fetch ──────────────────────────────────────────────────────
|
||||
|
||||
/// Fetch messages for a specific device.
|
||||
///
|
||||
/// When `device_id` is non-empty, the server uses the composite queue key
|
||||
/// `identity_key + device_id`. When empty, falls back to the bare identity key.
|
||||
pub async fn fetch_for_device(
|
||||
rpc: &RpcClient,
|
||||
my_identity_key: &[u8],
|
||||
device_id: &[u8],
|
||||
channel_id: &[u8],
|
||||
limit: u32,
|
||||
) -> Result<Vec<(u64, Vec<u8>)>, SdkError> {
|
||||
let req = FetchRequest {
|
||||
recipient_key: my_identity_key.to_vec(),
|
||||
channel_id: channel_id.to_vec(),
|
||||
limit,
|
||||
device_id: device_id.to_vec(),
|
||||
};
|
||||
|
||||
let resp_bytes = rpc
|
||||
.call(method_ids::FETCH, Bytes::from(req.encode_to_vec()))
|
||||
.await?;
|
||||
|
||||
let resp = FetchResponse::decode(resp_bytes)
|
||||
.map_err(|e| SdkError::Crypto(format!("decode FetchResponse: {e}")))?;
|
||||
|
||||
let mut payloads: Vec<(u64, Vec<u8>)> = resp
|
||||
.payloads
|
||||
.into_iter()
|
||||
.map(|env| (env.seq, env.data))
|
||||
.collect();
|
||||
|
||||
payloads.sort_by_key(|(seq, _)| *seq);
|
||||
Ok(payloads)
|
||||
}
|
||||
|
||||
// ── Acknowledge ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Acknowledge messages up to a sequence number.
|
||||
///
|
||||
/// When `device_id` is non-empty, the server acks on the device-scoped queue.
|
||||
pub async fn ack(
|
||||
rpc: &RpcClient,
|
||||
my_identity_key: &[u8],
|
||||
device_id: &[u8],
|
||||
channel_id: &[u8],
|
||||
seq_up_to: u64,
|
||||
) -> Result<(), SdkError> {
|
||||
let req = AckRequest {
|
||||
recipient_key: my_identity_key.to_vec(),
|
||||
channel_id: channel_id.to_vec(),
|
||||
seq_up_to,
|
||||
device_id: device_id.to_vec(),
|
||||
};
|
||||
|
||||
let resp_bytes = rpc
|
||||
.call(method_ids::ACK, Bytes::from(req.encode_to_vec()))
|
||||
.await?;
|
||||
|
||||
let _resp = AckResponse::decode(resp_bytes)
|
||||
.map_err(|e| SdkError::Crypto(format!("decode AckResponse: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detect_gaps_empty() {
|
||||
let (gaps, last) = detect_gaps(0, &[]);
|
||||
assert!(gaps.is_empty());
|
||||
assert_eq!(last, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_gaps_contiguous_from_zero() {
|
||||
let payloads = vec![
|
||||
(1, vec![]),
|
||||
(2, vec![]),
|
||||
(3, vec![]),
|
||||
];
|
||||
let (gaps, last) = detect_gaps(0, &payloads);
|
||||
assert!(gaps.is_empty());
|
||||
assert_eq!(last, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_gaps_contiguous_from_nonzero() {
|
||||
let payloads = vec![
|
||||
(6, vec![]),
|
||||
(7, vec![]),
|
||||
(8, vec![]),
|
||||
];
|
||||
let (gaps, last) = detect_gaps(5, &payloads);
|
||||
assert!(gaps.is_empty());
|
||||
assert_eq!(last, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_gaps_single_gap() {
|
||||
let payloads = vec![
|
||||
(1, vec![]),
|
||||
(2, vec![]),
|
||||
(5, vec![]), // gap: expected 3, got 5
|
||||
(6, vec![]),
|
||||
];
|
||||
let (gaps, last) = detect_gaps(0, &payloads);
|
||||
assert_eq!(gaps.len(), 1);
|
||||
assert_eq!(gaps[0].expected_seq, 3);
|
||||
assert_eq!(gaps[0].received_seq, 5);
|
||||
assert_eq!(last, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_gaps_multiple_gaps() {
|
||||
let payloads = vec![
|
||||
(3, vec![]), // gap from 1 to 3
|
||||
(7, vec![]), // gap from 4 to 7
|
||||
(8, vec![]),
|
||||
];
|
||||
let (gaps, last) = detect_gaps(0, &payloads);
|
||||
assert_eq!(gaps.len(), 2);
|
||||
assert_eq!(gaps[0].expected_seq, 1);
|
||||
assert_eq!(gaps[0].received_seq, 3);
|
||||
assert_eq!(gaps[1].expected_seq, 4);
|
||||
assert_eq!(gaps[1].received_seq, 7);
|
||||
assert_eq!(last, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_gaps_initial_gap() {
|
||||
// last_seen_seq = 5, but first received is 10
|
||||
let payloads = vec![
|
||||
(10, vec![]),
|
||||
(11, vec![]),
|
||||
];
|
||||
let (gaps, last) = detect_gaps(5, &payloads);
|
||||
assert_eq!(gaps.len(), 1);
|
||||
assert_eq!(gaps[0].expected_seq, 6);
|
||||
assert_eq!(gaps[0].received_seq, 10);
|
||||
assert_eq!(last, 11);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user