@databricks/zerobus-ingest-sdk 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib.rs CHANGED
@@ -13,22 +13,18 @@
13
13
  #![deny(clippy::all)]
14
14
 
15
15
  use napi::bindgen_prelude::*;
16
- use napi::threadsafe_function::{ThreadsafeFunction, ErrorStrategy};
17
- use napi::{Env, JsObject, JsFunction, JsUnknown, JsString, JsGlobal, ValueType};
16
+ use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction};
17
+ use napi::{Env, JsFunction, JsGlobal, JsObject, JsString, JsUnknown, ValueType};
18
18
  use napi_derive::napi;
19
19
 
20
+ use async_trait::async_trait;
21
+ use databricks_zerobus_ingest_sdk::databricks::zerobus::RecordType as RustRecordType;
20
22
  use databricks_zerobus_ingest_sdk::{
21
- EncodedRecord as RustRecordPayload,
22
- StreamConfigurationOptions as RustStreamOptions,
23
- TableProperties as RustTableProperties, ZerobusSdk as RustZerobusSdk,
23
+ DefaultTokenFactory, EncodedRecord as RustRecordPayload,
24
+ HeadersProvider as RustHeadersProvider, ZerobusError as RustZerobusError,
25
+ ZerobusResult as RustZerobusResult, ZerobusSdk as RustZerobusSdk,
24
26
  ZerobusStream as RustZerobusStream,
25
- HeadersProvider as RustHeadersProvider,
26
- ZerobusResult as RustZerobusResult,
27
- ZerobusError as RustZerobusError,
28
- DefaultTokenFactory,
29
27
  };
30
- use databricks_zerobus_ingest_sdk::databricks::zerobus::RecordType as RustRecordType;
31
- use async_trait::async_trait;
32
28
  use prost_types;
33
29
  use std::collections::HashMap;
34
30
  use std::sync::Arc;
@@ -96,32 +92,6 @@ pub struct StreamConfigurationOptions {
96
92
  pub stream_paused_max_wait_time_ms: Option<u32>,
97
93
  }
98
94
 
99
- impl From<StreamConfigurationOptions> for RustStreamOptions {
100
- fn from(opts: StreamConfigurationOptions) -> Self {
101
- let default = RustStreamOptions::default();
102
-
103
- let record_type = match opts.record_type {
104
- Some(0) => RustRecordType::Json,
105
- Some(1) => RustRecordType::Proto,
106
- _ => RustRecordType::Proto,
107
- };
108
-
109
- RustStreamOptions {
110
- max_inflight_requests: opts.max_inflight_requests.unwrap_or(default.max_inflight_requests as u32) as usize,
111
- recovery: opts.recovery.unwrap_or(default.recovery),
112
- recovery_timeout_ms: opts.recovery_timeout_ms.map(|v| v as u64).unwrap_or(default.recovery_timeout_ms),
113
- recovery_backoff_ms: opts.recovery_backoff_ms.map(|v| v as u64).unwrap_or(default.recovery_backoff_ms),
114
- recovery_retries: opts.recovery_retries.unwrap_or(default.recovery_retries),
115
- flush_timeout_ms: opts.flush_timeout_ms.map(|v| v as u64).unwrap_or(default.flush_timeout_ms),
116
- server_lack_of_ack_timeout_ms: opts.server_lack_of_ack_timeout_ms.map(|v| v as u64).unwrap_or(default.server_lack_of_ack_timeout_ms),
117
- record_type,
118
- callback_max_wait_time_ms: None, // Callbacks not supported in TS SDK
119
- stream_paused_max_wait_time_ms: opts.stream_paused_max_wait_time_ms.map(|v| v as u64),
120
- ack_callback: None, // Callbacks not supported in TS SDK
121
- }
122
- }
123
- }
124
-
125
95
  /// Properties of the target Delta table for ingestion.
126
96
  ///
127
97
  /// Specifies which Unity Catalog table to write to and optionally the schema descriptor
@@ -137,27 +107,6 @@ pub struct TableProperties {
137
107
  pub descriptor_proto: Option<String>,
138
108
  }
139
109
 
140
- impl TableProperties {
141
- fn to_rust(&self) -> Result<RustTableProperties> {
142
- let descriptor: Option<prost_types::DescriptorProto> = if let Some(ref desc_str) = self.descriptor_proto {
143
- let bytes = base64_decode(desc_str)
144
- .map_err(|e| Error::from_reason(format!("Failed to decode descriptor: {}", e)))?;
145
-
146
- let descriptor_proto: prost_types::DescriptorProto = prost::Message::decode(&bytes[..])
147
- .map_err(|e| Error::from_reason(format!("Failed to parse descriptor proto: {}", e)))?;
148
-
149
- Some(descriptor_proto)
150
- } else {
151
- None
152
- };
153
-
154
- Ok(RustTableProperties {
155
- table_name: self.table_name.clone(),
156
- descriptor_proto: descriptor,
157
- })
158
- }
159
- }
160
-
161
110
  /// Custom error type for Zerobus operations.
162
111
  ///
163
112
  /// This error type includes information about whether the error is retryable,
@@ -183,6 +132,19 @@ impl ZerobusError {
183
132
  }
184
133
  }
185
134
 
135
+ /// Convert a JS `BigInt` to `i64`, erroring if it can't be represented losslessly.
136
+ /// Used by `waitForOffset` to avoid the precision loss of the old
137
+ /// `Number(bigint)` round-trip past 2^53.
138
+ fn bigint_to_i64(value: BigInt) -> Result<i64> {
139
+ let (n, lossless) = value.get_i64();
140
+ if !lossless {
141
+ return Err(Error::from_reason(
142
+ "offsetId exceeds i64 range; cannot be represented without loss",
143
+ ));
144
+ }
145
+ Ok(n)
146
+ }
147
+
186
148
  /// Helper function to convert a JavaScript value to a RustRecordPayload.
187
149
  ///
188
150
  /// Supports:
