@reddb-io/client 1.7.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/README.md +34 -0
- package/index.browser.d.ts +453 -0
- package/index.d.ts +118 -0
- package/package.json +41 -4
- package/src/core/auth.js +102 -0
- package/src/core/embedded-rejection.js +90 -0
- package/src/core/errors.js +20 -0
- package/src/core/index.js +43 -0
- package/src/core/insert-ids.js +45 -0
- package/src/core/ndjson.js +59 -0
- package/src/core/reddb.js +314 -0
- package/src/core/serialization.js +94 -0
- package/src/core/url.js +271 -0
- package/src/embedded-rejection.js +9 -87
- package/src/http.js +191 -0
- package/src/index.browser.js +156 -0
- package/src/index.js +29 -404
- package/src/protocol.js +6 -13
- package/src/queue.js +24 -0
- package/src/redwire.js +186 -0
- package/src/streaming-web.js +450 -0
- package/src/streaming.js +362 -0
- package/src/url.js +9 -268
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
|
+
}
|