diff --git a/crates/brk_binder/src/generator/javascript.rs b/crates/brk_binder/src/generator/javascript.rs index ed9d372d8..c2b786a65 100644 --- a/crates/brk_binder/src/generator/javascript.rs +++ b/crates/brk_binder/src/generator/javascript.rs @@ -6,7 +6,7 @@ use std::path::Path; use brk_types::{Index, TreeNode}; -use super::{ClientMetadata, Endpoint, IndexSetPattern, PatternField, StructuralPattern, get_node_fields, to_camel_case, to_pascal_case, to_snake_case}; +use super::{ClientMetadata, Endpoint, IndexSetPattern, PatternField, StructuralPattern, get_node_fields, to_camel_case, to_pascal_case}; /// Generate JavaScript + JSDoc client from metadata and OpenAPI endpoints pub fn generate_javascript_client( @@ -50,6 +50,14 @@ fn generate_base_client(output: &mut String) { * @property {{number}} [timeout] - Request timeout in milliseconds */ +const _isBrowser = typeof window !== 'undefined' && 'caches' in window; +const _runIdle = (fn) => (globalThis.requestIdleCallback ?? setTimeout)(fn); + +/** @type {{Promise}} */ +const _cachePromise = _isBrowser + ? caches.open('__BRK_CLIENT__').catch(() => null) + : Promise.resolve(null); + /** * Custom error class for BRK client errors */ @@ -75,73 +83,73 @@ class MetricNode {{ * @param {{string}} path */ constructor(client, path) {{ - this.client = client; - this.path = path; + this._client = client; + this._path = path; }} /** * Fetch all data points for this metric. - * @returns {{Promise}} + * @param {{(value: T[]) => void}} [onUpdate] - Called when data is available (may be called twice: cache then fresh) + * @returns {{Promise}} */ - async get() {{ - return this.client.get(this.path); + get(onUpdate) {{ + return this._client.get(this._path, onUpdate); }} /** - * Fetch data points within a date range. - * @param {{string}} from - * @param {{string}} to - * @returns {{Promise}} + * Fetch data points within a range. + * @param {{string | number}} from + * @param {{string | number}} to + * @param {{(value: T[]) => void}} [onUpdate] - Called when data is available (may be called twice: cache then fresh) + * @returns {{Promise}} */ - async getRange(from, to) {{ - return this.client.get(`${{this.path}}?from=${{from}}&to=${{to}}`); + getRange(from, to, onUpdate) {{ + return this._client.get(`${{this._path}}?from=${{from}}&to=${{to}}`, onUpdate); }} }} /** - * Base HTTP client for making requests + * Base HTTP client for making requests with caching support */ class BrkClientBase {{ /** * @param {{BrkClientOptions|string}} options */ constructor(options) {{ - if (typeof options === 'string') {{ - this.baseUrl = options; - this.timeout = 30000; - }} else {{ - this.baseUrl = options.baseUrl; - this.timeout = options.timeout || 30000; - }} + const isString = typeof options === 'string'; + this.baseUrl = isString ? options : options.baseUrl; + this.timeout = isString ? 5000 : (options.timeout ?? 5000); }} /** - * Make a GET request + * Make a GET request with stale-while-revalidate caching * @template T * @param {{string}} path - * @returns {{Promise}} + * @param {{(value: T) => void}} [onUpdate] - Called when data is available + * @returns {{Promise}} */ - async get(path) {{ - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeout); + async get(path, onUpdate) {{ + const url = `${{this.baseUrl}}${{path}}`; + const cache = await _cachePromise; + const cachedRes = await cache?.match(url); + const cachedJson = cachedRes ? await cachedRes.json() : null; + + if (cachedJson) onUpdate?.(cachedJson); + if (!globalThis.navigator?.onLine) return cachedJson; try {{ - const response = await fetch(`${{this.baseUrl}}${{path}}`, {{ - signal: controller.signal, - }}); + const res = await fetch(url, {{ signal: AbortSignal.timeout(this.timeout) }}); + if (!res.ok) throw new BrkError(`HTTP ${{res.status}}`, res.status); + if (cachedRes?.headers.get('ETag') === res.headers.get('ETag')) return cachedJson; - if (!response.ok) {{ - throw new BrkError(`HTTP error: ${{response.status}}`, response.status); - }} - - return response.json(); - }} catch (error) {{ - if (error.name === 'AbortError') {{ - throw new BrkError('Request timeout'); - }} - throw error; - }} finally {{ - clearTimeout(timeoutId); + const cloned = res.clone(); + const json = await res.json(); + onUpdate?.(json); + if (cache) _runIdle(() => cache.put(url, cloned)); + return json; + }} catch (e) {{ + if (cachedJson) return cachedJson; + throw e; }} }} }} @@ -183,7 +191,7 @@ fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) { for (i, index) in pattern.indexes.iter().enumerate() { let field_name = index_to_camel_case(index); - let path_segment = index.serialize_short(); + let path_segment = index.serialize_long(); let comma = if i < pattern.indexes.len() - 1 { "," } else { "" }; writeln!( output, @@ -197,10 +205,9 @@ fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) { } } -/// Convert an Index to a camelCase field name (e.g., DateIndex -> byDate) +/// Convert an Index to a camelCase field name (e.g., DateIndex -> byDateIndex) fn index_to_camel_case(index: &Index) -> String { - let short = index.serialize_short(); - format!("by{}", to_pascal_case(&to_snake_case(short))) + format!("by{}", to_pascal_case(index.serialize_long())) } /// Generate structural pattern factory functions diff --git a/crates/brk_binder/src/generator/python.rs b/crates/brk_binder/src/generator/python.rs index 115e0bfe0..b4ff7c889 100644 --- a/crates/brk_binder/src/generator/python.rs +++ b/crates/brk_binder/src/generator/python.rs @@ -136,7 +136,7 @@ fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) { for index in &pattern.indexes { let field_name = index_to_snake_case(index); - let path_segment = index.serialize_short(); + let path_segment = index.serialize_long(); writeln!( output, " self.{}: MetricNode[T] = MetricNode(client, f'{{base_path}}/{}')", @@ -148,10 +148,9 @@ fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) { } } -/// Convert an Index to a snake_case field name (e.g., DateIndex -> by_date) +/// Convert an Index to a snake_case field name (e.g., DateIndex -> by_date_index) fn index_to_snake_case(index: &Index) -> String { - let short = index.serialize_short(); - format!("by_{}", to_snake_case(short)) + format!("by_{}", to_snake_case(index.serialize_long())) } /// Generate structural pattern classes diff --git a/crates/brk_binder/src/generator/rust.rs b/crates/brk_binder/src/generator/rust.rs index 7ae2060cd..d24f80307 100644 --- a/crates/brk_binder/src/generator/rust.rs +++ b/crates/brk_binder/src/generator/rust.rs @@ -203,7 +203,7 @@ fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) { for index in &pattern.indexes { let field_name = index_to_field_name(index); - let path_segment = index.serialize_short(); + let path_segment = index.serialize_long(); writeln!( output, " {}: MetricNode::new(client, format!(\"{{base_path}}/{}\")),", @@ -218,10 +218,9 @@ fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) { } } -/// Convert an Index to a snake_case field name (e.g., DateIndex -> by_date) +/// Convert an Index to a snake_case field name (e.g., DateIndex -> by_date_index) fn index_to_field_name(index: &Index) -> String { - let short = index.serialize_short(); - format!("by_{}", to_snake_case(short)) + format!("by_{}", to_snake_case(index.serialize_long())) } /// Generate pattern structs (those appearing 2+ times) diff --git a/crates/brk_binder/src/generator/types.rs b/crates/brk_binder/src/generator/types.rs index 1374a2315..17c6401f3 100644 --- a/crates/brk_binder/src/generator/types.rs +++ b/crates/brk_binder/src/generator/types.rs @@ -334,11 +334,11 @@ fn collect_indexes_from_tree( fn generate_index_set_name(indexes: &BTreeSet) -> String { if indexes.len() == 1 { let index = indexes.iter().next().unwrap(); - return format!("{}Accessor", to_pascal_case(index.serialize_short())); + return format!("{}Accessor", to_pascal_case(index.serialize_long())); } // For multiple indexes, create a descriptive name - let names: Vec<&str> = indexes.iter().map(|i| i.serialize_short()).collect(); + let names: Vec<&str> = indexes.iter().map(|i| i.serialize_long()).collect(); format!("{}Accessor", to_pascal_case(&names.join("_"))) } diff --git a/crates/brk_computer/src/stateful/process/lookup.rs b/crates/brk_computer/src/stateful/process/lookup.rs index dd4f6845e..cc1941f0b 100644 --- a/crates/brk_computer/src/stateful/process/lookup.rs +++ b/crates/brk_computer/src/stateful/process/lookup.rs @@ -5,15 +5,15 @@ use brk_types::{LoadedAddressData, OutputType, TypeIndex}; use super::super::address::AddressTypeToTypeIndexMap; use super::{EmptyAddressDataWithSource, LoadedAddressDataWithSource, WithAddressDataSource}; -/// Source of an address in lookup - reports where the data came from. +/// Tracking status of an address - determines cohort update strategy. #[derive(Clone, Copy)] -pub enum AddressSource { +pub enum TrackingStatus { /// Brand new address (never seen before) New, - /// Loaded from disk (has existing balance) - Loaded, - /// Was empty (zero balance), now receiving - FromEmpty, + /// Already tracked in a cohort (has existing balance) + Tracked, + /// Was in empty cache, now rejoining a cohort + WasEmpty, } /// Context for looking up and storing address data during block processing. @@ -27,7 +27,7 @@ impl<'a> AddressLookup<'a> { &mut self, output_type: OutputType, type_index: TypeIndex, - ) -> (&mut LoadedAddressDataWithSource, AddressSource) { + ) -> (&mut LoadedAddressDataWithSource, TrackingStatus) { use std::collections::hash_map::Entry; let map = self.loaded.get_mut(output_type).unwrap(); @@ -40,36 +40,38 @@ impl<'a> AddressLookup<'a> { // - If wrapper is New AND funded_txo_count == 0: hasn't received yet, // was just created in process_outputs this block → New // - If wrapper is New AND funded_txo_count > 0: received in previous - // block but still in cache (no flush) → Loaded - // - If wrapper is FromLoaded/FromEmpty: loaded from storage → use wrapper - let source = match entry.get() { + // block but still in cache (no flush) → Tracked + // - If wrapper is FromLoaded: loaded from storage → Tracked + // - If wrapper is FromEmpty AND utxo_count == 0: still empty → WasEmpty + // - If wrapper is FromEmpty AND utxo_count > 0: already received → Tracked + let status = match entry.get() { WithAddressDataSource::New(data) => { if data.funded_txo_count == 0 { - AddressSource::New + TrackingStatus::New } else { - AddressSource::Loaded + TrackingStatus::Tracked } } - WithAddressDataSource::FromLoaded(..) => AddressSource::Loaded, + WithAddressDataSource::FromLoaded(..) => TrackingStatus::Tracked, WithAddressDataSource::FromEmpty(_, data) => { if data.utxo_count() == 0 { - AddressSource::FromEmpty + TrackingStatus::WasEmpty } else { - AddressSource::Loaded + TrackingStatus::Tracked } } }; - (entry.into_mut(), source) + (entry.into_mut(), status) } Entry::Vacant(entry) => { if let Some(empty_data) = self.empty.get_mut(output_type).unwrap().remove(&type_index) { - return (entry.insert(empty_data.into()), AddressSource::FromEmpty); + return (entry.insert(empty_data.into()), TrackingStatus::WasEmpty); } ( entry.insert(WithAddressDataSource::New(LoadedAddressData::default())), - AddressSource::New, + TrackingStatus::New, ) } } diff --git a/crates/brk_computer/src/stateful/process/received.rs b/crates/brk_computer/src/stateful/process/received.rs index 98e84d377..1147fc1c3 100644 --- a/crates/brk_computer/src/stateful/process/received.rs +++ b/crates/brk_computer/src/stateful/process/received.rs @@ -6,7 +6,7 @@ use rustc_hash::FxHashMap; use super::super::address::AddressTypeToVec; use super::super::cohorts::AddressCohorts; -use super::lookup::{AddressLookup, AddressSource}; +use super::lookup::{AddressLookup, TrackingStatus}; pub fn process_received( received_data: AddressTypeToVec<(TypeIndex, Sats)>, @@ -31,23 +31,23 @@ pub fn process_received( } for (type_index, (total_value, output_count)) in aggregated { - let (addr_data, source) = lookup.get_or_create_for_receive(output_type, type_index); + let (addr_data, status) = lookup.get_or_create_for_receive(output_type, type_index); - match source { - AddressSource::New => { + match status { + TrackingStatus::New => { *addr_count.get_mut(output_type).unwrap() += 1; } - AddressSource::FromEmpty => { + TrackingStatus::WasEmpty => { *addr_count.get_mut(output_type).unwrap() += 1; *empty_addr_count.get_mut(output_type).unwrap() -= 1; } - AddressSource::Loaded => {} + TrackingStatus::Tracked => {} } - let is_new_entry = matches!(source, AddressSource::New | AddressSource::FromEmpty); + let is_new_entry = matches!(status, TrackingStatus::New | TrackingStatus::WasEmpty); if is_new_entry { - // New/from-empty address - just add to cohort + // New/was-empty address - just add to cohort addr_data.receive_outputs(total_value, price, output_count); cohorts .amount_range