@reddb-io/client 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 +42 -0
- package/index.d.ts +59 -2
- package/package.json +1 -1
- package/src/db-helpers.js +1 -1
- package/src/documents.js +121 -0
- package/src/grpc.js +6 -2
- package/src/index.js +93 -7
- package/src/kv.js +37 -1
package/README.md
CHANGED
|
@@ -50,6 +50,14 @@ await db.insert('users', { name: 'Alice' })
|
|
|
50
50
|
const result = await db.query('SELECT * FROM users WHERE name = $1', 'Alice')
|
|
51
51
|
console.log(result.rows)
|
|
52
52
|
|
|
53
|
+
const doc = await db.documents.insert('events', { event_type: 'login' })
|
|
54
|
+
await db.documents.patch('events', doc.rid, { reviewed: true })
|
|
55
|
+
|
|
56
|
+
await db.query('CREATE KV settings')
|
|
57
|
+
const kv = db.kv('settings')
|
|
58
|
+
await kv.put('characters:hansel', 'crumbs')
|
|
59
|
+
console.log(await kv.get('characters:hansel'))
|
|
60
|
+
|
|
53
61
|
await db.close()
|
|
54
62
|
```
|
|
55
63
|
|
|
@@ -61,6 +69,40 @@ For `http://` and `https://` connections, `connect()` verifies readiness with
|
|
|
61
69
|
a lightweight `SELECT 1` round-trip. `/health` states such as `degraded` are
|
|
62
70
|
transient during boot and are not fatal as long as queries succeed.
|
|
63
71
|
|
|
72
|
+
## Transactions
|
|
73
|
+
|
|
74
|
+
Use `db.transaction()` when a group of writes must commit or roll back together.
|
|
75
|
+
The callback receives a transaction handle with the same `query`, `insert`, and
|
|
76
|
+
`bulkInsert` methods as `db`.
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
const userId = await db.transaction(async (tx) => {
|
|
80
|
+
const inserted = await tx.insert('users', { name: 'Ada' })
|
|
81
|
+
await tx.query('INSERT INTO audit (action) VALUES ($1)', 'created user')
|
|
82
|
+
return inserted.rid
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The wrapper sends `BEGIN`, commits when the callback resolves, and rolls back
|
|
87
|
+
when the callback or a `tx.query()` / `tx.insert()` call throws. Nested
|
|
88
|
+
transactions on the same connection are rejected with `NESTED_TX_NOT_SUPPORTED`;
|
|
89
|
+
open another `connect()` handle for independent concurrent transactions.
|
|
90
|
+
|
|
91
|
+
## Rich Helpers
|
|
92
|
+
|
|
93
|
+
The client follows the SDK Helper Spec for the shared JS/TS surface:
|
|
94
|
+
|
|
95
|
+
- `db.insert(collection, payload)` returns `{ affected, rid, id }`; `id` is a
|
|
96
|
+
legacy alias for `rid`.
|
|
97
|
+
- `db.bulkInsert(collection, payloads)` returns `{ affected, rids, ids }`; `ids`
|
|
98
|
+
is a legacy alias for `rids`.
|
|
99
|
+
- `db.documents.insert/get/list/patch/delete` covers document CRUD. Insert
|
|
100
|
+
creates the document collection when needed; patch currently accepts top-level
|
|
101
|
+
fields.
|
|
102
|
+
- `db.kv(collection?)` preserves exact keys, including namespaced keys such as
|
|
103
|
+
`characters:hansel`, and exposes `put/get/exists/delete/list`.
|
|
104
|
+
- `db.queue` exposes `push/pop/peek/len/purge`.
|
|
105
|
+
|
|
64
106
|
## Accepted URI schemes
|
|
65
107
|
|
|
66
108
|
| Scheme | Transport | Default port |
|
package/index.d.ts
CHANGED
|
@@ -43,8 +43,12 @@ export type QueryParam =
|
|
|
43
43
|
| number[]
|
|
44
44
|
| Record<string, unknown>
|
|
45
45
|
|
|
46
|
-
export interface InsertResult { affected: number; id: string | number }
|
|
47
|
-
export interface BulkInsertResult {
|
|
46
|
+
export interface InsertResult { affected: number; rid: string | number; id: string | number }
|
|
47
|
+
export interface BulkInsertResult {
|
|
48
|
+
affected: number
|
|
49
|
+
rids: Array<string | number>
|
|
50
|
+
ids: Array<string | number>
|
|
51
|
+
}
|
|
48
52
|
export interface GetResult { entity: Record<string, unknown> | null }
|
|
49
53
|
export interface DeleteResult { affected: number }
|
|
50
54
|
export interface CollectionMeta {
|
|
@@ -126,6 +130,11 @@ export class KvClient {
|
|
|
126
130
|
): Promise<QueryResult>
|
|
127
131
|
get(key: string, options?: { collection?: string }): Promise<unknown | null>
|
|
128
132
|
getMany(keys: string[], options?: { collection?: string }): Promise<Array<unknown | null>>
|
|
133
|
+
exists(key: string, options?: { collection?: string }): Promise<{ exists: boolean }>
|
|
134
|
+
delete(key: string, options?: { collection?: string }): Promise<DeleteResult>
|
|
135
|
+
list(options?: { collection?: string; prefix?: string; limit?: number }): Promise<{
|
|
136
|
+
items: Array<{ key: string; value: unknown }>
|
|
137
|
+
}>
|
|
129
138
|
invalidateTags(tags: string[], options?: { collection?: string }): Promise<number>
|
|
130
139
|
watch(
|
|
131
140
|
key: string,
|
|
@@ -137,6 +146,33 @@ export class KvClient {
|
|
|
137
146
|
): AsyncIterable<KvWatchEvent>
|
|
138
147
|
}
|
|
139
148
|
|
|
149
|
+
export interface DocumentInsertResult<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
150
|
+
affected: number
|
|
151
|
+
rid: string | number
|
|
152
|
+
item: T & { rid: string | number }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export class DocumentClient {
|
|
156
|
+
insert<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
157
|
+
collection: string,
|
|
158
|
+
document: Record<string, unknown>,
|
|
159
|
+
): Promise<DocumentInsertResult<T>>
|
|
160
|
+
get<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
161
|
+
collection: string,
|
|
162
|
+
rid: string | number,
|
|
163
|
+
): Promise<T & { rid: string | number }>
|
|
164
|
+
list<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
165
|
+
collection: string,
|
|
166
|
+
options?: { filter?: string; orderBy?: string; order_by?: string; limit?: number },
|
|
167
|
+
): Promise<{ items: Array<T & { rid: string | number }> }>
|
|
168
|
+
patch<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
169
|
+
collection: string,
|
|
170
|
+
rid: string | number,
|
|
171
|
+
patch: Record<string, unknown>,
|
|
172
|
+
): Promise<T & { rid: string | number }>
|
|
173
|
+
delete(collection: string, rid: string | number): Promise<DeleteResult>
|
|
174
|
+
}
|
|
175
|
+
|
|
140
176
|
export class QueueClient {
|
|
141
177
|
push(
|
|
142
178
|
queue: string,
|
|
@@ -204,9 +240,27 @@ export const EMBEDDED_REJECTION_MESSAGE: string
|
|
|
204
240
|
/** Returns true when `uri` selects the embedded engine. */
|
|
205
241
|
export function isEmbeddedUri(uri: string): boolean
|
|
206
242
|
|
|
243
|
+
export interface RedDBTransaction {
|
|
244
|
+
query(sql: string): Promise<QueryResult>
|
|
245
|
+
query(sql: string, params: QueryParam[]): Promise<QueryResult>
|
|
246
|
+
query(sql: string, ...params: QueryParam[]): Promise<QueryResult>
|
|
247
|
+
execute(sql: string): Promise<QueryResult>
|
|
248
|
+
execute(sql: string, params: QueryParam[]): Promise<QueryResult>
|
|
249
|
+
execute(sql: string, ...params: QueryParam[]): Promise<QueryResult>
|
|
250
|
+
insert(collection: string, payload: Record<string, unknown>): Promise<InsertResult>
|
|
251
|
+
bulkInsert(
|
|
252
|
+
collection: string,
|
|
253
|
+
payloads: Array<Record<string, unknown>>,
|
|
254
|
+
): Promise<BulkInsertResult>
|
|
255
|
+
transaction<T>(
|
|
256
|
+
callback: (tx: RedDBTransaction) => T | Promise<T>,
|
|
257
|
+
): Promise<T>
|
|
258
|
+
}
|
|
259
|
+
|
|
207
260
|
export class RedDB {
|
|
208
261
|
readonly cache: CacheClient
|
|
209
262
|
readonly queue: QueueClient
|
|
263
|
+
readonly documents: DocumentClient
|
|
210
264
|
readonly kv: KvClient & ((collection?: string) => KvClient)
|
|
211
265
|
readonly config: (collection?: string) => ConfigClient
|
|
212
266
|
readonly vault: (collection?: string) => VaultClient
|
|
@@ -222,6 +276,9 @@ export class RedDB {
|
|
|
222
276
|
collection: string,
|
|
223
277
|
payloads: Array<Record<string, unknown>>,
|
|
224
278
|
): Promise<BulkInsertResult>
|
|
279
|
+
transaction<T>(
|
|
280
|
+
callback: (tx: RedDBTransaction) => T | Promise<T>,
|
|
281
|
+
): Promise<T>
|
|
225
282
|
exists(collection: string): Promise<boolean>
|
|
226
283
|
list(): Promise<CollectionMeta[]>
|
|
227
284
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reddb-io/client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "Thin remote-only RedDB driver. Downloads the `red_client` binary on install. Speaks RedWire/gRPC/HTTP. Embedded URIs (memory://, file://, red:///path) are rejected — use @reddb-io/sdk for those.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
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/grpc.js
CHANGED
|
@@ -165,19 +165,23 @@ function normalizeQueryReply(reply) {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
function normalizeEntityReply(reply) {
|
|
168
|
+
const rid = safeBigIntToJs(reply.id)
|
|
168
169
|
return {
|
|
169
170
|
ok: reply.ok,
|
|
170
171
|
affected: reply.ok ? 1 : 0,
|
|
171
|
-
|
|
172
|
+
rid,
|
|
173
|
+
id: rid,
|
|
172
174
|
entity: parseJsonOrNull(reply.entity_json),
|
|
173
175
|
}
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
function normalizeBulkEntityReply(reply) {
|
|
179
|
+
const rids = reply.items.map((item) => safeBigIntToJs(item.id))
|
|
177
180
|
return {
|
|
178
181
|
ok: reply.ok,
|
|
179
182
|
affected: safeBigIntToJs(reply.count),
|
|
180
|
-
|
|
183
|
+
rids,
|
|
184
|
+
ids: rids,
|
|
181
185
|
}
|
|
182
186
|
}
|
|
183
187
|
|
package/src/index.js
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
import { CacheClient } from './cache.js'
|
|
41
41
|
import { KvClient } from './kv.js'
|
|
42
42
|
import { QueueClient } from './queue.js'
|
|
43
|
+
import { DocumentClient } from './documents.js'
|
|
43
44
|
import { ConfigClient } from './config.js'
|
|
44
45
|
import { VaultClient } from './vault.js'
|
|
45
46
|
import { TypedQueryBuilder, collectionExists, listCollections } from './db-helpers.js'
|
|
@@ -48,12 +49,14 @@ export { RedDBError, EmbeddedNotSupported, EMBEDDED_REJECTION_MESSAGE, isEmbedde
|
|
|
48
49
|
export { CacheClient } from './cache.js'
|
|
49
50
|
export { KvClient } from './kv.js'
|
|
50
51
|
export { QueueClient } from './queue.js'
|
|
52
|
+
export { DocumentClient } from './documents.js'
|
|
51
53
|
export { ConfigClient } from './config.js'
|
|
52
54
|
export { VaultClient } from './vault.js'
|
|
53
55
|
export { TypedQueryBuilder } from './db-helpers.js'
|
|
54
56
|
export { parseUri, deriveLoginUrl } from './url.js'
|
|
55
57
|
|
|
56
58
|
const MIN_INSERT_ID_ENGINE_VERSION = '1.0.9'
|
|
59
|
+
const NESTED_TX_NOT_SUPPORTED = 'NESTED_TX_NOT_SUPPORTED'
|
|
57
60
|
|
|
58
61
|
/**
|
|
59
62
|
* Connect to a remote RedDB instance.
|
|
@@ -353,12 +356,39 @@ export async function login(loginUrl, { username, password }) {
|
|
|
353
356
|
* Identical surface to `@reddb-io/sdk`'s `RedDB`, minus the local-spawn
|
|
354
357
|
* lifecycle.
|
|
355
358
|
*/
|
|
359
|
+
class TransactionHandle {
|
|
360
|
+
constructor(db) {
|
|
361
|
+
this.db = db
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
query(sql, ...params) {
|
|
365
|
+
return this.db.query(sql, ...params)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
execute(sql, ...params) {
|
|
369
|
+
return this.db.execute(sql, ...params)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
insert(collection, payload) {
|
|
373
|
+
return this.db.insert(collection, payload)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
bulkInsert(collection, payloads) {
|
|
377
|
+
return this.db.bulkInsert(collection, payloads)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async transaction() {
|
|
381
|
+
throw nestedTransactionError()
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
356
385
|
export class RedDB {
|
|
357
386
|
/** @param {HttpRpcClient | import('./redwire.js').RedWireClient} client */
|
|
358
387
|
constructor(client) {
|
|
359
388
|
this.client = client
|
|
360
389
|
this.cache = new CacheClient(client)
|
|
361
390
|
this.queue = new QueueClient(client)
|
|
391
|
+
this.documents = new DocumentClient(this)
|
|
362
392
|
const defaultKv = new KvClient(client)
|
|
363
393
|
this.kv = Object.assign((collection = 'kv_default') => new KvClient(client, collection), {
|
|
364
394
|
put: defaultKv.put.bind(defaultKv),
|
|
@@ -368,6 +398,7 @@ export class RedDB {
|
|
|
368
398
|
})
|
|
369
399
|
this.config = (collection = 'red.config') => new ConfigClient(client, collection)
|
|
370
400
|
this.vault = (collection = 'red.vault') => new VaultClient(client, collection)
|
|
401
|
+
this.inTransaction = false
|
|
371
402
|
}
|
|
372
403
|
|
|
373
404
|
/** Execute a SQL query. Returns `{ statement, affected, columns, rows }`. */
|
|
@@ -384,26 +415,56 @@ export class RedDB {
|
|
|
384
415
|
return this.query(sql, ...params)
|
|
385
416
|
}
|
|
386
417
|
|
|
387
|
-
/** Insert one row. Returns `{ affected, id }`. */
|
|
418
|
+
/** Insert one row. Returns `{ affected, rid, id }`; `id` is a legacy alias for `rid`. */
|
|
388
419
|
async insert(collection, payload) {
|
|
389
420
|
let result = await this.client.call('insert', { collection, payload })
|
|
390
421
|
if (
|
|
391
422
|
result &&
|
|
392
423
|
typeof result === 'object' &&
|
|
393
424
|
!('affected' in result) &&
|
|
394
|
-
'id' in result
|
|
425
|
+
('rid' in result || 'id' in result)
|
|
395
426
|
) {
|
|
396
427
|
result = { ...result, affected: 1 }
|
|
397
428
|
}
|
|
398
429
|
return requireInsertId(result, 'insert')
|
|
399
430
|
}
|
|
400
431
|
|
|
401
|
-
/** Insert many rows in one call. Returns `{ affected, ids }
|
|
432
|
+
/** Insert many rows in one call. Returns `{ affected, rids, ids }`; `ids` is a legacy alias. */
|
|
402
433
|
async bulkInsert(collection, payloads) {
|
|
403
434
|
const result = await this.client.call('bulk_insert', { collection, payloads })
|
|
404
435
|
return requireInsertIds(result, payloads.length)
|
|
405
436
|
}
|
|
406
437
|
|
|
438
|
+
async transaction(callback) {
|
|
439
|
+
if (this.inTransaction) {
|
|
440
|
+
throw nestedTransactionError()
|
|
441
|
+
}
|
|
442
|
+
if (typeof callback !== 'function') {
|
|
443
|
+
throw new TypeError('transaction(callback) requires a function')
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
this.inTransaction = true
|
|
447
|
+
let began = false
|
|
448
|
+
try {
|
|
449
|
+
await this.query('BEGIN')
|
|
450
|
+
began = true
|
|
451
|
+
const result = await callback(new TransactionHandle(this))
|
|
452
|
+
await this.query('COMMIT')
|
|
453
|
+
return result
|
|
454
|
+
} catch (err) {
|
|
455
|
+
if (began) {
|
|
456
|
+
try {
|
|
457
|
+
await this.query('ROLLBACK')
|
|
458
|
+
} catch (rollbackErr) {
|
|
459
|
+
attachRollbackError(err, rollbackErr)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
throw err
|
|
463
|
+
} finally {
|
|
464
|
+
this.inTransaction = false
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
407
468
|
/** Return true when a collection is visible in the catalog. */
|
|
408
469
|
exists(collection) {
|
|
409
470
|
return collectionExists(this, collection)
|
|
@@ -473,27 +534,52 @@ export class RedDB {
|
|
|
473
534
|
}
|
|
474
535
|
}
|
|
475
536
|
|
|
537
|
+
function nestedTransactionError() {
|
|
538
|
+
return new RedDBError(
|
|
539
|
+
NESTED_TX_NOT_SUPPORTED,
|
|
540
|
+
`${NESTED_TX_NOT_SUPPORTED}: nested transactions are not supported on one connection`,
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function attachRollbackError(err, rollbackErr) {
|
|
545
|
+
if (err && typeof err === 'object') {
|
|
546
|
+
try {
|
|
547
|
+
err.rollbackError = rollbackErr
|
|
548
|
+
} catch {
|
|
549
|
+
// Preserve the original callback/query error even for frozen errors.
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
476
554
|
function requireInsertId(result, method) {
|
|
477
|
-
if (!result || typeof result !== 'object' || result.id == null) {
|
|
555
|
+
if (!result || typeof result !== 'object' || (result.rid == null && result.id == null)) {
|
|
478
556
|
throw new RedDBError(
|
|
479
557
|
'ENGINE_TOO_OLD',
|
|
480
558
|
`${method}() requires RedDB engine >= ${MIN_INSERT_ID_ENGINE_VERSION} with insert id support`,
|
|
481
559
|
)
|
|
482
560
|
}
|
|
561
|
+
if (result.rid == null) result.rid = result.id
|
|
562
|
+
if (result.id == null) result.id = result.rid
|
|
483
563
|
return result
|
|
484
564
|
}
|
|
485
565
|
|
|
486
566
|
function requireInsertIds(result, expected) {
|
|
487
|
-
if (
|
|
567
|
+
if (
|
|
568
|
+
!result ||
|
|
569
|
+
typeof result !== 'object' ||
|
|
570
|
+
(!Array.isArray(result.rids) && !Array.isArray(result.ids))
|
|
571
|
+
) {
|
|
488
572
|
throw new RedDBError(
|
|
489
573
|
'ENGINE_TOO_OLD',
|
|
490
574
|
`bulkInsert() requires RedDB engine >= ${MIN_INSERT_ID_ENGINE_VERSION} with bulk insert id support`,
|
|
491
575
|
)
|
|
492
576
|
}
|
|
493
|
-
if (result.
|
|
577
|
+
if (!Array.isArray(result.rids)) result.rids = result.ids
|
|
578
|
+
if (!Array.isArray(result.ids)) result.ids = result.rids
|
|
579
|
+
if (result.rids.length !== expected) {
|
|
494
580
|
throw new RedDBError(
|
|
495
581
|
'INVALID_RESPONSE',
|
|
496
|
-
`bulkInsert() expected ${expected}
|
|
582
|
+
`bulkInsert() expected ${expected} rids, got ${result.rids.length}`,
|
|
497
583
|
)
|
|
498
584
|
}
|
|
499
585
|
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
|
|