@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 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 for application code. If you just want to launch the CLI from
9
- npm, use:
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
- `grpc://` is not supported by the JS driver yet — the binary needs the
82
- `--connect` flag wired up first. See `PLAN_DRIVERS.md` in the repo root.
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
- ## Production deploy
213
+ ## Remote Deploy
183
214
 
184
- When you're ready to point this driver at a production RedDB cluster:
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 `reds://` (TLS)** or `red://...?tls=true` for any traffic
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
- /** Authentication credentials (grpc:// only). */
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
- /** Present when the underlying engine surfaces the inserted entity id. */
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: Array<number | string | null>): Promise<QueryResult>
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 only meaningful when connected via grpc://.
239
- // Embedded modes will receive 'unknown method' from the bridge.
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
- * - `grpc://host:port` — remote server
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`. Exported for tests / debug. New code should
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.8",
4
- "description": "Official RedDB drivertalks the native RedWire TCP protocol (mTLS), HTTP, gRPC bridge, or embedded stdio JSON-RPC. Works in Node 18+, Bun and Deno.",
3
+ "version": "1.1.1",
4
+ "description": "Official embedded RedDB SDKlaunches 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
- /** @param {{ call: Function }} client */
14
- constructor(client) {
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: { method: 'POST', body: JSON.stringify({ query: sql }) },
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
- * Authentication (only meaningful for `grpc://`; embedded modes ignore
20
- * auth options because the spawned binary inherits the caller's
21
- * filesystem privileges):
22
- *
23
- * await connect('grpc://host:5051', {
24
- * auth: { token: 'sk-...' } // raw bearer / api key
25
- * })
26
- * await connect('grpc://host:5051', {
27
- * auth: { apiKey: 'ak-...' } // alias for token
28
- * })
29
- * await connect('grpc://host:5051', {
30
- * auth: { username: 'admin', password: 'x' } // login flow — driver
31
- * // calls /auth/login,
32
- * // caches the bearer
33
- * })
34
- *
35
- * Username/password requires the server to expose the `auth.login`
36
- * JSON-RPC method (proxied through the gRPC bridge).
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 { HttpRpcClient } from './http.js'
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
- const merged = mergeAuthFromUri(parsed, options.auth)
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
- const auth = token ? { kind: 'bearer', token } : { kind: 'anonymous' }
142
- const tls = buildTlsOpts(parsed, options.tls)
143
- const client = await connectRedwire({
144
- host: parsed.host,
145
- port: parsed.port,
146
- auth,
147
- ...(tls ? { tls } : {}),
148
- })
149
- return new RedDB(client)
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
- // Postgres wire: not yet wired in the driver. Document the gap
153
- // so users get a clear actionable error instead of a silent
154
- // unsupported transport.
155
- if (parsed.kind === 'pg') {
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
- 'PG_TRANSPORT_NOT_WIRED',
158
- "PostgreSQL wire (proto=pg) requires a node-pg-style client; "
159
- + "the JS driver doesn't bundle one yet. Use a separate `pg` package "
160
- + 'against the same host:port for now, or open an issue if you want it built in.',
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
- 'UNSUPPORTED_KIND',
166
- `internal: parsed kind '${parsed.kind}' has no transport`,
140
+ 'UNSUPPORTED_PARAM',
141
+ `cannot encode query parameter of type ${typeof value}`,
167
142
  )
168
143
  }
169
144
 
170
- // Coerce a JS query parameter to a JSON-serializable shape the server
171
- // understands. The tracer scope (#355) lifts vector params: `Float32Array`
172
- // and `Float64Array` round-trip as plain JSON arrays of numbers, which
173
- // the embedded stdio handler maps to `Value::Vector`.
174
- function serializeParam(value) {
175
- if (value instanceof Float32Array || value instanceof Float64Array) {
176
- return Array.from(value)
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
- if (parsed.kind === 'grpc' || parsed.kind === 'grpcs') {
329
- const token = auth?.kind === 'token' ? auth.token : (parsed.token ?? parsed.apiKey ?? null)
330
- return grpcArgs(parsed, token)
331
- }
332
- throw new RedDBError(
333
- 'UNSUPPORTED_SCHEME',
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
- /** @param {RpcClient} client */
344
- constructor(client) {
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.cache = new CacheClient(client)
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. `params` is
364
- * an array (JS scalars: number | string | null map to engine
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
- if (params === undefined) {
372
- return this.client.call('query', { sql })
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
- if (!Array.isArray(params)) {
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
- /** Insert one row. Returns `{ affected, id? }`. */
382
- insert(collection, payload) {
383
- return this.client.call('insert', { collection, payload })
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 many rows in one call. Returns `{ affected }`. */
387
- bulkInsert(collection, payloads) {
388
- return this.client.call('bulk_insert', { collection, payloads })
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 no-ops in embedded mode because the
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
- // They forward to the server when the connection is grpc://.
375
+ // Use @reddb-io/client for remote authenticated servers.
415
376
  // ---------------------------------------------------------------
416
377
 
417
378
  /**
418
- * Exchange username + password for a bearer token. Returns
419
- * `{ token, username, role, expires_at }`. Server-side this
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
- return String(value).replace(/[^A-Za-z0-9_]/g, '_')
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' && typeof v.$ts === 'number' && Number.isFinite(v.$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
- new DataView(out.buffer).setBigInt64(1, BigInt(Math.trunc(v.$ts)), true)
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') {