@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 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 { affected: number; ids: Array<string | number> }
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.1.2",
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.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/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
- id: safeBigIntToJs(reply.id),
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
- ids: reply.items.map((item) => safeBigIntToJs(item.id)),
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 (!result || typeof result !== 'object' || !Array.isArray(result.ids)) {
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.ids.length !== expected) {
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} ids, got ${result.ids.length}`,
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)}.${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