@reddb-io/client 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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
|
@@ -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
|
+
}
|
package/src/core/url.js
ADDED
|
@@ -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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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) {
|