@reddb-io/client 1.6.0 → 1.8.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/redwire.js CHANGED
@@ -46,6 +46,16 @@ export const MessageKind = Object.freeze({
46
46
  QueryBinary: 0x07,
47
47
  BulkInsertPrevalidated: 0x08,
48
48
  QueryWithParams: 0x28,
49
+ // Output/input streaming lifecycle (PRD #759). Mirrors
50
+ // `reddb_wire::redwire::frame::MessageKind` so the JS streaming surface
51
+ // talks the same multiplexed-stream vocabulary as the Rust server.
52
+ RowDescription: 0x24,
53
+ StreamEnd: 0x25,
54
+ OpenStream: 0x29,
55
+ OpenAck: 0x2A,
56
+ StreamChunk: 0x2B,
57
+ StreamError: 0x2C,
58
+ StreamCancel: 0x2D,
49
59
  })
50
60
 
51
61
  export const Features = Object.freeze({ PARAMS: 0x0000_0001 })
@@ -231,6 +241,7 @@ export class RedWireClient {
231
241
  this.session = session
232
242
  this.serverFeatures = serverFeatures >>> 0
233
243
  this.nextCorr = 1n
244
+ this.nextStream = 1
234
245
  this.closed = false
235
246
  }
236
247
 
@@ -382,6 +393,174 @@ export class RedWireClient {
382
393
  return { ok: true }
383
394
  }
384
395
 
