@reddb-io/cli 1.0.7 → 1.0.8

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddb-io/sdk",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Official RedDB driver — talks the native RedWire TCP protocol (mTLS), HTTP, gRPC bridge, or embedded stdio JSON-RPC. Works in Node 18+, Bun and Deno.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "scripts": {
22
22
  "postinstall": "node postinstall.js",
23
- "test": "node --test test/cache.test.mjs && node test/smoke.test.mjs"
23
+ "test": "node --test test/cache.test.mjs test/params.test.mjs test/redwire.params.test.mjs && node test/smoke.test.mjs"
24
24
  },
25
25
  "engines": {
26
26
  "node": ">=18"
@@ -45,6 +45,14 @@ export const MessageKind = Object.freeze({
45
45
  BulkInsertBinary: 0x06,
46
46
  QueryBinary: 0x07,
47
47
  BulkInsertPrevalidated: 0x08,
48
+ QueryWithParams: 0x28,
49
+ })
50
+
51
+ export const Features = Object.freeze({ PARAMS: 0x0000_0001 })
52
+
53
+ export const ValueTag = Object.freeze({
54
+ Null: 0x00, Bool: 0x01, Int: 0x02, Float: 0x03, Text: 0x04,
55
+ Bytes: 0x05, Vector: 0x06, Json: 0x07, Timestamp: 0x08, Uuid: 0x09,
48
56
  })
49
57
 
50
58
  /**
@@ -201,8 +209,13 @@ export async function connectRedwire(opts) {
201
209
  )
202
210
  }
203
211
  const session = jsonOf(final.payload) ?? {}
212
+ const features = numberOr(session.features, numberOr(ackParsed?.features, 0))
204
213
 
205
- return new RedWireClient(socket, reader, session)
214
+ return new RedWireClient(socket, reader, session, features)
215
+ }
216
+
217
+ function numberOr(v, fallback) {
218
+ return typeof v === 'number' && Number.isFinite(v) ? v : fallback
206
219
  }
207
220
 
208
221
  /**
@@ -211,16 +224,27 @@ export async function connectRedwire(opts) {
211
224
  * transports so the surface above this is uniform.
212
225
  */
213
226
  export class RedWireClient {
214
- constructor(socket, reader, session) {
227
+ constructor(socket, reader, session, serverFeatures = 0) {
215
228
  this.socket = socket
216
229
  this.reader = reader
217
230
  this.session = session
231
+ this.serverFeatures = serverFeatures >>> 0
218
232
  this.nextCorr = 1n
219
233
  this.closed = false
220
234
  }
221
235
 
236
+ /** Raw advertised server feature bitmask. */
237
+ features() {
238
+ return this.serverFeatures
239
+ }
240
+
241
+ /** True when server advertised `FEATURE_PARAMS` (#357). */
242
+ supportsParams() {
243
+ return (this.serverFeatures & Features.PARAMS) === Features.PARAMS
244
+ }
245
+
222
246
  async call(method, params = {}) {
223
- if (method === 'query') return this.#query(params.sql ?? '')
247
+ if (method === 'query') return this.#query(params.sql ?? '', params.params)
224
248
  if (method === 'insert') return this.#insert({ collection: params.collection, payload: params.payload })
225
249
  if (method === 'bulk_insert') return this.#insert({ collection: params.collection, payloads: params.payloads })
226
250
  if (method === 'bulk_insert_binary') {
@@ -308,10 +332,26 @@ export class RedWireClient {
308
332
  )
309
333
  }
310
334
 
311
- async #query(sql) {
335
+ async #query(sql, params) {
312
336
  const corr = this.#corr()
313
- const payload = new TextEncoder().encode(sql)
314
- await writeFrame(this.socket, MessageKind.Query, corr, payload)
337
+ const hasParams = Array.isArray(params) && params.length > 0
338
+ let kind
339
+ let payload
340
+ if (hasParams) {
341
+ if (!this.supportsParams()) {
342
+ throw new RedDBError(
343
+ 'PARAMS_UNSUPPORTED',
344
+ 'server did not advertise FEATURE_PARAMS — upgrade the server '
345
+ + 'to one that supports parameterized queries.',
346
+ )
347
+ }
348
+ kind = MessageKind.QueryWithParams
349
+ payload = encodeQueryWithParams(sql, params)
350
+ } else {
351
+ kind = MessageKind.Query
352
+ payload = new TextEncoder().encode(sql)
353
+ }
354
+ await writeFrame(this.socket, kind, corr, payload)
315
355
  const resp = await this.reader.next()
316
356
  if (resp.kind === MessageKind.Result) {
317
357
  return jsonOf(resp.payload) ?? {}
@@ -714,6 +754,177 @@ function writeBinaryCell(buf, view, pos, cell, enc) {
714
754
  }
715
755
  }
716
756
 
757
+ // ---------------------------------------------------------------------------
758
+ // QueryWithParams payload codec — mirrors `reddb_wire::query_with_params`
759
+ // ---------------------------------------------------------------------------
760
+
761
+ const MAX_VALUE_PAYLOAD_LEN = MAX_FRAME_SIZE
762
+ const MAX_PARAM_COUNT = 65_536
763
+
764
+ /**
765
+ * Encode the `QueryWithParams` payload body.
766
+ * Layout: `[u32 sql_len LE][utf-8 sql][u32 param_count LE][N encoded values]`
767
+ */
768
+ export function encodeQueryWithParams(sql, params) {
769
+ if (typeof sql !== 'string') throw new TypeError('encodeQueryWithParams: sql must be a string')
770
+ if (!Array.isArray(params)) throw new TypeError('encodeQueryWithParams: params must be an array')
771
+ if (params.length > MAX_PARAM_COUNT) {
772
+ throw new RedDBError('PARAM_COUNT_OVER_LIMIT', `param_count ${params.length} > ${MAX_PARAM_COUNT}`)
773
+ }
774
+ const sqlBytes = new TextEncoder().encode(sql)
775
+ if (sqlBytes.length > MAX_VALUE_PAYLOAD_LEN) {
776
+ throw new RedDBError('PAYLOAD_TOO_LARGE', `sql_len ${sqlBytes.length} > ${MAX_VALUE_PAYLOAD_LEN}`)
777
+ }
778
+ const valueBlobs = params.map(encodeValue)
779
+ let total = 4 + sqlBytes.length + 4
780
+ for (const vb of valueBlobs) total += vb.length
781
+ const buf = new Uint8Array(total)
782
+ const view = new DataView(buf.buffer)
783
+ let pos = 0
784
+ view.setUint32(pos, sqlBytes.length, true); pos += 4
785
+ buf.set(sqlBytes, pos); pos += sqlBytes.length
786
+ view.setUint32(pos, valueBlobs.length, true); pos += 4
787
+ for (const vb of valueBlobs) { buf.set(vb, pos); pos += vb.length }
788
+ return buf
789
+ }
790
+
791
+ /**
792
+ * Encode a single wire `Value`. Mirrors `reddb_wire::value::encode`.
793
+ *
794
+ * Accepts native JS values + the JSON envelopes produced by
795
+ * `serializeParam` so the SDK can pass through a single shape:
796
+ * - `null` / `undefined` → Null
797
+ * - `boolean` → Bool
798
+ * - `bigint` → Int (i64)
799
+ * - `number` integer (safe range) → Int; otherwise → Float
800
+ * - `string` → Text
801
+ * - `Uint8Array` / `Buffer` → Bytes
802
+ * - `Float32Array` / `Array<number>` → Vector (f32)
803
+ * - `{ $bytes: <base64> }` → Bytes
804
+ * - `{ $ts: <unix-seconds> }` → Timestamp
805
+ * - `{ $uuid: <hyphenated> }` → Uuid
806
+ * - other plain object/array → Json (canonical bytes)
807
+ */
808
+ export function encodeValue(v) {
809
+ if (v === null || v === undefined) return Uint8Array.of(ValueTag.Null)
810
+ if (typeof v === 'boolean') return Uint8Array.of(ValueTag.Bool, v ? 1 : 0)
811
+ if (typeof v === 'bigint') {
812
+ const out = new Uint8Array(1 + 8)
813
+ out[0] = ValueTag.Int
814
+ new DataView(out.buffer).setBigInt64(1, v, true)
815
+ return out
816
+ }
817
+ if (typeof v === 'number') {
818
+ if (Number.isInteger(v) && v >= -(2 ** 53) && v <= 2 ** 53) {
819
+ const out = new Uint8Array(1 + 8)
820
+ out[0] = ValueTag.Int
821
+ new DataView(out.buffer).setBigInt64(1, BigInt(v), true)
822
+ return out
823
+ }
824
+ const out = new Uint8Array(1 + 8)
825
+ out[0] = ValueTag.Float
826
+ new DataView(out.buffer).setFloat64(1, v, true)
827
+ return out
828
+ }
829
+ if (typeof v === 'string') return encodeLenPrefixed(ValueTag.Text, new TextEncoder().encode(v))
830
+ if (v instanceof Uint8Array) return encodeLenPrefixed(ValueTag.Bytes, v)
831
+ if (typeof Buffer !== 'undefined' && v instanceof Buffer) {
832
+ return encodeLenPrefixed(ValueTag.Bytes, new Uint8Array(v.buffer, v.byteOffset, v.byteLength))
833
+ }
834
+ if (v instanceof Float32Array) return encodeVector(v)
835
+ if (v instanceof Float64Array) return encodeVector(Float32Array.from(v))
836
+ if (Array.isArray(v) && v.every((x) => typeof x === 'number')) {
837
+ return encodeVector(Float32Array.from(v))
838
+ }
839
+ if (typeof v === 'object') {
840
+ const keys = Object.keys(v)
841
+ if (keys.length === 1) {
842
+ const k = keys[0]
843
+ if (k === '$bytes' && typeof v.$bytes === 'string') {
844
+ return encodeLenPrefixed(ValueTag.Bytes, base64ToBytes(v.$bytes))
845
+ }
846
+ if (k === '$ts' && typeof v.$ts === 'number' && Number.isFinite(v.$ts)) {
847
+ const out = new Uint8Array(1 + 8)
848
+ out[0] = ValueTag.Timestamp
849
+ new DataView(out.buffer).setBigInt64(1, BigInt(Math.trunc(v.$ts)), true)
850
+ return out
851
+ }
852
+ if (k === '$uuid' && typeof v.$uuid === 'string') {
853
+ const bytes = parseUuidHyphenated(v.$uuid)
854
+ const out = new Uint8Array(1 + 16)
855
+ out[0] = ValueTag.Uuid
856
+ out.set(bytes, 1)
857
+ return out
858
+ }
859
+ }
860
+ return encodeLenPrefixed(ValueTag.Json, new TextEncoder().encode(canonicalJson(v)))
861
+ }
862
+ throw new RedDBError('UNSUPPORTED_PARAM', `cannot encode param of type ${typeof v}`)
863
+ }
864
+
865
+ function encodeLenPrefixed(tag, bytes) {
866
+ if (bytes.length > MAX_VALUE_PAYLOAD_LEN) {
867
+ throw new RedDBError('PAYLOAD_TOO_LARGE', `value len ${bytes.length} > ${MAX_VALUE_PAYLOAD_LEN}`)
868
+ }
869
+ const out = new Uint8Array(1 + 4 + bytes.length)
870
+ out[0] = tag
871
+ new DataView(out.buffer).setUint32(1, bytes.length, true)
872
+ out.set(bytes, 5)
873
+ return out
874
+ }
875
+
876
+ function encodeVector(f32) {
877
+ if (f32.length * 4 > MAX_VALUE_PAYLOAD_LEN) {
878
+ throw new RedDBError('PAYLOAD_TOO_LARGE', `vector bytes ${f32.length * 4} > ${MAX_VALUE_PAYLOAD_LEN}`)
879
+ }
880
+ const out = new Uint8Array(1 + 4 + f32.length * 4)
881
+ out[0] = ValueTag.Vector
882
+ const view = new DataView(out.buffer)
883
+ view.setUint32(1, f32.length, true)
884
+ for (let i = 0; i < f32.length; i++) {
885
+ view.setFloat32(5 + i * 4, f32[i], true)
886
+ }
887
+ return out
888
+ }
889
+
890
+ function base64ToBytes(s) {
891
+ if (typeof Buffer !== 'undefined') {
892
+ const b = Buffer.from(s, 'base64')
893
+ return new Uint8Array(b.buffer, b.byteOffset, b.byteLength)
894
+ }
895
+ // eslint-disable-next-line no-undef
896
+ const bin = atob(s)
897
+ const out = new Uint8Array(bin.length)
898
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
899
+ return out
900
+ }
901
+
902
+ function parseUuidHyphenated(s) {
903
+ const hex = s.replace(/-/g, '')
904
+ if (hex.length !== 32 || /[^0-9a-fA-F]/.test(hex)) {
905
+ throw new RedDBError('UUID_INVALID', `bad uuid: ${s}`)
906
+ }
907
+ const out = new Uint8Array(16)
908
+ for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
909
+ return out
910
+ }
911
+
912
+ /** Stable JSON serialization with sorted keys — matches the server's
913
+ * canonical `crate::json` output so round-tripped Json values compare
914
+ * byte-equal. */
915
+ function canonicalJson(v) {
916
+ if (v === null) return 'null'
917
+ if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'null'
918
+ if (typeof v === 'string') return JSON.stringify(v)
919
+ if (typeof v === 'boolean') return v ? 'true' : 'false'
920
+ if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']'
921
+ if (typeof v === 'object') {
922
+ const keys = Object.keys(v).sort()
923
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(v[k])).join(',') + '}'
924
+ }
925
+ return 'null'
926
+ }
927
+
717
928
  function jsonReason(bytes) {
718
929
  const v = jsonOf(bytes)
719
930
  if (v && typeof v === 'object' && typeof v.reason === 'string') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddb-io/cli",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "CLI launcher for RedDB. The JS/TS app driver is published as @reddb-io/sdk.",
5
5
  "type": "commonjs",
6
6
  "bin": {