@reddb-io/cli 1.1.2 → 1.2.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 CHANGED
@@ -44,7 +44,7 @@ INSERT INTO users (name, email) VALUES ('Alice', 'alice@co.com')
44
44
  INSERT INTO logs DOCUMENT (body) VALUES ({"level":"info","msg":"login"})
45
45
 
46
46
  -- Graph edges
47
- INSERT INTO network EDGE (label, from, to) VALUES ('CONNECTS', 1, 2)
47
+ INSERT INTO network EDGE (label, from_rid, to_rid) VALUES ('CONNECTS', 102, 103)
48
48
 
49
49
  -- Vector similarity search
50
50
  SEARCH SIMILAR TEXT 'anomaly detected' COLLECTION events
@@ -372,7 +372,7 @@ Same storage format across all three. Start embedded, scale to server, expose to
372
372
  RedDB uses multiple optimization techniques for fast queries at scale:
373
373
 
374
374
  - **Result Cache** -- identical SELECT queries return in <1ms; auto-invalidated on INSERT/UPDATE/DELETE (30s TTL, max 1000 entries)
375
- - **Hot Entity Cache** -- `get_any(id)` lookups served from an LRU cache (10K entries), O(1) instead of scanning all collections
375
+ - **Hot Item Cache** -- `get_any(rid)` lookups served from an LRU cache (10K entries), O(1) instead of scanning all collections
376
376
  - **Binary Bulk Insert** -- gRPC `BulkInsertBinary` with zero JSON overhead, protobuf native types -- 241K ops/sec
377
377
  - **Concurrent HTTP** -- thread-per-connection model; each request handled in its own OS thread
378
378
  - **Parallel Segment Scanning** -- sealed segments scanned in parallel via `std::thread::scope`; auto-detects single-core and skips parallelism
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddb-io/sdk",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
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",
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "scripts": {
22
22
  "postinstall": "node postinstall.js",
23
- "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"
23
+ "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 test/transaction.test.mjs && node test/smoke.test.mjs"
24
24
  },
