@reddb-io/sdk 1.1.2 → 1.2.3
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 +93 -4
- package/index.d.ts +56 -0
- package/package.json +2 -2
- package/src/db-helpers.js +1 -1
- package/src/documents.js +121 -0
- package/src/index.js +92 -6
- package/src/kv.js +37 -1
package/README.md
CHANGED
|
@@ -49,6 +49,17 @@ await db.bulkInsert('users', [{ name: 'Bob' }, { name: 'Carol' }])
|
|
|
49
49
|
const result = await db.query('SELECT * FROM users')
|
|
50
50
|
console.log(result.rows)
|
|
51
51
|
|
|
52
|
+
const doc = await db.documents.insert('events', {
|
|
53
|
+
event_type: 'login',
|
|
54
|
+
attempts: 1,
|
|
55
|
+
})
|
|
56
|
+
await db.documents.patch('events', doc.rid, { reviewed: true })
|
|
57
|
+
|
|
58
|
+
await db.query('CREATE KV settings')
|
|
59
|
+
const kv = db.kv('settings')
|
|
60
|
+
await kv.put('characters:hansel', 'crumbs')
|
|
61
|
+
console.log(await kv.get('characters:hansel'))
|
|
62
|
+
|
|
52
63
|
await db.close()
|
|
53
64
|
```
|
|
54
65
|
|
|
@@ -71,6 +82,28 @@ try {
|
|
|
71
82
|
await db.close()
|
|
72
83
|
```
|
|
73
84
|
|
|
85
|
+
For the SQL/RQL grammar that `db.query()` accepts, see
|
|
86
|
+
[`docs/reference/sql-1-0-x.md`](../../docs/reference/sql-1-0-x.md).
|
|
87
|
+
|
|
88
|
+
## Transactions
|
|
89
|
+
|
|
90
|
+
Use `db.transaction()` when a group of writes must commit or roll back together.
|
|
91
|
+
The callback receives a transaction handle with the same `query`, `insert`, and
|
|
92
|
+
`bulkInsert` methods as `db`.
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
const userId = await db.transaction(async (tx) => {
|
|
96
|
+
const inserted = await tx.insert('users', { name: 'Ada' })
|
|
97
|
+
await tx.query('INSERT INTO audit (action) VALUES ($1)', 'created user')
|
|
98
|
+
return inserted.rid
|
|
99
|
+
})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The wrapper sends `BEGIN`, commits when the callback resolves, and rolls back
|
|
103
|
+
when the callback or a `tx.query()` / `tx.insert()` call throws. Nested
|
|
104
|
+
transactions on the same connection are rejected with `NESTED_TX_NOT_SUPPORTED`;
|
|
105
|
+
open another `connect()` handle for independent concurrent transactions.
|
|
106
|
+
|
|
74
107
|
## Connection URIs
|
|
75
108
|
|
|
76
109
|
| URI | Mode |
|
|
@@ -135,13 +168,69 @@ client yet. Use the HTTP streaming API for incremental ASK frames; stdio
|
|
|
135
168
|
currently supports materialised cursor batching through `query.open` /
|
|
136
169
|
`query.next`, which is separate from ASK token streaming.
|
|
137
170
|
|
|
138
|
-
### `db.insert(collection, payload) → Promise<{ affected, id
|
|
171
|
+
### `db.insert(collection, payload) → Promise<{ affected, rid, id }>`
|
|
172
|
+
|
|
173
|
+
`id` is a legacy alias for `rid`.
|
|
174
|
+
|
|
175
|
+
### `db.bulkInsert(collection, payloads) → Promise<{ affected, rids, ids }>`
|
|
176
|
+
|
|
177
|
+
`ids` is a legacy alias for `rids`.
|
|
178
|
+
|
|
179
|
+
### `db.get(collection, rid) → Promise<{ entity }>`
|
|
180
|
+
|
|
181
|
+
### `db.delete(collection, rid) → Promise<{ affected }>`
|
|
139
182
|
|
|
140
|
-
### `db.
|
|
183
|
+
### `db.documents`
|
|
141
184
|
|
|
142
|
-
|
|
185
|
+
Document helpers follow the SDK Helper Spec:
|
|
143
186
|
|
|
144
|
-
|
|
187
|
+
```js
|
|
188
|
+
const inserted = await db.documents.insert('events', {
|
|
189
|
+
event_type: 'login',
|
|
190
|
+
details: { ip: '10.0.0.7' },
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const event = await db.documents.get('events', inserted.rid)
|
|
194
|
+
const page = await db.documents.list('events', {
|
|
195
|
+
filter: "event_type = 'login'",
|
|
196
|
+
limit: 10,
|
|
197
|
+
})
|
|
198
|
+
const updated = await db.documents.patch('events', inserted.rid, {
|
|
199
|
+
reviewed: true,
|
|
200
|
+
})
|
|
201
|
+
await db.documents.delete('events', inserted.rid)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`documents.insert()` creates the document collection when needed. Patch support
|
|
205
|
+
currently accepts top-level document fields.
|
|
206
|
+
|
|
207
|
+
### `db.kv(collection?)`
|
|
208
|
+
|
|
209
|
+
KV helpers preserve exact keys, including namespaced keys with `:`.
|
|
210
|
+
|
|
211
|
+
```js
|
|
212
|
+
await db.query('CREATE KV settings')
|
|
213
|
+
const kv = db.kv('settings')
|
|
214
|
+
|
|
215
|
+
await kv.put('characters:hansel', 'crumbs')
|
|
216
|
+
await kv.get('characters:hansel') // 'crumbs'
|
|
217
|
+
await kv.exists('characters:hansel') // { exists: true }
|
|
218
|
+
await kv.list({ prefix: 'characters:' }) // { items: [{ key, value }] }
|
|
219
|
+
await kv.delete('characters:hansel') // { affected }
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### `db.queue`
|
|
223
|
+
|
|
224
|
+
Queue helpers cover the embedded FIFO workflow:
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
await db.query('CREATE QUEUE jobs')
|
|
228
|
+
await db.queue.push('jobs', { task: 'ship' })
|
|
229
|
+
await db.queue.peek('jobs')
|
|
230
|
+
await db.queue.pop('jobs')
|
|
231
|
+
await db.queue.len('jobs')
|
|
232
|
+
await db.queue.purge('jobs')
|
|
233
|
+
```
|
|
145
234
|
|
|
146
235
|
### `db.health() → Promise<{ ok, version }>`
|
|
147
236
|
|
package/index.d.ts
CHANGED
|
@@ -89,11 +89,13 @@ export interface AskQueryResult {
|
|
|
89
89
|
|
|
90
90
|
export interface InsertResult {
|
|
91
91
|
affected: number
|
|
92
|
+
rid: string | number
|
|
92
93
|
id: string | number
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
export interface BulkInsertResult {
|
|
96
97
|
affected: number
|
|
98
|
+
rids: Array<string | number>
|
|
97
99
|
ids: Array<string | number>
|
|
98
100
|
}
|
|
99
101
|
|
|
@@ -233,6 +235,11 @@ export class KvClient {
|
|
|
233
235
|
): Promise<QueryResult>
|
|
234
236
|
get(key: string, options?: { collection?: string }): Promise<unknown | null>
|
|
235
237
|
getMany(keys: string[], options?: { collection?: string }): Promise<Array<unknown | null>>
|
|
238
|
+
exists(key: string, options?: { collection?: string }): Promise<{ exists: boolean }>
|
|
239
|
+
delete(key: string, options?: { collection?: string }): Promise<DeleteResult>
|
|
240
|
+
list(options?: { collection?: string; prefix?: string; limit?: number }): Promise<{
|
|
241
|
+
items: Array<{ key: string; value: unknown }>
|
|
242
|
+
}>
|
|
236
243
|
invalidateTags(tags: string[], options?: { collection?: string }): Promise<number>
|
|
237
244
|
watch(
|
|
238
245
|
key: string,
|
|
@@ -244,6 +251,33 @@ export class KvClient {
|
|
|
244
251
|
): AsyncIterable<KvWatchEvent>
|
|
245
252
|
}
|
|
246
253
|
|
|
254
|
+
export interface DocumentInsertResult<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
255
|
+
affected: number
|
|
256
|
+
rid: string | number
|
|
257
|
+
item: T & { rid: string | number }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export class DocumentClient {
|
|
261
|
+
insert<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
262
|
+
collection: string,
|
|
263
|
+
document: Record<string, unknown>,
|
|
264
|
+
): Promise<DocumentInsertResult<T>>
|
|
265
|
+
get<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
266
|
+
collection: string,
|
|
267
|
+
rid: string | number,
|
|
268
|
+
): Promise<T & { rid: string | number }>
|
|
269
|
+
list<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
270
|
+
collection: string,
|
|
271
|
+
options?: { filter?: string; orderBy?: string; order_by?: string; limit?: number },
|
|
272
|
+
): Promise<{ items: Array<T & { rid: string | number }> }>
|
|
273
|
+
patch<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
274
|
+
collection: string,
|
|
275
|
+
rid: string | number,
|
|
276
|
+
patch: Record<string, unknown>,
|
|
277
|
+
): Promise<T & { rid: string | number }>
|
|
278
|
+
delete(collection: string, rid: string | number): Promise<DeleteResult>
|
|
279
|
+
}
|
|
280
|
+
|
|
247
281
|
export class QueueClient {
|
|
248
282
|
push(
|
|
249
283
|
queue: string,
|
|
@@ -294,11 +328,30 @@ export class VaultClient {
|
|
|
294
328
|
unseal(key: string, options?: { collection?: string }): Promise<QueryResult>
|
|
295
329
|
}
|
|
296
330
|
|
|
331
|
+
export interface RedDBTransaction {
|
|
332
|
+
query(sql: `ASK ${string}`): Promise<AskQueryResult>
|
|
333
|
+
query(sql: string): Promise<QueryResult>
|
|
334
|
+
query(sql: string, params: QueryParam[]): Promise<QueryResult>
|
|
335
|
+
query(sql: string, ...params: QueryParam[]): Promise<QueryResult>
|
|
336
|
+
execute(sql: string): Promise<QueryResult>
|
|
337
|
+
execute(sql: string, params: QueryParam[]): Promise<QueryResult>
|
|
338
|
+
execute(sql: string, ...params: QueryParam[]): Promise<QueryResult>
|
|
339
|
+
insert(collection: string, payload: Record<string, unknown>): Promise<InsertResult>
|
|
340
|
+
bulkInsert(
|
|
341
|
+
collection: string,
|
|
342
|
+
payloads: Array<Record<string, unknown>>,
|
|
343
|
+
): Promise<BulkInsertResult>
|
|
344
|
+
transaction<T>(
|
|
345
|
+
callback: (tx: RedDBTransaction) => T | Promise<T>,
|
|
346
|
+
): Promise<T>
|
|
347
|
+
}
|
|
348
|
+
|
|
297
349
|
export class RedDB {
|
|
298
350
|
/** Underlying transport label. connect() returns 'embedded'. */
|
|
299
351
|
readonly transport: string | null
|
|
300
352
|
readonly cache: CacheClient
|
|
301
353
|
readonly queue: QueueClient
|
|
354
|
+
readonly documents: DocumentClient
|
|
302
355
|
readonly kv: KvClient & ((collection?: string) => KvClient)
|
|
303
356
|
readonly config: (collection?: string) => ConfigClient
|
|
304
357
|
readonly vault: (collection?: string) => VaultClient
|
|
@@ -315,6 +368,9 @@ export class RedDB {
|
|
|
315
368
|
collection: string,
|
|
316
369
|
payloads: Array<Record<string, unknown>>,
|
|
317
370
|
): Promise<BulkInsertResult>
|
|
371
|
+
transaction<T>(
|
|
372
|
+
callback: (tx: RedDBTransaction) => T | Promise<T>,
|
|
373
|
+
): Promise<T>
|
|
318
374
|
exists(collection: string): Promise<boolean>
|
|
319
375
|
list(): Promise<CollectionMeta[]>
|
|
320
376
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reddb-io/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.3",
|
|
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",
|
|
@@ -45,6 +45,6 @@
|
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"postinstall": "node postinstall.js",
|
|
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"
|
|
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 test/transaction.test.mjs && node test/smoke.test.mjs"
|
|
49
49
|
}
|
|
50
50
|
}
|
package/src/db-helpers.js
CHANGED
|
@@ -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
|
package/src/documents.js
ADDED
|
@@ -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/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/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
|
|