@reddb-io/sdk 1.2.5 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -87,9 +87,11 @@ For the SQL/RQL grammar that `db.query()` accepts, see
87
87
 
88
88
  ## Transactions
89
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`.
90
+ This driver supports **both** transaction forms from the SDK Helper Spec
91
+ (§7): the imperative `begin` / `commit` / `rollback` trio and the callback
92
+ form.
93
+
94
+ **Callback form** — `db.transaction(callback)` (or `db.tx().run(callback)`):
93
95
 
94
96
  ```js
95
97
  const userId = await db.transaction(async (tx) => {
@@ -100,9 +102,28 @@ const userId = await db.transaction(async (tx) => {
100
102
  ```
101
103
 
102
104
  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.
105
+ when the callback or a `tx.query()` / `tx.insert()` call throws.
106
+
107
+ **Imperative form** `db.tx()` returns a transaction handle:
108
+
109
+ ```js
110
+ const tx = db.tx()
111
+ await tx.begin()
112
+ try {
113
+ await db.query("INSERT INTO audit (action) VALUES ('created user')")
114
+ await tx.commit()
115
+ } catch (err) {
116
+ await tx.rollback()
117
+ throw err
118
+ }
119
+ ```
120
+
121
+ `begin` / `commit` / `rollback` each resolve to a `QueryResult`. A nested
122
+ `tx.run()` (or `db.transaction()`) on the same connection is rejected with
123
+ `INVALID_ARGUMENT` (`NESTED_TX_NOT_SUPPORTED` for the legacy
124
+ `db.transaction()` shortcut) — callers wanting savepoints issue them
125
+ directly via `tx.query()`. Open another `connect()` handle for independent
126
+ concurrent transactions.
106
127
 
107
128
  ## Connection URIs
108
129
 
@@ -201,8 +222,10 @@ const updated = await db.documents.patch('events', inserted.rid, {
201
222
  await db.documents.delete('events', inserted.rid)
202
223
  ```
203
224
 
204
- `documents.insert()` creates the document collection when needed. Patch support
205
- currently accepts top-level document fields.
225
+ `documents.insert()` creates the document collection when needed. Patch is a
226
+ top-level merge: unrelated fields survive. An empty patch raises
227
+ `INVALID_ARGUMENT`. `documents.delete` returns `{ affected, deleted }` and
228
+ NEVER raises on a missing rid (returns `{ affected: 0, deleted: false }`).
206
229
 
207
230
  ### `db.kv(collection?)`
208
231
 
@@ -212,26 +235,104 @@ KV helpers preserve exact keys, including namespaced keys with `:`.
212
235
  await db.query('CREATE KV settings')
213
236
  const kv = db.kv('settings')
214
237
 
215
- await kv.put('characters:hansel', 'crumbs')
216
- await kv.get('characters:hansel') // 'crumbs'
238
+ await kv.set('characters:hansel', 'crumbs') // `put` is a back-compat alias
239
+ await kv.get('characters:hansel') // 'crumbs' (null when missing)
217
240
  await kv.exists('characters:hansel') // { exists: true }
218
241
  await kv.list({ prefix: 'characters:' }) // { items: [{ key, value }] }
219
- await kv.delete('characters:hansel') // { affected }
242
+ await kv.delete('characters:hansel') // { affected, deleted }
220
243
  ```
221
244
 
222
- ### `db.queue`
245
+ A `kv.get` of a missing key returns `null` (never `NOT_FOUND`). `kv.delete`
246
+ returns the `{ affected, deleted }` envelope; deleting a missing key is not an
247
+ error and returns `{ affected: 0, deleted: false }`.
223
248
 
224
- Queue helpers cover the embedded FIFO workflow:
249
+ ### `db.queues` (alias `db.queue`)
250
+
251
+ Queue helpers cover the embedded FIFO workflow. The spec-canonical namespace
252
+ is the plural `db.queues`; `db.queue` remains as an alias.
253
+
254
+ ```js
255
+ await db.queues.create('jobs') // CREATE QUEUE IF NOT EXISTS (idempotent)
256
+ await db.queues.push('jobs', { task: 'ship' })
257
+ await db.queues.peek('jobs') // does NOT decrement length
258
+ await db.queues.pop('jobs') // empty queue → [] (never NOT_FOUND)
259
+ await db.queues.len('jobs')
260
+ await db.queues.purge('jobs')
261
+ ```
262
+
263
+ ## SDK Helper Spec conformance
264
+
265
+ This driver implements **SDK Helper Spec v1.0**
266
+ ([`docs/spec/sdk-helpers.md`](../../docs/spec/sdk-helpers.md)). The version is
267
+ exposed for cross-driver CI dashboards:
225
268
 
226
269
  ```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')
270
+ import { HELPER_SPEC_VERSION } from '@reddb-io/sdk'
271
+ HELPER_SPEC_VERSION // '1.0'
272
+ db.helperSpecVersion // '1.0'
273
+ ```
274
+
275
+ **Return envelopes** (wire field names preserved when serialised to JSON):
276
+
277
+ | Envelope | Fields |
278
+ |---------------------|---------------------------------------------------|
279
+ | `QueryResult` | `statement`, `affected`, `columns`, `rows` |
280
+ | `InsertResult` | `affected` (1), `rid` (`id` legacy alias) |
281
+ | `BulkInsertResult` | `affected`, `rids` in input order (`ids` alias) |
282
+ | `DeleteResult` | `affected`, `deleted` (= `affected > 0`) |
283
+ | `ExistsResult` | `exists` |
284
+
285
+ **Transaction support:** imperative (`db.tx().begin/commit/rollback`) **and**
286
+ callback (`db.transaction(cb)` / `db.tx().run(cb)`). Nested callbacks reject
287
+ with `INVALID_ARGUMENT`.
288
+
289
+ **Case matrix** (spec §12 — ported verbatim in
290
+ `test/conformance.test.mjs`):
291
+
292
+ | Case ID | Status |
293
+ |--------------------------------------|-------------|
294
+ | `meta.spec_version` | supported |
295
+ | `generic.query.no_params` | supported |
296
+ | `generic.query_with.params` | supported |
297
+ | `generic.insert.rid` | supported |
298
+ | `generic.bulk_insert.rids` | supported |
299
+ | `generic.delete` | supported |
300
+ | `documents.crud_nested_patch` | supported |
301
+ | `documents.delete_missing_no_error` | supported |
302
+ | `documents.patch_empty_rejects` | supported |
303
+ | `kv.exact_key_round_trip` | supported |
304
+ | `kv.missing_get_returns_none` | supported |
305
+ | `kv.delete_returns_envelope` | supported |
306
+ | `queues.fifo_peek_pop_len` | supported |
307
+ | `queues.empty_pop_returns_empty` | supported |
308
+ | `queues.purge_resets_len` | supported |
309
+ | `tx.commit_persists` | supported |
310
+ | `tx.rollback_discards` | supported |
311
+ | `errors.invalid_argument.empty_sql` | supported |
312
+ | `errors.not_found.document_get` | supported |
313
+ | `wire.probabilistic.hll_round_trip` | provisional (SQL via `db.query`) |
314
+ | `wire.vectors.sql_round_trip` | reachable via `db.query` (no v1.0 case) |
315
+ | `wire.graph.sql_round_trip` | reachable via `db.query` (no v1.0 case) |
316
+ | `wire.timeseries.sql_round_trip` | reachable via `db.query` (no v1.0 case) |
317
+
318
+ **Out-of-scope in v1.0** (reach via raw `db.query` until v1.1, per spec):
319
+ first-class `vectors.*`, `graph.*`, `timeseries.*`, and `probabilistic.*`
320
+ helpers; KV TTL (`kv.expire`) and gRPC watch; priority queues, consumer
321
+ groups, dead-letter routing; transaction isolation-level arguments and
322
+ cross-shard transactions; JSON Patch / nested / array-positional document
323
+ patches (top-level merge only).
324
+
325
+ Run the conformance harness against a locally built binary:
326
+
327
+ ```sh
328
+ cargo build # produces target/debug/red
329
+ node drivers/js/test/conformance.test.mjs
330
+ # or: REDDB_BINARY_PATH=/path/to/red node drivers/js/test/conformance.test.mjs
233
331
  ```
234
332
 
333
+ The harness (and the README-examples test) self-skip with exit 0 when no
334
+ binary is present, so `pnpm test` stays green on machines without a build.
335
+
235
336
  ### `db.health() → Promise<{ ok, version }>`
236
337
 
237
338
  ### `db.version() → Promise<{ version, protocol }>`
@@ -318,3 +419,30 @@ remote URIs.
318
419
  - **TLS posture, mTLS, OAuth/JWT and reverse-proxy patterns** are
319
420
  covered in [`docs/security/transport-tls.md`](../../docs/security/transport-tls.md).
320
421
  - See [Policies](../../docs/security/policies.md) for IAM-style authorization.
422
+
423
+ <!-- contract-matrix:begin -->
424
+ ## Public-surface support
425
+
426
+ > Generated from [`docs/conformance/public-surface-contract-matrix.json`](/docs/conformance/public-surface-contract-matrix.json) by `scripts/gen-docs-from-matrix.mjs`. Do not edit between the markers by hand — run `node scripts/gen-docs-from-matrix.mjs --write`. The matrix is the source of truth; this block can never claim more than it, and CI (`docs-matrix`) fails on drift.
427
+ >
428
+ > Driver-helper (SDK Helper Spec v1.0) support for every public promise. A helper not marked supported here is not promised by this driver.
429
+
430
+ | Promise | driver_helpers |
431
+ | --- | --- |
432
+ | **PSC-001** — RedDB is one multi-model database (tables, graph, KV, timeseries, probabilistic, vector, queue, documents) backed by a single file. | ✅ supported |
433
+ | **PSC-002** — MATCH supports node, edge, label, property, and LIMIT projections. | ✅ supported |
434
+ | **PSC-003** — GRAPH algorithms accept semantic identifiers, limits, ordering, and return stable rich rows. | ❌ unsupported |
435
+ | **PSC-004** — INSERT creates rows, documents, and native timeseries points. | ✅ supported |
436
+ | **PSC-005** — HLL/SKETCH/FILTER expose write and read commands for cardinality, frequency, and membership. | ⚠️ partial |
437
+ | **PSC-006** — Timeseries stores timestamped metrics with tags and supports query/readback. | ⚠️ partial |
438
+ | **PSC-007** — Documents are first-class: create, read, update, delete, and SQL analytics over JSON. | ✅ supported |
439
+ | **PSC-008** — KV helpers expose get/put/delete; get of a missing key returns null, delete reports affected. | ✅ supported |
440
+ | **PSC-009** — Queue helpers expose create/push/peek/pop/len/purge with FIFO semantics; empty pop is not an error. | ✅ supported |
441
+ | **PSC-010** — Transactions are imperative (begin/commit/rollback) plus a run(callback) form; empty SQL rejects with INVALID_ARGUMENT. | ✅ supported |
442
+ | **PSC-011** — SQL aggregate, projection, expression, and mutation behaviour matches ordinary SQL expectations where advertised. | ✅ supported |
443
+ | **PSC-012** — Server transports expose the same query contract as embedded (HTTP, RedWire, gRPC parity). | ✅ supported |
444
+ | **PSC-013** — Official drivers implement the SDK Helper Spec v1.0 conformance suite (all 22 §12 case IDs). | ✅ supported |
445
+ | **PSC-014** — ASK / SEARCH semantic surfaces return ranked results with stable shape. | ⚠️ partial |
446
+
447
+ _Status legend: ✅ supported · ⚠️ partial (known gaps) · ❌ unsupported._
448
+ <!-- contract-matrix:end -->
package/index.d.ts CHANGED
@@ -105,6 +105,8 @@ export interface GetResult {
105
105
 
106
106
  export interface DeleteResult {
107
107
  affected: number
108
+ /** `affected > 0`. Present on `documents.delete` / `kv.delete` (spec §2.4). */
109
+ deleted?: boolean
108
110
  }
109
111
 
110
112
  export interface CollectionMeta {
@@ -233,6 +235,12 @@ export class KvClient {
233
235
  value: unknown,
234
236
  options?: { collection?: string; expireMs?: number; tags?: string[] },
235
237
  ): Promise<QueryResult>
238
+ /** Spec-canonical alias for `put` (spec §5.1 `kv.set`). */
239
+ set(
240
+ key: string,
241
+ value: unknown,
242
+ options?: { collection?: string; expireMs?: number; tags?: string[] },
243
+ ): Promise<QueryResult>
236
244
  get(key: string, options?: { collection?: string }): Promise<unknown | null>
237
245
  getMany(keys: string[], options?: { collection?: string }): Promise<Array<unknown | null>>
238
246
  exists(key: string, options?: { collection?: string }): Promise<{ exists: boolean }>
@@ -279,6 +287,8 @@ export class DocumentClient {
279
287
  }
280
288
 
281
289
  export class QueueClient {
290
+ /** Idempotent CREATE QUEUE IF NOT EXISTS (spec §6.1). */
291
+ create(queue: string): Promise<QueryResult>
282
292
  push(
283
293
  queue: string,
284
294
  value: unknown,
@@ -290,6 +300,19 @@ export class QueueClient {
290
300
  purge(queue: string): Promise<QueryResult>
291
301
  }
292
302
 
303
+ /**
304
+ * Spec §7 transaction client returned by `db.tx()`. Imperative
305
+ * `begin`/`commit`/`rollback` each resolve to a `QueryResult`; `run` is the
306
+ * callback form (commit on success, rollback + re-throw on failure). Nested
307
+ * `run` rejects with `INVALID_ARGUMENT`.
308
+ */
309
+ export class TxClient {
310
+ begin(): Promise<QueryResult>
311
+ commit(): Promise<QueryResult>
312
+ rollback(): Promise<QueryResult>
313
+ run<T>(callback: (tx: RedDBTransaction) => T | Promise<T>): Promise<T>
314
+ }
315
+
293
316
  /**
294
317
  * Caller-typed SELECT builder. RedDB does not infer `T`; provide it
295
318
  * explicitly with `db.from<T>('collection')`.
@@ -349,8 +372,12 @@ export interface RedDBTransaction {
349
372
  export class RedDB {
350
373
  /** Underlying transport label. connect() returns 'embedded'. */
351
374
  readonly transport: string | null
375
+ /** SDK Helper Spec version this driver implements (spec §14). */
376
+ readonly helperSpecVersion: string
352
377
  readonly cache: CacheClient
353
378
  readonly queue: QueueClient
379
+ /** Spec-canonical plural alias for `queue` (spec §6). */
380
+ readonly queues: QueueClient
354
381
  readonly documents: DocumentClient
355
382
  readonly kv: KvClient & ((collection?: string) => KvClient)
356
383
  readonly config: (collection?: string) => ConfigClient
@@ -371,6 +398,8 @@ export class RedDB {
371
398
  transaction<T>(
372
399
  callback: (tx: RedDBTransaction) => T | Promise<T>,
373
400
  ): Promise<T>
401
+ /** Spec §7 transaction handle: imperative begin/commit/rollback + run(). */
402
+ tx(): TxClient
374
403
  exists(collection: string): Promise<boolean>
375
404
  list(): Promise<CollectionMeta[]>
376
405
  /**
@@ -409,6 +438,9 @@ export function connect(uri: string, options?: ConnectOptions): Promise<RedDB>
409
438
 
410
439
  export const EMBEDDED_ONLY_MESSAGE: string
411
440
 
441
+ /** SDK Helper Spec version implemented by this driver (spec §14). */
442
+ export const HELPER_SPEC_VERSION: string
443
+
412
444
  /**
413
445
  * Translate a connection URI + (optional) auth into argv for
414
446
  * `red rpc --stdio`. Remote URI schemes throw `EMBEDDED_ONLY`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddb-io/sdk",
3
- "version": "1.2.5",
3
+ "version": "1.4.0",
4
4
  "description": "Official embedded RedDB SDK — launches a local red binary over stdio JSON-RPC. Use @reddb-io/client for remote HTTP, gRPC, and RedWire.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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 test/transaction.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/helpers.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 && node test/conformance.test.mjs && node test/readme-examples.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.join(' AND ')}`
53
+ ? ` WHERE ${this.whereClauses.map((clause) => `(${clause})`).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 CHANGED
@@ -40,7 +40,10 @@ export class DocumentClient {
40
40
  validateObject(patch, 'documents.patch patch')
41
41
  const entries = Object.entries(patch)
42
42
  if (entries.length === 0) {
43
- return this.get(collection, rid)
43
+ throw new RedDBError(
44
+ 'INVALID_ARGUMENT',
45
+ 'documents.patch patch must be a non-empty object',
46
+ )
44
47
  }
45
48
  for (const [field] of entries) {
46
49
  if (field.includes('/')) {
@@ -66,7 +69,8 @@ export class DocumentClient {
66
69
 
67
70
  async delete(collection, rid) {
68
71
  const result = await this.db.delete(collection, rid)
69
- return { affected: result.affected ?? 0 }
72
+ const affected = result.affected ?? 0
73
+ return { affected, deleted: affected > 0 }
70
74
  }
71
75
 
72
76
  async ensureCollection(collection) {
package/src/index.js CHANGED
@@ -43,6 +43,13 @@ export { parseUri, deriveLoginUrl } from './url.js'
43
43
  export const EMBEDDED_ONLY_MESSAGE =
44
44
  'remote URIs are not supported in @reddb-io/sdk; install @reddb-io/client for grpc/http/red transports'
45
45
 
46
+ /**
47
+ * SDK Helper Spec version this driver implements. See
48
+ * `docs/spec/sdk-helpers.md` §14 — every official driver exposes this so
49
+ * cross-driver CI dashboards can assert against it.
50
+ */
51
+ export const HELPER_SPEC_VERSION = '1.0'
52
+
46
53
  const MIN_INSERT_ID_ENGINE_VERSION = '1.0.9'
47
54
  const NESTED_TX_NOT_SUPPORTED = 'NESTED_TX_NOT_SUPPORTED'
48
55
 
@@ -304,6 +311,92 @@ class TransactionHandle {
304
311
  }
305
312
  }
306
313
 
314
+ /**
315
+ * Spec §7 transaction client. Returned by `db.tx()`. Exposes the imperative
316
+ * `begin` / `commit` / `rollback` trio (each resolves to a `QueryResult`) plus
317
+ * the optional `run(callback)` form. Transaction state is tracked on the parent
318
+ * `RedDB` so it serialises with `db.transaction()` and nested opens are
319
+ * rejected rather than silently interleaved.
320
+ */
321
+ export class TxClient {
322
+ constructor(db) {
323
+ this.db = db
324
+ this.active = false
325
+ }
326
+
327
+ async begin() {
328
+ if (this.db.inTransaction) {
329
+ throw nestedTransactionError()
330
+ }
331
+ this.db.inTransaction = true
332
+ this.active = true
333
+ try {
334
+ return await this.db.query('BEGIN')
335
+ } catch (err) {
336
+ this.db.inTransaction = false
337
+ this.active = false
338
+ throw err
339
+ }
340
+ }
341
+
342
+ async commit() {
343
+ if (!this.active) {
344
+ throw new RedDBError('INVALID_ARGUMENT', 'tx.commit() called without an open transaction')
345
+ }
346
+ try {
347
+ return await this.db.query('COMMIT')
348
+ } finally {
349
+ this.active = false
350
+ this.db.inTransaction = false
351
+ }
352
+ }
353
+
354
+ async rollback() {
355
+ if (!this.active) {
356
+ throw new RedDBError('INVALID_ARGUMENT', 'tx.rollback() called without an open transaction')
357
+ }
358
+ try {
359
+ return await this.db.query('ROLLBACK')
360
+ } finally {
361
+ this.active = false
362
+ this.db.inTransaction = false
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Callback form: commit on success, roll back and re-throw on failure.
368
+ * Nested `tx.run` rejects with `INVALID_ARGUMENT` — callers wanting
369
+ * savepoints issue them directly via `tx.query()` (spec §7.2; the README
370
+ * records this choice).
371
+ */
372
+ async run(callback) {
373
+ if (typeof callback !== 'function') {
374
+ throw new TypeError('tx.run(callback) requires a function')
375
+ }
376
+ if (this.db.inTransaction) {
377
+ throw new RedDBError(
378
+ 'INVALID_ARGUMENT',
379
+ 'nested tx.run() is not supported; issue savepoints via tx.query() instead',
380
+ )
381
+ }
382
+ await this.begin()
383
+ try {
384
+ const result = await callback(new TransactionHandle(this.db))
385
+ await this.commit()
386
+ return result
387
+ } catch (err) {
388
+ if (this.active) {
389
+ try {
390
+ await this.rollback()
391
+ } catch (rollbackErr) {
392
+ attachRollbackError(err, rollbackErr)
393
+ }
394
+ }
395
+ throw err
396
+ }
397
+ }
398
+ }
399
+
307
400
  export class RedDB {
308
401
  /**
309
402
  * @param {RpcClient} client
@@ -315,8 +408,12 @@ export class RedDB {
315
408
  constructor(client, opts = {}) {
316
409
  this.client = client
317
410
  this.transport = opts.transport ?? null
411
+ this.helperSpecVersion = HELPER_SPEC_VERSION
318
412
  this.cache = new CacheClient(client, this.transport)
319
413
  this.queue = new QueueClient(client)
414
+ // Spec §6: the canonical namespace is the plural `queues`. `queue` is kept
415
+ // as a back-compat alias to the same handle.
416
+ this.queues = this.queue
320
417
  this.documents = new DocumentClient(this)
321
418
  const defaultKv = new KvClient(client)
322
419
  this.kv = Object.assign((collection = 'kv_default') => new KvClient(client, collection), {
@@ -341,6 +438,13 @@ export class RedDB {
341
438
  * Returns `{ statement, affected, columns, rows }`.
342
439
  */
343
440
  query(sql, ...params) {
441
+ // Spec §3.1 / §2.5: empty SQL is a caller bug; reject locally before
442
+ // touching the wire.
443
+ if (typeof sql !== 'string' || sql.trim().length === 0) {
444
+ return Promise.reject(
445
+ new RedDBError('INVALID_ARGUMENT', 'query() requires a non-empty SQL string'),
446
+ )
447
+ }
344
448
  const wireParams = normalizeQueryParams(params)
345
449
  if (wireParams == null) {
346
450
  return this.client.call('query', { sql }).then(normalizeResult)
@@ -361,10 +465,23 @@ export class RedDB {
361
465
 
362
466
  /** Insert many rows in one call. Returns `{ affected, rids, ids }`; `ids` is a legacy alias. */
363
467
  async bulkInsert(collection, payloads) {
468
+ // Spec §3.4: empty payloads is a no-op returning `{ affected: 0, rids: [] }`.
469
+ if (Array.isArray(payloads) && payloads.length === 0) {
470
+ return { affected: 0, rids: [], ids: [] }
471
+ }
364
472
  const result = await this.client.call('bulk_insert', { collection, payloads })
365
473
  return requireInsertIds(result, payloads.length)
366
474
  }
367
475
 
476
+ /**
477
+ * Spec §7 transaction handle. `db.tx()` returns a {@link TxClient} exposing
478
+ * imperative `begin` / `commit` / `rollback` plus a `run(callback)` form.
479
+ * `db.transaction(callback)` remains as the original callback-only shortcut.
480
+ */
481
+ tx() {
482
+ return new TxClient(this)
483
+ }
484
+
368
485
  async transaction(callback) {
369
486
  if (this.inTransaction) {
370
487
  throw nestedTransactionError()
package/src/kv.js CHANGED
@@ -17,6 +17,11 @@ export class KvClient {
17
17
  })
18
18
  }
19
19
 
20
+ // Spec-canonical alias for `put` (SDK Helper Spec §5.1 `kv.set`).
21
+ set(key, value, options = {}) {
22
+ return this.put(key, value, options)
23
+ }
24
+
20
25
  async get(key, options = {}) {
21
26
  const collection = options.collection ?? this.collection
22
27
  const result = await this.client.call('query', {
@@ -40,7 +45,9 @@ export class KvClient {
40
45
  const result = await this.client.call('query', {
41
46
  sql: `KV DELETE ${kvPath(collection, key)}`,
42
47
  })
43
- return { affected: result.affected ?? result.affected_rows ?? 0 }
48
+ const affected = result.affected ?? result.affected_rows ?? 0
49
+ // Spec §5.4 / §2.4 DeleteResult: `deleted` is `affected > 0`.
50
+ return { affected, deleted: affected > 0 }
44
51
  }
45
52
 
46
53
  async list(options = {}) {
package/src/queue.js CHANGED
@@ -5,6 +5,14 @@ export class QueueClient {
5
5
  this.client = client
6
6
  }
7
7
 
8
+ // Spec §6.1 `queues.create`: idempotent (CREATE QUEUE IF NOT EXISTS) so
9
+ // conformance fixtures can prime a queue the same way the Rust/Go harnesses do.
10
+ create(queue) {
11
+ return this.client.call('query', {
12
+ sql: `CREATE QUEUE IF NOT EXISTS ${queueIdentifier(queue)}`,
13
+ })
14
+ }
15
+
8
16
  push(queue, value, options = {}) {
9
17
  const priority = options.priority != null ? ` PRIORITY ${queuePriority(options.priority)}` : ''
10
18
  return this.client.call('query', {