mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-20 06:44:47 -07:00
global: fixes
This commit is contained in:
@@ -14,141 +14,235 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
|
||||
if !endpoint.should_generate() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let method_name = endpoint_to_method_name(endpoint);
|
||||
let base_return_type = if endpoint.returns_binary() {
|
||||
"Uint8Array".to_string()
|
||||
} else {
|
||||
jsdoc_normalize(&normalize_return_type(
|
||||
endpoint.schema_name().unwrap_or("*"),
|
||||
))
|
||||
};
|
||||
let return_type = if endpoint.supports_csv {
|
||||
format!("{} | string", base_return_type)
|
||||
} else {
|
||||
base_return_type
|
||||
};
|
||||
|
||||
writeln!(output, " /**").unwrap();
|
||||
if let Some(summary) = &endpoint.summary {
|
||||
writeln!(output, " * {}", summary).unwrap();
|
||||
}
|
||||
if let Some(desc) = &endpoint.description
|
||||
&& endpoint.summary.as_ref() != Some(desc)
|
||||
{
|
||||
writeln!(output, " *").unwrap();
|
||||
write_description(output, desc, " * ", " *");
|
||||
match endpoint.method.as_str() {
|
||||
"GET" => generate_get_method(output, endpoint),
|
||||
"POST" => generate_post_method(output, endpoint),
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add endpoint path
|
||||
fn generate_get_method(output: &mut String, endpoint: &Endpoint) {
|
||||
let method_name = endpoint_to_method_name(endpoint);
|
||||
let return_type = build_return_type(endpoint);
|
||||
|
||||
write_method_doc(output, endpoint);
|
||||
for param in &endpoint.path_params {
|
||||
let desc = format_param_desc(param.description.as_deref());
|
||||
let ty = jsdoc_normalize(¶m.param_type);
|
||||
writeln!(output, " * @param {{{}}} {}{}", ty, param.name, desc).unwrap();
|
||||
}
|
||||
for param in &endpoint.query_params {
|
||||
let optional = if param.required { "" } else { "=" };
|
||||
let desc = format_param_desc(param.description.as_deref());
|
||||
let ty = jsdoc_normalize(¶m.param_type);
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{}{}}} [{}]{}",
|
||||
ty, optional, param.name, desc
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{{ signal?: AbortSignal, onValue?: (value: {}) => void }}}} [options]",
|
||||
return_type
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " * @returns {{Promise<{}>}}", return_type).unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
|
||||
let params = build_method_params(endpoint);
|
||||
let params_with_opts = if params.is_empty() {
|
||||
"{ signal, onValue } = {}".to_string()
|
||||
} else {
|
||||
format!("{}, {{ signal, onValue }} = {{}}", params)
|
||||
};
|
||||
writeln!(output, " async {}({}) {{", method_name, params_with_opts).unwrap();
|
||||
|
||||
let path = build_path_template(&endpoint.path, &endpoint.path_params);
|
||||
|
||||
let fetch_call: String = if endpoint.returns_binary() {
|
||||
"this.getBytes(path, { signal, onValue })".to_string()
|
||||
} else if endpoint.returns_json() {
|
||||
"this.getJson(path, { signal, onValue })".to_string()
|
||||
} else if endpoint.response_kind.text_is_numeric() {
|
||||
"Number(await this.getText(path, { signal, onValue }))".to_string()
|
||||
} else {
|
||||
"this.getText(path, { signal, onValue })".to_string()
|
||||
};
|
||||
|
||||
write_path_assignment(output, endpoint, &path);
|
||||
|
||||
if endpoint.supports_csv {
|
||||
writeln!(
|
||||
output,
|
||||
" if (format === 'csv') return this.getText(path, {{ signal, onValue }});"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(output, " return {};", fetch_call).unwrap();
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
}
|
||||
|
||||
fn generate_post_method(output: &mut String, endpoint: &Endpoint) {
|
||||
let method_name = endpoint_to_method_name(endpoint);
|
||||
let return_type = build_return_type(endpoint);
|
||||
|
||||
write_method_doc(output, endpoint);
|
||||
for param in &endpoint.path_params {
|
||||
let desc = format_param_desc(param.description.as_deref());
|
||||
let ty = jsdoc_normalize(¶m.param_type);
|
||||
writeln!(output, " * @param {{{}}} {}{}", ty, param.name, desc).unwrap();
|
||||
}
|
||||
for param in &endpoint.query_params {
|
||||
let optional = if param.required { "" } else { "=" };
|
||||
let desc = format_param_desc(param.description.as_deref());
|
||||
let ty = jsdoc_normalize(¶m.param_type);
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{}{}}} [{}]{}",
|
||||
ty, optional, param.name, desc
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
if let Some(body) = &endpoint.request_body {
|
||||
let optional = if body.required { "" } else { "=" };
|
||||
let ty = jsdoc_normalize(&body.body_type);
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{}{}}} body - Request body",
|
||||
ty, optional
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{{ signal?: AbortSignal }}}} [options]"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " * @returns {{Promise<{}>}}", return_type).unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
|
||||
let mut params = build_method_params(endpoint);
|
||||
if endpoint.request_body.is_some() {
|
||||
if !params.is_empty() {
|
||||
params.push_str(", ");
|
||||
}
|
||||
params.push_str("body");
|
||||
}
|
||||
let params_with_opts = if params.is_empty() {
|
||||
"{ signal } = {}".to_string()
|
||||
} else {
|
||||
format!("{}, {{ signal }} = {{}}", params)
|
||||
};
|
||||
writeln!(output, " async {}({}) {{", method_name, params_with_opts).unwrap();
|
||||
|
||||
let path = build_path_template(&endpoint.path, &endpoint.path_params);
|
||||
let body_arg = if endpoint.request_body.is_some() {
|
||||
"body"
|
||||
} else {
|
||||
"''"
|
||||
};
|
||||
|
||||
let fetch_call: String = if endpoint.returns_binary() {
|
||||
format!("this.postBytes(path, {}, {{ signal }})", body_arg)
|
||||
} else if endpoint.returns_json() {
|
||||
format!("this.postJson(path, {}, {{ signal }})", body_arg)
|
||||
} else if endpoint.response_kind.text_is_numeric() {
|
||||
format!(
|
||||
"Number(await this.postText(path, {}, {{ signal }}))",
|
||||
body_arg
|
||||
)
|
||||
} else {
|
||||
format!("this.postText(path, {}, {{ signal }})", body_arg)
|
||||
};
|
||||
|
||||
write_path_assignment(output, endpoint, &path);
|
||||
|
||||
writeln!(output, " return {};", fetch_call).unwrap();
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
}
|
||||
|
||||
fn build_return_type(endpoint: &Endpoint) -> String {
|
||||
let base = if endpoint.returns_binary() {
|
||||
"Uint8Array".to_string()
|
||||
} else {
|
||||
jsdoc_normalize(&normalize_return_type(
|
||||
endpoint.schema_name().unwrap_or("*"),
|
||||
))
|
||||
};
|
||||
if endpoint.supports_csv {
|
||||
format!("{} | string", base)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
fn write_method_doc(output: &mut String, endpoint: &Endpoint) {
|
||||
writeln!(output, " /**").unwrap();
|
||||
if let Some(summary) = &endpoint.summary {
|
||||
writeln!(output, " * {}", summary).unwrap();
|
||||
}
|
||||
if let Some(desc) = &endpoint.description
|
||||
&& endpoint.summary.as_ref() != Some(desc)
|
||||
{
|
||||
writeln!(output, " *").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" * Endpoint: `{} {}`",
|
||||
endpoint.method.to_uppercase(),
|
||||
endpoint.path
|
||||
)
|
||||
.unwrap();
|
||||
write_description(output, desc, " * ", " *");
|
||||
}
|
||||
writeln!(output, " *").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" * Endpoint: `{} {}`",
|
||||
endpoint.method.to_uppercase(),
|
||||
endpoint.path
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if !endpoint.path_params.is_empty() || !endpoint.query_params.is_empty() {
|
||||
writeln!(output, " *").unwrap();
|
||||
}
|
||||
let has_body_param = endpoint.method == "POST" && endpoint.request_body.is_some();
|
||||
if !endpoint.path_params.is_empty() || !endpoint.query_params.is_empty() || has_body_param {
|
||||
writeln!(output, " *").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
for param in &endpoint.path_params {
|
||||
let desc = format_param_desc(param.description.as_deref());
|
||||
let ty = jsdoc_normalize(¶m.param_type);
|
||||
writeln!(output, " * @param {{{}}} {}{}", ty, param.name, desc).unwrap();
|
||||
}
|
||||
fn write_path_assignment(output: &mut String, endpoint: &Endpoint, path: &str) {
|
||||
if endpoint.query_params.is_empty() {
|
||||
writeln!(output, " const path = `{}`;", path).unwrap();
|
||||
} else {
|
||||
writeln!(output, " const params = new URLSearchParams();").unwrap();
|
||||
for param in &endpoint.query_params {
|
||||
let optional = if param.required { "" } else { "=" };
|
||||
let desc = format_param_desc(param.description.as_deref());
|
||||
let ty = jsdoc_normalize(¶m.param_type);
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{}{}}} [{}]{}",
|
||||
ty, optional, param.name, desc
|
||||
)
|
||||
.unwrap();
|
||||
let ident = sanitize_ident(¶m.name);
|
||||
let is_array = param.param_type.ends_with("[]");
|
||||
if is_array {
|
||||
writeln!(
|
||||
output,
|
||||
" for (const _v of {}) params.append('{}', String(_v));",
|
||||
ident, param.name
|
||||
)
|
||||
.unwrap();
|
||||
} else if param.required {
|
||||
writeln!(
|
||||
output,
|
||||
" params.set('{}', String({}));",
|
||||
param.name, ident
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" if ({} !== undefined) params.set('{}', String({}));",
|
||||
ident, param.name, ident
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output, " const query = params.toString();").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{{ signal?: AbortSignal, onValue?: (value: {}) => void }}}} [options]",
|
||||
return_type
|
||||
" const path = `{}${{query ? '?' + query : ''}}`;",
|
||||
path
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " * @returns {{Promise<{}>}}", return_type).unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
|
||||
let params = build_method_params(endpoint);
|
||||
let params_with_opts = if params.is_empty() {
|
||||
"{ signal, onValue } = {}".to_string()
|
||||
} else {
|
||||
format!("{}, {{ signal, onValue }} = {{}}", params)
|
||||
};
|
||||
writeln!(output, " async {}({}) {{", method_name, params_with_opts).unwrap();
|
||||
|
||||
let path = build_path_template(&endpoint.path, &endpoint.path_params);
|
||||
|
||||
let fetch_call: String = if endpoint.returns_binary() {
|
||||
"this.getBytes(path, { signal, onValue })".to_string()
|
||||
} else if endpoint.returns_json() {
|
||||
"this.getJson(path, { signal, onValue })".to_string()
|
||||
} else if endpoint.response_kind.text_is_numeric() {
|
||||
"Number(await this.getText(path, { signal, onValue }))".to_string()
|
||||
} else {
|
||||
"this.getText(path, { signal, onValue })".to_string()
|
||||
};
|
||||
|
||||
if endpoint.query_params.is_empty() {
|
||||
writeln!(output, " const path = `{}`;", path).unwrap();
|
||||
} else {
|
||||
writeln!(output, " const params = new URLSearchParams();").unwrap();
|
||||
for param in &endpoint.query_params {
|
||||
let ident = sanitize_ident(¶m.name);
|
||||
let is_array = param.param_type.ends_with("[]");
|
||||
if is_array {
|
||||
writeln!(
|
||||
output,
|
||||
" for (const _v of {}) params.append('{}', String(_v));",
|
||||
ident, param.name
|
||||
)
|
||||
.unwrap();
|
||||
} else if param.required {
|
||||
writeln!(
|
||||
output,
|
||||
" params.set('{}', String({}));",
|
||||
param.name, ident
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" if ({} !== undefined) params.set('{}', String({}));",
|
||||
ident, param.name, ident
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output, " const query = params.toString();").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" const path = `{}${{query ? '?' + query : ''}}`;",
|
||||
path
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
if endpoint.supports_csv {
|
||||
writeln!(
|
||||
output,
|
||||
" if (format === 'csv') return this.getText(path, {{ signal, onValue }});"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(output, " return {};", fetch_call).unwrap();
|
||||
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -569,6 +569,67 @@ class BrkClientBase {{
|
||||
return this._getCached(path, async (res) => new Uint8Array(await res.arrayBuffer()), options);
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a POST request with a string body.
|
||||
*
|
||||
* POST responses are uncached and never invoke `onValue` — every call hits
|
||||
* the network with the same body and returns the upstream response.
|
||||
*
|
||||
* @param {{string}} path
|
||||
* @param {{string}} body
|
||||
* @param {{{{ signal?: AbortSignal }}}} [options]
|
||||
* @returns {{Promise<Response>}}
|
||||
*/
|
||||
async post(path, body, {{ signal }} = {{}}) {{
|
||||
const url = `${{this.baseUrl}}${{path}}`;
|
||||
const signals = [AbortSignal.timeout(this.timeout)];
|
||||
if (signal) signals.push(signal);
|
||||
const res = await fetch(url, {{
|
||||
method: 'POST',
|
||||
body,
|
||||
signal: AbortSignal.any(signals),
|
||||
}});
|
||||
if (!res.ok) throw new BrkError(`HTTP ${{res.status}}: ${{url}}`, res.status);
|
||||
return res;
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a POST request expecting a JSON response.
|
||||
* @template T
|
||||
* @param {{string}} path
|
||||
* @param {{string}} body
|
||||
* @param {{{{ signal?: AbortSignal }}}} [options]
|
||||
* @returns {{Promise<T>}}
|
||||
*/
|
||||
async postJson(path, body, options) {{
|
||||
const res = await this.post(path, body, options);
|
||||
return _addCamelGetters(await res.json());
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a POST request expecting a text response.
|
||||
* @param {{string}} path
|
||||
* @param {{string}} body
|
||||
* @param {{{{ signal?: AbortSignal }}}} [options]
|
||||
* @returns {{Promise<string>}}
|
||||
*/
|
||||
async postText(path, body, options) {{
|
||||
const res = await this.post(path, body, options);
|
||||
return res.text();
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a POST request expecting binary data (application/octet-stream).
|
||||
* @param {{string}} path
|
||||
* @param {{string}} body
|
||||
* @param {{{{ signal?: AbortSignal }}}} [options]
|
||||
* @returns {{Promise<Uint8Array>}}
|
||||
*/
|
||||
async postBytes(path, body, options) {{
|
||||
const res = await this.post(path, body, options);
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
}}
|
||||
|
||||
/**
|
||||
* Fetch series data and wrap with helper methods (internal)
|
||||
* @template T
|
||||
|
||||
@@ -162,12 +162,20 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
|
||||
// Build path
|
||||
let path = build_path_template(&endpoint.path, &endpoint.path_params);
|
||||
|
||||
let fetch_method = if endpoint.returns_binary() {
|
||||
"get"
|
||||
} else if endpoint.returns_json() {
|
||||
"get_json"
|
||||
let is_post = endpoint.method == "POST";
|
||||
let fetch_method = match (is_post, &endpoint.response_kind) {
|
||||
(false, _) if endpoint.returns_binary() => "get",
|
||||
(false, _) if endpoint.returns_json() => "get_json",
|
||||
(false, _) => "get_text",
|
||||
(true, _) if endpoint.returns_binary() => "post",
|
||||
(true, _) if endpoint.returns_json() => "post_json",
|
||||
(true, _) => "post_text",
|
||||
};
|
||||
|
||||
let body_arg = if is_post && endpoint.request_body.is_some() {
|
||||
", body"
|
||||
} else {
|
||||
"get_text"
|
||||
""
|
||||
};
|
||||
|
||||
let (wrap_prefix, wrap_suffix) = if endpoint.response_kind.text_is_numeric() {
|
||||
@@ -180,15 +188,15 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
|
||||
if endpoint.path_params.is_empty() {
|
||||
writeln!(
|
||||
output,
|
||||
" return {}self.{}('{}'){}",
|
||||
wrap_prefix, fetch_method, path, wrap_suffix
|
||||
" return {}self.{}('{}'{}){}",
|
||||
wrap_prefix, fetch_method, path, body_arg, wrap_suffix
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" return {}self.{}(f'{}'){}",
|
||||
wrap_prefix, fetch_method, path, wrap_suffix
|
||||
" return {}self.{}(f'{}'{}){}",
|
||||
wrap_prefix, fetch_method, path, body_arg, wrap_suffix
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -234,15 +242,15 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
|
||||
writeln!(output, " return self.get_text(path)").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" return {}self.{}(path){}",
|
||||
wrap_prefix, fetch_method, wrap_suffix
|
||||
" return {}self.{}(path{}){}",
|
||||
wrap_prefix, fetch_method, body_arg, wrap_suffix
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" return {}self.{}(path){}",
|
||||
wrap_prefix, fetch_method, wrap_suffix
|
||||
" return {}self.{}(path{}){}",
|
||||
wrap_prefix, fetch_method, body_arg, wrap_suffix
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -279,6 +287,14 @@ fn build_method_params(endpoint: &Endpoint) -> String {
|
||||
params.push(format!(", {}: Optional[{}] = None", safe_name, py_type));
|
||||
}
|
||||
}
|
||||
if let Some(body) = &endpoint.request_body {
|
||||
let py_type = js_type_to_python(&body.body_type);
|
||||
if body.required {
|
||||
params.push(format!(", body: {}", py_type));
|
||||
} else {
|
||||
params.push(format!(", body: Optional[{}] = None", py_type));
|
||||
}
|
||||
}
|
||||
params.join("")
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,28 @@ class BrkClientBase:
|
||||
"""Make a GET request and return text."""
|
||||
return self.get(path).decode()
|
||||
|
||||
def post(self, path: str, body: str) -> bytes:
|
||||
"""Make a POST request with a string body and return raw bytes."""
|
||||
try:
|
||||
conn = self._connect()
|
||||
conn.request("POST", path, body=body)
|
||||
res = conn.getresponse()
|
||||
data = res.read()
|
||||
if res.status >= 400:
|
||||
raise BrkError(f"HTTP error: {{res.status}}", res.status)
|
||||
return data
|
||||
except (ConnectionError, OSError, TimeoutError) as e:
|
||||
self._conn = None
|
||||
raise BrkError(str(e))
|
||||
|
||||
def post_json(self, path: str, body: str) -> Any:
|
||||
"""Make a POST request and return JSON."""
|
||||
return json.loads(self.post(path, body))
|
||||
|
||||
def post_text(self, path: str, body: str) -> str:
|
||||
"""Make a POST request and return text."""
|
||||
return self.post(path, body).decode()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the HTTP client."""
|
||||
if self._conn:
|
||||
|
||||
@@ -87,132 +87,200 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
|
||||
if !endpoint.should_generate() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let method_name = endpoint_to_method_name(endpoint);
|
||||
let base_return_type = if endpoint.returns_binary() {
|
||||
"Vec<u8>".to_string()
|
||||
} else if endpoint.returns_text() {
|
||||
// Text bodies arrive as `String`; per-type parsing is left to the caller.
|
||||
"String".to_string()
|
||||
} else {
|
||||
endpoint
|
||||
.schema_name()
|
||||
.map(js_type_to_rust)
|
||||
.unwrap_or_else(|| "String".to_string())
|
||||
};
|
||||
|
||||
let return_type = if endpoint.supports_csv {
|
||||
format!("FormatResponse<{}>", base_return_type)
|
||||
} else {
|
||||
base_return_type.clone()
|
||||
};
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
" /// {}",
|
||||
endpoint.summary.as_deref().unwrap_or(&method_name)
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(desc) = &endpoint.description
|
||||
&& endpoint.summary.as_ref() != Some(desc)
|
||||
{
|
||||
writeln!(output, " ///").unwrap();
|
||||
write_description(output, desc, " /// ", " ///");
|
||||
match endpoint.method.as_str() {
|
||||
"GET" => generate_get_method(output, endpoint),
|
||||
"POST" => generate_post_method(output, endpoint),
|
||||
_ => continue,
|
||||
}
|
||||
// Add endpoint path
|
||||
writeln!(output, " ///").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" /// Endpoint: `{} {}`",
|
||||
endpoint.method.to_uppercase(),
|
||||
endpoint.path
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let params = build_method_params(endpoint);
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn {}(&self{}) -> Result<{}> {{",
|
||||
method_name, params, return_type
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (path, index_arg) = build_path_template(endpoint);
|
||||
let fetch_method = if endpoint.returns_binary() {
|
||||
"get_bytes"
|
||||
} else if endpoint.returns_json() {
|
||||
"get_json"
|
||||
} else {
|
||||
"get_text"
|
||||
};
|
||||
|
||||
if endpoint.query_params.is_empty() {
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.{}(&format!(\"{}\"{}))",
|
||||
fetch_method, path, index_arg
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(output, " let mut query = Vec::new();").unwrap();
|
||||
for param in &endpoint.query_params {
|
||||
let ident = sanitize_ident(¶m.name);
|
||||
let is_array = param.param_type.ends_with("[]");
|
||||
if is_array {
|
||||
writeln!(
|
||||
output,
|
||||
" for v in {} {{ query.push(format!(\"{}={{}}\", v)); }}",
|
||||
ident, param.name
|
||||
)
|
||||
.unwrap();
|
||||
} else if param.required {
|
||||
writeln!(
|
||||
output,
|
||||
" query.push(format!(\"{}={{}}\", {}));",
|
||||
param.name, ident
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" if let Some(v) = {} {{ query.push(format!(\"{}={{}}\", v)); }}",
|
||||
ident, param.name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output, " let query_str = if query.is_empty() {{ String::new() }} else {{ format!(\"?{{}}\", query.join(\"&\")) }};").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" let path = format!(\"{}{{}}\"{}, query_str);",
|
||||
path, index_arg
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if endpoint.supports_csv {
|
||||
writeln!(output, " if format == Some(Format::CSV) {{").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.get_text(&path).map(FormatResponse::Csv)"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " }} else {{").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.{}(&path).map(FormatResponse::Json)",
|
||||
fetch_method
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " }}").unwrap();
|
||||
} else {
|
||||
writeln!(output, " self.base.{}(&path)", fetch_method).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_get_method(output: &mut String, endpoint: &Endpoint) {
|
||||
let method_name = endpoint_to_method_name(endpoint);
|
||||
let return_type = build_return_type(endpoint);
|
||||
|
||||
write_method_doc(output, endpoint);
|
||||
|
||||
let params = build_method_params(endpoint);
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn {}(&self{}) -> Result<{}> {{",
|
||||
method_name, params, return_type
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (path, index_arg) = build_path_template(endpoint);
|
||||
let fetch_method = if endpoint.returns_binary() {
|
||||
"get_bytes"
|
||||
} else if endpoint.returns_json() {
|
||||
"get_json"
|
||||
} else {
|
||||
"get_text"
|
||||
};
|
||||
|
||||
if endpoint.query_params.is_empty() {
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.{}(&format!(\"{}\"{}))",
|
||||
fetch_method, path, index_arg
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
write_query_assembly(output, endpoint, &path, &index_arg);
|
||||
|
||||
if endpoint.supports_csv {
|
||||
writeln!(output, " if format == Some(Format::CSV) {{").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.get_text(&path).map(FormatResponse::Csv)"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " }} else {{").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.{}(&path).map(FormatResponse::Json)",
|
||||
fetch_method
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " }}").unwrap();
|
||||
} else {
|
||||
writeln!(output, " self.base.{}(&path)", fetch_method).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
}
|
||||
|
||||
fn generate_post_method(output: &mut String, endpoint: &Endpoint) {
|
||||
let method_name = endpoint_to_method_name(endpoint);
|
||||
let return_type = build_return_type(endpoint);
|
||||
|
||||
write_method_doc(output, endpoint);
|
||||
|
||||
let mut params = build_method_params(endpoint);
|
||||
if endpoint.request_body.is_some() {
|
||||
params.push_str(", body: &str");
|
||||
}
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn {}(&self{}) -> Result<{}> {{",
|
||||
method_name, params, return_type
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (path, index_arg) = build_path_template(endpoint);
|
||||
let body_arg = if endpoint.request_body.is_some() {
|
||||
"body"
|
||||
} else {
|
||||
"\"\""
|
||||
};
|
||||
let fetch_method = if endpoint.returns_binary() {
|
||||
"post_bytes"
|
||||
} else if endpoint.returns_json() {
|
||||
"post_json"
|
||||
} else {
|
||||
"post_text"
|
||||
};
|
||||
|
||||
if endpoint.query_params.is_empty() {
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.{}(&format!(\"{}\"{}), {})",
|
||||
fetch_method, path, index_arg, body_arg
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
write_query_assembly(output, endpoint, &path, &index_arg);
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.{}(&path, {})",
|
||||
fetch_method, body_arg
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
}
|
||||
|
||||
fn build_return_type(endpoint: &Endpoint) -> String {
|
||||
let base = if endpoint.returns_binary() {
|
||||
"Vec<u8>".to_string()
|
||||
} else if endpoint.returns_text() {
|
||||
"String".to_string()
|
||||
} else {
|
||||
endpoint
|
||||
.schema_name()
|
||||
.map(js_type_to_rust)
|
||||
.unwrap_or_else(|| "String".to_string())
|
||||
};
|
||||
if endpoint.supports_csv {
|
||||
format!("FormatResponse<{}>", base)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
fn write_method_doc(output: &mut String, endpoint: &Endpoint) {
|
||||
let method_name = endpoint_to_method_name(endpoint);
|
||||
writeln!(
|
||||
output,
|
||||
" /// {}",
|
||||
endpoint.summary.as_deref().unwrap_or(&method_name)
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(desc) = &endpoint.description
|
||||
&& endpoint.summary.as_ref() != Some(desc)
|
||||
{
|
||||
writeln!(output, " ///").unwrap();
|
||||
write_description(output, desc, " /// ", " ///");
|
||||
}
|
||||
writeln!(output, " ///").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" /// Endpoint: `{} {}`",
|
||||
endpoint.method.to_uppercase(),
|
||||
endpoint.path
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_query_assembly(output: &mut String, endpoint: &Endpoint, path: &str, index_arg: &str) {
|
||||
writeln!(output, " let mut query = Vec::new();").unwrap();
|
||||
for param in &endpoint.query_params {
|
||||
let ident = sanitize_ident(¶m.name);
|
||||
let is_array = param.param_type.ends_with("[]");
|
||||
if is_array {
|
||||
writeln!(
|
||||
output,
|
||||
" for v in {} {{ query.push(format!(\"{}={{}}\", v)); }}",
|
||||
ident, param.name
|
||||
)
|
||||
.unwrap();
|
||||
} else if param.required {
|
||||
writeln!(
|
||||
output,
|
||||
" query.push(format!(\"{}={{}}\", {}));",
|
||||
param.name, ident
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" if let Some(v) = {} {{ query.push(format!(\"{}={{}}\", v)); }}",
|
||||
ident, param.name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output, " let query_str = if query.is_empty() {{ String::new() }} else {{ format!(\"?{{}}\", query.join(\"&\")) }};").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" let path = format!(\"{}{{}}\"{}, query_str);",
|
||||
path, index_arg
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn endpoint_to_method_name(endpoint: &Endpoint) -> String {
|
||||
to_snake_case(&endpoint.operation_name())
|
||||
}
|
||||
|
||||
@@ -111,6 +111,30 @@ impl BrkClientBase {{
|
||||
.and_then(|mut r| r.body_mut().read_to_vec())
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})
|
||||
}}
|
||||
|
||||
/// Make a POST request and deserialize JSON response.
|
||||
pub fn post_json<T: DeserializeOwned>(&self, path: &str, body: &str) -> Result<T> {{
|
||||
self.agent.post(&self.url(path))
|
||||
.send(body)
|
||||
.and_then(|mut r| r.body_mut().read_json())
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})
|
||||
}}
|
||||
|
||||
/// Make a POST request and return raw text response.
|
||||
pub fn post_text(&self, path: &str, body: &str) -> Result<String> {{
|
||||
self.agent.post(&self.url(path))
|
||||
.send(body)
|
||||
.and_then(|mut r| r.body_mut().read_to_string())
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})
|
||||
}}
|
||||
|
||||
/// Make a POST request and return raw bytes response.
|
||||
pub fn post_bytes(&self, path: &str, body: &str) -> Result<Vec<u8>> {{
|
||||
self.agent.post(&self.url(path))
|
||||
.send(body)
|
||||
.and_then(|mut r| r.body_mut().read_to_vec())
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Build series name with suffix.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
use crate::openapi::{Parameter, ResponseKind};
|
||||
|
||||
/// Request body shape for POST/PUT/PATCH endpoints.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RequestBody {
|
||||
/// Body content type as a name (e.g. "string" for text/plain, "Foo" for an `application/json` $ref).
|
||||
pub body_type: String,
|
||||
/// Whether the body is required.
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
/// Endpoint information extracted from OpenAPI spec.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Endpoint {
|
||||
@@ -17,6 +26,8 @@ pub struct Endpoint {
|
||||
pub path_params: Vec<Parameter>,
|
||||
/// Query parameters
|
||||
pub query_params: Vec<Parameter>,
|
||||
/// Request body, if any (POST/PUT/PATCH).
|
||||
pub request_body: Option<RequestBody>,
|
||||
/// Body kind for the 200 response.
|
||||
pub response_kind: ResponseKind,
|
||||
/// Whether this endpoint is deprecated
|
||||
@@ -27,9 +38,9 @@ pub struct Endpoint {
|
||||
|
||||
impl Endpoint {
|
||||
/// Returns true if this endpoint should be included in client generation.
|
||||
/// Only non-deprecated GET endpoints are included.
|
||||
/// Non-deprecated GET and POST endpoints are included.
|
||||
pub fn should_generate(&self) -> bool {
|
||||
self.method == "GET" && !self.deprecated
|
||||
!self.deprecated && (self.method == "GET" || self.method == "POST")
|
||||
}
|
||||
|
||||
/// Returns true if this endpoint returns JSON.
|
||||
|
||||
@@ -3,7 +3,7 @@ mod parameter;
|
||||
mod response_kind;
|
||||
mod text_schema;
|
||||
|
||||
pub use endpoint::Endpoint;
|
||||
pub use endpoint::{Endpoint, RequestBody};
|
||||
pub use parameter::Parameter;
|
||||
pub use response_kind::ResponseKind;
|
||||
pub use text_schema::TextSchema;
|
||||
@@ -129,6 +129,7 @@ fn extract_endpoint(
|
||||
let query_params = extract_parameters(operation, ParameterIn::Query);
|
||||
|
||||
let response_kind = extract_response_kind(operation, spec);
|
||||
let request_body = extract_request_body(operation);
|
||||
let supports_csv = check_csv_support(operation);
|
||||
|
||||
Some(Endpoint {
|
||||
@@ -139,12 +140,38 @@ fn extract_endpoint(
|
||||
description: operation.description.clone(),
|
||||
path_params,
|
||||
query_params,
|
||||
request_body,
|
||||
response_kind,
|
||||
deprecated: operation.deprecated.unwrap_or(false),
|
||||
supports_csv,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the request body shape, if any.
|
||||
/// Prefers `text/plain` (string) over `application/json` (typed).
|
||||
fn extract_request_body(operation: &Operation) -> Option<RequestBody> {
|
||||
let req = operation.request_body.as_ref()?;
|
||||
let req = match req {
|
||||
ObjectOrReference::Object(rb) => rb,
|
||||
ObjectOrReference::Ref { .. } => return None,
|
||||
};
|
||||
|
||||
let body_type = if req.content.contains_key("text/plain; charset=utf-8")
|
||||
|| req.content.contains_key("text/plain")
|
||||
{
|
||||
"string".to_string()
|
||||
} else if let Some(content) = req.content.get("application/json") {
|
||||
schema_name_from_content(content).unwrap_or_else(|| "Object".to_string())
|
||||
} else {
|
||||
"string".to_string()
|
||||
};
|
||||
|
||||
Some(RequestBody {
|
||||
body_type,
|
||||
required: req.required.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if the endpoint supports CSV format (has text/csv in 200 response content types).
|
||||
fn check_csv_support(operation: &Operation) -> bool {
|
||||
let Some(responses) = operation.responses.as_ref() else {
|
||||
|
||||
@@ -99,6 +99,30 @@ impl BrkClientBase {
|
||||
.and_then(|mut r| r.body_mut().read_to_vec())
|
||||
.map_err(|e| BrkError { message: e.to_string() })
|
||||
}
|
||||
|
||||
/// Make a POST request and deserialize JSON response.
|
||||
pub fn post_json<T: DeserializeOwned>(&self, path: &str, body: &str) -> Result<T> {
|
||||
self.agent.post(&self.url(path))
|
||||
.send(body)
|
||||
.and_then(|mut r| r.body_mut().read_json())
|
||||
.map_err(|e| BrkError { message: e.to_string() })
|
||||
}
|
||||
|
||||
/// Make a POST request and return raw text response.
|
||||
pub fn post_text(&self, path: &str, body: &str) -> Result<String> {
|
||||
self.agent.post(&self.url(path))
|
||||
.send(body)
|
||||
.and_then(|mut r| r.body_mut().read_to_string())
|
||||
.map_err(|e| BrkError { message: e.to_string() })
|
||||
}
|
||||
|
||||
/// Make a POST request and return raw bytes response.
|
||||
pub fn post_bytes(&self, path: &str, body: &str) -> Result<Vec<u8>> {
|
||||
self.agent.post(&self.url(path))
|
||||
.send(body)
|
||||
.and_then(|mut r| r.body_mut().read_to_vec())
|
||||
.map_err(|e| BrkError { message: e.to_string() })
|
||||
}
|
||||
}
|
||||
|
||||
/// Build series name with suffix.
|
||||
@@ -9002,42 +9026,45 @@ impl BrkClient {
|
||||
|
||||
/// Address transactions
|
||||
///
|
||||
/// Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. Use ?after_txid=<txid> for pagination.
|
||||
/// Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`.
|
||||
///
|
||||
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
|
||||
///
|
||||
/// Endpoint: `GET /api/address/{address}/txs`
|
||||
pub fn get_address_txs(&self, address: Addr, after_txid: Option<Txid>) -> Result<Vec<Transaction>> {
|
||||
let mut query = Vec::new();
|
||||
if let Some(v) = after_txid { query.push(format!("after_txid={}", v)); }
|
||||
let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) };
|
||||
let path = format!("/api/address/{address}/txs{}", query_str);
|
||||
self.base.get_json(&path)
|
||||
pub fn get_address_txs(&self, address: Addr) -> Result<Vec<Transaction>> {
|
||||
self.base.get_json(&format!("/api/address/{address}/txs"))
|
||||
}
|
||||
|
||||
/// Address confirmed transactions
|
||||
///
|
||||
/// Get confirmed transactions for an address, 25 per page. Use ?after_txid=<txid> for pagination.
|
||||
/// Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`.
|
||||
///
|
||||
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*
|
||||
///
|
||||
/// Endpoint: `GET /api/address/{address}/txs/chain`
|
||||
pub fn get_address_confirmed_txs(&self, address: Addr, after_txid: Option<Txid>) -> Result<Vec<Transaction>> {
|
||||
let mut query = Vec::new();
|
||||
if let Some(v) = after_txid { query.push(format!("after_txid={}", v)); }
|
||||
let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) };
|
||||
let path = format!("/api/address/{address}/txs/chain{}", query_str);
|
||||
self.base.get_json(&path)
|
||||
pub fn get_address_confirmed_txs(&self, address: Addr) -> Result<Vec<Transaction>> {
|
||||
self.base.get_json(&format!("/api/address/{address}/txs/chain"))
|
||||
}
|
||||
|
||||
/// Address confirmed transactions (paginated)
|
||||
///
|
||||
/// Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space).
|
||||
///
|
||||
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*
|
||||
///
|
||||
/// Endpoint: `GET /api/address/{address}/txs/chain/{after_txid}`
|
||||
pub fn get_address_confirmed_txs_after(&self, address: Addr, after_txid: Txid) -> Result<Vec<Transaction>> {
|
||||
self.base.get_json(&format!("/api/address/{address}/txs/chain/{after_txid}"))
|
||||
}
|
||||
|
||||
/// Address mempool transactions
|
||||
///
|
||||
/// Get unconfirmed transaction IDs for an address from the mempool (up to 50).
|
||||
/// Get unconfirmed transactions for an address from the mempool, newest first (up to 50).
|
||||
///
|
||||
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)*
|
||||
///
|
||||
/// Endpoint: `GET /api/address/{address}/txs/mempool`
|
||||
pub fn get_address_mempool_txs(&self, address: Addr) -> Result<Vec<Txid>> {
|
||||
pub fn get_address_mempool_txs(&self, address: Addr) -> Result<Vec<Transaction>> {
|
||||
self.base.get_json(&format!("/api/address/{address}/txs/mempool"))
|
||||
}
|
||||
|
||||
@@ -9408,6 +9435,17 @@ impl BrkClient {
|
||||
self.base.get_json(&format!("/api/server/sync"))
|
||||
}
|
||||
|
||||
/// Broadcast transaction
|
||||
///
|
||||
/// Broadcast a raw transaction to the network. The transaction should be provided as hex in the request body. The txid will be returned on success.
|
||||
///
|
||||
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#post-transaction)*
|
||||
///
|
||||
/// Endpoint: `POST /api/tx`
|
||||
pub fn post_tx(&self, body: &str) -> Result<Txid> {
|
||||
self.base.post_json(&format!("/api/tx"), body)
|
||||
}
|
||||
|
||||
/// Txid by index
|
||||
///
|
||||
/// Retrieve the transaction ID (txid) at a given global transaction index. Returns the txid as plain text.
|
||||
|
||||
@@ -17,7 +17,7 @@ use std::{sync::Arc, thread, time::Duration};
|
||||
|
||||
use brk_error::Result;
|
||||
use brk_rpc::Client;
|
||||
use brk_types::{AddrBytes, MempoolInfo, TxOut, Txid, Vout};
|
||||
use brk_types::{AddrBytes, MempoolInfo, OutpointPrefix, TxOut, Txid, TxidPrefix, Vin, Vout};
|
||||
use parking_lot::RwLockReadGuard;
|
||||
use tracing::error;
|
||||
|
||||
@@ -75,6 +75,25 @@ impl Mempool {
|
||||
self.0.state.addrs.read().stats_hash(addr)
|
||||
}
|
||||
|
||||
/// Look up the mempool tx that spends `(txid, vout)`. Returns
|
||||
/// `(spender_txid, vin)` if the outpoint is spent in the mempool,
|
||||
/// `None` otherwise. The spender's input list is walked to rule
|
||||
/// out a `TxidPrefix` collision before returning a match.
|
||||
pub fn lookup_spender(&self, txid: &Txid, vout: Vout) -> Option<(Txid, Vin)> {
|
||||
let key = OutpointPrefix::new(TxidPrefix::from(txid), vout);
|
||||
let txs = self.0.state.txs.read();
|
||||
let entries = self.0.state.entries.read();
|
||||
let outpoint_spends = self.0.state.outpoint_spends.read();
|
||||
let idx = outpoint_spends.get(&key)?;
|
||||
let spender_txid = entries.slot(idx)?.txid.clone();
|
||||
let spender_tx = txs.get(&spender_txid)?;
|
||||
let vin_pos = spender_tx
|
||||
.input
|
||||
.iter()
|
||||
.position(|inp| inp.txid == *txid && inp.vout == vout)?;
|
||||
Some((spender_txid, Vin::from(vin_pos)))
|
||||
}
|
||||
|
||||
pub fn txs(&self) -> RwLockReadGuard<'_, TxStore> {
|
||||
self.0.state.txs.read()
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ impl Applier {
|
||||
}
|
||||
|
||||
fn bury_one(s: &mut LockedState, prefix: &TxidPrefix, reason: TxRemoval) {
|
||||
let Some(entry) = s.entries.remove(prefix) else {
|
||||
let Some((idx, entry)) = s.entries.remove(prefix) else {
|
||||
return;
|
||||
};
|
||||
let txid = entry.txid.clone();
|
||||
@@ -41,6 +41,7 @@ impl Applier {
|
||||
};
|
||||
s.info.remove(&tx, entry.fee);
|
||||
s.addrs.remove_tx(&tx, &txid);
|
||||
s.outpoint_spends.remove_spends(&tx, idx);
|
||||
s.graveyard.bury(txid, tx, entry, reason);
|
||||
}
|
||||
|
||||
@@ -71,7 +72,8 @@ impl Applier {
|
||||
s.info.add(&tx, entry.fee);
|
||||
s.addrs.add_tx(&tx, &entry.txid);
|
||||
let txid = entry.txid.clone();
|
||||
s.entries.insert(entry);
|
||||
let idx = s.entries.insert(entry);
|
||||
s.outpoint_spends.insert_spends(&tx, idx);
|
||||
(txid, tx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,11 @@ pub struct EntryPool {
|
||||
}
|
||||
|
||||
impl EntryPool {
|
||||
pub fn insert(&mut self, entry: TxEntry) {
|
||||
pub fn insert(&mut self, entry: TxEntry) -> TxIndex {
|
||||
let prefix = entry.txid_prefix();
|
||||
let idx = self.claim_slot(entry);
|
||||
self.prefix_to_idx.insert(prefix, idx);
|
||||
idx
|
||||
}
|
||||
|
||||
fn claim_slot(&mut self, entry: TxEntry) -> TxIndex {
|
||||
@@ -53,11 +54,11 @@ impl EntryPool {
|
||||
self.entries.get(idx.as_usize())?.as_ref()
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, prefix: &TxidPrefix) -> Option<TxEntry> {
|
||||
pub fn remove(&mut self, prefix: &TxidPrefix) -> Option<(TxIndex, TxEntry)> {
|
||||
let idx = self.prefix_to_idx.remove(prefix)?;
|
||||
let entry = self.entries.get_mut(idx.as_usize())?.take()?;
|
||||
self.free_slots.push(idx);
|
||||
Some(entry)
|
||||
Some((idx, entry))
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[Option<TxEntry>] {
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
//! Stateful in-memory holders. Each owns its `RwLock` and exposes a
|
||||
//! behaviour-shaped API (insert, remove, evict, query).
|
||||
//!
|
||||
//! [`state::MempoolState`] aggregates four locked buckets:
|
||||
//! [`state::MempoolState`] aggregates five locked buckets:
|
||||
//!
|
||||
//! - [`tx_store::TxStore`] - full `Transaction` data for live txs.
|
||||
//! - [`addr_tracker::AddrTracker`] - per-address mempool stats.
|
||||
//! - [`entry_pool::EntryPool`] - slot-recycled [`TxEntry`](crate::TxEntry)
|
||||
//! storage indexed by [`entry_pool::TxIndex`].
|
||||
//! - [`outpoint_spends::OutpointSpends`] - outpoint → spending mempool
|
||||
//! tx index, used to answer mempool-to-mempool outspend queries.
|
||||
//! - [`tx_graveyard::TxGraveyard`] - recently-dropped txs as
|
||||
//! [`tx_graveyard::TxTombstone`]s, retained for reappearance
|
||||
//! detection and post-mine analytics.
|
||||
//!
|
||||
//! A fifth bucket, `info`, holds a `MempoolInfo` from `brk_types`,
|
||||
//! A sixth bucket, `info`, holds a `MempoolInfo` from `brk_types`,
|
||||
//! so it has no file here.
|
||||
|
||||
pub mod addr_tracker;
|
||||
pub mod entry_pool;
|
||||
pub(crate) mod outpoint_spends;
|
||||
pub mod state;
|
||||
pub mod tx_graveyard;
|
||||
pub mod tx_store;
|
||||
|
||||
pub use addr_tracker::AddrTracker;
|
||||
pub use entry_pool::{EntryPool, TxIndex};
|
||||
pub(crate) use outpoint_spends::OutpointSpends;
|
||||
pub(crate) use state::LockedState;
|
||||
pub use state::MempoolState;
|
||||
pub use tx_graveyard::{TxGraveyard, TxTombstone};
|
||||
|
||||
45
crates/brk_mempool/src/stores/outpoint_spends.rs
Normal file
45
crates/brk_mempool/src/stores/outpoint_spends.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use brk_types::{OutpointPrefix, Transaction, TxidPrefix};
|
||||
use derive_more::Deref;
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use super::TxIndex;
|
||||
|
||||
/// Mempool index from spent outpoint to spending mempool tx.
|
||||
///
|
||||
/// Keys are `OutpointPrefix` (8 bytes txid + 2 bytes vout); prefix
|
||||
/// collisions are possible, so callers must verify the candidate
|
||||
/// spender's input list. Values are slot indices into `EntryPool`,
|
||||
/// stable for the lifetime of an entry.
|
||||
#[derive(Default, Deref)]
|
||||
pub struct OutpointSpends(FxHashMap<OutpointPrefix, TxIndex>);
|
||||
|
||||
impl OutpointSpends {
|
||||
pub fn insert_spends(&mut self, tx: &Transaction, idx: TxIndex) {
|
||||
for input in &tx.input {
|
||||
if input.is_coinbase {
|
||||
continue;
|
||||
}
|
||||
let key = OutpointPrefix::new(TxidPrefix::from(&input.txid), input.vout);
|
||||
self.0.insert(key, idx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Only removes entries whose stored `TxIndex` still matches `idx`,
|
||||
/// so a slot already recycled by a later insert is left alone.
|
||||
pub fn remove_spends(&mut self, tx: &Transaction, idx: TxIndex) {
|
||||
for input in &tx.input {
|
||||
if input.is_coinbase {
|
||||
continue;
|
||||
}
|
||||
let key = OutpointPrefix::new(TxidPrefix::from(&input.txid), input.vout);
|
||||
if self.0.get(&key) == Some(&idx) {
|
||||
self.0.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get(&self, key: &OutpointPrefix) -> Option<TxIndex> {
|
||||
self.0.get(key).copied()
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
use brk_types::MempoolInfo;
|
||||
use parking_lot::{RwLock, RwLockWriteGuard};
|
||||
|
||||
use super::{AddrTracker, EntryPool, TxGraveyard, TxStore};
|
||||
use super::{AddrTracker, EntryPool, OutpointSpends, TxGraveyard, TxStore};
|
||||
|
||||
/// The five buckets making up live mempool state.
|
||||
/// The six buckets making up live mempool state.
|
||||
///
|
||||
/// Each bucket has its own `RwLock` so readers of different buckets
|
||||
/// don't contend with each other. The Applier takes all five write
|
||||
/// don't contend with each other. The Applier takes all six write
|
||||
/// locks in a fixed order for a brief window once per cycle.
|
||||
#[derive(Default)]
|
||||
pub struct MempoolState {
|
||||
@@ -14,11 +14,12 @@ pub struct MempoolState {
|
||||
pub(crate) txs: RwLock<TxStore>,
|
||||
pub(crate) addrs: RwLock<AddrTracker>,
|
||||
pub(crate) entries: RwLock<EntryPool>,
|
||||
pub(crate) outpoint_spends: RwLock<OutpointSpends>,
|
||||
pub(crate) graveyard: RwLock<TxGraveyard>,
|
||||
}
|
||||
|
||||
impl MempoolState {
|
||||
/// All five write guards in the canonical lock order. Used by the
|
||||
/// All six write guards in the canonical lock order. Used by the
|
||||
/// Applier to apply a sync diff atomically.
|
||||
pub(crate) fn write_all(&self) -> LockedState<'_> {
|
||||
LockedState {
|
||||
@@ -26,6 +27,7 @@ impl MempoolState {
|
||||
txs: self.txs.write(),
|
||||
addrs: self.addrs.write(),
|
||||
entries: self.entries.write(),
|
||||
outpoint_spends: self.outpoint_spends.write(),
|
||||
graveyard: self.graveyard.write(),
|
||||
}
|
||||
}
|
||||
@@ -36,5 +38,6 @@ pub(crate) struct LockedState<'a> {
|
||||
pub txs: RwLockWriteGuard<'a, TxStore>,
|
||||
pub addrs: RwLockWriteGuard<'a, AddrTracker>,
|
||||
pub entries: RwLockWriteGuard<'a, EntryPool>,
|
||||
pub outpoint_spends: RwLockWriteGuard<'a, OutpointSpends>,
|
||||
pub graveyard: RwLockWriteGuard<'a, TxGraveyard>,
|
||||
}
|
||||
|
||||
@@ -63,9 +63,10 @@ pub fn main() -> Result<()> {
|
||||
25
|
||||
));
|
||||
|
||||
let _ = dbg!(query.addr_utxos(Addr::from(
|
||||
"bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38".to_string()
|
||||
)));
|
||||
let _ = dbg!(query.addr_utxos(
|
||||
Addr::from("bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38".to_string()),
|
||||
1000,
|
||||
));
|
||||
|
||||
// dbg!(query.search_and_format(SeriesSelection {
|
||||
// index: Index::Height,
|
||||
|
||||
@@ -4,16 +4,13 @@ use bitcoin::{Network, PublicKey, ScriptBuf};
|
||||
use brk_error::{Error, OptionData, Result};
|
||||
use brk_types::{
|
||||
Addr, AddrBytes, AddrChainStats, AddrHash, AddrIndexOutPoint, AddrIndexTxIndex, AddrStats,
|
||||
AnyAddrDataIndexEnum, Dollars, Height, OutputType, Transaction, TxIndex, TxStatus, Txid,
|
||||
TypeIndex, Unit, Utxo, Vout,
|
||||
AnyAddrDataIndexEnum, Dollars, Height, OutputType, Timestamp, Transaction, TxIndex, TxStatus,
|
||||
Txid, TxidPrefix, TypeIndex, Unit, Utxo, Vout,
|
||||
};
|
||||
use vecdb::VecIndex;
|
||||
|
||||
use crate::Query;
|
||||
|
||||
/// Maximum number of mempool txids to return
|
||||
const MAX_MEMPOOL_TXIDS: usize = 50;
|
||||
|
||||
impl Query {
|
||||
pub fn addr(&self, addr: Addr) -> Result<AddrStats> {
|
||||
let indexer = self.indexer();
|
||||
@@ -36,14 +33,12 @@ impl Query {
|
||||
let Ok(bytes) = AddrBytes::try_from((&script, output_type)) else {
|
||||
return Err(Error::InvalidAddr);
|
||||
};
|
||||
let addr_type = output_type;
|
||||
let hash = AddrHash::from(&bytes);
|
||||
|
||||
let Some(store) = stores.addr_type_to_addr_hash_to_addr_index.get(addr_type) else {
|
||||
let Some(store) = stores.addr_type_to_addr_hash_to_addr_index.get(output_type) else {
|
||||
return Err(Error::InvalidAddr);
|
||||
};
|
||||
let Ok(Some(type_index)) = store.get(&hash).map(|opt| opt.map(|cow| cow.into_owned()))
|
||||
else {
|
||||
let Some(type_index) = store.get(&hash)?.map(|cow| cow.into_owned()) else {
|
||||
return Err(Error::UnknownAddr);
|
||||
};
|
||||
|
||||
@@ -52,30 +47,32 @@ impl Query {
|
||||
.any_addr_indexes
|
||||
.get_once(output_type, type_index)?;
|
||||
|
||||
let addr_data = match any_addr_index.to_enum() {
|
||||
AnyAddrDataIndexEnum::Funded(index) => computer
|
||||
.distribution
|
||||
.addrs_data
|
||||
.funded
|
||||
.reader()
|
||||
.get(usize::from(index)),
|
||||
AnyAddrDataIndexEnum::Empty(index) => computer
|
||||
.distribution
|
||||
.addrs_data
|
||||
.empty
|
||||
.reader()
|
||||
.get(usize::from(index))
|
||||
.into(),
|
||||
};
|
||||
|
||||
let realized_price = match &any_addr_index.to_enum() {
|
||||
AnyAddrDataIndexEnum::Funded(_) => addr_data.realized_price().to_dollars(),
|
||||
AnyAddrDataIndexEnum::Empty(_) => Dollars::default(),
|
||||
let (addr_data, realized_price) = match any_addr_index.to_enum() {
|
||||
AnyAddrDataIndexEnum::Funded(index) => {
|
||||
let data = computer
|
||||
.distribution
|
||||
.addrs_data
|
||||
.funded
|
||||
.reader()
|
||||
.get(usize::from(index));
|
||||
let price = data.realized_price().to_dollars();
|
||||
(data, price)
|
||||
}
|
||||
AnyAddrDataIndexEnum::Empty(index) => {
|
||||
let data = computer
|
||||
.distribution
|
||||
.addrs_data
|
||||
.empty
|
||||
.reader()
|
||||
.get(usize::from(index))
|
||||
.into();
|
||||
(data, Dollars::default())
|
||||
}
|
||||
};
|
||||
|
||||
Ok(AddrStats {
|
||||
addr,
|
||||
addr_type,
|
||||
addr_type: output_type,
|
||||
chain_stats: AddrChainStats {
|
||||
type_index,
|
||||
funded_txo_count: addr_data.funded_txo_count,
|
||||
@@ -85,22 +82,38 @@ impl Query {
|
||||
tx_count: addr_data.tx_count,
|
||||
realized_price,
|
||||
},
|
||||
mempool_stats: self.mempool().map(|m| {
|
||||
m.addrs()
|
||||
.get(&bytes)
|
||||
.map(|e| e.stats.clone())
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
mempool_stats: self
|
||||
.mempool()
|
||||
.and_then(|m| m.addrs().get(&bytes).map(|e| e.stats.clone()))
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Esplora `/address/:address/txs` first page: up to `mempool_limit`
|
||||
/// mempool (newest first) followed by the first `chain_limit`
|
||||
/// confirmed. Pagination is path-style via `/txs/chain/:after_txid`.
|
||||
pub fn addr_txs(
|
||||
&self,
|
||||
addr: Addr,
|
||||
mempool_limit: usize,
|
||||
chain_limit: usize,
|
||||
) -> Result<Vec<Transaction>> {
|
||||
let mut out = if self.mempool().is_some() {
|
||||
self.addr_mempool_txs(&addr, mempool_limit)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
out.extend(self.addr_txs_chain(&addr, None, chain_limit)?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn addr_txs_chain(
|
||||
&self,
|
||||
addr: &Addr,
|
||||
after_txid: Option<Txid>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Transaction>> {
|
||||
let txindices = self.addr_txindices(&addr, after_txid, limit)?;
|
||||
let txindices = self.addr_txindices(addr, after_txid, limit)?;
|
||||
self.transactions_by_indices(&txindices)
|
||||
}
|
||||
|
||||
@@ -112,11 +125,10 @@ impl Query {
|
||||
) -> Result<Vec<Txid>> {
|
||||
let txindices = self.addr_txindices(&addr, after_txid, limit)?;
|
||||
let txid_reader = self.indexer().vecs.transactions.txid.reader();
|
||||
let txids = txindices
|
||||
Ok(txindices
|
||||
.into_iter()
|
||||
.map(|tx_index| txid_reader.get(tx_index.to_usize()))
|
||||
.collect();
|
||||
Ok(txids)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn addr_txindices(
|
||||
@@ -125,8 +137,7 @@ impl Query {
|
||||
after_txid: Option<Txid>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<TxIndex>> {
|
||||
let indexer = self.indexer();
|
||||
let stores = &indexer.stores;
|
||||
let stores = &self.indexer().stores;
|
||||
|
||||
let (output_type, type_index) = self.resolve_addr(addr)?;
|
||||
|
||||
@@ -137,8 +148,6 @@ impl Query {
|
||||
|
||||
if let Some(after_txid) = after_txid {
|
||||
let after_tx_index = self.resolve_tx_index(&after_txid)?;
|
||||
|
||||
// Seek directly to after_tx_index and iterate backward — O(limit)
|
||||
let min = AddrIndexTxIndex::min_for_addr(type_index);
|
||||
let bound = AddrIndexTxIndex::from((type_index, after_tx_index));
|
||||
Ok(store
|
||||
@@ -148,7 +157,6 @@ impl Query {
|
||||
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
|
||||
.collect())
|
||||
} else {
|
||||
// No pagination — scan from end of prefix
|
||||
let prefix = u32::from(type_index).to_be_bytes();
|
||||
Ok(store
|
||||
.prefix(prefix)
|
||||
@@ -159,7 +167,7 @@ impl Query {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn addr_utxos(&self, addr: Addr) -> Result<Vec<Utxo>> {
|
||||
pub fn addr_utxos(&self, addr: Addr, max_utxos: usize) -> Result<Vec<Utxo>> {
|
||||
let indexer = self.indexer();
|
||||
let stores = &indexer.stores;
|
||||
let vecs = &indexer.vecs;
|
||||
@@ -173,14 +181,12 @@ impl Query {
|
||||
|
||||
let prefix = u32::from(type_index).to_be_bytes();
|
||||
|
||||
// Bounds worst-case work and response size, prevents heavy-address DDoS.
|
||||
const MAX_UTXOS: usize = 1000;
|
||||
let outpoints: Vec<(TxIndex, Vout)> = store
|
||||
.prefix(prefix)
|
||||
.map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout()))
|
||||
.take(MAX_UTXOS + 1)
|
||||
.take(max_utxos + 1)
|
||||
.collect();
|
||||
if outpoints.len() > MAX_UTXOS {
|
||||
if outpoints.len() > max_utxos {
|
||||
return Err(Error::TooManyUtxos);
|
||||
}
|
||||
|
||||
@@ -218,24 +224,38 @@ impl Query {
|
||||
Ok(utxos)
|
||||
}
|
||||
|
||||
pub fn addr_mempool_hash(&self, addr: &Addr) -> u64 {
|
||||
let Some(mempool) = self.mempool() else {
|
||||
return 0;
|
||||
};
|
||||
let Ok(bytes) = AddrBytes::from_str(addr) else {
|
||||
return 0;
|
||||
};
|
||||
mempool.addr_state_hash(&bytes)
|
||||
pub fn addr_mempool_hash(&self, addr: &Addr) -> Option<u64> {
|
||||
let mempool = self.mempool()?;
|
||||
let bytes = AddrBytes::from_str(addr).ok()?;
|
||||
Some(mempool.addr_state_hash(&bytes))
|
||||
}
|
||||
|
||||
pub fn addr_mempool_txids(&self, addr: Addr) -> Result<Vec<Txid>> {
|
||||
let bytes = AddrBytes::from_str(&addr)?;
|
||||
pub fn addr_mempool_txs(&self, addr: &Addr, limit: usize) -> Result<Vec<Transaction>> {
|
||||
let bytes = AddrBytes::from_str(addr)?;
|
||||
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
|
||||
Ok(mempool
|
||||
.addrs()
|
||||
.get(&bytes)
|
||||
.map(|e| e.txids.iter().take(MAX_MEMPOOL_TXIDS).cloned().collect())
|
||||
.unwrap_or_default())
|
||||
let addrs = mempool.addrs();
|
||||
let Some(entry) = addrs.get(&bytes) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let entries = mempool.entries();
|
||||
let mut ordered: Vec<(Timestamp, &Txid)> = entry
|
||||
.txids
|
||||
.iter()
|
||||
.map(|txid| {
|
||||
let first_seen = entries
|
||||
.get(&TxidPrefix::from(txid))
|
||||
.map(|e| e.first_seen)
|
||||
.unwrap_or_default();
|
||||
(first_seen, txid)
|
||||
})
|
||||
.collect();
|
||||
ordered.sort_unstable_by(|a, b| b.0.cmp(&a.0));
|
||||
let txs = mempool.txs();
|
||||
Ok(ordered
|
||||
.into_iter()
|
||||
.filter_map(|(_, txid)| txs.get(txid).cloned())
|
||||
.take(limit)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Height of the last on-chain activity for an address (last tx_index → height).
|
||||
@@ -253,14 +273,9 @@ impl Query {
|
||||
.next_back()
|
||||
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
|
||||
.ok_or(Error::UnknownAddr)?;
|
||||
self.computer()
|
||||
.indexes
|
||||
.tx_heights
|
||||
.get_shared(last_tx_index)
|
||||
.ok_or(Error::UnknownAddr)
|
||||
self.confirmed_status_height(last_tx_index)
|
||||
}
|
||||
|
||||
/// Resolve an address string to its output type and type_index
|
||||
fn resolve_addr(&self, addr: &Addr) -> Result<(OutputType, TypeIndex)> {
|
||||
let stores = &self.indexer().stores;
|
||||
|
||||
@@ -268,12 +283,12 @@ impl Query {
|
||||
let output_type = OutputType::from(&bytes);
|
||||
let hash = AddrHash::from(&bytes);
|
||||
|
||||
let Ok(Some(type_index)) = stores
|
||||
let Some(type_index) = stores
|
||||
.addr_type_to_addr_hash_to_addr_index
|
||||
.get(output_type)
|
||||
.data()?
|
||||
.get(&hash)
|
||||
.map(|opt| opt.map(|cow| cow.into_owned()))
|
||||
.get(&hash)?
|
||||
.map(|cow| cow.into_owned())
|
||||
else {
|
||||
return Err(Error::UnknownAddr);
|
||||
};
|
||||
|
||||
@@ -94,24 +94,25 @@ impl Query {
|
||||
Ok(mempool.txs().recent().to_vec())
|
||||
}
|
||||
|
||||
/// CPFP cluster for `txid`. Returns the mempool cluster when the txid is
|
||||
/// unconfirmed; otherwise reconstructs the confirmed same-block cluster
|
||||
/// from indexer state. Works even when the mempool feature is off.
|
||||
pub fn cpfp(&self, txid: &Txid) -> Result<CpfpInfo> {
|
||||
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
|
||||
let prefix = TxidPrefix::from(txid);
|
||||
Ok(mempool
|
||||
.cpfp_info(&prefix)
|
||||
.unwrap_or_else(|| self.confirmed_cpfp(txid)))
|
||||
let mempool_cluster = self.mempool().and_then(|m| m.cpfp_info(&prefix));
|
||||
Ok(mempool_cluster.unwrap_or_else(|| self.confirmed_cpfp(txid)))
|
||||
}
|
||||
|
||||
/// CPFP cluster for a confirmed tx: the connected component of
|
||||
/// same-block parent/child edges, reconstructed by BFS on demand.
|
||||
/// Walks entirely in `TxIndex` space using direct vec reads (height,
|
||||
/// weight, fee) - skips full `Transaction` reconstruction and avoids
|
||||
/// `txid -> tx_index` lookups by reading `OutPoint`'s packed
|
||||
/// `tx_index` directly. Capped at 25 each side to match Bitcoin
|
||||
/// Core's default mempool chain limits and mempool.space's own
|
||||
/// truncation. `effectiveFeePerVsize` is the simple package rate;
|
||||
/// mempool's `calculateGoodBlockCpfp` chunk-rate algorithm is not
|
||||
/// ported.
|
||||
/// same-block parent/child edges, reconstructed by a depth-first
|
||||
/// walk on demand. Walks entirely in `TxIndex` space using direct
|
||||
/// vec reads (height, weight, fee) - skips full `Transaction`
|
||||
/// reconstruction and avoids `txid -> tx_index` lookups by reading
|
||||
/// `OutPoint`'s packed `tx_index` directly. Capped at 25 each side
|
||||
/// to match Bitcoin Core's default mempool chain limits and
|
||||
/// mempool.space's own truncation. `effectiveFeePerVsize` is the
|
||||
/// simple package rate; mempool's `calculateGoodBlockCpfp`
|
||||
/// chunk-rate algorithm is not ported.
|
||||
fn confirmed_cpfp(&self, txid: &Txid) -> CpfpInfo {
|
||||
const MAX: usize = 25;
|
||||
let Ok(seed_idx) = self.resolve_tx_index(txid) else {
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
use std::{collections::BTreeMap, sync::LazyLock};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use brk_error::{Error, Result};
|
||||
use brk_traversable::TreeNode;
|
||||
use brk_types::{
|
||||
BlockHashPrefix, CacheClass, Date, DetailedSeriesCount, Epoch, Format, Halving, Height, Index,
|
||||
IndexInfo, LegacyValue, Limit, Output, OutputLegacy, PaginatedSeries, Pagination,
|
||||
PaginationIndex, RangeIndex, RangeMap, SearchQuery, SeriesData, SeriesInfo, SeriesName,
|
||||
SeriesOutput, SeriesOutputLegacy, SeriesSelection, Timestamp, Version,
|
||||
IndexInfo, LegacyValue, Limit, Output, OutputLegacy, PaginatedSeries, Pagination, RangeIndex,
|
||||
RangeMap, SearchQuery, SeriesData, SeriesInfo, SeriesName, SeriesOutput, SeriesOutputLegacy,
|
||||
SeriesSelection, Timestamp, Version,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
use vecdb::{AnyExportableVec, ReadableVec};
|
||||
|
||||
use crate::{
|
||||
Query,
|
||||
vecs::{IndexToVec, SeriesToVec},
|
||||
};
|
||||
use crate::Query;
|
||||
|
||||
/// Monotonic block timestamps → height. Lazily extended as new blocks are indexed.
|
||||
static HEIGHT_BY_MONOTONIC_TIMESTAMP: LazyLock<RwLock<RangeMap<Timestamp, Height>>> =
|
||||
@@ -24,14 +21,17 @@ static HEIGHT_BY_MONOTONIC_TIMESTAMP: LazyLock<RwLock<RangeMap<Timestamp, Height
|
||||
const CSV_HEADER_BYTES_PER_COL: usize = 10;
|
||||
/// Estimated bytes per cell value
|
||||
const CSV_CELL_BYTES: usize = 15;
|
||||
/// Estimated bytes per JSON cell value
|
||||
const JSON_CELL_BYTES: usize = 12;
|
||||
|
||||
impl Query {
|
||||
pub fn search_series(&self, query: &SearchQuery) -> Vec<&'static str> {
|
||||
self.vecs().matches(&query.q, query.limit)
|
||||
}
|
||||
|
||||
/// Returns the error for a missing series: `SeriesUnsupportedIndex` if the name
|
||||
/// exists at other indexes, else `SeriesNotFound` with fuzzy-match suggestions.
|
||||
pub fn series_not_found_error(&self, series: &SeriesName) -> Error {
|
||||
// Check if series exists but with different indexes
|
||||
if let Some(indexes) = self.vecs().series_to_indexes(series) {
|
||||
let supported = indexes
|
||||
.iter()
|
||||
@@ -44,7 +44,6 @@ impl Query {
|
||||
};
|
||||
}
|
||||
|
||||
// Series doesn't exist, suggest alternatives
|
||||
let matches = self
|
||||
.vecs()
|
||||
.matches(series, Limit::DEFAULT)
|
||||
@@ -63,25 +62,8 @@ impl Query {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let from = Some(start as i64);
|
||||
let to = Some(end as i64);
|
||||
|
||||
let num_rows = columns[0].range_count(from, to);
|
||||
let num_cols = columns.len();
|
||||
|
||||
let estimated_size =
|
||||
num_cols * CSV_HEADER_BYTES_PER_COL + num_rows * num_cols * CSV_CELL_BYTES;
|
||||
let mut csv = String::with_capacity(estimated_size);
|
||||
|
||||
// Single-column fast path: stream directly, no Vec<T> materialization
|
||||
if num_cols == 1 {
|
||||
let col = columns[0];
|
||||
csv.push_str(col.name());
|
||||
csv.push('\n');
|
||||
col.write_csv_column(Some(start), Some(end), &mut csv)?;
|
||||
return Ok(csv);
|
||||
}
|
||||
|
||||
let mut csv = String::with_capacity(num_cols * CSV_HEADER_BYTES_PER_COL);
|
||||
for (i, col) in columns.iter().enumerate() {
|
||||
if i > 0 {
|
||||
csv.push(',');
|
||||
@@ -90,6 +72,17 @@ impl Query {
|
||||
}
|
||||
csv.push('\n');
|
||||
|
||||
// Stream a single column without materializing Vec<T>.
|
||||
if num_cols == 1 {
|
||||
columns[0].write_csv_column(Some(start), Some(end), &mut csv)?;
|
||||
return Ok(csv);
|
||||
}
|
||||
|
||||
let from = Some(start as i64);
|
||||
let to = Some(end as i64);
|
||||
let num_rows = columns[0].range_count(from, to);
|
||||
csv.reserve(num_rows * num_cols * CSV_CELL_BYTES);
|
||||
|
||||
let mut writers: Vec<_> = columns
|
||||
.iter()
|
||||
.map(|col| col.create_writer(from, to))
|
||||
@@ -108,31 +101,31 @@ impl Query {
|
||||
Ok(csv)
|
||||
}
|
||||
|
||||
fn get_vec(
|
||||
&self,
|
||||
series: &SeriesName,
|
||||
index: Index,
|
||||
) -> Result<&'static dyn AnyExportableVec> {
|
||||
self.vecs()
|
||||
.get(series, index)
|
||||
.ok_or_else(|| self.series_not_found_error(series))
|
||||
}
|
||||
|
||||
/// Returns the latest value for a single series as a JSON value.
|
||||
pub fn latest(&self, series: &SeriesName, index: Index) -> Result<serde_json::Value> {
|
||||
let vec = self
|
||||
.vecs()
|
||||
.get(series, index)
|
||||
.ok_or_else(|| self.series_not_found_error(series))?;
|
||||
vec.last_json_value().ok_or(Error::NoData)
|
||||
self.get_vec(series, index)?
|
||||
.last_json_value()
|
||||
.ok_or(Error::NoData)
|
||||
}
|
||||
|
||||
/// Returns the length (total data points) for a single series.
|
||||
pub fn len(&self, series: &SeriesName, index: Index) -> Result<usize> {
|
||||
let vec = self
|
||||
.vecs()
|
||||
.get(series, index)
|
||||
.ok_or_else(|| self.series_not_found_error(series))?;
|
||||
Ok(vec.len())
|
||||
Ok(self.get_vec(series, index)?.len())
|
||||
}
|
||||
|
||||
/// Returns the version for a single series.
|
||||
pub fn version(&self, series: &SeriesName, index: Index) -> Result<Version> {
|
||||
let vec = self
|
||||
.vecs()
|
||||
.get(series, index)
|
||||
.ok_or_else(|| self.series_not_found_error(series))?;
|
||||
Ok(vec.version())
|
||||
Ok(self.get_vec(series, index)?.version())
|
||||
}
|
||||
|
||||
/// Search for vecs matching the given series and index.
|
||||
@@ -141,14 +134,11 @@ impl Query {
|
||||
if params.series.is_empty() {
|
||||
return Err(Error::NoSeries);
|
||||
}
|
||||
let mut vecs = Vec::with_capacity(params.series.len());
|
||||
for series in params.series.iter() {
|
||||
match self.vecs().get(series, params.index) {
|
||||
Some(vec) => vecs.push(vec),
|
||||
None => return Err(self.series_not_found_error(series)),
|
||||
}
|
||||
}
|
||||
Ok(vecs)
|
||||
params
|
||||
.series
|
||||
.iter()
|
||||
.map(|s| self.get_vec(s, params.index))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Calculate total weight of the vecs for the given range.
|
||||
@@ -165,25 +155,21 @@ impl Query {
|
||||
let version: Version = vecs.iter().map(|v| v.version()).sum();
|
||||
let index = params.index;
|
||||
|
||||
let resolve_bound = |ri: RangeIndex, fallback: usize| -> Result<usize> {
|
||||
let i = self.range_index_to_i64(ri, index)?;
|
||||
Ok(vecs.iter().map(|v| v.i64_to_usize(i)).min().unwrap_or(fallback))
|
||||
};
|
||||
|
||||
let start = match params.start() {
|
||||
Some(ri) => {
|
||||
let i = self.range_index_to_i64(ri, index)?;
|
||||
vecs.iter().map(|v| v.i64_to_usize(i)).min().unwrap_or(0)
|
||||
}
|
||||
Some(ri) => resolve_bound(ri, 0)?,
|
||||
None => 0,
|
||||
};
|
||||
|
||||
let end = match params.end() {
|
||||
Some(ri) => {
|
||||
let i = self.range_index_to_i64(ri, index)?;
|
||||
vecs.iter()
|
||||
.map(|v| v.i64_to_usize(i))
|
||||
.min()
|
||||
.unwrap_or(total)
|
||||
}
|
||||
Some(ri) => resolve_bound(ri, total)?,
|
||||
None => params
|
||||
.limit()
|
||||
.map(|l| (start + *l).min(total))
|
||||
.map(|l| start.saturating_add(*l).min(total))
|
||||
.unwrap_or(total),
|
||||
};
|
||||
|
||||
@@ -236,33 +222,34 @@ impl Query {
|
||||
CacheClass::Bucket { margin } => Some(total.saturating_sub(margin)),
|
||||
CacheClass::Entity => {
|
||||
let h = Height::from((*tip_height).saturating_sub(6));
|
||||
let v = &self.indexer().vecs;
|
||||
let n = match index {
|
||||
Index::TxIndex => v.transactions.first_tx_index.collect_one(h).map(usize::from),
|
||||
Index::TxInIndex => v.inputs.first_txin_index.collect_one(h).map(usize::from),
|
||||
Index::TxOutIndex => v.outputs.first_txout_index.collect_one(h).map(usize::from),
|
||||
Index::EmptyOutputIndex => v.scripts.empty.first_index.collect_one(h).map(usize::from),
|
||||
Index::OpReturnIndex => v.scripts.op_return.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2MSOutputIndex => v.scripts.p2ms.first_index.collect_one(h).map(usize::from),
|
||||
Index::UnknownOutputIndex => v.scripts.unknown.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2AAddrIndex => v.addrs.p2a.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2PK33AddrIndex => v.addrs.p2pk33.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2PK65AddrIndex => v.addrs.p2pk65.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2PKHAddrIndex => v.addrs.p2pkh.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2SHAddrIndex => v.addrs.p2sh.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2TRAddrIndex => v.addrs.p2tr.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2WPKHAddrIndex => v.addrs.p2wpkh.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2WSHAddrIndex => v.addrs.p2wsh.first_index.collect_one(h).map(usize::from),
|
||||
_ => unreachable!("non-entity index in CacheClass::Entity arm"),
|
||||
}
|
||||
.unwrap_or(0)
|
||||
.min(total);
|
||||
Some(n)
|
||||
Some(self.entity_index_at(index, h).unwrap_or(0).min(total))
|
||||
}
|
||||
CacheClass::Mutable => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn entity_index_at(&self, index: Index, h: Height) -> Option<usize> {
|
||||
let v = &self.indexer().vecs;
|
||||
match index {
|
||||
Index::TxIndex => v.transactions.first_tx_index.collect_one(h).map(usize::from),
|
||||
Index::TxInIndex => v.inputs.first_txin_index.collect_one(h).map(usize::from),
|
||||
Index::TxOutIndex => v.outputs.first_txout_index.collect_one(h).map(usize::from),
|
||||
Index::EmptyOutputIndex => v.scripts.empty.first_index.collect_one(h).map(usize::from),
|
||||
Index::OpReturnIndex => v.scripts.op_return.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2MSOutputIndex => v.scripts.p2ms.first_index.collect_one(h).map(usize::from),
|
||||
Index::UnknownOutputIndex => v.scripts.unknown.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2AAddrIndex => v.addrs.p2a.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2PK33AddrIndex => v.addrs.p2pk33.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2PK65AddrIndex => v.addrs.p2pk65.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2PKHAddrIndex => v.addrs.p2pkh.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2SHAddrIndex => v.addrs.p2sh.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2TRAddrIndex => v.addrs.p2tr.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2WPKHAddrIndex => v.addrs.p2wpkh.first_index.collect_one(h).map(usize::from),
|
||||
Index::P2WSHAddrIndex => v.addrs.p2wsh.first_index.collect_one(h).map(usize::from),
|
||||
_ => unreachable!("entity_index_at called for non-Entity Index: {index:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a resolved query (expensive).
|
||||
/// Call after ETag/cache checks to avoid unnecessary work.
|
||||
pub fn format(&self, resolved: ResolvedQuery) -> Result<SeriesOutput> {
|
||||
@@ -281,22 +268,9 @@ impl Query {
|
||||
Format::CSV => Output::CSV(Self::columns_to_csv(&vecs, start, end)?),
|
||||
Format::JSON => {
|
||||
let count = end.saturating_sub(start);
|
||||
if vecs.len() == 1 {
|
||||
let mut buf = Vec::with_capacity(count * 12 + 256);
|
||||
SeriesData::serialize(vecs[0], index, start, end, &mut buf)?;
|
||||
Output::Json(buf)
|
||||
} else {
|
||||
let mut buf = Vec::with_capacity(count * 12 * vecs.len() + 256);
|
||||
buf.push(b'[');
|
||||
for (i, vec) in vecs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
buf.push(b',');
|
||||
}
|
||||
SeriesData::serialize(*vec, index, start, end, &mut buf)?;
|
||||
}
|
||||
buf.push(b']');
|
||||
Output::Json(buf)
|
||||
}
|
||||
Output::Json(Self::write_json_array(&vecs, count, 256, |v, buf| {
|
||||
SeriesData::serialize(v, index, start, end, buf)
|
||||
})?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -309,10 +283,11 @@ impl Query {
|
||||
})
|
||||
}
|
||||
|
||||
/// Format a resolved query as raw data (just the JSON array, no SeriesData wrapper).
|
||||
/// Format a resolved query as raw data (just the JSON values, no SeriesData wrapper).
|
||||
/// Single vec → `[v1,v2,...]`. Multi-vec → `[[v1,v2],[v3,v4],...]`.
|
||||
/// CSV output is identical to `format` (no wrapper distinction for CSV).
|
||||
pub fn format_raw(&self, resolved: ResolvedQuery) -> Result<SeriesOutput> {
|
||||
if resolved.format() == Format::CSV {
|
||||
if resolved.format == Format::CSV {
|
||||
return self.format(resolved);
|
||||
}
|
||||
|
||||
@@ -326,8 +301,9 @@ impl Query {
|
||||
} = resolved;
|
||||
|
||||
let count = end.saturating_sub(start);
|
||||
let mut buf = Vec::with_capacity(count * 12 + 2);
|
||||
vecs[0].write_json(Some(start), Some(end), &mut buf)?;
|
||||
let buf = Self::write_json_array(&vecs, count, 2, |v, buf| {
|
||||
v.write_json(Some(start), Some(end), buf)
|
||||
})?;
|
||||
|
||||
Ok(SeriesOutput {
|
||||
output: Output::Json(buf),
|
||||
@@ -338,12 +314,28 @@ impl Query {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn series_to_index_to_vec(&self) -> &BTreeMap<&str, IndexToVec<'_>> {
|
||||
&self.vecs().series_to_index_to_vec
|
||||
}
|
||||
|
||||
pub fn index_to_series_to_vec(&self) -> &BTreeMap<Index, SeriesToVec<'_>> {
|
||||
&self.vecs().index_to_series_to_vec
|
||||
fn write_json_array(
|
||||
vecs: &[&dyn AnyExportableVec],
|
||||
cell_count: usize,
|
||||
wrapper_overhead: usize,
|
||||
mut write_one: impl FnMut(&dyn AnyExportableVec, &mut Vec<u8>) -> vecdb::Result<()>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let multi = vecs.len() > 1;
|
||||
let mut buf =
|
||||
Vec::with_capacity(cell_count * JSON_CELL_BYTES * vecs.len() + wrapper_overhead);
|
||||
if multi {
|
||||
buf.push(b'[');
|
||||
}
|
||||
for (i, vec) in vecs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
buf.push(b',');
|
||||
}
|
||||
write_one(*vec, &mut buf)?;
|
||||
}
|
||||
if multi {
|
||||
buf.push(b']');
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
pub fn series_count(&self) -> DetailedSeriesCount {
|
||||
@@ -365,25 +357,8 @@ impl Query {
|
||||
self.vecs().catalog()
|
||||
}
|
||||
|
||||
pub fn index_to_vecids(&self, paginated_index: PaginationIndex) -> Option<&[&str]> {
|
||||
self.vecs().index_to_ids(paginated_index)
|
||||
}
|
||||
|
||||
pub fn series_info(&self, series: &SeriesName) -> Option<SeriesInfo> {
|
||||
let index_to_vec = self
|
||||
.vecs()
|
||||
.series_to_index_to_vec
|
||||
.get(series.replace("-", "_").as_str())?;
|
||||
let value_type = index_to_vec.values().next()?.value_type_to_string();
|
||||
let indexes = index_to_vec.keys().copied().collect();
|
||||
Some(SeriesInfo {
|
||||
indexes,
|
||||
value_type: value_type.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn series_to_indexes(&self, series: &SeriesName) -> Option<&Vec<Index>> {
|
||||
self.vecs().series_to_indexes(series)
|
||||
self.vecs().series_info(series)
|
||||
}
|
||||
|
||||
/// Resolve a RangeIndex to an i64 offset for the given index type.
|
||||
@@ -396,20 +371,16 @@ impl Query {
|
||||
}
|
||||
|
||||
fn date_to_i64(&self, date: Date, index: Index) -> Result<i64> {
|
||||
// Direct date-based index conversion (day1, week1, month1, etc.)
|
||||
if let Some(idx) = index.date_to_index(date) {
|
||||
return Ok(idx as i64);
|
||||
}
|
||||
// Fall through to timestamp-based resolution (height, epoch, halving)
|
||||
self.timestamp_to_i64(Timestamp::from(date), index)
|
||||
}
|
||||
|
||||
fn timestamp_to_i64(&self, ts: Timestamp, index: Index) -> Result<i64> {
|
||||
// Direct timestamp-based index conversion (minute10, hour1, etc.)
|
||||
if let Some(idx) = index.timestamp_to_index(ts) {
|
||||
return Ok(idx as i64);
|
||||
}
|
||||
// Height-based indexes: find block height, then convert
|
||||
let height = Height::from(self.height_for_timestamp(ts));
|
||||
match index {
|
||||
Index::Height => Ok(usize::from(height) as i64),
|
||||
@@ -425,21 +396,22 @@ impl Query {
|
||||
/// O(log n) binary search. Lazily rebuilt as new blocks arrive.
|
||||
fn height_for_timestamp(&self, ts: Timestamp) -> usize {
|
||||
let current_height: usize = self.height().into();
|
||||
let lookup = |map: &RangeMap<Timestamp, Height>| {
|
||||
map.ceil(ts).map(usize::from).unwrap_or(current_height)
|
||||
};
|
||||
|
||||
// Fast path: read lock, ceil is &self
|
||||
{
|
||||
let map = HEIGHT_BY_MONOTONIC_TIMESTAMP.read();
|
||||
if map.len() > current_height {
|
||||
return map.ceil(ts).map(usize::from).unwrap_or(current_height);
|
||||
return lookup(&map);
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: rebuild from computer's precomputed monotonic timestamps
|
||||
let mut map = HEIGHT_BY_MONOTONIC_TIMESTAMP.write();
|
||||
if map.len() <= current_height {
|
||||
*map = RangeMap::from(self.computer().indexes.timestamp.monotonic.collect());
|
||||
}
|
||||
map.ceil(ts).map(usize::from).unwrap_or(current_height)
|
||||
lookup(&map)
|
||||
}
|
||||
|
||||
/// Deprecated - format a resolved query as legacy output (expensive).
|
||||
@@ -520,10 +492,6 @@ pub struct ResolvedQuery {
|
||||
}
|
||||
|
||||
impl ResolvedQuery {
|
||||
pub fn format(&self) -> Format {
|
||||
self.format
|
||||
}
|
||||
|
||||
pub fn csv_filename(&self) -> String {
|
||||
let names: Vec<_> = self.vecs.iter().map(|v| v.name()).collect();
|
||||
format!("{}-{}.csv", names.join("_"), self.index)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use bitcoin::hex::DisplayHex;
|
||||
use bitcoin::{
|
||||
hashes::{Hash, sha256d},
|
||||
hex::DisplayHex,
|
||||
};
|
||||
use brk_error::{Error, OptionData, Result};
|
||||
use brk_types::{
|
||||
BlockHash, Height, MerkleProof, Timestamp, Transaction, TxInIndex, TxIndex, TxOutIndex,
|
||||
@@ -17,17 +20,12 @@ impl Query {
|
||||
self.indexer()
|
||||
.stores
|
||||
.txid_prefix_to_tx_index
|
||||
.get(&TxidPrefix::from(txid))
|
||||
.map_err(|_| Error::UnknownTxid)?
|
||||
.get(&TxidPrefix::from(txid))?
|
||||
.map(|cow| cow.into_owned())
|
||||
.ok_or(Error::UnknownTxid)
|
||||
}
|
||||
|
||||
pub fn txid_by_index(&self, index: TxIndex) -> Result<Txid> {
|
||||
let len = self.indexer().vecs.transactions.txid.len();
|
||||
if index.to_usize() >= len {
|
||||
return Err(Error::OutOfRange("Transaction index out of range".into()));
|
||||
}
|
||||
self.indexer()
|
||||
.vecs
|
||||
.transactions
|
||||
@@ -55,23 +53,11 @@ impl Query {
|
||||
.data()
|
||||
}
|
||||
|
||||
/// Full confirmed TxStatus from a tx_index.
|
||||
#[inline]
|
||||
pub(crate) fn confirmed_status(&self, tx_index: TxIndex) -> Result<TxStatus> {
|
||||
let height = self.confirmed_status_height(tx_index)?;
|
||||
self.confirmed_status_at(height)
|
||||
}
|
||||
|
||||
/// Full confirmed TxStatus from a known height.
|
||||
#[inline]
|
||||
pub(crate) fn confirmed_status_at(&self, height: Height) -> Result<TxStatus> {
|
||||
let (block_hash, block_time) = self.block_hash_and_time(height)?;
|
||||
Ok(TxStatus {
|
||||
confirmed: true,
|
||||
block_height: Some(height),
|
||||
block_hash: Some(block_hash),
|
||||
block_time: Some(block_time),
|
||||
})
|
||||
Ok(TxStatus::confirmed(height, block_hash, block_time))
|
||||
}
|
||||
|
||||
/// Block hash + timestamp for a height (cached vecs, fast).
|
||||
@@ -85,11 +71,15 @@ impl Query {
|
||||
|
||||
// ── Transaction queries ────────────────────────────────────────
|
||||
|
||||
/// Map a mempool transaction by txid through `f`, returning `None`
|
||||
/// if no mempool is attached or the txid is not in mempool.
|
||||
fn map_mempool_tx<R>(&self, txid: &Txid, f: impl FnOnce(&Transaction) -> R) -> Option<R> {
|
||||
self.mempool()?.txs().get(txid).map(f)
|
||||
}
|
||||
|
||||
pub fn transaction(&self, txid: &Txid) -> Result<Transaction> {
|
||||
if let Some(mempool) = self.mempool()
|
||||
&& let Some(tx) = mempool.txs().get(txid)
|
||||
{
|
||||
return Ok(tx.clone());
|
||||
if let Some(tx) = self.map_mempool_tx(txid, Transaction::clone) {
|
||||
return Ok(tx);
|
||||
}
|
||||
self.transaction_by_index(self.resolve_tx_index(txid)?)
|
||||
}
|
||||
@@ -98,23 +88,20 @@ impl Query {
|
||||
if self.mempool().is_some_and(|m| m.txs().contains_key(txid)) {
|
||||
return Ok(TxStatus::UNCONFIRMED);
|
||||
}
|
||||
self.confirmed_status(self.resolve_tx_index(txid)?)
|
||||
let (_, height) = self.resolve_tx(txid)?;
|
||||
self.confirmed_status_at(height)
|
||||
}
|
||||
|
||||
pub fn transaction_raw(&self, txid: &Txid) -> Result<Vec<u8>> {
|
||||
if let Some(mempool) = self.mempool()
|
||||
&& let Some(tx) = mempool.txs().get(txid)
|
||||
{
|
||||
return Ok(tx.encode_bytes());
|
||||
if let Some(bytes) = self.map_mempool_tx(txid, Transaction::encode_bytes) {
|
||||
return Ok(bytes);
|
||||
}
|
||||
self.transaction_raw_by_index(self.resolve_tx_index(txid)?)
|
||||
}
|
||||
|
||||
pub fn transaction_hex(&self, txid: &Txid) -> Result<String> {
|
||||
if let Some(mempool) = self.mempool()
|
||||
&& let Some(tx) = mempool.txs().get(txid)
|
||||
{
|
||||
return Ok(tx.encode_bytes().to_lower_hex_string());
|
||||
if let Some(hex) = self.map_mempool_tx(txid, |tx| tx.encode_bytes().to_lower_hex_string()) {
|
||||
return Ok(hex);
|
||||
}
|
||||
self.transaction_hex_by_index(self.resolve_tx_index(txid)?)
|
||||
}
|
||||
@@ -123,23 +110,49 @@ impl Query {
|
||||
|
||||
pub fn outspend(&self, txid: &Txid, vout: Vout) -> Result<TxOutspend> {
|
||||
if self.mempool().is_some_and(|m| m.txs().contains_key(txid)) {
|
||||
return Ok(TxOutspend::UNSPENT);
|
||||
return Ok(self.mempool_outspend(txid, vout));
|
||||
}
|
||||
let (_, first_txout, output_count) = self.resolve_tx_outputs(txid)?;
|
||||
if usize::from(vout) >= output_count {
|
||||
return Ok(TxOutspend::UNSPENT);
|
||||
}
|
||||
self.resolve_outspend(first_txout + vout)
|
||||
let confirmed = self.resolve_outspend(first_txout + vout)?;
|
||||
if confirmed.spent {
|
||||
return Ok(confirmed);
|
||||
}
|
||||
Ok(self.mempool_outspend(txid, vout))
|
||||
}
|
||||
|
||||
pub fn outspends(&self, txid: &Txid) -> Result<Vec<TxOutspend>> {
|
||||
if let Some(mempool) = self.mempool()
|
||||
&& let Some(tx) = mempool.txs().get(txid)
|
||||
&& let Some(output_count) = mempool.txs().get(txid).map(|tx| tx.output.len())
|
||||
{
|
||||
return Ok(vec![TxOutspend::UNSPENT; tx.output.len()]);
|
||||
return Ok((0..output_count)
|
||||
.map(|i| self.mempool_outspend(txid, Vout::from(i)))
|
||||
.collect());
|
||||
}
|
||||
let (_, first_txout, output_count) = self.resolve_tx_outputs(txid)?;
|
||||
self.resolve_outspends(first_txout, output_count)
|
||||
let mut spends = self.resolve_outspends(first_txout, output_count)?;
|
||||
for (i, spend) in spends.iter_mut().enumerate() {
|
||||
if !spend.spent {
|
||||
*spend = self.mempool_outspend(txid, Vout::from(i));
|
||||
}
|
||||
}
|
||||
Ok(spends)
|
||||
}
|
||||
|
||||
fn mempool_outspend(&self, txid: &Txid, vout: Vout) -> TxOutspend {
|
||||
let Some((spender_txid, vin)) =
|
||||
self.mempool().and_then(|m| m.lookup_spender(txid, vout))
|
||||
else {
|
||||
return TxOutspend::UNSPENT;
|
||||
};
|
||||
TxOutspend {
|
||||
spent: true,
|
||||
txid: Some(spender_txid),
|
||||
vin: Some(vin),
|
||||
status: Some(TxStatus::UNCONFIRMED),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve spend status for a single output. Minimal reads.
|
||||
@@ -204,12 +217,7 @@ impl Query {
|
||||
spent: true,
|
||||
txid: Some(spending_txid),
|
||||
vin: Some(vin),
|
||||
status: Some(TxStatus {
|
||||
confirmed: true,
|
||||
block_height: Some(spending_height),
|
||||
block_hash: Some(block_hash),
|
||||
block_time: Some(block_time),
|
||||
}),
|
||||
status: Some(TxStatus::confirmed(spending_height, block_hash, block_time)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -223,7 +231,7 @@ impl Query {
|
||||
.vecs
|
||||
.inputs
|
||||
.tx_index
|
||||
.collect_one_at(usize::from(txin_index))
|
||||
.collect_one(txin_index)
|
||||
.data()?;
|
||||
let spending_first_txin: TxInIndex = indexer
|
||||
.vecs
|
||||
@@ -236,8 +244,8 @@ impl Query {
|
||||
.vecs
|
||||
.transactions
|
||||
.txid
|
||||
.reader()
|
||||
.get(spending_tx_index.to_usize());
|
||||
.collect_one(spending_tx_index)
|
||||
.data()?;
|
||||
let spending_height = self.confirmed_status_height(spending_tx_index)?;
|
||||
let (block_hash, block_time) = self.block_hash_and_time(spending_height)?;
|
||||
|
||||
@@ -245,12 +253,7 @@ impl Query {
|
||||
spent: true,
|
||||
txid: Some(spending_txid),
|
||||
vin: Some(vin),
|
||||
status: Some(TxStatus {
|
||||
confirmed: true,
|
||||
block_height: Some(spending_height),
|
||||
block_hash: Some(block_hash),
|
||||
block_time: Some(block_time),
|
||||
}),
|
||||
status: Some(TxStatus::confirmed(spending_height, block_hash, block_time)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -258,26 +261,25 @@ impl Query {
|
||||
fn resolve_tx_outputs(&self, txid: &Txid) -> Result<(TxIndex, TxOutIndex, usize)> {
|
||||
let tx_index = self.resolve_tx_index(txid)?;
|
||||
let indexer = self.indexer();
|
||||
let first = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_txout_index
|
||||
.read_once(tx_index)?;
|
||||
let next = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_txout_index
|
||||
.read_once(tx_index.incremented())?;
|
||||
let first_txout_vec = &indexer.vecs.transactions.first_txout_index;
|
||||
let first = first_txout_vec.read_once(tx_index)?;
|
||||
let next_tx = tx_index.incremented();
|
||||
let next = if next_tx.to_usize() < first_txout_vec.len() {
|
||||
first_txout_vec.read_once(next_tx)?
|
||||
} else {
|
||||
TxOutIndex::from(indexer.vecs.outputs.value.len())
|
||||
};
|
||||
Ok((tx_index, first, usize::from(next) - usize::from(first)))
|
||||
}
|
||||
|
||||
// === Helper methods ===
|
||||
|
||||
pub fn transaction_by_index(&self, tx_index: TxIndex) -> Result<Transaction> {
|
||||
self.transactions_by_indices(&[tx_index])?
|
||||
fn transaction_by_index(&self, tx_index: TxIndex) -> Result<Transaction> {
|
||||
Ok(self
|
||||
.transactions_by_indices(&[tx_index])?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(Error::NotFound("Transaction not found".into()))
|
||||
.expect("transactions_by_indices returns one tx per input index"))
|
||||
}
|
||||
|
||||
fn transaction_raw_by_index(&self, tx_index: TxIndex) -> Result<Vec<u8>> {
|
||||
@@ -328,7 +330,7 @@ impl Query {
|
||||
.transactions
|
||||
.first_tx_index
|
||||
.collect_one(height)
|
||||
.ok_or(Error::NotFound("Block not found".into()))?;
|
||||
.data()?;
|
||||
let pos = tx_index.to_usize() - first_tx.to_usize();
|
||||
let txids = self.block_txids_by_height(height)?;
|
||||
|
||||
@@ -341,12 +343,10 @@ impl Query {
|
||||
}
|
||||
|
||||
fn merkle_path(txids: &[Txid], pos: usize) -> Vec<String> {
|
||||
use bitcoin::hashes::{Hash, sha256d};
|
||||
|
||||
// Txid bytes are in internal order (same layout as bitcoin::Txid)
|
||||
let mut hashes: Vec<[u8; 32]> = txids
|
||||
.iter()
|
||||
.map(|t| bitcoin::Txid::from(t).to_byte_array())
|
||||
.map(|t| <&bitcoin::Txid>::from(t).to_byte_array())
|
||||
.collect();
|
||||
|
||||
let mut proof = Vec::new();
|
||||
@@ -357,7 +357,7 @@ fn merkle_path(txids: &[Txid], pos: usize) -> Vec<String> {
|
||||
// Display order: reverse bytes for hex output
|
||||
let mut display = hashes[sibling];
|
||||
display.reverse();
|
||||
proof.push(bitcoin::hex::DisplayHex::to_lower_hex_string(&display));
|
||||
proof.push(display.to_lower_hex_string());
|
||||
|
||||
hashes = hashes
|
||||
.chunks(2)
|
||||
|
||||
@@ -14,20 +14,19 @@ impl Query {
|
||||
let mut cohorts: Vec<Cohort> = fs::read_dir(states_path)?
|
||||
.filter_map(|entry| {
|
||||
let name = entry.ok()?.file_name().into_string().ok()?;
|
||||
states_path
|
||||
.join(&name)
|
||||
.join("urpd")
|
||||
.exists()
|
||||
.then(|| Cohort::from(name))
|
||||
if !states_path.join(&name).join("urpd").exists() {
|
||||
return None;
|
||||
}
|
||||
Cohort::new(name)
|
||||
})
|
||||
.collect();
|
||||
|
||||
cohorts.sort_by_key(|a| a.to_string());
|
||||
cohorts.sort_unstable();
|
||||
|
||||
Ok(cohorts)
|
||||
}
|
||||
|
||||
pub(crate) fn urpd_dir(&self, cohort: &str) -> Result<PathBuf> {
|
||||
pub(crate) fn urpd_dir(&self, cohort: &Cohort) -> Result<PathBuf> {
|
||||
let dir = self
|
||||
.computer()
|
||||
.distribution
|
||||
@@ -59,7 +58,7 @@ impl Query {
|
||||
.filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok())
|
||||
.collect();
|
||||
|
||||
dates.sort();
|
||||
dates.sort_unstable();
|
||||
Ok(dates)
|
||||
}
|
||||
|
||||
@@ -79,7 +78,7 @@ impl Query {
|
||||
/// URPD for a cohort on a specific date.
|
||||
pub fn urpd_at(&self, cohort: &Cohort, date: Date, agg: UrpdAggregation) -> Result<Urpd> {
|
||||
let raw = self.urpd_raw(cohort, date)?;
|
||||
let day1 = Day1::try_from(date).map_err(|e| Error::Parse(e.to_string()))?;
|
||||
let day1 = Day1::try_from(date)?;
|
||||
let close = self
|
||||
.computer()
|
||||
.prices
|
||||
|
||||
@@ -4,13 +4,13 @@ use brk_computer::Computer;
|
||||
use brk_indexer::Indexer;
|
||||
use brk_traversable::{Traversable, TreeNode};
|
||||
use brk_types::{
|
||||
Index, IndexInfo, Limit, PaginatedSeries, Pagination, PaginationIndex, SeriesCount, SeriesName,
|
||||
Index, IndexInfo, Limit, PaginatedSeries, Pagination, SeriesCount, SeriesInfo, SeriesName,
|
||||
};
|
||||
use derive_more::{Deref, DerefMut};
|
||||
use quickmatch::{QuickMatch, QuickMatchConfig};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use vecdb::{AnyExportableVec, Ro};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Vecs<'a> {
|
||||
pub series_to_index_to_vec: BTreeMap<&'a str, IndexToVec<'a>>,
|
||||
pub index_to_series_to_vec: BTreeMap<Index, SeriesToVec<'a>>,
|
||||
@@ -18,10 +18,9 @@ pub struct Vecs<'a> {
|
||||
pub indexes: Vec<IndexInfo>,
|
||||
pub counts: SeriesCount,
|
||||
pub counts_by_db: BTreeMap<String, SeriesCount>,
|
||||
catalog: Option<TreeNode>,
|
||||
matcher: Option<QuickMatch<'a>>,
|
||||
catalog: TreeNode,
|
||||
matcher: QuickMatch<'a>,
|
||||
series_to_indexes: BTreeMap<&'a str, Vec<Index>>,
|
||||
index_to_series: BTreeMap<Index, Vec<&'a str>>,
|
||||
}
|
||||
|
||||
impl<'a> Vecs<'a> {
|
||||
@@ -49,39 +48,26 @@ impl<'a> Vecs<'a> {
|
||||
computed_vecs: impl Iterator<Item = (&'static str, &'a dyn AnyExportableVec)>,
|
||||
computed_tree: TreeNode,
|
||||
) -> Self {
|
||||
let mut this = Vecs::default();
|
||||
|
||||
indexed_vecs.for_each(|vec| this.insert(vec, "indexed"));
|
||||
computed_vecs.for_each(|(db, vec)| this.insert(vec, db));
|
||||
|
||||
let mut ids = this
|
||||
.series_to_index_to_vec
|
||||
.keys()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
let mut builder = Builder::default();
|
||||
indexed_vecs.for_each(|vec| builder.insert(vec, "indexed"));
|
||||
computed_vecs.for_each(|(db, vec)| builder.insert(vec, db));
|
||||
builder.counts.distinct_series = builder.series_to_index_to_vec.len();
|
||||
let Builder {
|
||||
series_to_index_to_vec,
|
||||
index_to_series_to_vec,
|
||||
counts,
|
||||
counts_by_db,
|
||||
..
|
||||
} = builder;
|
||||
|
||||
let sort_ids = |ids: &mut Vec<&str>| {
|
||||
ids.sort_unstable_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)))
|
||||
};
|
||||
|
||||
sort_ids(&mut ids);
|
||||
let mut series = series_to_index_to_vec.keys().copied().collect::<Vec<_>>();
|
||||
sort_ids(&mut series);
|
||||
|
||||
this.series = ids;
|
||||
this.counts.distinct_series = this.series_to_index_to_vec.len();
|
||||
this.counts.total_endpoints = this
|
||||
.index_to_series_to_vec
|
||||
.values()
|
||||
.map(|tree| tree.len())
|
||||
.sum::<usize>();
|
||||
this.counts.lazy_endpoints = this
|
||||
.index_to_series_to_vec
|
||||
.values()
|
||||
.flat_map(|tree| tree.values())
|
||||
.filter(|vec| vec.region_names().is_empty())
|
||||
.count();
|
||||
this.counts.stored_endpoints = this.counts.total_endpoints - this.counts.lazy_endpoints;
|
||||
this.indexes = this
|
||||
.index_to_series_to_vec
|
||||
let indexes = index_to_series_to_vec
|
||||
.keys()
|
||||
.map(|i| IndexInfo {
|
||||
index: *i,
|
||||
@@ -93,60 +79,35 @@ impl<'a> Vecs<'a> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
this.series_to_indexes = this
|
||||
.series_to_index_to_vec
|
||||
let series_to_indexes = series_to_index_to_vec
|
||||
.iter()
|
||||
.map(|(id, index_to_vec)| (*id, index_to_vec.keys().copied().collect::<Vec<_>>()))
|
||||
.collect();
|
||||
this.index_to_series = this
|
||||
.index_to_series_to_vec
|
||||
.iter()
|
||||
.map(|(index, id_to_vec)| (*index, id_to_vec.keys().copied().collect::<Vec<_>>()))
|
||||
.collect();
|
||||
this.index_to_series.values_mut().for_each(sort_ids);
|
||||
this.catalog.replace(
|
||||
TreeNode::Branch(
|
||||
[
|
||||
("indexed".to_string(), indexed_tree),
|
||||
("computed".to_string(), computed_tree),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)
|
||||
.merge_branches()
|
||||
.expect("indexed/computed catalog merge: same series leaf with incompatible schemas"),
|
||||
);
|
||||
this.matcher = Some(QuickMatch::new(&this.series));
|
||||
|
||||
this
|
||||
}
|
||||
let catalog = TreeNode::Branch(
|
||||
[
|
||||
("indexed".to_string(), indexed_tree),
|
||||
("computed".to_string(), computed_tree),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)
|
||||
.merge_branches()
|
||||
.expect("indexed/computed catalog merge: same series leaf with incompatible schemas");
|
||||
|
||||
fn insert(&mut self, vec: &'a dyn AnyExportableVec, db: &str) {
|
||||
let name = vec.name();
|
||||
let serialized_index = vec.index_type_to_string();
|
||||
let index = Index::try_from(serialized_index)
|
||||
.unwrap_or_else(|_| panic!("Unknown index type: {serialized_index}"));
|
||||
let matcher = QuickMatch::new(&series);
|
||||
|
||||
let prev = self
|
||||
.series_to_index_to_vec
|
||||
.entry(name)
|
||||
.or_default()
|
||||
.insert(index, vec);
|
||||
assert!(
|
||||
prev.is_none(),
|
||||
"Duplicate series: {name} for index {index:?}"
|
||||
);
|
||||
|
||||
self.index_to_series_to_vec
|
||||
.entry(index)
|
||||
.or_default()
|
||||
.insert(name, vec);
|
||||
|
||||
let is_lazy = vec.region_names().is_empty();
|
||||
self.counts_by_db
|
||||
.entry(db.to_string())
|
||||
.or_default()
|
||||
.add_endpoint(name, is_lazy);
|
||||
Self {
|
||||
series_to_index_to_vec,
|
||||
index_to_series_to_vec,
|
||||
series,
|
||||
indexes,
|
||||
counts,
|
||||
counts_by_db,
|
||||
catalog,
|
||||
matcher,
|
||||
series_to_indexes,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn series(&'static self, pagination: Pagination) -> PaginatedSeries {
|
||||
@@ -170,25 +131,21 @@ impl<'a> Vecs<'a> {
|
||||
}
|
||||
|
||||
pub fn series_to_indexes(&self, series: &SeriesName) -> Option<&Vec<Index>> {
|
||||
self.series_to_indexes
|
||||
.get(series.replace("-", "_").as_str())
|
||||
self.series_to_indexes.get(series.normalize().as_ref())
|
||||
}
|
||||
|
||||
pub fn index_to_ids(
|
||||
&self,
|
||||
PaginationIndex { index, pagination }: PaginationIndex,
|
||||
) -> Option<&[&'a str]> {
|
||||
let vec = self.index_to_series.get(&index)?;
|
||||
|
||||
let len = vec.len();
|
||||
let start = pagination.start(len);
|
||||
let end = pagination.end(len);
|
||||
|
||||
Some(&vec[start..end])
|
||||
pub fn series_info(&self, series: &SeriesName) -> Option<SeriesInfo> {
|
||||
let index_to_vec = self.series_to_index_to_vec.get(series.normalize().as_ref())?;
|
||||
let value_type = index_to_vec.values().next()?.value_type_to_string();
|
||||
let indexes = index_to_vec.keys().copied().collect();
|
||||
Some(SeriesInfo {
|
||||
indexes,
|
||||
value_type: value_type.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn catalog(&self) -> &TreeNode {
|
||||
self.catalog.as_ref().expect("catalog not initialized")
|
||||
&self.catalog
|
||||
}
|
||||
|
||||
pub fn matches(&self, series: &SeriesName, limit: Limit) -> Vec<&'_ str> {
|
||||
@@ -196,16 +153,13 @@ impl<'a> Vecs<'a> {
|
||||
return Vec::new();
|
||||
}
|
||||
self.matcher
|
||||
.as_ref()
|
||||
.expect("matcher not initialized")
|
||||
.matches_with(series, &QuickMatchConfig::new().with_limit(*limit))
|
||||
}
|
||||
|
||||
/// Look up a vec by series name and index
|
||||
/// Look up a vec by series name and index. `series` is normalized (`-` → `_`, lowercased).
|
||||
pub fn get(&self, series: &SeriesName, index: Index) -> Option<&'a dyn AnyExportableVec> {
|
||||
let series_name = series.replace("-", "_");
|
||||
self.series_to_index_to_vec
|
||||
.get(series_name.as_str())
|
||||
.get(series.normalize().as_ref())
|
||||
.and_then(|index_to_vec| index_to_vec.get(&index).copied())
|
||||
}
|
||||
}
|
||||
@@ -215,3 +169,48 @@ pub struct IndexToVec<'a>(BTreeMap<Index, &'a dyn AnyExportableVec>);
|
||||
|
||||
#[derive(Default, Deref, DerefMut)]
|
||||
pub struct SeriesToVec<'a>(BTreeMap<&'a str, &'a dyn AnyExportableVec>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Builder<'a> {
|
||||
series_to_index_to_vec: BTreeMap<&'a str, IndexToVec<'a>>,
|
||||
index_to_series_to_vec: BTreeMap<Index, SeriesToVec<'a>>,
|
||||
counts: SeriesCount,
|
||||
counts_by_db: BTreeMap<String, SeriesCount>,
|
||||
seen_by_db: FxHashMap<&'a str, FxHashSet<&'a str>>,
|
||||
}
|
||||
|
||||
impl<'a> Builder<'a> {
|
||||
fn insert(&mut self, vec: &'a dyn AnyExportableVec, db: &'a str) {
|
||||
let name = vec.name();
|
||||
let serialized_index = vec.index_type_to_string();
|
||||
let index = Index::try_from(serialized_index)
|
||||
.unwrap_or_else(|_| panic!("Unknown index type: {serialized_index}"));
|
||||
|
||||
let prev = self
|
||||
.series_to_index_to_vec
|
||||
.entry(name)
|
||||
.or_default()
|
||||
.insert(index, vec);
|
||||
assert!(prev.is_none(), "Duplicate series: {name} for index {index:?}");
|
||||
|
||||
self.index_to_series_to_vec
|
||||
.entry(index)
|
||||
.or_default()
|
||||
.insert(name, vec);
|
||||
|
||||
let is_lazy = vec.region_names().is_empty();
|
||||
let by_db = self.counts_by_db.entry(db.to_string()).or_default();
|
||||
self.counts.total_endpoints += 1;
|
||||
by_db.total_endpoints += 1;
|
||||
if is_lazy {
|
||||
self.counts.lazy_endpoints += 1;
|
||||
by_db.lazy_endpoints += 1;
|
||||
} else {
|
||||
self.counts.stored_endpoints += 1;
|
||||
by_db.stored_endpoints += 1;
|
||||
}
|
||||
if self.seen_by_db.entry(db).or_default().insert(name) {
|
||||
by_db.distinct_series += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use aide::axum::{ApiRouter, routing::get_with};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, Uri},
|
||||
};
|
||||
use brk_types::{AddrStats, AddrValidation, Transaction, Txid, Utxo, Version};
|
||||
use brk_types::{AddrStats, AddrValidation, Transaction, Utxo, Version};
|
||||
|
||||
use crate::{
|
||||
AppState, CacheStrategy,
|
||||
extended::TransformResponseExtended,
|
||||
params::{AddrParam, AddrTxidsParam, Empty, ValidateAddrParam},
|
||||
params::{AddrAfterTxidParam, AddrParam, Empty, ValidateAddrParam},
|
||||
};
|
||||
|
||||
pub trait AddrRoutes {
|
||||
@@ -46,16 +46,16 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<AddrParam>,
|
||||
Query(params): Query<AddrTxidsParam>,
|
||||
_: Empty,
|
||||
State(state): State<AppState>
|
||||
| {
|
||||
let strategy = state.addr_strategy(Version::ONE, &path.addr, false);
|
||||
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs(path.addr, params.after_txid, 50)).await
|
||||
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs(path.addr, 50, 25)).await
|
||||
}, |op| op
|
||||
.id("get_address_txs")
|
||||
.addrs_tag()
|
||||
.summary("Address transactions")
|
||||
.description("Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. Use ?after_txid=<txid> for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*")
|
||||
.description("Get transaction history for an address, sorted with newest first. Returns up to 50 mempool transactions plus the first 25 confirmed transactions. To paginate further confirmed transactions, use `/address/{address}/txs/chain/{last_seen_txid}`.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*")
|
||||
.json_response::<Vec<Transaction>>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
@@ -69,16 +69,39 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<AddrParam>,
|
||||
Query(params): Query<AddrTxidsParam>,
|
||||
_: Empty,
|
||||
State(state): State<AppState>
|
||||
| {
|
||||
let strategy = state.addr_strategy(Version::ONE, &path.addr, true);
|
||||
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs(path.addr, params.after_txid, 25)).await
|
||||
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs_chain(&path.addr, None, 25)).await
|
||||
}, |op| op
|
||||
.id("get_address_confirmed_txs")
|
||||
.addrs_tag()
|
||||
.summary("Address confirmed transactions")
|
||||
.description("Get confirmed transactions for an address, 25 per page. Use ?after_txid=<txid> for pagination.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*")
|
||||
.description("Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*")
|
||||
.json_response::<Vec<Transaction>>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
.server_error()
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/address/{address}/txs/chain/{after_txid}",
|
||||
get_with(async |
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<AddrAfterTxidParam>,
|
||||
_: Empty,
|
||||
State(state): State<AppState>
|
||||
| {
|
||||
let strategy = state.addr_strategy(Version::ONE, &path.addr, true);
|
||||
state.respond_json(&headers, strategy, &uri, move |q| q.addr_txs_chain(&path.addr, Some(path.after_txid), 25)).await
|
||||
}, |op| op
|
||||
.id("get_address_confirmed_txs_after")
|
||||
.addrs_tag()
|
||||
.summary("Address confirmed transactions (paginated)")
|
||||
.description("Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*")
|
||||
.json_response::<Vec<Transaction>>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
@@ -95,14 +118,14 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
_: Empty,
|
||||
State(state): State<AppState>
|
||||
| {
|
||||
let hash = state.sync(|q| q.addr_mempool_hash(&path.addr));
|
||||
state.respond_json(&headers, CacheStrategy::MempoolHash(hash), &uri, move |q| q.addr_mempool_txids(path.addr)).await
|
||||
let hash = state.sync(|q| q.addr_mempool_hash(&path.addr)).unwrap_or(0);
|
||||
state.respond_json(&headers, CacheStrategy::MempoolHash(hash), &uri, move |q| q.addr_mempool_txs(&path.addr, 50)).await
|
||||
}, |op| op
|
||||
.id("get_address_mempool_txs")
|
||||
.addrs_tag()
|
||||
.summary("Address mempool transactions")
|
||||
.description("Get unconfirmed transaction IDs for an address from the mempool (up to 50).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)*")
|
||||
.json_response::<Vec<Txid>>()
|
||||
.description("Get unconfirmed transactions for an address from the mempool, newest first (up to 50).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)*")
|
||||
.json_response::<Vec<Transaction>>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
@@ -119,7 +142,7 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
State(state): State<AppState>
|
||||
| {
|
||||
let strategy = state.addr_strategy(Version::ONE, &path.addr, false);
|
||||
state.respond_json(&headers, strategy, &uri, move |q| q.addr_utxos(path.addr)).await
|
||||
state.respond_json(&headers, strategy, &uri, move |q| q.addr_utxos(path.addr, 1000)).await
|
||||
}, |op| op
|
||||
.id("get_address_utxos")
|
||||
.addrs_tag()
|
||||
|
||||
@@ -40,7 +40,7 @@ pub(super) async fn serve(
|
||||
let max_weight = state.max_weight;
|
||||
let resolved = state.run(move |q| q.resolve(params, max_weight)).await?;
|
||||
|
||||
let format = resolved.format();
|
||||
let format = resolved.format;
|
||||
let csv_filename = resolved.csv_filename();
|
||||
let cache_params = CacheParams::series(
|
||||
resolved.version,
|
||||
|
||||
@@ -100,7 +100,7 @@ fn cost_basis_formatted(
|
||||
value: CostBasisValue,
|
||||
) -> BrkResult<CostBasisFormatted> {
|
||||
let raw = q.urpd_raw(cohort, date)?;
|
||||
let day1 = Day1::try_from(date).map_err(|e| Error::Parse(e.to_string()))?;
|
||||
let day1 = Day1::try_from(date)?;
|
||||
let spot_cents = q
|
||||
.computer()
|
||||
.prices
|
||||
|
||||
@@ -61,6 +61,7 @@ fn error_status(e: &BrkError) -> StatusCode {
|
||||
| BrkError::NotFound(_)
|
||||
| BrkError::NoData
|
||||
| BrkError::OutOfRange(_)
|
||||
| BrkError::UnindexableDate
|
||||
| BrkError::SeriesNotFound(_) => StatusCode::NOT_FOUND,
|
||||
|
||||
BrkError::AuthFailed => StatusCode::FORBIDDEN,
|
||||
@@ -85,6 +86,7 @@ fn error_code(e: &BrkError) -> &'static str {
|
||||
BrkError::UnknownTxid => "unknown_txid",
|
||||
BrkError::NotFound(_) => "not_found",
|
||||
BrkError::OutOfRange(_) => "out_of_range",
|
||||
BrkError::UnindexableDate => "unindexable_date",
|
||||
BrkError::NoData => "no_data",
|
||||
BrkError::SeriesNotFound(_) => "series_not_found",
|
||||
BrkError::MempoolNotAvailable => "mempool_not_available",
|
||||
|
||||
14
crates/brk_server/src/params/addr_after_txid_param.rs
Normal file
14
crates/brk_server/src/params/addr_after_txid_param.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use brk_types::{Addr, Txid};
|
||||
|
||||
/// Bitcoin address + last-seen txid path parameters (Esplora-style pagination)
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
pub struct AddrAfterTxidParam {
|
||||
#[serde(rename = "address")]
|
||||
pub addr: Addr,
|
||||
|
||||
/// Last txid from the previous page (return transactions strictly older than this)
|
||||
pub after_txid: Txid,
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use brk_types::Txid;
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AddrTxidsParam {
|
||||
/// Txid to paginate from (return transactions before this one)
|
||||
pub after_txid: Option<Txid>,
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
mod addr_after_txid_param;
|
||||
mod addr_param;
|
||||
mod addr_txids_param;
|
||||
mod block_count_param;
|
||||
mod blockhash_param;
|
||||
mod blockhash_start_index;
|
||||
@@ -17,8 +17,8 @@ mod txids_param;
|
||||
mod urpd_params;
|
||||
mod validate_addr_param;
|
||||
|
||||
pub use addr_after_txid_param::*;
|
||||
pub use addr_param::*;
|
||||
pub use addr_txids_param::*;
|
||||
pub use block_count_param::*;
|
||||
pub use blockhash_param::*;
|
||||
pub use blockhash_start_index::*;
|
||||
|
||||
@@ -75,11 +75,10 @@ impl AppState {
|
||||
/// - Unknown address → `Tip`
|
||||
pub fn addr_strategy(&self, version: Version, addr: &Addr, chain_only: bool) -> CacheStrategy {
|
||||
self.sync(|q| {
|
||||
if !chain_only {
|
||||
let mempool_hash = q.addr_mempool_hash(addr);
|
||||
if mempool_hash != 0 {
|
||||
return CacheStrategy::MempoolHash(mempool_hash);
|
||||
}
|
||||
if !chain_only
|
||||
&& let Some(mempool_hash) = q.addr_mempool_hash(addr)
|
||||
{
|
||||
return CacheStrategy::MempoolHash(mempool_hash);
|
||||
}
|
||||
q.addr_last_activity_height(addr)
|
||||
.and_then(|h| {
|
||||
|
||||
@@ -19,5 +19,5 @@ pub struct AddrStats {
|
||||
pub chain_stats: AddrChainStats,
|
||||
|
||||
/// Statistics for unconfirmed transactions in the mempool
|
||||
pub mempool_stats: Option<AddrMempoolStats>,
|
||||
pub mempool_stats: AddrMempoolStats,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use std::{fmt, ops::Deref};
|
||||
use std::{fmt, ops::Deref, path::Path};
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
/// URPD cohort identifier. Use `GET /api/urpd` to list available cohorts.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
|
||||
///
|
||||
/// Validated at construction: non-empty, ASCII `[a-z0-9_]+`. Matches the
|
||||
/// schemars enum value set; the type therefore proves "this is a valid
|
||||
/// cohort name" wherever a `Cohort` is held.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, JsonSchema)]
|
||||
#[schemars(extend("enum" = [
|
||||
"all", "sth", "lth",
|
||||
"utxos_under_1h_old", "utxos_1h_to_1d_old", "utxos_1d_to_1w_old", "utxos_1w_to_1m_old",
|
||||
@@ -16,15 +20,20 @@ use serde::{Deserialize, Serialize};
|
||||
]))]
|
||||
pub struct Cohort(String);
|
||||
|
||||
impl fmt::Display for Cohort {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
impl Cohort {
|
||||
/// Returns `Some(Cohort)` iff `s` is non-empty ASCII `[a-z0-9_]+`.
|
||||
pub fn new(s: impl Into<String>) -> Option<Self> {
|
||||
let s = s.into();
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') {
|
||||
return None;
|
||||
}
|
||||
Some(Self(s))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Into<String>> From<T> for Cohort {
|
||||
fn from(s: T) -> Self {
|
||||
Self(s.into())
|
||||
impl fmt::Display for Cohort {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,3 +43,24 @@ impl Deref for Cohort {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Cohort {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Path> for Cohort {
|
||||
fn as_ref(&self) -> &Path {
|
||||
Path::new(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Cohort {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
Self::new(s).ok_or_else(|| {
|
||||
serde::de::Error::custom("invalid cohort: expected non-empty [a-z0-9_]+")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ mod op_return_index;
|
||||
mod option_ext;
|
||||
mod oracle_bins;
|
||||
mod outpoint;
|
||||
mod outpoint_prefix;
|
||||
mod output;
|
||||
mod output_type;
|
||||
mod p2a_addr_index;
|
||||
@@ -115,7 +116,6 @@ mod p2wpkh_bytes;
|
||||
mod p2wsh_addr_index;
|
||||
mod p2wsh_bytes;
|
||||
mod pagination;
|
||||
mod pagination_index;
|
||||
mod percentile;
|
||||
mod pool;
|
||||
mod pool_detail;
|
||||
@@ -287,6 +287,7 @@ pub use op_return_index::*;
|
||||
pub use option_ext::*;
|
||||
pub use oracle_bins::*;
|
||||
pub use outpoint::*;
|
||||
pub use outpoint_prefix::*;
|
||||
pub use output::*;
|
||||
pub use output_type::*;
|
||||
pub use p2a_addr_index::*;
|
||||
@@ -307,7 +308,6 @@ pub use p2wpkh_bytes::*;
|
||||
pub use p2wsh_addr_index::*;
|
||||
pub use p2wsh_bytes::*;
|
||||
pub use pagination::*;
|
||||
pub use pagination_index::*;
|
||||
pub use percentile::*;
|
||||
pub use pool::*;
|
||||
pub use pool_detail::*;
|
||||
|
||||
44
crates/brk_types/src/outpoint_prefix.rs
Normal file
44
crates/brk_types/src/outpoint_prefix.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use crate::{Txid, TxidPrefix, Vout};
|
||||
|
||||
/// Compact `(TxidPrefix, Vout)` outpoint identifier. Prefix collisions
|
||||
/// are possible and must be verified by the caller.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct OutpointPrefix(TxidPrefix, Vout);
|
||||
|
||||
impl OutpointPrefix {
|
||||
#[inline]
|
||||
pub fn new(txid_prefix: TxidPrefix, vout: Vout) -> Self {
|
||||
Self(txid_prefix, vout)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn txid_prefix(self) -> TxidPrefix {
|
||||
self.0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn vout(self) -> Vout {
|
||||
self.1
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(TxidPrefix, Vout)> for OutpointPrefix {
|
||||
#[inline]
|
||||
fn from((txid_prefix, vout): (TxidPrefix, Vout)) -> Self {
|
||||
Self(txid_prefix, vout)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(&Txid, Vout)> for OutpointPrefix {
|
||||
#[inline]
|
||||
fn from((txid, vout): (&Txid, Vout)) -> Self {
|
||||
Self(TxidPrefix::from(txid), vout)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(Txid, Vout)> for OutpointPrefix {
|
||||
#[inline]
|
||||
fn from((txid, vout): (Txid, Vout)) -> Self {
|
||||
Self(TxidPrefix::from(&txid), vout)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{Index, Pagination};
|
||||
|
||||
/// Pagination parameters with an index filter
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct PaginationIndex {
|
||||
/// The index to filter by
|
||||
pub index: Index,
|
||||
#[serde(flatten)]
|
||||
pub pagination: Pagination,
|
||||
}
|
||||
@@ -30,6 +30,7 @@ pub enum PoolSlug {
|
||||
UltimusPool,
|
||||
TerraPool,
|
||||
Luxor,
|
||||
#[serde(rename = "1thash")]
|
||||
OneThash,
|
||||
BtcCom,
|
||||
Bitfarms,
|
||||
@@ -38,6 +39,7 @@ pub enum PoolSlug {
|
||||
CanoePool,
|
||||
BtcTop,
|
||||
BitcoinCom,
|
||||
#[serde(rename = "175btc")]
|
||||
Pool175btc,
|
||||
GbMiners,
|
||||
AXbt,
|
||||
@@ -53,6 +55,7 @@ pub enum PoolSlug {
|
||||
MaxBtc,
|
||||
TripleMining,
|
||||
CoinLab,
|
||||
#[serde(rename = "50btc")]
|
||||
Pool50btc,
|
||||
GhashIo,
|
||||
StMiningCorp,
|
||||
@@ -84,8 +87,10 @@ pub enum PoolSlug {
|
||||
ExxBw,
|
||||
Bitsolo,
|
||||
BitFury,
|
||||
#[serde(rename = "21inc")]
|
||||
TwentyOneInc,
|
||||
DigitalBtc,
|
||||
#[serde(rename = "8baochi")]
|
||||
EightBaochi,
|
||||
MyBtcCoinPool,
|
||||
TbDice,
|
||||
@@ -95,6 +100,7 @@ pub enum PoolSlug {
|
||||
HotPool,
|
||||
OkExPool,
|
||||
BcMonster,
|
||||
#[serde(rename = "1hash")]
|
||||
OneHash,
|
||||
Bixin,
|
||||
TatmasPool,
|
||||
@@ -105,12 +111,14 @@ pub enum PoolSlug {
|
||||
DcExploration,
|
||||
Dcex,
|
||||
BtPool,
|
||||
#[serde(rename = "58coin")]
|
||||
FiftyEightCoin,
|
||||
BitcoinIndia,
|
||||
ShawnP0wers,
|
||||
PHashIo,
|
||||
RigPool,
|
||||
HaoZhuZhu,
|
||||
#[serde(rename = "7pool")]
|
||||
SevenPool,
|
||||
MiningKings,
|
||||
HashBx,
|
||||
@@ -164,6 +172,7 @@ pub enum PoolSlug {
|
||||
EkanemBtc,
|
||||
Canoe,
|
||||
Tiger,
|
||||
#[serde(rename = "1m1x")]
|
||||
OneM1x,
|
||||
Zulupool,
|
||||
SecPool,
|
||||
@@ -200,6 +209,7 @@ pub enum PoolSlug {
|
||||
RedRockPool,
|
||||
Est3lar,
|
||||
BraiinsSolo,
|
||||
#[serde(rename = "solopoolcom")]
|
||||
SoloPool,
|
||||
Noderunners,
|
||||
#[serde(skip)]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use rustc_hash::FxHashSet;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -19,22 +18,6 @@ pub struct SeriesCount {
|
||||
/// Number of eager (stored on disk) series-index combinations
|
||||
#[schemars(example = 16000)]
|
||||
pub stored_endpoints: usize,
|
||||
#[serde(skip)]
|
||||
seen: FxHashSet<String>,
|
||||
}
|
||||
|
||||
impl SeriesCount {
|
||||
pub fn add_endpoint(&mut self, name: &str, is_lazy: bool) {
|
||||
self.total_endpoints += 1;
|
||||
if is_lazy {
|
||||
self.lazy_endpoints += 1;
|
||||
} else {
|
||||
self.stored_endpoints += 1;
|
||||
}
|
||||
if self.seen.insert(name.to_string()) {
|
||||
self.distinct_series += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detailed series count with per-database breakdown
|
||||
|
||||
@@ -30,19 +30,14 @@ impl From<SeriesName> for SeriesList {
|
||||
impl From<String> for SeriesList {
|
||||
#[inline]
|
||||
fn from(value: String) -> Self {
|
||||
Self::from(SeriesName::from(value.replace("-", "_").to_lowercase()))
|
||||
Self::from(SeriesName::from(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<Vec<&'a str>> for SeriesList {
|
||||
#[inline]
|
||||
fn from(value: Vec<&'a str>) -> Self {
|
||||
Self(
|
||||
value
|
||||
.iter()
|
||||
.map(|s| SeriesName::from(s.replace("-", "_").to_lowercase()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
Self(value.into_iter().map(SeriesName::from).collect())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::fmt::Display;
|
||||
use std::{borrow::Cow, fmt::Display};
|
||||
|
||||
use derive_more::Deref;
|
||||
use schemars::JsonSchema;
|
||||
@@ -15,6 +15,17 @@ use serde::{Deserialize, Serialize};
|
||||
)]
|
||||
pub struct SeriesName(String);
|
||||
|
||||
impl SeriesName {
|
||||
/// Lookup key: `-` → `_`, lowercased. Borrows when already normalized.
|
||||
pub fn normalize(&self) -> Cow<'_, str> {
|
||||
if self.0.bytes().any(|b| b == b'-' || b.is_ascii_uppercase()) {
|
||||
Cow::Owned(self.0.replace('-', "_").to_lowercase())
|
||||
} else {
|
||||
Cow::Borrowed(&self.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for SeriesName {
|
||||
#[inline]
|
||||
fn from(series: String) -> Self {
|
||||
|
||||
@@ -30,4 +30,13 @@ impl TxStatus {
|
||||
block_height: None,
|
||||
block_time: None,
|
||||
};
|
||||
|
||||
pub fn confirmed(height: Height, block_hash: BlockHash, block_time: Timestamp) -> Self {
|
||||
Self {
|
||||
confirmed: true,
|
||||
block_height: Some(height),
|
||||
block_hash: Some(block_hash),
|
||||
block_time: Some(block_time),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user