396
+ /**
397
+ * Open a streaming read over RedWire. Sends `OpenStream` and returns an
398
+ * async iterable of typed frames (see streaming.js) plus a
399
+ * `cancel(reason)` that emits a `StreamCancel` for this stream_id. The
400
+ * `OpenAck` is consumed internally; rows arrive as `StreamChunk`s and
401
+ * the stream closes on `StreamEnd`. A `StreamError` rejects iteration.
402
+ *
403
+ * @param {{ sql?: string, cursor?: string }} opts
404
+ */
405
+ async streamSelect({ sql, cursor } = {}) {
406
+ if (cursor != null) {
407
+ throw new RedDBError(
408
+ 'STREAM_CURSOR_UNSUPPORTED',
409
+ 'resumable cursors are only available over the HTTP transport in this release',
410
+ )
411
+ }
412
+ const streamId = this.#stream()
413
+ const corr = this.#corr()
414
+ await this.#writeStreamFrame(MessageKind.OpenStream, corr, jsonBytes({ sql }), streamId)
415
+
416
+ const reader = this.reader
417
+ const client = this
418
+ return {
419
+ async *[Symbol.asyncIterator]() {
420
+ for (;;) {
421
+ const resp = await reader.next()
422
+ if (resp.streamId !== 0 && resp.streamId !== streamId) {
423
+ continue
424
+ }
425
+ if (resp.kind === MessageKind.OpenAck) {
426
+ continue
427
+ }
428
+ if (resp.kind === MessageKind.StreamChunk) {
429
+ const chunk = jsonOf(resp.payload) ?? {}
430
+ const rows = Array.isArray(chunk.rows) ? chunk.rows : []
431
+ for (const row of rows) {
432
+ yield { type: 'row', value: row }
433
+ }
434
+ continue
435
+ }
436
+ if (resp.kind === MessageKind.StreamEnd) {
437
+ const end = jsonOf(resp.payload) ?? {}
438
+ yield { type: 'end', value: end.stats ?? end }
439
+ return
440
+ }
441
+ if (resp.kind === MessageKind.StreamError || resp.kind === MessageKind.Error) {
442
+ const err = jsonOf(resp.payload) ?? {}
443
+ throw new RedDBError(
444
+ err.code || 'STREAM_ERROR',
445
+ err.message || new TextDecoder().decode(resp.payload),
446
+ err,
447
+ )
448
+ }
449
+ throw new RedDBError(
450
+ 'STREAM_PROTOCOL',
451
+ `unexpected frame in stream: ${KIND_NAME[resp.kind] ?? resp.kind}`,
452
+ )
453
+ }
454
+ },
455
+ async cancel(reason) {
456
+ await client.#cancelStream(streamId, reason)
457
+ },
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Open a streaming write over RedWire. The `OpenStream {direction:"in"}`
463
+ * frame is sent on the first `write()` (so columns can be inferred from
464
+ * the first row); each row is shipped as a one-row `StreamChunk` and the
465
+ * terminal chunk closes the input phase. `close()` resolves with the
466
+ * server's `StreamEnd` stats.
467
+ *
468
+ * @param {{ target: string, columns?: string[] }} opts
469
+ */
470
+ async streamInput({ target, columns } = {}) {
471
+ const streamId = this.#stream()
472
+ const corr = this.#corr()
473
+ const client = this
474
+ let opened = false
475
+ let seq = 0
476
+ let cols = Array.isArray(columns) && columns.length > 0 ? columns.slice() : null
477
+
478
+ const ensureOpen = async (row) => {
479
+ if (opened) return
480
+ if (!cols) {
481
+ cols = row && typeof row === 'object' ? Object.keys(row) : null
482
+ }
483
+ if (!cols || cols.length === 0) {
484
+ throw new RedDBError(
485
+ 'INVALID_STREAM_COLUMNS',
486
+ 'inputStream() needs a non-empty column set — pass { columns } or write at least one object row',
487
+ )
488
+ }
489
+ await client.#writeStreamFrame(
490
+ MessageKind.OpenStream,
491
+ corr,
492
+ jsonBytes({ direction: 'in', target, columns: cols }),
493
+ streamId,
494
+ )
495
+ const ack = await client.reader.next()
496
+ if (ack.kind === MessageKind.StreamError || ack.kind === MessageKind.Error) {
497
+ const err = jsonOf(ack.payload) ?? {}
498
+ throw new RedDBError(err.code || 'STREAM_ERROR', err.message || 'input stream refused', err)
499
+ }
500
+ if (ack.kind !== MessageKind.OpenAck) {
501
+ throw new RedDBError(
502
+ 'STREAM_PROTOCOL',
503
+ `expected OpenAck, got ${KIND_NAME[ack.kind] ?? ack.kind}`,
504
+ )
505
+ }
506
+ opened = true
507
+ }
508
+
509
+ return {
510
+ async write(row) {
511
+ await ensureOpen(row)
512
+ await client.#writeStreamFrame(
513
+ MessageKind.StreamChunk,
514
+ corr,
515
+ jsonBytes({ seq: seq++, rows: [row], terminal: false }),
516
+ streamId,
517
+ )
518
+ },
519
+ async close() {
520
+ await ensureOpen(null)
521
+ await client.#writeStreamFrame(
522
+ MessageKind.StreamChunk,
523
+ corr,
524
+ jsonBytes({ seq: seq++, rows: [], terminal: true }),
525
+ streamId,
526
+ )
527
+ const end = await client.reader.next()
528
+ if (end.kind === MessageKind.StreamError || end.kind === MessageKind.Error) {
529
+ const err = jsonOf(end.payload) ?? {}
530
+ throw new RedDBError(err.code || 'STREAM_ERROR', err.message || 'input stream failed', err)
531
+ }
532
+ if (end.kind !== MessageKind.StreamEnd) {
533
+ throw new RedDBError(
534
+ 'STREAM_PROTOCOL',
535
+ `expected StreamEnd, got ${KIND_NAME[end.kind] ?? end.kind}`,
536
+ )
537
+ }
538
+ const parsed = jsonOf(end.payload) ?? {}
539
+ return parsed.stats ?? parsed
540
+ },
541
+ async cancel(reason) {
542
+ await client.#cancelStream(streamId, reason)
543
+ },
544
+ }
545
+ }
546
+
547
+ async #cancelStream(streamId, reason) {
548
+ if (this.closed) return
549
+ const payload = typeof reason === 'string' && reason.length > 0
550
+ ? jsonBytes({ reason })
551
+ : new Uint8Array()
552
+ try {
553
+ await this.#writeStreamFrame(MessageKind.StreamCancel, this.#corr(), payload, streamId)
554
+ } catch {
555
+ // best-effort — the socket may already be torn down.
556
+ }
557
+ }
558
+
559
+ #writeStreamFrame(kind, corr, payload, streamId) {
560
+ const buf = encodeFrame(kind, corr, payload, 0, streamId)
561
+ return writeAll(this.socket, buf)
562
+ }
563
+
385
564
  async close() {
386
565
  if (this.closed) return
387
566
  this.closed = true
@@ -399,6 +578,13 @@ export class RedWireClient {
399
578
  this.nextCorr = this.nextCorr + 1n
400
579
  return c
401
580
  }
581
+
582
+ #stream() {
583
+ const id = this.nextStream
584
+ // stream_id 0 is reserved for handshake/lifecycle frames; wrap past it.
585
+ this.nextStream = this.nextStream >= 0xffff ? 1 : this.nextStream + 1
586
+ return id
587
+ }
402
588
  }
