@reddb-io/cli 1.1.1 → 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 +2 -2
- package/drivers/js/package.json +2 -2
- package/drivers/js/src/db-helpers.js +1 -1
- package/drivers/js/src/documents.js +121 -0
- package/drivers/js/src/index.js +92 -6
- package/drivers/js/src/kv.js +37 -1
- package/package.json +1 -1
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,
|
|
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
|
|
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
|
package/drivers/js/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reddb-io/sdk",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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
|
+
}
|
package/drivers/js/src/index.js
CHANGED
|
@@ -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 (
|
|
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.
|
|
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}
|
|
526
|
+
`bulkInsert() expected ${expected} rids, got ${result.rids.length}`,
|
|
441
527
|
)
|
|
442
528
|
}
|
|
443
529
|
return result
|
package/drivers/js/src/kv.js
CHANGED
|
@@ -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)}.${
|
|
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
|
|