@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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * SQL query-parameter serialization — the wire encoding shared by every
3
+ * transport. Pure JS: value encoding, base64, UUID, Date, typed-array,
4
+ * and NaN/Infinity handling. Imports zero `node:` built-ins.
5
+ *
6
+ * `Buffer` is referenced only behind a `typeof Buffer !== 'undefined'`
7
+ * guard so the module loads unchanged on runtimes that have no `Buffer`
8
+ * (browsers, Deno without the node shim).
9
+ */
10
+
11
+ import { RedDBError } from './errors.js'
12
+
13
+ export function serializeParam(value) {
14
+ assertSupportedParam(value)
15
+ if (value instanceof Float32Array || value instanceof Float64Array) {
16
+ return Array.from(value)
17
+ }
18
+ if (value instanceof Date) {
19
+ return { $ts: String(BigInt(value.getTime()) * 1_000_000n) }
20
+ }
21
+ if (value instanceof Uint8Array || (typeof Buffer !== 'undefined' && value instanceof Buffer)) {
22
+ return { $bytes: bytesToBase64(value) }
23
+ }
24
+ if (typeof value === 'number' && !Number.isFinite(value)) {
25
+ if (Number.isNaN(value)) return { $float: 'NaN' }
26
+ return { $float: value > 0 ? 'Infinity' : '-Infinity' }
27
+ }
28
+ if (typeof value === 'string' && isUuidString(value)) {
29
+ return { $uuid: value }
30
+ }
31
+ return value
32
+ }
33
+
34
+ export function assertSupportedParam(value) {
35
+ if (value == null) return
36
+ if (
37
+ typeof value === 'boolean'
38
+ || typeof value === 'number'
39
+ || typeof value === 'string'
40
+ ) {
41
+ return
42
+ }
43
+ if (value instanceof Date) {
44
+ if (Number.isNaN(value.getTime())) {
45
+ throw new RedDBError('UNSUPPORTED_PARAM', 'cannot encode invalid Date query parameter')
46
+ }
47
+ return
48
+ }
49
+ if (
50
+ value instanceof Uint8Array
51
+ || value instanceof Float32Array
52
+ || value instanceof Float64Array
53
+ || (typeof Buffer !== 'undefined' && value instanceof Buffer)
54
+ ) {
55
+ return
56
+ }
57
+ if (Array.isArray(value)) {
58
+ if (value.every((item) => typeof item === 'number')) return
59
+ throw new RedDBError(
60
+ 'UNSUPPORTED_PARAM',
61
+ 'array query parameters must contain only numbers',
62
+ )
63
+ }
64
+ if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
65
+ return
66
+ }
67
+ throw new RedDBError(
68
+ 'UNSUPPORTED_PARAM',
69
+ `cannot encode query parameter of type ${typeof value}`,
70
+ )
71
+ }
72
+
73
+ export function normalizeQueryParams(args) {
74
+ if (args.length === 0) return null
75
+ if (args.length === 1 && Array.isArray(args[0])) return args[0].map(serializeParam)
76
+ return args.map(serializeParam)
77
+ }
78
+
79
+ export function bytesToBase64(value) {
80
+ const bytes = value instanceof Uint8Array
81
+ ? value
82
+ : new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
83
+ if (typeof Buffer !== 'undefined') {
84
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64')
85
+ }
86
+ let text = ''
87
+ for (const byte of bytes) text += String.fromCharCode(byte)
88
+ // eslint-disable-next-line no-undef
89
+ return btoa(text)
90
+ }
91
+
92
+ export function isUuidString(value) {
93
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
94
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * `red://` connection-string parser.
3
+ *
4
+ * One URL covers every transport RedDB speaks:
5
+ *
6
+ * red:// embedded in-memory
7
+ * red:///abs/path/data.rdb embedded persistent
8
+ * red://user:pass@host:5050 remote, default proto=red (wire)
9
+ * red://host:8080?proto=https remote HTTPS
10
+ * red://host:5432?proto=pg PostgreSQL wire
11
+ * red://host:5055?proto=grpc&token=sk-abc remote gRPC w/ bearer
12
+ * red://host:8080?proto=https&apiKey=ak-xyz remote HTTPS w/ api key
13
+ *
14
+ * Backwards-compat: legacy `memory://`, `file://`, `grpc://` URLs
15
+ * still work via `parseLegacyUrl`. New code should prefer `red://`
16
+ * because it carries auth + protocol selection in one place.
17
+ */
18
+
19
+ import { RedDBError } from './errors.js'
20
+
21
+ /**
22
+ * @typedef {object} ParsedUri
23
+ * @property {'embedded' | 'http' | 'https' | 'red' | 'reds' | 'grpc' | 'grpcs' | 'pg'} kind
24
+ * @property {string} [host]
25
+ * @property {number} [port]
26
+ * @property {string} [path] // for embedded `file://`-equivalent
27
+ * @property {string} [username]
28
+ * @property {string} [password]
29
+ * @property {string} [token]
30
+ * @property {string} [apiKey]
31
+ * @property {string} [loginUrl] // explicit override for login flow
32
+ * @property {URLSearchParams} [params] // remaining query params
33
+ * @property {string} originalUri
34
+ */
35
+
36
+ /**
37
+ * Parse any URI string into a normalised `ParsedUri`.
38
+ * Accepts `red://`, `memory://`, `file://`, `grpc://` (the latter
39
+ * three for backwards compat).
40
+ *
41
+ * @param {string} uri
42
+ * @returns {ParsedUri}
43
+ */
44
+ export function parseUri(uri) {
45
+ if (typeof uri !== 'string' || uri.length === 0) {
46
+ throw new TypeError(
47
+ "connect() requires a URI string (e.g. 'red://localhost:5050' or 'red:///data.rdb')",
48
+ )
49
+ }
50
+ if (uri.startsWith('red://') || uri === 'red:' || uri === 'red:/') {
51
+ return parseRedUrl(uri)
52
+ }
53
+ return parseLegacyUrl(uri)
54
+ }
55
+
56
+ /**
57
+ * Parse a `red://` URL.
58
+ *
59
+ * Authority shape: `[user[:pass]@]host[:port]`
60
+ * Path: optional, used as filesystem path when `host` is absent or
61
+ * is the special token `localhost-embedded` (rare).
62
+ * Query: `proto`, `token`, `apiKey`, `loginUrl`.
63
+ */
64
+ export function parseRedUrl(uri) {
65
+ // The host might be missing (`red:///path`), the URL constructor
66
+ // requires *something* there. Re-write to a parse-friendly shape:
67
+ // - `red:///x` → `red://embedded.local/x` (embedded with path)
68
+ // - `red://memory` → `red://embedded.local` (embedded in-memory)
69
+ // - `red://` → `red://embedded.local` (embedded in-memory)
70
+ let normalised = uri
71
+ if (uri === 'red:' || uri === 'red:/' || uri === 'red://') {
72
+ normalised = 'red://embedded.local'
73
+ } else if (uri.startsWith('red:///')) {
74
+ normalised = `red://embedded.local${uri.slice('red://'.length)}`
75
+ } else if (
76
+ uri === 'red://memory'
77
+ || uri === 'red://memory/'
78
+ || uri === 'red://:memory'
79
+ || uri === 'red://:memory:' // SQLite-style ":memory:" alias
80
+ ) {
81
+ normalised = 'red://embedded.local'
82
+ }
83
+
84
+ let parsed
85
+ try {
86
+ parsed = new URL(normalised)
87
+ } catch (err) {
88
+ throw new RedDBError('UNPARSEABLE_URI', `failed to parse '${uri}': ${err.message}`)
89
+ }
90
+
91
+ const params = parsed.searchParams
92
+ const proto = (params.get('proto') || '').toLowerCase()
93
+ const path = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname : ''
94
+
95
+ // Embedded: special host, OR `proto=embedded`, OR no proto + has path
96
+ // and the user clearly meant a file path (red:///abs/path).
97
+ if (parsed.hostname === 'embedded.local') {
98
+ if (path) {
99
+ return {
100
+ kind: 'embedded',
101
+ path,
102
+ params,
103
+ originalUri: uri,
104
+ }
105
+ }
106
+ return {
107
+ kind: 'embedded',
108
+ params,
109
+ originalUri: uri,
110
+ }
111
+ }
112
+
113
+ // Remote — default proto is red (wire).
114
+ const kind = resolveKind(proto)
115
+ const port = parsed.port ? Number(parsed.port) : defaultPortFor(kind)
116
+ const username = parsed.username ? decodeURIComponent(parsed.username) : undefined
117
+ const password = parsed.password ? decodeURIComponent(parsed.password) : undefined
118
+
119
+ return {
120
+ kind,
121
+ host: parsed.hostname,
122
+ port,
123
+ path: path || undefined,
124
+ username,
125
+ password,
126
+ token: params.get('token') ?? undefined,
127
+ apiKey: params.get('apiKey') ?? params.get('api_key') ?? undefined,
128
+ loginUrl: params.get('loginUrl') ?? params.get('login_url') ?? undefined,
129
+ params,
130
+ originalUri: uri,
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Backwards-compat parser for the legacy URL shapes the driver
136
+ * accepted before `red://` existed. Returns the same `ParsedUri`
137
+ * shape so downstream code is uniform.
138
+ */
139
+ export function parseLegacyUrl(uri) {
140
+ if (uri === 'memory://' || uri === 'memory:') {
141
+ return { kind: 'embedded', originalUri: uri }
142
+ }
143
+ if (uri.startsWith('file://')) {
144
+ const path = uri.slice('file://'.length)
145
+ if (!path) {
146
+ throw new TypeError(`invalid file:// URI: missing path in '${uri}'`)
147
+ }
148
+ return { kind: 'embedded', path, originalUri: uri }
149
+ }
150
+ if (
151
+ uri.startsWith('grpc://')
152
+ || uri.startsWith('grpcs://')
153
+ || uri.startsWith('reds://')
154
+ ) {
155
+ const scheme = uri.split('://', 1)[0]
156
+ const stripped = uri.slice(`${scheme}://`.length)
157
+ const [hostPort] = stripped.split(/[/?]/, 1)
158
+ const [host, portStr] = hostPort.split(':')
159
+ if (!host) {
160
+ throw new TypeError(`invalid ${scheme}:// URI: missing host in '${uri}'`)
161
+ }
162
+ const legacyKind = scheme === 'reds' ? 'reds' : scheme === 'grpcs' ? 'grpcs' : scheme === 'grpc' ? 'grpc' : 'red'
163
+ return {
164
+ kind: legacyKind,
165
+ host,
166
+ port: portStr ? Number(portStr) : defaultPortFor(legacyKind),
167
+ originalUri: uri,
168
+ }
169
+ }
170
+ if (uri.startsWith('http://') || uri.startsWith('https://')) {
171
+ let parsed
172
+ try {
173
+ parsed = new URL(uri)
174
+ } catch (err) {
175
+ throw new RedDBError('UNPARSEABLE_URI', `failed to parse '${uri}': ${err.message}`)
176
+ }
177
+ return {
178
+ kind: parsed.protocol === 'https:' ? 'https' : 'http',
179
+ host: parsed.hostname,
180
+ port: parsed.port ? Number(parsed.port) : defaultPortFor(parsed.protocol === 'https:' ? 'https' : 'http'),
181
+ path: parsed.pathname !== '/' ? parsed.pathname : undefined,
182
+ username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
183
+ password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
184
+ token: parsed.searchParams.get('token') ?? undefined,
185
+ apiKey: parsed.searchParams.get('apiKey') ?? undefined,
186
+ params: parsed.searchParams,
187
+ originalUri: uri,
188
+ }
189
+ }
190
+ throw new RedDBError(
191
+ 'UNSUPPORTED_SCHEME',
192
+ `unsupported URI: '${uri}'. Use 'red://...' or one of memory://, file://, grpc://, http(s)://`,
193
+ )
194
+ }
195
+
196
+ function resolveKind(protoQueryParam) {
197
+ switch (protoQueryParam) {
198
+ case '':
199
+ case 'red':
200
+ return 'red'
201
+ case 'reds':
202
+ return 'reds'
203
+ case 'grpc':
204
+ return 'grpc'
205
+ case 'grpcs':
206
+ return 'grpcs'
207
+ case 'http':
208
+ return 'http'
209
+ case 'https':
210
+ return 'https'
211
+ case 'pg':
212
+ case 'postgres':
213
+ case 'postgresql':
214
+ return 'pg'
215
+ default:
216
+ throw new RedDBError(
217
+ 'UNSUPPORTED_PROTO',
218
+ `unknown proto='${protoQueryParam}'. Supported: red | reds | grpc | grpcs | http | https | pg`,
219
+ )
220
+ }
221
+ }
222
+
223
+ function defaultPortFor(kind) {
224
+ switch (kind) {
225
+ case 'http':
226
+ return 8080
227
+ case 'https':
228
+ return 8443
229
+ case 'red':
230
+ case 'reds':
231
+ case 'redwire':
232
+ return 5050
233
+ case 'grpc':
234
+ return 5055
235
+ case 'grpcs':
236
+ return 5056
237
+ case 'pg':
238
+ case 'postgres':
239
+ case 'postgresql':
240
+ return 5432
241
+ default:
242
+ return undefined
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Derive the HTTP login URL (`/auth/login`) from a parsed URI.
248
+ * Used by the auto-login flow when the user supplies `username:password@`
249
+ * but not an explicit `loginUrl`.
250
+ *
251
+ * Strategy: if proto is already http/https, just append `/auth/login`.
252
+ * For grpc/grpcs/pg, default to https://host:443 — operators that
253
+ * don't want that should pass `loginUrl=` explicitly.
254
+ */
255
+ export function deriveLoginUrl(parsed) {
256
+ if (parsed.loginUrl) return parsed.loginUrl
257
+ if (!parsed.host) {
258
+ throw new RedDBError(
259
+ 'AUTH_LOGIN_NEEDS_HOST',
260
+ 'cannot derive loginUrl without a host; pass it explicitly via loginUrl=...',
261
+ )
262
+ }
263
+ if (parsed.kind === 'http' || parsed.kind === 'https') {
264
+ const scheme = parsed.kind
265
+ const port = parsed.port ?? defaultPortFor(parsed.kind)
266
+ return `${scheme}://${parsed.host}:${port}/auth/login`
267
+ }
268
+ // Non-HTTP transports — default to HTTPS on 443 unless the user
269
+ // tells us otherwise.
270
+ return `https://${parsed.host}/auth/login`
271
+ }
@@ -1,90 +1,12 @@
1
1
  /**
2
- * Embedded-URI rejection for the thin remote-only client.
3
- *
4
- * The `@reddb-io/client` package ships only the `red_client` binary,
5
- * which has no embedded engine. Any URI that asks for an in-memory
6
- * or file-backed database must be rejected at parse time with the
7
- * same wording the Rust `red_client` binary prints, so users get a
8
- * uniform error across language drivers and the CLI.
9
- *
10
- * Mirrors `is_embedded_uri` in
11
- * `crates/reddb-client/src/bin/red_client.rs` (the rejected forms):
12
- *
13
- * "red://" — bare red:// with no host
14
- * "red:" — degenerate
15
- * "red:///" — explicit empty path
16
- * "red:///<path>" — any red:// URL with a leading-slash path
17
- * "red://:memory" — SQLite-style alias
18
- * "red://:memory:" — SQLite-style alias
19
- *
20
- * The legacy `memory://`, `memory:`, and `file://<path>` schemes are
21
- * also rejected because they were always shorthand for the embedded
22
- * engine.
2
+ * Compatibility shim. The embedded-URI rejection logic now lives in the
3
+ * transport-agnostic core (`core/embedded-rejection.js`); this file keeps
4
+ * the historical `./embedded-rejection.js` import path working.
23
5
  */
24
6
 
25
- import { RedDBError } from './protocol.js'
26
-
27
- /** Wording is intentionally identical to the Rust binary stderr message. */
28
- export const EMBEDDED_REJECTION_MESSAGE =
29
- 'embedded schemes (memory:// / file://) are not supported.\n'
30
- + 'Use the full `red` binary for in-memory or file-backed engines.'
31
-
32
- /**
33
- * Specialised error raised when an embedded URI is passed to the
34
- * thin client. Always carries `code === 'EmbeddedNotSupported'` and
35
- * the wording from `EMBEDDED_REJECTION_MESSAGE`, surfacing the same
36
- * actionable hint as the underlying Rust binary.
37
- */
38
- export class EmbeddedNotSupported extends RedDBError {
39
- constructor(uri) {
40
- super('EmbeddedNotSupported', EMBEDDED_REJECTION_MESSAGE, { uri })
41
- this.name = 'EmbeddedNotSupported'
42
- this.uri = uri
43
- }
44
- }
45
-
46
- /**
47
- * Return true when `uri` selects the embedded engine.
48
- *
49
- * @param {string} uri
50
- * @returns {boolean}
51
- */
52
- export function isEmbeddedUri(uri) {
53
- if (typeof uri !== 'string') return false
54
- const trimmed = uri.trim()
55
- if (
56
- trimmed === 'red://'
57
- || trimmed === 'red:'
58
- || trimmed === 'red:/'
59
- || trimmed === 'red:///'
60
- || trimmed === 'red://:memory'
61
- || trimmed === 'red://:memory:'
62
- ) {
63
- return true
64
- }
65
- // Any `red:///<path>` form is the embedded persistent engine.
66
- if (trimmed.startsWith('red:///')) return true
67
- // Legacy shorthands that always meant embedded.
68
- if (trimmed === 'memory://' || trimmed === 'memory:') return true
69
- if (trimmed.startsWith('file://')) return true
70
- return false
71
- }
72
-
73
- /**
74
- * Throws `EmbeddedNotSupported` if `uri` is an embedded shape.
75
- * Otherwise returns the trimmed URI for downstream consumption.
76
- *
77
- * @param {string} uri
78
- * @returns {string}
79
- */
80
- export function rejectEmbeddedUri(uri) {
81
- if (typeof uri !== 'string' || uri.length === 0) {
82
- throw new TypeError(
83
- "connect() requires a URI string (e.g. 'red://localhost:5050' or 'grpc://host:5055')",
84
- )
85
- }
86
- if (isEmbeddedUri(uri)) {
87
- throw new EmbeddedNotSupported(uri)
88
- }
89
- return uri.trim()
90
- }
7
+ export {
8
+ EMBEDDED_REJECTION_MESSAGE,
9
+ EmbeddedNotSupported,
10
+ isEmbeddedUri,
11
+ rejectEmbeddedUri,
12
+ } from './core/embedded-rejection.js'
package/src/http.js CHANGED
@@ -37,6 +37,7 @@
37
37
  */
38
38
 
39
39
  import { RedDBError } from './protocol.js'
40
+ import { classifyNdjsonFrame, splitLines } from './core/ndjson.js'
40
41
 
41
42
  export class HttpRpcClient {
42
43
  /**
@@ -87,6 +88,196 @@ export class HttpRpcClient {
87
88
  }
88
89
  return { ...init, headers }
89
90
  }
91
+
92
+ authHeaders(extra = {}) {
93
+ const headers = new Headers(extra)
94
+ if (this.token && !headers.has('authorization')) {
95
+ headers.set('authorization', `Bearer ${this.token}`)
96
+ }
97
+ return headers
98
+ }
99
+
100
+ /**
101
+ * Open a streaming read against `POST /query/stream`. Returns an async
102
+ * iterable of typed frames (see streaming.js) plus a `cancel(reason)`
103
+ * that aborts the underlying fetch. A non-streaming refusal (e.g. a
104
+ * non-read-only statement) is surfaced as a rejected `RedDBError`
105
+ * before any frame is yielded, so callers can tell "never accepted"
106
+ * from a mid-stream failure.
107
+ *
108
+ * @param {{ sql?: string, cursor?: string, signal?: AbortSignal }} opts
109
+ */
110
+ async streamSelect({ sql, cursor, signal } = {}) {
111
+ const controller = new AbortController()
112
+ linkSignal(signal, controller)
113
+ const body = cursor != null ? { cursor } : { query: sql }
114
+ const response = await fetch(`${this.baseUrl}/query/stream`, {
115
+ method: 'POST',
116
+ headers: this.authHeaders({ 'content-type': 'application/json' }),
117
+ body: JSON.stringify(body),
118
+ signal: controller.signal,
119
+ })
120
+ if (!response.ok) {
121
+ await throwHttpStreamRefusal(response)
122
+ }
123
+ const reader = response.body?.getReader()
124
+ if (!reader) {
125
+ throw new RedDBError('STREAM_PROTOCOL', 'streaming response had no body')
126
+ }
127
+ return {
128
+ [Symbol.asyncIterator]() {
129
+ return ndjsonFrameIterator(reader, controller)
130
+ },
131
+ async cancel() {
132
+ controller.abort()
133
+ try {
134
+ await reader.cancel()
135
+ } catch {
136
+ // best-effort
137
+ }
138
+ },
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Open a streaming write against `POST /streams/input`. The request
144
+ * body is an NDJSON stream: an `open` frame (target + columns), then
145
+ * one `row` frame per record. Backpressure flows through the request
146
+ * body's writer. The terminal envelope is returned by `close()`.
147
+ *
148
+ * @param {{ target: string, columns?: string[], signal?: AbortSignal }} opts
149
+ */
150
+ async streamInput({ target, columns, signal } = {}) {
151
+ const controller = new AbortController()
152
+ linkSignal(signal, controller)
153
+ const transform = new TransformStream()
154
+ const writer = transform.writable.getWriter()
155
+ const fetchPromise = fetch(`${this.baseUrl}/streams/input`, {
156
+ method: 'POST',
157
+ headers: this.authHeaders({ 'content-type': 'application/x-ndjson' }),
158
+ body: transform.readable,
159
+ duplex: 'half',
160
+ signal: controller.signal,
161
+ })
162
+ // Surface a connection/refusal failure on the write path too, rather
163
+ // than leaving the promise unhandled if the caller never calls close().
164
+ fetchPromise.catch(() => {})
165
+
166
+ let opened = false
167
+ let cols = Array.isArray(columns) && columns.length > 0 ? columns.slice() : null
168
+ const encodeLine = (obj) => writer.write(`${JSON.stringify(obj)}\n`)
169
+ const ensureOpen = async (row) => {
170
+ if (opened) return
171
+ if (!cols) {
172
+ cols = row && typeof row === 'object' ? Object.keys(row) : null
173
+ }
174
+ if (!cols || cols.length === 0) {
175
+ throw new RedDBError(
176
+ 'INVALID_STREAM_COLUMNS',
177
+ 'inputStream() needs a non-empty column set — pass { columns } or write at least one object row',
178
+ )
179
+ }
180
+ await writer.write(`${JSON.stringify({ open: { target, columns: cols } })}\n`)
181
+ opened = true
182
+ }
183
+
184
+ return {
185
+ async write(row) {
186
+ await ensureOpen(row)
187
+ await writer.ready
188
+ await encodeLine({ row })
189
+ },
190
+ async close() {
191
+ await ensureOpen(null)
192
+ await writer.close()
193
+ const response = await fetchPromise
194
+ return await readInputTerminal(response)
195
+ },
196
+ async cancel(reason) {
197
+ controller.abort()
198
+ try {
199
+ await writer.abort(reason)
200
+ } catch {
201
+ // best-effort — the abort above already tore the request down.
202
+ }
203
+ },
204
+ }
205
+ }
206
+ }
207
+
208
+ function linkSignal(signal, controller) {
209
+ if (!signal) return
210
+ if (signal.aborted) {
211
+ controller.abort(signal.reason)
212
+ return
213
+ }
214
+ signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true })
215
+ }
216
+
217
+ async function throwHttpStreamRefusal(response) {
218
+ const text = await response.text().catch(() => '')
219
+ let body = null
220
+ if (text) {
221
+ try {
222
+ body = JSON.parse(text)
223
+ } catch {
224
+ body = { raw: text }
225
+ }
226
+ }
227
+ const code = body?.code || body?.error_code || `HTTP_${response.status}`
228
+ const message =
229
+ body?.error || body?.message || `stream refused with status ${response.status}`
230
+ throw new RedDBError(code, message, body)
231
+ }
232
+
233
+ /** Async iterator over NDJSON frames from a web ReadableStream reader. */
234
+ async function* ndjsonFrameIterator(reader, controller) {
235
+ const decoder = new TextDecoder()
236
+ let buffer = ''
237
+ try {
238
+ for (;;) {
239
+ const { value, done } = await reader.read()
240
+ if (done) break
241
+ buffer += decoder.decode(value, { stream: true })
242
+ const { lines, rest } = splitLines(buffer)
243
+ buffer = rest
244
+ for (const line of lines) {
245
+ const frame = classifyNdjsonFrame(line)
246
+ if (frame) yield frame
247
+ }
248
+ }
249
+ const tail = buffer + decoder.decode()
250
+ const frame = classifyNdjsonFrame(tail)
251
+ if (frame) yield frame
252
+ } finally {
253
+ controller.abort()
254
+ }
255
+ }
256
+
257
+ /** Read the input-stream response body and return its terminal envelope. */
258
+ async function readInputTerminal(response) {
259
+ const text = await response.text()
260
+ if (!response.ok) {
261
+ let body = null
262
+ try {
263
+ body = text ? JSON.parse(text) : null
264
+ } catch {
265
+ body = { raw: text }
266
+ }
267
+ const code = body?.code || body?.error_code || `HTTP_${response.status}`
268
+ const message = body?.error || body?.message || `input stream failed (${response.status})`
269
+ throw new RedDBError(code, message, body)
270
+ }
271
+ const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
272
+ let end = null
273
+ for (const line of lines) {
274
+ const frame = classifyNdjsonFrame(line) // throws RedDBError on an {error} frame
275
+ if (frame && frame.type === 'end') end = frame.value
276
+ }
277
+ if (!end) {
278
+ throw new RedDBError('STREAM_PROTOCOL', 'input stream closed without a terminal end envelope')
279
+ }
280
+ return end
90
281
  }
91
282
 
92
283
  async function parseResponse(response) {