@reddb-io/sdk 1.0.8 → 1.1.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/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 +6 -2
package/README.md
CHANGED
|
@@ -5,8 +5,9 @@ stdio to a local `red` binary, which is downloaded automatically on install.
|
|
|
5
5
|
Works in **Node 18+**, **Bun** and **Deno** (via `npm:` specifier) — same
|
|
6
6
|
package, no per-runtime fork.
|
|
7
7
|
|
|
8
|
-
Use this package
|
|
9
|
-
|
|
8
|
+
Use this package when your application should run an embedded local RedDB
|
|
9
|
+
engine. For remote HTTP, gRPC, or RedWire connections, install
|
|
10
|
+
`@reddb-io/client` instead. If you just want to launch the CLI from npm, use:
|
|
10
11
|
|
|
11
12
|
```bash
|
|
12
13
|
npx @reddb-io/cli@latest version
|
|
@@ -76,10 +77,9 @@ await db.close()
|
|
|
76
77
|
|----------------------------|--------------------------------------|
|
|
77
78
|
| `memory://` | Ephemeral, in-memory database |
|
|
78
79
|
| `file:///absolute/path` | Embedded engine, persisted to disk |
|
|
79
|
-
| `grpc://host:port` | Remote server (planned, not yet) |
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
Remote URIs such as `http://...`, `red://...`, and `grpc://...` are rejected
|
|
82
|
+
with `EMBEDDED_ONLY`. Use `@reddb-io/client` for those transports.
|
|
83
83
|
|
|
84
84
|
## API
|
|
85
85
|
|
|
@@ -102,7 +102,38 @@ const persisted = await connect('file:///tmp/app.rdb')
|
|
|
102
102
|
const custom = await connect('memory://', { binary: '/usr/local/bin/red' })
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
### `db.query(sql) → Promise<{ statement, affected, columns, rows }>`
|
|
105
|
+
### `db.query(sql, ...params) → Promise<{ statement, affected, columns, rows }>`
|
|
106
|
+
|
|
107
|
+
Bind user values with `$1`, `$2`, ... placeholders. The variadic form is the
|
|
108
|
+
preferred API; the older `db.query(sql, paramsArray)` form remains supported.
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
const result = await db.query(
|
|
112
|
+
'SELECT * FROM users WHERE id = $1 AND name = $2',
|
|
113
|
+
42,
|
|
114
|
+
'Alice',
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`db.execute(sql, ...params)` is an alias for statements where the affected row
|
|
119
|
+
count is the primary result.
|
|
120
|
+
|
|
121
|
+
`ASK '...'` returns the ASK envelope directly:
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
const answer = await db.query("ASK 'why did deploy fail?'")
|
|
125
|
+
answer.answer
|
|
126
|
+
answer.citations
|
|
127
|
+
answer.sources_flat
|
|
128
|
+
answer.validation
|
|
129
|
+
answer.cache_hit
|
|
130
|
+
answer.cost_usd
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`ASK '...' STREAM` notifications are not wired over the JS stdio JSON-RPC
|
|
134
|
+
client yet. Use the HTTP streaming API for incremental ASK frames; stdio
|
|
135
|
+
currently supports materialised cursor batching through `query.open` /
|
|
136
|
+
`query.next`, which is separate from ASK token streaming.
|
|
106
137
|
|
|
107
138
|
### `db.insert(collection, payload) → Promise<{ affected, id? }>`
|
|
108
139
|
|
|
@@ -179,9 +210,11 @@ bun test/smoke.test.mjs
|
|
|
179
210
|
deno run -A test/smoke.test.mjs
|
|
180
211
|
```
|
|
181
212
|
|
|
182
|
-
##
|
|
213
|
+
## Remote Deploy
|
|
183
214
|
|
|
184
|
-
When you're ready to point
|
|
215
|
+
When you're ready to point JavaScript code at a production RedDB cluster, use
|
|
216
|
+
`@reddb-io/client`. The SDK package is embedded-only and intentionally rejects
|
|
217
|
+
remote URIs.
|
|
185
218
|
|
|
186
219
|
- **Run RedDB with the encrypted vault** so auth state and
|
|
187
220
|
`red.secret.*` values are protected at rest. See
|
|
@@ -192,8 +225,7 @@ When you're ready to point this driver at a production RedDB cluster:
|
|
|
192
225
|
- **Track every secret** the driver consumes (bearer tokens, mTLS
|
|
193
226
|
cert + key, OAuth JWTs) in
|
|
194
227
|
[`docs/operations/secrets.md`](../../docs/operations/secrets.md).
|
|
195
|
-
- **Use
|
|
196
|
-
crossing the network — never plain `red://` outside localhost.
|
|
228
|
+
- **Use TLS** for any traffic crossing the network.
|
|
197
229
|
- **TLS posture, mTLS, OAuth/JWT and reverse-proxy patterns** are
|
|
198
230
|
covered in [`docs/security/transport-tls.md`](../../docs/security/transport-tls.md).
|
|
199
231
|
- See [Policies](../../docs/security/policies.md) for IAM-style authorization.
|
package/index.d.ts
CHANGED
|
@@ -4,16 +4,6 @@
|
|
|
4
4
|
* Hand-written, kept in sync with src/index.js.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* Authentication credentials. Only meaningful for `grpc://` URIs;
|
|
9
|
-
* embedded modes (memory://, file://) inherit the caller's
|
|
10
|
-
* filesystem privileges and reject auth options at the boundary.
|
|
11
|
-
*
|
|
12
|
-
* The shape is `{ token }` (or its `{ apiKey }` alias). For
|
|
13
|
-
* username/password login, mint a token first via the standalone
|
|
14
|
-
* `login(httpUrl, { username, password })` helper, then pass it
|
|
15
|
-
* here — the gRPC surface does not currently bridge `auth.login`.
|
|
16
|
-
*/
|
|
17
7
|
export type AuthOptions =
|
|
18
8
|
| { token: string }
|
|
19
9
|
| { apiKey: string }
|
|
@@ -35,13 +25,9 @@ export interface TlsOptions {
|
|
|
35
25
|
export interface ConnectOptions {
|
|
36
26
|
/** Override the path to the `red` binary (defaults to bundled). */
|
|
37
27
|
binary?: string
|
|
38
|
-
/**
|
|
28
|
+
/** Rejected for embedded SDK connections; use @reddb-io/client remotely. */
|
|
39
29
|
auth?: AuthOptions
|
|
40
|
-
/**
|
|
41
|
-
* TLS for `redwire(s)://` connections. URL params (`tls=true`,
|
|
42
|
-
* `cert=`, `key=`, `ca=`) feed the same field; this option
|
|
43
|
-
* always wins.
|
|
44
|
-
*/
|
|
30
|
+
/** Reserved for remote clients; ignored by the embedded SDK. */
|
|
45
31
|
tls?: TlsOptions
|
|
46
32
|
}
|
|
47
33
|
|
|
@@ -52,14 +38,63 @@ export interface QueryResult {
|
|
|
52
38
|
rows: Array<Record<string, unknown>>
|
|
53
39
|
}
|
|
54
40
|
|
|
41
|
+
export type QueryParam =
|
|
42
|
+
| null
|
|
43
|
+
| boolean
|
|
44
|
+
| number
|
|
45
|
+
| string
|
|
46
|
+
| Uint8Array
|
|
47
|
+
| Buffer
|
|
48
|
+
| Date
|
|
49
|
+
| Float32Array
|
|
50
|
+
| Float64Array
|
|
51
|
+
| number[]
|
|
52
|
+
| Record<string, unknown>
|
|
53
|
+
|
|
54
|
+
export interface AskSource {
|
|
55
|
+
urn: string
|
|
56
|
+
payload: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AskCitation {
|
|
60
|
+
marker: number
|
|
61
|
+
urn: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AskValidationItem {
|
|
65
|
+
kind: string
|
|
66
|
+
detail: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AskValidation {
|
|
70
|
+
ok: boolean
|
|
71
|
+
warnings: AskValidationItem[]
|
|
72
|
+
errors: AskValidationItem[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AskQueryResult {
|
|
76
|
+
answer: string
|
|
77
|
+
cache_hit: boolean
|
|
78
|
+
citations: AskCitation[]
|
|
79
|
+
completion_tokens: number
|
|
80
|
+
cost_usd: number
|
|
81
|
+
mode: 'strict' | 'lenient'
|
|
82
|
+
model: string
|
|
83
|
+
prompt_tokens: number
|
|
84
|
+
provider: string
|
|
85
|
+
retry_count: number
|
|
86
|
+
sources_flat: AskSource[]
|
|
87
|
+
validation: AskValidation
|
|
88
|
+
}
|
|
89
|
+
|
|
55
90
|
export interface InsertResult {
|
|
56
91
|
affected: number
|
|
57
|
-
|
|
58
|
-
id?: string | number
|
|
92
|
+
id: string | number
|
|
59
93
|
}
|
|
60
94
|
|
|
61
95
|
export interface BulkInsertResult {
|
|
62
96
|
affected: number
|
|
97
|
+
ids: Array<string | number>
|
|
63
98
|
}
|
|
64
99
|
|
|
65
100
|
export interface GetResult {
|
|
@@ -70,6 +105,13 @@ export interface DeleteResult {
|
|
|
70
105
|
affected: number
|
|
71
106
|
}
|
|
72
107
|
|
|
108
|
+
export interface CollectionMeta {
|
|
109
|
+
name: string
|
|
110
|
+
model: string
|
|
111
|
+
capabilities: string[]
|
|
112
|
+
[key: string]: unknown
|
|
113
|
+
}
|
|
114
|
+
|
|
73
115
|
export interface HealthResult {
|
|
74
116
|
ok: boolean
|
|
75
117
|
version: string
|
|
@@ -144,6 +186,13 @@ export interface CacheInvalidateResult {
|
|
|
144
186
|
removed: number
|
|
145
187
|
}
|
|
146
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Cache client. Requires an HTTP or gRPC / RedWire transport — the
|
|
191
|
+
* underlying `cache.*` RPC methods are not served by the embedded
|
|
192
|
+
* (stdio JSON-RPC) handler. Calls on an unsupported transport throw
|
|
193
|
+
* `RedDBError` with code `UNSUPPORTED_TRANSPORT` before issuing any
|
|
194
|
+
* RPC.
|
|
195
|
+
*/
|
|
147
196
|
export class CacheClient {
|
|
148
197
|
/** Fetch a cached value. Returns Uint8Array on hit, null on miss. */
|
|
149
198
|
get(namespace: string, key: string): Promise<Uint8Array | null>
|
|
@@ -182,6 +231,8 @@ export class KvClient {
|
|
|
182
231
|
value: unknown,
|
|
183
232
|
options?: { collection?: string; expireMs?: number; tags?: string[] },
|
|
184
233
|
): Promise<QueryResult>
|
|
234
|
+
get(key: string, options?: { collection?: string }): Promise<unknown | null>
|
|
235
|
+
getMany(keys: string[], options?: { collection?: string }): Promise<Array<unknown | null>>
|
|
185
236
|
invalidateTags(tags: string[], options?: { collection?: string }): Promise<number>
|
|
186
237
|
watch(
|
|
187
238
|
key: string,
|
|
@@ -193,6 +244,32 @@ export class KvClient {
|
|
|
193
244
|
): AsyncIterable<KvWatchEvent>
|
|
194
245
|
}
|
|
195
246
|
|
|
247
|
+
export class QueueClient {
|
|
248
|
+
push(
|
|
249
|
+
queue: string,
|
|
250
|
+
value: unknown,
|
|
251
|
+
options?: { priority?: number },
|
|
252
|
+
): Promise<QueryResult>
|
|
253
|
+
pop(queue: string, count?: number): Promise<unknown[]>
|
|
254
|
+
peek(queue: string, count?: number): Promise<unknown[]>
|
|
255
|
+
len(queue: string): Promise<number>
|
|
256
|
+
purge(queue: string): Promise<QueryResult>
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Caller-typed SELECT builder. RedDB does not infer `T`; provide it
|
|
261
|
+
* explicitly with `db.from<T>('collection')`.
|
|
262
|
+
*/
|
|
263
|
+
export class TypedQueryBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
264
|
+
select(): TypedQueryBuilder<T>
|
|
265
|
+
select(column: '*'): TypedQueryBuilder<T>
|
|
266
|
+
select<K extends keyof T & string>(...columns: K[]): TypedQueryBuilder<Pick<T, K>>
|
|
267
|
+
select<K extends keyof T & string>(columns: K[]): TypedQueryBuilder<Pick<T, K>>
|
|
268
|
+
where(condition: string, params: QueryParam[]): TypedQueryBuilder<T>
|
|
269
|
+
where(condition: string, ...params: QueryParam[]): TypedQueryBuilder<T>
|
|
270
|
+
run(): Promise<T[]>
|
|
271
|
+
}
|
|
272
|
+
|
|
196
273
|
export class ConfigClient {
|
|
197
274
|
put(
|
|
198
275
|
key: string,
|
|
@@ -218,25 +295,42 @@ export class VaultClient {
|
|
|
218
295
|
}
|
|
219
296
|
|
|
220
297
|
export class RedDB {
|
|
298
|
+
/** Underlying transport label. connect() returns 'embedded'. */
|
|
299
|
+
readonly transport: string | null
|
|
221
300
|
readonly cache: CacheClient
|
|
301
|
+
readonly queue: QueueClient
|
|
222
302
|
readonly kv: KvClient & ((collection?: string) => KvClient)
|
|
223
303
|
readonly config: (collection?: string) => ConfigClient
|
|
224
304
|
readonly vault: (collection?: string) => VaultClient
|
|
225
305
|
|
|
306
|
+
query(sql: `ASK ${string}`): Promise<AskQueryResult>
|
|
226
307
|
query(sql: string): Promise<QueryResult>
|
|
227
|
-
query(sql: string, params:
|
|
308
|
+
query(sql: string, params: QueryParam[]): Promise<QueryResult>
|
|
309
|
+
query(sql: string, ...params: QueryParam[]): Promise<QueryResult>
|
|
310
|
+
execute(sql: string): Promise<QueryResult>
|
|
311
|
+
execute(sql: string, params: QueryParam[]): Promise<QueryResult>
|
|
312
|
+
execute(sql: string, ...params: QueryParam[]): Promise<QueryResult>
|
|
228
313
|
insert(collection: string, payload: Record<string, unknown>): Promise<InsertResult>
|
|
229
314
|
bulkInsert(
|
|
230
315
|
collection: string,
|
|
231
316
|
payloads: Array<Record<string, unknown>>,
|
|
232
317
|
): Promise<BulkInsertResult>
|
|
318
|
+
exists(collection: string): Promise<boolean>
|
|
319
|
+
list(): Promise<CollectionMeta[]>
|
|
320
|
+
/**
|
|
321
|
+
* Caller-typed collection handle. Supply `T`; the SDK does not
|
|
322
|
+
* generate or validate row types at runtime.
|
|
323
|
+
*/
|
|
324
|
+
from<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
325
|
+
collection: string,
|
|
326
|
+
): TypedQueryBuilder<T>
|
|
233
327
|
get(collection: string, id: string | number): Promise<GetResult>
|
|
234
328
|
delete(collection: string, id: string | number): Promise<DeleteResult>
|
|
235
329
|
health(): Promise<HealthResult>
|
|
236
330
|
version(): Promise<VersionResult>
|
|
237
331
|
|
|
238
|
-
// Auth surface
|
|
239
|
-
//
|
|
332
|
+
// Auth surface is not available through embedded SDK connections.
|
|
333
|
+
// Use @reddb-io/client for remote authenticated servers.
|
|
240
334
|
login(username: string, password: string): Promise<LoginResult>
|
|
241
335
|
whoami(): Promise<WhoamiResult>
|
|
242
336
|
changePassword(currentPassword: string, newPassword: string): Promise<ChangePasswordResult>
|
|
@@ -252,32 +346,16 @@ export class RedDB {
|
|
|
252
346
|
* Accepted URI schemes:
|
|
253
347
|
* - `memory://` — ephemeral in-memory database
|
|
254
348
|
* - `file:///absolute/path` — embedded, persisted to disk
|
|
255
|
-
*
|
|
349
|
+
*
|
|
350
|
+
* Remote URI schemes throw `EMBEDDED_ONLY`; use @reddb-io/client.
|
|
256
351
|
*/
|
|
257
352
|
export function connect(uri: string, options?: ConnectOptions): Promise<RedDB>
|
|
258
353
|
|
|
259
|
-
|
|
260
|
-
* Exchange username + password for a bearer token by hitting the
|
|
261
|
-
* server's `POST /auth/login` HTTP endpoint. The returned `token`
|
|
262
|
-
* can be passed to `connect(uri, { auth: { token } })`.
|
|
263
|
-
*
|
|
264
|
-
* @example
|
|
265
|
-
* import { connect, login } from '@reddb-io/sdk'
|
|
266
|
-
* const { token } = await login(
|
|
267
|
-
* 'https://reddb.example.com/auth/login',
|
|
268
|
-
* { username: 'admin', password: 'secret' },
|
|
269
|
-
* )
|
|
270
|
-
* const db = await connect('grpc://reddb.example.com:5051', { auth: { token } })
|
|
271
|
-
*/
|
|
272
|
-
export function login(
|
|
273
|
-
loginUrl: string,
|
|
274
|
-
credentials: { username: string; password: string },
|
|
275
|
-
): Promise<LoginResult>
|
|
354
|
+
export const EMBEDDED_ONLY_MESSAGE: string
|
|
276
355
|
|
|
277
356
|
/**
|
|
278
357
|
* Translate a connection URI + (optional) auth into argv for
|
|
279
|
-
* `red rpc --stdio`.
|
|
280
|
-
* use `parseUri` directly and let `connect` handle dispatch.
|
|
358
|
+
* `red rpc --stdio`. Remote URI schemes throw `EMBEDDED_ONLY`.
|
|
281
359
|
*/
|
|
282
360
|
export function uriToArgs(
|
|
283
361
|
uri: string,
|
|
@@ -288,7 +366,7 @@ export function uriToArgs(
|
|
|
288
366
|
* Parsed `red://` (or legacy) URI. Returned by `parseUri`.
|
|
289
367
|
*/
|
|
290
368
|
export interface ParsedUri {
|
|
291
|
-
kind: 'embedded' | 'http' | 'https' | 'grpc' | 'grpcs' | 'pg'
|
|
369
|
+
kind: 'embedded' | 'http' | 'https' | 'red' | 'reds' | 'grpc' | 'grpcs' | 'pg'
|
|
292
370
|
host?: string
|
|
293
371
|
port?: number
|
|
294
372
|
path?: string
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reddb-io/sdk",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Official RedDB
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "Official embedded RedDB SDK — launches a local red binary over stdio JSON-RPC. Use @reddb-io/client for remote HTTP, gRPC, and RedWire.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -45,6 +45,6 @@
|
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"postinstall": "node postinstall.js",
|
|
48
|
-
"test": "node --test test/cache.test.mjs test/params.test.mjs test/redwire.params.test.mjs && node test/smoke.test.mjs"
|
|
48
|
+
"test": "node --test test/ask.test.mjs test/cache.test.mjs test/db-helpers.test.mjs test/embedded-only.test.mjs test/insert-ids.test.mjs test/kv.test.mjs test/params.test.mjs test/postinstall.test.mjs test/queue.test.mjs test/redwire.params.test.mjs && node test/smoke.test.mjs"
|
|
49
49
|
}
|
|
50
50
|
}
|
package/src/cache.js
CHANGED
|
@@ -6,13 +6,37 @@
|
|
|
6
6
|
* flushNamespace routes to the existing POST /admin/blob_cache/flush_namespace.
|
|
7
7
|
* All others target endpoints planned for a future server release.
|
|
8
8
|
*
|
|
9
|
+
* Requires an HTTP or gRPC transport. On embedded (stdio JSON-RPC), every
|
|
10
|
+
* method throws `UNSUPPORTED_TRANSPORT` before issuing the RPC call —
|
|
11
|
+
* the engine's stdio handler doesn't implement `cache.*`.
|
|
12
|
+
*
|
|
9
13
|
* Values are base64-encoded in transit so binary payloads survive JSON.
|
|
10
14
|
*/
|
|
11
15
|
|
|
16
|
+
import { RedDBError } from './protocol.js'
|
|
17
|
+
|
|
18
|
+
const UNSUPPORTED_TRANSPORTS = new Set(['embedded'])
|
|
19
|
+
|
|
12
20
|
export class CacheClient {
|
|
13
|
-
/**
|
|
14
|
-
|
|
21
|
+
/**
|
|
22
|
+
* @param {{ call: Function }} client
|
|
23
|
+
* @param {string} [transport] Underlying transport label (e.g. 'http',
|
|
24
|
+
* 'grpc', 'embedded'). When the transport doesn't serve `cache.*`,
|
|
25
|
+
* every method throws `UNSUPPORTED_TRANSPORT` before any RPC call.
|
|
26
|
+
*/
|
|
27
|
+
constructor(client, transport) {
|
|
15
28
|
this._client = client
|
|
29
|
+
this._transport = transport ?? null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_guard(method) {
|
|
33
|
+
if (this._transport && UNSUPPORTED_TRANSPORTS.has(this._transport)) {
|
|
34
|
+
throw new RedDBError(
|
|
35
|
+
'UNSUPPORTED_TRANSPORT',
|
|
36
|
+
`cache.${method} is not available on '${this._transport}' transport; `
|
|
37
|
+
+ 'use @reddb-io/client for remote cache endpoints.',
|
|
38
|
+
)
|
|
39
|
+
}
|
|
16
40
|
}
|
|
17
41
|
|
|
18
42
|
/**
|
|
@@ -22,6 +46,7 @@ export class CacheClient {
|
|
|
22
46
|
* @returns {Promise<Uint8Array | null>}
|
|
23
47
|
*/
|
|
24
48
|
async get(namespace, key) {
|
|
49
|
+
this._guard('get')
|
|
25
50
|
const result = await this._client.call('cache.get', { namespace, key })
|
|
26
51
|
if (result == null || result.value == null) return null
|
|
27
52
|
return base64ToBytes(result.value)
|
|
@@ -39,6 +64,7 @@ export class CacheClient {
|
|
|
39
64
|
* @returns {Promise<void>}
|
|
40
65
|
*/
|
|
41
66
|
async put(namespace, key, value, opts = {}) {
|
|
67
|
+
this._guard('put')
|
|
42
68
|
const encoded = bytesToBase64(value)
|
|
43
69
|
await this._client.call('cache.put', {
|
|
44
70
|
namespace,
|
|
@@ -55,6 +81,7 @@ export class CacheClient {
|
|
|
55
81
|
* @returns {Promise<'present' | 'absent' | 'maybe'>}
|
|
56
82
|
*/
|
|
57
83
|
async exists(namespace, key) {
|
|
84
|
+
this._guard('exists')
|
|
58
85
|
const result = await this._client.call('cache.exists', { namespace, key })
|
|
59
86
|
return result?.status ?? 'maybe'
|
|
60
87
|
}
|
|
@@ -66,6 +93,7 @@ export class CacheClient {
|
|
|
66
93
|
* @returns {Promise<void>}
|
|
67
94
|
*/
|
|
68
95
|
async invalidate(namespace, key) {
|
|
96
|
+
this._guard('invalidate')
|
|
69
97
|
await this._client.call('cache.invalidate', { namespace, key })
|
|
70
98
|
}
|
|
71
99
|
|
|
@@ -76,6 +104,7 @@ export class CacheClient {
|
|
|
76
104
|
* @returns {Promise<number>} Number of entries removed.
|
|
77
105
|
*/
|
|
78
106
|
async invalidatePrefix(namespace, prefix) {
|
|
107
|
+
this._guard('invalidatePrefix')
|
|
79
108
|
const result = await this._client.call('cache.invalidate_prefix', { namespace, prefix })
|
|
80
109
|
return result?.removed ?? 0
|
|
81
110
|
}
|
|
@@ -87,6 +116,7 @@ export class CacheClient {
|
|
|
87
116
|
* @returns {Promise<number>} Number of entries removed.
|
|
88
117
|
*/
|
|
89
118
|
async invalidateTags(namespace, tags) {
|
|
119
|
+
this._guard('invalidateTags')
|
|
90
120
|
const result = await this._client.call('cache.invalidate_tags', { namespace, tags })
|
|
91
121
|
return result?.removed ?? 0
|
|
92
122
|
}
|
|
@@ -98,6 +128,7 @@ export class CacheClient {
|
|
|
98
128
|
* @returns {Promise<void>}
|
|
99
129
|
*/
|
|
100
130
|
async flushNamespace(namespace) {
|
|
131
|
+
this._guard('flushNamespace')
|
|
101
132
|
await this._client.call('cache.flush_namespace', { namespace })
|
|
102
133
|
}
|
|
103
134
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { RedDBError } from './protocol.js'
|
|
2
|
+
|
|
3
|
+
export async function listCollections(db) {
|
|
4
|
+
const result = await db.query('SHOW COLLECTIONS')
|
|
5
|
+
return (result.rows ?? []).map(collectionMeta)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function collectionExists(db, collection) {
|
|
9
|
+
const result = await db.query(`SHOW COLLECTIONS WHERE name = ${sqlString(collection)}`)
|
|
10
|
+
return (result.rows ?? []).some((row) => row.name === String(collection))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class TypedQueryBuilder {
|
|
14
|
+
constructor(db, collection, columns = null, whereClauses = [], params = []) {
|
|
15
|
+
this.db = db
|
|
16
|
+
this.collection = collection
|
|
17
|
+
this.columns = columns
|
|
18
|
+
this.whereClauses = whereClauses
|
|
19
|
+
this.params = params
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
select(...columns) {
|
|
23
|
+
const selected = columns.length === 1 && Array.isArray(columns[0]) ? columns[0] : columns
|
|
24
|
+
const projection = selected.length === 1 && selected[0] === '*' ? null : selected
|
|
25
|
+
return new TypedQueryBuilder(
|
|
26
|
+
this.db,
|
|
27
|
+
this.collection,
|
|
28
|
+
projection != null && projection.length > 0 ? projection : null,
|
|
29
|
+
this.whereClauses,
|
|
30
|
+
this.params,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
where(condition, ...params) {
|
|
35
|
+
if (typeof condition !== 'string' || condition.trim().length === 0) {
|
|
36
|
+
throw new RedDBError('INVALID_QUERY_BUILDER', 'where() requires a non-empty SQL condition')
|
|
37
|
+
}
|
|
38
|
+
const nextParams = params.length === 1 && Array.isArray(params[0]) ? params[0] : params
|
|
39
|
+
return new TypedQueryBuilder(
|
|
40
|
+
this.db,
|
|
41
|
+
this.collection,
|
|
42
|
+
this.columns,
|
|
43
|
+
[...this.whereClauses, condition.trim()],
|
|
44
|
+
[...this.params, ...nextParams],
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async run() {
|
|
49
|
+
const projection = this.columns == null
|
|
50
|
+
? '*'
|
|
51
|
+
: this.columns.map(sqlIdentifierPath).join(', ')
|
|
52
|
+
const where = this.whereClauses.length > 0
|
|
53
|
+
? ` WHERE ${this.whereClauses.map((clause) => `(${clause})`).join(' AND ')}`
|
|
54
|
+
: ''
|
|
55
|
+
const sql = `SELECT ${projection} FROM ${sqlIdentifierPath(this.collection)}${where}`
|
|
56
|
+
const result = this.params.length > 0
|
|
57
|
+
? await this.db.query(sql, this.params)
|
|
58
|
+
: await this.db.query(sql)
|
|
59
|
+
const rows = result.rows ?? []
|
|
60
|
+
if (this.columns == null) return rows
|
|
61
|
+
return rows.map((row) => {
|
|
62
|
+
const selected = {}
|
|
63
|
+
for (const column of this.columns) selected[column] = row[column]
|
|
64
|
+
return selected
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function collectionMeta(row) {
|
|
70
|
+
return {
|
|
71
|
+
...row,
|
|
72
|
+
name: String(row.name),
|
|
73
|
+
model: String(row.model),
|
|
74
|
+
capabilities: Array.isArray(row.capabilities) ? row.capabilities : [],
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sqlIdentifierPath(value) {
|
|
79
|
+
return String(value).split('.').map(sqlIdentifier).join('.')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sqlIdentifier(value) {
|
|
83
|
+
const ident = String(value)
|
|
84
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ident)) {
|
|
85
|
+
throw new RedDBError(
|
|
86
|
+
'INVALID_IDENTIFIER',
|
|
87
|
+
`invalid SQL identifier "${ident}"`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
return ident
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sqlString(value) {
|
|
94
|
+
return `'${String(value).replace(/'/g, "''")}'`
|
|
95
|
+
}
|
package/src/http.js
CHANGED
|
@@ -123,9 +123,14 @@ async function parseResponse(response) {
|
|
|
123
123
|
const ROUTES = {
|
|
124
124
|
health: (base) => ({ url: `${base}/health`, init: { method: 'GET' } }),
|
|
125
125
|
version: (base) => ({ url: `${base}/admin/version`, init: { method: 'GET' } }),
|
|
126
|
-
query: (base, { sql }) => ({
|
|
126
|
+
query: (base, { sql, params }) => ({
|
|
127
127
|
url: `${base}/query`,
|
|
128
|
-
init: {
|
|
128
|
+
init: {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
body: JSON.stringify(
|
|
131
|
+
Array.isArray(params) ? { query: sql, params } : { query: sql },
|
|
132
|
+
),
|
|
133
|
+
},
|
|
129
134
|
}),
|
|
130
135
|
insert: (base, { collection, payload }) => ({
|
|
131
136
|
url: `${base}/collections/${encodeURIComponent(collection)}/rows`,
|
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
|
+
}
|
package/src/redwire.js
CHANGED
|
@@ -843,10 +843,14 @@ export function encodeValue(v) {
|
|
|
843
843
|
if (k === '$bytes' && typeof v.$bytes === 'string') {
|
|
844
844
|
return encodeLenPrefixed(ValueTag.Bytes, base64ToBytes(v.$bytes))
|
|
845
845
|
}
|
|
846
|
-
if (k === '$ts' &&
|
|
846
|
+
if (k === '$ts' && (
|
|
847
|
+
(typeof v.$ts === 'number' && Number.isFinite(v.$ts))
|
|
848
|
+
|| typeof v.$ts === 'string'
|
|
849
|
+
)) {
|
|
847
850
|
const out = new Uint8Array(1 + 8)
|
|
848
851
|
out[0] = ValueTag.Timestamp
|
|
849
|
-
|
|
852
|
+
const raw = typeof v.$ts === 'string' ? BigInt(v.$ts) : BigInt(Math.trunc(v.$ts))
|
|
853
|
+
new DataView(out.buffer).setBigInt64(1, raw, true)
|
|
850
854
|
return out
|
|
851
855
|
}
|
|
852
856
|
if (k === '$uuid' && typeof v.$uuid === 'string') {
|