25
25
  "engines": {
26
26
  "node": ">=18"
@@ -50,7 +50,7 @@ export class TypedQueryBuilder {
50
50
  ? '*'
51
51
  : this.columns.map(sqlIdentifierPath).join(', ')
52
52
  const where = this.whereClauses.length > 0
53
- ? ` WHERE ${this.whereClauses.map((clause) => `(${clause})`).join(' AND ')}`
53
+ ? ` WHERE ${this.whereClauses.join(' AND ')}`
54
54
  : ''
55
55
  const sql = `SELECT ${projection} FROM ${sqlIdentifierPath(this.collection)}${where}`
56
56
  const result = this.params.length > 0
@@ -0,0 +1,121 @@
1
+ import { RedDBError } from './protocol.js'
2
+
3
+ export class DocumentClient {
4
+ constructor(db) {
5
+ this.db = db
6
+ }
7
+
8
+ async insert(collection, document) {
9
+ validateObject(document, 'documents.insert document')
10
+ await this.ensureCollection(collection)
11
+ const result = await this.db.query(
12
+ `INSERT INTO ${sqlIdentifierPath(collection)} DOCUMENT (body) VALUES (${sqlJsonLiteral(document)}) RETURNING *`,
13
+ )
14
+ const item = result.rows?.[0]
15
+ if (!item || item.rid == null) {
16
+ throw new RedDBError('INVALID_RESPONSE', 'documents.insert expected one returned item with rid')
17
+ }
18
+ return { affected: result.affected ?? 1, rid: item.rid, item }
19
+ }
20
+
21
+ async get(collection, rid) {
22
+ const result = await this.db.get(collection, rid)
23
+ if (!result.entity) {
24
+ throw new RedDBError('NOT_FOUND', `document ${String(rid)} was not found`)
25
+ }
26
+ return result.entity
27
+ }
28
+
29
+ async list(collection, options = {}) {
30
+ const limit = normalizeLimit(options.limit)
31
+ const orderBy = options.orderBy ?? options.order_by ?? 'rid ASC'
32
+ const where = options.filter ? ` WHERE ${String(options.filter)}` : ''
33
+ const result = await this.db.query(
34
+ `SELECT * FROM ${sqlIdentifierPath(collection)}${where} ORDER BY ${orderBy} LIMIT ${limit}`,
35
+ )
36
+ return { items: result.rows ?? [] }
37
+ }
38
+
39
+ async patch(collection, rid, patch) {
40
+ validateObject(patch, 'documents.patch patch')
41
+ const entries = Object.entries(patch)
42
+ if (entries.length === 0) {
43
+ return this.get(collection, rid)
44
+ }
45
+ for (const [field] of entries) {
46
+ if (field.includes('/')) {
47
+ throw new RedDBError(
48
+ 'INVALID_ARGUMENT',
49
+ 'documents.patch currently accepts top-level document fields',
50
+ )
51
+ }
52
+ }
53
+ const assignments = entries
54
+ .map(([field, value]) => `${sqlIdentifier(field)} = ${sqlValueLiteral(value)}`)
55
+ .join(', ')
56
+ const result = await this.db.query(
57
+ `UPDATE ${sqlIdentifierPath(collection)} DOCUMENTS SET ${assignments} WHERE rid = $1 RETURNING *`,
58
+ rid,
59
+ )
60
+ const item = result.rows?.[0]
61
+ if (!item) {
62
+ throw new RedDBError('NOT_FOUND', `document ${String(rid)} was not found`)
63
+ }
64
+ return item
65
+ }
66
+
67
+ async delete(collection, rid) {
68
+ const result = await this.db.delete(collection, rid)
69
+ return { affected: result.affected ?? 0 }
70
+ }
71
+
72
+ async ensureCollection(collection) {
73
+ try {
74
+ await this.db.query(`CREATE DOCUMENT ${sqlIdentifierPath(collection)}`)
75
+ } catch (err) {
76
+ const message = String(err?.message ?? '')
77
+ if (!message.includes('already exists')) throw err
78
+ }
79
+ }
80
+ }
81
+
82
+ function validateObject(value, label) {
83
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
84
+ throw new RedDBError('INVALID_ARGUMENT', `${label} must be an object`)
85
+ }
86
+ }
87
+
88
+ function normalizeLimit(value) {
89
+ if (value == null) return 100
90
+ if (!Number.isInteger(value) || value <= 0) {
91
+ throw new RedDBError('INVALID_ARGUMENT', 'limit must be a positive integer')
92
+ }
93
+ return value
94
+ }
95
+
96
+ function sqlIdentifierPath(value) {
97
+ return String(value).split('.').map(sqlIdentifier).join('.')
98
+ }
99
+
100
+ function sqlIdentifier(value) {
101
+ const ident = String(value)
102
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ident)) {
103
+ throw new RedDBError('INVALID_ARGUMENT', `invalid SQL identifier "${ident}"`)
104
+ }
105
+ return ident
106
+ }
107
+
108
+ function sqlJsonLiteral(value) {
109
+ return sqlString(JSON.stringify(value))
110
+ }
111
+
112
+ function sqlValueLiteral(value) {
113
+ if (value == null) return 'NULL'
114
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
115
+ if (typeof value === 'object') return sqlJsonLiteral(value)
116
+ return sqlString(value)
117
+ }
118
+
119
+ function sqlString(value) {
120
+ return `'${String(value).replace(/'/g, "''")}'`
121
+ }
@@ -25,6 +25,7 @@ import { parseUri } from './url.js'
25
25
  import { CacheClient } from './cache.js'
26
26
  import { KvClient } from './kv.js'
27
27
  import { QueueClient } from './queue.js'
28
+ import { DocumentClient } from './documents.js'
28
29
  import { ConfigClient } from './config.js'
29
30
  import { VaultClient } from './vault.js'
30
31
  import { TypedQueryBuilder, collectionExists, listCollections } from './db-helpers.js'
@@ -33,6 +34,7 @@ export { RedDBError }
33
34
  export { CacheClient } from './cache.js'
34
35
  export { KvClient } from './kv.js'
35
36
  export { QueueClient } from './queue.js'
37
+ export { DocumentClient } from './documents.js'
36
38
  export { ConfigClient } from './config.js'
37
39
  export { VaultClient } from './vault.js'
38
40
  export { TypedQueryBuilder } from './db-helpers.js'
@@ -42,6 +44,7 @@ export const EMBEDDED_ONLY_MESSAGE =
42
44
  'remote URIs are not supported in @reddb-io/sdk; install @reddb-io/client for grpc/http/red transports'
43
45
 
44
46
  const MIN_INSERT_ID_ENGINE_VERSION = '1.0.9'
47
+ const NESTED_TX_NOT_SUPPORTED = 'NESTED_TX_NOT_SUPPORTED'
45
48
 
46
49
  /**
47
50
  * Connect to a RedDB instance.
@@ -275,6 +278,32 @@ function rejectRemoteUri(parsed) {
275
278
  /**
276
279
  * Connection handle. Methods map 1:1 to JSON-RPC methods on the binary.
277
280
  */
