@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/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.0
|
|
4
|
-
"description": "Official RedDB
|
|
3
|
+
"version": "1.1.0",
|
|
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 && 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`,
|