global: fixes

This commit is contained in:
nym21
2026-04-29 16:51:01 +02:00
parent a7e41df1c6
commit 43f3be4924
101 changed files with 3074 additions and 2869 deletions

View File

@@ -69,7 +69,7 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
writeln!(
output,
" * @param {{{{ signal?: AbortSignal, onUpdate?: (value: {}) => void }}}} [options]",
" * @param {{{{ signal?: AbortSignal, onValue?: (value: {}) => void }}}} [options]",
return_type
)
.unwrap();
@@ -78,18 +78,18 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
let params = build_method_params(endpoint);
let params_with_opts = if params.is_empty() {
"{ signal, onUpdate } = {}".to_string()
"{ signal, onValue } = {}".to_string()
} else {
format!("{}, {{ signal, onUpdate }} = {{}}", params)
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 = if endpoint.returns_json() {
"this.getJson(path, { signal, onUpdate })"
"this.getJson(path, { signal, onValue })"
} else {
"this.getText(path, { signal, onUpdate })"
"this.getText(path, { signal, onValue })"
};
if endpoint.query_params.is_empty() {
@@ -126,7 +126,7 @@ pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
if endpoint.supports_csv {
writeln!(
output,
" if (format === 'csv') return this.getText(path, {{ signal, onUpdate }});"
" if (format === 'csv') return this.getText(path, {{ signal, onValue }});"
)
.unwrap();
}

View File

@@ -16,12 +16,16 @@ pub fn generate_base_client(output: &mut String) {
* @typedef {{Object}} BrkClientOptions
* @property {{string}} baseUrl - Base URL for the API
* @property {{number}} [timeout] - Request timeout in milliseconds
* @property {{string|boolean}} [cache] - Enable browser cache with default name (true), custom name (string), or disable (false). No effect in Node.js. Default: true
* @property {{string|boolean}} [browserCache] - Enable browser Cache API with default name (true), custom name (string), or disable (false). No effect in Node.js. Default: true
* @property {{number|boolean}} [memCache] - In-memory parsed-response cache size (LRU). true/undefined → 1000, false/0 → disabled. Lets 304 responses skip the JSON parse entirely. Default: 1000
*/
const _isBrowser = typeof window !== 'undefined' && 'caches' in window;
const _runIdle = (/** @type {{VoidFunction}} */ fn) => (globalThis.requestIdleCallback ?? setTimeout)(fn);
const _defaultCacheName = '__BRK_CLIENT__';
const _defaultBrowserCacheName = '__BRK_CLIENT__';
const _DEFAULT_MEM_CACHE_SIZE = 1000;
/** @template T @typedef {{{{ etag: string | null, value: T }}}} _MemEntry */
/** @param {{*}} v */
const _addCamelGetters = (v) => {{
if (Array.isArray(v)) {{ v.forEach(_addCamelGetters); return v; }}
@@ -38,12 +42,12 @@ const _addCamelGetters = (v) => {{
}};
/**
* @param {{string|boolean|undefined}} cache
* @param {{string|boolean|undefined}} option
* @returns {{Promise<Cache | null>}}
*/
const _openCache = (cache) => {{
if (!_isBrowser || cache === false) return Promise.resolve(null);
const name = typeof cache === 'string' ? cache : _defaultCacheName;
const _openBrowserCache = (option) => {{
if (!_isBrowser || option === false) return Promise.resolve(null);
const name = typeof option === 'string' ? option : _defaultBrowserCacheName;
return caches.open(name).catch(() => null);
}};
@@ -233,7 +237,7 @@ function _wrapSeriesData(raw) {{
* @property {{(n: number) => RangeBuilder<T>}} first - Get first n items
* @property {{(n: number) => RangeBuilder<T>}} last - Get last n items
* @property {{(n: number) => SkippedBuilder<T>}} skip - Skip first n items, chain with take()
* @property {{(onUpdate?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch all data
* @property {{(onValue?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch all data
* @property {{() => Promise<string>}} fetchCsv - Fetch all data as CSV
* @property {{() => Promise<number>}} len - Get total number of data points
* @property {{() => Promise<Version>}} version - Get the current version of the series
@@ -249,7 +253,7 @@ function _wrapSeriesData(raw) {{
* @property {{(n: number) => DateRangeBuilder<T>}} first - Get first n items
* @property {{(n: number) => DateRangeBuilder<T>}} last - Get last n items
* @property {{(n: number) => DateSkippedBuilder<T>}} skip - Skip first n items, chain with take()
* @property {{(onUpdate?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch all data
* @property {{(onValue?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch all data
* @property {{() => Promise<string>}} fetchCsv - Fetch all data as CSV
* @property {{() => Promise<number>}} len - Get total number of data points
* @property {{() => Promise<Version>}} version - Get the current version of the series
@@ -260,39 +264,39 @@ function _wrapSeriesData(raw) {{
/** @typedef {{SeriesEndpoint<any>}} AnySeriesEndpoint */
/** @template T @typedef {{Object}} SingleItemBuilder
* @property {{(onUpdate?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch the item
* @property {{(onValue?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch the item
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
* @property {{Thenable<T>}} then - Thenable
*/
/** @template T @typedef {{Object}} DateSingleItemBuilder
* @property {{(onUpdate?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch the item
* @property {{(onValue?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch the item
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
* @property {{DateThenable<T>}} then - Thenable
*/
/** @template T @typedef {{Object}} SkippedBuilder
* @property {{(n: number) => RangeBuilder<T>}} take - Take n items after skipped position
* @property {{(onUpdate?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch from skipped position to end
* @property {{(onValue?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch from skipped position to end
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
* @property {{Thenable<T>}} then - Thenable
*/
/** @template T @typedef {{Object}} DateSkippedBuilder
* @property {{(n: number) => DateRangeBuilder<T>}} take - Take n items after skipped position
* @property {{(onUpdate?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch from skipped position to end
* @property {{(onValue?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch from skipped position to end
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
* @property {{DateThenable<T>}} then - Thenable
*/
/** @template T @typedef {{Object}} RangeBuilder
* @property {{(onUpdate?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch the range
* @property {{(onValue?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch the range
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
* @property {{Thenable<T>}} then - Thenable
*/
/** @template T @typedef {{Object}} DateRangeBuilder
* @property {{(onUpdate?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch the range
* @property {{(onValue?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch the range
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
* @property {{DateThenable<T>}} then - Thenable
*/
@@ -340,7 +344,7 @@ function _endpoint(client, name, index) {{
* @returns {{DateRangeBuilder<T>}}
*/
const rangeBuilder = (start, end) => ({{
fetch(onUpdate) {{ return client._fetchSeriesData(buildPath(start, end), onUpdate); }},
fetch(onValue) {{ return client._fetchSeriesData(buildPath(start, end), onValue); }},
fetchCsv() {{ return client.getText(buildPath(start, end, 'csv')); }},
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
}});
@@ -350,7 +354,7 @@ function _endpoint(client, name, index) {{
* @returns {{DateSingleItemBuilder<T>}}
*/
const singleItemBuilder = (idx) => ({{
fetch(onUpdate) {{ return client._fetchSeriesData(buildPath(idx, idx + 1), onUpdate); }},
fetch(onValue) {{ return client._fetchSeriesData(buildPath(idx, idx + 1), onValue); }},
fetchCsv() {{ return client.getText(buildPath(idx, idx + 1, 'csv')); }},
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
}});
@@ -361,7 +365,7 @@ function _endpoint(client, name, index) {{
*/
const skippedBuilder = (start) => ({{
take(n) {{ return rangeBuilder(start, start + n); }},
fetch(onUpdate) {{ return client._fetchSeriesData(buildPath(start, undefined), onUpdate); }},
fetch(onValue) {{ return client._fetchSeriesData(buildPath(start, undefined), onValue); }},
fetchCsv() {{ return client.getText(buildPath(start, undefined, 'csv')); }},
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
}});
@@ -377,7 +381,7 @@ function _endpoint(client, name, index) {{
first(n) {{ return rangeBuilder(undefined, n); }},
last(n) {{ return n === 0 ? rangeBuilder(undefined, 0) : rangeBuilder(-n, undefined); }},
skip(n) {{ return skippedBuilder(n); }},
fetch(onUpdate) {{ return client._fetchSeriesData(buildPath(), onUpdate); }},
fetch(onValue) {{ return client._fetchSeriesData(buildPath(), onValue); }},
fetchCsv() {{ return client.getText(buildPath(undefined, undefined, 'csv')); }},
len() {{ return client.getSeriesLen(name, index); }},
version() {{ return client.getSeriesVersion(name, index); }},
@@ -401,10 +405,45 @@ class BrkClientBase {{
this.baseUrl = rawUrl.endsWith('/') ? rawUrl.slice(0, -1) : rawUrl;
this.timeout = isString ? 5000 : (options.timeout ?? 5000);
/** @type {{Promise<Cache | null>}} */
this._cachePromise = _openCache(isString ? undefined : options.cache);
this._browserCachePromise = _openBrowserCache(isString ? undefined : options.browserCache);
/** @type {{Cache | null}} */
this._cache = null;
this._cachePromise.then(c => this._cache = c);
this._browserCache = null;
this._browserCachePromise.then(c => this._browserCache = c);
const memOpt = isString ? undefined : options.memCache;
this._memCacheMax = memOpt === false || memOpt === 0
? 0
: (typeof memOpt === 'number' ? memOpt : _DEFAULT_MEM_CACHE_SIZE);
/** @type {{Map<string, _MemEntry<unknown>>}} */
this._memCache = new Map();
}}
/**
* @template T
* @param {{string}} key
* @returns {{_MemEntry<T> | undefined}}
*/
_memGet(key) {{
if (!this._memCacheMax) return undefined;
const hit = this._memCache.get(key);
if (!hit) return undefined;
this._memCache.delete(key);
this._memCache.set(key, hit);
return /** @type {{_MemEntry<T>}} */ (hit);
}}
/**
* @param {{string}} key
* @param {{string | null}} etag
* @param {{unknown}} value
*/
_memSet(key, etag, value) {{
if (!this._memCacheMax) return;
if (this._memCache.has(key)) this._memCache.delete(key);
else if (this._memCache.size >= this._memCacheMax) {{
const oldest = this._memCache.keys().next().value;
if (oldest !== undefined) this._memCache.delete(oldest);
}}
this._memCache.set(key, {{ etag, value }});
}}
/**
@@ -422,66 +461,86 @@ class BrkClientBase {{
}}
/**
* Make a GET request - races cache vs network, first to resolve calls onUpdate.
* Shared implementation backing `getJson` and `getText`.
* Make a GET request with layered caching.
*
* Contract:
* - The returned Promise resolves with the **freshest** value (post-revalidation).
* - `onValue` fires once with the freshest value, or twice if a stale snapshot
* could be shown first (stale-while-revalidate). On a 304 there is no second fire.
*
* Layers:
* - L1 (memCache): in-memory parsed values keyed by URL+ETag. Lets 304s skip the parse entirely.
* - L2 (browserCache): Cache API, survives reload and feeds onValue fast on cold start.
*
* @template T
* @param {{string}} path
* @param {{(res: Response) => Promise<T>}} parse - Response body reader
* @param {{{{ onUpdate?: (value: T) => void, signal?: AbortSignal }}}} [options]
* @param {{{{ onValue?: (value: T) => void, signal?: AbortSignal }}}} [options]
* @returns {{Promise<T>}}
*/
async _getCached(path, parse, {{ onUpdate, signal }} = {{}}) {{
async _getCached(path, parse, {{ onValue, signal }} = {{}}) {{
const url = `${{this.baseUrl}}${{path}}`;
const cache = this._cache ?? await this._cachePromise;
/** @type {{_MemEntry<T> | undefined}} */
const memHit = this._memGet(url);
const browserCache = this._browserCache ?? await this._browserCachePromise;
let resolved = false;
/** @type {{Response | null}} */
let cachedRes = null;
// Race cache vs network - first to resolve calls onUpdate
const cachePromise = cache?.match(url).then(async (res) => {{
cachedRes = res ?? null;
if (!res) return null;
const value = await parse(res);
if (!resolved && onUpdate) {{
resolved = true;
onUpdate(value);
}}
return value;
}});
const networkPromise = this.get(path, {{ signal }}).then(async (res) => {{
const cloned = res.clone();
const value = await parse(res);
// Skip update if ETag matches and cache already delivered
if (cachedRes?.headers.get('ETag') === res.headers.get('ETag')) {{
if (!resolved && onUpdate) {{
resolved = true;
onUpdate(value);
}}
// L1 fast path: deliver from memCache, revalidate via network.
// ETag match → zero parse, zero clone, zero cache write, no second onValue fire.
if (memHit) {{
if (onValue) onValue(memHit.value);
try {{
const res = await this.get(path, {{ signal }});
const netEtag = res.headers.get('ETag');
if (netEtag && netEtag === memHit.etag) return memHit.value;
const cloned = browserCache ? res.clone() : null;
const value = await parse(res);
this._memSet(url, netEtag, value);
if (onValue) onValue(value);
if (cloned) _runIdle(() => browserCache.put(url, cloned));
return value;
}} catch {{
return memHit.value;
}}
resolved = true;
if (onUpdate) onUpdate(value);
if (cache) _runIdle(() => cache.put(url, cloned));
return value;
}});
}}
// L1 miss: race browserCache (stale snapshot) vs network (fresh).
let networkSettled = false;
const stalePromise = onValue && browserCache
? browserCache.match(url).then(async (res) => {{
if (!res || networkSettled) return null;
const value = await parse(res);
if (networkSettled) return value;
this._memSet(url, res.headers.get('ETag'), value);
onValue(value);
return value;
}}).catch(() => null)
: null;
try {{
return await networkPromise;
const res = await this.get(path, {{ signal }});
networkSettled = true;
const netEtag = res.headers.get('ETag');
// Stale won and populated memCache with matching ETag → reuse, skip parse + second onValue.
const populated = /** @type {{_MemEntry<T> | undefined}} */ (this._memGet(url));
if (populated && netEtag && netEtag === populated.etag) return populated.value;
const cloned = browserCache ? res.clone() : null;
const value = await parse(res);
this._memSet(url, netEtag, value);
if (onValue) onValue(value);
if (cloned) _runIdle(() => browserCache.put(url, cloned));
return value;
}} catch (e) {{
// Network failed - wait for cache
const cachedValue = await cachePromise?.catch(() => null);
if (cachedValue != null) return cachedValue;
const stale = await stalePromise;
if (stale != null) return stale;
throw e;
}}
}}
/**
* Make a GET request expecting a JSON response. Cached and supports `onUpdate`.
* Make a GET request expecting a JSON response. Cached and supports `onValue`.
* @template T
* @param {{string}} path
* @param {{{{ onUpdate?: (value: T) => void, signal?: AbortSignal }}}} [options]
* @param {{{{ onValue?: (value: T) => void, signal?: AbortSignal }}}} [options]
* @returns {{Promise<T>}}
*/
getJson(path, options) {{
@@ -490,9 +549,9 @@ class BrkClientBase {{
/**
* Make a GET request expecting a text response (text/plain, text/csv, ...).
* Cached and supports `onUpdate`, same as `getJson`.
* Cached and supports `onValue`, same as `getJson`.
* @param {{string}} path
* @param {{{{ onUpdate?: (value: string) => void, signal?: AbortSignal }}}} [options]
* @param {{{{ onValue?: (value: string) => void, signal?: AbortSignal }}}} [options]
* @returns {{Promise<string>}}
*/
getText(path, options) {{
@@ -503,12 +562,12 @@ class BrkClientBase {{
* Fetch series data and wrap with helper methods (internal)
* @template T
* @param {{string}} path
* @param {{(value: DateSeriesData<T>) => void}} [onUpdate]
* @param {{(value: DateSeriesData<T>) => void}} [onValue]
* @returns {{Promise<DateSeriesData<T>>}}
*/
async _fetchSeriesData(path, onUpdate) {{
const wrappedOnUpdate = onUpdate ? (/** @type {{SeriesData<T>}} */ raw) => onUpdate(_wrapSeriesData(raw)) : undefined;
const raw = await this.getJson(path, {{ onUpdate: wrappedOnUpdate }});
async _fetchSeriesData(path, onValue) {{
const wrappedOnValue = onValue ? (/** @type {{SeriesData<T>}} */ raw) => onValue(_wrapSeriesData(raw)) : undefined;
const raw = await this.getJson(path, {{ onValue: wrappedOnValue }});
return _wrapSeriesData(raw);
}}
}}

View File

@@ -325,13 +325,13 @@ AnyDateSeriesData = DateSeriesData[Any]
class _EndpointConfig:
"""Shared endpoint configuration."""
client: BrkClientBase
client: BrkClient
name: str
index: Index
start: Optional[int]
end: Optional[int]
def __init__(self, client: BrkClientBase, name: str, index: Index,
def __init__(self, client: BrkClient, name: str, index: Index,
start: Optional[int] = None, end: Optional[int] = None):
self.client = client
self.name = name
@@ -366,6 +366,12 @@ class _EndpointConfig:
def get_csv(self) -> str:
return self.client.get_text(self._build_path(format='csv'))
def get_len(self) -> int:
return self.client.get_series_len(self.name, self.index)
def get_version(self) -> Version:
return self.client.get_series_version(self.name, self.index)
class RangeBuilder(Generic[T]):
"""Builder with range specified."""
@@ -449,7 +455,7 @@ class SeriesEndpoint(Generic[T]):
data = endpoint.skip(100).take(10).fetch()
"""
def __init__(self, client: BrkClientBase, name: str, index: Index):
def __init__(self, client: BrkClient, name: str, index: Index):
self._config = _EndpointConfig(client, name, index)
@overload
@@ -483,6 +489,14 @@ class SeriesEndpoint(Generic[T]):
"""Fetch all data as CSV."""
return self._config.get_csv()
def len(self) -> int:
"""Total number of data points for this series."""
return self._config.get_len()
def version(self) -> Version:
"""Current version of the series."""
return self._config.get_version()
def path(self) -> str:
"""Get the base endpoint path."""
return self._config.path()
@@ -500,7 +514,7 @@ class DateSeriesEndpoint(Generic[T]):
data = endpoint[:10].fetch()
"""
def __init__(self, client: BrkClientBase, name: str, index: Index):
def __init__(self, client: BrkClient, name: str, index: Index):
self._config = _EndpointConfig(client, name, index)
@overload
@@ -546,6 +560,14 @@ class DateSeriesEndpoint(Generic[T]):
"""Fetch all data as CSV."""
return self._config.get_csv()
def len(self) -> int:
"""Total number of data points for this series."""
return self._config.get_len()
def version(self) -> Version:
"""Current version of the series."""
return self._config.get_version()
def path(self) -> str:
"""Get the base endpoint path."""
return self._config.path()
@@ -604,10 +626,10 @@ pub fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern
// Generate helper functions
writeln!(
output,
r#"def _ep(c: BrkClientBase, n: str, i: Index) -> SeriesEndpoint[Any]:
r#"def _ep(c: BrkClient, n: str, i: Index) -> SeriesEndpoint[Any]:
return SeriesEndpoint(c, n, i)
def _dep(c: BrkClientBase, n: str, i: Index) -> DateSeriesEndpoint[Any]:
def _dep(c: BrkClient, n: str, i: Index) -> DateSeriesEndpoint[Any]:
return DateSeriesEndpoint(c, n, i)
"#
)
@@ -623,7 +645,7 @@ def _dep(c: BrkClientBase, n: str, i: Index) -> DateSeriesEndpoint[Any]:
writeln!(output, "class {}(Generic[T]):", by_class_name).unwrap();
writeln!(
output,
" def __init__(self, c: BrkClientBase, n: str): self._c, self._n = c, n"
" def __init__(self, c: BrkClient, n: str): self._c, self._n = c, n"
)
.unwrap();
for index in &pattern.indexes {
@@ -648,7 +670,7 @@ def _dep(c: BrkClientBase, n: str, i: Index) -> DateSeriesEndpoint[Any]:
writeln!(output, " by: {}[T]", by_class_name).unwrap();
writeln!(
output,
" def __init__(self, c: BrkClientBase, n: str): self._n, self.by = n, {}(c, n)",
" def __init__(self, c: BrkClient, n: str): self._n, self.by = n, {}(c, n)",
by_class_name
)
.unwrap();
@@ -705,13 +727,13 @@ pub fn generate_structural_patterns(
if pattern.is_templated() {
writeln!(
output,
" def __init__(self, client: BrkClientBase, acc: str, disc: str):"
" def __init__(self, client: BrkClient, acc: str, disc: str):"
)
.unwrap();
} else {
writeln!(
output,
" def __init__(self, client: BrkClientBase, acc: str):"
" def __init__(self, client: BrkClient, acc: str):"
)
.unwrap();
}

View File

@@ -64,7 +64,7 @@ fn generate_tree_class(
writeln!(output, " ").unwrap();
writeln!(
output,
" def __init__(self, client: BrkClientBase, base_path: str = ''):"
" def __init__(self, client: BrkClient, base_path: str = ''):"
)
.unwrap();

View File

@@ -187,6 +187,14 @@ impl EndpointConfig {{
fn get_text(&self, format: Option<&str>) -> Result<String> {{
self.client.get_text(&self.build_path(format))
}}
fn get_len(&self) -> Result<i64> {{
self.client.get_json(&format!("/api/series/{{}}/{{}}/len", self.name, self.index.name()))
}}
fn get_version(&self) -> Result<Version> {{
self.client.get_json(&format!("/api/series/{{}}/{{}}/version", self.name, self.index.name()))
}}
}}
/// Builder for series endpoint queries.
@@ -280,6 +288,17 @@ impl<T: DeserializeOwned, D: DeserializeOwned> SeriesEndpoint<T, D> {{
self.config.get_text(Some("csv"))
}}
/// Total number of data points for this series.
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> Result<i64> {{
self.config.get_len()
}}
/// Current version of the series.
pub fn version(&self) -> Result<Version> {{
self.config.get_version()
}}
/// Get the base endpoint path.
pub fn path(&self) -> String {{
self.config.path()

View File

@@ -19,10 +19,11 @@ BRK uses [sparse files](https://en.wikipedia.org/wiki/Sparse_file). Tools like `
## Install
```bash
rustup update
RUSTFLAGS="-C target-cpu=native" cargo install --locked brk_cli
rustup update && RUSTFLAGS="-C target-cpu=native" cargo install --locked brk_cli --version $(cargo search brk_cli | head -1 | awk -F'"' '{print $2}')
```
Updates Rust, then builds `brk_cli` with optimizations tuned to your CPU. The `--version $(...)` subshell queries crates.io for the absolute latest published version, including pre-releases (rc/beta/alpha); without it, `cargo install` only picks the latest stable.
Portable build (without native CPU optimizations):
```bash

View File

@@ -159,6 +159,14 @@ impl EndpointConfig {
fn get_text(&self, format: Option<&str>) -> Result<String> {
self.client.get_text(&self.build_path(format))
}
fn get_len(&self) -> Result<i64> {
self.client.get_json(&format!("/api/series/{}/{}/len", self.name, self.index.name()))
}
fn get_version(&self) -> Result<Version> {
self.client.get_json(&format!("/api/series/{}/{}/version", self.name, self.index.name()))
}
}
/// Builder for series endpoint queries.
@@ -252,6 +260,17 @@ impl<T: DeserializeOwned, D: DeserializeOwned> SeriesEndpoint<T, D> {
self.config.get_text(Some("csv"))
}
/// Total number of data points for this series.
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> Result<i64> {
self.config.get_len()
}
/// Current version of the series.
pub fn version(&self) -> Result<Version> {
self.config.get_version()
}
/// Get the base endpoint path.
pub fn path(&self) -> String {
self.config.path()

View File

@@ -42,8 +42,6 @@ impl TxRemoval {
.collect()
}
/// `Replaced` if any of `tx`'s inputs is now claimed by a freshly
/// added tx (BIP-125 inferred); otherwise `Vanished`.
fn find_removal(tx: &Transaction, spent_by: &SpentBy) -> Self {
tx.input
.iter()

View File

@@ -43,9 +43,6 @@ impl Linearizer {
.collect()
}
/// Singleton clusters bypass SFL: there's only one ordering. Larger
/// clusters are linearized into chunks, each chunk becoming a Package
/// with its order index recorded for downstream stability.
fn pack_cluster(cluster: &Cluster, cluster_id: u32) -> Vec<Package> {
if cluster.nodes.len() == 1 {
return vec![Package::singleton(cluster, cluster_id)];

View File

@@ -45,8 +45,7 @@ impl EntryPool {
}
/// Direct children of a transaction (txs whose `depends` includes
/// `prefix`). Derived on demand via a linear scan, called only by
/// the CPFP query endpoint, which is not on the hot path.
/// `prefix`). Linear scan over all entries.
pub fn children(&self, prefix: &TxidPrefix) -> SmallVec<[TxidPrefix; 2]> {
self.entries
.iter()

View File

@@ -36,16 +36,12 @@ impl TxStore {
self.promote_recent(new_recent);
}
/// Append to the cap-bounded sample buffer if there's room. The
/// pre-cap window becomes the next `recent()` value.
fn sample_recent(buf: &mut Vec<MempoolRecentTx>, txid: &Txid, tx: &Transaction) {
if buf.len() < RECENT_CAP {
buf.push(MempoolRecentTx::from((txid, tx)));
}
}
/// Record `txid` in the unresolved set if any input lacks a
/// prevout. Cleared later by `apply_fills` once all inputs fill.
fn track_unresolved(&mut self, txid: &Txid, tx: &Transaction) {
if tx.input.iter().any(|i| i.prevout.is_none()) {
self.unresolved.insert(txid.clone());
@@ -96,9 +92,6 @@ impl TxStore {
applied
}
/// Apply each `(vin, prevout)` to its empty input slot. Skips vins
/// that are out of range or already filled. Returns the prevouts
/// that were actually written.
fn write_prevouts(tx: &mut Transaction, fills: Vec<(Vin, TxOut)>) -> Vec<TxOut> {
let mut applied = Vec::with_capacity(fills.len());
for (vin, prevout) in fills {
@@ -118,8 +111,6 @@ impl TxStore {
tx.total_sigop_cost = tx.total_sigop_cost();
}
/// Drop `txid` from the unresolved set if every input now has a
/// prevout. Idempotent if the tx was removed between phases.
fn refresh_unresolved(&mut self, txid: &Txid) {
if self.txs.get(txid).is_some_and(Self::all_resolved) {
self.unresolved.remove(txid);

View File

@@ -85,9 +85,12 @@ impl Query {
tx_count: addr_data.tx_count,
realized_price,
},
mempool_stats: self
.mempool()
.and_then(|m| m.addrs().get(&bytes).map(|e| e.stats.clone())),
mempool_stats: self.mempool().map(|m| {
m.addrs()
.get(&bytes)
.map(|e| e.stats.clone())
.unwrap_or_default()
}),
})
}

View File

@@ -279,6 +279,7 @@ impl Query {
coinbase_signature,
coinbase_signature_ascii,
scriptsig_bytes,
coinbase_total_size,
) = match reader.reader_at(positions[i]) {
Ok(mut blk) => {
let mut header_buf = [0u8; HEADER_SIZE];
@@ -291,6 +292,7 @@ impl Query {
String::new(),
String::new(),
vec![],
0,
)
} else {
// Skip tx count varint
@@ -299,7 +301,7 @@ impl Query {
let coinbase = Self::parse_coinbase_from_read(blk);
(
header_buf, coinbase.0, coinbase.1, coinbase.2, coinbase.3, coinbase.4,
coinbase.5,
coinbase.5, coinbase.6,
)
}
}
@@ -311,6 +313,7 @@ impl Query {
String::new(),
String::new(),
vec![],
0,
),
};
let header = Self::decode_header(&raw_header)?;
@@ -382,8 +385,11 @@ impl Query {
coinbase_addresses,
coinbase_signature,
coinbase_signature_ascii,
avg_tx_size: if tx_count > 0 {
size as f64 / tx_count as f64
avg_tx_size: if tx_count > 0 && coinbase_total_size > 0 {
let non_coinbase_total = (size as usize)
.saturating_sub(HEADER_SIZE + varint_len + coinbase_total_size);
let raw = non_coinbase_total as f64 / tx_count as f64;
(raw * 100.0).round() / 100.0
} else {
0.0
},
@@ -542,7 +548,15 @@ impl Query {
fn parse_coinbase_from_read(
reader: impl Read,
) -> (String, Option<String>, Vec<String>, String, String, Vec<u8>) {
) -> (
String,
Option<String>,
Vec<String>,
String,
String,
Vec<u8>,
usize,
) {
let empty = (
String::new(),
None,
@@ -550,6 +564,7 @@ impl Query {
String::new(),
String::new(),
vec![],
0,
);
let tx =
@@ -558,6 +573,8 @@ impl Query {
Err(_) => return empty,
};
let coinbase_total_size = tx.total_size();
let scriptsig_bytes: Vec<u8> = tx
.input
.first()
@@ -595,6 +612,7 @@ impl Query {
coinbase_signature,
coinbase_signature_ascii,
scriptsig_bytes,
coinbase_total_size,
)
}
}

View File

@@ -23,7 +23,9 @@ impl Query {
let (first, tx_count) = self.block_tx_range(height)?;
let start: usize = start_index.into();
if start >= tx_count {
return Ok(Vec::new());
return Err(Error::OutOfRange(
"start index past last transaction in block".into(),
));
}
let count = BLOCK_TXS_PAGE_SIZE.min(tx_count - start);
let indices: Vec<TxIndex> = (first + start..first + start + count)

View File

@@ -62,11 +62,7 @@ pub enum Auth {
CookieFile(PathBuf),
}
///
/// Bitcoin Core RPC Client
///
/// Thread safe and free to clone
///
/// Bitcoin Core RPC client. Thread-safe and cheap to clone.
#[derive(Debug, Clone)]
pub struct Client(pub(crate) Arc<ClientInner>);

View File

@@ -106,7 +106,6 @@ impl Client {
})
}
/// Get block hash at a given height
pub fn get_block_hash<H>(&self, height: H) -> Result<BlockHash>
where
H: Into<u64> + Copy,
@@ -188,7 +187,6 @@ impl Client {
Ok(FeeRate::from(r.mempool_min_fee * 100_000.0))
}
/// Get txids of all transactions in a memory pool
pub fn get_raw_mempool(&self) -> Result<Vec<Txid>> {
let r: GetRawMempool = self.0.call_with_retry("getrawmempool", &[])?;
r.0.iter()
@@ -310,7 +308,19 @@ impl Client {
pub fn send_raw_transaction(&self, hex: &str) -> Result<Txid> {
let txid: bitcoin::Txid = self
.0
.call_once("sendrawtransaction", &[Value::String(hex.to_string())])?;
.call_once("sendrawtransaction", &[Value::String(hex.to_string())])
.map_err(|e| {
// Bitcoin Core returns RPC error codes for client-side problems
// (decode failed, verification failed, already in chain, etc.).
// Surface these as 400 (Parse) so HTTP callers see a 4xx, matching
// mempool.space's POST /api/tx behavior.
if let Error::CorepcRPC(JsonRpcError::Rpc(rpc)) = &e
&& matches!(rpc.code, -22 | -25 | -26 | -27)
{
return Error::Parse(rpc.message.clone());
}
e
})?;
Ok(Txid::from(txid))
}

View File

@@ -18,7 +18,7 @@ impl TxidsParam {
/// Rejects unknown keys to prevent cache-busting via injected query params.
pub fn from_query(query: &str) -> Result<Self, String> {
if query.is_empty() {
return Ok(Self { txids: Vec::new() });
return Err("missing required query parameter `txId[]`".into());
}
let mut txids = Vec::new();
for pair in query.split('&') {
@@ -49,8 +49,7 @@ mod tests {
const T2: &str = "0000000000000000000000000000000000000000000000000000000000000002";
#[test]
fn parses_empty_single_and_multi() {
assert!(TxidsParam::from_query("").unwrap().txids.is_empty());
fn parses_single_and_multi() {
assert_eq!(TxidsParam::from_query(&format!("txId[]={T1}")).unwrap().txids.len(), 1);
assert_eq!(
TxidsParam::from_query(&format!("txId%5B%5D={T1}&txId[]={T2}"))
@@ -62,7 +61,8 @@ mod tests {
}
#[test]
fn rejects_unknown_key_and_invalid_txid() {
fn rejects_empty_unknown_key_and_invalid_txid() {
assert!(TxidsParam::from_query("").is_err());
assert!(TxidsParam::from_query("foo=bar").is_err());
assert!(TxidsParam::from_query("txId[]=notahex").is_err());
assert!(TxidsParam::from_query("noequals").is_err());

View File

@@ -72,7 +72,10 @@ impl AddrValidation {
let output_type = OutputType::from(&script);
let script_hex = script.as_bytes().to_lower_hex_string();
let is_script = matches!(output_type, OutputType::P2SH | OutputType::P2TR);
let is_script = matches!(
output_type,
OutputType::P2SH | OutputType::P2WSH | OutputType::P2TR
);
let is_witness = matches!(
output_type,
OutputType::P2WPKH | OutputType::P2WSH | OutputType::P2TR | OutputType::P2A