@@ -209,17 +171,19 @@ fn convert_js_to_record_payload(env: &Env, payload: Unknown) -> Result<RustRecor
209
171
  if constructor_obj.has_named_property("encode")? {
210
172
  let encode_fn: JsFunction = constructor_obj.get_named_property("encode")?;
211
173
  let obj_as_unknown = obj.into_unknown();
212
- let encode_result: JsUnknown = encode_fn.call::<JsUnknown>(Some(&constructor_obj), &[obj_as_unknown])?;
174
+ let encode_result: JsUnknown =
175
+ encode_fn.call::<JsUnknown>(Some(&constructor_obj), &[obj_as_unknown])?;
213
176
  let encode_obj = JsObject::from_unknown(encode_result)?;
214
177
 
215
178
  if encode_obj.has_named_property("finish")? {
216
179
  let finish_fn: JsFunction = encode_obj.get_named_property("finish")?;
217
- let buffer_result: JsUnknown = finish_fn.call::<JsUnknown>(Some(&encode_obj), &[])?;
180
+ let buffer_result: JsUnknown =
181
+ finish_fn.call::<JsUnknown>(Some(&encode_obj), &[])?;
218
182
  let buffer: Buffer = Buffer::from_unknown(buffer_result)?;
219
183
  Ok(RustRecordPayload::Proto(buffer.to_vec()))
220
184
  } else {
221
185
  Err(Error::from_reason(
222
- "Protobuf message .encode() must return an object with .finish() method"
186
+ "Protobuf message .encode() must return an object with .finish() method",
223
187
  ))
224
188
  }
225
189
  } else {
@@ -227,7 +191,8 @@ fn convert_js_to_record_payload(env: &Env, payload: Unknown) -> Result<RustRecor
227
191
  let json_obj: JsObject = global.get_named_property("JSON")?;
228
192
  let stringify: JsFunction = json_obj.get_named_property("stringify")?;
229
193
  let obj_as_unknown = obj.into_unknown();
230
- let str_result: JsUnknown = stringify.call::<JsUnknown>(Some(&json_obj), &[obj_as_unknown])?;
194
+ let str_result: JsUnknown =
195
+ stringify.call::<JsUnknown>(Some(&json_obj), &[obj_as_unknown])?;
231
196
  let js_string = JsString::from_unknown(str_result)?;
232
197
  let json_string = js_string.into_utf8()?.as_str()?.to_string();
233
198
 
@@ -240,11 +205,9 @@ fn convert_js_to_record_payload(env: &Env, payload: Unknown) -> Result<RustRecor
240
205
  let json_string = js_string.into_utf8()?.as_str()?.to_string();
241
206
  Ok(RustRecordPayload::Json(json_string))
242
207
  }
243
- _ => {
244
- Err(Error::from_reason(
245
- "Payload must be a Buffer, string, protobuf message object, or plain JavaScript object"
246
- ))
247
- }
208
+ _ => Err(Error::from_reason(
209
+ "Payload must be a Buffer, string, protobuf message object, or plain JavaScript object",
210
+ )),
248
211
  }
249
212
  }
250
213
 
