@databricks/zerobus-ingest-sdk 1.0.2 → 1.2.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;
@@ -54,7 +50,7 @@ pub enum RecordType {
54
50
  #[napi(object)]
55
51
  pub struct StreamConfigurationOptions {
56
52
  /// Maximum number of unacknowledged requests that can be in flight.
57
- /// Default: 10,000
53
+ /// Default: 1,000,000
58
54
  pub max_inflight_requests: Option<u32>,
59
55
 
60
56
  /// Enable automatic stream recovery on transient failures.
@@ -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
@@ -133,31 +103,11 @@ pub struct TableProperties {
133
103
  pub table_name: String,
134
104
 
135
105
  /// Optional Protocol Buffer descriptor as a base64-encoded string.
136
- /// If not provided, JSON encoding will be used.
106
+ /// Omitting this does not select JSON. The stream defaults to Protocol Buffers
107
+ /// unless `record_type` is set to JSON.
137
108
  pub descriptor_proto: Option<String>,
138
109
  }
139
110
 
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
111
  /// Custom error type for Zerobus operations.
162
112
  ///
163
113
  /// This error type includes information about whether the error is retryable,
@@ -183,6 +133,19 @@ impl ZerobusError {
183
133
  }
184
134
  }
185
135
 
136
+ /// Convert a JS `BigInt` to `i64`, erroring if it can't be represented losslessly.
137
+ /// Used by `waitForOffset` to avoid the precision loss of the old
138
+ /// `Number(bigint)` round-trip past 2^53.
139
+ fn bigint_to_i64(value: BigInt) -> Result<i64> {
140
+ let (n, lossless) = value.get_i64();
141
+ if !lossless {
142
+ return Err(Error::from_reason(
143
+ "offsetId exceeds i64 range; cannot be represented without loss",
144
+ ));
145
+ }
146
+ Ok(n)
147
+ }
148
+
186
149
  /// Helper function to convert a JavaScript value to a RustRecordPayload.
187
150
  ///
188
151
  /// Supports:
@@ -209,17 +172,19 @@ fn convert_js_to_record_payload(env: &Env, payload: Unknown) -> Result<RustRecor
209
172
  if constructor_obj.has_named_property("encode")? {
210
173
  let encode_fn: JsFunction = constructor_obj.get_named_property("encode")?;
211
174
  let obj_as_unknown = obj.into_unknown();
212
- let encode_result: JsUnknown = encode_fn.call::<JsUnknown>(Some(&constructor_obj), &[obj_as_unknown])?;
175
+ let encode_result: JsUnknown =
176
+ encode_fn.call::<JsUnknown>(Some(&constructor_obj), &[obj_as_unknown])?;
213
177
  let encode_obj = JsObject::from_unknown(encode_result)?;
214
178
 
215
179
  if encode_obj.has_named_property("finish")? {
216
180
  let finish_fn: JsFunction = encode_obj.get_named_property("finish")?;
217
- let buffer_result: JsUnknown = finish_fn.call::<JsUnknown>(Some(&encode_obj), &[])?;
181
+ let buffer_result: JsUnknown =
182
+ finish_fn.call::<JsUnknown>(Some(&encode_obj), &[])?;
218
183
  let buffer: Buffer = Buffer::from_unknown(buffer_result)?;
219
184
  Ok(RustRecordPayload::Proto(buffer.to_vec()))
220
185
  } else {
221
186
  Err(Error::from_reason(
222
- "Protobuf message .encode() must return an object with .finish() method"
187
+ "Protobuf message .encode() must return an object with .finish() method",
223
188
  ))
224
189
  }
225
190
  } else {
@@ -227,7 +192,8 @@ fn convert_js_to_record_payload(env: &Env, payload: Unknown) -> Result<RustRecor
227
192
  let json_obj: JsObject = global.get_named_property("JSON")?;
228
193
  let stringify: JsFunction = json_obj.get_named_property("stringify")?;
229
194
  let obj_as_unknown = obj.into_unknown();
230
- let str_result: JsUnknown = stringify.call::<JsUnknown>(Some(&json_obj), &[obj_as_unknown])?;
195
+ let str_result: JsUnknown =
196
+ stringify.call::<JsUnknown>(Some(&json_obj), &[obj_as_unknown])?;
231
197
  let js_string = JsString::from_unknown(str_result)?;
232
198
  let json_string = js_string.into_utf8()?.as_str()?.to_string();
233
199
 
@@ -240,11 +206,9 @@ fn convert_js_to_record_payload(env: &Env, payload: Unknown) -> Result<RustRecor
240
206
  let json_string = js_string.into_utf8()?.as_str()?.to_string();
241
207
  Ok(RustRecordPayload::Json(json_string))
242
208
  }
243
- _ => {
244
- Err(Error::from_reason(
245
- "Payload must be a Buffer, string, protobuf message object, or plain JavaScript object"
246
- ))
247
- }
209
+ _ => Err(Error::from_reason(
210
+ "Payload must be a Buffer, string, protobuf message object, or plain JavaScript object",
211
+ )),
248
212
  }
249
213
  }
250
214
 
@@ -257,8 +221,8 @@ fn convert_js_to_record_payload(env: &Env, payload: Unknown) -> Result<RustRecor
257
221
  ///
258
222
  /// ```typescript
259
223
  /// const stream = await sdk.createStream(tableProps, clientId, clientSecret, options);
260
- /// const ackPromise = await stream.ingestRecord(Buffer.from([1, 2, 3]));
261
- /// const offset = await ackPromise;
224
+ /// const offset = await stream.ingestRecordOffset(Buffer.from([1, 2, 3]));
225
+ /// await stream.flush();
262
226
  /// await stream.close();
263
227
  /// ```
264
228
  #[napi]
