@@ -16,12 +16,19 @@ use async_lock::RwLock;
1616use crate :: api:: types:: * ;
1717
1818/// Build the underlying `fula_client::Config` from the Dart-facing
19- /// `FulaConfig`, plumbing every Phase 1.2 / 2.x field through. Used by
20- /// `create_client`, `create_encrypted_client`, and
21- /// `create_encrypted_client_with_pinning` to keep the three constructors
22- /// in lockstep — adding a new field to FulaConfig only requires a
23- /// change here.
24- fn build_inner_config ( config : & FulaConfig ) -> fula_client:: Config {
19+ /// `FulaConfig`, plumbing every Phase 1.2 / 2.x / 3.3 / 19 field
20+ /// through. Used by `create_client`, `create_encrypted_client`, and
21+ /// `create_encrypted_client_with_pinning` to keep the three
22+ /// constructors in lockstep — adding a new field to FulaConfig only
23+ /// requires a change here.
24+ ///
25+ /// `dispatcher` is the per-handle dispatcher that the FRB layer
26+ /// always wires into `Config::health_callback` so apps can subscribe
27+ /// to `MasterHealthEvent` events via `subscribe_master_health_events`.
28+ fn build_inner_config (
29+ config : & FulaConfig ,
30+ dispatcher : & Arc < HealthEventDispatcher > ,
31+ ) -> fula_client:: Config {
2532 let mut inner = fula_client:: Config :: new ( & config. endpoint )
2633 . with_timeout ( Duration :: from_secs ( config. timeout_seconds ) ) ;
2734
@@ -50,6 +57,45 @@ fn build_inner_config(config: &FulaConfig) -> fula_client::Config {
5057 inner. gateway_fallback_urls = config. gateway_fallback_urls . clone ( ) ;
5158 inner. gateway_race_concurrency = config. gateway_race_concurrency as usize ;
5259
60+ // Phase 3.3 — cold-start hybrid resolver. The resolver activates
61+ // iff all four required strings (rpc_url, anchor_address,
62+ // ipns_name, user_key) are non-empty AND the user_key is `Some`.
63+ // Empty strings collapse to "disabled" — same default behavior as
64+ // pre-Phase-3.3 builds.
65+ inner. users_index_chain_rpc_url = config. users_index_chain_rpc_url . clone ( ) ;
66+ inner. users_index_anchor_address = config. users_index_anchor_address . clone ( ) ;
67+ inner. users_index_ipns_name = config. users_index_ipns_name . clone ( ) ;
68+ inner. users_index_user_key = if config. users_index_user_key . is_empty ( ) {
69+ None
70+ } else {
71+ Some ( config. users_index_user_key . clone ( ) )
72+ } ;
73+ inner. users_index_ipns_gateway_urls =
74+ config. users_index_ipns_gateway_urls . clone ( ) ;
75+ inner. users_index_ipfs_gateway_urls =
76+ config. users_index_ipfs_gateway_urls . clone ( ) ;
77+
78+ // Phase 19 — always wire a forwarding callback into the gate so
79+ // Dart-side subscribers can observe health transitions. The
80+ // dispatcher is per-handle, so events from this client never
81+ // leak to a different client's subscribers. Native-only — wasm
82+ // doesn't include the health-callback Arc in fula_client::Config
83+ // because `Arc<dyn Fn>` doesn't cross wasm-bindgen cleanly; the
84+ // wasm path surfaces via typed errors.
85+ #[ cfg( not( target_arch = "wasm32" ) ) ]
86+ {
87+ let dispatcher = Arc :: clone ( dispatcher) ;
88+ let cb: fula_client:: HealthCallback = Arc :: new ( move |ev| {
89+ dispatcher. dispatch ( ev) ;
90+ } ) ;
91+ inner. health_callback = Some ( cb) ;
92+ }
93+ // Suppress unused-variable warning on wasm where we don't read
94+ // `dispatcher` at config-build time (subscribers still register;
95+ // they just never receive events because no callback fires).
96+ #[ cfg( target_arch = "wasm32" ) ]
97+ let _ = dispatcher;
98+
5399 if let Some ( token) = & config. access_token {
54100 inner = inner. with_token ( token. clone ( ) ) ;
55101 }
@@ -63,11 +109,13 @@ fn build_inner_config(config: &FulaConfig) -> fula_client::Config {
63109
64110/// Create a new Fula client with the given configuration
65111pub fn create_client ( config : FulaConfig ) -> anyhow:: Result < FulaClientHandle > {
66- let inner_config = build_inner_config ( & config) ;
112+ let dispatcher = Arc :: new ( HealthEventDispatcher :: new ( ) ) ;
113+ let inner_config = build_inner_config ( & config, & dispatcher) ;
67114 let client = fula_client:: FulaClient :: new ( inner_config) ?;
68115
69116 Ok ( FulaClientHandle {
70117 inner : Arc :: new ( client) ,
118+ health_dispatcher : dispatcher,
71119 } )
72120}
73121
@@ -76,7 +124,8 @@ pub fn create_encrypted_client(
76124 config : FulaConfig ,
77125 encryption : EncryptionConfig ,
78126) -> anyhow:: Result < EncryptedClientHandle > {
79- let inner_config = build_inner_config ( & config) ;
127+ let dispatcher = Arc :: new ( HealthEventDispatcher :: new ( ) ) ;
128+ let inner_config = build_inner_config ( & config, & dispatcher) ;
80129
81130 // Create encryption config
82131 let enc_config = if let Some ( secret_key) = encryption. secret_key {
@@ -113,6 +162,7 @@ pub fn create_encrypted_client(
113162
114163 Ok ( EncryptedClientHandle {
115164 inner : Arc :: new ( RwLock :: new ( client) ) ,
165+ health_dispatcher : dispatcher,
116166 } )
117167}
118168
@@ -122,7 +172,8 @@ pub fn create_encrypted_client_with_pinning(
122172 encryption : EncryptionConfig ,
123173 pinning : PinningConfig ,
124174) -> anyhow:: Result < EncryptedClientHandle > {
125- let inner_config = build_inner_config ( & config) ;
175+ let dispatcher = Arc :: new ( HealthEventDispatcher :: new ( ) ) ;
176+ let inner_config = build_inner_config ( & config, & dispatcher) ;
126177
127178 // Create encryption config
128179 let enc_config = if let Some ( secret_key) = encryption. secret_key {
@@ -168,9 +219,137 @@ pub fn create_encrypted_client_with_pinning(
168219
169220 Ok ( EncryptedClientHandle {
170221 inner : Arc :: new ( RwLock :: new ( client) ) ,
222+ health_dispatcher : dispatcher,
171223 } )
172224}
173225
226+ // ============================================================================
227+ // Phase 3.3 — derive_user_key_from_email
228+ // ============================================================================
229+
230+ /// Compute the canonical fula `userKey` for cold-start config from a
231+ /// plaintext email. Mirrors `fula_client::derive_user_key_from_email`
232+ /// — same domain separator, same hash chain (sha256(lower(email))
233+ /// → BLAKE3("fula:user_id:" || _).bytes[..16] → hex-encode).
234+ ///
235+ /// Apps call this once at sign-in (the OAuth flow has plaintext
236+ /// email), then set `FulaConfig::users_index_user_key` to the
237+ /// returned string. The SDK never sees the raw email.
238+ ///
239+ /// Native-only — wasm32 surfaces this via the JS-side `deriveKey`
240+ /// helper because the cold-start resolver (Phase 3.3) itself isn't
241+ /// wired on wasm.
242+ #[ cfg( not( target_arch = "wasm32" ) ) ]
243+ pub fn derive_user_key_from_email ( email : String ) -> String {
244+ fula_client:: derive_user_key_from_email ( & email)
245+ }
246+
247+ #[ cfg( target_arch = "wasm32" ) ]
248+ pub fn derive_user_key_from_email ( _email : String ) -> String {
249+ // The Rust cold-start resolver isn't wired on wasm32; expose
250+ // the function for API symmetry but emit an empty key so the
251+ // resolver self-disables (per build_inner_config: empty user_key
252+ // → users_index_user_key=None → resolver inactive).
253+ String :: new ( )
254+ }
255+
256+ // ============================================================================
257+ // Phase 19 — health-event subscription
258+ // ============================================================================
259+
260+ /// Drain every `MasterHealthEvent` observed since the last call to
261+ /// this function. Returns events in the order they fired (oldest
262+ /// first). After draining the buffer is empty.
263+ ///
264+ /// Apps poll this on a timer (or on UI rebuilds) and update their
265+ /// online/offline indicator. Internal buffer is bounded at 64
266+ /// entries — if an app falls so far behind that the buffer
267+ /// overflows, the oldest events are dropped first; the latest state
268+ /// is preserved. For latest-only consumers, see
269+ /// [`get_last_master_health_event`].
270+ ///
271+ /// Events delivered:
272+ /// - `Online` — master went Up after being Down
273+ /// - `OfflineFallbackActive { reason }` — master went Down
274+ /// - `SeverelyDegraded { reason }` — both master AND cold-start
275+ /// channels (IPNS + chain) are unreachable; cold-start GETs
276+ /// will fail
277+ ///
278+ /// Native-only at runtime: on wasm32 the function compiles for API
279+ /// symmetry but never returns events because the health-callback
280+ /// Arc isn't wired on wasm (`Arc<dyn Fn>` doesn't cross
281+ /// wasm-bindgen cleanly).
282+ pub fn poll_master_health_events (
283+ client : & FulaClientHandle ,
284+ ) -> Vec < MasterHealthEvent > {
285+ client. health_dispatcher . drain_events ( )
286+ }
287+
288+ /// Same as `poll_master_health_events` for an `EncryptedClientHandle`.
289+ /// Exposed separately because Dart-side the encrypted client has
290+ /// its own handle type and FRB doesn't auto-reflect "this method
291+ /// works on either handle".
292+ pub fn poll_master_health_events_encrypted (
293+ client : & EncryptedClientHandle ,
294+ ) -> Vec < MasterHealthEvent > {
295+ client. health_dispatcher . drain_events ( )
296+ }
297+
298+ /// Read the most recent `MasterHealthEvent` observed by the SDK
299+ /// without draining the buffer. Returns `None` if no transition has
300+ /// happened yet (master has been Up the whole session). Useful for
301+ /// apps that build UI state from a single field on mount.
302+ pub fn get_last_master_health_event (
303+ client : & FulaClientHandle ,
304+ ) -> Option < MasterHealthEvent > {
305+ client. health_dispatcher . last_event ( )
306+ }
307+
308+ /// Encrypted-client variant of `get_last_master_health_event`.
309+ pub fn get_last_master_health_event_encrypted (
310+ client : & EncryptedClientHandle ,
311+ ) -> Option < MasterHealthEvent > {
312+ client. health_dispatcher . last_event ( )
313+ }
314+
315+ // ============================================================================
316+ // Phase 19 — get_object_with_offline_fallback
317+ // ============================================================================
318+
319+ /// Phase 19 GET wrapper that returns transparency fields alongside
320+ /// the bytes. Routes through the SDK's full Phase 2.x + 3.3 stack:
321+ ///
322+ /// | State | Returns |
323+ /// |-----------------------------------|-------------------------------------------|
324+ /// | Master up | source = Master, freshness = Live |
325+ /// | Master down + warm cache hit | source = LocalCache or Gateway(url), |
326+ /// | | freshness = Cached { observed_at } |
327+ /// | Master down + cold-start | source = Gateway(url), |
328+ /// | | freshness = Cached { observed_at } |
329+ /// | Master down + cache miss + no | Err(UsersIndexResolutionFailed) |
330+ /// | resolver configured | |
331+ ///
332+ /// Apps that don't care about transparency can read `result.inner.data`.
333+ /// Apps that surface "you're offline" UI inspect `result.source` /
334+ /// `result.freshness`.
335+ ///
336+ /// Native-only at runtime: on wasm32 the SDK currently only wraps
337+ /// `get_object_with_metadata` (no offline fallback infrastructure on
338+ /// browsers — block_cache + gateway_fetch are gated out). The wasm
339+ /// path returns `OfflineGetResult` with `source = Master, freshness =
340+ /// Live` so the API shape is identical across platforms.
341+ pub async fn get_object_with_offline_fallback (
342+ client : & FulaClientHandle ,
343+ bucket : String ,
344+ key : String ,
345+ ) -> anyhow:: Result < OfflineGetResult > {
346+ let result = client
347+ . inner
348+ . get_object_with_offline_fallback ( & bucket, & key)
349+ . await ?;
350+ Ok ( result. into ( ) )
351+ }
352+
174353// ============================================================================
175354// Bucket Operations
176355// ============================================================================
0 commit comments