use std::time::Duration;
use anyhow::{bail, Context, Result};
use arete_sdk::{Frame, ServerMessage, SnapshotEntity, Subscription, SubscriptionQuery};
use clap::Args;
use serde_json::{json, Value};
use super::filter::{self, Filter};
use super::session::{Notice, ReconnectPolicy, SessionHandler, StreamEnd, StreamSession};
use super::token;
#[derive(Args)]
pub struct GetArgs {
pub view: String,
#[arg(short, long)]
pub stack: Option<String>,
#[arg(long)]
pub url: Option<String>,
#[arg(short, long)]
pub key: Option<String>,
#[arg(long = "where", value_name = "EXPR")]
pub filters: Vec<String>,
#[arg(long)]
pub select: Option<String>,
#[arg(long, default_value_t = 10, value_parser = clap::value_parser!(u32).range(1..))]
pub limit: u32,
#[arg(long, value_name = "SECS", default_value_t = 15, value_parser = clap::value_parser!(u64).range(1..))]
pub timeout: u64,
}
pub const GET_HELP: &str = "\
Output:
One JSON document on stdout:
{\"view\", \"key\"?, \"total\", \"matching\", \"returned\", \"truncated\", \"entities\": [...]}
\"entities\" holds at most --limit entities in the view's order. Without
--where the server sends only --limit of them, so \"total\" is at most
--limit; with --where, \"total\" counts the whole snapshot and \"matching\"
the entities it kept. With --key, an entity that does not exist is an
error.
Token amounts:
Float fields a stack derives with ui_amount are whole token units; the
integer fields they come from are raw base units (often string-encoded
u64; for SOL, lamports). `a4 explore stack <stack> --views <view>` shows
each scaled field's scale and decimals.
Examples:
a4 get OreRound/latest --stack ore --limit 1
a4 get OreRound/latest --stack ore --limit 1 --select id.round_id,state.total_deployed,treasury.motherlode
a4 get OreMiner/state --stack ore --key <authority>";
pub fn run(args: GetArgs) -> Result<()> {
let view = args.view.trim();
if !view.contains('/') {
bail!("<VIEW> must be EntityName/mode (e.g. OreRound/latest), got '{view}'");
}
let filter = Filter::parse(&args.filters)?;
let select = args.select.as_deref().map(filter::parse_select);
let url = super::resolve_ws_url(args.url.as_deref(), args.stack.as_deref())?;
let (url, refresh) = token::ensure_hosted_ws_token(url)?;
let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
let timeout = Duration::from_secs(args.timeout);
let take = (args.key.is_none() && filter.is_empty()).then_some(args.limit as usize);
let rows = rt.block_on(read_snapshot(
url,
refresh,
view,
args.key.clone(),
take,
timeout,
))?;
let output = shape_output(
view,
args.key.as_deref(),
rows,
&filter,
select.as_deref(),
args.limit as usize,
)?;
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
async fn read_snapshot(
url: String,
refresh: Option<token::SessionRefresh>,
view: &str,
key: Option<String>,
take: Option<usize>,
timeout: Duration,
) -> Result<Vec<SnapshotEntity>> {
let deadline = tokio::time::Instant::now() + timeout;
let subscription_id = format!("a4-get:{}", uuid::Uuid::new_v4().simple());
let mut query = SubscriptionQuery::new(view);
if let Some(key) = key {
query = query.with_key(key);
}
if let Some(take) = take {
query = query.with_take(take);
}
let subscription = Subscription::new(subscription_id.clone(), query).with_snapshot(true);
let policy = ReconnectPolicy::new(true, Some(2));
let mut session = StreamSession::new(url, refresh, subscription, policy)?;
let socket = tokio::time::timeout_at(deadline, session.connect())
.await
.map_err(|_| {
anyhow::anyhow!(
"Timed out after {}s connecting to {}",
timeout.as_secs(),
session.display_url()
)
})??;
let mut collector = Collector::new(subscription_id);
let stop = tokio::time::sleep_until(deadline);
tokio::pin!(stop);
let end = session.run(socket, &mut collector, stop).await?;
if let Some(rows) = collector.snapshot {
return Ok(rows);
}
if let Some(error) = collector.error {
bail!("{error}");
}
match end {
StreamEnd::Stopped => bail!(
"Timed out after {}s waiting for the snapshot of {view}. Check the view id with \
`a4 explore stack <stack>`, or raise --timeout.",
timeout.as_secs()
),
StreamEnd::Failed(failure) => bail!("{failure}"),
StreamEnd::Lost(loss) => bail!("{loss}"),
StreamEnd::Finished => {
bail!("The server ended the subscription to {view} before its snapshot")
}
}
}
struct Collector {
subscription_id: String,
pending: Option<(String, Vec<SnapshotEntity>)>,
snapshot: Option<Vec<SnapshotEntity>>,
error: Option<String>,
}
impl Collector {
fn new(subscription_id: String) -> Self {
Self {
subscription_id,
pending: None,
snapshot: None,
error: None,
}
}
}
impl SessionHandler for Collector {
fn on_message(&mut self, message: ServerMessage) -> Result<bool> {
match message {
ServerMessage::Error(error) => {
let ours = error.subscription_id.as_deref() == Some(self.subscription_id.as_str());
let text = format!("Server error [{}]: {}", error.code, error.message);
if error.fatal || ours {
self.error = Some(text);
return Ok(true);
}
eprintln!("{text}");
Ok(false)
}
ServerMessage::Frame(Frame::Snapshot {
snapshot_id,
data,
complete,
..
}) => {
let (id, rows) = self
.pending
.get_or_insert_with(|| (snapshot_id.clone(), Vec::new()));
if *id != snapshot_id {
*id = snapshot_id;
rows.clear();
}
for row in data {
match rows.iter_mut().find(|item| item.key == row.key) {
Some(existing) => *existing = row,
None => rows.push(row),
}
}
if complete {
self.snapshot = self.pending.take().map(|(_, rows)| rows);
return Ok(true);
}
Ok(false)
}
ServerMessage::Frame(Frame::Unsubscribed { .. }) => Ok(true),
ServerMessage::Frame(_) => Ok(false),
}
}
fn on_notice(&mut self, notice: Notice<'_>) {
match notice {
Notice::Reconnecting(reconnecting) => eprintln!("{reconnecting}"),
Notice::Unparsed { binary, error } => eprintln!(
"Warning: failed to parse {} frame: {error}",
if binary { "binary" } else { "text" }
),
Notice::RefreshRefused(_) | Notice::RefreshFailed(_) => {}
}
}
fn on_reconnected(&mut self, _url: &str) -> Result<()> {
self.pending = None;
Ok(())
}
}
fn shape_output(
view: &str,
key: Option<&str>,
rows: Vec<SnapshotEntity>,
filter: &Filter,
select: Option<&[Vec<String>]>,
limit: usize,
) -> Result<Value> {
let total = rows.len();
if let Some(key) = key {
if !rows.iter().any(|row| row.key == key) {
bail!(
"No entity with key '{key}' in {view}. Keyed reads usually need the entity's \
/state view; list keys with `a4 get <Entity>/list --stack <stack>`."
);
}
}
let matched: Vec<Value> = rows
.into_iter()
.filter(|row| key.is_none_or(|key| row.key == key))
.map(|row| row.data)
.filter(|data| filter.is_empty() || filter.matches(data))
.collect();
let matching = matched.len();
let entities: Vec<Value> = matched
.into_iter()
.take(limit)
.map(|data| match select {
Some(fields) => filter::select_fields(&data, fields),
None => data,
})
.collect();
let mut output = json!({
"view": view,
"total": total,
"matching": matching,
"returned": entities.len(),
"truncated": matching > entities.len(),
"entities": entities,
});
if let Some(key) = key {
output["key"] = json!(key);
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
use arete_sdk::Mode;
fn snapshot(id: &str, complete: bool, keys: &[(&str, u64)]) -> ServerMessage {
ServerMessage::Frame(Frame::Snapshot {
protocol_version: 2,
subscription_id: "a4-get:test".to_string(),
snapshot_id: id.to_string(),
authoritative: true,
mode: Mode::List,
entity: "Round/latest".to_string(),
key: None,
data: keys
.iter()
.map(|(key, n)| SnapshotEntity {
key: (*key).to_string(),
data: json!({"id": key, "n": n}),
})
.collect(),
complete,
})
}
#[test]
fn collects_every_batch_of_the_first_complete_snapshot() {
let mut collector = Collector::new("a4-get:test".into());
assert!(!collector
.on_message(snapshot("s1", false, &[("a", 1), ("b", 2)]))
.unwrap());
assert!(collector
.on_message(snapshot("s1", true, &[("b", 3), ("c", 4)]))
.unwrap());
let rows = collector.snapshot.unwrap();
let keys: Vec<_> = rows.iter().map(|row| row.key.as_str()).collect();
assert_eq!(keys, ["a", "b", "c"]);
assert_eq!(rows[1].data["n"], 3);
}
#[test]
fn an_empty_snapshot_completes_the_read() {
let mut collector = Collector::new("a4-get:test".into());
assert!(collector.on_message(snapshot("s1", true, &[])).unwrap());
assert_eq!(collector.snapshot.unwrap().len(), 0);
}
#[test]
fn a_reconnect_drops_a_partial_snapshot() {
let mut collector = Collector::new("a4-get:test".into());
collector
.on_message(snapshot("s1", false, &[("a", 1)]))
.unwrap();
collector.on_reconnected("wss://example.test").unwrap();
collector
.on_message(snapshot("s2", true, &[("b", 2)]))
.unwrap();
let rows = collector.snapshot.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].key, "b");
}
fn rows() -> Vec<SnapshotEntity> {
(1..=3)
.map(|n| SnapshotEntity {
key: n.to_string(),
data: json!({"id": {"n": n}, "state": {"v": n * 10}}),
})
.collect()
}
#[test]
fn output_filters_selects_and_limits() {
let filter = Filter::parse(&["state.v>10".to_string()]).unwrap();
let select = filter::parse_select("id.n");
let output = shape_output("T/list", None, rows(), &filter, Some(&select), 1).unwrap();
assert_eq!(
output,
json!({
"view": "T/list",
"total": 3,
"matching": 2,
"returned": 1,
"truncated": true,
"entities": [{"id.n": 2}],
})
);
}
#[test]
fn a_missing_key_is_an_error() {
let filter = Filter::parse(&[]).unwrap();
let error = shape_output("T/state", Some("9"), rows(), &filter, None, 20).unwrap_err();
assert!(error.to_string().contains("No entity with key '9'"));
let found = shape_output("T/state", Some("2"), rows(), &filter, None, 20).unwrap();
assert_eq!(found["entities"][0]["state"]["v"], 20);
assert_eq!(found["key"], "2");
}
}