mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-19 14:24:47 -07:00
global: fixes
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>);
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user