@reddb-io/sdk 1.0.7 → 1.1.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 +42 -10
- package/index.d.ts +120 -42
- package/package.json +3 -3
- package/src/cache.js +33 -2
- package/src/db-helpers.js +95 -0
- package/src/http.js +7 -2
- package/src/index.js +209 -226
- package/src/kv.js +23 -1
- package/src/queue.js +78 -0
- package/src/redwire.js +221 -6
package/src/index.js
CHANGED
|
@@ -14,46 +14,35 @@
|
|
|
14
14
|
* Connection URIs:
|
|
15
15
|
* - 'memory://' — ephemeral in-memory database (embedded)
|
|
16
16
|
* - 'file:///absolute/path' — embedded, persisted to disk
|
|
17
|
-
* - 'grpc://host:port' — remote server via gRPC
|
|
18
17
|
*
|
|
19
|
-
*
|
|
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).
|
|
18
|
+
* Remote URIs belong to @reddb-io/client. This SDK is embedded-only.
|
|
37
19
|
*/
|
|
38
20
|
|
|
39
21
|
import { spawnRed } from './spawn.js'
|
|
40
22
|
import { resolveSdkBinary } from './binary.js'
|
|
41
23
|
import { RpcClient, RedDBError } from './protocol.js'
|
|
42
|
-
import {
|
|
43
|
-
import { connectRedwire } from './redwire.js'
|
|
44
|
-
import { parseUri, deriveLoginUrl } from './url.js'
|
|
24
|
+
import { parseUri } from './url.js'
|
|
45
25
|
import { CacheClient } from './cache.js'
|
|
46
26
|
import { KvClient } from './kv.js'
|
|
27
|
+
import { QueueClient } from './queue.js'
|
|
47
28
|
import { ConfigClient } from './config.js'
|
|
48
29
|
import { VaultClient } from './vault.js'
|
|
30
|
+
import { TypedQueryBuilder, collectionExists, listCollections } from './db-helpers.js'
|
|
49
31
|
|
|
50
32
|
export { RedDBError }
|
|
51
33
|
export { CacheClient } from './cache.js'
|
|
52
34
|
export { KvClient } from './kv.js'
|
|
35
|
+
export { QueueClient } from './queue.js'
|
|
53
36
|
export { ConfigClient } from './config.js'
|
|
54
37
|
export { VaultClient } from './vault.js'
|
|
38
|
+
export { TypedQueryBuilder } from './db-helpers.js'
|
|
55
39
|
export { parseUri, deriveLoginUrl } from './url.js'
|
|
56
40
|
|
|
41
|
+
export const EMBEDDED_ONLY_MESSAGE =
|
|
42
|
+
'remote URIs are not supported in @reddb-io/sdk; install @reddb-io/client for grpc/http/red transports'
|
|
43
|
+
|
|
44
|
+
const MIN_INSERT_ID_ENGINE_VERSION = '1.0.9'
|
|
45
|
+
|
|
57
46
|
/**
|
|
58
47
|
* Connect to a RedDB instance.
|
|
59
48
|
*
|
|
@@ -69,11 +58,12 @@ export { parseUri, deriveLoginUrl } from './url.js'
|
|
|
69
58
|
*/
|
|
70
59
|
export async function connect(uri, options = {}) {
|
|
71
60
|
const parsed = parseUri(uri)
|
|
72
|
-
|
|
61
|
+
rejectRemoteUri(parsed)
|
|
73
62
|
|
|
74
63
|
// Embedded modes: spawn the binary with stdio JSON-RPC. Auth is
|
|
75
64
|
// not applicable (caller already has filesystem privileges).
|
|
76
65
|
if (parsed.kind === 'embedded') {
|
|
66
|
+
const merged = mergeAuthFromUri(parsed, options.auth)
|
|
77
67
|
if (merged.token || merged.username) {
|
|
78
68
|
throw new RedDBError(
|
|
79
69
|
'AUTH_NOT_APPLICABLE',
|
|
@@ -85,95 +75,130 @@ export async function connect(uri, options = {}) {
|
|
|
85
75
|
const child = await spawnRed(binary, args)
|
|
86
76
|
const client = new RpcClient(child)
|
|
87
77
|
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
|
-
}
|
|
78
|
+
return new RedDB(client, { transport: 'embedded' })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
140
81
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
82
|
+
// Coerce a JS query parameter to a JSON-serializable shape the server
|
|
83
|
+
// understands. Values JSON cannot represent losslessly use the
|
|
84
|
+
// stdio/HTTP query parameter envelopes.
|
|
85
|
+
function serializeParam(value) {
|
|
86
|
+
assertSupportedParam(value)
|
|
87
|
+
if (value instanceof Float32Array || value instanceof Float64Array) {
|
|
88
|
+
return Array.from(value)
|
|
89
|
+
}
|
|
90
|
+
if (value instanceof Date) {
|
|
91
|
+
return { $ts: String(BigInt(value.getTime()) * 1_000_000n) }
|
|
92
|
+
}
|
|
93
|
+
if (value instanceof Uint8Array || (typeof Buffer !== 'undefined' && value instanceof Buffer)) {
|
|
94
|
+
return { $bytes: bytesToBase64(value) }
|
|
150
95
|
}
|
|
96
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
97
|
+
if (Number.isNaN(value)) return { $float: 'NaN' }
|
|
98
|
+
return { $float: value > 0 ? 'Infinity' : '-Infinity' }
|
|
99
|
+
}
|
|
100
|
+
if (typeof value === 'string' && isUuidString(value)) {
|
|
101
|
+
return { $uuid: value }
|
|
102
|
+
}
|
|
103
|
+
return value
|
|
104
|
+
}
|
|
151
105
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
106
|
+
function assertSupportedParam(value) {
|
|
107
|
+
if (value == null) return
|
|
108
|
+
if (
|
|
109
|
+
typeof value === 'boolean'
|
|
110
|
+
|| typeof value === 'number'
|
|
111
|
+
|| typeof value === 'string'
|
|
112
|
+
) {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (value instanceof Date) {
|
|
116
|
+
if (Number.isNaN(value.getTime())) {
|
|
117
|
+
throw new RedDBError('UNSUPPORTED_PARAM', 'cannot encode invalid Date query parameter')
|
|
118
|
+
}
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
value instanceof Uint8Array
|
|
123
|
+
|| value instanceof Float32Array
|
|
124
|
+
|| value instanceof Float64Array
|
|
125
|
+
|| (typeof Buffer !== 'undefined' && value instanceof Buffer)
|
|
126
|
+
) {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
if (Array.isArray(value)) {
|
|
130
|
+
if (value.every((item) => typeof item === 'number')) return
|
|
156
131
|
throw new RedDBError(
|
|
157
|
-
'
|
|
158
|
-
|
|
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.',
|
|
132
|
+
'UNSUPPORTED_PARAM',
|
|
133
|
+
'array query parameters must contain only numbers',
|
|
161
134
|
)
|
|
162
135
|
}
|
|
163
|
-
|
|
136
|
+
if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
137
|
+
return
|
|
138
|
+
}
|
|
164
139
|
throw new RedDBError(
|
|
165
|
-
'
|
|
166
|
-
`
|
|
140
|
+
'UNSUPPORTED_PARAM',
|
|
141
|
+
`cannot encode query parameter of type ${typeof value}`,
|
|
167
142
|
)
|
|
168
143
|
}
|
|
169
144
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
145
|
+
function normalizeQueryParams(args) {
|
|
146
|
+
if (args.length === 0) return null
|
|
147
|
+
if (args.length === 1 && Array.isArray(args[0])) return args[0].map(serializeParam)
|
|
148
|
+
return args.map(serializeParam)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function bytesToBase64(value) {
|
|
152
|
+
const bytes = value instanceof Uint8Array
|
|
153
|
+
? value
|
|
154
|
+
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
|
155
|
+
if (typeof Buffer !== 'undefined') {
|
|
156
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64')
|
|
157
|
+
}
|
|
158
|
+
let text = ''
|
|
159
|
+
for (const byte of bytes) text += String.fromCharCode(byte)
|
|
160
|
+
// eslint-disable-next-line no-undef
|
|
161
|
+
return btoa(text)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function base64ToBytes(value) {
|
|
165
|
+
if (typeof Buffer !== 'undefined') {
|
|
166
|
+
const buf = Buffer.from(value, 'base64')
|
|
167
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
|
|
168
|
+
}
|
|
169
|
+
// eslint-disable-next-line no-undef
|
|
170
|
+
const text = atob(value)
|
|
171
|
+
const out = new Uint8Array(text.length)
|
|
172
|
+
for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i)
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isUuidString(value) {
|
|
177
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function normalizeResult(value) {
|
|
181
|
+
if (Array.isArray(value)) return value.map(normalizeResult)
|
|
182
|
+
if (value && typeof value === 'object') {
|
|
183
|
+
const keys = Object.keys(value)
|
|
184
|
+
if (keys.length === 1) {
|
|
185
|
+
if (typeof value.$bytes === 'string') return base64ToBytes(value.$bytes)
|
|
186
|
+
if (typeof value.$uuid === 'string') return value.$uuid
|
|
187
|
+
if (typeof value.$float === 'string') {
|
|
188
|
+
if (value.$float === 'NaN') return Number.NaN
|
|
189
|
+
if (value.$float === 'Infinity' || value.$float === '+Infinity') return Infinity
|
|
190
|
+
if (value.$float === '-Infinity') return -Infinity
|
|
191
|
+
}
|
|
192
|
+
if (typeof value.$ts === 'string' || typeof value.$ts === 'number') {
|
|
193
|
+
const raw = typeof value.$ts === 'string'
|
|
194
|
+
? BigInt(value.$ts)
|
|
195
|
+
: BigInt(Math.trunc(value.$ts))
|
|
196
|
+
return new Date(Number(raw / 1_000_000n))
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const out = {}
|
|
200
|
+
for (const [key, item] of Object.entries(value)) out[key] = normalizeResult(item)
|
|
201
|
+
return out
|
|
177
202
|
}
|
|
178
203
|
return value
|
|
179
204
|
}
|
|
@@ -183,51 +208,11 @@ function embeddedArgs(parsed) {
|
|
|
183
208
|
return ['rpc', '--stdio']
|
|
184
209
|
}
|
|
185
210
|
|
|
186
|
-
function grpcArgs(parsed, token) {
|
|
187
|
-
const scheme = parsed.kind === 'grpcs' ? 'grpcs' : 'grpc'
|
|
188
|
-
const url = `${scheme}://${parsed.host}:${parsed.port}${parsed.path ?? ''}`
|
|
189
|
-
const args = ['rpc', '--stdio', '--connect', url]
|
|
190
|
-
if (token) args.push('--token', token)
|
|
191
|
-
return args
|
|
192
|
-
}
|
|
193
|
-
|
|
194
211
|
/**
|
|
195
212
|
* Merge `options.auth` (legacy `{ token, apiKey, username, password }`
|
|
196
213
|
* shape) with credentials lifted from the URI itself. Explicit
|
|
197
214
|
* `options.auth` always wins to keep behaviour predictable.
|
|
198
215
|
*/
|
|
199
|
-
/**
|
|
200
|
-
* Resolve TLS options for a redwire(s) connection.
|
|
201
|
-
*
|
|
202
|
-
* Sources, in priority order:
|
|
203
|
-
* - `options.tls` from the caller (object form), wins everything
|
|
204
|
-
* - `parsed.kind === 'grpcs'` (i.e. `redwires://` or `?proto=grpcs`)
|
|
205
|
-
* - `?tls=true` in the URL params
|
|
206
|
-
* - `?ca=`, `?cert=`, `?key=`, `?servername=`,
|
|
207
|
-
* `?rejectUnauthorized=false` URL params (paths or PEM strings)
|
|
208
|
-
*
|
|
209
|
-
* Returns `null` when TLS isn't requested.
|
|
210
|
-
*/
|
|
211
|
-
function buildTlsOpts(parsed, callerTls) {
|
|
212
|
-
if (callerTls && typeof callerTls === 'object') {
|
|
213
|
-
return callerTls
|
|
214
|
-
}
|
|
215
|
-
const params = parsed.params
|
|
216
|
-
const wantsTls =
|
|
217
|
-
parsed.kind === 'grpcs'
|
|
218
|
-
|| params?.get?.('tls') === 'true'
|
|
219
|
-
|| params?.get?.('tls') === '1'
|
|
220
|
-
if (!wantsTls) return null
|
|
221
|
-
return {
|
|
222
|
-
ca: params?.get?.('ca') ?? undefined,
|
|
223
|
-
cert: params?.get?.('cert') ?? undefined,
|
|
224
|
-
key: params?.get?.('key') ?? undefined,
|
|
225
|
-
servername: params?.get?.('servername') ?? undefined,
|
|
226
|
-
rejectUnauthorized:
|
|
227
|
-
params?.get?.('rejectUnauthorized') === 'false' ? false : true,
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
216
|
function mergeAuthFromUri(parsed, optionAuth) {
|
|
232
217
|
const out = {
|
|
233
218
|
token: parsed.token ?? parsed.apiKey ?? null,
|
|
@@ -269,53 +254,6 @@ function mergeAuthFromUri(parsed, optionAuth) {
|
|
|
269
254
|
return out
|
|
270
255
|
}
|
|
271
256
|
|
|
272
|
-
/**
|
|
273
|
-
* Exchange username + password for a bearer token by hitting the
|
|
274
|
-
* server's `POST /auth/login` HTTP endpoint, then return that token
|
|
275
|
-
* for use with subsequent `connect({ auth: { token } })` calls.
|
|
276
|
-
*
|
|
277
|
-
* Why a separate function: the gRPC surface does not currently
|
|
278
|
-
* expose `auth.login` as an RPC, so the driver can't piggyback on
|
|
279
|
-
* the binary spawn for password auth. The HTTP listener does
|
|
280
|
-
* expose it, and is the canonical login site (the same endpoint
|
|
281
|
-
* the dashboard uses).
|
|
282
|
-
*
|
|
283
|
-
* @param {string} loginUrl Full URL of the server's auth endpoint
|
|
284
|
-
* (e.g. `https://reddb.example.com/auth/login`).
|
|
285
|
-
* @param {{ username: string, password: string }} credentials
|
|
286
|
-
* @returns {Promise<{ token: string, username: string, role: string, expires_at: number }>}
|
|
287
|
-
*/
|
|
288
|
-
export async function login(loginUrl, { username, password }) {
|
|
289
|
-
if (typeof loginUrl !== 'string' || !loginUrl.startsWith('http')) {
|
|
290
|
-
throw new TypeError("login() requires an http(s):// URL pointing at /auth/login")
|
|
291
|
-
}
|
|
292
|
-
if (typeof username !== 'string' || username.length === 0) {
|
|
293
|
-
throw new TypeError('login() requires a non-empty username')
|
|
294
|
-
}
|
|
295
|
-
if (typeof password !== 'string' || password.length === 0) {
|
|
296
|
-
throw new TypeError('login() requires a non-empty password')
|
|
297
|
-
}
|
|
298
|
-
const response = await fetch(loginUrl, {
|
|
299
|
-
method: 'POST',
|
|
300
|
-
headers: { 'content-type': 'application/json' },
|
|
301
|
-
body: JSON.stringify({ username, password }),
|
|
302
|
-
})
|
|
303
|
-
const body = await response.json().catch(() => ({}))
|
|
304
|
-
if (!response.ok || body.ok === false) {
|
|
305
|
-
const code = body.error_code || `HTTP_${response.status}`
|
|
306
|
-
const message = body.error || `auth/login returned ${response.status}`
|
|
307
|
-
throw new RedDBError(code, message, body)
|
|
308
|
-
}
|
|
309
|
-
if (typeof body.token !== 'string') {
|
|
310
|
-
throw new RedDBError(
|
|
311
|
-
'AUTH_LOGIN_BAD_RESPONSE',
|
|
312
|
-
'auth/login response missing string token',
|
|
313
|
-
body,
|
|
314
|
-
)
|
|
315
|
-
}
|
|
316
|
-
return body
|
|
317
|
-
}
|
|
318
|
-
|
|
319
257
|
/**
|
|
320
258
|
* Backwards-compatible shim: translate a URI into argv for
|
|
321
259
|
* `red rpc --stdio`. New code should call `parseUri` directly and
|
|
@@ -325,14 +263,12 @@ export async function login(loginUrl, { username, password }) {
|
|
|
325
263
|
export function uriToArgs(uri, auth = null) {
|
|
326
264
|
const parsed = parseUri(uri)
|
|
327
265
|
if (parsed.kind === 'embedded') return embeddedArgs(parsed)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
`uriToArgs() supports embedded + grpc kinds; for '${parsed.kind}' use connect() directly.`,
|
|
335
|
-
)
|
|
266
|
+
rejectRemoteUri(parsed)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function rejectRemoteUri(parsed) {
|
|
270
|
+
if (parsed.kind === 'embedded') return
|
|
271
|
+
throw new RedDBError('EMBEDDED_ONLY', EMBEDDED_ONLY_MESSAGE)
|
|
336
272
|
}
|
|
337
273
|
|
|
338
274
|
|
|
@@ -340,10 +276,18 @@ export function uriToArgs(uri, auth = null) {
|
|
|
340
276
|
* Connection handle. Methods map 1:1 to JSON-RPC methods on the binary.
|
|
341
277
|
*/
|
|
342
278
|
export class RedDB {
|
|
343
|
-
/**
|
|
344
|
-
|
|
279
|
+
/**
|
|
280
|
+
* @param {RpcClient} client
|
|
281
|
+
* @param {object} [opts]
|
|
282
|
+
* @param {string} [opts.transport] Underlying transport label
|
|
283
|
+
* (normally 'embedded'). Used to gate calls that the embedded
|
|
284
|
+
* stdio bridge does not serve, like `cache.*`.
|
|
285
|
+
*/
|
|
286
|
+
constructor(client, opts = {}) {
|
|
345
287
|
this.client = client
|
|
346
|
-
this.
|
|
288
|
+
this.transport = opts.transport ?? null
|
|
289
|
+
this.cache = new CacheClient(client, this.transport)
|
|
290
|
+
this.queue = new QueueClient(client)
|
|
347
291
|
const defaultKv = new KvClient(client)
|
|
348
292
|
this.kv = Object.assign((collection = 'kv_default') => new KvClient(client, collection), {
|
|
349
293
|
put: defaultKv.put.bind(defaultKv),
|
|
@@ -360,32 +304,49 @@ export class RedDB {
|
|
|
360
304
|
*
|
|
361
305
|
* Two signatures:
|
|
362
306
|
* - `query(sql)` — legacy single-arg form.
|
|
363
|
-
* - `query(sql, params)` — positional `$N` bind values.
|
|
364
|
-
*
|
|
365
|
-
* int/float / text / null). Indices in the SQL are 1-based
|
|
366
|
-
* (`$1`, `$2`, ...), `params` is 0-based JS-style.
|
|
307
|
+
* - `query(sql, ...params)` — positional `$N` bind values.
|
|
308
|
+
* - `query(sql, paramsArray)` — legacy array form.
|
|
367
309
|
*
|
|
368
310
|
* Returns `{ statement, affected, columns, rows }`.
|
|
369
311
|
*/
|
|
370
|
-
query(sql, params) {
|
|
371
|
-
|
|
372
|
-
|
|
312
|
+
query(sql, ...params) {
|
|
313
|
+
const wireParams = normalizeQueryParams(params)
|
|
314
|
+
if (wireParams == null) {
|
|
315
|
+
return this.client.call('query', { sql }).then(normalizeResult)
|
|
373
316
|
}
|
|
374
|
-
|
|
375
|
-
throw new TypeError('query: `params` must be an array')
|
|
376
|
-
}
|
|
377
|
-
const wireParams = params.map(serializeParam)
|
|
378
|
-
return this.client.call('query', { sql, params: wireParams })
|
|
317
|
+
return this.client.call('query', { sql, params: wireParams }).then(normalizeResult)
|
|
379
318
|
}
|
|
380
319
|
|
|
381
|
-
/**
|
|
382
|
-
|
|
383
|
-
return this.
|
|
320
|
+
/** Execute a SQL statement. Alias for `query`, including parameter binding. */
|
|
321
|
+
execute(sql, ...params) {
|
|
322
|
+
return this.query(sql, ...params)
|
|
384
323
|
}
|
|
385
324
|
|
|
386
|
-
/** Insert
|
|
387
|
-
|
|
388
|
-
|
|
325
|
+
/** Insert one row. Returns `{ affected, id }`. */
|
|
326
|
+
async insert(collection, payload) {
|
|
327
|
+
const result = await this.client.call('insert', { collection, payload })
|
|
328
|
+
return requireInsertId(result, 'insert')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Insert many rows in one call. Returns `{ affected, ids }`. */
|
|
332
|
+
async bulkInsert(collection, payloads) {
|
|
333
|
+
const result = await this.client.call('bulk_insert', { collection, payloads })
|
|
334
|
+
return requireInsertIds(result, payloads.length)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Return true when a collection is visible in the catalog. */
|
|
338
|
+
exists(collection) {
|
|
339
|
+
return collectionExists(this, collection)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** List visible collections using SHOW COLLECTIONS. */
|
|
343
|
+
list() {
|
|
344
|
+
return listCollections(this)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Return a caller-typed query builder for a collection. */
|
|
348
|
+
from(collection) {
|
|
349
|
+
return new TypedQueryBuilder(this, collection)
|
|
389
350
|
}
|
|
390
351
|
|
|
391
352
|
/** Get an entity by id. Returns `{ entity }` (entity is `null` if not found). */
|
|
@@ -409,18 +370,14 @@ export class RedDB {
|
|
|
409
370
|
}
|
|
410
371
|
|
|
411
372
|
// ---------------------------------------------------------------
|
|
412
|
-
// Auth surface — these are
|
|
373
|
+
// Auth surface — these are not available in embedded mode because the
|
|
413
374
|
// bridge layer doesn't expose `auth.*` JSON-RPC methods locally.
|
|
414
|
-
//
|
|
375
|
+
// Use @reddb-io/client for remote authenticated servers.
|
|
415
376
|
// ---------------------------------------------------------------
|
|
416
377
|
|
|
417
378
|
/**
|
|
418
|
-
* Exchange username + password for a bearer token
|
|
419
|
-
*
|
|
420
|
-
* routes to `POST /auth/login`.
|
|
421
|
-
*
|
|
422
|
-
* Prefer the `auth: { username, password }` form on `connect()`
|
|
423
|
-
* — it does the same exchange + caches the token transparently.
|
|
379
|
+
* Exchange username + password for a bearer token when the underlying
|
|
380
|
+
* client supports auth RPCs. Embedded SDK connections do not.
|
|
424
381
|
*/
|
|
425
382
|
login(username, password) {
|
|
426
383
|
return this.client.call('auth.login', { username, password })
|
|
@@ -459,3 +416,29 @@ export class RedDB {
|
|
|
459
416
|
return this.client.close()
|
|
460
417
|
}
|
|
461
418
|
}
|
|
419
|
+
|
|
420
|
+
function requireInsertId(result, method) {
|
|
421
|
+
if (!result || typeof result !== 'object' || result.id == null) {
|
|
422
|
+
throw new RedDBError(
|
|
423
|
+
'ENGINE_TOO_OLD',
|
|
424
|
+
`${method}() requires RedDB engine >= ${MIN_INSERT_ID_ENGINE_VERSION} with insert id support`,
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
return result
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function requireInsertIds(result, expected) {
|
|
431
|
+
if (!result || typeof result !== 'object' || !Array.isArray(result.ids)) {
|
|
432
|
+
throw new RedDBError(
|
|
433
|
+
'ENGINE_TOO_OLD',
|
|
434
|
+
`bulkInsert() requires RedDB engine >= ${MIN_INSERT_ID_ENGINE_VERSION} with bulk insert id support`,
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
if (result.ids.length !== expected) {
|
|
438
|
+
throw new RedDBError(
|
|
439
|
+
'INVALID_RESPONSE',
|
|
440
|
+
`bulkInsert() expected ${expected} ids, got ${result.ids.length}`,
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
return result
|
|
444
|
+
}
|
package/src/kv.js
CHANGED
|
@@ -17,6 +17,20 @@ export class KvClient {
|
|
|
17
17
|
})
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
async get(key, options = {}) {
|
|
21
|
+
const collection = options.collection ?? this.collection
|
|
22
|
+
const result = await this.client.call('query', {
|
|
23
|
+
sql: `KV GET ${kvPath(collection, key)}`,
|
|
24
|
+
})
|
|
25
|
+
return result?.rows?.[0]?.value ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async getMany(keys, options = {}) {
|
|
29
|
+
const values = []
|
|
30
|
+
for (const key of keys) values.push(await this.get(key, options))
|
|
31
|
+
return values
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
async invalidateTags(tags, options = {}) {
|
|
21
35
|
const collection = options.collection ?? this.collection
|
|
22
36
|
const result = await this.client.call('query', {
|
|
@@ -56,7 +70,15 @@ function kvPath(collection, key) {
|
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
function kvIdentifier(value) {
|
|
59
|
-
|
|
73
|
+
const ident = String(value)
|
|
74
|
+
const invalid = ident.match(/[^A-Za-z0-9_]/)
|
|
75
|
+
if (invalid) {
|
|
76
|
+
throw new RedDBError(
|
|
77
|
+
'INVALID_KV_KEY',
|
|
78
|
+
`invalid KV key "${ident}": character "${invalid[0]}" is not supported`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
return ident
|
|
60
82
|
}
|
|
61
83
|
|
|
62
84
|
function kvValueLiteral(value) {
|
package/src/queue.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { RedDBError } from './protocol.js'
|
|
2
|
+
|
|
3
|
+
export class QueueClient {
|
|
4
|
+
constructor(client) {
|
|
5
|
+
this.client = client
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
push(queue, value, options = {}) {
|
|
9
|
+
const priority = options.priority != null ? ` PRIORITY ${queuePriority(options.priority)}` : ''
|
|
10
|
+
return this.client.call('query', {
|
|
11
|
+
sql: `QUEUE PUSH ${queueIdentifier(queue)} ${queueValueLiteral(value)}${priority}`,
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async pop(queue, count) {
|
|
16
|
+
const result = await this.client.call('query', {
|
|
17
|
+
sql: `QUEUE POP ${queueIdentifier(queue)}${queueCount(count)}`,
|
|
18
|
+
})
|
|
19
|
+
return queuePayloads(result)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async peek(queue, count) {
|
|
23
|
+
const result = await this.client.call('query', {
|
|
24
|
+
sql: `QUEUE PEEK ${queueIdentifier(queue)}${queueCount(count)}`,
|
|
25
|
+
})
|
|
26
|
+
return queuePayloads(result)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async len(queue) {
|
|
30
|
+
const result = await this.client.call('query', {
|
|
31
|
+
sql: `QUEUE LEN ${queueIdentifier(queue)}`,
|
|
32
|
+
})
|
|
33
|
+
return Number(result?.rows?.[0]?.len ?? 0)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
purge(queue) {
|
|
37
|
+
return this.client.call('query', {
|
|
38
|
+
sql: `QUEUE PURGE ${queueIdentifier(queue)}`,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function queueIdentifier(value) {
|
|
44
|
+
const ident = String(value)
|
|
45
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ident)) {
|
|
46
|
+
throw new RedDBError(
|
|
47
|
+
'INVALID_QUEUE_NAME',
|
|
48
|
+
`invalid queue name "${ident}": expected an SQL identifier`,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
return ident
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function queueCount(count) {
|
|
55
|
+
if (count == null) return ''
|
|
56
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
57
|
+
throw new RedDBError('INVALID_QUEUE_COUNT', 'queue count must be a non-negative integer')
|
|
58
|
+
}
|
|
59
|
+
return ` COUNT ${count}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function queuePriority(priority) {
|
|
63
|
+
if (!Number.isInteger(priority)) {
|
|
64
|
+
throw new RedDBError('INVALID_QUEUE_PRIORITY', 'queue priority must be an integer')
|
|
65
|
+
}
|
|
66
|
+
return String(priority)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function queueValueLiteral(value) {
|
|
70
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
71
|
+
if (value == null) return 'NULL'
|
|
72
|
+
if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`
|
|
73
|
+
return JSON.stringify(value)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function queuePayloads(result) {
|
|
77
|
+
return Array.isArray(result?.rows) ? result.rows.map((row) => row.payload) : []
|
|
78
|
+
}
|