@reddb-io/client 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/README.md CHANGED
@@ -5,6 +5,9 @@ RedWire (TCP + mTLS), gRPC, and HTTP straight to a remote RedDB
5
5
  server. Ships the `red_client` thin binary for an ad-hoc REPL — about
6
6
  10x smaller than `@reddb-io/sdk`.
7
7
 
8
+ This package is the remote counterpart to the embedded-only
9
+ `@reddb-io/sdk`.
10
+
8
11
  > Embedded engines (`memory://`, `file:///path`) are intentionally
9
12
  > rejected by this package. Use [`@reddb-io/sdk`](../js) instead if you
10
13
  > need an in-process database.
@@ -44,12 +47,20 @@ const db = await connect('red://reddb.example.com:5050', {
44
47
  })
45
48
 
46
49
  await db.insert('users', { name: 'Alice' })
47
- const result = await db.query('SELECT * FROM users LIMIT 10')
50
+ const result = await db.query('SELECT * FROM users WHERE name = $1', 'Alice')
48
51
  console.log(result.rows)
49
52
 
50
53
  await db.close()
51
54
  ```
52
55
 
56
+ Use `db.query(sql, ...params)` or `db.execute(sql, ...params)` for
57
+ parameterized statements. The compatibility form `db.query(sql, paramsArray)`
58
+ is still accepted.
59
+
60
+ For `http://` and `https://` connections, `connect()` verifies readiness with
61
+ a lightweight `SELECT 1` round-trip. `/health` states such as `degraded` are
62
+ transient during boot and are not fatal as long as queries succeed.
63
+
53
64
  ## Accepted URI schemes
54
65
 
55
66
  | Scheme | Transport | Default port |
package/index.d.ts CHANGED
@@ -30,10 +30,29 @@ export interface QueryResult {
30
30
  rows: Array<Record<string, unknown>>
31
31
  }
32
32
 
33
- export interface InsertResult { affected: number; id?: string | number }
34
- export interface BulkInsertResult { affected: number }
33
+ export type QueryParam =
34
+ | null
35
+ | boolean
36
+ | number
37
+ | string
38
+ | Uint8Array
39
+ | Buffer
40
+ | Date
41
+ | Float32Array
42
+ | Float64Array
43
+ | number[]
44
+ | Record<string, unknown>
45
+
46
+ export interface InsertResult { affected: number; id: string | number }
47
+ export interface BulkInsertResult { affected: number; ids: Array<string | number> }
35
48
  export interface GetResult { entity: Record<string, unknown> | null }
36
49
  export interface DeleteResult { affected: number }
50
+ export interface CollectionMeta {
51
+ name: string
52
+ model: string
53
+ capabilities: string[]
54
+ [key: string]: unknown
55
+ }
37
56
  export interface HealthResult { ok: boolean; version: string }
38
57
  export interface VersionResult { version: string; protocol: string }
39
58
 
@@ -105,6 +124,8 @@ export class KvClient {
105
124
  value: unknown,
106
125
  options?: { collection?: string; expireMs?: number; tags?: string[] },
107
126
  ): Promise<QueryResult>
127
+ get(key: string, options?: { collection?: string }): Promise<unknown | null>
128
+ getMany(keys: string[], options?: { collection?: string }): Promise<Array<unknown | null>>
108
129
  invalidateTags(tags: string[], options?: { collection?: string }): Promise<number>
109
130
  watch(
110
131
  key: string,
@@ -116,6 +137,32 @@ export class KvClient {
116
137
  ): AsyncIterable<KvWatchEvent>
117
138
  }
118
139
 
