@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/README.md +12 -1
- package/index.d.ts +64 -2
- package/package.json +1 -1
- package/src/db-helpers.js +95 -0
- package/src/grpc.js +435 -0
- package/src/http.js +7 -2
- package/src/index.js +179 -15
- package/src/kv.js +23 -1
- package/src/queue.js +78 -0
- package/src/redwire.js +320 -7
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
|
|
314
|
-
|
|
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
|
|
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') {
|