thetadatadx 13.0.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.
@@ -0,0 +1,789 @@
1
+ /**
2
+ * `await using session = await client.streaming(callback)` (TC39 explicit
3
+ * resource management) wrapper for the Client FPSS streaming
4
+ * lifecycle.
5
+ *
6
+ * On `[Symbol.asyncDispose]`, `stopStreaming()` is called and then the
7
+ * async ring-drain barrier `awaitDrain(5000)` is awaited, so the
8
+ * streaming consumer thread has stopped and enqueues no further events
9
+ * before the JS callback closure is released. This mirrors the C++ RAII
10
+ * destructor in `thetadatadx-cpp/src/thetadatadx.cpp`.
11
+ *
12
+ * Delivery caveat (napi-specific, differs from Python/C++): the per-event
13
+ * callback runs on the Node main thread, delivered through a bounded
14
+ * threadsafe-function queue that sits DOWNSTREAM of the consumer thread.
15
+ * `awaitDrain` waits on the consumer thread, not on that delivery queue,
16
+ * so a bounded backlog of already-queued events can still invoke the
17
+ * callback on later event-loop turns AFTER the disposer resolves. napi
18
+ * delivers that backlog in throttled bursts across event-loop turns, so
19
+ * no finite wait in this wrapper can guarantee it has fully flushed. In
20
+ * the Python and C++ bindings the callback runs on the consumer thread
21
+ * itself, so their barrier is exact; here it is not. Do not release state
22
+ * the callback dereferences at scope exit assuming zero further
23
+ * invocations -- guard the callback to no-op after teardown if a late
24
+ * invocation against freed state would be unsafe.
25
+ *
26
+ * Every public method on `Client` (subscribe_*,
27
+ * unsubscribe_*, activeSubscriptions, droppedEventCount, reconnect,
28
+ * ...) is reachable on the session via a `Proxy` `get` trap. There is
29
+ * no hand-listed mirror of subscription methods here -- adding a new
30
+ * method to the napi-rs `Client` makes it callable through the
31
+ * session automatically, with zero drift surface.
32
+ *
33
+ * Loaded as the package's CommonJS entry point (`main` in
34
+ * `package.json`) so consumers get the wrapper when they
35
+ * `require('thetadatadx')`. The native loader in `index.js` is
36
+ * auto-generated by napi-rs and re-exported untouched.
37
+ */
38
+
39
+ 'use strict';
40
+
41
+ const native = require('./index.js');
42
+ const { wrapStreamViewBatches } = require('./record_batch_forwarder.js');
43
+
44
+ // ── Typed error hierarchy ─────────────────────────────────────────────
45
+ //
46
+ // The Rust napi binding (`to_napi_err` in `thetadatadx-ts/src/lib.rs`)
47
+ // surfaces every `thetadatadx::Error` as a `napi::Error` whose
48
+ // `reason` carries a `[ClassName] ...` prefix. The JS interceptor
49
+ // below parses that prefix and re-throws the matching subclass so
50
+ // callers can write `catch (e) { if (e instanceof thetadatadx.SubscriptionError)
51
+ // { ... } }` rather than substring-matching the message.
52
+ //
53
+ // The canonical leaf set (`NotFoundError`, `DeadlineExceededError`,
54
+ // `UnavailableError`, `InvalidParameterError`, ...) is identical to the
55
+ // Python, C++, and C ABI leaf sets, so a `catch` clause ports across
56
+ // bindings by class name. Python additionally ships two back-compat
57
+ // aliases (`NoDataFoundError` / `TimeoutError`) that do not exist here.
58
+ //
59
+ // A rate limit carrying a server back-off hint widens the prefix to
60
+ // `[RateLimitError retry_after_ms=N] ...`; the interceptor parses the
61
+ // hint off and seats it as a `retryAfter` (seconds) property on the
62
+ // thrown `RateLimitError`.
63
+
64
+ class ThetaDataError extends Error {
65
+ constructor(message) {
66
+ super(message);
67
+ this.name = 'ThetaDataError';
68
+ }
69
+ }
70
+ class AuthenticationError extends ThetaDataError {
71
+ constructor(message) {
72
+ super(message);
73
+ this.name = 'AuthenticationError';
74
+ }
75
+ }
76
+ class InvalidCredentialsError extends AuthenticationError {
77
+ constructor(message) {
78
+ super(message);
79
+ this.name = 'InvalidCredentialsError';
80
+ }
81
+ }
82
+ class SubscriptionError extends ThetaDataError {
83
+ constructor(message) {
84
+ super(message);
85
+ this.name = 'SubscriptionError';
86
+ }
87
+ }
88
+ class RateLimitError extends ThetaDataError {
89
+ constructor(message) {
90
+ super(message);
91
+ this.name = 'RateLimitError';
92
+ // Server-supplied minimum back-off in seconds, parsed from the
93
+ // upstream `google.rpc.RetryInfo` hint, or `null` when none was
94
+ // supplied. Always present so callers can read it unconditionally.
95
+ this.retryAfter = null;
96
+ }
97
+ }
98
+ class InvalidParameterError extends ThetaDataError {
99
+ constructor(message) {
100
+ super(message);
101
+ this.name = 'InvalidParameterError';
102
+ }
103
+ }
104
+ class NotFoundError extends ThetaDataError {
105
+ constructor(message) {
106
+ super(message);
107
+ this.name = 'NotFoundError';
108
+ }
109
+ }
110
+ class DeadlineExceededError extends ThetaDataError {
111
+ constructor(message) {
112
+ super(message);
113
+ this.name = 'DeadlineExceededError';
114
+ }
115
+ }
116
+ class UnavailableError extends ThetaDataError {
117
+ constructor(message) {
118
+ super(message);
119
+ this.name = 'UnavailableError';
120
+ }
121
+ }
122
+ class NetworkError extends ThetaDataError {
123
+ constructor(message) {
124
+ super(message);
125
+ this.name = 'NetworkError';
126
+ }
127
+ }
128
+ class SchemaMismatchError extends ThetaDataError {
129
+ constructor(message) {
130
+ super(message);
131
+ this.name = 'SchemaMismatchError';
132
+ }
133
+ }
134
+ class StreamError extends ThetaDataError {
135
+ constructor(message) {
136
+ super(message);
137
+ this.name = 'StreamError';
138
+ }
139
+ }
140
+ class ConfigError extends ThetaDataError {
141
+ constructor(message) {
142
+ super(message);
143
+ this.name = 'ConfigError';
144
+ }
145
+ }
146
+
147
+ const CLASS_BY_NAME = {
148
+ ThetaDataError,
149
+ AuthenticationError,
150
+ InvalidCredentialsError,
151
+ SubscriptionError,
152
+ RateLimitError,
153
+ InvalidParameterError,
154
+ NotFoundError,
155
+ DeadlineExceededError,
156
+ UnavailableError,
157
+ NetworkError,
158
+ SchemaMismatchError,
159
+ StreamError,
160
+ ConfigError,
161
+ };
162
+
163
+ // `[ClassName] message` or `[ClassName retry_after_ms=N] message`. The
164
+ // optional `retry_after_ms` group carries the rate-limit back-off hint
165
+ // the Rust shim widens the prefix with; it is parsed off and surfaced
166
+ // as `retryAfter` (seconds) on the thrown `RateLimitError`.
167
+ const PREFIX_RE = /^\[([A-Za-z]+Error)(?:\s+retry_after_ms=(\d+))?\]\s*(.*)$/s;
168
+
169
+ /**
170
+ * Re-cast a napi-thrown `Error` as the matching typed subclass when
171
+ * the reason carries the `[ClassName] ...` prefix the Rust shim
172
+ * emits. Anything that doesn't match the prefix surfaces unchanged
173
+ * — preserves the existing `Error` shape for failures from the
174
+ * generic napi machinery (e.g. argument coercion failures inside
175
+ * the bindings).
176
+ */
177
+ function rethrowTyped(err) {
178
+ if (!(err instanceof Error) || typeof err.message !== 'string') {
179
+ throw err;
180
+ }
181
+ const match = PREFIX_RE.exec(err.message);
182
+ if (!match) {
183
+ throw err;
184
+ }
185
+ const [, className, retryAfterMs, payload] = match;
186
+ const Cls = CLASS_BY_NAME[className];
187
+ if (!Cls) {
188
+ throw err;
189
+ }
190
+ const typed = new Cls(payload);
191
+ // Seat the rate-limit back-off (ms → seconds) when the prefix carried
192
+ // a `retry_after_ms` hint, so callers can read `err.retryAfter`.
193
+ if (retryAfterMs !== undefined && typed instanceof RateLimitError) {
194
+ typed.retryAfter = Number(retryAfterMs) / 1000;
195
+ }
196
+ // Preserve the stack from the napi-thrown Error so the typed
197
+ // re-throw still carries the original call site.
198
+ typed.stack = err.stack;
199
+ throw typed;
200
+ }
201
+
202
+ // Drain timeout applied on `[Symbol.asyncDispose]`. Matches the C++
203
+ // destructor's 5 s budget in `thetadatadx-cpp/src/thetadatadx.cpp` and the FFI
204
+ // free-path budget in `thetadatadx-ffi/src/streaming.rs::FREE_DRAIN_TIMEOUT`.
205
+ // Cross-binding parity matters more than tunability here -- a slow
206
+ // JS callback that needs more than 5 s to drain is already a contract
207
+ // violation worth surfacing.
208
+ const EXIT_DRAIN_TIMEOUT_MS = 5000;
209
+
210
+ // Wrapper-defined member names. Lookups for these resolve on the
211
+ // session itself; everything else proxies to the underlying
212
+ // `Client`. Symbol.asyncDispose is included so `await using`
213
+ // finds the wrapper's dispose, not anything on the native binding.
214
+ const WRAPPER_OWN = new Set(['_client', 'constructor']);
215
+
216
+ function isClosedClientError(err) {
217
+ return err instanceof Error && /client is closed/.test(err.message);
218
+ }
219
+
220
+ function closedClientError() {
221
+ return new StreamError(
222
+ 'client is closed; construct a new client to make further calls',
223
+ );
224
+ }
225
+
226
+ // apache-arrow is loaded lazily so it is only required by callers that
227
+ // actually consume the columnar `batches()` reader. The per-tick
228
+ // `*ToArrowIpc` exports likewise hand back raw IPC buffers and leave the
229
+ // apache-arrow decode to the caller; the reader decodes here so the user
230
+ // sees native apache-arrow `RecordBatch` values straight from `for await`.
231
+ let _arrow = null;
232
+ function loadArrow() {
233
+ if (_arrow === null) {
234
+ try {
235
+ // eslint-disable-next-line global-require
236
+ _arrow = require('apache-arrow');
237
+ } catch (e) {
238
+ throw new Error(
239
+ "the streaming `batches()` reader decodes Arrow IPC with apache-arrow; " +
240
+ "install it as a peer dependency (`npm install apache-arrow`). " +
241
+ `Underlying error: ${e && e.message ? e.message : e}`,
242
+ );
243
+ }
244
+ }
245
+ return _arrow;
246
+ }
247
+
248
+ /**
249
+ * Pull-based columnar reader over the live stream — a sibling to the
250
+ * per-event `startStreaming(callback)`.
251
+ *
252
+ * `for await (const batch of reader)` yields apache-arrow `RecordBatch`
253
+ * values under a fixed schema (concatenate them freely). The reader is an
254
+ * `AsyncIterable` and a TC39 async-disposable: `await using reader = ...`
255
+ * closes it (unsubscribe + tear down) on scope exit, or call `close()`.
256
+ *
257
+ * Wraps the native `RecordBatchStreamHandle`, which crosses the napi
258
+ * boundary as Arrow IPC buffers; this class decodes each with
259
+ * `apache-arrow.tableFromIPC` so the public `RecordBatch` type is the
260
+ * native apache-arrow one.
261
+ */
262
+ class RecordBatchStream {
263
+ constructor(handle) {
264
+ this._handle = handle;
265
+ this._schema = undefined;
266
+ }
267
+
268
+ /**
269
+ * The fixed Arrow schema every yielded batch carries, as an
270
+ * apache-arrow `Schema`. Decoded once from the schema-only IPC buffer.
271
+ */
272
+ get schema() {
273
+ if (this._schema === undefined) {
274
+ const arrow = loadArrow();
275
+ const ipc = this._handle.schemaIpc();
276
+ // A schema-only IPC stream decodes to a zero-batch Table whose
277
+ // `.schema` is the fixed schema.
278
+ this._schema = arrow.tableFromIPC(ipc).schema;
279
+ }
280
+ return this._schema;
281
+ }
282
+
283
+ /** Batches dropped so far under the `dropOldest` policy; `0` under `block`. */
284
+ get dropped() {
285
+ return this._handle.dropped;
286
+ }
287
+
288
+ /**
289
+ * Close the reader: unsubscribe and tear the FPSS session down.
290
+ * Idempotent; further iteration ends.
291
+ */
292
+ close() {
293
+ this._handle.close();
294
+ }
295
+
296
+ async *[Symbol.asyncIterator]() {
297
+ const arrow = loadArrow();
298
+ try {
299
+ for (;;) {
300
+ // eslint-disable-next-line no-await-in-loop
301
+ const ipc = await this._handle.nextIpc();
302
+ if (ipc === null || ipc === undefined) {
303
+ return;
304
+ }
305
+ // One batch per IPC buffer (the reader writes exactly one). Decode
306
+ // to a Table and yield its single RecordBatch.
307
+ const table = arrow.tableFromIPC(ipc);
308
+ for (const batch of table.batches) {
309
+ yield batch;
310
+ }
311
+ }
312
+ } finally {
313
+ // Iteration ended (consumed, broke out, or threw): release the
314
+ // session deterministically, mirroring the `await using` dispose.
315
+ this._handle.close();
316
+ }
317
+ }
318
+
319
+ async [Symbol.asyncDispose]() {
320
+ this._handle.close();
321
+ }
322
+ }
323
+
324
+ /**
325
+ * The `client.stream` view, or `null` when the client is closed. A closed
326
+ * client throws on the `stream` getter ("client is closed"); every caller here
327
+ * treats that single `null` sentinel as "no streaming surface" so operating on
328
+ * a closed client stays a no-op rather than throwing, matching the base
329
+ * client's asyncDispose guard.
330
+ *
331
+ * @param {InstanceType<typeof native.Client>} client
332
+ * @returns {any}
333
+ */
334
+ function streamOrNull(client) {
335
+ try {
336
+ return client.stream;
337
+ } catch {
338
+ return null;
339
+ }
340
+ }
341
+
342
+ function streamOrClosed(client) {
343
+ try {
344
+ return { stream: client.stream, closed: false };
345
+ } catch (err) {
346
+ if (isClosedClientError(err)) {
347
+ return { stream: null, closed: true };
348
+ }
349
+ return { stream: null, closed: false };
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Resolve the object that carries the streaming lifecycle methods
355
+ * (`stopStreaming` / `awaitDrain`) for either client kind, or null when
356
+ * there is nothing live to tear down.
357
+ *
358
+ * The unified `Client` exposes streaming under a `.stream` sub-view; the
359
+ * standalone `StreamingClient` IS the streaming surface, with the same
360
+ * methods directly on it. `streamOrNull` alone returns `undefined` for a
361
+ * standalone client (it has no `.stream`), which is why the session disposer
362
+ * used to silently tear nothing down while the standalone stream kept
363
+ * running. This resolver returns the `.stream` view for a unified client and
364
+ * the client itself for a standalone one.
365
+ *
366
+ * @param {any} client
367
+ * @returns {any}
368
+ */
369
+ function streamSurface(client) {
370
+ const view = streamOrNull(client);
371
+ if (view) return view;
372
+ // Standalone StreamingClient: the lifecycle methods live on the client.
373
+ if (
374
+ client &&
375
+ typeof client.stopStreaming === 'function' &&
376
+ typeof client.awaitDrain === 'function'
377
+ ) {
378
+ return client;
379
+ }
380
+ return null;
381
+ }
382
+
383
+ class StreamingSession {
384
+ /**
385
+ * Construct a session bound to a `Client` instance. Returns a
386
+ * Proxy that forwards every unknown attribute access to the
387
+ * underlying instance, so adding a `subscribe_X` method to the napi
388
+ * binding makes it callable through the session with no drift.
389
+ *
390
+ * @param {InstanceType<typeof native.Client>} client
391
+ * @returns {StreamingSession}
392
+ */
393
+ constructor(client) {
394
+ this._client = client;
395
+ return new Proxy(this, {
396
+ get(target, prop, receiver) {
397
+ // Wrapper-defined members (Symbol.asyncDispose, _client,
398
+ // constructor) take precedence; everything else forwards to
399
+ // the underlying Client instance.
400
+ if (WRAPPER_OWN.has(prop) || prop === Symbol.asyncDispose) {
401
+ return Reflect.get(target, prop, receiver);
402
+ }
403
+ const client = target._client;
404
+ // The unified client's streaming surface (subscribe, diagnostics,
405
+ // lifecycle) moved onto the `client.stream` sub-namespace view.
406
+ // Resolve a name there first so `session.subscribe(...)` and
407
+ // `session.activeSubscriptions()` keep working, then fall back to
408
+ // the methods that stay on `Client` (e.g. `sessionUuid`,
409
+ // `subscriptionInfo`, `activeFullSubscriptions`).
410
+ const { stream, closed } = streamOrClosed(client);
411
+ if (stream && prop in stream) {
412
+ const value = stream[prop];
413
+ return typeof value === 'function' ? value.bind(stream) : value;
414
+ }
415
+ if (!(prop in client) && closed && typeof prop === 'string') {
416
+ throw closedClientError();
417
+ }
418
+ const value = client[prop];
419
+ // Bind methods to the underlying instance so `this` resolves
420
+ // correctly when the caller invokes them.
421
+ return typeof value === 'function' ? value.bind(client) : value;
422
+ },
423
+ has(target, prop) {
424
+ if (WRAPPER_OWN.has(prop) || prop === Symbol.asyncDispose) return true;
425
+ const client = target._client;
426
+ const stream = streamOrNull(client);
427
+ return (stream && prop in stream) || prop in client;
428
+ },
429
+ });
430
+ }
431
+
432
+ /**
433
+ * TC39 explicit resource management: invoked by `await using session
434
+ * = ...` on scope exit. Stops the streaming connection and awaits the
435
+ * ring-drain barrier so the consumer thread has stopped and enqueues no
436
+ * further events before the JS closure is released. Mirrors the C++
437
+ * RAII destructor.
438
+ *
439
+ * NOT an exact "no callback after this resolves" barrier on the napi
440
+ * delivery path: already-queued events can still invoke the callback on
441
+ * later event-loop turns after this resolves (see the module-level
442
+ * delivery caveat). Python and C++ fire on the consumer thread, so their
443
+ * barrier is exact; this one covers the consumer thread only.
444
+ *
445
+ * Drain timeout failures fire `console.warn` rather than throwing;
446
+ * the streaming pipeline is already torn down (`stopStreaming` ran),
447
+ * and the drain barrier is best-effort observability. Throwing here
448
+ * would mask any error from the `using` block body.
449
+ *
450
+ * @returns {Promise<void>}
451
+ */
452
+ async [Symbol.asyncDispose]() {
453
+ // Disposing a closed client is a no-op: the `stream` getter throws once
454
+ // `close()` has run, and there is nothing left to drain. `streamSurface`
455
+ // resolves both the unified client (`.stream` view) and the standalone
456
+ // `StreamingClient` (methods on the client itself) so a session wrapping a
457
+ // standalone client actually tears the stream down instead of leaking it.
458
+ const stream = streamSurface(this._client);
459
+ if (!stream) return;
460
+ stream.stopStreaming();
461
+ const drained = await stream.awaitDrain(EXIT_DRAIN_TIMEOUT_MS);
462
+ if (!drained) {
463
+ console.warn(
464
+ `Client streaming drain timed out after ${EXIT_DRAIN_TIMEOUT_MS}ms; ` +
465
+ 'consumer callback may still be firing. The JS callback closure ' +
466
+ 'will remain referenced until the consumer exits.',
467
+ );
468
+ }
469
+ }
470
+ }
471
+
472
+ // Monkey-patch `streaming(callback)` onto the napi-generated
473
+ // `Client` class. Returning a `Promise<StreamingSession>` matches
474
+ // the spec example (`await using session = await client.streaming(cb)`)
475
+ // and leaves room for an async startup path in the future without an
476
+ // API break.
477
+ if (
478
+ native.Client &&
479
+ typeof native.Client.prototype.streaming !== 'function'
480
+ ) {
481
+ native.Client.prototype.streaming = async function streaming(callback) {
482
+ // Await the handshake: startStreaming is async, so a connect/auth
483
+ // failure must reject this Promise (and reach the caller's typed
484
+ // catch) rather than escape as an unhandled rejection, and the
485
+ // session must not be handed back before the stream is live.
486
+ await this.stream.startStreaming(callback);
487
+ return new StreamingSession(this);
488
+ };
489
+ }
490
+
491
+ // The same `streaming(callback)` factory on the standalone `StreamingClient`,
492
+ // which has no `.stream` sub-view — its lifecycle methods live directly on the
493
+ // client. Without this a standalone-client user could not open a context-managed
494
+ // `await using session = await streamingClient.streaming(cb)` session at all, so
495
+ // the `StreamingSession` disposer (now resolving the standalone surface via
496
+ // `streamSurface`) had nothing to attach to. Matches the Python standalone
497
+ // client's `streaming(...)` helper.
498
+ if (
499
+ native.StreamingClient &&
500
+ typeof native.StreamingClient.prototype.streaming !== 'function'
501
+ ) {
502
+ native.StreamingClient.prototype.streaming = async function streaming(callback) {
503
+ await this.startStreaming(callback);
504
+ return new StreamingSession(this);
505
+ };
506
+ }
507
+
508
+ // TC39 explicit resource management on the base clients. `close()` is the
509
+ // napi-generated deterministic teardown; these computed-symbol members cannot
510
+ // be emitted by napi-rs (it names only identifier methods), so they are patched
511
+ // on here — the same wrapper-side pattern as `streaming()` and the
512
+ // `StreamingSession` disposers. `[Symbol.dispose]` backs `using client = ...`
513
+ // (sync scope exit); `[Symbol.asyncDispose]` backs `await using client = ...`
514
+ // and additionally awaits the streaming drain barrier so a callback closure is
515
+ // safe to release, warning (never throwing) on timeout to match the session.
516
+ for (const Klass of [native.Client, native.MarketDataClient]) {
517
+ if (!Klass) continue;
518
+ if (typeof Klass.prototype[Symbol.dispose] !== 'function') {
519
+ Klass.prototype[Symbol.dispose] = function dispose() {
520
+ this.close();
521
+ };
522
+ }
523
+ if (typeof Klass.prototype[Symbol.asyncDispose] !== 'function') {
524
+ Klass.prototype[Symbol.asyncDispose] = async function asyncDispose() {
525
+ // `stream` exists only on the unified `Client`; the market-data-only
526
+ // client has no streaming surface, so `close()` alone is the teardown.
527
+ // An already-closed client throws on the `stream` getter ("client is
528
+ // closed"); treat that as nothing-to-drain so disposing a closed client
529
+ // stays a no-op rather than rejecting.
530
+ let stream;
531
+ try {
532
+ stream = this.stream;
533
+ } catch {
534
+ stream = undefined;
535
+ }
536
+ if (stream) {
537
+ // Await the ring-drain barrier first so the consumer thread has
538
+ // stopped and enqueues no further events before the JS closure is
539
+ // released; warn (never throw) on timeout to match the context-managed
540
+ // session. This covers the consumer thread only: already-queued events
541
+ // may still invoke the callback on later event-loop turns after this
542
+ // resolves (see the module-level delivery caveat).
543
+ stream.stopStreaming();
544
+ const drained = await stream.awaitDrain(EXIT_DRAIN_TIMEOUT_MS);
545
+ if (!drained) {
546
+ console.warn(
547
+ `Client close drain timed out after ${EXIT_DRAIN_TIMEOUT_MS}ms; ` +
548
+ 'the streaming consumer callback may still be firing.',
549
+ );
550
+ }
551
+ }
552
+ // Always run the deterministic close: it releases the registered
553
+ // callback back to V8 and is the single teardown the sync disposer also
554
+ // routes through. Skipping it would leak the callback reference on the
555
+ // `await using` path. Idempotent after the stop above.
556
+ this.close();
557
+ };
558
+ }
559
+ }
560
+
561
+ // TC39 explicit resource management on the standalone `StreamingClient`, whose
562
+ // terminal teardown is `stopStreaming()` (it has no separate `close()`; the
563
+ // stop clears the callback and retires the session). `[Symbol.dispose]` backs
564
+ // `using sc = StreamingClient.connect(...)`; `[Symbol.asyncDispose]` additionally
565
+ // awaits the ring-drain barrier (consumer thread stopped, no further events
566
+ // enqueued) before release, warning (never throwing) on timeout to match the
567
+ // unified client and the session. As on the other disposers this covers the
568
+ // consumer thread only -- already-queued events may still invoke the callback
569
+ // on later event-loop turns after it resolves (see the module-level caveat).
570
+ if (native.StreamingClient) {
571
+ const Klass = native.StreamingClient;
572
+ if (typeof Klass.prototype[Symbol.dispose] !== 'function') {
573
+ Klass.prototype[Symbol.dispose] = function dispose() {
574
+ this.stopStreaming();
575
+ };
576
+ }
577
+ if (typeof Klass.prototype[Symbol.asyncDispose] !== 'function') {
578
+ Klass.prototype[Symbol.asyncDispose] = async function asyncDispose() {
579
+ this.stopStreaming();
580
+ const drained = await this.awaitDrain(EXIT_DRAIN_TIMEOUT_MS);
581
+ if (!drained) {
582
+ console.warn(
583
+ `StreamingClient drain timed out after ${EXIT_DRAIN_TIMEOUT_MS}ms; ` +
584
+ 'the streaming consumer callback may still be firing.',
585
+ );
586
+ }
587
+ };
588
+ }
589
+ }
590
+
591
+
592
+ // Re-cast typed errors on every native entrypoint — instance method,
593
+ // static factory, or free function. The Rust binding tags every
594
+ // `thetadatadx::Error` with a `[ClassName] ...` prefix on the napi
595
+ // `reason`; this interceptor parses it and re-throws the matching
596
+ // subclass so the caller can branch on `instanceof`. Errors without
597
+ // the prefix (napi argument coercion, etc.) surface unchanged. The
598
+ // single helper keeps the sync-throw and async-rejection handling
599
+ // identical across every call shape — there is no second copy of the
600
+ // try/`rethrowTyped` logic to drift.
601
+ function installTypedErrorInterceptor(original, boundThis) {
602
+ return function typedErrorBoundary(...args) {
603
+ let result;
604
+ try {
605
+ result = original.apply(boundThis !== undefined ? boundThis : this, args);
606
+ } catch (err) {
607
+ rethrowTyped(err);
608
+ }
609
+ // napi-rs async methods return a Promise; intercept the
610
+ // rejection without altering the resolved value.
611
+ if (result && typeof result.then === 'function') {
612
+ return result.then(
613
+ (value) => value,
614
+ (err) => rethrowTyped(err),
615
+ );
616
+ }
617
+ return result;
618
+ };
619
+ }
620
+
621
+ // Intrinsic own props every function carries; never wrappable members.
622
+ const FUNCTION_INTRINSICS = new Set(['prototype', 'length', 'name', 'constructor']);
623
+
624
+ // `true` when `value` is a native class constructor rather than a free
625
+ // function. napi-rs emits both as plain (writable-`prototype`)
626
+ // functions, so the ES2015 `prototype` non-writability test does not
627
+ // apply; a class is distinguished structurally — it carries instance
628
+ // members on its prototype and/or static methods on itself, whereas a
629
+ // free function's prototype holds only `constructor` and it has no own
630
+ // statics.
631
+ function isNativeClass(value) {
632
+ if (typeof value !== 'function') return false;
633
+ const proto = value.prototype;
634
+ if (proto && Object.getOwnPropertyNames(proto).some((n) => n !== 'constructor')) {
635
+ return true;
636
+ }
637
+ return Object.getOwnPropertyNames(value).some(
638
+ (n) => !FUNCTION_INTRINSICS.has(n) && typeof value[n] === 'function',
639
+ );
640
+ }
641
+
642
+ // Re-cast typed errors thrown by a class's instance methods. Instance
643
+ // methods live on `klass.prototype` and napi-rs emits them as writable
644
+ // data properties, so they are patched in place. Static / factory
645
+ // methods cannot be patched this way — napi-rs seals them as
646
+ // non-writable, non-configurable own props of the constructor — so
647
+ // those are re-exposed through a subclass below.
648
+ function wrapInstanceMethods(klass) {
649
+ if (!klass || !klass.prototype) return;
650
+ for (const name of Object.getOwnPropertyNames(klass.prototype)) {
651
+ if (name === 'constructor') continue;
652
+ const desc = Object.getOwnPropertyDescriptor(klass.prototype, name);
653
+ if (!desc || typeof desc.value !== 'function') continue;
654
+ klass.prototype[name] = installTypedErrorInterceptor(desc.value);
655
+ }
656
+ }
657
+
658
+ // Re-expose a native class through a thin subclass whose static methods
659
+ // carry the typed-error interceptor. napi-rs seals factory statics
660
+ // (`Client.connectFromFile`, `Contract.option`, ...) as
661
+ // non-writable, non-configurable own props, so they cannot be patched
662
+ // in place; a subclass owns fresh static slots that shadow them while
663
+ // inheriting everything else. Each wrapped static invokes the native
664
+ // static with `this` bound to the native class, so the value it returns
665
+ // is a genuine native instance — `result instanceof NativeClass` stays
666
+ // true. `Symbol.hasInstance` defers to the native class so
667
+ // `x instanceof Exported` continues to recognise those native instances
668
+ // even though their prototype chain points at the native class, not the
669
+ // subclass. Returns the native class unchanged when it has no statics
670
+ // (nothing to re-expose), so non-factory classes keep their identity.
671
+ function wrapClassStatics(klass) {
672
+ const staticNames = Object.getOwnPropertyNames(klass).filter(
673
+ (n) => !FUNCTION_INTRINSICS.has(n) && typeof klass[n] === 'function',
674
+ );
675
+ if (staticNames.length === 0) return klass;
676
+
677
+ const Exported = class extends klass {};
678
+ for (const name of staticNames) {
679
+ const nativeStatic = klass[name];
680
+ Object.defineProperty(Exported, name, {
681
+ value: installTypedErrorInterceptor(nativeStatic, klass),
682
+ writable: true,
683
+ enumerable: false,
684
+ configurable: true,
685
+ });
686
+ }
687
+ // Native factory statics return native-class instances, never
688
+ // subclass instances; keep `instanceof Exported` recognising them.
689
+ Object.defineProperty(Exported, Symbol.hasInstance, {
690
+ value: (inst) => inst instanceof klass,
691
+ configurable: true,
692
+ });
693
+ // Preserve the readable class name (`extends` would otherwise leave
694
+ // the anonymous-class name empty).
695
+ Object.defineProperty(Exported, 'name', {
696
+ value: klass.name,
697
+ configurable: true,
698
+ });
699
+ return Exported;
700
+ }
701
+
702
+ // Every exported native class: patch its instance methods in place,
703
+ // then re-expose it (static-wrapped). Discovered structurally so a
704
+ // newly added native class is covered with no edit here. Aliased
705
+ // exports (`Contract` shares `ContractRef`'s constructor) are
706
+ // processed once per distinct native constructor — keyed by identity —
707
+ // so instance methods are not double-wrapped and every alias receives
708
+ // the same re-exposed subclass.
709
+ const exportedClasses = {};
710
+ const wrappedByNativeClass = new Map();
711
+ for (const name of Object.getOwnPropertyNames(native)) {
712
+ const value = native[name];
713
+ if (!isNativeClass(value)) continue;
714
+ let wrapped = wrappedByNativeClass.get(value);
715
+ if (wrapped === undefined) {
716
+ wrapInstanceMethods(value);
717
+ wrapped = wrapClassStatics(value);
718
+ wrappedByNativeClass.set(value, wrapped);
719
+ }
720
+ exportedClasses[name] = wrapped;
721
+ }
722
+
723
+ // Present `StreamView.batches(...)` as the columnar `AsyncIterable`
724
+ // reader: the native method returns a `RecordBatchStreamHandle` (Arrow IPC
725
+ // transport); wrap it in the JS `RecordBatchStream` so the user gets
726
+ // `for await (const batch of reader)` over apache-arrow `RecordBatch`
727
+ // values plus `close()` / `Symbol.asyncDispose`. Done here, after the
728
+ // generic typed-error wrapping above, so the handle's errors still
729
+ // reclassify. The native method stays `async`, so the patched method
730
+ // awaits and re-wraps. The forwarder lives in `record_batch_forwarder.js`
731
+ // (internal, not exported) so the test suite drives the real call shape
732
+ // against a stub native method rather than a reimplementation.
733
+ if (native.StreamView && native.StreamView.prototype) {
734
+ const nativeBatches = native.StreamView.prototype.batches;
735
+ if (typeof nativeBatches === 'function') {
736
+ native.StreamView.prototype.batches = wrapStreamViewBatches(
737
+ nativeBatches,
738
+ RecordBatchStream,
739
+ );
740
+ }
741
+ }
742
+
743
+ // Free functions (e.g. `calendarDayToArrowIpc`, `eodTickToArrowIpc`)
744
+ // are exported directly on the native binding, not on a class. Wrap
745
+ // every own function-valued export that is not a class so a
746
+ // `[ClassName] ...` error raised inside a free function reclassifies
747
+ // the same way it does on a method — one rule: any native export
748
+ // reclassifies typed errors.
749
+ const exportedFreeFns = {};
750
+ for (const name of Object.getOwnPropertyNames(native)) {
751
+ const value = native[name];
752
+ if (typeof value !== 'function' || isNativeClass(value)) continue;
753
+ exportedFreeFns[name] = installTypedErrorInterceptor(value);
754
+ }
755
+
756
+ // Re-export the entire native surface plus the StreamingSession class.
757
+ // `Object.assign` rather than spread so non-enumerable properties on
758
+ // the napi binding survive. The static-wrapped classes
759
+ // (`exportedClasses`) and reclassifying free functions
760
+ // (`exportedFreeFns`) shadow their raw native counterparts so every
761
+ // public entrypoint reaches the typed-error hierarchy.
762
+ //
763
+ // `Contract` aliases `ContractRef` — napi-rs exposes the fluent
764
+ // contract type under the `ContractRef` symbol because `Contract`
765
+ // already names the FPSS event-payload data class. The public-facing
766
+ // surface documented in the quickstart is `Contract.stock("AAPL")` /
767
+ // `Contract.option(...)`, so the alias makes the JS export agree with
768
+ // the docs without a second pyclass.
769
+ module.exports = Object.assign({}, native, exportedClasses, exportedFreeFns, {
770
+ StreamingSession,
771
+ // The JS-side columnar reader returned by `client.stream.batches(...)`.
772
+ RecordBatchStream,
773
+ // The documented `Contract` name resolves to the same static-wrapped
774
+ // export as `ContractRef`, so `Contract.option(...)` reclassifies too.
775
+ Contract: exportedClasses.ContractRef ?? native.ContractRef,
776
+ ThetaDataError,
777
+ AuthenticationError,
778
+ InvalidCredentialsError,
779
+ SubscriptionError,
780
+ RateLimitError,
781
+ InvalidParameterError,
782
+ NotFoundError,
783
+ DeadlineExceededError,
784
+ UnavailableError,
785
+ NetworkError,
786
+ SchemaMismatchError,
787
+ StreamError,
788
+ ConfigError,
789
+ });