@reddb-io/client 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/src/index.js CHANGED
@@ -28,6 +28,7 @@
28
28
 
29
29
  import { RedDBError } from './protocol.js'
30
30
  import { HttpRpcClient } from './http.js'
31
+ import { GrpcRpcClient } from './grpc.js'
31
32
  import { connectRedwire } from './redwire.js'
32
33
  import { parseUri, deriveLoginUrl } from './url.js'
33
34
  import {
@@ -38,16 +39,22 @@ import {
38
39
  } from './embedded-rejection.js'
39
40
  import { CacheClient } from './cache.js'
40
41
  import { KvClient } from './kv.js'
42
+ import { QueueClient } from './queue.js'
41
43
  import { ConfigClient } from './config.js'
42
44
  import { VaultClient } from './vault.js'
45
+ import { TypedQueryBuilder, collectionExists, listCollections } from './db-helpers.js'
43
46
 
44
47
  export { RedDBError, EmbeddedNotSupported, EMBEDDED_REJECTION_MESSAGE, isEmbeddedUri }
45
48
  export { CacheClient } from './cache.js'
46
49
  export { KvClient } from './kv.js'
50
+ export { QueueClient } from './queue.js'
47
51
  export { ConfigClient } from './config.js'
48
52
  export { VaultClient } from './vault.js'
53
+ export { TypedQueryBuilder } from './db-helpers.js'
49
54
  export { parseUri, deriveLoginUrl } from './url.js'
50
55
 
56
+ const MIN_INSERT_ID_ENGINE_VERSION = '1.0.9'
57
+
51
58
  /**
52
59
  * Connect to a remote RedDB instance.
53
60
  *
@@ -90,16 +97,29 @@ export async function connect(uri, options = {}) {
90
97
  token = session.token
91
98
  }
92
99
  const client = new HttpRpcClient({ baseUrl, token })
93
- await client.call('health', {})
100
+ await client.call('query', { sql: 'SELECT 1' })
94
101
  return new RedDB(client)
95
102
  }
96
103
 
97
- if (
98
- parsed.kind === 'red'
99
- || parsed.kind === 'reds'
100
- || parsed.kind === 'grpc'
101
- || parsed.kind === 'grpcs'
102
- ) {
104
+ if (parsed.kind === 'grpc' || parsed.kind === 'grpcs') {
105
+ let token = merged.token
106
+ if (!token && merged.username && merged.password) {
107
+ const loginUrl = merged.loginUrl ?? deriveLoginUrl(parsed)
108
+ const session = await login(loginUrl, {
109
+ username: merged.username,
110
+ password: merged.password,
111
+ })
112
+ token = session.token
113
+ }
114
+ const scheme = parsed.kind === 'grpcs' ? 'https' : 'http'
115
+ const client = new GrpcRpcClient({
116
+ baseUrl: `${scheme}://${parsed.host}:${parsed.port}`,
117
+ token,
118
+ })
119
+ return new RedDB(client)
120
+ }
121
+
122
+ if (parsed.kind === 'red' || parsed.kind === 'reds') {
103
123
  let token = merged.token
104
124
  if (!token && merged.username && merged.password) {
105
125
  const loginUrl = merged.loginUrl ?? deriveLoginUrl(parsed)
@@ -135,6 +155,89 @@ export async function connect(uri, options = {}) {
135
155
  )
136
156
  }
137
157
 
158
+ function serializeParam(value) {
159
+ assertSupportedParam(value)
160
+ if (value instanceof Float32Array || value instanceof Float64Array) {
161
+ return Array.from(value)
162
+ }
163
+ if (value instanceof Date) {
164
+ return { $ts: String(BigInt(value.getTime()) * 1_000_000n) }
165
+ }
166
+ if (value instanceof Uint8Array || (typeof Buffer !== 'undefined' && value instanceof Buffer)) {
167
+ return { $bytes: bytesToBase64(value) }
168
+ }
169
+ if (typeof value === 'number' && !Number.isFinite(value)) {
170
+ if (Number.isNaN(value)) return { $float: 'NaN' }
171
+ return { $float: value > 0 ? 'Infinity' : '-Infinity' }
172
+ }
173
+ if (typeof value === 'string' && isUuidString(value)) {
174
+ return { $uuid: value }
175
+ }
176
+ return value
177
+ }
178
+
179
+ function assertSupportedParam(value) {
180
+ if (value == null) return
181
+ if (
182
+ typeof value === 'boolean'
183
+ || typeof value === 'number'
184
+ || typeof value === 'string'
185
+ ) {
186
+ return
187
+ }
188
+ if (value instanceof Date) {
189
+ if (Number.isNaN(value.getTime())) {
190
+ throw new RedDBError('UNSUPPORTED_PARAM', 'cannot encode invalid Date query parameter')
191
+ }
192
+ return
193
+ }
194
+ if (
195
+ value instanceof Uint8Array
196
+ || value instanceof Float32Array
197
+ || value instanceof Float64Array
198
+ || (typeof Buffer !== 'undefined' && value instanceof Buffer)
199
+ ) {
200
+ return
201
+ }
202
+ if (Array.isArray(value)) {
203
+ if (value.every((item) => typeof item === 'number')) return
204
+ throw new RedDBError(
205
+ 'UNSUPPORTED_PARAM',
206
+ 'array query parameters must contain only numbers',
207
+ )
208
+ }
209
+ if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
210
+ return
211
+ }
212
+ throw new RedDBError(
213
+ 'UNSUPPORTED_PARAM',
214
+ `cannot encode query parameter of type ${typeof value}`,
215
+ )
216
+ }
217
+
218
+ function normalizeQueryParams(args) {
219
+ if (args.length === 0) return null
220
+ if (args.length === 1 && Array.isArray(args[0])) return args[0].map(serializeParam)
221
+ return args.map(serializeParam)
222
+ }
223
+
224
+ function bytesToBase64(value) {
225
+ const bytes = value instanceof Uint8Array
226
+ ? value
227
+ : new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
228
+ if (typeof Buffer !== 'undefined') {
229
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64')
230
+ }
231
+ let text = ''
232
+ for (const byte of bytes) text += String.fromCharCode(byte)
233
+ // eslint-disable-next-line no-undef
234
+ return btoa(text)
235
+ }
236
+
237
+ function isUuidString(value) {
238
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
239
+ }
240
+
138
241
  /**
139
242
  * Resolve TLS options for a redwire(s) connection. Source order:
140
243
  * 1. caller-supplied `options.tls` object.
@@ -255,6 +358,7 @@ export class RedDB {
255
358
  constructor(client) {
256
359
  this.client = client
257
360
  this.cache = new CacheClient(client)
361
+ this.queue = new QueueClient(client)
258
362
  const defaultKv = new KvClient(client)
259
363
  this.kv = Object.assign((collection = 'kv_default') => new KvClient(client, collection), {
260
364
  put: defaultKv.put.bind(defaultKv),
@@ -267,18 +371,52 @@ export class RedDB {
267
371
  }
268
372
 
269
373
  /** Execute a SQL query. Returns `{ statement, affected, columns, rows }`. */
270
- query(sql) {
271
- return this.client.call('query', { sql })
374
+ query(sql, ...params) {
375
+ const wireParams = normalizeQueryParams(params)
376
+ if (wireParams == null) {
377
+ return this.client.call('query', { sql })
378
+ }
379
+ return this.client.call('query', { sql, params: wireParams })
380
+ }
381
+
382
+ /** Execute a SQL statement. Alias for `query`, including parameter binding. */
383
+ execute(sql, ...params) {
384
+ return this.query(sql, ...params)
385
+ }
386
+
387
+ /** Insert one row. Returns `{ affected, id }`. */
388
+ async insert(collection, payload) {
389
+ let result = await this.client.call('insert', { collection, payload })
390
+ if (
391
+ result &&
392
+ typeof result === 'object' &&
393
+ !('affected' in result) &&
394
+ 'id' in result
395
+ ) {
396
+ result = { ...result, affected: 1 }
397
+ }
398
+ return requireInsertId(result, 'insert')
272
399
  }
273
400
 
274
- /** Insert one row. Returns `{ affected, id? }`. */
275
- insert(collection, payload) {
276
- return this.client.call('insert', { collection, payload })
401
+ /** Insert many rows in one call. Returns `{ affected, ids }`. */
402
+ async bulkInsert(collection, payloads) {
403
+ const result = await this.client.call('bulk_insert', { collection, payloads })
404
+ return requireInsertIds(result, payloads.length)
277
405
  }
278
406
 
279
- /** Insert many rows in one call. Returns `{ affected }`. */
280
- bulkInsert(collection, payloads) {
281
- return this.client.call('bulk_insert', { collection, payloads })
407
+ /** Return true when a collection is visible in the catalog. */
408
+ exists(collection) {
409
+ return collectionExists(this, collection)
410
+ }
411
+
412
+ /** List visible collections using SHOW COLLECTIONS. */
413
+ list() {
414
+ return listCollections(this)
415
+ }
416
+
417
+ /** Return a caller-typed query builder for a collection. */
418
+ from(collection) {
419
+ return new TypedQueryBuilder(this, collection)
282
420
  }
283
421
 
284
422
  /** Get an entity by id. Returns `{ entity }` (entity is `null` if not found). */
@@ -334,3 +472,29 @@ export class RedDB {
334
472
  return this.client.close()
335
473
  }
336
474
  }
475
+
476
+ function requireInsertId(result, method) {
477
+ if (!result || typeof result !== 'object' || result.id == null) {
478
+ throw new RedDBError(
479
+ 'ENGINE_TOO_OLD',
480
+ `${method}() requires RedDB engine >= ${MIN_INSERT_ID_ENGINE_VERSION} with insert id support`,
481
+ )
482
+ }
483
+ return result
484
+ }
485
+
486
+ function requireInsertIds(result, expected) {
487
+ if (!result || typeof result !== 'object' || !Array.isArray(result.ids)) {
488
+ throw new RedDBError(
489
+ 'ENGINE_TOO_OLD',
490
+ `bulkInsert() requires RedDB engine >= ${MIN_INSERT_ID_ENGINE_VERSION} with bulk insert id support`,
491
+ )
492
+ }
493
+ if (result.ids.length !== expected) {
494
+ throw new RedDBError(
495
+ 'INVALID_RESPONSE',
496
+ `bulkInsert() expected ${expected} ids, got ${result.ids.length}`,
497
+ )
498
+ }
499
+ return result
500
+ }
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
+ }