@reddb-io/sdk 1.0.7 → 1.1.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/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
  /**
@@ -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,181 @@ 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' && (
847
+ (typeof v.$ts === 'number' && Number.isFinite(v.$ts))
848
+ || typeof v.$ts === 'string'
849
+ )) {
850
+ const out = new Uint8Array(1 + 8)
851
+ out[0] = ValueTag.Timestamp
852
+ const raw = typeof v.$ts === 'string' ? BigInt(v.$ts) : BigInt(Math.trunc(v.$ts))
853
+ new DataView(out.buffer).setBigInt64(1, raw, true)
854
+ return out
855
+ }
856
+ if (k === '$uuid' && typeof v.$uuid === 'string') {
857
+ const bytes = parseUuidHyphenated(v.$uuid)
858
+ const out = new Uint8Array(1 + 16)
859
+ out[0] = ValueTag.Uuid
860
+ out.set(bytes, 1)
861
+ return out
862
+ }
863
+ }
864
+ return encodeLenPrefixed(ValueTag.Json, new TextEncoder().encode(canonicalJson(v)))
865
+ }
866
+ throw new RedDBError('UNSUPPORTED_PARAM', `cannot encode param of type ${typeof v}`)
867
+ }
868
+
869
+ function encodeLenPrefixed(tag, bytes) {
870
+ if (bytes.length > MAX_VALUE_PAYLOAD_LEN) {
871
+ throw new RedDBError('PAYLOAD_TOO_LARGE', `value len ${bytes.length} > ${MAX_VALUE_PAYLOAD_LEN}`)
872
+ }
873
+ const out = new Uint8Array(1 + 4 + bytes.length)
874
+ out[0] = tag
875
+ new DataView(out.buffer).setUint32(1, bytes.length, true)
876
+ out.set(bytes, 5)
877
+ return out
878
+ }
879
+
880
+ function encodeVector(f32) {
881
+ if (f32.length * 4 > MAX_VALUE_PAYLOAD_LEN) {
882
+ throw new RedDBError('PAYLOAD_TOO_LARGE', `vector bytes ${f32.length * 4} > ${MAX_VALUE_PAYLOAD_LEN}`)
883
+ }
884
+ const out = new Uint8Array(1 + 4 + f32.length * 4)
885
+ out[0] = ValueTag.Vector
886
+ const view = new DataView(out.buffer)
887
+ view.setUint32(1, f32.length, true)
888
+ for (let i = 0; i < f32.length; i++) {
889
+ view.setFloat32(5 + i * 4, f32[i], true)
890
+ }
891
+ return out
892
+ }
893
+
894
+ function base64ToBytes(s) {
895
+ if (typeof Buffer !== 'undefined') {
896
+ const b = Buffer.from(s, 'base64')
897
+ return new Uint8Array(b.buffer, b.byteOffset, b.byteLength)
898
+ }
899
+ // eslint-disable-next-line no-undef
900
+ const bin = atob(s)
901
+ const out = new Uint8Array(bin.length)
902
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
903
+ return out
904
+ }
905
+
906
+ function parseUuidHyphenated(s) {
907
+ const hex = s.replace(/-/g, '')
908
+ if (hex.length !== 32 || /[^0-9a-fA-F]/.test(hex)) {
909
+ throw new RedDBError('UUID_INVALID', `bad uuid: ${s}`)
910
+ }
911
+ const out = new Uint8Array(16)
912
+ for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
913
+ return out
914
+ }
915
+
916
+ /** Stable JSON serialization with sorted keys — matches the server's
917
+ * canonical `crate::json` output so round-tripped Json values compare
918
+ * byte-equal. */
919
+ function canonicalJson(v) {
920
+ if (v === null) return 'null'
921
+ if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'null'
922
+ if (typeof v === 'string') return JSON.stringify(v)
923
+ if (typeof v === 'boolean') return v ? 'true' : 'false'
924
+ if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']'
925
+ if (typeof v === 'object') {
926
+ const keys = Object.keys(v).sort()
927
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(v[k])).join(',') + '}'
928
+ }
929
+ return 'null'
930
+ }
931
+
717
932
  function jsonReason(bytes) {
718
933
  const v = jsonOf(bytes)
719
934
  if (v && typeof v === 'object' && typeof v.reason === 'string') {