@reddb-io/sdk 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.
- package/package.json +2 -2
- package/src/redwire.js +217 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reddb-io/sdk",
|
|
3
|
-
"version": "1.0.
|
|
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",
|
|
@@ -45,6 +45,6 @@
|
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"postinstall": "node postinstall.js",
|
|
48
|
-
"test": "node --test test/cache.test.mjs && node test/smoke.test.mjs"
|
|
48
|
+
"test": "node --test test/cache.test.mjs test/params.test.mjs test/redwire.params.test.mjs && node test/smoke.test.mjs"
|
|
49
49
|
}
|
|
50
50
|
}
|
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
|
|
314
|
-
|
|
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') {
|