@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 +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/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
|
|
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
|
|
34
|
-
|
|
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
|
|
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: {
|
|
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`,
|