@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 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.bulkInsert(collection, payloads) → Promise<{ affected }>`
183
+ ### `db.documents`
141
184
 
142
- ### `db.get(collection, id) Promise<{ entity }>`
185
+ Document helpers follow the SDK Helper Spec:
143
186
 
144
- ### `db.delete(collection, id) → Promise<{ affected }>`
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.1.2",
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.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
+ }
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 (!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
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)}.${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