@@ -309,35 +273,37 @@ impl ZerobusStream {
309
273
  #[allow(deprecated)]
310
274
  pub fn ingest_record(&self, env: Env, payload: Unknown) -> Result<JsObject> {
311
275
  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
- };
276
+ let stream = self.inner.clone();
329
277
 
330
278
  env.execute_tokio_future(
331
279
  async move {
332
- ack_future
333
- .await
334
- .map_err(|e| napi::Error::from_reason(format!("Acknowledgment failed: {}", e)))
280
+ let offset = {
281
+ let mut guard = stream.lock().await;
282
+ let stream_ref = guard
283
+ .as_mut()
284
+ .ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
285
+ stream_ref
286
+ .ingest_record_offset(record_payload)
287
+ .await
288
+ .map_err(|e| {
289
+ napi::Error::from_reason(format!("Failed to ingest record: {}", e))
290
+ })?
291
+ };
292
+ {
293
+ let guard = stream.lock().await;
294
+ let stream_ref = guard
295
+ .as_ref()
296
+ .ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
297
+ stream_ref.wait_for_offset(offset).await.map_err(|e| {
298
+ napi::Error::from_reason(format!("Acknowledgment failed: {}", e))
299
+ })?;
300
+ }
301
+ Ok(offset)
335
302
  },
336
- |env, result| {
337
- let result_str = result.to_string();
303
+ |env, offset_id| {
338
304
  let global: JsGlobal = env.get_global()?;
339
305
  let bigint_ctor: JsFunction = global.get_named_property("BigInt")?;
340
- let js_str = env.create_string(&result_str)?;
306
+ let js_str = env.create_string(&offset_id.to_string())?;
341
307
  bigint_ctor.call(None, &[js_str.into_unknown()])
342
308
  },
343
309
  )
@@ -378,50 +344,49 @@ impl ZerobusStream {
378
344
  #[napi(ts_return_type = "Promise<bigint | null>")]
379
345
  #[allow(deprecated)]
380
346
  pub fn ingest_records(&self, env: Env, records: Vec<Unknown>) -> Result<JsObject> {
347
+ // Rust SDK 2.0 removed the blocking `ingest_records`. v1 semantics
348
+ // (Promise resolves after server ack; `null` for empty batches) are
349
+ // preserved via `ingest_records_offset` + `wait_for_offset`.
381
350
  let record_payloads: Result<Vec<RustRecordPayload>> = records
382
351
  .into_iter()
383
352
  .map(|payload| convert_js_to_record_payload(&env, payload))
384
353
  .collect();
385
-
386
354
  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
- };
355
+ let stream = self.inner.clone();
405
356
 
406
357
  env.execute_tokio_future(
407
358
  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
- )),
359
+ let offset_opt = {
360
+ let mut guard = stream.lock().await;
361
+ let stream_ref = guard
362
+ .as_mut()
363
+ .ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
364
+ stream_ref
365
+ .ingest_records_offset(record_payloads)
366
+ .await
367
+ .map_err(|e| {
368
+ napi::Error::from_reason(format!("Failed to ingest batch: {}", e))
369
+ })?
370
+ };
371
+ if let Some(offset) = offset_opt {
372
+ let guard = stream.lock().await;
373
+ let stream_ref = guard
374
+ .as_ref()
375
+ .ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
376
+ stream_ref.wait_for_offset(offset).await.map_err(|e| {
377
+ napi::Error::from_reason(format!("Batch acknowledgment failed: {}", e))
378
+ })?;
414
379
  }
380
+ Ok(offset_opt)
415
381
  },
416
382
  |env, result| match result {
417
383
  Some(offset_id) => {
418
- let offset_str = offset_id.to_string();
419
384
  let global: JsGlobal = env.get_global()?;
420
385
  let bigint_ctor: JsFunction = global.get_named_property("BigInt")?;
421
- let js_str = env.create_string(&offset_str)?;
386
+ let js_str = env.create_string(&offset_id.to_string())?;
422
387
  let bigint = bigint_ctor.call(None, &[js_str.into_unknown()])?;
423
388
  Ok(bigint.into_unknown())
424
- },
389
+ }
425
390
  None => env.get_null().map(|v| v.into_unknown()),
426
391
  },
427
392
  )