140
+ export class QueueClient {
141
+ push(
142
+ queue: string,
143
+ value: unknown,
144
+ options?: { priority?: number },
145
+ ): Promise<QueryResult>
146
+ pop(queue: string, count?: number): Promise<unknown[]>
147
+ peek(queue: string, count?: number): Promise<unknown[]>
148
+ len(queue: string): Promise<number>
149
+ purge(queue: string): Promise<QueryResult>
150
+ }
151
+
152
+ /**
153
+ * Caller-typed SELECT builder. RedDB does not infer `T`; provide it
154
+ * explicitly with `db.from<T>('collection')`.
155
+ */
156
+ export class TypedQueryBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
157
+ select(): TypedQueryBuilder<T>
158
+ select(column: '*'): TypedQueryBuilder<T>
159
+ select<K extends keyof T & string>(...columns: K[]): TypedQueryBuilder<Pick<T, K>>
160
+ select<K extends keyof T & string>(columns: K[]): TypedQueryBuilder<Pick<T, K>>
161
+ where(condition: string, params: QueryParam[]): TypedQueryBuilder<T>
162
+ where(condition: string, ...params: QueryParam[]): TypedQueryBuilder<T>
163
+ run(): Promise<T[]>
164
+ }
165
+
119
166
  export class ConfigClient {
120
167
  put(
121
168
  key: string,
@@ -159,16 +206,31 @@ export function isEmbeddedUri(uri: string): boolean
159
206
 
160
207
  export class RedDB {
161
208
  readonly cache: CacheClient
209
+ readonly queue: QueueClient
162
210
  readonly kv: KvClient & ((collection?: string) => KvClient)
163
211
  readonly config: (collection?: string) => ConfigClient
164
212
  readonly vault: (collection?: string) => VaultClient
165
213
 
166
214
  query(sql: string): Promise<QueryResult>
215
+ query(sql: string, params: QueryParam[]): Promise<QueryResult>
216
+ query(sql: string, ...params: QueryParam[]): Promise<QueryResult>
217
+ execute(sql: string): Promise<QueryResult>
218
+ execute(sql: string, params: QueryParam[]): Promise<QueryResult>
219
+ execute(sql: string, ...params: QueryParam[]): Promise<QueryResult>
167
220
  insert(collection: string, payload: Record<string, unknown>): Promise<InsertResult>
168
221
  bulkInsert(
169
222
  collection: string,
170
223
  payloads: Array<Record<string, unknown>>,
171
224
  ): Promise<BulkInsertResult>
225
+ exists(collection: string): Promise<boolean>
226
+ list(): Promise<CollectionMeta[]>
227
+ /**
228
+ * Caller-typed collection handle. Supply `T`; the SDK does not
229
+ * generate or validate row types at runtime.
230
+ */
231
+ from<T extends Record<string, unknown> = Record<string, unknown>>(
232
+ collection: string,
233
+ ): TypedQueryBuilder<T>
172
234
  get(collection: string, id: string | number): Promise<GetResult>
173
235
  delete(collection: string, id: string | number): Promise<DeleteResult>
174
236
  health(): Promise<HealthResult>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddb-io/client",
3
- "version": "1.0.7",
3
+ "version": "1.1.0",
4
4
  "description": "Thin remote-only RedDB driver. Downloads the `red_client` binary on install. Speaks RedWire/gRPC/HTTP. Embedded URIs (memory://, file://, red:///path) are rejected — use @reddb-io/sdk for those.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,95 @@
1
+ import { RedDBError } from './protocol.js'
2
+
3
+ export async function listCollections(db) {
4
+ const result = await db.query('SHOW COLLECTIONS')
5
+ return (result.rows ?? []).map(collectionMeta)
6
+ }
7
+
8
+ export async function collectionExists(db, collection) {
9
+ const result = await db.query(`SHOW COLLECTIONS WHERE name = ${sqlString(collection)}`)
10
+ return (result.rows ?? []).some((row) => row.name === String(collection))
11
+ }
12
+
13
+ export class TypedQueryBuilder {
14
+ constructor(db, collection, columns = null, whereClauses = [], params = []) {
15
+ this.db = db
16
+ this.collection = collection
17
+ this.columns = columns
18
+ this.whereClauses = whereClauses
19
+ this.params = params
20
+ }
21
+
22
+ select(...columns) {
23
+ const selected = columns.length === 1 && Array.isArray(columns[0]) ? columns[0] : columns
24
+ const projection = selected.length === 1 && selected[0] === '*' ? null : selected
25
+ return new TypedQueryBuilder(
26
+ this.db,
27
+ this.collection,
28
+ projection != null && projection.length > 0 ? projection : null,
29
+ this.whereClauses,
30
+ this.params,
31
+ )
32
+ }
33
+
34
+ where(condition, ...params) {
35
+ if (typeof condition !== 'string' || condition.trim().length === 0) {
36
+ throw new RedDBError('INVALID_QUERY_BUILDER', 'where() requires a non-empty SQL condition')
37
+ }
38
+ const nextParams = params.length === 1 && Array.isArray(params[0]) ? params[0] : params
39
+ return new TypedQueryBuilder(
40
+ this.db,
41
+ this.collection,
42
+ this.columns,
43
+ [...this.whereClauses, condition.trim()],
44
+ [...this.params, ...nextParams],
45
+ )
46
+ }
47
+
48
+ async run() {
49
+ const projection = this.columns == null
50
+ ? '*'
51
+ : this.columns.map(sqlIdentifierPath).join(', ')
52
+ const where = this.whereClauses.length > 0
53
+ ? ` WHERE ${this.whereClauses.map((clause) => `(${clause})`).join(' AND ')}`
54
+ : ''
55
+ const sql = `SELECT ${projection} FROM ${sqlIdentifierPath(this.collection)}${where}`
56
+ const result = this.params.length > 0
57
+ ? await this.db.query(sql, this.params)
58
+ : await this.db.query(sql)
59
+ const rows = result.rows ?? []
60
+ if (this.columns == null) return rows
61
+ return rows.map((row) => {
62
+ const selected = {}
63
+ for (const column of this.columns) selected[column] = row[column]
64
+ return selected
65
+ })
66
+ }
67
+ }
68
+
69
+ function collectionMeta(row) {
70
+ return {
71
+ ...row,
72
+ name: String(row.name),
73
+ model: String(row.model),
74
+ capabilities: Array.isArray(row.capabilities) ? row.capabilities : [],
75
+ }
76
+ }
77
+
78
+ function sqlIdentifierPath(value) {
79
+ return String(value).split('.').map(sqlIdentifier).join('.')
80
+ }
81
+
82
+ function sqlIdentifier(value) {
83
+ const ident = String(value)
84
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ident)) {
85
+ throw new RedDBError(
86
+ 'INVALID_IDENTIFIER',
87
+ `invalid SQL identifier "${ident}"`,
88
+ )
89
+ }
90
+ return ident
91
+ }
92
+
93
+ function sqlString(value) {
94
+ return `'${String(value).replace(/'/g, "''")}'`
95
+ }
package/src/grpc.js ADDED
@@ -0,0 +1,435 @@
1
+ /**
2
+ * Minimal gRPC transport for the JS driver.
3
+ *
4
+ * Uses Node's built-in HTTP/2 client and a small protobuf codec for
5
+ * the RedDb RPCs the public `RedDB` surface calls. Keeping this local
6
+ * avoids routing grpc:// through the RedWire frame parser.
7
+ */
8
+
9
+ import { connect as connectHttp2 } from 'node:http2'
10
+ import { Buffer } from 'node:buffer'
11
+
12
+ import { RedDBError } from './protocol.js'
13
+
14
+ const SERVICE = '/reddb.v1.RedDb'
15
+
16
+ export class GrpcRpcClient {
17
+ constructor({ baseUrl, token }) {
18
+ if (typeof baseUrl !== 'string' || baseUrl.length === 0) {
19
+ throw new TypeError('GrpcRpcClient: baseUrl required')
20
+ }
21
+ this.baseUrl = baseUrl.replace(/\/$/, '')
22
+ this.token = token ?? null
23
+ this.session = connectHttp2(this.baseUrl)
24
+ this.session.on('error', () => {})
25
+ }
26
+
27
+ setToken(token) {
28
+ this.token = token
29
+ }
30
+
31
+ async close() {
32
+ this.session.close()
33
+ }
34
+
35
+ async call(method, params = {}) {
36
+ switch (method) {
37
+ case 'query':
38
+ return normalizeQueryReply(await this.#rpc(
39
+ 'Query',
40
+ encodeQueryRequest(params.sql ?? '', params.params),
41
+ decodeQueryReply,
42
+ ))
43
+ case 'insert':
44
+ return normalizeEntityReply(await this.#rpc(
45
+ 'CreateRow',
46
+ encodeJsonCreateRequest(params.collection, params.payload),
47
+ decodeEntityReply,
48
+ ))
49
+ case 'bulk_insert':
50
+ return normalizeBulkEntityReply(await this.#rpc(
51
+ 'BulkCreateRows',
52
+ encodeJsonBulkCreateRequest(params.collection, params.payloads),
53
+ decodeBulkEntityReply,
54
+ ))
55
+ case 'delete':
56
+ return normalizeOperationReply(await this.#rpc(
57
+ 'DeleteEntity',
58
+ encodeDeleteEntityRequest(params.collection, params.id),
59
+ decodeOperationReply,
60
+ ))
61
+ case 'health':
62
+ case 'version':
63
+ return normalizeHealthReply(await this.#rpc('Ready', new Uint8Array(), decodeHealthReply))
64
+ default:
65
+ throw new RedDBError(
66
+ 'UNKNOWN_METHOD',
67
+ `gRPC transport has no route for method '${method}'`,
68
+ )
69
+ }
70
+ }
71
+
72
+ #rpc(name, payload, decode) {
73
+ return new Promise((resolve, reject) => {
74
+ const headers = {
75
+ ':method': 'POST',
76
+ ':path': `${SERVICE}/${name}`,
77
+ 'content-type': 'application/grpc',
78
+ te: 'trailers',
79
+ }
80
+ if (this.token) headers.authorization = `Bearer ${this.token}`
81
+
82
+ const req = this.session.request(headers)
83
+ const chunks = []
84
+ let responseHeaders = null
85
+ let trailers = null
86
+
87
+ req.on('response', (headers) => {
88
+ responseHeaders = headers
89
+ })
90
+ req.on('trailers', (headers) => {
91
+ trailers = headers
92
+ })
93
+ req.on('data', (chunk) => {
94
+ chunks.push(chunk)
95
+ })
96
+ req.on('error', reject)
97
+ req.on('end', () => {
98
+ try {
99
+ const status = Number(responseHeaders?.[':status'] ?? 0)
100
+ const grpcStatus = String(
101
+ trailers?.['grpc-status'] ?? responseHeaders?.['grpc-status'] ?? '0',
102
+ )
103
+ if (status !== 200 || grpcStatus !== '0') {
104
+ const message = String(
105
+ trailers?.['grpc-message']
106
+ ?? responseHeaders?.['grpc-message']
107
+ ?? `gRPC ${name} failed`,
108
+ )
109
+ throw new RedDBError(
110
+ grpcStatus === '0' ? `HTTP_${status}` : `GRPC_${grpcStatus}`,
111
+ decodeURIComponent(message),
112
+ )
113
+ }
114
+ const body = Buffer.concat(chunks)
115
+ resolve(decode(readGrpcMessage(body)))
116
+ } catch (err) {
117
+ reject(err)
118
+ }
119
+ })
120
+ req.end(writeGrpcMessage(payload))
121
+ })
122
+ }
123
+ }
124
+
125
+ function writeGrpcMessage(payload) {
126
+ const out = new Uint8Array(5 + payload.length)
127
+ const view = new DataView(out.buffer)
128
+ out[0] = 0
129
+ view.setUint32(1, payload.length, false)
130
+ out.set(payload, 5)
131
+ return out
132
+ }
133
+
134
+ function readGrpcMessage(body) {
135
+ if (body.length < 5) {
136
+ throw new RedDBError('GRPC_PROTOCOL', 'gRPC response missing message header')
137
+ }
138
+ if (body[0] !== 0) {
139
+ throw new RedDBError('GRPC_PROTOCOL', 'compressed gRPC responses are not supported')
140
+ }
141
+ const len = body.readUInt32BE(1)
142
+ if (body.length < 5 + len) {
143
+ throw new RedDBError('GRPC_PROTOCOL', 'gRPC response truncated')
144
+ }
145
+ return body.subarray(5, 5 + len)
146
+ }
147
+
148
+ function normalizeQueryReply(reply) {
149
+ let parsed = {}
150
+ if (reply.result_json) {
151
+ try {
152
+ parsed = JSON.parse(reply.result_json)
153
+ } catch (err) {
154
+ throw new RedDBError('QUERY_ERROR', `bad gRPC query JSON: ${err.message}`)
155
+ }
156
+ }
157
+ const rows = parsed.rows ?? parsed.records ?? []
158
+ return {
159
+ ok: reply.ok,
160
+ statement: parsed.statement ?? reply.statement ?? '',
161
+ affected: parsed.affected ?? parsed.affected_rows ?? 0,
162
+ columns: parsed.columns ?? reply.columns ?? [],
163
+ rows,
164
+ }
165
+ }
166
+
167
+ function normalizeEntityReply(reply) {
168
+ return {
169
+ ok: reply.ok,
170
+ affected: reply.ok ? 1 : 0,
171
+ id: safeBigIntToJs(reply.id),
172
+ entity: parseJsonOrNull(reply.entity_json),
173
+ }
174
+ }
175
+
176
+ function normalizeBulkEntityReply(reply) {
177
+ return {
178
+ ok: reply.ok,
179
+ affected: safeBigIntToJs(reply.count),
180
+ ids: reply.items.map((item) => safeBigIntToJs(item.id)),
181
+ }
182
+ }
183
+
184
+ function normalizeOperationReply(reply) {
185
+ return { ok: reply.ok, affected: reply.ok ? 1 : 0, message: reply.message }
186
+ }
187
+
188
+ function normalizeHealthReply(reply) {
189
+ return {
190
+ ok: reply.healthy,
191
+ state: reply.state,
192
+ checked_at_unix_ms: safeBigIntToJs(reply.checked_at_unix_ms),
193
+ }
194
+ }
195
+
196
+ function parseJsonOrNull(text) {
197
+ if (!text) return null
198
+ try {
199
+ return JSON.parse(text)
200
+ } catch {
201
+ return null
202
+ }
203
+ }
204
+
205
+ function encodeQueryRequest(sql, params) {
206
+ const fields = [stringField(1, sql)]
207
+ if (Array.isArray(params)) {
208
+ for (const param of params) fields.push(bytesField(4, encodeQueryValue(param)))
209
+ }
210
+ return concat(fields)
211
+ }
212
+
213
+ function encodeQueryValue(value) {
214
+ if (value == null) return bytesField(1, new Uint8Array())
215
+ if (typeof value === 'boolean') return boolField(2, value)
216
+ if (typeof value === 'bigint') return int64Field(3, value)
217
+ if (typeof value === 'number') {
218
+ return Number.isInteger(value) && Number.isSafeInteger(value)
219
+ ? int64Field(3, BigInt(value))
220
+ : doubleField(4, value)
221
+ }
222
+ if (typeof value === 'string') return stringField(5, value)
223
+ if (value instanceof Uint8Array || (typeof Buffer !== 'undefined' && value instanceof Buffer)) {
224
+ return bytesField(6, value)
225
+ }
226
+ if (Array.isArray(value) && value.every((item) => typeof item === 'number')) {
227
+ return bytesField(7, encodeQueryVector(value))
228
+ }
229
+ if (typeof value === 'object') {
230
+ if (typeof value.$bytes === 'string') return bytesField(6, Buffer.from(value.$bytes, 'base64'))
231
+ if (typeof value.$float === 'string') return doubleField(4, Number(value.$float))
232
+ if (value.$ts != null) return int64Field(9, BigInt(value.$ts))
233
+ if (typeof value.$uuid === 'string') return bytesField(10, uuidBytes(value.$uuid))
234
+ return stringField(8, JSON.stringify(value))
235
+ }
236
+ throw new RedDBError('UNSUPPORTED_PARAM', `cannot encode gRPC query parameter of type ${typeof value}`)
237
+ }
238
+
239
+ function encodeQueryVector(values) {
240
+ const packed = new Uint8Array(values.length * 4)
241
+ const view = new DataView(packed.buffer)
242
+ values.forEach((value, index) => view.setFloat32(index * 4, Number(value), true))
243
+ return bytesField(1, packed)
244
+ }
245
+
246
+ function encodeJsonCreateRequest(collection, payload) {
247
+ return concat([
248
+ stringField(1, collection ?? ''),
249
+ stringField(2, JSON.stringify(payload ?? {})),
250
+ ])
251
+ }
252
+
253
+ function encodeJsonBulkCreateRequest(collection, payloads) {
254
+ const fields = [stringField(1, collection ?? '')]
255
+ for (const payload of payloads ?? []) {
256
+ fields.push(stringField(2, JSON.stringify(payload ?? {})))
257
+ }
258
+ return concat(fields)
259
+ }
260
+
261
+ function encodeDeleteEntityRequest(collection, id) {
262
+ return concat([
263
+ stringField(1, collection ?? ''),
264
+ uint64Field(2, BigInt(id ?? 0)),
265
+ ])
266
+ }
267
+
268
+ function decodeQueryReply(bytes) {
269
+ const out = { ok: false, mode: '', statement: '', engine: '', columns: [], record_count: 0, result_json: '' }
270
+ for (const field of readFields(bytes)) {
271
+ if (field.no === 1 && field.wire === 0) out.ok = field.value !== 0n
272
+ else if (field.no === 2 && field.wire === 2) out.mode = text(field.value)
273
+ else if (field.no === 3 && field.wire === 2) out.statement = text(field.value)
274
+ else if (field.no === 4 && field.wire === 2) out.engine = text(field.value)
275
+ else if (field.no === 5 && field.wire === 2) out.columns.push(text(field.value))
276
+ else if (field.no === 6 && field.wire === 0) out.record_count = safeBigIntToJs(field.value)
277
+ else if (field.no === 7 && field.wire === 2) out.result_json = text(field.value)
278
+ }
279
+ return out
280
+ }
281
+
282
+ function decodeEntityReply(bytes) {
283
+ const out = { ok: false, id: 0n, entity_json: '' }
284
+ for (const field of readFields(bytes)) {
285
+ if (field.no === 1 && field.wire === 0) out.ok = field.value !== 0n
286
+ else if (field.no === 2 && field.wire === 0) out.id = field.value
287
+ else if (field.no === 3 && field.wire === 2) out.entity_json = text(field.value)
288
+ }
289
+ return out
290
+ }
291
+
292
+ function decodeBulkEntityReply(bytes) {
293
+ const out = { ok: false, count: 0n, items: [] }
294
+ for (const field of readFields(bytes)) {
295
+ if (field.no === 1 && field.wire === 0) out.ok = field.value !== 0n
296
+ else if (field.no === 2 && field.wire === 0) out.count = field.value
297
+ else if (field.no === 3 && field.wire === 2) out.items.push(decodeEntityReply(field.value))
298
+ }
299
+ return out
300
+ }
301
+
302
+ function decodeOperationReply(bytes) {
303
+ const out = { ok: false, message: '' }
304
+ for (const field of readFields(bytes)) {
305
+ if (field.no === 1 && field.wire === 0) out.ok = field.value !== 0n
306
+ else if (field.no === 2 && field.wire === 2) out.message = text(field.value)
307
+ }
308
+ return out
309
+ }
310
+
311
+ function decodeHealthReply(bytes) {
312
+ const out = { healthy: false, state: '', checked_at_unix_ms: 0n }
313
+ for (const field of readFields(bytes)) {
314
+ if (field.no === 1 && field.wire === 0) out.healthy = field.value !== 0n
315
+ else if (field.no === 2 && field.wire === 2) out.state = text(field.value)
316
+ else if (field.no === 3 && field.wire === 0) out.checked_at_unix_ms = field.value
317
+ }
318
+ return out
319
+ }
320
+
321
+ function readFields(bytes) {
322
+ let pos = 0
323
+ const fields = []
324
+ while (pos < bytes.length) {
325
+ const key = readVarint(bytes, pos)
326
+ pos = key.pos
327
+ const no = Number(key.value >> 3n)
328
+ const wire = Number(key.value & 0x07n)
329
+ if (wire === 0) {
330
+ const value = readVarint(bytes, pos)
331
+ pos = value.pos
332
+ fields.push({ no, wire, value: value.value })
333
+ } else if (wire === 1) {
334
+ fields.push({ no, wire, value: bytes.subarray(pos, pos + 8) })
335
+ pos += 8
336
+ } else if (wire === 2) {
337
+ const len = readVarint(bytes, pos)
338
+ pos = len.pos
339
+ const end = pos + Number(len.value)
340
+ fields.push({ no, wire, value: bytes.subarray(pos, end) })
341
+ pos = end
342
+ } else if (wire === 5) {
343
+ fields.push({ no, wire, value: bytes.subarray(pos, pos + 4) })
344
+ pos += 4
345
+ } else {
346
+ throw new RedDBError('GRPC_PROTOCOL', `unsupported protobuf wire type ${wire}`)
347
+ }
348
+ }
349
+ return fields
350
+ }
351
+
352
+ function stringField(no, value) {
353
+ return bytesField(no, new TextEncoder().encode(String(value)))
354
+ }
355
+
356
+ function bytesField(no, value) {
357
+ const bytes = value instanceof Uint8Array ? value : new Uint8Array(value)
358
+ return concat([varint((BigInt(no) << 3n) | 2n), varint(BigInt(bytes.length)), bytes])
359
+ }
360
+
361
+ function boolField(no, value) {
362
+ return concat([varint((BigInt(no) << 3n) | 0n), varint(value ? 1n : 0n)])
363
+ }
364
+
365
+ function uint64Field(no, value) {
366
+ return concat([varint((BigInt(no) << 3n) | 0n), varint(BigInt(value))])
367
+ }
368
+
369
+ function int64Field(no, value) {
370
+ return concat([varint((BigInt(no) << 3n) | 0n), varint(BigInt.asUintN(64, BigInt(value)))])
371
+ }
372
+
373
+ function doubleField(no, value) {
374
+ const bytes = new Uint8Array(8)
375
+ new DataView(bytes.buffer).setFloat64(0, Number(value), true)
376
+ return concat([varint((BigInt(no) << 3n) | 1n), bytes])
377
+ }
378
+
379
+ function varint(value) {
380
+ let n = BigInt(value)
381
+ const bytes = []
382
+ while (n >= 0x80n) {
383
+ bytes.push(Number((n & 0x7fn) | 0x80n))
384
+ n >>= 7n
385
+ }
386
+ bytes.push(Number(n))
387
+ return Uint8Array.from(bytes)
388
+ }
389
+
390
+ function readVarint(bytes, start) {
391
+ let shift = 0n
392
+ let value = 0n
393
+ let pos = start
394
+ while (pos < bytes.length) {
395
+ const byte = BigInt(bytes[pos])
396
+ pos += 1
397
+ value |= (byte & 0x7fn) << shift
398
+ if ((byte & 0x80n) === 0n) return { value, pos }
399
+ shift += 7n
400
+ }
401
+ throw new RedDBError('GRPC_PROTOCOL', 'truncated protobuf varint')
402
+ }
403
+
404
+ function concat(parts) {
405
+ const total = parts.reduce((sum, part) => sum + part.length, 0)
406
+ const out = new Uint8Array(total)
407
+ let pos = 0
408
+ for (const part of parts) {
409
+ out.set(part, pos)
410
+ pos += part.length
411
+ }
412
+ return out
413
+ }
414
+
415
+ function text(bytes) {
416
+ return new TextDecoder().decode(bytes)
417
+ }
418
+
419
+ function uuidBytes(value) {
420
+ const hex = value.replace(/-/g, '')
421
+ if (!/^[0-9a-f]{32}$/i.test(hex)) {
422
+ throw new RedDBError('UNSUPPORTED_PARAM', `invalid UUID query parameter: ${value}`)
423
+ }
424
+ return Uint8Array.from(hex.match(/../g).map((pair) => Number.parseInt(pair, 16)))
425
+ }
426
+
427
+ function safeBigIntToJs(value) {
428
+ if (
429
+ value >= BigInt(Number.MIN_SAFE_INTEGER)
430
+ && value <= BigInt(Number.MAX_SAFE_INTEGER)
431
+ ) {
432
+ return Number(value)
433
+ }
434
+ return value.toString()
435
+ }
package/src/http.js CHANGED
@@ -123,9 +123,14 @@ async function parseResponse(response) {
123
123
  const ROUTES = {
124
124
  health: (base) => ({ url: `${base}/health`, init: { method: 'GET' } }),
125
125
  version: (base) => ({ url: `${base}/admin/version`, init: { method: 'GET' } }),
126
- query: (base, { sql }) => ({
126
+ query: (base, { sql, params }) => ({
127
127
  url: `${base}/query`,
128
- init: { method: 'POST', body: JSON.stringify({ query: sql }) },
128
+ init: {
129
+ method: 'POST',
130
+ body: JSON.stringify(
131
+ Array.isArray(params) ? { query: sql, params } : { query: sql },
132
+ ),
133
+ },
129
134
  }),
130
135
  insert: (base, { collection, payload }) => ({
131
136
  url: `${base}/collections/${encodeURIComponent(collection)}/rows`,