281
+ class TransactionHandle {
282
+ constructor(db) {
283
+ this.db = db
284
+ }
285
+
286
+ query(sql, ...params) {
287
+ return this.db.query(sql, ...params)
288
+ }
289
+
290
+ execute(sql, ...params) {
291
+ return this.db.execute(sql, ...params)
292
+ }
293
+
294
+ insert(collection, payload) {
295
+ return this.db.insert(collection, payload)
296
+ }
297
+
298
+ bulkInsert(collection, payloads) {
299
+ return this.db.bulkInsert(collection, payloads)
300
+ }
301
+
302
+ async transaction() {
303
+ throw nestedTransactionError()
304
+ }
305
+ }
306
+
278
307
  export class RedDB {
279
308
  /**
280
309
  * @param {RpcClient} client
@@ -288,6 +317,7 @@ export class RedDB {
288
317
  this.transport = opts.transport ?? null
289
318
  this.cache = new CacheClient(client, this.transport)
290
319
  this.queue = new QueueClient(client)
320
+ this.documents = new DocumentClient(this)
291
321
  const defaultKv = new KvClient(client)
292
322
  this.kv = Object.assign((collection = 'kv_default') => new KvClient(client, collection), {
293
323
  put: defaultKv.put.bind(defaultKv),
@@ -297,6 +327,7 @@ export class RedDB {
297
327
  })
298
328
  this.config = (collection = 'red.config') => new ConfigClient(client, collection)
299
329
  this.vault = (collection = 'red.vault') => new VaultClient(client, collection)
330
+ this.inTransaction = false
300
331
  }
301
332
 
302
333
  /**
@@ -322,18 +353,48 @@ export class RedDB {
322
353
  return this.query(sql, ...params)
323
354
  }
324
355
 
325
- /** Insert one row. Returns `{ affected, id }`. */
356
+ /** Insert one row. Returns `{ affected, rid, id }`; `id` is a legacy alias. */
326
357
  async insert(collection, payload) {
327
358
  const result = await this.client.call('insert', { collection, payload })
328
359
  return requireInsertId(result, 'insert')
329
360
  }
330
361
 
331
- /** Insert many rows in one call. Returns `{ affected, ids }`. */
362
+ /** Insert many rows in one call. Returns `{ affected, rids, ids }`; `ids` is a legacy alias. */
332
363
  async bulkInsert(collection, payloads) {
333
364
  const result = await this.client.call('bulk_insert', { collection, payloads })
334
365
  return requireInsertIds(result, payloads.length)
335
366
  }
336
367
 
368
+ async transaction(callback) {
369
+ if (this.inTransaction) {
370
+ throw nestedTransactionError()
371
+ }
372
+ if (typeof callback !== 'function') {
373
+ throw new TypeError('transaction(callback) requires a function')
374
+ }
375
+
376
+ this.inTransaction = true
377
+ let began = false
378
+ try {
379
+ await this.query('BEGIN')
380
+ began = true
381
+ const result = await callback(new TransactionHandle(this))
382
+ await this.query('COMMIT')
383
+ return result
384
+ } catch (err) {
385
+ if (began) {
386
+ try {
387
+ await this.query('ROLLBACK')
388
+ } catch (rollbackErr) {
389
+ attachRollbackError(err, rollbackErr)
390
+ }
391
+ }
392
+ throw err
393
+ } finally {
394
+ this.inTransaction = false
395
+ }
396
+ }
397
+
337
398
  /** Return true when a collection is visible in the catalog. */
338
399
  exists(collection) {
339
400
  return collectionExists(this, collection)
@@ -417,27 +478,52 @@ export class RedDB {
417
478
  }
418
479
  }
419
480
 
481
+ function nestedTransactionError() {
482
+ return new RedDBError(
483
+ NESTED_TX_NOT_SUPPORTED,
484
+ `${NESTED_TX_NOT_SUPPORTED}: nested transactions are not supported on one connection`,
485
+ )
486
+ }
487
+
488
+ function attachRollbackError(err, rollbackErr) {
489
+ if (err && typeof err === 'object') {
490
+ try {
491
+ err.rollbackError = rollbackErr
492
+ } catch {
493
+ // Preserve the original callback/query error even for frozen errors.
494
+ }
495
+ }
496
+ }
497
+
420
498
  function requireInsertId(result, method) {
421
- if (!result || typeof result !== 'object' || result.id == null) {
499
+ if (!result || typeof result !== 'object' || (result.rid == null && result.id == null)) {
422
500
  throw new RedDBError(
423
501
  'ENGINE_TOO_OLD',
424
502
  `${method}() requires RedDB engine >= ${MIN_INSERT_ID_ENGINE_VERSION} with insert id support`,
425
503
  )
426
504
  }
505
+ if (result.rid == null) result.rid = result.id
506
+ if (result.id == null) result.id = result.rid
427
507
  return result
428
508
  }
429
509
 
430
510
  function requireInsertIds(result, expected) {
431
- if (!result || typeof result !== 'object' || !Array.isArray(result.ids)) {
511
+ if (
512
+ !result ||
513
+ typeof result !== 'object' ||
514
+ (!Array.isArray(result.rids) && !Array.isArray(result.ids))
515
+ ) {
432
516
  throw new RedDBError(
433
517
  'ENGINE_TOO_OLD',
434
518
  `bulkInsert() requires RedDB engine >= ${MIN_INSERT_ID_ENGINE_VERSION} with bulk insert id support`,
435
519
  )
436
520
  }
437
- if (result.ids.length !== expected) {
521
+ if (!Array.isArray(result.rids)) result.rids = result.ids
522
+ if (!Array.isArray(result.ids)) result.ids = result.rids
523
+ if (result.rids.length !== expected) {
438
524
  throw new RedDBError(
439
525
  'INVALID_RESPONSE',
440
- `bulkInsert() expected ${expected} ids, got ${result.ids.length}`,
526
+ `bulkInsert() expected ${expected} rids, got ${result.rids.length}`,
441
527
  )
442
528
  }
443
529
  return result
@@ -31,6 +31,35 @@ export class KvClient {
31
31
  return values
32
32
  }
33
33
 
34
+ async exists(key, options = {}) {
35
+ return { exists: (await this.get(key, options)) !== null }
36
+ }
37
+
38
+ async delete(key, options = {}) {
39
+ const collection = options.collection ?? this.collection
40
+ const result = await this.client.call('query', {
41
+ sql: `KV DELETE ${kvPath(collection, key)}`,
42
+ })
43
+ return { affected: result.affected ?? result.affected_rows ?? 0 }
44
+ }
45
+
46
+ async list(options = {}) {
47
+ const collection = options.collection ?? this.collection
48
+ const limit = options.limit == null ? 100 : Number(options.limit)
49
+ if (!Number.isInteger(limit) || limit <= 0) {
50
+ throw new RedDBError('INVALID_ARGUMENT', 'kv.list limit must be a positive integer')
51
+ }
52
+ const prefix = options.prefix == null ? '' : String(options.prefix)
53
+ const result = await this.client.call('query', {
54
+ sql: `SELECT key, value FROM ${kvIdentifier(collection)} ORDER BY key ASC LIMIT ${limit}`,
55
+ })
56
+ const rows = result.rows ?? []
57
+ const items = prefix.length > 0
58
+ ? rows.filter((row) => String(row.key).startsWith(prefix))
59
+ : rows
60
+ return { items }
61
+ }
62
+
34
63
  async invalidateTags(tags, options = {}) {
35
64
  const collection = options.collection ?? this.collection
36
65
  const result = await this.client.call('query', {
@@ -66,7 +95,7 @@ export class KvClient {
66
95
  }
67
96
 
68
97
  function kvPath(collection, key) {
69
- return `${kvIdentifier(collection)}.${kvIdentifier(key)}`
98
+ return `${kvIdentifier(collection)}.${kvKeySegment(key)}`
70
99
  }
71
100
 
72
101
  function kvIdentifier(value) {
@@ -81,9 +110,16 @@ function kvIdentifier(value) {
81
110
  return ident
82
111
  }
83
112
 
113
+ function kvKeySegment(value) {
114
+ const key = String(value)
115
+ if (/^[A-Za-z0-9_]+$/.test(key)) return key
116
+ return `'${key.replace(/'/g, "''")}'`
117
+ }
118
+
84
119
  function kvValueLiteral(value) {
85
120
  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
86
121
  if (value == null) return 'NULL'
122
+ if (typeof value === 'object') return `'${JSON.stringify(value).replace(/'/g, "''")}'`
87
123
  return `'${String(value).replace(/'/g, "''")}'`
88
124
  }
89
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddb-io/cli",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "CLI launcher for RedDB. The JS/TS app driver is published as @reddb-io/sdk.",
5
5
  "type": "commonjs",
6
6
  "bin": {