@reddb-io/client 1.0.8 → 1.1.1

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/src/redwire.js CHANGED
@@ -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
  /**
@@ -57,6 +65,7 @@ export const BinaryTag = Object.freeze({
57
65
  F64: 2,
58
66
  Text: 3,
59
67
  Bool: 4,
68
+ U64: 5,
60
69
  })
61
70
 
62
71
  const KIND_NAME = Object.fromEntries(
@@ -201,8 +210,13 @@ export async function connectRedwire(opts) {
201
210
  )
202
211
  }
203
212
  const session = jsonOf(final.payload) ?? {}
213
+ const features = numberOr(session.features, numberOr(ackParsed?.features, 0))
204
214
 
205
- return new RedWireClient(socket, reader, session)
215
+ return new RedWireClient(socket, reader, session, features)
216
+ }
217
+
218
+ function numberOr(v, fallback) {
219
+ return typeof v === 'number' && Number.isFinite(v) ? v : fallback
206
220
  }
207
221
 
208
222
  /**
@@ -211,16 +225,27 @@ export async function connectRedwire(opts) {
211
225
  * transports so the surface above this is uniform.
212
226
  */
213
227
  export class RedWireClient {
214
- constructor(socket, reader, session) {
228
+ constructor(socket, reader, session, serverFeatures = 0) {
215
229
  this.socket = socket
216
230
  this.reader = reader
217
231
  this.session = session
232
+ this.serverFeatures = serverFeatures >>> 0
218
233
  this.nextCorr = 1n
219
234
  this.closed = false
220
235
  }
221
236
 
237
+ /** Raw advertised server feature bitmask. */
238
+ features() {
239
+ return this.serverFeatures
240
+ }
241
+
242
+ /** True when server advertised `FEATURE_PARAMS` (#357). */
243
+ supportsParams() {
244
+ return (this.serverFeatures & Features.PARAMS) === Features.PARAMS
245
+ }
246
+
222
247
  async call(method, params = {}) {
223
- if (method === 'query') return this.#query(params.sql ?? '')
248
+ if (method === 'query') return this.#query(params.sql ?? '', params.params)
224
249
  if (method === 'insert') return this.#insert({ collection: params.collection, payload: params.payload })
225
250
  if (method === 'bulk_insert') return this.#insert({ collection: params.collection, payloads: params.payloads })
226
251
  if (method === 'bulk_insert_binary') {
@@ -308,13 +333,29 @@ export class RedWireClient {
308
333
  )
309
334
  }
310
335
 
311
- async #query(sql) {
336
+ async #query(sql, params) {
312
337
  const corr = this.#corr()
313
- const payload = new TextEncoder().encode(sql)
314
- await writeFrame(this.socket, MessageKind.Query, corr, payload)
338
+ const hasParams = Array.isArray(params) && params.length > 0
339
+ let kind
340
+ let payload
341
+ if (hasParams) {
342
+ if (!this.supportsParams()) {
343
+ throw new RedDBError(
344
+ 'PARAMS_UNSUPPORTED',
345
+ 'server did not advertise FEATURE_PARAMS — upgrade the server '
346
+ + 'to one that supports parameterized queries.',
347
+ )
348
+ }
349
+ kind = MessageKind.QueryWithParams
350
+ payload = encodeQueryWithParams(sql, params)
351
+ } else {
352
+ kind = isSelectQuery(sql) ? MessageKind.QueryBinary : MessageKind.Query
353
+ payload = new TextEncoder().encode(sql)
354
+ }
355
+ await writeFrame(this.socket, kind, corr, payload)
315
356
  const resp = await this.reader.next()
316
357
  if (resp.kind === MessageKind.Result) {
317
- return jsonOf(resp.payload) ?? {}
358
+ return decodeResultPayload(resp.payload)
318
359
  }
319
360
  if (resp.kind === MessageKind.Error) {
320
361
  throw new RedDBError(
@@ -618,6 +659,97 @@ function jsonOf(bytes) {
618
659
  }
619
660
  }
620
661
 
662
+ function isSelectQuery(sql) {
663
+ return typeof sql === 'string' && /^\s*select\b/i.test(sql)
664
+ }
665
+
666
+ export function decodeResultPayload(payload) {
667
+ const json = jsonOf(payload)
668
+ if (json) return json
669
+ return decodeBinaryResultPayload(payload)
670
+ }
671
+
672
+ function decodeBinaryResultPayload(payload) {
673
+ if (!(payload instanceof Uint8Array)) {
674
+ payload = new Uint8Array(payload)
675
+ }
676
+ const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength)
677
+ const dec = new TextDecoder()
678
+ let pos = 0
679
+
680
+ const read = (n, label) => {
681
+ if (pos + n > payload.length) {
682
+ throw new RedDBError('PROTOCOL', `Result payload truncated while reading ${label}`)
683
+ }
684
+ const start = pos
685
+ pos += n
686
+ return start
687
+ }
688
+ const readU16 = (label) => view.getUint16(read(2, label), true)
689
+ const readU32 = (label) => view.getUint32(read(4, label), true)
690
+ const readI64 = (label) => safeBigIntToJs(view.getBigInt64(read(8, label), true))
691
+ const readU64 = (label) => safeBigIntToJs(view.getBigUint64(read(8, label), true))
692
+ const readF64 = (label) => view.getFloat64(read(8, label), true)
693
+ const readText = (n, label) => dec.decode(payload.subarray(read(n, label), pos))
694
+
695
+ const columnCount = readU16('column count')
696
+ const columns = []
697
+ for (let i = 0; i < columnCount; i += 1) {
698
+ const len = readU16(`column ${i} length`)
699
+ columns.push(readText(len, `column ${i} name`))
700
+ }
701
+
702
+ const rowCount = readU32('row count')
703
+ const rows = []
704
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
705
+ const row = {}
706
+ for (const column of columns) {
707
+ row[column] = readBinaryValue()
708
+ }
709
+ rows.push(row)
710
+ }
711
+
712
+ return {
713
+ ok: true,
714
+ statement: 'SELECT',
715
+ affected: 0,
716
+ columns,
717
+ rows,
718
+ }
719
+
720
+ function readBinaryValue() {
721
+ const tag = payload[read(1, 'value tag')]
722
+ switch (tag) {
723
+ case BinaryTag.Null:
724
+ return null
725
+ case BinaryTag.I64:
726
+ return readI64('i64 value')
727
+ case BinaryTag.U64:
728
+ return readU64('u64 value')
729
+ case BinaryTag.F64:
730
+ return readF64('f64 value')
731
+ case BinaryTag.Text: {
732
+ const len = readU32('text length')
733
+ return readText(len, 'text value')
734
+ }
735
+ case BinaryTag.Bool:
736
+ return payload[read(1, 'bool value')] !== 0
737
+ default:
738
+ throw new RedDBError('PROTOCOL', `Result payload has unknown value tag ${tag}`)
739
+ }
740
+ }
741
+ }
742
+
743
+ function safeBigIntToJs(value) {
744
+ if (
745
+ value >= BigInt(Number.MIN_SAFE_INTEGER)
746
+ && value <= BigInt(Number.MAX_SAFE_INTEGER)
747
+ ) {
748
+ return Number(value)
749
+ }
750
+ return value
751
+ }
752
+
621
753
  /**
622
754
  * Encode the binary bulk-insert payload body (raw, no RedWire frame
623
755
  * header — the body is wrapped by the caller as a `BulkInsertBinary`
@@ -680,6 +812,7 @@ function sizeOfBinaryCell(cell) {
680
812
  return 1 + 4 + bytes
681
813
  }
682
814
  case 4: return 1 + 1
815
+ case 5: return 1 + 8
683
816
  default: throw new RedDBError('UNKNOWN_BINARY_TAG', `tag=${tag}`)
684
817
  }
685
818
  }
@@ -709,11 +842,191 @@ function writeBinaryCell(buf, view, pos, cell, enc) {
709
842
  buf[pos] = value ? 1 : 0
710
843
  return pos + 1
711
844
  }
845
+ case 5: { // U64
846
+ const bi = typeof value === 'bigint' ? value : BigInt(value)
847
+ view.setBigUint64(pos, bi, true)
848
+ return pos + 8
849
+ }
712
850
  default:
713
851
  throw new RedDBError('UNKNOWN_BINARY_TAG', `tag=${tag}`)
714
852
  }
715
853
  }
716
854
 
855
+ // ---------------------------------------------------------------------------
856
+ // QueryWithParams payload codec — mirrors `reddb_wire::query_with_params`
857
+ // ---------------------------------------------------------------------------
858
+
859
+ const MAX_VALUE_PAYLOAD_LEN = MAX_FRAME_SIZE
860
+ const MAX_PARAM_COUNT = 65_536
861
+
862
+ /**
863
+ * Encode the `QueryWithParams` payload body.
864
+ * Layout: `[u32 sql_len LE][utf-8 sql][u32 param_count LE][N encoded values]`
865
+ */
866
+ export function encodeQueryWithParams(sql, params) {
867
+ if (typeof sql !== 'string') throw new TypeError('encodeQueryWithParams: sql must be a string')
868
+ if (!Array.isArray(params)) throw new TypeError('encodeQueryWithParams: params must be an array')
869
+ if (params.length > MAX_PARAM_COUNT) {
870
+ throw new RedDBError('PARAM_COUNT_OVER_LIMIT', `param_count ${params.length} > ${MAX_PARAM_COUNT}`)
871
+ }
872
+ const sqlBytes = new TextEncoder().encode(sql)
873
+ if (sqlBytes.length > MAX_VALUE_PAYLOAD_LEN) {
874
+ throw new RedDBError('PAYLOAD_TOO_LARGE', `sql_len ${sqlBytes.length} > ${MAX_VALUE_PAYLOAD_LEN}`)
875
+ }
876
+ const valueBlobs = params.map(encodeValue)
877
+ let total = 4 + sqlBytes.length + 4
878
+ for (const vb of valueBlobs) total += vb.length
879
+ const buf = new Uint8Array(total)
880
+ const view = new DataView(buf.buffer)
881
+ let pos = 0
882
+ view.setUint32(pos, sqlBytes.length, true); pos += 4
883
+ buf.set(sqlBytes, pos); pos += sqlBytes.length
884
+ view.setUint32(pos, valueBlobs.length, true); pos += 4
885
+ for (const vb of valueBlobs) { buf.set(vb, pos); pos += vb.length }
886
+ return buf
887
+ }
888
+
889
+ /**
890
+ * Encode a single wire `Value`. Mirrors `reddb_wire::value::encode`.
891
+ *
892
+ * Accepts native JS values + the JSON envelopes produced by
893
+ * `serializeParam` so the SDK can pass through a single shape:
894
+ * - `null` / `undefined` → Null
895
+ * - `boolean` → Bool
896
+ * - `bigint` → Int (i64)
897
+ * - `number` integer (safe range) → Int; otherwise → Float
898
+ * - `string` → Text
899
+ * - `Uint8Array` / `Buffer` → Bytes
900
+ * - `Float32Array` / `Array<number>` → Vector (f32)
901
+ * - `{ $bytes: <base64> }` → Bytes
902
+ * - `{ $ts: <unix-seconds> }` → Timestamp
903
+ * - `{ $uuid: <hyphenated> }` → Uuid
904
+ * - other plain object/array → Json (canonical bytes)
905
+ */
906
+ export function encodeValue(v) {
907
+ if (v === null || v === undefined) return Uint8Array.of(ValueTag.Null)
908
+ if (typeof v === 'boolean') return Uint8Array.of(ValueTag.Bool, v ? 1 : 0)
909
+ if (typeof v === 'bigint') {
910
+ const out = new Uint8Array(1 + 8)
911
+ out[0] = ValueTag.Int
912
+ new DataView(out.buffer).setBigInt64(1, v, true)
913
+ return out
914
+ }
915
+ if (typeof v === 'number') {
916
+ if (Number.isInteger(v) && v >= -(2 ** 53) && v <= 2 ** 53) {
917
+ const out = new Uint8Array(1 + 8)
918
+ out[0] = ValueTag.Int
919
+ new DataView(out.buffer).setBigInt64(1, BigInt(v), true)
920
+ return out
921
+ }
922
+ const out = new Uint8Array(1 + 8)
923
+ out[0] = ValueTag.Float
924
+ new DataView(out.buffer).setFloat64(1, v, true)
925
+ return out
926
+ }
927
+ if (typeof v === 'string') return encodeLenPrefixed(ValueTag.Text, new TextEncoder().encode(v))
928
+ if (v instanceof Uint8Array) return encodeLenPrefixed(ValueTag.Bytes, v)
929
+ if (typeof Buffer !== 'undefined' && v instanceof Buffer) {
930
+ return encodeLenPrefixed(ValueTag.Bytes, new Uint8Array(v.buffer, v.byteOffset, v.byteLength))
931
+ }
932
+ if (v instanceof Float32Array) return encodeVector(v)
933
+ if (v instanceof Float64Array) return encodeVector(Float32Array.from(v))
934
+ if (Array.isArray(v) && v.every((x) => typeof x === 'number')) {
935
+ return encodeVector(Float32Array.from(v))
936
+ }
937
+ if (typeof v === 'object') {
938
+ const keys = Object.keys(v)
939
+ if (keys.length === 1) {
940
+ const k = keys[0]
941
+ if (k === '$bytes' && typeof v.$bytes === 'string') {
942
+ return encodeLenPrefixed(ValueTag.Bytes, base64ToBytes(v.$bytes))
943
+ }
944
+ if (k === '$ts' && (
945
+ (typeof v.$ts === 'number' && Number.isFinite(v.$ts))
946
+ || typeof v.$ts === 'string'
947
+ )) {
948
+ const out = new Uint8Array(1 + 8)
949
+ out[0] = ValueTag.Timestamp
950
+ const raw = typeof v.$ts === 'string' ? BigInt(v.$ts) : BigInt(Math.trunc(v.$ts))
951
+ new DataView(out.buffer).setBigInt64(1, raw, true)
952
+ return out
953
+ }
954
+ if (k === '$uuid' && typeof v.$uuid === 'string') {
955
+ const bytes = parseUuidHyphenated(v.$uuid)
956
+ const out = new Uint8Array(1 + 16)
957
+ out[0] = ValueTag.Uuid
958
+ out.set(bytes, 1)
959
+ return out
960
+ }
961
+ }
962
+ return encodeLenPrefixed(ValueTag.Json, new TextEncoder().encode(canonicalJson(v)))
963
+ }
964
+ throw new RedDBError('UNSUPPORTED_PARAM', `cannot encode param of type ${typeof v}`)
965
+ }
966
+
967
+ function encodeLenPrefixed(tag, bytes) {
968
+ if (bytes.length > MAX_VALUE_PAYLOAD_LEN) {
969
+ throw new RedDBError('PAYLOAD_TOO_LARGE', `value len ${bytes.length} > ${MAX_VALUE_PAYLOAD_LEN}`)
970
+ }
971
+ const out = new Uint8Array(1 + 4 + bytes.length)
972
+ out[0] = tag
973
+ new DataView(out.buffer).setUint32(1, bytes.length, true)
974
+ out.set(bytes, 5)
975
+ return out
976
+ }
977
+
978
+ function encodeVector(f32) {
979
+ if (f32.length * 4 > MAX_VALUE_PAYLOAD_LEN) {
980
+ throw new RedDBError('PAYLOAD_TOO_LARGE', `vector bytes ${f32.length * 4} > ${MAX_VALUE_PAYLOAD_LEN}`)
981
+ }
982
+ const out = new Uint8Array(1 + 4 + f32.length * 4)
983
+ out[0] = ValueTag.Vector
984
+ const view = new DataView(out.buffer)
985
+ view.setUint32(1, f32.length, true)
986
+ for (let i = 0; i < f32.length; i++) {
987
+ view.setFloat32(5 + i * 4, f32[i], true)
988
+ }
989
+ return out
990
+ }
991
+
992
+ function base64ToBytes(s) {
993
+ if (typeof Buffer !== 'undefined') {
994
+ const b = Buffer.from(s, 'base64')
995
+ return new Uint8Array(b.buffer, b.byteOffset, b.byteLength)
996
+ }
997
+ // eslint-disable-next-line no-undef
998
+ const bin = atob(s)
999
+ const out = new Uint8Array(bin.length)
1000
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
1001
+ return out
1002
+ }
1003
+
1004
+ function parseUuidHyphenated(s) {
1005
+ const hex = s.replace(/-/g, '')
1006
+ if (hex.length !== 32 || /[^0-9a-fA-F]/.test(hex)) {
1007
+ throw new RedDBError('UUID_INVALID', `bad uuid: ${s}`)
1008
+ }
1009
+ const out = new Uint8Array(16)
1010
+ for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
1011
+ return out
1012
+ }
1013
+
1014
+ /** Stable JSON serialization with sorted keys — matches the server's
1015
+ * canonical `crate::json` output so round-tripped Json values compare
1016
+ * byte-equal. */
1017
+ function canonicalJson(v) {
1018
+ if (v === null) return 'null'
1019
+ if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'null'
1020
+ if (typeof v === 'string') return JSON.stringify(v)
1021
+ if (typeof v === 'boolean') return v ? 'true' : 'false'
1022
+ if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']'
1023
+ if (typeof v === 'object') {
1024
+ const keys = Object.keys(v).sort()
1025
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(v[k])).join(',') + '}'
1026
+ }
1027
+ return 'null'
1028
+ }
1029
+
717
1030
  function jsonReason(bytes) {
718
1031
  const v = jsonOf(bytes)
719
1032
  if (v && typeof v === 'object' && typeof v.reason === 'string') {