403
589
 
404
590
  // ---------------------------------------------------------------------------
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Web-native streaming surface for the JS driver (PRD #874 / #876).
3
+ *
4
+ * The browser/Web counterpart to `./streaming.js`. It exposes the **same**
5
+ * streaming interface the Node implementation does — `createSelectStream`,
6
+ * `createInputStream`, and the `RowReadable` / `RowWritable` row wrappers —
7
+ * so it drops straight into the injected-streaming seam on the core `RedDB`
8
+ * (`new RedDB(client, { createSelectStream, createInputStream })`). A browser
9
+ * entry wires it in; this module imports **zero `node:` built-ins**.
10
+ *
11
+ * Where the Node version builds on `node:stream`'s `Readable` / `Writable`,
12
+ * this one builds on the Web Streams primitives that already power the HTTP
13
+ * transport's `fetch` sessions:
14
+ *
15
+ * - `RowReadable` — wraps a Web `ReadableStream` (object chunks) and is an
16
+ * `AsyncIterable<Row>`: `for await (const row of stream)` yields each row
17
+ * in order and exits cleanly on stream end. The descriptor and resumable
18
+ * cursor (when the transport surfaces them) are captured on
19
+ * `.descriptor` / `.cursor` and also fan out as `'descriptor'` /
20
+ * `'cursor'` events. A mid-stream error frame rejects the `for await`
21
+ * iteration with the transport's `RedDBError`.
22
+ * - `RowWritable` — wraps a Web `WritableStream`. `write(row)` pushes a row
23
+ * (backpressure flows through the returned promise / the writer's
24
+ * `ready`); `end()` signals end-of-stream; the server's terminal envelope
25
+ * resolves `.completion()`.
26
+ *
27
+ * Both expose the uniform `cancel(reason?)` the Node wrappers do: it aborts
28
+ * the underlying transport session — which, over HTTP, calls
29
+ * `AbortController.abort()` on the `fetch` — and rejects anything pending with
30
+ * a `STREAM_CANCELLED` error.
31
+ *
32
+ * The transport session contracts consumed here are identical to the Node
33
+ * ones (see `./streaming.js`): a read session is an async-iterable of typed
34
+ * `{type,value}` frames plus `cancel(reason)`, and a write session is
35
+ * `{ write(row), close(): Promise<EndEnvelope>, cancel(reason) }`.
36
+ */
37
+
38
+ import { RedDBError } from './core/errors.js'
39
+ import { classifyNdjsonFrame } from './core/ndjson.js'
40
+
41
+ // Re-exported for parity with `./streaming.js`, so callers that reach for the
42
+ // NDJSON classifier from the streaming module keep working on either impl.
43
+ export { classifyNdjsonFrame }
44
+
45
+ function cancelError(reason) {
46
+ const suffix = typeof reason === 'string' && reason.length > 0 ? `: ${reason}` : ''
47
+ return new RedDBError('STREAM_CANCELLED', `stream cancelled${suffix}`)
48
+ }
49
+
50
+ function toError(err) {
51
+ return err instanceof Error ? err : new RedDBError('STREAM_ERROR', String(err))
52
+ }
53
+
54
+ function deferred() {
55
+ let resolve
56
+ let reject
57
+ const promise = new Promise((res, rej) => {
58
+ resolve = res
59
+ reject = rej
60
+ })
61
+ return { promise, resolve, reject }
62
+ }
63
+
64
+ function abortReason(signal) {
65
+ const reason = signal?.reason
66
+ if (typeof reason === 'string') return reason
67
+ if (reason && typeof reason.message === 'string') return reason.message
68
+ return 'aborted'
69
+ }
70
+
71
+ /**
72
+ * Attach a tiny `on` / `once` / `off` event surface to `target` and return an
73
+ * internal `emit(event, ...args)`. Mirrors the events the Node `Readable` /
74
+ * `Writable` raise (`descriptor` / `cursor` / `error` / `end` / `close`)
75
+ * without dragging in `node:events`. Unlike Node's `EventEmitter`, an
76
+ * unhandled `'error'` does not throw — the iteration rejection already carries
77
+ * the failure.
78
+ */
79
+ function attachEvents(target) {
80
+ const listeners = new Map()
81
+ target.on = (event, fn) => {
82
+ let set = listeners.get(event)
83
+ if (!set) {
84
+ set = new Set()
85
+ listeners.set(event, set)
86
+ }
87
+ set.add(fn)
88
+ return target
89
+ }
90
+ target.off = (event, fn) => {
91
+ listeners.get(event)?.delete(fn)
92
+ return target
93
+ }
94
+ target.once = (event, fn) => {
95
+ const wrapper = (...args) => {
96
+ target.off(event, wrapper)
97
+ fn(...args)
98
+ }
99
+ return target.on(event, wrapper)
100
+ }
101
+ return (event, ...args) => {
102
+ const set = listeners.get(event)
103
+ if (!set) return
104
+ for (const fn of [...set]) {
105
+ try {
106
+ fn(...args)
107
+ } catch {
108
+ // a throwing listener must not derail the stream
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * `AsyncIterable<Row>` over a transport read session, backed by a Web
116
+ * `ReadableStream`. Same surface as the Node `RowReadable`.
117
+ */
118
+ export class RowReadable {
119
+ /**
120
+ * @param {Promise<object>} sessionPromise resolves to a read session.
121
+ * @param {{ signal?: AbortSignal }} [opts]
122
+ */
123
+ constructor(sessionPromise, { signal } = {}) {
124
+ this._sessionPromise = sessionPromise
125
+ this._session = null
126
+ this._iter = null
127
+ this._ended = false
128
+ this._cancelled = false
129
+ this._cancelReason = undefined
130
+ this._cancelDone = null
131
+ this._controller = null
132
+ /** Schema descriptor (HTTP NDJSON) once seen; null otherwise. */
133
+ this.descriptor = null
134
+ /** Resumable cursor control frame (#807) once seen; null otherwise. */
135
+ this.cursor = null
136
+ /** Terminal `end` envelope once the stream completes; null otherwise. */
137
+ this.endInfo = null
138
+ this._emit = attachEvents(this)
139
+
140
+ this._stream = new ReadableStream({
141
+ start: (controller) => {
142
+ this._controller = controller
143
+ },
144
+ pull: (controller) => this._pull(controller),
145
+ cancel: (reason) => this._forwardCancel(reason),
146
+ })
147
+
148
+ if (signal) {
149
+ if (signal.aborted) {
150
+ queueMicrotask(() => this.cancel(abortReason(signal)))
151
+ } else {
152
+ signal.addEventListener('abort', () => this.cancel(abortReason(signal)), { once: true })
153
+ }
154
+ }
155
+ }
156
+
157
+ async _resolveIter() {
158
+ if (this._iter) return this._iter
159
+ this._session = await this._sessionPromise
160
+ this._iter = this._session[Symbol.asyncIterator]()
161
+ return this._iter
162
+ }
163
+
164
+ async _pull(controller) {
165
+ if (this._cancelled) return
166
+ let iter
167
+ try {
168
+ iter = await this._resolveIter()
169
+ } catch (err) {
170
+ if (this._cancelled) return
171
+ const e = toError(err)
172
+ this._emit('error', e)
173
+ controller.error(e)
174
+ return
175
+ }
176
+ // Loop until backpressure (one row enqueued) or completion. Control
177
+ // frames (descriptor/cursor) are surfaced as events/properties and do not
178
+ // count against the readable buffer, so we keep pulling past them.
179
+ for (;;) {
180
+ let result
181
+ try {
182
+ result = await iter.next()
183
+ } catch (err) {
184
+ if (this._cancelled) return
185
+ const e = toError(err)
186
+ this._emit('error', e)
187
+ controller.error(e)
188
+ return
189
+ }
190
+ if (this._cancelled) return
191
+ const { value: frame, done } = result
192
+ if (done) {
193
+ this._ended = true
194
+ this._emit('end', this.endInfo)
195
+ controller.close()
196
+ return
197
+ }
198
+ if (frame.type === 'descriptor') {
199
+ this.descriptor = frame.value
200
+ this._emit('descriptor', frame.value)
201
+ continue
202
+ }
203
+ if (frame.type === 'cursor') {
204
+ this.cursor = frame.value
205
+ this._emit('cursor', frame.value)
206
+ continue
207
+ }
208
+ if (frame.type === 'end') {
209
+ this.endInfo = frame.value
210
+ this._ended = true
211
+ this._emit('end', frame.value)
212
+ controller.close()
213
+ return
214
+ }
215
+ // frame.type === 'row'
216
+ controller.enqueue(frame.value)
217
+ return
218
+ }
219
+ }
220
+
221
+ _forwardCancel(reason) {
222
+ return Promise.resolve(this._sessionPromise)
223
+ .then((session) => (session && session.cancel ? session.cancel(reason) : undefined))
224
+ .catch(() => {})
225
+ }
226
+
227
+ /**
228
+ * `for await (const row of stream)` — single-consumer, like the Node
229
+ * `Readable`. Yields rows in order; rejects on a mid-stream error frame or a
230
+ * `cancel()`.
231
+ */
232
+ async *[Symbol.asyncIterator]() {
233
+ const reader = this._stream.getReader()
234
+ try {
235
+ for (;;) {
236
+ const { value, done } = await reader.read()
237
+ if (done) return
238
+ yield value
239
+ }
240
+ } finally {
241
+ reader.releaseLock()
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Terminate the stream. Sends a transport-level cancel and rejects any
247
+ * pending `for await` iteration with a `STREAM_CANCELLED` error.
248
+ * @param {string} [reason]
249
+ * @returns {Promise<void>}
250
+ */
251
+ cancel(reason) {
252
+ if (this._cancelled || this._ended) return this._cancelDone ?? Promise.resolve()
253
+ this._cancelled = true
254
+ this._cancelReason = reason
255
+ // Error the readable so pending/future reads reject — `ReadableStream.cancel`
256
+ // would merely resolve them with `{done:true}`, which is not the contract.
257
+ try {
258
+ this._controller?.error(cancelError(reason))
259
+ } catch {
260
+ // already closed/errored — fine.
261
+ }
262
+ this._emit('close')
263
+ this._cancelDone = this._forwardCancel(reason)
264
+ return this._cancelDone
265
+ }
266
+ }
267
+
268
+ /**
269
+ * `WritableStream`-backed sink over a transport write session. End-of-stream
270
+ * is `end()`; the server's terminal envelope resolves `.completion()`. Same
271
+ * surface as the Node `RowWritable`.
272
+ */
273
+ export class RowWritable {
274
+ /**
275
+ * @param {Promise<object>} sessionPromise resolves to a write session.
276
+ * @param {{ signal?: AbortSignal }} [opts]
277
+ */
278
+ constructor(sessionPromise, { signal } = {}) {
279
+ this._sessionPromise = sessionPromise
280
+ this._sessionResolved = null
281
+ this._finished = false
282
+ this._cancelled = false
283
+ this._cancelReason = undefined
284
+ this._cancelDone = null
285
+ /** Terminal `end` envelope once the stream finishes; null otherwise. */
286
+ this.endInfo = null
287
+ this._emit = attachEvents(this)
288
+
289
+ const completion = deferred()
290
+ this._completionPromise = completion.promise
291
+ this._resolveCompletion = completion.resolve
292
+ this._rejectCompletion = completion.reject
293
+ // Don't crash on an unobserved completion() — `'error'`/cancel carry it.
294
+ this._completionPromise.catch(() => {})
295
+
296
+ this._stream = new WritableStream({
297
+ write: async (row) => {
298
+ const session = await this._resolveSession()
299
+ await session.write(row)
300
+ },
301
+ close: async () => {
302
+ const session = await this._resolveSession()
303
+ const end = await session.close()
304
+ this.endInfo = end
305
+ this._finished = true
306
+ this._resolveCompletion(end)
307
+ this._emit('finish', end)
308
+ },
309
+ abort: async () => {
310
+ const session = await this._resolveSession().catch(() => null)
311
+ if (session && session.cancel) {
312
+ try {
313
+ await session.cancel(this._cancelReason)
314
+ } catch {
315
+ // best-effort — the abort already tore the request down.
316
+ }
317
+ }
318
+ },
319
+ })
320
+ this._writer = this._stream.getWriter()
321
+ // Funnel any write/abort failure into completion() and an 'error' event.
322
+ this._writer.closed.then(
323
+ () => {},
324
+ (err) => {
325
+ const e = this._cancelled ? cancelError(this._cancelReason) : toError(err)
326
+ this._rejectCompletion(e)
327
+ if (!this._cancelled) this._emit('error', e)
328
+ },
329
+ )
330
+
331
+ if (signal) {
332
+ if (signal.aborted) {
333
+ queueMicrotask(() => this.cancel(abortReason(signal)))
334
+ } else {
335
+ signal.addEventListener('abort', () => this.cancel(abortReason(signal)), { once: true })
336
+ }
337
+ }
338
+ }
339
+
340
+ async _resolveSession() {
341
+ if (!this._sessionResolved) {
342
+ this._sessionResolved = await this._sessionPromise
343
+ }
344
+ return this._sessionResolved
345
+ }
346
+
347
+ /**
348
+ * Push a row. Returns a promise that resolves when the row is accepted;
349
+ * backpressure flows through it (and the underlying writer's `ready`).
350
+ * @param {object} row
351
+ * @returns {Promise<void>}
352
+ */
353
+ write(row) {
354
+ const p = this._writer.write(row)
355
+ // Per-write failures surface via completion()/'error'; keep the returned
356
+ // promise handled so an ignored write() can't trip an unhandled rejection.
357
+ p.catch(() => {})
358
+ return p
359
+ }
360
+
361
+ /**
362
+ * Signal end-of-stream. The server's terminal envelope resolves
363
+ * `.completion()`.
364
+ * @returns {Promise<void>}
365
+ */
366
+ end() {
367
+ const p = this._writer.close()
368
+ p.catch(() => {})
369
+ return p
370
+ }
371
+
372
+ /**
373
+ * Resolves with the server's terminal `end` envelope once the stream
374
+ * finishes successfully; rejects if the stream errors or is cancelled.
375
+ * @returns {Promise<object>}
376
+ */
377
+ completion() {
378
+ return this._completionPromise
379
+ }
380
+
381
+ /**
382
+ * Terminate the stream without flushing the remaining rows. Rejects
383
+ * `.completion()` with a `STREAM_CANCELLED` error and aborts the transport.
384
+ * @param {string} [reason]
385
+ * @returns {Promise<void>}
386
+ */
387
+ cancel(reason) {
388
+ if (this._cancelled || this._finished) return this._cancelDone ?? Promise.resolve()
389
+ this._cancelled = true
390
+ this._cancelReason = reason
391
+ this._rejectCompletion(cancelError(reason))
392
+ this._emit('close')
393
+ // abort() on the writable runs the abort algorithm above, which forwards
394
+ // the cancel to the transport session.
395
+ this._cancelDone = Promise.resolve(this._writer.abort(cancelError(reason))).catch(() => {})
396
+ return this._cancelDone
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Build a `RowReadable` from a connection's transport client. Identical
402
+ * validation and session construction to the Node `createSelectStream`.
403
+ * @param {object} client transport exposing `streamSelect`.
404
+ * @param {string} sql read-only SELECT to stream.
405
+ * @param {{ signal?: AbortSignal, cursor?: string }} [opts]
406
+ * @returns {RowReadable}
407
+ */
408
+ export function createSelectStream(client, sql, opts = {}) {
409
+ if (typeof client.streamSelect !== 'function') {
410
+ throw new RedDBError(
411
+ 'STREAMING_UNSUPPORTED',
412
+ 'the active transport does not support streaming reads (use red:// or http(s)://)',
413
+ )
414
+ }
415
+ if (opts.cursor == null && (typeof sql !== 'string' || sql.trim().length === 0)) {
416
+ throw new RedDBError('INVALID_STREAM_QUERY', 'stream() requires a non-empty SQL string')
417
+ }
418
+ const sessionPromise = Promise.resolve().then(() =>
419
+ client.streamSelect({ sql, cursor: opts.cursor, signal: opts.signal }),
420
+ )
421
+ return new RowReadable(sessionPromise, { signal: opts.signal })
422
+ }
423
+
424
+ /**
425
+ * Build a `RowWritable` from a connection's transport client. Identical
426
+ * validation and session construction to the Node `createInputStream`.
427
+ * @param {object} client transport exposing `streamInput`.
428
+ * @param {string} target table to ingest into.
429
+ * @param {{ signal?: AbortSignal, columns?: string[] }} [opts]
430
+ * @returns {RowWritable}
431
+ */
432
+ export function createInputStream(client, target, opts = {}) {
433
+ if (typeof client.streamInput !== 'function') {
434
+ throw new RedDBError(
435
+ 'STREAMING_UNSUPPORTED',
436
+ 'the active transport does not support streaming writes (use red:// or http(s)://)',
437
+ )
438
+ }
439
+ if (typeof target !== 'string' || target.trim().length === 0) {
440
+ throw new RedDBError('INVALID_STREAM_TARGET', 'inputStream() requires a non-empty target table')
441
+ }
442
+ const sessionPromise = Promise.resolve().then(() =>
443
+ client.streamInput({
444
+ target,
445
+ columns: opts.columns,
446
+ signal: opts.signal,
447
+ }),
448
+ )
449
+ return new RowWritable(sessionPromise, { signal: opts.signal })
450
+ }