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.
- package/README.md +267 -0
- package/index.d.ts +7487 -0
- package/index.js +685 -0
- package/package.json +50 -0
- package/record_batch_forwarder.js +20 -0
- package/streaming-session.d.ts +266 -0
- package/streaming-session.js +789 -0
|
@@ -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
|
+
});
|