@@ -436,6 +401,13 @@ impl ZerobusStream {
436
401
  /// This is the recommended API for high-throughput scenarios where you want to
437
402
  /// decouple record ingestion from acknowledgment tracking.
438
403
  ///
404
+ /// **Acknowledgments:** the idiomatic flow is to ingest in a loop and then `flush()`
405
+ /// once to confirm everything queued so far. The returned offset, together with
406
+ /// `waitForOffset()`, lets you confirm a specific record when you need it (acks are
407
+ /// ordered, so the last offset confirms the whole run) — prefer `flush()` for bulk.
408
+ /// Avoid calling `waitForOffset()` after every record in a tight loop, since that
409
+ /// limits throughput to one record per round-trip.
410
+ ///
439
411
  /// # Arguments
440
412
  ///
441
413
  /// * `payload` - The record data (Buffer, string, protobuf message, or plain object)
@@ -448,11 +420,14 @@ impl ZerobusStream {
448
420
  /// # Example
449
421
  ///
450
422
  /// ```typescript
451
- /// // Promise resolves immediately with offset (before server ack)
452
- /// const offset1 = await stream.ingestRecordOffset(record1);
453
- /// const offset2 = await stream.ingestRecordOffset(record2);
454
- /// // Wait for both to be acknowledged
455
- /// await stream.waitForOffset(offset2);
423
+ /// // High-throughput pattern: ingest in a loop, wait once at the end.
424
+ /// let lastOffset: bigint | null = null;
425
+ /// for (const record of records) {
426
+ /// lastOffset = await stream.ingestRecordOffset(record); // resolves on queue, no round-trip
427
+ /// }
428
+ /// // The ack watermark is monotonic: waiting on the last offset confirms all prior records.
429
+ /// if (lastOffset !== null) await stream.waitForOffset(lastOffset);
430
+ /// // Or simply: await stream.flush();
456
431
  /// ```
457
432
  #[napi(ts_return_type = "Promise<bigint>")]
458
433
  pub fn ingest_record_offset(&self, env: Env, payload: Unknown) -> Result<JsObject> {
@@ -470,7 +445,9 @@ impl ZerobusStream {
470
445
  stream_ref
471
446
  .ingest_record_offset(record_payload)
472
447
  .await
473
- .map_err(|e| napi::Error::from_reason(format!("Failed to ingest record: {}", e)))
448
+ .map_err(|e| {
449
+ napi::Error::from_reason(format!("Failed to ingest record: {}", e))
450
+ })
474
451
  },
475
452
  |env, offset_id| {
476
453
  let offset_str = offset_id.to_string();
@@ -488,6 +465,12 @@ impl ZerobusStream {
488
465
  /// the batch is queued, without waiting for server acknowledgment. Use
489
466
  /// `waitForOffset()` to wait for acknowledgment when needed.
490
467
  ///
468
+ /// **Acknowledgments:** the idiomatic flow is to ingest your batches in a loop and
469
+ /// then `flush()` once to confirm. The returned offset, together with `waitForOffset()`,
470
+ /// confirms a specific batch when you need it (acks are ordered, so the last offset
471
+ /// confirms the whole run) — prefer `flush()` for bulk. Avoid calling `waitForOffset()`
472
+ /// after every batch in a tight loop, since that limits throughput to one round-trip per batch.
473
+ ///
491
474
  /// # Arguments
492
475
  ///
493
476
  /// * `records` - Array of record data
@@ -500,11 +483,14 @@ impl ZerobusStream {
500
483
  /// # Example
501
484
  ///
502
485
  /// ```typescript
503
- /// // Promise resolves immediately with offset (before server ack)
504
- /// const offset = await stream.ingestRecordsOffset(batch);
505
- /// if (offset !== null) {
506
- /// await stream.waitForOffset(offset);
486
+ /// // Ingest many batches without waiting, then flush once.
487
+ /// let lastOffset = null;
488
+ /// for (const batch of batches) {
489
+ /// const offset = await stream.ingestRecordsOffset(batch); // resolves on queue
490
+ /// if (offset !== null) lastOffset = offset;
507
491
  /// }
492
+ /// if (lastOffset !== null) await stream.waitForOffset(lastOffset);
493
+ /// // Or simply: await stream.flush();
508
494
  /// ```
509
495
  #[napi(ts_return_type = "Promise<bigint | null>")]
510
496
  pub fn ingest_records_offset(&self, env: Env, records: Vec<Unknown>) -> Result<JsObject> {
@@ -537,7 +523,7 @@ impl ZerobusStream {
537
523
  let js_str = env.create_string(&offset_str)?;
538
524
  let bigint = bigint_ctor.call(None, &[js_str.into_unknown()])?;
539
525
  Ok(bigint.into_unknown())
540
- },
526
+ }
541
527
  None => env.get_null().map(|v| v.into_unknown()),
542
528
  },
543
529
  )
@@ -545,9 +531,12 @@ impl ZerobusStream {
545
531
 
546
532
  /// Waits for a specific offset to be acknowledged by the server.
547
533
  ///
548
- /// Use this method with `ingestRecordOffset()` and `ingestRecordsOffset()` to
549
- /// selectively wait for acknowledgments. This allows you to ingest many records
550
- /// quickly and then wait only for specific offsets when needed.
534
+ /// Use this method with `ingestRecordOffset()` and `ingestRecordsOffset()` to confirm
535
+ /// a specific record before continuing. Acks are ordered, so waiting on the LAST offset
536
+ /// confirms every prior record too you never need to wait on intermediate offsets.
537
+ /// For confirming a bulk run, `flush()` is usually simpler; reach for `waitForOffset()`
538
+ /// when one particular record must be confirmed. Avoid calling it after every record in
539
+ /// a tight loop, since that limits throughput to one record per round-trip.
551
540
  ///
552
541
  /// # Arguments
553
542
  ///
@@ -561,19 +550,16 @@ impl ZerobusStream {
561
550
  /// # Example
562
551
  ///
563
552
  /// ```typescript
564
- /// const offsets = [];
553
+ /// let lastOffset: bigint | null = null;
565
554
  /// for (const record of records) {
566
- /// offsets.push(await stream.ingestRecordOffset(record));
555
+ /// lastOffset = await stream.ingestRecordOffset(record); // no per-record wait
567
556
  /// }
568
- /// // Wait for the last offset (implies all previous are also acknowledged)
569
- /// await stream.waitForOffset(offsets[offsets.length - 1]);
557
+ /// // Wait for the last offset only (implies all previous are also acknowledged).
558
+ /// if (lastOffset !== null) await stream.waitForOffset(lastOffset);
570
559
  /// ```
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()?;
560
+ #[napi(ts_return_type = "Promise<void>")]
561
+ pub fn wait_for_offset(&self, env: Env, offset_id: BigInt) -> Result<JsObject> {
562
+ let offset = bigint_to_i64(offset_id)?;
577
563
 
578
564
  let stream = self.inner.clone();
579
565
 
@@ -584,10 +570,9 @@ impl ZerobusStream {
584
570
  .as_ref()
585
571
  .ok_or_else(|| napi::Error::from_reason("Stream has been closed"))?;
586
572
 
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)))
573
+ stream_ref.wait_for_offset(offset).await.map_err(|e| {
574
+ napi::Error::from_reason(format!("Failed to wait for offset: {}", e))
575
+ })
591
576
  },
592
577
  |_env, _| Ok(()),
593
578
  )
@@ -598,6 +583,11 @@ impl ZerobusStream {
598
583
  /// This method ensures all previously ingested records have been sent to the server
599
584
  /// and acknowledged. It's useful for checkpointing or ensuring data durability.
600
585
  ///
586
+ /// This is the idiomatic way to confirm records ingested via `ingestRecordOffset()` /
587
+ /// `ingestRecordsOffset()`: ingest in a loop, then `flush()` once (for a bounded batch,
588
+ /// or periodically for a long-running stream). It resolves once everything queued so
589
+ /// far is acknowledged.
590
+ ///
601
591
  /// # Errors
602
592
  ///
603
593
  /// - Timeout errors if flush takes longer than configured timeout
@@ -684,15 +674,12 @@ impl ZerobusStream {
684
674
  ///
685
675
  /// ```typescript
686
676
  /// try {
687
- /// await stream.ingestRecords(batch1);
688
- /// await stream.ingestRecords(batch2);
677
+ /// await stream.ingestRecordsOffset(batch1);
678
+ /// await stream.ingestRecordsOffset(batch2);
679
+ /// await stream.flush();
689
680
  /// } catch (error) {
690
681
  /// const unackedBatches = await stream.getUnackedBatches();
691
- ///
692
- /// // Re-ingest with new stream
693
- /// for (const batch of unackedBatches) {
694
- /// await newStream.ingestRecords(batch);
695
- /// }
682
+ /// console.log(`Batches available for recovery: ${unackedBatches.length}`);
696
683
  /// }
697
684
  /// ```
698
685
  #[napi]
@@ -725,10 +712,10 @@ impl ZerobusStream {
725
712
  /// JavaScript headers provider callback wrapper.
726
713
  ///
727
714
  /// Allows TypeScript code to provide custom authentication headers
728
- /// by implementing a getHeaders() function.
715
+ /// by implementing a getHeadersCallback() function.
729
716
  #[napi(object)]
730
717
  pub struct JsHeadersProvider {
731
- /// JavaScript function: () => Promise<Array<[string, string]>>
718
+ /// JavaScript function: () => Array<[string, string]>
732
719
  pub get_headers_callback: JsFunction,
733
720
  }
734
721
 
@@ -750,20 +737,15 @@ impl StaticHeadersProvider {
750
737
 
751
738
  if !map.contains_key("authorization") {
752
739
  return Err(RustZerobusError::InvalidArgument(
753
- "HeadersProvider must include 'authorization' header with Bearer token".to_string()
740
+ "HeadersProvider must include 'authorization' header with Bearer token".to_string(),
754
741
  ));
755
742
  }
756
743
  if !map.contains_key("x-databricks-zerobus-table-name") {
757
744
  return Err(RustZerobusError::InvalidArgument(
758
- "HeadersProvider must include 'x-databricks-zerobus-table-name' header".to_string()
745
+ "HeadersProvider must include 'x-databricks-zerobus-table-name' header".to_string(),
759
746
  ));
760
747
  }
761
748
 
762
- // Add TS user agent if not provided
763
- if !map.contains_key("user-agent") {
764
- map.insert("user-agent", TS_SDK_USER_AGENT.to_string());
765
- }
766
-
767
749
  Ok(Self { headers: map })
768
750
  }
769
751
  }
@@ -776,13 +758,18 @@ impl RustHeadersProvider for StaticHeadersProvider {
776
758
  }
777
759
 
778
760
  /// Helper to create a threadsafe function from JavaScript callback
779
- fn create_headers_tsfn(js_func: JsFunction) -> Result<ThreadsafeFunction<(), ErrorStrategy::Fatal>> {
761
+ fn create_headers_tsfn(
762
+ js_func: JsFunction,
763
+ ) -> Result<ThreadsafeFunction<(), ErrorStrategy::Fatal>> {
780
764
  js_func.create_threadsafe_function(0, |ctx| Ok(vec![ctx.value]))
781
765
  }
782
766
 
783
767
  /// 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(())
768
+ async fn call_headers_tsfn(
769
+ tsfn: ThreadsafeFunction<(), ErrorStrategy::Fatal>,
770
+ ) -> Result<Vec<(String, String)>> {
771
+ let raw_headers: Vec<Vec<String>> = tsfn
772
+ .call_async(())
786
773
  .await
787
774
  .map_err(|e| Error::from_reason(format!("Failed to call headers callback: {}", e)))?;
788
775
 
@@ -843,11 +830,17 @@ impl RustHeadersProvider for TsOAuthHeadersProvider {
843
830
  let mut headers = HashMap::new();
844
831
  headers.insert("authorization", format!("Bearer {}", token));
845
832
  headers.insert("x-databricks-zerobus-table-name", self.table_name.clone());
846
- headers.insert("user-agent", TS_SDK_USER_AGENT.to_string());
847
833
  Ok(headers)
848
834
  }
849
835
  }
850
836
 
837
+ #[napi(object)]
838
+ #[derive(Default)]
839
+ pub struct ZerobusSdkOptions {
840
+ /// Identifier appended to the `user-agent` header
841
+ pub application_name: Option<String>,
842
+ }
843
+
851
844
  /// The main SDK for interacting with the Databricks Zerobus service.
852
845
  ///
853
846
  /// This is the entry point for creating ingestion streams to Delta tables.
@@ -857,7 +850,8 @@ impl RustHeadersProvider for TsOAuthHeadersProvider {
857
850
  /// ```typescript
858
851
  /// const sdk = new ZerobusSdk(
859
852
  /// "https://workspace-id.zerobus.region.cloud.databricks.com",
860
- /// "https://workspace.cloud.databricks.com"
853
+ /// "https://workspace.cloud.databricks.com",
854
+ /// { applicationName: "my-app/1.0" }
861
855
  /// );
862
856
  ///
863
857
  /// const stream = await sdk.createStream(
@@ -885,25 +879,41 @@ impl ZerobusSdk {
885
879
  /// (e.g., "https://workspace-id.zerobus.region.cloud.databricks.com")
886
880
  /// * `unity_catalog_url` - The Unity Catalog endpoint URL
887
881
  /// (e.g., "https://workspace.cloud.databricks.com")
882
+ /// * `options` - Optional SDK configuration (see `ZerobusSdkOptions`),
883
+ /// including `applicationName` for server-side attribution.
888
884
  ///
889
885
  /// # Errors
890
886
  ///
891
887
  /// - Invalid endpoint URLs
892
888
  /// - Failed to extract workspace ID from the endpoint
893
889
  #[napi(constructor)]
894
- pub fn new(zerobus_endpoint: String, unity_catalog_url: String) -> Result<Self> {
890
+ pub fn new(
891
+ zerobus_endpoint: String,
892
+ unity_catalog_url: String,
893
+ options: Option<ZerobusSdkOptions>,
894
+ ) -> Result<Self> {
895
895
  let workspace_id = zerobus_endpoint
896
896
  .strip_prefix("https://")
897
897
  .or_else(|| zerobus_endpoint.strip_prefix("http://"))
898
898
  .and_then(|s| s.split('.').next())
899
899
  .map(|s| s.to_string())
900
900
  .ok_or_else(|| {
901
- Error::from_reason("Failed to extract workspace_id from zerobus_endpoint".to_string())
901
+ Error::from_reason(
902
+ "Failed to extract workspace_id from zerobus_endpoint".to_string(),
903
+ )
902
904
  })?;
903
905
 
904
- let inner = RustZerobusSdk::builder()
906
+ let options = options.unwrap_or_default();
907
+
908
+ let builder = RustZerobusSdk::builder()
905
909
  .endpoint(&zerobus_endpoint)
906
910
  .unity_catalog_url(&unity_catalog_url)
911
+ .sdk_identifier(TS_SDK_USER_AGENT);
912
+ let builder = match options.application_name {
913
+ Some(name) => builder.application_name(name),
914
+ None => builder,
915
+ };
916
+ let inner = builder
907
917
  .build()
908
918
  .map_err(|e| Error::from_reason(format!("Failed to create SDK: {}", e)))?;
909
919
 
@@ -958,7 +968,7 @@ impl ZerobusSdk {
958
968
  /// "", // ignored
959
969
  /// undefined,
960
970
  /// {
961
- /// getHeadersCallback: async () => [
971
+ /// getHeadersCallback: () => [
962
972
  /// ["authorization", `Bearer ${myToken}`],
963
973
  /// ["x-databricks-zerobus-table-name", tableName]
964
974
  /// ]
@@ -975,13 +985,45 @@ impl ZerobusSdk {
975
985
  options: Option<StreamConfigurationOptions>,
976
986
  headers_provider: Option<JsHeadersProvider>,
977
987
  ) -> Result<JsObject> {
978
- let rust_table_props = table_properties.to_rust()?;
979
- let rust_options: RustStreamOptions = options.map(|o| o.into()).unwrap_or_default();
988
+ // Decode the optional protobuf descriptor up-front so we can hand it
989
+ // to the builder's `.compiled_proto()` setter; the builder constructs
990
+ // the (now-private) `TableProperties` itself.
991
+ let descriptor_proto: Option<prost_types::DescriptorProto> = if let Some(ref desc_str) =
992
+ table_properties.descriptor_proto
993
+ {
994
+ let bytes = base64_decode(desc_str)
995
+ .map_err(|e| Error::from_reason(format!("Failed to decode descriptor: {}", e)))?;
996
+ let dp: prost_types::DescriptorProto =
997
+ prost::Message::decode(&bytes[..]).map_err(|e| {
998
+ Error::from_reason(format!("Failed to parse descriptor proto: {}", e))
999
+ })?;
1000
+ Some(dp)
1001
+ } else {
1002
+ None
1003
+ };
1004
+
1005
+ let opts = options.unwrap_or(StreamConfigurationOptions {
1006
+ max_inflight_requests: None,
1007
+ recovery: None,
1008
+ recovery_timeout_ms: None,
1009
+ recovery_backoff_ms: None,
1010
+ recovery_retries: None,
1011
+ flush_timeout_ms: None,
1012
+ server_lack_of_ack_timeout_ms: None,
1013
+ record_type: None,
1014
+ stream_paused_max_wait_time_ms: None,
1015
+ });
1016
+
1017
+ let record_type = match opts.record_type {
1018
+ Some(0) => RustRecordType::Json,
1019
+ Some(1) => RustRecordType::Proto,
1020
+ _ => RustRecordType::Proto,
1021
+ };
980
1022
 
981
1023
  let headers_tsfn = match headers_provider {
982
- Some(JsHeadersProvider { get_headers_callback }) => {
983
- Some(create_headers_tsfn(get_headers_callback)?)
984
- }
1024
+ Some(JsHeadersProvider {
1025
+ get_headers_callback,
1026
+ }) => Some(create_headers_tsfn(get_headers_callback)?),
985
1027
  None => None,
986
1028
  };
987
1029
 
@@ -992,34 +1034,70 @@ impl ZerobusSdk {
992
1034
 
993
1035
  env.execute_tokio_future(
994
1036
  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
-
1037
+ let headers_provider_arc: Arc<dyn RustHeadersProvider> = if let Some(tsfn) =
1038
+ headers_tsfn
1039
+ {
1040
+ let headers = call_headers_tsfn(tsfn).await.map_err(|e| {
1041
+ napi::Error::from_reason(format!("Headers callback failed: {}", e))
1042
+ })?;
1000
1043
  let static_provider = StaticHeadersProvider::new(headers)
1001
1044
  .map_err(|e| napi::Error::from_reason(format!("Invalid headers: {}", e)))?;
1002
-
1003
1045
  Arc::new(static_provider)
1004
1046
  } else {
1005
- // Default OAuth with TS user agent
1006
1047
  Arc::new(TsOAuthHeadersProvider::new(
1007
1048
  client_id,
1008
1049
  client_secret,
1009
- table_name,
1050
+ table_name.clone(),
1010
1051
  workspace_id,
1011
1052
  unity_catalog_url,
1012
1053
  ))
1013
1054
  };
1014
1055
 
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)))?;
1056
+ let mut builder = sdk
1057
+ .stream_builder()
1058
+ .table(table_name)
1059
+ .headers_provider(headers_provider_arc);
1060
+
1061
+ if let Some(v) = opts.max_inflight_requests {
1062
+ builder = builder.max_inflight_requests(v as usize);
1063
+ }
1064
+ if let Some(v) = opts.recovery {
1065
+ builder = builder.recovery(v);
1066
+ }
1067
+ if let Some(v) = opts.recovery_timeout_ms {
1068
+ builder = builder.recovery_timeout_ms(v as u64);
1069
+ }
1070
+ if let Some(v) = opts.recovery_backoff_ms {
1071
+ builder = builder.recovery_backoff_ms(v as u64);
1072
+ }
1073
+ if let Some(v) = opts.recovery_retries {
1074
+ builder = builder.recovery_retries(v);
1075
+ }
1076
+ if let Some(v) = opts.flush_timeout_ms {
1077
+ builder = builder.flush_timeout_ms(v as u64);
1078
+ }
1079
+ if let Some(v) = opts.server_lack_of_ack_timeout_ms {
1080
+ builder = builder.server_lack_of_ack_timeout_ms(v as u64);
1081
+ }
1082
+ if let Some(v) = opts.stream_paused_max_wait_time_ms {
1083
+ builder = builder.stream_paused_max_wait_time_ms(Some(v as u64));
1084
+ }
1085
+
1086
+ let builder = match record_type {
1087
+ RustRecordType::Json => builder.json(),
1088
+ RustRecordType::Proto | RustRecordType::Unspecified => {
1089
+ let desc = descriptor_proto.ok_or_else(|| {
1090
+ napi::Error::from_reason(
1091
+ "Proto record type requires descriptor_proto on TableProperties",
1092
+ )
1093
+ })?;
1094
+ builder.compiled_proto(desc)
1095
+ }
1096
+ };
1097
+
1098
+ let stream = builder.build().await.map_err(|e| {
1099
+ napi::Error::from_reason(format!("Failed to create stream: {}", e))
1100
+ })?;
1023
1101
 
1024
1102
  Ok(ZerobusStream {
1025
1103
  inner: Arc::new(Mutex::new(Some(stream))),
@@ -1039,7 +1117,8 @@ impl ZerobusSdk {
1039
1117
  ///
1040
1118
  /// # Arguments
1041
1119
  ///
1042
- /// * `stream` - The failed or closed stream to recreate
1120
+ /// * `stream` - The terminally failed stream to recreate. The TypeScript wrapper
1121
+ /// must not have been closed because `close()` releases its native handle.
1043
1122
  ///
1044
1123
  /// # Returns
1045
1124
  ///
@@ -1055,12 +1134,23 @@ impl ZerobusSdk {
1055
1134
  ///
1056
1135
  /// ```typescript
1057
1136
  /// try {
1058
- /// await stream.ingestRecords(batch);
1137
+ /// await stream.ingestRecordsOffset(batch);
1138
+ /// await stream.flush();
1059
1139
  /// } catch (error) {
1060
- /// await stream.close();
1061
- /// // Recreate stream with all unacked batches re-ingested
1062
- /// const newStream = await sdk.recreateStream(stream);
1063
- /// // Continue ingesting with newStream
1140
+ /// try {
1141
+ /// const newStream = await sdk.recreateStream(stream);
1142
+ /// try {
1143
+ /// await newStream.flush();
1144
+ /// } finally {
1145
+ /// await newStream.close();
1146
+ /// }
1147
+ /// } finally {
1148
+ /// try {
1149
+ /// await stream.close();
1150
+ /// } catch (closeError) {
1151
+ /// console.error("Failed stream released:", closeError);
1152
+ /// }
1153
+ /// }
1064
1154
  /// }
1065
1155
  /// ```
1066
1156
  #[napi]
@@ -1091,31 +1181,24 @@ fn base64_decode(input: &str) -> std::result::Result<Vec<u8>, String> {
1091
1181
  }
1092
1182
 
1093
1183
  // =============================================================================
1094
- // Arrow Flight Support (Experimental/Unsupported)
1184
+ // Arrow Flight Support (Beta)
1095
1185
  // Enabled with feature flag: cargo build --features arrow-flight
1096
1186
  // =============================================================================
1097
1187
 
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
1188
  #[cfg(feature = "arrow-flight")]
1111
1189
  use arrow_ipc::writer::StreamWriter;
1112
1190
  #[cfg(feature = "arrow-flight")]
1113
- use std::io::Cursor;
1191
+ use bytes::Bytes;
1192
+ #[cfg(feature = "arrow-flight")]
1193
+ use databricks_zerobus_ingest_sdk::{
1194
+ ArrowSchema as RustArrowSchema, DataType as RustDataType, Field as RustField,
1195
+ RecordBatch as RustRecordBatch, ZerobusArrowStream as RustZerobusArrowStream,
1196
+ };
1114
1197
 
1115
1198
  /// IPC compression type for Arrow Flight streams.
1116
1199
  ///
1117
- /// **Experimental/Unsupported**: Arrow Flight support is experimental and not yet
1118
- /// supported for production use. The API may change in future releases.
1200
+ /// **Beta**: Arrow Flight support is in Beta. The API is stabilising but
1201
+ /// may still change before reaching GA.
1119
1202
  #[cfg(feature = "arrow-flight")]
1120
1203
  #[napi]
1121
1204
  pub enum IpcCompressionType {
@@ -1127,8 +1210,8 @@ pub enum IpcCompressionType {
1127
1210
 
1128
1211
  /// Configuration options for Arrow Flight streams.
1129
1212
  ///
1130
- /// **Experimental/Unsupported**: Arrow Flight support is experimental and not yet
1131
- /// supported for production use. The API may change in future releases.
1213
+ /// **Beta**: Arrow Flight support is in Beta. The API is stabilising but
1214
+ /// may still change before reaching GA.
1132
1215
  #[cfg(feature = "arrow-flight")]
1133
1216
  #[napi(object)]
1134
1217
  #[derive(Debug, Clone)]
@@ -1169,34 +1252,23 @@ pub struct ArrowStreamConfigurationOptions {
1169
1252
  pub ipc_compression: Option<i32>,
1170
1253
  }
1171
1254
 
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
- };
1255
+ // Rust SDK 2.0 `ArrowStreamConfigurationOptions` is `#[non_exhaustive]` and
1256
+ // cannot be constructed via struct literal from this crate. Arrow options are
1257
+ // applied via setters on `sdk.stream_builder()` inside `create_arrow_stream`
1258
+ // below.
1182
1259
 
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
- }
1260
+ #[cfg(feature = "arrow-flight")]
1261
+ fn map_ipc_compression(value: Option<i32>) -> Option<arrow_ipc::CompressionType> {
1262
+ match value {
1263
+ Some(0) => Some(arrow_ipc::CompressionType::LZ4_FRAME),
1264
+ Some(1) => Some(arrow_ipc::CompressionType::ZSTD),
1265
+ _ => None,
1194
1266
  }
1195
1267
  }
1196
1268
 
1197
1269
  /// Arrow data type enum for schema definition.
1198
1270
  ///
1199
- /// **Experimental/Unsupported**: Arrow Flight support is experimental.
1271
+ /// **Beta**: Arrow Flight support is in Beta.
1200
1272
  #[cfg(feature = "arrow-flight")]
1201
1273
  #[napi]
1202
1274
  pub enum ArrowDataType {
@@ -1268,7 +1340,7 @@ fn convert_arrow_data_type(dt: i32) -> RustDataType {
1268
1340
 
1269
1341
  /// Arrow field definition for schema.
1270
1342
  ///
1271
- /// **Experimental/Unsupported**: Arrow Flight support is experimental.
1343
+ /// **Beta**: Arrow Flight support is in Beta.
1272
1344
  #[cfg(feature = "arrow-flight")]
1273
1345
  #[napi(object)]
1274
1346
  #[derive(Debug, Clone)]
@@ -1286,8 +1358,8 @@ pub struct ArrowField {
1286
1358
  /// Unlike `TableProperties` which uses Protocol Buffers, Arrow Flight streams
1287
1359
  /// require an Arrow schema definition.
1288
1360
  ///
1289
- /// **Experimental/Unsupported**: Arrow Flight support is experimental and not yet
1290
- /// supported for production use. The API may change in future releases.
1361
+ /// **Beta**: Arrow Flight support is in Beta. The API is stabilising but
1362
+ /// may still change before reaching GA.
1291
1363
  #[cfg(feature = "arrow-flight")]
1292
1364
  #[napi(object)]
1293
1365
  #[derive(Debug, Clone)]
@@ -1298,24 +1370,22 @@ pub struct ArrowTableProperties {
1298
1370
  pub schema_fields: Vec<ArrowField>,
1299
1371
  }
1300
1372
 
1373
+ // Rust SDK 2.0 made `ArrowTableProperties` non-public; the table name and
1374
+ // schema are passed to `sdk.stream_builder().table(...).arrow(schema)`
1375
+ // directly. This helper builds just the Arrow `Schema`.
1301
1376
  #[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| {
1377
+ fn build_arrow_schema(fields: &[ArrowField]) -> Arc<RustArrowSchema> {
1378
+ let rust_fields: Vec<RustField> = fields
1379
+ .iter()
1380
+ .map(|f| {
1305
1381
  RustField::new(
1306
1382
  &f.name,
1307
1383
  convert_arrow_data_type(f.data_type),
1308
1384
  f.nullable.unwrap_or(true),
1309
1385
  )
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
1386
  })
1318
- }
1387
+ .collect();
1388
+ Arc::new(RustArrowSchema::new(rust_fields))
1319
1389
  }
1320
1390
 
1321
1391
  /// An Arrow Flight stream for ingesting Arrow RecordBatches into a Delta table.
@@ -1323,8 +1393,8 @@ impl ArrowTableProperties {
1323
1393
  /// This stream provides a high-performance interface for streaming Arrow data
1324
1394
  /// to Databricks Delta tables using the Arrow Flight protocol.
1325
1395
  ///
1326
- /// **Experimental/Unsupported**: Arrow Flight support is experimental and not yet
1327
- /// supported for production use. The API may change in future releases.
1396
+ /// **Beta**: Arrow Flight support is in Beta. The API is stabilising but
1397
+ /// may still change before reaching GA.
1328
1398
  ///
1329
1399
  /// # Lifecycle
1330
1400
  ///
@@ -1358,26 +1428,6 @@ pub struct ZerobusArrowStream {
1358
1428
  schema: Arc<RustArrowSchema>,
1359
1429
  }
1360
1430
 
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
1431
  /// Helper to serialize RecordBatch to Arrow IPC buffer
1382
1432
  #[cfg(feature = "arrow-flight")]
1383
1433
  fn serialize_batch_to_ipc(batch: &RustRecordBatch) -> Result<Vec<u8>> {
@@ -1385,9 +1435,11 @@ fn serialize_batch_to_ipc(batch: &RustRecordBatch) -> Result<Vec<u8>> {
1385
1435
  {
1386
1436
  let mut writer = StreamWriter::try_new(&mut buffer, batch.schema().as_ref())
1387
1437
  .map_err(|e| Error::from_reason(format!("Failed to create Arrow IPC writer: {}", e)))?;
1388
- writer.write(batch)
1438
+ writer
1439
+ .write(batch)
1389
1440
  .map_err(|e| Error::from_reason(format!("Failed to write batch to IPC: {}", e)))?;
1390
- writer.finish()
1441
+ writer
1442
+ .finish()
1391
1443
  .map_err(|e| Error::from_reason(format!("Failed to finish IPC stream: {}", e)))?;
1392
1444
  }
1393
1445
  Ok(buffer)
@@ -1423,29 +1475,28 @@ impl ZerobusArrowStream {
1423
1475
  /// ```
1424
1476
  #[napi(ts_return_type = "Promise<bigint>")]
1425
1477
  pub fn ingest_batch(&self, env: Env, ipc_buffer: Buffer) -> Result<JsObject> {
1426
- let schema = self.schema.clone();
1478
+ // The Rust SDK's `ingest_ipc_batch` materialises the bytes into a
1479
+ // `RecordBatch`, rejects multi-batch streams, and validates the schema
1480
+ // against the stream's schema. No need to duplicate any of that here.
1427
1481
  let stream = self.inner.clone();
1428
1482
  let buffer_vec = ipc_buffer.to_vec();
1429
1483
 
1430
1484
  env.execute_tokio_future(
1431
1485
  async move {
1432
- let batch = parse_arrow_ipc_to_batch(&buffer_vec, &schema)?;
1433
-
1434
1486
  let mut guard = stream.lock().await;
1435
1487
  let stream_ref = guard
1436
1488
  .as_mut()
1437
1489
  .ok_or_else(|| napi::Error::from_reason("Arrow stream has been closed"))?;
1438
1490
 
1439
1491
  stream_ref
1440
- .ingest_batch(batch)
1492
+ .ingest_ipc_batch(Bytes::from(buffer_vec))
1441
1493
  .await
1442
1494
  .map_err(|e| napi::Error::from_reason(format!("Failed to ingest batch: {}", e)))
1443
1495
  },
1444
1496
  |env, offset_id| {
1445
- let offset_str = offset_id.to_string();
1446
1497
  let global: JsGlobal = env.get_global()?;
1447
1498
  let bigint_ctor: JsFunction = global.get_named_property("BigInt")?;
1448
- let js_str = env.create_string(&offset_str)?;
1499
+ let js_str = env.create_string(&offset_id.to_string())?;
1449
1500
  bigint_ctor.call(None, &[js_str.into_unknown()])
1450
1501
  },
1451
1502
  )
@@ -1458,12 +1509,9 @@ impl ZerobusArrowStream {
1458
1509
  /// # Arguments
1459
1510
  ///
1460
1511
  /// * `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()?;
1512
+ #[napi(ts_return_type = "Promise<void>")]
1513
+ pub fn wait_for_offset(&self, env: Env, offset_id: BigInt) -> Result<JsObject> {
1514
+ let offset = bigint_to_i64(offset_id)?;
1467
1515
 
1468
1516
  let stream = self.inner.clone();
1469
1517
 
@@ -1474,10 +1522,9 @@ impl ZerobusArrowStream {
1474
1522
  .as_ref()
1475
1523
  .ok_or_else(|| napi::Error::from_reason("Arrow stream has been closed"))?;
1476
1524
 
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)))
1525
+ stream_ref.wait_for_offset(offset).await.map_err(|e| {
1526
+ napi::Error::from_reason(format!("Failed to wait for offset: {}", e))
1527
+ })
1481
1528
  },
1482
1529
  |_env, _| Ok(()),
1483
1530
  )
@@ -1523,7 +1570,9 @@ impl ZerobusArrowStream {
1523
1570
  /// Returns the table name for this stream.
1524
1571
  #[napi(getter)]
1525
1572
  pub fn table_name(&self) -> Result<String> {
1526
- let guard = self.inner.try_lock()
1573
+ let guard = self
1574
+ .inner
1575
+ .try_lock()
1527
1576
  .map_err(|_| Error::from_reason("Stream is busy"))?;
1528
1577
  let stream = guard
1529
1578
  .as_ref()
@@ -1568,8 +1617,8 @@ impl ZerobusArrowStream {
1568
1617
  impl ZerobusSdk {
1569
1618
  /// Creates a new Arrow Flight stream to a Delta table.
1570
1619
  ///
1571
- /// **Experimental/Unsupported**: Arrow Flight support is experimental and not yet
1572
- /// supported for production use. The API may change in future releases.
1620
+ /// **Beta**: Arrow Flight support is in Beta. The API is stabilising
1621
+ /// but may still change before reaching GA.
1573
1622
  ///
1574
1623
  /// This method establishes an Arrow Flight connection to the Zerobus service
1575
1624
  /// for high-performance columnar data ingestion.
@@ -1613,22 +1662,69 @@ impl ZerobusSdk {
1613
1662
  client_secret: String,
1614
1663
  options: Option<ArrowStreamConfigurationOptions>,
1615
1664
  ) -> 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());
1665
+ // Rust SDK 2.0 removed the convenience `create_arrow_stream` method;
1666
+ // open via `sdk.stream_builder().table(...).oauth(...).arrow(schema)
1667
+ // .build_arrow()` and apply options via setters. `ArrowTableProperties`
1668
+ // is also private now, so we build just the schema here.
1669
+ let schema = build_arrow_schema(&table_properties.schema_fields);
1670
+ let table_name = table_properties.table_name.clone();
1671
+ let opts = options.unwrap_or(ArrowStreamConfigurationOptions {
1672
+ max_inflight_batches: None,
1673
+ recovery: None,
1674
+ recovery_timeout_ms: None,
1675
+ recovery_backoff_ms: None,
1676
+ recovery_retries: None,
1677
+ server_lack_of_ack_timeout_ms: None,
1678
+ flush_timeout_ms: None,
1679
+ connection_timeout_ms: None,
1680
+ ipc_compression: None,
1681
+ });
1682
+ let ipc_compression = map_ipc_compression(opts.ipc_compression);
1683
+ let schema_for_stream = schema.clone();
1619
1684
 
1620
1685
  let sdk = self.inner.clone();
1621
1686
 
1622
1687
  env.execute_tokio_future(
1623
1688
  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)))?;
1689
+ let mut builder = sdk
1690
+ .stream_builder()
1691
+ .table(table_name)
1692
+ .oauth(client_id, client_secret)
1693
+ .arrow(schema.clone());
1694
+
1695
+ if let Some(v) = opts.max_inflight_batches {
1696
+ builder = builder.max_inflight_batches(v as usize);
1697
+ }
1698
+ if let Some(v) = opts.recovery {
1699
+ builder = builder.recovery(v);
1700
+ }
1701
+ if let Some(v) = opts.recovery_timeout_ms {
1702
+ builder = builder.recovery_timeout_ms(v as u64);
1703
+ }
1704
+ if let Some(v) = opts.recovery_backoff_ms {
1705
+ builder = builder.recovery_backoff_ms(v as u64);
1706
+ }
1707
+ if let Some(v) = opts.recovery_retries {
1708
+ builder = builder.recovery_retries(v);
1709
+ }
1710
+ if let Some(v) = opts.server_lack_of_ack_timeout_ms {
1711
+ builder = builder.server_lack_of_ack_timeout_ms(v as u64);
1712
+ }
1713
+ if let Some(v) = opts.flush_timeout_ms {
1714
+ builder = builder.flush_timeout_ms(v as u64);
1715
+ }
1716
+ if let Some(v) = opts.connection_timeout_ms {
1717
+ builder = builder.connection_timeout_ms(v as u64);
1718
+ }
1719
+ builder = builder.ipc_compression(ipc_compression);
1720
+
1721
+ let stream = builder.build_arrow().await.map_err(|e| {
1722
+ napi::Error::from_reason(format!("Failed to create arrow stream: {}", e))
1723
+ })?;
1628
1724
 
1629
1725
  Ok(ZerobusArrowStream {
1630
1726
  inner: Arc::new(Mutex::new(Some(stream))),
1631
- schema,
1727
+ schema: schema_for_stream,
1632
1728
  })
1633
1729
  },
1634
1730
  |_env, stream| Ok(stream),
@@ -1637,17 +1733,21 @@ impl ZerobusSdk {
1637
1733
 
1638
1734
  /// Recreates an Arrow stream with the same configuration and re-ingests unacknowledged batches.
1639
1735
  ///
1640
- /// **Experimental/Unsupported**: Arrow Flight support is experimental.
1736
+ /// **Beta**: Arrow Flight support is in Beta.
1641
1737
  ///
1642
1738
  /// # Arguments
1643
1739
  ///
1644
- /// * `stream` - The failed or closed Arrow stream to recreate
1740
+ /// * `stream` - The terminally failed Arrow stream to recreate. The TypeScript wrapper
1741
+ /// must not have been closed because `close()` releases its native handle.
1645
1742
  ///
1646
1743
  /// # Returns
1647
1744
  ///
1648
1745
  /// A Promise that resolves to a new ZerobusArrowStream with all unacknowledged batches re-ingested.
1649
1746
  #[napi]
1650
- pub async fn recreate_arrow_stream(&self, stream: &ZerobusArrowStream) -> Result<ZerobusArrowStream> {
1747
+ pub async fn recreate_arrow_stream(
1748
+ &self,
1749
+ stream: &ZerobusArrowStream,
1750
+ ) -> Result<ZerobusArrowStream> {
1651
1751
  let inner_guard = stream.inner.lock().await;
1652
1752
  let rust_stream = inner_guard
1653
1753
  .as_ref()