@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/core/auth.js
ADDED
|
@@ -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
|
+
}
|