@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,102 @@
1
+ /**
2
+ * Authentication helpers shared across transports.
3
+ *
4
+ * - `login()` — username/password → bearer-token exchange over
5
+ * the server's `POST /auth/login` HTTP endpoint.
6
+ * - `mergeAuthFromUri()` — fold credentials parsed from the connection
7
+ * URI together with caller-supplied `options.auth`.
8
+ *
9
+ * Uses the global `fetch`; imports zero `node:` built-ins.
10
+ */
11
+
12
+ import { RedDBError } from './errors.js'
13
+
14
+ /**
15
+ * Exchange username + password for a bearer token via the server's
16
+ * `POST /auth/login` HTTP endpoint. Same flow used by `connect()` when
17
+ * the caller passes `auth: { username, password }`.
18
+ *
19
+ * @param {string} loginUrl Full URL of the server's auth endpoint.
20
+ * @param {{ username: string, password: string }} credentials
21
+ * @returns {Promise<{ token: string, username: string, role: string, expires_at: number }>}
22
+ */
23
+ export async function login(loginUrl, { username, password }) {
24
+ if (typeof loginUrl !== 'string' || !loginUrl.startsWith('http')) {
25
+ throw new TypeError("login() requires an http(s):// URL pointing at /auth/login")
26
+ }
27
+ if (typeof username !== 'string' || username.length === 0) {
28
+ throw new TypeError('login() requires a non-empty username')
29
+ }
30
+ if (typeof password !== 'string' || password.length === 0) {
31
+ throw new TypeError('login() requires a non-empty password')
32
+ }
33
+ const response = await fetch(loginUrl, {
34
+ method: 'POST',
35
+ headers: { 'content-type': 'application/json' },
36
+ body: JSON.stringify({ username, password }),
37
+ })
38
+ const body = await response.json().catch(() => ({}))
39
+ if (!response.ok || body.ok === false) {
40
+ const code = body.error_code || `HTTP_${response.status}`
41
+ const message = body.error || `auth/login returned ${response.status}`
42
+ throw new RedDBError(code, message, body)
43
+ }
44
+ if (typeof body.token !== 'string') {
45
+ throw new RedDBError(
46
+ 'AUTH_LOGIN_BAD_RESPONSE',
47
+ 'auth/login response missing string token',
48
+ body,
49
+ )
50
+ }
51
+ return body
52
+ }
53
+
54
+ /**
55
+ * Fold credentials carried on the parsed URI together with the
56
+ * caller-supplied `options.auth`. `options.auth` wins on every field it
57
+ * sets; URI-derived values are the fallback. Returns a normalised
58
+ * `{ token, username, password, loginUrl }` shape.
59
+ *
60
+ * @param {object} parsed result of `parseUri()`.
61
+ * @param {object} [optionAuth] caller-supplied `options.auth`.
62
+ */
63
+ export function mergeAuthFromUri(parsed, optionAuth) {
64
+ const out = {
65
+ token: parsed.token ?? parsed.apiKey ?? null,
66
+ username: parsed.username ?? null,
67
+ password: parsed.password ?? null,
68
+ loginUrl: parsed.loginUrl ?? null,
69
+ }
70
+ if (optionAuth == null) return out
71
+ if (typeof optionAuth !== 'object') {
72
+ throw new TypeError('options.auth must be an object')
73
+ }
74
+ if (optionAuth.token != null) {
75
+ if (typeof optionAuth.token !== 'string' || optionAuth.token.length === 0) {
76
+ throw new TypeError('options.auth.token must be a non-empty string')
77
+ }
78
+ out.token = optionAuth.token
79
+ }
80
+ if (optionAuth.apiKey != null) {
81
+ if (typeof optionAuth.apiKey !== 'string' || optionAuth.apiKey.length === 0) {
82
+ throw new TypeError('options.auth.apiKey must be a non-empty string')
83
+ }
84
+ out.token = optionAuth.apiKey
85
+ }
86
+ if (optionAuth.username != null) {
87
+ if (typeof optionAuth.username !== 'string' || optionAuth.username.length === 0) {
88
+ throw new TypeError('options.auth.username must be a non-empty string')
89
+ }
90
+ out.username = optionAuth.username
91
+ }
92
+ if (optionAuth.password != null) {
93
+ if (typeof optionAuth.password !== 'string' || optionAuth.password.length === 0) {
94
+ throw new TypeError('options.auth.password must be a non-empty string')
95
+ }
96
+ out.password = optionAuth.password
97
+ }
98
+ if (optionAuth.loginUrl != null) {
99
+ out.loginUrl = optionAuth.loginUrl
100
+ }
101
+ return out
102
+ }
@@ -0,0 +1,90 @@
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.
23
+ */
24
+
25
+ import { RedDBError } from './errors.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
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Transport-agnostic error type shared by the whole driver.
3
+ *
4
+ * Lives in the core so every core module (serialization, auth, NDJSON
5
+ * helpers, the connection handle) can raise it without reaching for a
6
+ * transport-specific module. Imports zero `node:` built-ins.
7
+ */
8
+
9
+ /**
10
+ * RedDB-shaped error. Drivers in other languages should expose an
11
+ * equivalent class with the same `code` field.
12
+ */
13
+ export class RedDBError extends Error {
14
+ constructor(code, message, data) {
15
+ super(message)
16
+ this.name = 'RedDBError'
17
+ this.code = code
18
+ this.data = data ?? null
19
+ }
20
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Transport-agnostic core of `@reddb-io/client`.
3
+ *
4
+ * Everything re-exported here is pure JS with **zero `node:` imports**: the
5
+ * `RedDB` connection handle (and `Collection` / transaction handles), SQL
6
+ * parameter serialization, the `login()` / auth-merge credential flow,
7
+ * insert-id normalization, embedded-URI rejection, the `red://` URL parser,
8
+ * and the NDJSON frame helpers.
9
+ *
10
+ * Streaming is injected, not imported: `RedDB` takes a streaming
11
+ * implementation as a constructor argument (see `core/reddb.js`). The Node
12
+ * entry (`../index.js`) supplies the `node:stream`-based one. A browser
13
+ * entry (separate slice) builds on this same seam.
14
+ */
15
+
16
+ export { RedDBError } from './errors.js'
17
+ export { RedDB, Collection } from './reddb.js'
18
+ export {
19
+ serializeParam,
20
+ assertSupportedParam,
21
+ normalizeQueryParams,
22
+ bytesToBase64,
23
+ isUuidString,
24
+ } from './serialization.js'
25
+ export { login, mergeAuthFromUri } from './auth.js'
26
+ export {
27
+ MIN_INSERT_ID_ENGINE_VERSION,
28
+ requireInsertId,
29
+ requireInsertIds,
30
+ } from './insert-ids.js'
31
+ export { classifyNdjsonFrame, splitLines } from './ndjson.js'
32
+ export {
33
+ EMBEDDED_REJECTION_MESSAGE,
34
+ EmbeddedNotSupported,
35
+ isEmbeddedUri,
36
+ rejectEmbeddedUri,
37
+ } from './embedded-rejection.js'
38
+ export {
39
+ parseUri,
40
+ parseRedUrl,
41
+ parseLegacyUrl,
42
+ deriveLoginUrl,
43
+ } from './url.js'
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Insert-id normalization for `insert()` / `bulkInsert()` results.
3
+ *
4
+ * The server returns either `rid`/`rids` (current) or `id`/`ids` (legacy);
5
+ * these helpers mirror one onto the other so callers see both, and raise
6
+ * `ENGINE_TOO_OLD` when neither is present. Imports zero `node:` built-ins.
7
+ */
8
+
9
+ import { RedDBError } from './errors.js'
10
+
11
+ export const MIN_INSERT_ID_ENGINE_VERSION = '1.0.9'
12
+
13
+ export function requireInsertId(result, method) {
14
+ if (!result || typeof result !== 'object' || (result.rid == null && result.id == null)) {
15
+ throw new RedDBError(
16
+ 'ENGINE_TOO_OLD',
17
+ `${method}() requires RedDB engine >= ${MIN_INSERT_ID_ENGINE_VERSION} with insert id support`,
18
+ )
19
+ }
20
+ if (result.rid == null) result.rid = result.id
21
+ if (result.id == null) result.id = result.rid
22
+ return result
23
+ }
24
+
25
+ export function requireInsertIds(result, expected) {
26
+ if (
27
+ !result ||
28
+ typeof result !== 'object' ||
29
+ (!Array.isArray(result.rids) && !Array.isArray(result.ids))
30
+ ) {
31
+ throw new RedDBError(
32
+ 'ENGINE_TOO_OLD',
33
+ `bulkInsert() requires RedDB engine >= ${MIN_INSERT_ID_ENGINE_VERSION} with bulk insert id support`,
34
+ )
35
+ }
36
+ if (!Array.isArray(result.rids)) result.rids = result.ids
37
+ if (!Array.isArray(result.ids)) result.ids = result.rids
38
+ if (result.rids.length !== expected) {
39
+ throw new RedDBError(
40
+ 'INVALID_RESPONSE',
41
+ `bulkInsert() expected ${expected} rids, got ${result.rids.length}`,
42
+ )
43
+ }
44
+ return result
45
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Pure-JS NDJSON frame helpers shared by every text-framed transport.
3
+ *
4
+ * - `classifyNdjsonFrame()` — parse one NDJSON line into a typed
5
+ * read-session frame (`descriptor` / `cursor` / `row` / `end`), throwing
6
+ * `RedDBError` on an `{error}` frame or unrecognised shape.
7
+ * - `splitLines()` — split a text buffer on `\n` into complete lines plus
8
+ * the trailing remainder, the building block for incremental NDJSON
9
+ * readers.
10
+ *
11
+ * Imports zero `node:` built-ins — no `node:stream`, no `Buffer`.
12
+ */
13
+
14
+ import { RedDBError } from './errors.js'
15
+
16
+ /**
17
+ * Parse an NDJSON line into a typed read-session frame, or `null` for a
18
+ * blank line. Shared by the HTTP and any text-framed transport.
19
+ * @param {string} line
20
+ * @returns {{type:string,value:unknown}|null}
21
+ */
22
+ export function classifyNdjsonFrame(line) {
23
+ const trimmed = line.trim()
24
+ if (trimmed.length === 0) return null
25
+ let parsed
26
+ try {
27
+ parsed = JSON.parse(trimmed)
28
+ } catch (err) {
29
+ throw new RedDBError('STREAM_PROTOCOL', `stream frame is not JSON: ${err.message}`)
30
+ }
31
+ if (parsed && typeof parsed === 'object') {
32
+ if ('descriptor' in parsed) return { type: 'descriptor', value: parsed.descriptor }
33
+ if ('cursor' in parsed) return { type: 'cursor', value: parsed.cursor }
34
+ if ('row' in parsed) return { type: 'row', value: parsed.row }
35
+ if ('end' in parsed) return { type: 'end', value: parsed.end }
36
+ if ('error' in parsed) {
37
+ const e = parsed.error ?? {}
38
+ throw new RedDBError(e.code || 'STREAM_ERROR', e.message || 'stream error', e)
39
+ }
40
+ }
41
+ throw new RedDBError('STREAM_PROTOCOL', `unrecognised stream frame: ${trimmed.slice(0, 120)}`)
42
+ }
43
+
44
+ /**
45
+ * Split a text buffer on `\n` into complete lines plus the trailing
46
+ * remainder (the text after the last newline, which may be a partial
47
+ * line awaiting more bytes). Pure — no I/O, no streams.
48
+ * @param {string} buffer
49
+ * @returns {{ lines: string[], rest: string }}
50
+ */
51
+ export function splitLines(buffer) {
52
+ const lines = []
53
+ let nl
54
+ while ((nl = buffer.indexOf('\n')) !== -1) {
55
+ lines.push(buffer.slice(0, nl))
56
+ buffer = buffer.slice(nl + 1)
57
+ }
58
+ return { lines, rest: buffer }
59
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Transport-agnostic connection handle.
3
+ *
4
+ * `RedDB` and its `Collection` / transaction handles own the request
5
+ * shaping (parameter serialization, insert-id normalization, transaction
6
+ * orchestration) that is identical regardless of which wire the underlying
7
+ * `client` speaks. The class is constructed with a low-level transport
8
+ * `client` (anything exposing `call(method, params)` / `close()`, and
9
+ * optionally `streamSelect` / `streamInput`) plus an injected `streaming`
10
+ * implementation.
11
+ *
12
+ * Streaming is **injected**, never imported: `stream()` / `inputStream()`
13
+ * delegate to `streaming.createSelectStream` / `streaming.createInputStream`
14
+ * supplied by the entrypoint, so this module never statically references
15
+ * `node:stream` (or Web streams). A connection built without a streaming
16
+ * implementation raises `STREAMING_UNSUPPORTED` if a stream is requested.
17
+ *
18
+ * Imports zero `node:` built-ins.
19
+ */
20
+
21
+ import { RedDBError } from './errors.js'
22
+ import { normalizeQueryParams } from './serialization.js'
23
+ import { requireInsertId, requireInsertIds } from './insert-ids.js'
24
+ import { CacheClient } from '../cache.js'
25
+ import { KvClient } from '../kv.js'
26
+ import { QueueClient } from '../queue.js'
27
+ import { DocumentClient } from '../documents.js'
28
+ import { ConfigClient } from '../config.js'
29
+ import { VaultClient } from '../vault.js'
30
+ import { TypedQueryBuilder, collectionExists, listCollections } from '../db-helpers.js'
31
+
32
+ const NESTED_TX_NOT_SUPPORTED = 'NESTED_TX_NOT_SUPPORTED'
33
+
34
+ /**
35
+ * Connection handle. Methods map 1:1 to JSON-RPC methods on the server.
36
+ * Identical surface to `@reddb-io/sdk`'s `RedDB`, minus the local-spawn
37
+ * lifecycle.
38
+ */
39
+ class TransactionHandle {
40
+ constructor(db) {
41
+ this.db = db
42
+ }
43
+
44
+ query(sql, ...params) {
45
+ return this.db.query(sql, ...params)
46
+ }
47
+
48
+ execute(sql, ...params) {
49
+ return this.db.execute(sql, ...params)
50
+ }
51
+
52
+ insert(collection, payload) {
53
+ return this.db.insert(collection, payload)
54
+ }
55
+
56
+ bulkInsert(collection, payloads) {
57
+ return this.db.bulkInsert(collection, payloads)
58
+ }
59
+
60
+ async transaction() {
61
+ throw nestedTransactionError()
62
+ }
63
+ }
64
+
65
+ /**
66
+ * A streaming-capable collection/table handle (PRD #759 S11). `query()`
67
+ * stays a one-shot Promise so callers never accidentally pay streaming
68
+ * overhead for small reads; `stream()` / `inputStream()` are the explicit
69
+ * streaming surfaces. The bound `name` is the default ingest target.
70
+ */
71
+ export class Collection {
72
+ /** @param {RedDB} db @param {string} name */
73
+ constructor(db, name) {
74
+ if (typeof name !== 'string' || name.length === 0) {
75
+ throw new RedDBError('INVALID_COLLECTION', 'collection(name) requires a non-empty name')
76
+ }
77
+ this.db = db
78
+ this.name = name
79
+ }
80
+
81
+ /** One-shot Promise query — no streaming surface leakage. */
82
+ query(sql, ...params) {
83
+ return this.db.query(sql, ...params)
84
+ }
85
+
86
+ /** Stream a read-only SELECT as a Readable/AsyncIterable. */
87
+ stream(sql, opts = {}) {
88
+ return this.db.stream(sql, opts)
89
+ }
90
+
91
+ /** Open a streaming write into this collection. */
92
+ inputStream(opts = {}) {
93
+ return this.db.inputStream(this.name, opts)
94
+ }
95
+ }
96
+
97
+ export class RedDB {
98
+ /**
99
+ * @param {object} client transport exposing `call`/`close` (and optionally
100
+ * `streamSelect`/`streamInput`).
101
+ * @param {{ createSelectStream: Function, createInputStream: Function }} [streaming]
102
+ * injected streaming implementation. The entrypoint supplies the
103
+ * `node:stream`-based one; omit it for transports that never stream.
104
+ */
105
+ constructor(client, streaming) {
106
+ this.client = client
107
+ this._streaming = streaming ?? null
108
+ this.cache = new CacheClient(client)
109
+ this.queue = new QueueClient(client)
110
+ this.documents = new DocumentClient(this)
111
+ const defaultKv = new KvClient(client)
112
+ this.kv = Object.assign((collection = 'kv_default') => new KvClient(client, collection), {
113
+ put: defaultKv.put.bind(defaultKv),
114
+ invalidateTags: defaultKv.invalidateTags.bind(defaultKv),
115
+ watch: defaultKv.watch.bind(defaultKv),
116
+ watchPrefix: defaultKv.watchPrefix.bind(defaultKv),
117
+ })
118
+ this.config = (collection = 'red.config') => new ConfigClient(client, collection)
119
+ this.vault = (collection = 'red.vault') => new VaultClient(client, collection)
120
+ this.inTransaction = false
121
+ }
122
+
123
+ /** Execute a SQL query. Returns `{ statement, affected, columns, rows }`. */
124
+ query(sql, ...params) {
125
+ const wireParams = normalizeQueryParams(params)
126
+ if (wireParams == null) {
127
+ return this.client.call('query', { sql })
128
+ }
129
+ return this.client.call('query', { sql, params: wireParams })
130
+ }
131
+
132
+ /** Execute a SQL statement. Alias for `query`, including parameter binding. */
133
+ execute(sql, ...params) {
134
+ return this.query(sql, ...params)
135
+ }
136
+
137
+ /** Insert one row. Returns `{ affected, rid, id }`; `id` is a legacy alias for `rid`. */
138
+ async insert(collection, payload) {
139
+ let result = await this.client.call('insert', { collection, payload })
140
+ if (
141
+ result &&
142
+ typeof result === 'object' &&
143
+ !('affected' in result) &&
144
+ ('rid' in result || 'id' in result)
145
+ ) {
146
+ result = { ...result, affected: 1 }
147
+ }
148
+ return requireInsertId(result, 'insert')
149
+ }
150
+
151
+ /** Insert many rows in one call. Returns `{ affected, rids, ids }`; `ids` is a legacy alias. */
152
+ async bulkInsert(collection, payloads) {
153
+ const result = await this.client.call('bulk_insert', { collection, payloads })
154
+ return requireInsertIds(result, payloads.length)
155
+ }
156
+
157
+ async transaction(callback) {
158
+ if (this.inTransaction) {
159
+ throw nestedTransactionError()
160
+ }
161
+ if (typeof callback !== 'function') {
162
+ throw new TypeError('transaction(callback) requires a function')
163
+ }
164
+
165
+ this.inTransaction = true
166
+ let began = false
167
+ try {
168
+ await this.query('BEGIN')
169
+ began = true
170
+ const result = await callback(new TransactionHandle(this))
171
+ await this.query('COMMIT')
172
+ return result
173
+ } catch (err) {
174
+ if (began) {
175
+ try {
176
+ await this.query('ROLLBACK')
177
+ } catch (rollbackErr) {
178
+ attachRollbackError(err, rollbackErr)
179
+ }
180
+ }
181
+ throw err
182
+ } finally {
183
+ this.inTransaction = false
184
+ }
185
+ }
186
+
187
+ /** Return true when a collection is visible in the catalog. */
188
+ exists(collection) {
189
+ return collectionExists(this, collection)
190
+ }
191
+
192
+ /** List visible collections using SHOW COLLECTIONS. */
193
+ list() {
194
+ return listCollections(this)
195
+ }
196
+
197
+ /** Return a caller-typed query builder for a collection. */
198
+ from(collection) {
199
+ return new TypedQueryBuilder(this, collection)
200
+ }
201
+
202
+ /**
203
+ * Return a streaming-capable handle for a collection/table. Exposes the
204
+ * explicit method separation of PRD #759: `.query()` is a one-shot
205
+ * Promise, `.stream()` is a streaming Readable, `.inputStream()` is a
206
+ * streaming Writable. The collection name binds the ingest target.
207
+ */
208
+ collection(name) {
209
+ return new Collection(this, name)
210
+ }
211
+
212
+ /**
213
+ * Stream a read-only SELECT. Returns a Node `Readable` in object mode
214
+ * that also conforms to `AsyncIterable<Row>`. Uses RedWire when the
215
+ * connection is RedWire, HTTP NDJSON when it is HTTP — identical surface
216
+ * either way. Call `.cancel(reason?)` to terminate mid-stream.
217
+ *
218
+ * Delegates to the injected streaming implementation.
219
+ */
220
+ stream(sql, opts = {}) {
221
+ return this.#streaming().createSelectStream(this.client, sql, opts)
222
+ }
223
+
224
+ /**
225
+ * Open a streaming write into `target`. Returns a Node `Writable` in
226
+ * object mode whose `.completion()` resolves with the server's terminal
227
+ * envelope. Call `.cancel(reason?)` to abandon the ingest.
228
+ *
229
+ * Delegates to the injected streaming implementation.
230
+ */
231
+ inputStream(target, opts = {}) {
232
+ return this.#streaming().createInputStream(this.client, target, opts)
233
+ }
234
+
235
+ #streaming() {
236
+ if (!this._streaming) {
237
+ throw new RedDBError(
238
+ 'STREAMING_UNSUPPORTED',
239
+ 'this connection was built without a streaming implementation',
240
+ )
241
+ }
242
+ return this._streaming
243
+ }
244
+
245
+ /** Get an entity by id. Returns `{ entity }` (entity is `null` if not found). */
246
+ get(collection, id) {
247
+ return this.client.call('get', { collection, id: String(id) })
248
+ }
249
+
250
+ /** Delete an entity by id. Returns `{ affected }`. */
251
+ delete(collection, id) {
252
+ return this.client.call('delete', { collection, id: String(id) })
253
+ }
254
+
255
+ /** Probe the server. Returns `{ ok: true, version }`. */
256
+ health() {
257
+ return this.client.call('health', {})
258
+ }
259
+
260
+ /** Server version + protocol version. */
261
+ version() {
262
+ return this.client.call('version', {})
263
+ }
264
+
265
+ /** Exchange username + password for a bearer token. */
266
+ login(username, password) {
267
+ return this.client.call('auth.login', { username, password })
268
+ }
269
+
270
+ /** Identify the current caller. */
271
+ whoami() {
272
+ return this.client.call('auth.whoami', {})
273
+ }
274
+
275
+ /** Change the current caller's password. */
276
+ changePassword(currentPassword, newPassword) {
277
+ return this.client.call('auth.change_password', {
278
+ current_password: currentPassword,
279
+ new_password: newPassword,
280
+ })
281
+ }
282
+
283
+ /** Mint a long-lived API key. */
284
+ createApiKey({ username, role } = {}) {
285
+ return this.client.call('auth.create_api_key', { username, role })
286
+ }
287
+
288
+ /** Revoke an API key by its public id. */
289
+ revokeApiKey(key) {
290
+ return this.client.call('auth.revoke_api_key', { key })
291
+ }
292
+
293
+ /** Close the underlying transport. */
294
+ close() {
295
+ return this.client.close()
296
+ }
297
+ }
298
+
299
+ function nestedTransactionError() {
300
+ return new RedDBError(
301
+ NESTED_TX_NOT_SUPPORTED,
302
+ `${NESTED_TX_NOT_SUPPORTED}: nested transactions are not supported on one connection`,
303
+ )
304
+ }
305
+
306
+ function attachRollbackError(err, rollbackErr) {
307
+ if (err && typeof err === 'object') {
308
+ try {
309
+ err.rollbackError = rollbackErr
310
+ } catch {
311
+ // Preserve the original callback/query error even for frozen errors.
312
+ }
313
+ }
314
+ }