@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
package/src/streaming.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-native streaming surface for the JS driver (PRD #759 / S11).
|
|
3
|
+
*
|
|
4
|
+
* Two transport-agnostic wrappers turn a low-level streaming *session*
|
|
5
|
+
* — supplied by whichever transport the connection uses (HTTP NDJSON or
|
|
6
|
+
* RedWire) — into idiomatic Node streams:
|
|
7
|
+
*
|
|
8
|
+
* - `RowReadable` — a `Readable` in object mode that also conforms to
|
|
9
|
+
* `AsyncIterable<Row>` (Readable already is one). Rows flow with
|
|
10
|
+
* natural backpressure via `read()` / `pause()` / `resume()`. The
|
|
11
|
+
* descriptor and the resumable cursor (when the transport surfaces
|
|
12
|
+
* them) arrive as `'descriptor'` / `'cursor'` events. A mid-stream
|
|
13
|
+
* `error` frame surfaces both as an `'error'` event and as a
|
|
14
|
+
* rejected `for await` iteration.
|
|
15
|
+
* - `RowWritable` — a `Writable` in object mode. Backpressure flows
|
|
16
|
+
* through `write()`'s return value and `'drain'`. `end()` signals
|
|
17
|
+
* end-of-stream; the server's terminal envelope resolves the
|
|
18
|
+
* `.completion()` promise.
|
|
19
|
+
*
|
|
20
|
+
* Both expose a uniform `cancel(reason?)` that terminates the underlying
|
|
21
|
+
* transport stream (a `StreamCancel` frame over RedWire, an
|
|
22
|
+
* `AbortController.abort()` over HTTP) and rejects anything pending.
|
|
23
|
+
*
|
|
24
|
+
* The transport session contracts these wrappers consume:
|
|
25
|
+
*
|
|
26
|
+
* read session (transport.streamSelect):
|
|
27
|
+
* { [Symbol.asyncIterator](): AsyncIterator<{type,value}>,
|
|
28
|
+
* cancel(reason): Promise<void> }
|
|
29
|
+
* where `type` is 'descriptor' | 'cursor' | 'row' | 'end'; the
|
|
30
|
+
* iterator throws a `RedDBError` when the server emits an error frame.
|
|
31
|
+
*
|
|
32
|
+
* write session (transport.streamInput):
|
|
33
|
+
* { write(row): Promise<void>, // resolves when accepted (backpressure)
|
|
34
|
+
* close(): Promise<EndEnvelope>, // send terminal, await server end
|
|
35
|
+
* cancel(reason): Promise<void> }
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { Readable, Writable, Transform } from 'node:stream'
|
|
39
|
+
import { RedDBError } from './protocol.js'
|
|
40
|
+
import { classifyNdjsonFrame } from './core/ndjson.js'
|
|
41
|
+
|
|
42
|
+
// `classifyNdjsonFrame` is a pure NDJSON helper that now lives in the
|
|
43
|
+
// transport-agnostic core; re-exported here so the historical
|
|
44
|
+
// `import { classifyNdjsonFrame } from './streaming.js'` path keeps working.
|
|
45
|
+
export { classifyNdjsonFrame }
|
|
46
|
+
|
|
47
|
+
function cancelError(reason) {
|
|
48
|
+
const suffix = typeof reason === 'string' && reason.length > 0 ? `: ${reason}` : ''
|
|
49
|
+
return new RedDBError('STREAM_CANCELLED', `stream cancelled${suffix}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `Readable` (object mode) over a transport read session. Also an
|
|
54
|
+
* `AsyncIterable<Row>` — `for await (const row of stream)` yields each
|
|
55
|
+
* row and exits cleanly on stream end.
|
|
56
|
+
*/
|
|
57
|
+
export class RowReadable extends Readable {
|
|
58
|
+
/**
|
|
59
|
+
* @param {Promise<object>} sessionPromise resolves to a read session.
|
|
60
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
61
|
+
*/
|
|
62
|
+
constructor(sessionPromise, { signal } = {}) {
|
|
63
|
+
super({ objectMode: true })
|
|
64
|
+
this._sessionPromise = sessionPromise
|
|
65
|
+
this._session = null
|
|
66
|
+
this._iter = null
|
|
67
|
+
this._pumping = false
|
|
68
|
+
this._ended = false
|
|
69
|
+
this._cancelReason = undefined
|
|
70
|
+
/** Schema descriptor (HTTP NDJSON) once seen; null otherwise. */
|
|
71
|
+
this.descriptor = null
|
|
72
|
+
/** Resumable cursor control frame (#807) once seen; null otherwise. */
|
|
73
|
+
this.cursor = null
|
|
74
|
+
/** Terminal `end` envelope once the stream completes; null otherwise. */
|
|
75
|
+
this.endInfo = null
|
|
76
|
+
if (signal) {
|
|
77
|
+
if (signal.aborted) {
|
|
78
|
+
queueMicrotask(() => this.cancel(abortReason(signal)))
|
|
79
|
+
} else {
|
|
80
|
+
signal.addEventListener('abort', () => this.cancel(abortReason(signal)), { once: true })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async _resolveIter() {
|
|
86
|
+
if (this._iter) return this._iter
|
|
87
|
+
this._session = await this._sessionPromise
|
|
88
|
+
this._iter = this._session[Symbol.asyncIterator]()
|
|
89
|
+
return this._iter
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_read() {
|
|
93
|
+
if (this._pumping) return
|
|
94
|
+
this._pumping = true
|
|
95
|
+
this._pump().then(
|
|
96
|
+
() => {
|
|
97
|
+
this._pumping = false
|
|
98
|
+
},
|
|
99
|
+
(err) => {
|
|
100
|
+
this._pumping = false
|
|
101
|
+
this.destroy(err instanceof Error ? err : new RedDBError('STREAM_ERROR', String(err)))
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async _pump() {
|
|
107
|
+
const iter = await this._resolveIter()
|
|
108
|
+
// Loop until backpressure (push returned false) or completion. Non-row
|
|
109
|
+
// control frames (descriptor/cursor) are surfaced as events and do not
|
|
110
|
+
// count against the readable buffer, so we keep pulling after them.
|
|
111
|
+
for (;;) {
|
|
112
|
+
const { value: frame, done } = await iter.next()
|
|
113
|
+
if (done) {
|
|
114
|
+
this._ended = true
|
|
115
|
+
this.push(null)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
if (frame.type === 'descriptor') {
|
|
119
|
+
this.descriptor = frame.value
|
|
120
|
+
this.emit('descriptor', frame.value)
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
if (frame.type === 'cursor') {
|
|
124
|
+
this.cursor = frame.value
|
|
125
|
+
this.emit('cursor', frame.value)
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (frame.type === 'end') {
|
|
129
|
+
this.endInfo = frame.value
|
|
130
|
+
this._ended = true
|
|
131
|
+
this.push(null)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
// frame.type === 'row'
|
|
135
|
+
if (!this.push(frame.value)) {
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_destroy(err, callback) {
|
|
142
|
+
// A stream that ended on its own terminal frame needs no cancel — only
|
|
143
|
+
// an early teardown (error or explicit cancel) signals the transport.
|
|
144
|
+
const session = this._ended ? null : this._session
|
|
145
|
+
const reason = this._cancelReason
|
|
146
|
+
Promise.resolve(session && session.cancel ? session.cancel(reason) : undefined)
|
|
147
|
+
.catch(() => {})
|
|
148
|
+
.finally(() => callback(err))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Terminate the stream. Sends a transport-level cancel and rejects any
|
|
153
|
+
* pending `for await` iteration with a `STREAM_CANCELLED` error.
|
|
154
|
+
* @param {string} [reason]
|
|
155
|
+
* @returns {Promise<void>}
|
|
156
|
+
*/
|
|
157
|
+
cancel(reason) {
|
|
158
|
+
if (this.destroyed) return Promise.resolve()
|
|
159
|
+
this._cancelReason = reason
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
this.once('close', resolve)
|
|
162
|
+
this.destroy(cancelError(reason))
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `Writable` (object mode) over a transport write session. End-of-stream
|
|
169
|
+
* is `end()`; the server's terminal envelope resolves `.completion()`.
|
|
170
|
+
*/
|
|
171
|
+
export class RowWritable extends Writable {
|
|
172
|
+
/**
|
|
173
|
+
* @param {Promise<object>} sessionPromise resolves to a write session.
|
|
174
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
175
|
+
*/
|
|
176
|
+
constructor(sessionPromise, { signal } = {}) {
|
|
177
|
+
super({ objectMode: true })
|
|
178
|
+
this._sessionPromise = sessionPromise
|
|
179
|
+
this._session = null
|
|
180
|
+
this._finished = false
|
|
181
|
+
this._cancelReason = undefined
|
|
182
|
+
this._completion = null
|
|
183
|
+
this._completionPromise = new Promise((resolve, reject) => {
|
|
184
|
+
this._completion = { resolve, reject }
|
|
185
|
+
})
|
|
186
|
+
// Don't crash the process if nobody attaches to completion() and the
|
|
187
|
+
// stream errors — the 'error' event already carries the failure.
|
|
188
|
+
this._completionPromise.catch(() => {})
|
|
189
|
+
if (signal) {
|
|
190
|
+
if (signal.aborted) {
|
|
191
|
+
queueMicrotask(() => this.cancel(abortReason(signal)))
|
|
192
|
+
} else {
|
|
193
|
+
signal.addEventListener('abort', () => this.cancel(abortReason(signal)), { once: true })
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async _resolveSession() {
|
|
199
|
+
if (!this._session) this._session = await this._sessionPromise
|
|
200
|
+
return this._session
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_write(row, _enc, callback) {
|
|
204
|
+
this._resolveSession()
|
|
205
|
+
.then((session) => session.write(row))
|
|
206
|
+
.then(() => callback(), (err) => callback(err))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
_final(callback) {
|
|
210
|
+
this._resolveSession()
|
|
211
|
+
.then((session) => session.close())
|
|
212
|
+
.then(
|
|
213
|
+
(end) => {
|
|
214
|
+
this._finished = true
|
|
215
|
+
this._completion.resolve(end)
|
|
216
|
+
callback()
|
|
217
|
+
},
|
|
218
|
+
(err) => {
|
|
219
|
+
this._completion.reject(err)
|
|
220
|
+
callback(err)
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
_destroy(err, callback) {
|
|
226
|
+
if (err) this._completion.reject(err)
|
|
227
|
+
const session = this._finished ? null : this._session
|
|
228
|
+
const reason = this._cancelReason
|
|
229
|
+
Promise.resolve(session && session.cancel ? session.cancel(reason) : undefined)
|
|
230
|
+
.catch(() => {})
|
|
231
|
+
.finally(() => callback(err))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Resolves with the server's terminal `end` envelope once the stream
|
|
236
|
+
* finishes successfully; rejects if the stream errors or is cancelled.
|
|
237
|
+
* @returns {Promise<object>}
|
|
238
|
+
*/
|
|
239
|
+
completion() {
|
|
240
|
+
return this._completionPromise
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Terminate the stream without flushing the remaining rows. Rejects
|
|
245
|
+
* `.completion()` with a `STREAM_CANCELLED` error.
|
|
246
|
+
* @param {string} [reason]
|
|
247
|
+
* @returns {Promise<void>}
|
|
248
|
+
*/
|
|
249
|
+
cancel(reason) {
|
|
250
|
+
if (this.destroyed) return Promise.resolve()
|
|
251
|
+
this._cancelReason = reason
|
|
252
|
+
return new Promise((resolve) => {
|
|
253
|
+
this.once('close', resolve)
|
|
254
|
+
this.destroy(cancelError(reason))
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function abortReason(signal) {
|
|
260
|
+
const reason = signal?.reason
|
|
261
|
+
if (typeof reason === 'string') return reason
|
|
262
|
+
if (reason && typeof reason.message === 'string') return reason.message
|
|
263
|
+
return 'aborted'
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A `Transform` that splits an NDJSON byte/text stream into parsed row
|
|
268
|
+
* objects, ready to pipe into `table.inputStream()`:
|
|
269
|
+
*
|
|
270
|
+
* fs.createReadStream('rows.ndjson').pipe(splitNdjson()).pipe(table.inputStream())
|
|
271
|
+
*
|
|
272
|
+
* Each non-empty line is `JSON.parse`d; a `{ "row": {...} }` envelope is
|
|
273
|
+
* unwrapped to its inner object so files written by the streaming reader
|
|
274
|
+
* round-trip, while bare-object lines pass through untouched.
|
|
275
|
+
* @returns {Transform}
|
|
276
|
+
*/
|
|
277
|
+
export function splitNdjson() {
|
|
278
|
+
let buffer = ''
|
|
279
|
+
const parseLine = (line, push, callback) => {
|
|
280
|
+
const trimmed = line.trim()
|
|
281
|
+
if (trimmed.length === 0) return true
|
|
282
|
+
let parsed
|
|
283
|
+
try {
|
|
284
|
+
parsed = JSON.parse(trimmed)
|
|
285
|
+
} catch (err) {
|
|
286
|
+
callback(new RedDBError('NDJSON_PARSE_ERROR', `invalid NDJSON line: ${err.message}`))
|
|
287
|
+
return false
|
|
288
|
+
}
|
|
289
|
+
const row = parsed && typeof parsed === 'object' && 'row' in parsed ? parsed.row : parsed
|
|
290
|
+
push(row)
|
|
291
|
+
return true
|
|
292
|
+
}
|
|
293
|
+
return new Transform({
|
|
294
|
+
readableObjectMode: true,
|
|
295
|
+
writableObjectMode: false,
|
|
296
|
+
transform(chunk, _enc, callback) {
|
|
297
|
+
buffer += chunk.toString('utf8')
|
|
298
|
+
let nl
|
|
299
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
300
|
+
const line = buffer.slice(0, nl)
|
|
301
|
+
buffer = buffer.slice(nl + 1)
|
|
302
|
+
if (!parseLine(line, (row) => this.push(row), callback)) return
|
|
303
|
+
}
|
|
304
|
+
callback()
|
|
305
|
+
},
|
|
306
|
+
flush(callback) {
|
|
307
|
+
if (parseLine(buffer, (row) => this.push(row), callback)) callback()
|
|
308
|
+
},
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Build a `RowReadable` from a connection's transport client.
|
|
314
|
+
* @param {object} client transport exposing `streamSelect`.
|
|
315
|
+
* @param {string} sql read-only SELECT to stream.
|
|
316
|
+
* @param {{ signal?: AbortSignal, cursor?: string }} [opts]
|
|
317
|
+
* @returns {RowReadable}
|
|
318
|
+
*/
|
|
319
|
+
export function createSelectStream(client, sql, opts = {}) {
|
|
320
|
+
if (typeof client.streamSelect !== 'function') {
|
|
321
|
+
throw new RedDBError(
|
|
322
|
+
'STREAMING_UNSUPPORTED',
|
|
323
|
+
'the active transport does not support streaming reads (use red:// or http(s)://)',
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
if (opts.cursor == null && (typeof sql !== 'string' || sql.trim().length === 0)) {
|
|
327
|
+
throw new RedDBError('INVALID_STREAM_QUERY', 'stream() requires a non-empty SQL string')
|
|
328
|
+
}
|
|
329
|
+
const sessionPromise = Promise.resolve().then(() =>
|
|
330
|
+
client.streamSelect({ sql, cursor: opts.cursor, signal: opts.signal }),
|
|
331
|
+
)
|
|
332
|
+
return new RowReadable(sessionPromise, { signal: opts.signal })
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Build a `RowWritable` from a connection's transport client.
|
|
337
|
+
* @param {object} client transport exposing `streamInput`.
|
|
338
|
+
* @param {string} target table to ingest into.
|
|
339
|
+
* @param {{ signal?: AbortSignal, columns?: string[] }} [opts] `columns`
|
|
340
|
+
* fixes the ingest column set; when omitted it is inferred from the
|
|
341
|
+
* keys of the first row written.
|
|
342
|
+
* @returns {RowWritable}
|
|
343
|
+
*/
|
|
344
|
+
export function createInputStream(client, target, opts = {}) {
|
|
345
|
+
if (typeof client.streamInput !== 'function') {
|
|
346
|
+
throw new RedDBError(
|
|
347
|
+
'STREAMING_UNSUPPORTED',
|
|
348
|
+
'the active transport does not support streaming writes (use red:// or http(s)://)',
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
if (typeof target !== 'string' || target.trim().length === 0) {
|
|
352
|
+
throw new RedDBError('INVALID_STREAM_TARGET', 'inputStream() requires a non-empty target table')
|
|
353
|
+
}
|
|
354
|
+
const sessionPromise = Promise.resolve().then(() =>
|
|
355
|
+
client.streamInput({
|
|
356
|
+
target,
|
|
357
|
+
columns: opts.columns,
|
|
358
|
+
signal: opts.signal,
|
|
359
|
+
}),
|
|
360
|
+
)
|
|
361
|
+
return new RowWritable(sessionPromise, { signal: opts.signal })
|
|
362
|
+
}
|
package/src/url.js
CHANGED
|
@@ -1,271 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `red://` connection-string parser
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
2
|
+
* Compatibility shim. The `red://` connection-string parser now lives in
|
|
3
|
+
* the transport-agnostic core (`core/url.js`); this file keeps the
|
|
4
|
+
* historical `./url.js` import path working.
|
|
17
5
|
*/
|
|
18
6
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
}
|
|
7
|
+
export {
|
|
8
|
+
parseUri,
|
|
9
|
+
parseRedUrl,
|
|
10
|
+
parseLegacyUrl,
|
|
11
|
+
deriveLoginUrl,
|
|
12
|
+
} from './core/url.js'
|