@@ -309,35 +272,37 @@ impl ZerobusStream {
309
272
  #[allow(deprecated)]
310
273
  pub fn ingest_record(&self, env: Env, payload: Unknown) -> Result<JsObject> {
311
274
  let record_payload = convert_js_to_record_payload(&env, payload)?;
312
-
313
- let ack_future = {
314
- let handle = tokio::runtime::Handle::current();
315
- let stream = self.inner.clone();
316
-
317
- handle.block_on(async move {
318
- let mut guard = stream.lock().await;
319
- let stream_ref = guard
320
- .as_mut()
321
- .ok_or_else(|| Error::from_reason("Stream has been closed"))?;
322
-
323
- stream_ref
324
- .ingest_record(record_payload)
325
- .await
326
- .map_err(|e| Error::from_reason(format!("Failed to ingest record: {}", e)))
327
- })?
328
- };
275
+ let stream = self.inner.clone();
329
276
 
330
277
  env.execute_tokio_future(
331
278
  async move {
332
- ack_future
333
- .await
334
- .map_err(|e| napi::Error::from_reason(format!("Acknowledgment failed: {}", e)))
279
+ let offset = {
280
+ let mut guard = stream.lock().await;
281
+ let stream_ref = guard
282
+ .as_mut()
283
+ .ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
284
+ stream_ref
285
+ .ingest_record_offset(record_payload)
286
+ .await
287
+ .map_err(|e| {
288
+ napi::Error::from_reason(format!("Failed to ingest record: {}", e))
289
+ })?
290
+ };
291
+ {
292
+ let guard = stream.lock().await;
293
+ let stream_ref = guard
294
+ .as_ref()
295
+ .ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
296
+ stream_ref.wait_for_offset(offset).await.map_err(|e| {
297
+ napi::Error::from_reason(format!("Acknowledgment failed: {}", e))
298
+ })?;
299
+ }
300
+ Ok(offset)
335
301
  },
336
- |env, result| {
337
- let result_str = result.to_string();
302
+ |env, offset_id| {
338
303
  let global: JsGlobal = env.get_global()?;
339
304
  let bigint_ctor: JsFunction = global.get_named_property("BigInt")?;
340
- let js_str = env.create_string(&result_str)?;
305
+ let js_str = env.create_string(&offset_id.to_string())?;
341
306
  bigint_ctor.call(None, &[js_str.into_unknown()])
342
307
  },
343
308
  )
@@ -378,50 +343,49 @@ impl ZerobusStream {
378
343
  #[napi(ts_return_type = "Promise<bigint | null>")]
379
344
  #[allow(deprecated)]
380
345
  pub fn ingest_records(&self, env: Env, records: Vec<Unknown>) -> Result<JsObject> {
346
+ // Rust SDK 2.0 removed the blocking `ingest_records`. v1 semantics
347
+ // (Promise resolves after server ack; `null` for empty batches) are
348
+ // preserved via `ingest_records_offset` + `wait_for_offset`.
381
349
  let record_payloads: Result<Vec<RustRecordPayload>> = records
382
350
  .into_iter()
383
351
  .map(|payload| convert_js_to_record_payload(&env, payload))
384
352
  .collect();
385
-
386
353
  let record_payloads = record_payloads?;
387
-
388
- let ack_future_option = {
389
- let handle = tokio::runtime::Handle::current();
390
- let stream = self.inner.clone();
391
-
392
- handle.block_on(async move {
393
- let mut guard = stream.lock().await;
394
- let stream_ref = guard
395
- .as_mut()
396
- .ok_or_else(|| Error::from_reason("Stream has been closed"))?;
397
-
398
- // Send batch to SDK
399
- stream_ref
400
- .ingest_records(record_payloads)
401
- .await
402
- .map_err(|e| Error::from_reason(format!("Failed to ingest batch: {}", e)))
403
- })?
404
- };
354
+ let stream = self.inner.clone();
405
355
 
406
356
  env.execute_tokio_future(
407
357
  async move {
408
- match ack_future_option.await {
409
- Ok(Some(offset_id)) => Ok(Some(offset_id)),
410
- Ok(None) => Ok(None),
411
- Err(e) => Err(napi::Error::from_reason(
412
- format!("Batch acknowledgment failed: {}", e)
413
- )),
358
+ let offset_opt = {
359
+ let mut guard = stream.lock().await;
360
+ let stream_ref = guard
361
+ .as_mut()
362
+ .ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
363
+ stream_ref
364
+ .ingest_records_offset(record_payloads)
365
+ .await
366
+ .map_err(|e| {
367
+ napi::Error::from_reason(format!("Failed to ingest batch: {}", e))
368
+ })?
369
+ };
370
+ if let Some(offset) = offset_opt {
371
+ let guard = stream.lock().await;
372
+ let stream_ref = guard
373
+ .as_ref()
374
+ .ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
375
+ stream_ref.wait_for_offset(offset).await.map_err(|e| {
376
+ napi::Error::from_reason(format!("Batch acknowledgment failed: {}", e))
377
+ })?;
414
378
  }
379
+ Ok(offset_opt)
415
380
  },
416
381
  |env, result| match result {
417
382
  Some(offset_id) => {
418
- let offset_str = offset_id.to_string();
419
383
  let global: JsGlobal = env.get_global()?;
420
384
  let bigint_ctor: JsFunction = global.get_named_property("BigInt")?;
421
- let js_str = env.create_string(&offset_str)?;
385
+ let js_str = env.create_string(&offset_id.to_string())?;
422
386
  let bigint = bigint_ctor.call(None, &[js_str.into_unknown()])?;
423
387
  Ok(bigint.into_unknown())
424
- },
388
+ }
425
389
  None => env.get_null().map(|v| v.into_unknown()),
426
390
  },
427
391
  )
@@ -470,7 +434,9 @@ impl ZerobusStream {
470
434
  stream_ref
471
435
  .ingest_record_offset(record_payload)
472
436
  .await
473
- .map_err(|e| napi::Error::from_reason(format!("Failed to ingest record: {}", e)))
437
+ .map_err(|e| {
438
+ napi::Error::from_reason(format!("Failed to ingest record: {}", e))
439
+ })
474
440
  },
475
441
  |env, offset_id| {
476
442
  let offset_str = offset_id.to_string();
@@ -537,7 +503,7 @@ impl ZerobusStream {
537
503
  let js_str = env.create_string(&offset_str)?;
538
504
  let bigint = bigint_ctor.call(None, &[js_str.into_unknown()])?;
539
505
  Ok(bigint.into_unknown())
540
- },
506
+ }
541
507
  None => env.get_null().map(|v| v.into_unknown()),
542
508
  },
543
509
  )
@@ -568,12 +534,9 @@ impl ZerobusStream {
568
534
  /// // Wait for the last offset (implies all previous are also acknowledged)
569
535
  /// await stream.waitForOffset(offsets[offsets.length - 1]);
570
536
  /// ```
571
- #[napi(ts_args_type = "offsetId: bigint", ts_return_type = "Promise<void>")]
572
- pub fn wait_for_offset(&self, env: Env, offset_id: JsUnknown) -> Result<JsObject> {
573
- let global: JsGlobal = env.get_global()?;
574
- let number_ctor: JsFunction = global.get_named_property("Number")?;
575
- let num_result: JsUnknown = number_ctor.call(None, &[offset_id])?;
576
- let offset: i64 = num_result.coerce_to_number()?.get_int64()?;
537
+ #[napi(ts_return_type = "Promise<void>")]
538
+ pub fn wait_for_offset(&self, env: Env, offset_id: BigInt) -> Result<JsObject> {
539
+ let offset = bigint_to_i64(offset_id)?;
577
540
 
578
541
  let stream = self.inner.clone();
579
542
 
@@ -584,10 +547,9 @@ impl ZerobusStream {
584
547
  .as_ref()
585
548
  .ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
586
549
 
587
- stream_ref
588
- .wait_for_offset(offset)
589
- .await
590
- .map_err(|e| napi::Error::from_reason(format!("Failed to wait for offset: {}", e)))
550
+ stream_ref.wait_for_offset(offset).await.map_err(|e| {
551
+ napi::Error::from_reason(format!("Failed to wait for offset: {}", e))
552
+ })
591
553
  },
592
554
  |_env, _| Ok(()),
593
555
  )
@@ -750,12 +712,12 @@ impl StaticHeadersProvider {
750
712
 
751
713
  if !map.contains_key("authorization") {
752
714
  return Err(RustZerobusError::InvalidArgument(
753
- "HeadersProvider must include 'authorization' header with Bearer token".to_string()
715
+ "HeadersProvider must include 'authorization' header with Bearer token".to_string(),
754
716
  ));
755
717
  }
756
718
  if !map.contains_key("x-databricks-zerobus-table-name") {
757
719
  return Err(RustZerobusError::InvalidArgument(
758
- "HeadersProvider must include 'x-databricks-zerobus-table-name' header".to_string()
720
+ "HeadersProvider must include 'x-databricks-zerobus-table-name' header".to_string(),
759
721
  ));
760
722
  }
761
723
 
@@ -776,13 +738,18 @@ impl RustHeadersProvider for StaticHeadersProvider {
776
738
  }
777
739
 
778
740
  /// Helper to create a threadsafe function from JavaScript callback
779
- fn create_headers_tsfn(js_func: JsFunction) -> Result<ThreadsafeFunction<(), ErrorStrategy::Fatal>> {
741
+ fn create_headers_tsfn(
742
+ js_func: JsFunction,
743
+ ) -> Result<ThreadsafeFunction<(), ErrorStrategy::Fatal>> {
780
744
  js_func.create_threadsafe_function(0, |ctx| Ok(vec![ctx.value]))
781
745
  }
782
746
 
783
747
  /// Helper to call headers callback and get result
784
- async fn call_headers_tsfn(tsfn: ThreadsafeFunction<(), ErrorStrategy::Fatal>) -> Result<Vec<(String, String)>> {
785
- let raw_headers: Vec<Vec<String>> = tsfn.call_async(())
748
+ async fn call_headers_tsfn(
749
+ tsfn: ThreadsafeFunction<(), ErrorStrategy::Fatal>,
750
+ ) -> Result<Vec<(String, String)>> {
751
+ let raw_headers: Vec<Vec<String>> = tsfn
752
+ .call_async(())
786
753
  .await
787
754
  .map_err(|e| Error::from_reason(format!("Failed to call headers callback: {}", e)))?;
788
755
 
@@ -898,7 +865,9 @@ impl ZerobusSdk {
898
865
  .and_then(|s| s.split('.').next())
899
866
  .map(|s| s.to_string())
900
867
  .ok_or_else(|| {
901
- Error::from_reason("Failed to extract workspace_id from zerobus_endpoint".to_string())
868
+ Error::from_reason(
869
+ "Failed to extract workspace_id from zerobus_endpoint".to_string(),
870
+ )
902
871
  })?;
903
872
 
904
873
  let inner = RustZerobusSdk::builder()
@@ -975,13 +944,45 @@ impl ZerobusSdk {
975
944
  options: Option<StreamConfigurationOptions>,
976
945
  headers_provider: Option<JsHeadersProvider>,
977
946
  ) -> Result<JsObject> {
978
- let rust_table_props = table_properties.to_rust()?;
979
- let rust_options: RustStreamOptions = options.map(|o| o.into()).unwrap_or_default();
947
+ // Decode the optional protobuf descriptor up-front so we can hand it
948
+ // to the builder's `.compiled_proto()` setter; the builder constructs
949
+ // the (now-private) `TableProperties` itself.
950
+ let descriptor_proto: Option<prost_types::DescriptorProto> =
951
+ if let Some(ref desc_str) = table_properties.descriptor_proto {
952
+ let bytes = base64_decode(desc_str).map_err(|e| {
953
+ Error::from_reason(format!("Failed to decode descriptor: {}", e))
954
+ })?;
955
+ let dp: prost_types::DescriptorProto = prost::Message::decode(&bytes[..])
956
+ .map_err(|e| {
957
+ Error::from_reason(format!("Failed to parse descriptor proto: {}", e))
958
+ })?;
959
+ Some(dp)
960
+ } else {
961
+ None
962
+ };
963
+
964
+ let opts = options.unwrap_or(StreamConfigurationOptions {
965
+ max_inflight_requests: None,
966
+ recovery: None,
967
+ recovery_timeout_ms: None,
968
+ recovery_backoff_ms: None,
969
+ recovery_retries: None,
970
+ flush_timeout_ms: None,
971
+ server_lack_of_ack_timeout_ms: None,
972
+ record_type: None,
973
+ stream_paused_max_wait_time_ms: None,
974
+ });
975
+
976
+ let record_type = match opts.record_type {
977
+ Some(0) => RustRecordType::Json,
978
+ Some(1) => RustRecordType::Proto,
979
+ _ => RustRecordType::Proto,
980
+ };
980
981
 
981
982
  let headers_tsfn = match headers_provider {
982
- Some(JsHeadersProvider { get_headers_callback }) => {
983
- Some(create_headers_tsfn(get_headers_callback)?)
984
- }
983
+ Some(JsHeadersProvider {
984
+ get_headers_callback,
985
+ }) => Some(create_headers_tsfn(get_headers_callback)?),
985
986
  None => None,
986
987
  };
987
988
 
@@ -992,34 +993,70 @@ impl ZerobusSdk {
992
993
 
993
994
  env.execute_tokio_future(
994
995
  async move {
995
- let headers_provider_arc: Arc<dyn RustHeadersProvider> = if let Some(tsfn) = headers_tsfn {
996
- // Custom headers provider from JavaScript callback
997
- let headers = call_headers_tsfn(tsfn).await
998
- .map_err(|e| napi::Error::from_reason(format!("Headers callback failed: {}", e)))?;
999
-
996
+ let headers_provider_arc: Arc<dyn RustHeadersProvider> = if let Some(tsfn) =
997
+ headers_tsfn
998
+ {
999
+ let headers = call_headers_tsfn(tsfn).await.map_err(|e| {
1000
+ napi::Error::from_reason(format!("Headers callback failed: {}", e))
1001
+ })?;
1000
1002
  let static_provider = StaticHeadersProvider::new(headers)
1001
1003
  .map_err(|e| napi::Error::from_reason(format!("Invalid headers: {}", e)))?;
1002
-
1003
1004
  Arc::new(static_provider)
1004
1005
  } else {
1005
- // Default OAuth with TS user agent
1006
1006
  Arc::new(TsOAuthHeadersProvider::new(
1007
1007
  client_id,
1008
1008
  client_secret,
1009
- table_name,
1009
+ table_name.clone(),
1010
1010
  workspace_id,
1011
1011
  unity_catalog_url,
1012
1012
  ))
1013
1013
  };
1014
1014
 
1015
- let stream = sdk
1016
- .create_stream_with_headers_provider(
1017
- rust_table_props,
1018
- headers_provider_arc,
1019
- Some(rust_options),
1020
- )
1021
- .await
1022
- .map_err(|e| napi::Error::from_reason(format!("Failed to create stream: {}", e)))?;
1015
+ let mut builder = sdk
1016
+ .stream_builder()
1017
+ .table(table_name)
1018
+ .headers_provider(headers_provider_arc);
1019
+
1020
+ if let Some(v) = opts.max_inflight_requests {
1021
+ builder = builder.max_inflight_requests(v as usize);
1022
+ }
1023
+ if let Some(v) = opts.recovery {
1024
+ builder = builder.recovery(v);
1025
+ }
1026
+ if let Some(v) = opts.recovery_timeout_ms {
1027
+ builder = builder.recovery_timeout_ms(v as u64);
1028
+ }
1029
+ if let Some(v) = opts.recovery_backoff_ms {
1030
+ builder = builder.recovery_backoff_ms(v as u64);
1031
+ }
1032
+ if let Some(v) = opts.recovery_retries {
1033
+ builder = builder.recovery_retries(v);
1034
+ }
1035
+ if let Some(v) = opts.flush_timeout_ms {
1036
+ builder = builder.flush_timeout_ms(v as u64);
1037
+ }
1038
+ if let Some(v) = opts.server_lack_of_ack_timeout_ms {
1039
+ builder = builder.server_lack_of_ack_timeout_ms(v as u64);
1040
+ }
1041
+ if let Some(v) = opts.stream_paused_max_wait_time_ms {
1042
+ builder = builder.stream_paused_max_wait_time_ms(Some(v as u64));
1043
+ }
1044
+
1045
+ let builder = match record_type {
1046
+ RustRecordType::Json => builder.json(),
1047
+ RustRecordType::Proto | RustRecordType::Unspecified => {
1048
+ let desc = descriptor_proto.ok_or_else(|| {
1049
+ napi::Error::from_reason(
1050
+ "Proto record type requires descriptor_proto on TableProperties",
1051
+ )
1052
+ })?;
1053
+ builder.compiled_proto(desc)
1054
+ }
1055
+ };
1056
+
1057
+ let stream = builder.build().await.map_err(|e| {
1058
+ napi::Error::from_reason(format!("Failed to create stream: {}", e))
1059
+ })?;
1023
1060
 
1024
1061
  Ok(ZerobusStream {
1025
1062
  inner: Arc::new(Mutex::new(Some(stream))),
@@ -1091,31 +1128,24 @@ fn base64_decode(input: &str) -> std::result::Result<Vec<u8>, String> {
1091
1128
  }
1092
1129
 
1093
1130
  // =============================================================================
1094
- // Arrow Flight Support (Experimental/Unsupported)
1131
+ // Arrow Flight Support (Beta)
1095
1132
  // Enabled with feature flag: cargo build --features arrow-flight
1096
1133
  // =============================================================================
1097
1134
 
1098
- #[cfg(feature = "arrow-flight")]
1099
- use databricks_zerobus_ingest_sdk::{
1100
- ArrowStreamConfigurationOptions as RustArrowStreamOptions,
1101
- ArrowTableProperties as RustArrowTableProperties,
1102
- ZerobusArrowStream as RustZerobusArrowStream,
1103
- ArrowSchema as RustArrowSchema,
1104
- RecordBatch as RustRecordBatch,
1105
- Field as RustField,
1106
- DataType as RustDataType,
1107
- };
1108
- #[cfg(feature = "arrow-flight")]
1109
- use arrow_ipc::reader::StreamReader;
1110
1135
  #[cfg(feature = "arrow-flight")]
1111
1136
  use arrow_ipc::writer::StreamWriter;
1112
1137
  #[cfg(feature = "arrow-flight")]
1113
- use std::io::Cursor;
1138
+ use bytes::Bytes;
1139
+ #[cfg(feature = "arrow-flight")]
1140
+ use databricks_zerobus_ingest_sdk::{
1141
+ ArrowSchema as RustArrowSchema, DataType as RustDataType, Field as RustField,
1142
+ RecordBatch as RustRecordBatch, ZerobusArrowStream as RustZerobusArrowStream,
1143
+ };
1114
1144
 
1115
1145
  /// IPC compression type for Arrow Flight streams.
1116
1146
  ///
1117
- /// **Experimental/Unsupported**: Arrow Flight support is experimental and not yet
1118
- /// supported for production use. The API may change in future releases.
1147
+ /// **Beta**: Arrow Flight support is in Beta. The API is stabilising but
1148
+ /// may still change before reaching GA.
1119
1149
  #[cfg(feature = "arrow-flight")]
1120
1150
  #[napi]
1121
1151
  pub enum IpcCompressionType {
@@ -1127,8 +1157,8 @@ pub enum IpcCompressionType {
1127
1157
 
1128
1158
  /// Configuration options for Arrow Flight streams.
1129
1159
  ///
1130
- /// **Experimental/Unsupported**: Arrow Flight support is experimental and not yet
1131
- /// supported for production use. The API may change in future releases.
1160
+ /// **Beta**: Arrow Flight support is in Beta. The API is stabilising but
1161
+ /// may still change before reaching GA.
1132
1162
  #[cfg(feature = "arrow-flight")]
1133
1163
  #[napi(object)]
1134
1164
  #[derive(Debug, Clone)]
@@ -1169,34 +1199,23 @@ pub struct ArrowStreamConfigurationOptions {
1169
1199
  pub ipc_compression: Option<i32>,
1170
1200
  }
1171
1201
 
1172
- #[cfg(feature = "arrow-flight")]
1173
- impl From<ArrowStreamConfigurationOptions> for RustArrowStreamOptions {
1174
- fn from(opts: ArrowStreamConfigurationOptions) -> Self {
1175
- let default = RustArrowStreamOptions::default();
1176
-
1177
- let ipc_compression = match opts.ipc_compression {
1178
- Some(0) => Some(arrow_ipc::CompressionType::LZ4_FRAME),
1179
- Some(1) => Some(arrow_ipc::CompressionType::ZSTD),
1180
- _ => None,
1181
- };
1202
+ // Rust SDK 2.0 `ArrowStreamConfigurationOptions` is `#[non_exhaustive]` and
1203
+ // cannot be constructed via struct literal from this crate. Arrow options are
1204
+ // applied via setters on `sdk.stream_builder()` inside `create_arrow_stream`
1205
+ // below.
1182
1206
 
1183
- RustArrowStreamOptions {
1184
- max_inflight_batches: opts.max_inflight_batches.unwrap_or(default.max_inflight_batches as u32) as usize,
1185
- recovery: opts.recovery.unwrap_or(default.recovery),
1186
- recovery_timeout_ms: opts.recovery_timeout_ms.map(|v| v as u64).unwrap_or(default.recovery_timeout_ms),
1187
- recovery_backoff_ms: opts.recovery_backoff_ms.map(|v| v as u64).unwrap_or(default.recovery_backoff_ms),
1188
- recovery_retries: opts.recovery_retries.unwrap_or(default.recovery_retries),
1189
- server_lack_of_ack_timeout_ms: opts.server_lack_of_ack_timeout_ms.map(|v| v as u64).unwrap_or(default.server_lack_of_ack_timeout_ms),
1190
- flush_timeout_ms: opts.flush_timeout_ms.map(|v| v as u64).unwrap_or(default.flush_timeout_ms),
1191
- connection_timeout_ms: opts.connection_timeout_ms.map(|v| v as u64).unwrap_or(default.connection_timeout_ms),
1192
- ipc_compression,
1193
- }
1207
+ #[cfg(feature = "arrow-flight")]
1208
+ fn map_ipc_compression(value: Option<i32>) -> Option<arrow_ipc::CompressionType> {
1209
+ match value {
1210
+ Some(0) => Some(arrow_ipc::CompressionType::LZ4_FRAME),
1211
+ Some(1) => Some(arrow_ipc::CompressionType::ZSTD),
1212
+ _ => None,
1194
1213
  }
1195
1214
  }
1196
1215
 
1197
1216
  /// Arrow data type enum for schema definition.
1198
1217
  ///
1199
- /// **Experimental/Unsupported**: Arrow Flight support is experimental.
1218
+ /// **Beta**: Arrow Flight support is in Beta.
1200
1219
  #[cfg(feature = "arrow-flight")]
1201
1220
  #[napi]
1202
1221
  pub enum ArrowDataType {
@@ -1268,7 +1287,7 @@ fn convert_arrow_data_type(dt: i32) -> RustDataType {
1268
1287
 
1269
1288
  /// Arrow field definition for schema.
1270
1289
  ///
1271
- /// **Experimental/Unsupported**: Arrow Flight support is experimental.
1290
+ /// **Beta**: Arrow Flight support is in Beta.
1272
1291
  #[cfg(feature = "arrow-flight")]
1273
1292
  #[napi(object)]
1274
1293
  #[derive(Debug, Clone)]
@@ -1286,8 +1305,8 @@ pub struct ArrowField {
1286
1305
  /// Unlike `TableProperties` which uses Protocol Buffers, Arrow Flight streams
1287
1306
  /// require an Arrow schema definition.
1288
1307
  ///
1289
- /// **Experimental/Unsupported**: Arrow Flight support is experimental and not yet
1290
- /// supported for production use. The API may change in future releases.
1308
+ /// **Beta**: Arrow Flight support is in Beta. The API is stabilising but
1309
+ /// may still change before reaching GA.
1291
1310
  #[cfg(feature = "arrow-flight")]
1292
1311
  #[napi(object)]
1293
1312
  #[derive(Debug, Clone)]
@@ -1298,24 +1317,22 @@ pub struct ArrowTableProperties {
1298
1317
  pub schema_fields: Vec<ArrowField>,
1299
1318
  }
1300
1319
 
1320
+ // Rust SDK 2.0 made `ArrowTableProperties` non-public; the table name and
1321
+ // schema are passed to `sdk.stream_builder().table(...).arrow(schema)`
1322
+ // directly. This helper builds just the Arrow `Schema`.
1301
1323
  #[cfg(feature = "arrow-flight")]
1302
- impl ArrowTableProperties {
1303
- fn to_rust(&self) -> Result<RustArrowTableProperties> {
1304
- let fields: Vec<RustField> = self.schema_fields.iter().map(|f| {
1324
+ fn build_arrow_schema(fields: &[ArrowField]) -> Arc<RustArrowSchema> {
1325
+ let rust_fields: Vec<RustField> = fields
1326
+ .iter()
1327
+ .map(|f| {
1305
1328
  RustField::new(
1306
1329
  &f.name,
1307
1330
  convert_arrow_data_type(f.data_type),
1308
1331
  f.nullable.unwrap_or(true),
1309
1332
  )
1310
- }).collect();
1311
-
1312
- let schema = Arc::new(RustArrowSchema::new(fields));
1313
-
1314
- Ok(RustArrowTableProperties {
1315
- table_name: self.table_name.clone(),
1316
- schema,
1317
1333
  })
1318
- }
1334
+ .collect();
1335
+ Arc::new(RustArrowSchema::new(rust_fields))
1319
1336
  }
1320
1337
 
1321
1338
  /// An Arrow Flight stream for ingesting Arrow RecordBatches into a Delta table.
@@ -1323,8 +1340,8 @@ impl ArrowTableProperties {
1323
1340
  /// This stream provides a high-performance interface for streaming Arrow data
1324
1341
  /// to Databricks Delta tables using the Arrow Flight protocol.
1325
1342
  ///
1326
- /// **Experimental/Unsupported**: Arrow Flight support is experimental and not yet
1327
- /// supported for production use. The API may change in future releases.
1343
+ /// **Beta**: Arrow Flight support is in Beta. The API is stabilising but
1344
+ /// may still change before reaching GA.
1328
1345
  ///
1329
1346
  /// # Lifecycle
1330
1347
  ///
@@ -1358,26 +1375,6 @@ pub struct ZerobusArrowStream {
1358
1375
  schema: Arc<RustArrowSchema>,
1359
1376
  }
1360
1377
 
1361
- /// Helper to parse Arrow IPC buffer to RecordBatch
1362
- #[cfg(feature = "arrow-flight")]
1363
- fn parse_arrow_ipc_to_batch(ipc_buffer: &[u8], _expected_schema: &RustArrowSchema) -> Result<RustRecordBatch> {
1364
- let cursor = Cursor::new(ipc_buffer);
1365
- let reader = StreamReader::try_new(cursor, None)
1366
- .map_err(|e| Error::from_reason(format!("Failed to parse Arrow IPC: {}", e)))?;
1367
-
1368
- // Collect all batches (typically just one)
1369
- let batches: Vec<RustRecordBatch> = reader
1370
- .filter_map(|r| r.ok())
1371
- .collect();
1372
-
1373
- if batches.is_empty() {
1374
- return Err(Error::from_reason("Arrow IPC buffer contains no record batches"));
1375
- }
1376
-
1377
- // Return the first batch (or could concatenate if multiple)
1378
- Ok(batches.into_iter().next().unwrap())
1379
- }
1380
-
1381
1378
  /// Helper to serialize RecordBatch to Arrow IPC buffer
1382
1379
  #[cfg(feature = "arrow-flight")]
1383
1380
  fn serialize_batch_to_ipc(batch: &RustRecordBatch) -> Result<Vec<u8>> {
@@ -1385,9 +1382,11 @@ fn serialize_batch_to_ipc(batch: &RustRecordBatch) -> Result<Vec<u8>> {
1385
1382
  {
1386
1383
  let mut writer = StreamWriter::try_new(&mut buffer, batch.schema().as_ref())
1387
1384
  .map_err(|e| Error::from_reason(format!("Failed to create Arrow IPC writer: {}", e)))?;
1388
- writer.write(batch)
1385
+ writer
1386
+ .write(batch)
1389
1387
  .map_err(|e| Error::from_reason(format!("Failed to write batch to IPC: {}", e)))?;
1390
- writer.finish()
1388
+ writer
1389
+ .finish()
1391
1390
  .map_err(|e| Error::from_reason(format!("Failed to finish IPC stream: {}", e)))?;
1392
1391
  }
1393
1392
  Ok(buffer)
@@ -1423,29 +1422,30 @@ impl ZerobusArrowStream {
1423
1422
  /// ```
1424
1423
  #[napi(ts_return_type = "Promise<bigint>")]
1425
1424
  pub fn ingest_batch(&self, env: Env, ipc_buffer: Buffer) -> Result<JsObject> {
1426
- let schema = self.schema.clone();
1425
+ // The Rust SDK's `ingest_ipc_batch` materialises the bytes into a
1426
+ // `RecordBatch`, rejects multi-batch streams, and validates the schema
1427
+ // against the stream's schema. No need to duplicate any of that here.
1427
1428
  let stream = self.inner.clone();
1428
1429
  let buffer_vec = ipc_buffer.to_vec();
1429
1430
 
1430
1431
  env.execute_tokio_future(
1431
1432
  async move {
1432
- let batch = parse_arrow_ipc_to_batch(&buffer_vec, &schema)?;
1433
-
1434
1433
  let mut guard = stream.lock().await;
1435
1434
  let stream_ref = guard
1436
1435
  .as_mut()
1437
1436
  .ok_or_else(|| napi::Error::from_reason("Arrow stream has been closed"))?;
1438
1437
 
1439
1438
  stream_ref
1440
- .ingest_batch(batch)
1439
+ .ingest_ipc_batch(Bytes::from(buffer_vec))
1441
1440
  .await
1442
- .map_err(|e| napi::Error::from_reason(format!("Failed to ingest batch: {}", e)))
1441
+ .map_err(|e| {
1442
+ napi::Error::from_reason(format!("Failed to ingest batch: {}", e))
1443
+ })
1443
1444
  },
1444
1445
  |env, offset_id| {
1445
- let offset_str = offset_id.to_string();
1446
1446
  let global: JsGlobal = env.get_global()?;
1447
1447
  let bigint_ctor: JsFunction = global.get_named_property("BigInt")?;
1448
- let js_str = env.create_string(&offset_str)?;
1448
+ let js_str = env.create_string(&offset_id.to_string())?;
1449
1449
  bigint_ctor.call(None, &[js_str.into_unknown()])
1450
1450
  },
1451
1451
  )
@@ -1458,12 +1458,9 @@ impl ZerobusArrowStream {
1458
1458
  /// # Arguments
1459
1459
  ///
1460
1460
  /// * `offset_id` - The offset ID to wait for (returned by ingestBatch)
1461
- #[napi(ts_args_type = "offsetId: bigint", ts_return_type = "Promise<void>")]
1462
- pub fn wait_for_offset(&self, env: Env, offset_id: JsUnknown) -> Result<JsObject> {
1463
- let global: JsGlobal = env.get_global()?;
1464
- let number_ctor: JsFunction = global.get_named_property("Number")?;
1465
- let num_result: JsUnknown = number_ctor.call(None, &[offset_id])?;
1466
- let offset: i64 = num_result.coerce_to_number()?.get_int64()?;
1461
+ #[napi(ts_return_type = "Promise<void>")]
1462
+ pub fn wait_for_offset(&self, env: Env, offset_id: BigInt) -> Result<JsObject> {
1463
+ let offset = bigint_to_i64(offset_id)?;
1467
1464
 
1468
1465
  let stream = self.inner.clone();
1469
1466
 
@@ -1474,10 +1471,9 @@ impl ZerobusArrowStream {
1474
1471
  .as_ref()
1475
1472
  .ok_or_else(|| napi::Error::from_reason("Arrow stream has been closed"))?;
1476
1473
 
1477
- stream_ref
1478
- .wait_for_offset(offset)
1479
- .await
1480
- .map_err(|e| napi::Error::from_reason(format!("Failed to wait for offset: {}", e)))
1474
+ stream_ref.wait_for_offset(offset).await.map_err(|e| {
1475
+ napi::Error::from_reason(format!("Failed to wait for offset: {}", e))
1476
+ })
1481
1477
  },
1482
1478
  |_env, _| Ok(()),
1483
1479
  )
@@ -1523,7 +1519,9 @@ impl ZerobusArrowStream {
1523
1519
  /// Returns the table name for this stream.
1524
1520
  #[napi(getter)]
1525
1521
  pub fn table_name(&self) -> Result<String> {
1526
- let guard = self.inner.try_lock()
1522
+ let guard = self
1523
+ .inner
1524
+ .try_lock()
1527
1525
  .map_err(|_| Error::from_reason("Stream is busy"))?;
1528
1526
  let stream = guard
1529
1527
  .as_ref()
@@ -1568,8 +1566,8 @@ impl ZerobusArrowStream {
1568
1566
  impl ZerobusSdk {
1569
1567
  /// Creates a new Arrow Flight stream to a Delta table.
1570
1568
  ///
1571
- /// **Experimental/Unsupported**: Arrow Flight support is experimental and not yet
1572
- /// supported for production use. The API may change in future releases.
1569
+ /// **Beta**: Arrow Flight support is in Beta. The API is stabilising
1570
+ /// but may still change before reaching GA.
1573
1571
  ///
1574
1572
  /// This method establishes an Arrow Flight connection to the Zerobus service
1575
1573
  /// for high-performance columnar data ingestion.
@@ -1613,22 +1611,69 @@ impl ZerobusSdk {
1613
1611
  client_secret: String,
1614
1612
  options: Option<ArrowStreamConfigurationOptions>,
1615
1613
  ) -> Result<JsObject> {
1616
- let rust_table_props = table_properties.to_rust()?;
1617
- let schema = rust_table_props.schema.clone();
1618
- let rust_options: Option<RustArrowStreamOptions> = options.map(|o| o.into());
1614
+ // Rust SDK 2.0 removed the convenience `create_arrow_stream` method;
1615
+ // open via `sdk.stream_builder().table(...).oauth(...).arrow(schema)
1616
+ // .build_arrow()` and apply options via setters. `ArrowTableProperties`
1617
+ // is also private now, so we build just the schema here.
1618
+ let schema = build_arrow_schema(&table_properties.schema_fields);
1619
+ let table_name = table_properties.table_name.clone();
1620
+ let opts = options.unwrap_or(ArrowStreamConfigurationOptions {
1621
+ max_inflight_batches: None,
1622
+ recovery: None,
1623
+ recovery_timeout_ms: None,
1624
+ recovery_backoff_ms: None,
1625
+ recovery_retries: None,
1626
+ server_lack_of_ack_timeout_ms: None,
1627
+ flush_timeout_ms: None,
1628
+ connection_timeout_ms: None,
1629
+ ipc_compression: None,
1630
+ });
1631
+ let ipc_compression = map_ipc_compression(opts.ipc_compression);
1632
+ let schema_for_stream = schema.clone();
1619
1633
 
1620
1634
  let sdk = self.inner.clone();
1621
1635
 
1622
1636
  env.execute_tokio_future(
1623
1637
  async move {
1624
- let stream = sdk
1625
- .create_arrow_stream(rust_table_props, client_id, client_secret, rust_options)
1626
- .await
1627
- .map_err(|e| napi::Error::from_reason(format!("Failed to create arrow stream: {}", e)))?;
1638
+ let mut builder = sdk
1639
+ .stream_builder()
1640
+ .table(table_name)
1641
+ .oauth(client_id, client_secret)
1642
+ .arrow(schema.clone());
1643
+
1644
+ if let Some(v) = opts.max_inflight_batches {
1645
+ builder = builder.max_inflight_batches(v as usize);
1646
+ }
1647
+ if let Some(v) = opts.recovery {
1648
+ builder = builder.recovery(v);
1649
+ }
1650
+ if let Some(v) = opts.recovery_timeout_ms {
1651
+ builder = builder.recovery_timeout_ms(v as u64);
1652
+ }
1653
+ if let Some(v) = opts.recovery_backoff_ms {
1654
+ builder = builder.recovery_backoff_ms(v as u64);
1655
+ }
1656
+ if let Some(v) = opts.recovery_retries {
1657
+ builder = builder.recovery_retries(v);
1658
+ }
1659
+ if let Some(v) = opts.server_lack_of_ack_timeout_ms {
1660
+ builder = builder.server_lack_of_ack_timeout_ms(v as u64);
1661
+ }
1662
+ if let Some(v) = opts.flush_timeout_ms {
1663
+ builder = builder.flush_timeout_ms(v as u64);
1664
+ }
1665
+ if let Some(v) = opts.connection_timeout_ms {
1666
+ builder = builder.connection_timeout_ms(v as u64);
1667
+ }
1668
+ builder = builder.ipc_compression(ipc_compression);
1669
+
1670
+ let stream = builder.build_arrow().await.map_err(|e| {
1671
+ napi::Error::from_reason(format!("Failed to create arrow stream: {}", e))
1672
+ })?;
1628
1673
 
1629
1674
  Ok(ZerobusArrowStream {
1630
1675
  inner: Arc::new(Mutex::new(Some(stream))),
1631
- schema,
1676
+ schema: schema_for_stream,
1632
1677
  })
1633
1678
  },
1634
1679
  |_env, stream| Ok(stream),
@@ -1637,7 +1682,7 @@ impl ZerobusSdk {
1637
1682
 
1638
1683
  /// Recreates an Arrow stream with the same configuration and re-ingests unacknowledged batches.
1639
1684
  ///
1640
- /// **Experimental/Unsupported**: Arrow Flight support is experimental.
1685
+ /// **Beta**: Arrow Flight support is in Beta.
1641
1686
  ///
1642
1687
  /// # Arguments
1643
1688
  ///
@@ -1647,7 +1692,10 @@ impl ZerobusSdk {
1647
1692
  ///
1648
1693
  /// A Promise that resolves to a new ZerobusArrowStream with all unacknowledged batches re-ingested.
1649
1694
  #[napi]
1650
- pub async fn recreate_arrow_stream(&self, stream: &ZerobusArrowStream) -> Result<ZerobusArrowStream> {
1695
+ pub async fn recreate_arrow_stream(
1696
+ &self,
1697
+ stream: &ZerobusArrowStream,
1698
+ ) -> Result<ZerobusArrowStream> {
1651
1699
  let inner_guard = stream.inner.lock().await;
1652
1700
  let rust_stream = inner_guard
1653
1701
  .as_ref()