@reddb-io/sdk 1.0.1
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/LICENSE +661 -0
- package/README.md +199 -0
- package/index.d.ts +362 -0
- package/package.json +50 -0
- package/postinstall.js +86 -0
- package/src/binary.js +66 -0
- package/src/cache.js +137 -0
- package/src/cli.js +25 -0
- package/src/config.js +66 -0
- package/src/http.js +200 -0
- package/src/index.js +432 -0
- package/src/internal/asset-fetcher/asset-name.js +37 -0
- package/src/internal/asset-fetcher/checksum.js +23 -0
- package/src/internal/asset-fetcher/download.js +89 -0
- package/src/internal/asset-fetcher/index.js +52 -0
- package/src/internal/bin-resolver/index.js +57 -0
- package/src/internal/version-compare/index.js +163 -0
- package/src/kv.js +70 -0
- package/src/protocol.js +157 -0
- package/src/redwire.js +723 -0
- package/src/spawn.js +177 -0
- package/src/url.js +271 -0
- package/src/vault.js +58 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RedDB JavaScript driver.
|
|
3
|
+
*
|
|
4
|
+
* Public API:
|
|
5
|
+
* import { connect } from '@reddb-io/sdk'
|
|
6
|
+
* const db = await connect('file:///data.rdb')
|
|
7
|
+
* const result = await db.query('SELECT * FROM users LIMIT 10')
|
|
8
|
+
* const inserted = await db.insert('users', { name: 'Alice' })
|
|
9
|
+
* await db.bulkInsert('users', [{ name: 'Bob' }, { name: 'Carol' }])
|
|
10
|
+
* const row = await db.get('users', '42')
|
|
11
|
+
* await db.delete('users', '42')
|
|
12
|
+
* await db.close()
|
|
13
|
+
*
|
|
14
|
+
* Connection URIs:
|
|
15
|
+
* - 'memory://' — ephemeral in-memory database (embedded)
|
|
16
|
+
* - 'file:///absolute/path' — embedded, persisted to disk
|
|
17
|
+
* - 'grpc://host:port' — remote server via gRPC
|
|
18
|
+
*
|
|
19
|
+
* Authentication (only meaningful for `grpc://`; embedded modes ignore
|
|
20
|
+
* auth options because the spawned binary inherits the caller's
|
|
21
|
+
* filesystem privileges):
|
|
22
|
+
*
|
|
23
|
+
* await connect('grpc://host:5051', {
|
|
24
|
+
* auth: { token: 'sk-...' } // raw bearer / api key
|
|
25
|
+
* })
|
|
26
|
+
* await connect('grpc://host:5051', {
|
|
27
|
+
* auth: { apiKey: 'ak-...' } // alias for token
|
|
28
|
+
* })
|
|
29
|
+
* await connect('grpc://host:5051', {
|
|
30
|
+
* auth: { username: 'admin', password: 'x' } // login flow — driver
|
|
31
|
+
* // calls /auth/login,
|
|
32
|
+
* // caches the bearer
|
|
33
|
+
* })
|
|
34
|
+
*
|
|
35
|
+
* Username/password requires the server to expose the `auth.login`
|
|
36
|
+
* JSON-RPC method (proxied through the gRPC bridge).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { spawnRed } from './spawn.js'
|
|
40
|
+
import { resolveSdkBinary } from './binary.js'
|
|
41
|
+
import { RpcClient, RedDBError } from './protocol.js'
|
|
42
|
+
import { HttpRpcClient } from './http.js'
|
|
43
|
+
import { connectRedwire } from './redwire.js'
|
|
44
|
+
import { parseUri, deriveLoginUrl } from './url.js'
|
|
45
|
+
import { CacheClient } from './cache.js'
|
|
46
|
+
import { KvClient } from './kv.js'
|
|
47
|
+
import { ConfigClient } from './config.js'
|
|
48
|
+
import { VaultClient } from './vault.js'
|
|
49
|
+
|
|
50
|
+
export { RedDBError }
|
|
51
|
+
export { CacheClient } from './cache.js'
|
|
52
|
+
export { KvClient } from './kv.js'
|
|
53
|
+
export { ConfigClient } from './config.js'
|
|
54
|
+
export { VaultClient } from './vault.js'
|
|
55
|
+
export { parseUri, deriveLoginUrl } from './url.js'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Connect to a RedDB instance.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} uri Connection URI. See module docstring for accepted schemes.
|
|
61
|
+
* @param {object} [options]
|
|
62
|
+
* @param {string} [options.binary] Override the path to the `red` binary.
|
|
63
|
+
* @param {object} [options.auth] Authentication credentials. See module docstring.
|
|
64
|
+
* @param {string} [options.auth.token] Bearer / API-key token.
|
|
65
|
+
* @param {string} [options.auth.apiKey] Alias for `token`.
|
|
66
|
+
* @param {string} [options.auth.username] Username for password login.
|
|
67
|
+
* @param {string} [options.auth.password] Password for password login.
|
|
68
|
+
* @returns {Promise<RedDB>}
|
|
69
|
+
*/
|
|
70
|
+
export async function connect(uri, options = {}) {
|
|
71
|
+
const parsed = parseUri(uri)
|
|
72
|
+
const merged = mergeAuthFromUri(parsed, options.auth)
|
|
73
|
+
|
|
74
|
+
// Embedded modes: spawn the binary with stdio JSON-RPC. Auth is
|
|
75
|
+
// not applicable (caller already has filesystem privileges).
|
|
76
|
+
if (parsed.kind === 'embedded') {
|
|
77
|
+
if (merged.token || merged.username) {
|
|
78
|
+
throw new RedDBError(
|
|
79
|
+
'AUTH_NOT_APPLICABLE',
|
|
80
|
+
'auth is only meaningful for remote connections; embedded modes inherit caller privileges.',
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
const args = embeddedArgs(parsed)
|
|
84
|
+
const binary = options.binary ?? resolveSdkBinary()
|
|
85
|
+
const child = await spawnRed(binary, args)
|
|
86
|
+
const client = new RpcClient(child)
|
|
87
|
+
await client.call('version', {})
|
|
88
|
+
return new RedDB(client)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// HTTP / HTTPS: speak directly to the server via fetch().
|
|
92
|
+
if (parsed.kind === 'http' || parsed.kind === 'https') {
|
|
93
|
+
const baseUrl = `${parsed.kind}://${parsed.host}:${parsed.port}`
|
|
94
|
+
let token = merged.token
|
|
95
|
+
if (!token && merged.username && merged.password) {
|
|
96
|
+
const loginUrl = merged.loginUrl ?? `${baseUrl}/auth/login`
|
|
97
|
+
const session = await login(loginUrl, {
|
|
98
|
+
username: merged.username,
|
|
99
|
+
password: merged.password,
|
|
100
|
+
})
|
|
101
|
+
token = session.token
|
|
102
|
+
}
|
|
103
|
+
const client = new HttpRpcClient({ baseUrl, token })
|
|
104
|
+
// Sanity check before returning the handle.
|
|
105
|
+
await client.call('health', {})
|
|
106
|
+
return new RedDB(client)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// gRPC / gRPCs / RedWire (default for grpc-shaped URIs):
|
|
110
|
+
// speak the RedWire binary protocol natively via TCP. No spawn, no
|
|
111
|
+
// gRPC bridge. Resolves bearer auth from username/password via
|
|
112
|
+
// HTTP /auth/login first when needed.
|
|
113
|
+
//
|
|
114
|
+
// The server multiplexes RedWire on the same port as gRPC and HTTP
|
|
115
|
+
// via the service router's 0xFE detector, so pure grpc:// URLs
|
|
116
|
+
// still flow through RedWire because it wins on perf and parity.
|
|
117
|
+
if (parsed.kind === 'grpc' || parsed.kind === 'grpcs') {
|
|
118
|
+
let token = merged.token
|
|
119
|
+
if (!token && merged.username && merged.password) {
|
|
120
|
+
const loginUrl = merged.loginUrl ?? deriveLoginUrl(parsed)
|
|
121
|
+
const session = await login(loginUrl, {
|
|
122
|
+
username: merged.username,
|
|
123
|
+
password: merged.password,
|
|
124
|
+
})
|
|
125
|
+
token = session.token
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Honour `proto=spawn-grpc` as an escape hatch for callers that
|
|
129
|
+
// explicitly want the legacy stdio→gRPC bridge. Default is the
|
|
130
|
+
// RedWire transport.
|
|
131
|
+
const protoOverride = parsed.params?.get?.('proto') ?? ''
|
|
132
|
+
if (protoOverride === 'spawn-grpc') {
|
|
133
|
+
const args = grpcArgs(parsed, token)
|
|
134
|
+
const binary = options.binary ?? resolveSdkBinary()
|
|
135
|
+
const child = await spawnRed(binary, args)
|
|
136
|
+
const legacy = new RpcClient(child)
|
|
137
|
+
await legacy.call('version', {})
|
|
138
|
+
return new RedDB(legacy)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const auth = token ? { kind: 'bearer', token } : { kind: 'anonymous' }
|
|
142
|
+
const tls = buildTlsOpts(parsed, options.tls)
|
|
143
|
+
const client = await connectRedwire({
|
|
144
|
+
host: parsed.host,
|
|
145
|
+
port: parsed.port,
|
|
146
|
+
auth,
|
|
147
|
+
...(tls ? { tls } : {}),
|
|
148
|
+
})
|
|
149
|
+
return new RedDB(client)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Postgres wire: not yet wired in the driver. Document the gap
|
|
153
|
+
// so users get a clear actionable error instead of a silent
|
|
154
|
+
// unsupported transport.
|
|
155
|
+
if (parsed.kind === 'pg') {
|
|
156
|
+
throw new RedDBError(
|
|
157
|
+
'PG_TRANSPORT_NOT_WIRED',
|
|
158
|
+
"PostgreSQL wire (proto=pg) requires a node-pg-style client; "
|
|
159
|
+
+ "the JS driver doesn't bundle one yet. Use a separate `pg` package "
|
|
160
|
+
+ 'against the same host:port for now, or open an issue if you want it built in.',
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
throw new RedDBError(
|
|
165
|
+
'UNSUPPORTED_KIND',
|
|
166
|
+
`internal: parsed kind '${parsed.kind}' has no transport`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function embeddedArgs(parsed) {
|
|
171
|
+
if (parsed.path) return ['rpc', '--stdio', '--path', parsed.path]
|
|
172
|
+
return ['rpc', '--stdio']
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function grpcArgs(parsed, token) {
|
|
176
|
+
const scheme = parsed.kind === 'grpcs' ? 'grpcs' : 'grpc'
|
|
177
|
+
const url = `${scheme}://${parsed.host}:${parsed.port}${parsed.path ?? ''}`
|
|
178
|
+
const args = ['rpc', '--stdio', '--connect', url]
|
|
179
|
+
if (token) args.push('--token', token)
|
|
180
|
+
return args
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Merge `options.auth` (legacy `{ token, apiKey, username, password }`
|
|
185
|
+
* shape) with credentials lifted from the URI itself. Explicit
|
|
186
|
+
* `options.auth` always wins to keep behaviour predictable.
|
|
187
|
+
*/
|
|
188
|
+
/**
|
|
189
|
+
* Resolve TLS options for a redwire(s) connection.
|
|
190
|
+
*
|
|
191
|
+
* Sources, in priority order:
|
|
192
|
+
* - `options.tls` from the caller (object form), wins everything
|
|
193
|
+
* - `parsed.kind === 'grpcs'` (i.e. `redwires://` or `?proto=grpcs`)
|
|
194
|
+
* - `?tls=true` in the URL params
|
|
195
|
+
* - `?ca=`, `?cert=`, `?key=`, `?servername=`,
|
|
196
|
+
* `?rejectUnauthorized=false` URL params (paths or PEM strings)
|
|
197
|
+
*
|
|
198
|
+
* Returns `null` when TLS isn't requested.
|
|
199
|
+
*/
|
|
200
|
+
function buildTlsOpts(parsed, callerTls) {
|
|
201
|
+
if (callerTls && typeof callerTls === 'object') {
|
|
202
|
+
return callerTls
|
|
203
|
+
}
|
|
204
|
+
const params = parsed.params
|
|
205
|
+
const wantsTls =
|
|
206
|
+
parsed.kind === 'grpcs'
|
|
207
|
+
|| params?.get?.('tls') === 'true'
|
|
208
|
+
|| params?.get?.('tls') === '1'
|
|
209
|
+
if (!wantsTls) return null
|
|
210
|
+
return {
|
|
211
|
+
ca: params?.get?.('ca') ?? undefined,
|
|
212
|
+
cert: params?.get?.('cert') ?? undefined,
|
|
213
|
+
key: params?.get?.('key') ?? undefined,
|
|
214
|
+
servername: params?.get?.('servername') ?? undefined,
|
|
215
|
+
rejectUnauthorized:
|
|
216
|
+
params?.get?.('rejectUnauthorized') === 'false' ? false : true,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function mergeAuthFromUri(parsed, optionAuth) {
|
|
221
|
+
const out = {
|
|
222
|
+
token: parsed.token ?? parsed.apiKey ?? null,
|
|
223
|
+
username: parsed.username ?? null,
|
|
224
|
+
password: parsed.password ?? null,
|
|
225
|
+
loginUrl: parsed.loginUrl ?? null,
|
|
226
|
+
}
|
|
227
|
+
if (optionAuth == null) return out
|
|
228
|
+
if (typeof optionAuth !== 'object') {
|
|
229
|
+
throw new TypeError('options.auth must be an object')
|
|
230
|
+
}
|
|
231
|
+
if (optionAuth.token != null) {
|
|
232
|
+
if (typeof optionAuth.token !== 'string' || optionAuth.token.length === 0) {
|
|
233
|
+
throw new TypeError('options.auth.token must be a non-empty string')
|
|
234
|
+
}
|
|
235
|
+
out.token = optionAuth.token
|
|
236
|
+
}
|
|
237
|
+
if (optionAuth.apiKey != null) {
|
|
238
|
+
if (typeof optionAuth.apiKey !== 'string' || optionAuth.apiKey.length === 0) {
|
|
239
|
+
throw new TypeError('options.auth.apiKey must be a non-empty string')
|
|
240
|
+
}
|
|
241
|
+
out.token = optionAuth.apiKey
|
|
242
|
+
}
|
|
243
|
+
if (optionAuth.username != null) {
|
|
244
|
+
if (typeof optionAuth.username !== 'string' || optionAuth.username.length === 0) {
|
|
245
|
+
throw new TypeError('options.auth.username must be a non-empty string')
|
|
246
|
+
}
|
|
247
|
+
out.username = optionAuth.username
|
|
248
|
+
}
|
|
249
|
+
if (optionAuth.password != null) {
|
|
250
|
+
if (typeof optionAuth.password !== 'string' || optionAuth.password.length === 0) {
|
|
251
|
+
throw new TypeError('options.auth.password must be a non-empty string')
|
|
252
|
+
}
|
|
253
|
+
out.password = optionAuth.password
|
|
254
|
+
}
|
|
255
|
+
if (optionAuth.loginUrl != null) {
|
|
256
|
+
out.loginUrl = optionAuth.loginUrl
|
|
257
|
+
}
|
|
258
|
+
return out
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Exchange username + password for a bearer token by hitting the
|
|
263
|
+
* server's `POST /auth/login` HTTP endpoint, then return that token
|
|
264
|
+
* for use with subsequent `connect({ auth: { token } })` calls.
|
|
265
|
+
*
|
|
266
|
+
* Why a separate function: the gRPC surface does not currently
|
|
267
|
+
* expose `auth.login` as an RPC, so the driver can't piggyback on
|
|
268
|
+
* the binary spawn for password auth. The HTTP listener does
|
|
269
|
+
* expose it, and is the canonical login site (the same endpoint
|
|
270
|
+
* the dashboard uses).
|
|
271
|
+
*
|
|
272
|
+
* @param {string} loginUrl Full URL of the server's auth endpoint
|
|
273
|
+
* (e.g. `https://reddb.example.com/auth/login`).
|
|
274
|
+
* @param {{ username: string, password: string }} credentials
|
|
275
|
+
* @returns {Promise<{ token: string, username: string, role: string, expires_at: number }>}
|
|
276
|
+
*/
|
|
277
|
+
export async function login(loginUrl, { username, password }) {
|
|
278
|
+
if (typeof loginUrl !== 'string' || !loginUrl.startsWith('http')) {
|
|
279
|
+
throw new TypeError("login() requires an http(s):// URL pointing at /auth/login")
|
|
280
|
+
}
|
|
281
|
+
if (typeof username !== 'string' || username.length === 0) {
|
|
282
|
+
throw new TypeError('login() requires a non-empty username')
|
|
283
|
+
}
|
|
284
|
+
if (typeof password !== 'string' || password.length === 0) {
|
|
285
|
+
throw new TypeError('login() requires a non-empty password')
|
|
286
|
+
}
|
|
287
|
+
const response = await fetch(loginUrl, {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: { 'content-type': 'application/json' },
|
|
290
|
+
body: JSON.stringify({ username, password }),
|
|
291
|
+
})
|
|
292
|
+
const body = await response.json().catch(() => ({}))
|
|
293
|
+
if (!response.ok || body.ok === false) {
|
|
294
|
+
const code = body.error_code || `HTTP_${response.status}`
|
|
295
|
+
const message = body.error || `auth/login returned ${response.status}`
|
|
296
|
+
throw new RedDBError(code, message, body)
|
|
297
|
+
}
|
|
298
|
+
if (typeof body.token !== 'string') {
|
|
299
|
+
throw new RedDBError(
|
|
300
|
+
'AUTH_LOGIN_BAD_RESPONSE',
|
|
301
|
+
'auth/login response missing string token',
|
|
302
|
+
body,
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
return body
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Backwards-compatible shim: translate a URI into argv for
|
|
310
|
+
* `red rpc --stdio`. New code should call `parseUri` directly and
|
|
311
|
+
* route via `connect`. Kept exported for tests that pre-date the
|
|
312
|
+
* `red://` parser.
|
|
313
|
+
*/
|
|
314
|
+
export function uriToArgs(uri, auth = null) {
|
|
315
|
+
const parsed = parseUri(uri)
|
|
316
|
+
if (parsed.kind === 'embedded') return embeddedArgs(parsed)
|
|
317
|
+
if (parsed.kind === 'grpc' || parsed.kind === 'grpcs') {
|
|
318
|
+
const token = auth?.kind === 'token' ? auth.token : (parsed.token ?? parsed.apiKey ?? null)
|
|
319
|
+
return grpcArgs(parsed, token)
|
|
320
|
+
}
|
|
321
|
+
throw new RedDBError(
|
|
322
|
+
'UNSUPPORTED_SCHEME',
|
|
323
|
+
`uriToArgs() supports embedded + grpc kinds; for '${parsed.kind}' use connect() directly.`,
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Connection handle. Methods map 1:1 to JSON-RPC methods on the binary.
|
|
330
|
+
*/
|
|
331
|
+
export class RedDB {
|
|
332
|
+
/** @param {RpcClient} client */
|
|
333
|
+
constructor(client) {
|
|
334
|
+
this.client = client
|
|
335
|
+
this.cache = new CacheClient(client)
|
|
336
|
+
const defaultKv = new KvClient(client)
|
|
337
|
+
this.kv = Object.assign((collection = 'kv_default') => new KvClient(client, collection), {
|
|
338
|
+
put: defaultKv.put.bind(defaultKv),
|
|
339
|
+
invalidateTags: defaultKv.invalidateTags.bind(defaultKv),
|
|
340
|
+
watch: defaultKv.watch.bind(defaultKv),
|
|
341
|
+
watchPrefix: defaultKv.watchPrefix.bind(defaultKv),
|
|
342
|
+
})
|
|
343
|
+
this.config = (collection = 'red.config') => new ConfigClient(client, collection)
|
|
344
|
+
this.vault = (collection = 'red.vault') => new VaultClient(client, collection)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Execute a SQL query. Returns `{ statement, affected, columns, rows }`. */
|
|
348
|
+
query(sql) {
|
|
349
|
+
return this.client.call('query', { sql })
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Insert one row. Returns `{ affected, id? }`. */
|
|
353
|
+
insert(collection, payload) {
|
|
354
|
+
return this.client.call('insert', { collection, payload })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Insert many rows in one call. Returns `{ affected }`. */
|
|
358
|
+
bulkInsert(collection, payloads) {
|
|
359
|
+
return this.client.call('bulk_insert', { collection, payloads })
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Get an entity by id. Returns `{ entity }` (entity is `null` if not found). */
|
|
363
|
+
get(collection, id) {
|
|
364
|
+
return this.client.call('get', { collection, id: String(id) })
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Delete an entity by id. Returns `{ affected }`. */
|
|
368
|
+
delete(collection, id) {
|
|
369
|
+
return this.client.call('delete', { collection, id: String(id) })
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Probe the server. Returns `{ ok: true, version }`. */
|
|
373
|
+
health() {
|
|
374
|
+
return this.client.call('health', {})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Server version + protocol version. */
|
|
378
|
+
version() {
|
|
379
|
+
return this.client.call('version', {})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------
|
|
383
|
+
// Auth surface — these are no-ops in embedded mode because the
|
|
384
|
+
// bridge layer doesn't expose `auth.*` JSON-RPC methods locally.
|
|
385
|
+
// They forward to the server when the connection is grpc://.
|
|
386
|
+
// ---------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Exchange username + password for a bearer token. Returns
|
|
390
|
+
* `{ token, username, role, expires_at }`. Server-side this
|
|
391
|
+
* routes to `POST /auth/login`.
|
|
392
|
+
*
|
|
393
|
+
* Prefer the `auth: { username, password }` form on `connect()`
|
|
394
|
+
* — it does the same exchange + caches the token transparently.
|
|
395
|
+
*/
|
|
396
|
+
login(username, password) {
|
|
397
|
+
return this.client.call('auth.login', { username, password })
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Identify the current caller. Returns `{ username, role }`. */
|
|
401
|
+
whoami() {
|
|
402
|
+
return this.client.call('auth.whoami', {})
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Change the current caller's password. */
|
|
406
|
+
changePassword(currentPassword, newPassword) {
|
|
407
|
+
return this.client.call('auth.change_password', {
|
|
408
|
+
current_password: currentPassword,
|
|
409
|
+
new_password: newPassword,
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Mint a long-lived API key for the caller (or a sub-user, when
|
|
415
|
+
* the caller has `Admin` role). Returns `{ key, role, created_at }`.
|
|
416
|
+
* Pass the returned `key` back via `auth: { apiKey: key }` on
|
|
417
|
+
* future `connect()` calls.
|
|
418
|
+
*/
|
|
419
|
+
createApiKey({ username, role } = {}) {
|
|
420
|
+
return this.client.call('auth.create_api_key', { username, role })
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Revoke an API key by its public id. */
|
|
424
|
+
revokeApiKey(key) {
|
|
425
|
+
return this.client.call('auth.revoke_api_key', { key })
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Close the connection and wait for the binary to exit. */
|
|
429
|
+
close() {
|
|
430
|
+
return this.client.close()
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the GitHub release asset filename for a given Node
|
|
3
|
+
* `process.platform` / `process.arch` pair and binary base name.
|
|
4
|
+
*
|
|
5
|
+
* Mapping is the existing scheme from `drivers/js/postinstall.js`:
|
|
6
|
+
* linux + x64 → <bin>-linux-x86_64
|
|
7
|
+
* linux + arm64 → <bin>-linux-aarch64
|
|
8
|
+
* linux + arm/armv7l→ <bin>-linux-armv7
|
|
9
|
+
* darwin + x64 → <bin>-macos-x86_64
|
|
10
|
+
* darwin + arm64 → <bin>-macos-aarch64
|
|
11
|
+
* win32 + x64 → <bin>-windows-x86_64.exe
|
|
12
|
+
*
|
|
13
|
+
* Throws `UnsupportedPlatformError` for any other combination.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export class UnsupportedPlatformError extends Error {
|
|
17
|
+
constructor(platform, arch) {
|
|
18
|
+
super(`unsupported platform/arch combination: ${platform}/${arch}`)
|
|
19
|
+
this.name = 'UnsupportedPlatformError'
|
|
20
|
+
this.code = 'UNSUPPORTED_PLATFORM'
|
|
21
|
+
this.platform = platform
|
|
22
|
+
this.arch = arch
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function composeAssetName({ platform, arch, binName }) {
|
|
27
|
+
if (typeof binName !== 'string' || binName === '') {
|
|
28
|
+
throw new TypeError('composeAssetName: `binName` must be a non-empty string')
|
|
29
|
+
}
|
|
30
|
+
if (platform === 'linux' && arch === 'x64') return `${binName}-linux-x86_64`
|
|
31
|
+
if (platform === 'linux' && arch === 'arm64') return `${binName}-linux-aarch64`
|
|
32
|
+
if (platform === 'linux' && (arch === 'arm' || arch === 'armv7l')) return `${binName}-linux-armv7`
|
|
33
|
+
if (platform === 'darwin' && arch === 'x64') return `${binName}-macos-x86_64`
|
|
34
|
+
if (platform === 'darwin' && arch === 'arm64') return `${binName}-macos-aarch64`
|
|
35
|
+
if (platform === 'win32' && arch === 'x64') return `${binName}-windows-x86_64.exe`
|
|
36
|
+
throw new UnsupportedPlatformError(platform, arch)
|
|
37
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export class ChecksumMismatchError extends Error {
|
|
4
|
+
constructor(expected, actual) {
|
|
5
|
+
super(`checksum mismatch: expected sha256=${expected}, got sha256=${actual}`)
|
|
6
|
+
this.name = 'ChecksumMismatchError'
|
|
7
|
+
this.code = 'CHECKSUM_MISMATCH'
|
|
8
|
+
this.expected = expected
|
|
9
|
+
this.actual = actual
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function sha256Hex(buf) {
|
|
14
|
+
return createHash('sha256').update(buf).digest('hex')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function verifySha256(buf, expected) {
|
|
18
|
+
const expectedNorm = String(expected).trim().toLowerCase()
|
|
19
|
+
const actual = sha256Hex(buf)
|
|
20
|
+
if (actual !== expectedNorm) {
|
|
21
|
+
throw new ChecksumMismatchError(expectedNorm, actual)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { request as httpsRequest } from 'node:https'
|
|
2
|
+
import { request as httpRequest } from 'node:http'
|
|
3
|
+
|
|
4
|
+
const MAX_REDIRECTS = 5
|
|
5
|
+
|
|
6
|
+
export class AssetNotFoundError extends Error {
|
|
7
|
+
constructor(url) {
|
|
8
|
+
super(`asset not found (HTTP 404) at ${url}`)
|
|
9
|
+
this.name = 'AssetNotFoundError'
|
|
10
|
+
this.code = 'ASSET_NOT_FOUND'
|
|
11
|
+
this.url = url
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class HttpError extends Error {
|
|
16
|
+
constructor(status, url) {
|
|
17
|
+
super(`HTTP ${status} fetching ${url}`)
|
|
18
|
+
this.name = 'HttpError'
|
|
19
|
+
this.code = 'HTTP_ERROR'
|
|
20
|
+
this.status = status
|
|
21
|
+
this.url = url
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class TooManyRedirectsError extends Error {
|
|
26
|
+
constructor(url) {
|
|
27
|
+
super(`too many redirects (>${MAX_REDIRECTS}) starting at ${url}`)
|
|
28
|
+
this.name = 'TooManyRedirectsError'
|
|
29
|
+
this.code = 'TOO_MANY_REDIRECTS'
|
|
30
|
+
this.url = url
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pickRequest(url) {
|
|
35
|
+
return url.startsWith('http://') ? httpRequest : httpsRequest
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveLocation(currentUrl, location) {
|
|
39
|
+
if (/^https?:\/\//i.test(location)) return location
|
|
40
|
+
return new URL(location, currentUrl).toString()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function downloadFollowingRedirects(url, { userAgent, originalUrl } = {}, depth = 0) {
|
|
44
|
+
const startUrl = originalUrl || url
|
|
45
|
+
if (depth > MAX_REDIRECTS) {
|
|
46
|
+
return Promise.reject(new TooManyRedirectsError(startUrl))
|
|
47
|
+
}
|
|
48
|
+
const request = pickRequest(url)
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const req = request(
|
|
51
|
+
url,
|
|
52
|
+
{
|
|
53
|
+
method: 'GET',
|
|
54
|
+
headers: {
|
|
55
|
+
'User-Agent': userAgent || 'reddb-internal-asset-fetcher',
|
|
56
|
+
Accept: 'application/octet-stream',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
(res) => {
|
|
60
|
+
const status = res.statusCode || 0
|
|
61
|
+
if (status >= 300 && status < 400 && res.headers.location) {
|
|
62
|
+
res.resume()
|
|
63
|
+
const next = resolveLocation(url, res.headers.location)
|
|
64
|
+
downloadFollowingRedirects(next, { userAgent, originalUrl: startUrl }, depth + 1).then(
|
|
65
|
+
resolve,
|
|
66
|
+
reject,
|
|
67
|
+
)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (status === 404) {
|
|
71
|
+
res.resume()
|
|
72
|
+
reject(new AssetNotFoundError(startUrl))
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (status < 200 || status >= 300) {
|
|
76
|
+
res.resume()
|
|
77
|
+
reject(new HttpError(status, url))
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const chunks = []
|
|
81
|
+
res.on('data', (chunk) => chunks.push(chunk))
|
|
82
|
+
res.on('end', () => resolve(Buffer.concat(chunks)))
|
|
83
|
+
res.on('error', reject)
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
req.on('error', reject)
|
|
87
|
+
req.end()
|
|
88
|
+
})
|
|
89
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @reddb-io/internal-asset-fetcher — fetch a `red`/`red_client` binary
|
|
3
|
+
* from a GitHub release.
|
|
4
|
+
*
|
|
5
|
+
* Public surface: one function.
|
|
6
|
+
*
|
|
7
|
+
* fetchReleaseAsset({ repo, tag, platform, arch, binName, sha256? }) → Buffer
|
|
8
|
+
*
|
|
9
|
+
* Steps:
|
|
10
|
+
* 1. Map (platform, arch, binName) → asset filename.
|
|
11
|
+
* 2. Compose the GitHub download URL: `https://github.com/<repo>/releases/download/<tag>/<asset>`.
|
|
12
|
+
* 3. Follow up to 5 redirects, returning the final body as a Buffer.
|
|
13
|
+
* 4. If `sha256` was supplied, verify before returning.
|
|
14
|
+
*
|
|
15
|
+
* Errors carry distinct `.code` values so callers can differentiate:
|
|
16
|
+
* - UNSUPPORTED_PLATFORM — no asset for this platform/arch
|
|
17
|
+
* - ASSET_NOT_FOUND — HTTP 404 (release/tag/asset name wrong)
|
|
18
|
+
* - CHECKSUM_MISMATCH — body downloaded but sha256 mismatched
|
|
19
|
+
* - HTTP_ERROR — any other non-2xx status
|
|
20
|
+
* - TOO_MANY_REDIRECTS — redirect chain longer than 5 hops
|
|
21
|
+
*
|
|
22
|
+
* Internal modules (`./asset-name.js`, `./download.js`, `./checksum.js`)
|
|
23
|
+
* are not part of the public contract — only `fetchReleaseAsset` is.
|
|
24
|
+
* They are imported directly in tests for focused coverage.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { composeAssetName } from './asset-name.js'
|
|
28
|
+
import { downloadFollowingRedirects } from './download.js'
|
|
29
|
+
import { verifySha256 } from './checksum.js'
|
|
30
|
+
|
|
31
|
+
export async function fetchReleaseAsset({ repo, tag, platform, arch, binName, sha256 } = {}) {
|
|
32
|
+
if (typeof repo !== 'string' || repo === '') {
|
|
33
|
+
throw new TypeError('fetchReleaseAsset: `repo` must be a non-empty string (e.g. "reddb-io/reddb")')
|
|
34
|
+
}
|
|
35
|
+
if (typeof tag !== 'string' || tag === '') {
|
|
36
|
+
throw new TypeError('fetchReleaseAsset: `tag` must be a non-empty string (e.g. "v0.2.9")')
|
|
37
|
+
}
|
|
38
|
+
if (typeof platform !== 'string' || platform === '') {
|
|
39
|
+
throw new TypeError('fetchReleaseAsset: `platform` must be a non-empty string')
|
|
40
|
+
}
|
|
41
|
+
if (typeof arch !== 'string' || arch === '') {
|
|
42
|
+
throw new TypeError('fetchReleaseAsset: `arch` must be a non-empty string')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const assetName = composeAssetName({ platform, arch, binName })
|
|
46
|
+
const url = `https://github.com/${repo}/releases/download/${tag}/${assetName}`
|
|
47
|
+
const body = await downloadFollowingRedirects(url)
|
|
48
|
+
if (sha256) {
|
|
49
|
+
verifySha256(body, sha256)
|
|
50
|
+
}
|
|
51
|
+
return body
|
|
52
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @reddb-io/internal-bin-resolver — runtime lookup for a pinned binary.
|
|
3
|
+
*
|
|
4
|
+
* Precedence:
|
|
5
|
+
* 1. `process.env[envVar]` if set and non-empty → returned verbatim.
|
|
6
|
+
* No existence probe: the env var is the user's "I know what I'm
|
|
7
|
+
* doing" override.
|
|
8
|
+
* 2. `<packageRoot>/bin/<name>` if it exists.
|
|
9
|
+
* 3. Otherwise throw an actionable error naming the env var, the
|
|
10
|
+
* expected local path, and a one-line `pnpm install` hint.
|
|
11
|
+
*
|
|
12
|
+
* `PATH` is deliberately never consulted. SDK/client wire formats are
|
|
13
|
+
* version-coupled to the binary; silent fallback to a stale `PATH`
|
|
14
|
+
* binary fails as misframed RPC, not "command not found". See ADR 0006.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync } from 'node:fs'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ name: string, packageRoot: string, envVar: string }} opts
|
|
22
|
+
* @returns {string} absolute path to the binary
|
|
23
|
+
* @throws {Error} when neither env override nor local binary is usable
|
|
24
|
+
*/
|
|
25
|
+
export function resolveBin(opts) {
|
|
26
|
+
if (!opts || typeof opts !== 'object') {
|
|
27
|
+
throw new TypeError('resolveBin: options object required')
|
|
28
|
+
}
|
|
29
|
+
const { name, packageRoot, envVar } = opts
|
|
30
|
+
if (typeof name !== 'string' || name === '') {
|
|
31
|
+
throw new TypeError('resolveBin: `name` must be a non-empty string')
|
|
32
|
+
}
|
|
33
|
+
if (typeof packageRoot !== 'string' || packageRoot === '') {
|
|
34
|
+
throw new TypeError('resolveBin: `packageRoot` must be a non-empty string')
|
|
35
|
+
}
|
|
36
|
+
if (typeof envVar !== 'string' || envVar === '') {
|
|
37
|
+
throw new TypeError('resolveBin: `envVar` must be a non-empty string')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const override = process.env?.[envVar]
|
|
41
|
+
if (typeof override === 'string' && override !== '') {
|
|
42
|
+
return override
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const local = join(packageRoot, 'bin', name)
|
|
46
|
+
if (existsSync(local)) {
|
|
47
|
+
return local
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw new Error(
|
|
51
|
+
`reddb: binary "${name}" not found.\n` +
|
|
52
|
+
` expected at: ${local}\n` +
|
|
53
|
+
` override: set ${envVar}=/path/to/${name}\n` +
|
|
54
|
+
` fix: re-run \`pnpm install\` (the postinstall script downloads it),\n` +
|
|
55
|
+
` or check the postinstall log for a download error.`,
|
|
56
|
+
)
|
|
57
|
+
}
|