@reddb-io/cli 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 +2 -0
- package/drivers/js/package.json +3 -3
- package/drivers/js/src/cache.js +33 -2
- package/drivers/js/src/db-helpers.js +95 -0
- package/drivers/js/src/http.js +7 -2
- package/drivers/js/src/index.js +209 -226
- package/drivers/js/src/kv.js +23 -1
- package/drivers/js/src/queue.js +78 -0
- package/drivers/js/src/redwire.js +221 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -597,12 +597,14 @@ npx @reddb-io/cli@latest server --wire-bind 127.0.0.1:5050 --http-bind 127.0.0.1
|
|
|
597
597
|
Or via Docker:
|
|
598
598
|
|
|
599
599
|
```bash
|
|
600
|
+
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USER" --password-stdin # if GHCR requires auth
|
|
600
601
|
docker run --rm -p 5050:5050 -p 5055:5055 -p 8080:8080 ghcr.io/reddb-io/reddb:latest
|
|
601
602
|
```
|
|
602
603
|
|
|
603
604
|
Or, if you only need the thin remote-only client (~7 MB image):
|
|
604
605
|
|
|
605
606
|
```bash
|
|
607
|
+
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USER" --password-stdin # if GHCR requires auth
|
|
606
608
|
docker run --rm ghcr.io/reddb-io/reddb-client:latest red://reddb.example.com:5050 -c "SELECT 1"
|
|
607
609
|
```
|
|
608
610
|
|
package/drivers/js/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reddb-io/sdk",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Official RedDB
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Official embedded RedDB SDK — launches a local red binary over stdio JSON-RPC. Use @reddb-io/client for remote HTTP, gRPC, and RedWire.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
],
|
|
21
21
|
"scripts": {
|
|
22
22
|
"postinstall": "node postinstall.js",
|
|
23
|
-
"test": "node --test test/cache.test.mjs && node test/smoke.test.mjs"
|
|
23
|
+
"test": "node --test test/ask.test.mjs test/cache.test.mjs test/db-helpers.test.mjs test/embedded-only.test.mjs test/insert-ids.test.mjs test/kv.test.mjs test/params.test.mjs test/postinstall.test.mjs test/queue.test.mjs test/redwire.params.test.mjs && node test/smoke.test.mjs"
|
|
24
24
|
},
|
|
25
25
|
"engines": {
|
|
26
26
|
"node": ">=18"
|
package/drivers/js/src/cache.js
CHANGED
|
@@ -6,13 +6,37 @@
|
|
|
6
6
|
* flushNamespace routes to the existing POST /admin/blob_cache/flush_namespace.
|
|
7
7
|
* All others target endpoints planned for a future server release.
|
|
8
8
|
*
|
|
9
|
+
* Requires an HTTP or gRPC transport. On embedded (stdio JSON-RPC), every
|
|
10
|
+
* method throws `UNSUPPORTED_TRANSPORT` before issuing the RPC call —
|
|
11
|
+
* the engine's stdio handler doesn't implement `cache.*`.
|
|
12
|
+
*
|
|
9
13
|
* Values are base64-encoded in transit so binary payloads survive JSON.
|
|
10
14
|
*/
|
|
11
15
|
|
|
16
|
+
import { RedDBError } from './protocol.js'
|
|
17
|
+
|
|
18
|
+
const UNSUPPORTED_TRANSPORTS = new Set(['embedded'])
|
|
19
|
+
|
|
12
20
|
export class CacheClient {
|
|
13
|
-
/**
|
|
14
|
-
|
|
21
|
+
/**
|
|
22
|
+
* @param {{ call: Function }} client
|
|
23
|
+
* @param {string} [transport] Underlying transport label (e.g. 'http',
|
|
24
|
+
* 'grpc', 'embedded'). When the transport doesn't serve `cache.*`,
|
|
25
|
+
* every method throws `UNSUPPORTED_TRANSPORT` before any RPC call.
|
|
26
|
+
*/
|
|
27
|
+
constructor(client, transport) {
|
|
15
28
|
this._client = client
|
|
29
|
+
this._transport = transport ?? null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_guard(method) {
|
|
33
|
+
if (this._transport && UNSUPPORTED_TRANSPORTS.has(this._transport)) {
|
|
34
|
+
throw new RedDBError(
|
|
35
|
+
'UNSUPPORTED_TRANSPORT',
|
|
36
|
+
`cache.${method} is not available on '${this._transport}' transport; `
|
|
37
|
+
+ 'use @reddb-io/client for remote cache endpoints.',
|
|
38
|
+
)
|
|
39
|
+
}
|
|
16
40
|
}
|
|
17
41
|
|
|
18
42
|
/**
|
|
@@ -22,6 +46,7 @@ export class CacheClient {
|
|
|
22
46
|
* @returns {Promise<Uint8Array | null>}
|
|
23
47
|
*/
|
|
24
48
|
async get(namespace, key) {
|
|
49
|
+
this._guard('get')
|
|
25
50
|
const result = await this._client.call('cache.get', { namespace, key })
|
|
26
51
|
if (result == null || result.value == null) return null
|
|
27
52
|
return base64ToBytes(result.value)
|
|
@@ -39,6 +64,7 @@ export class CacheClient {
|
|
|
39
64
|
* @returns {Promise<void>}
|
|
40
65
|
*/
|
|
41
66
|
async put(namespace, key, value, opts = {}) {
|
|
67
|
+
this._guard('put')
|
|
42
68
|
const encoded = bytesToBase64(value)
|
|
43
69
|
await this._client.call('cache.put', {
|
|
44
70
|
namespace,
|
|
@@ -55,6 +81,7 @@ export class CacheClient {
|
|
|
55
81
|
* @returns {Promise<'present' | 'absent' | 'maybe'>}
|
|
56
82
|
*/
|
|
57
83
|
async exists(namespace, key) {
|
|
84
|
+
this._guard('exists')
|
|
58
85
|
const result = await this._client.call('cache.exists', { namespace, key })
|
|
59
86
|
return result?.status ?? 'maybe'
|
|
60
87
|
}
|
|
@@ -66,6 +93,7 @@ export class CacheClient {
|
|
|
66
93
|
* @returns {Promise<void>}
|
|
67
94
|
*/
|
|
68
95
|
async invalidate(namespace, key) {
|
|
96
|
+
this._guard('invalidate')
|
|
69
97
|
await this._client.call('cache.invalidate', { namespace, key })
|
|
70
98
|
}
|
|
71
99
|
|
|
@@ -76,6 +104,7 @@ export class CacheClient {
|
|
|
76
104
|
* @returns {Promise<number>} Number of entries removed.
|
|
77
105
|
*/
|
|
78
106
|
async invalidatePrefix(namespace, prefix) {
|
|
107
|
+
this._guard('invalidatePrefix')
|
|
79
108
|
const result = await this._client.call('cache.invalidate_prefix', { namespace, prefix })
|
|
80
109
|
return result?.removed ?? 0
|
|
81
110
|
}
|
|
@@ -87,6 +116,7 @@ export class CacheClient {
|
|
|
87
116
|
* @returns {Promise<number>} Number of entries removed.
|
|
88
117
|
*/
|
|
89
118
|
async invalidateTags(namespace, tags) {
|
|
119
|
+
this._guard('invalidateTags')
|
|
90
120
|
const result = await this._client.call('cache.invalidate_tags', { namespace, tags })
|
|
91
121
|
return result?.removed ?? 0
|
|
92
122
|
}
|
|
@@ -98,6 +128,7 @@ export class CacheClient {
|
|
|
98
128
|
* @returns {Promise<void>}
|
|
99
129
|
*/
|
|
100
130
|
async flushNamespace(namespace) {
|
|
131
|
+
this._guard('flushNamespace')
|
|
101
132
|
await this._client.call('cache.flush_namespace', { namespace })
|
|
102
133
|
}
|
|
103
134
|
}
|
|
@@ -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/drivers/js/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`,
|
package/drivers/js/src/index.js
CHANGED
|
@@ -14,46 +14,35 @@
|
|
|
14
14
|
* Connection URIs:
|
|
15
15
|
* - 'memory://' — ephemeral in-memory database (embedded)
|
|
16
16
|
* - 'file:///absolute/path' — embedded, persisted to disk
|
|
17
|
-
* - 'grpc://host:port' — remote server via gRPC
|
|
18
17
|
*
|
|
19
|
-
*
|
|
20
|
-
* auth options because the spawned binary inherits the caller's
|
|
21
|
-
* filesystem privileges):
|
|
22
|
-
*
|
|
23
|
-
* await connect('grpc://host:5051', {
|
|
24
|
-
* auth: { token: 'sk-...' } // raw bearer / api key
|
|
25
|
-
* })
|
|
26
|
-
* await connect('grpc://host:5051', {
|
|
27
|
-
* auth: { apiKey: 'ak-...' } // alias for token
|
|
28
|
-
* })
|
|
29
|
-
* await connect('grpc://host:5051', {
|
|
30
|
-
* auth: { username: 'admin', password: 'x' } // login flow — driver
|
|
31
|
-
* // calls /auth/login,
|
|
32
|
-
* // caches the bearer
|
|
33
|
-
* })
|
|
34
|
-
*
|
|
35
|
-
* Username/password requires the server to expose the `auth.login`
|
|
36
|
-
* JSON-RPC method (proxied through the gRPC bridge).
|
|
18
|
+
* Remote URIs belong to @reddb-io/client. This SDK is embedded-only.
|
|
37
19
|
*/
|
|
38
20
|
|
|
39
21
|
import { spawnRed } from './spawn.js'
|
|
40
22
|
import { resolveSdkBinary } from './binary.js'
|
|
41
23
|
import { RpcClient, RedDBError } from './protocol.js'
|
|
42
|
-
import {
|
|
43
|
-
import { connectRedwire } from './redwire.js'
|
|
44
|
-
import { parseUri, deriveLoginUrl } from './url.js'
|
|
24
|
+
import { parseUri } from './url.js'
|
|
45
25
|
import { CacheClient } from './cache.js'
|
|
46
26
|
import { KvClient } from './kv.js'
|
|
27
|
+
import { QueueClient } from './queue.js'
|
|
47
28
|
import { ConfigClient } from './config.js'
|
|
48
29
|
import { VaultClient } from './vault.js'
|
|
30
|
+
import { TypedQueryBuilder, collectionExists, listCollections } from './db-helpers.js'
|
|
49
31
|
|
|
50
32
|
export { RedDBError }
|
|
51
33
|
export { CacheClient } from './cache.js'
|
|
52
34
|
export { KvClient } from './kv.js'
|
|
35
|
+
export { QueueClient } from './queue.js'
|
|
53
36
|
export { ConfigClient } from './config.js'
|
|
54
37
|
export { VaultClient } from './vault.js'
|
|
38
|
+
export { TypedQueryBuilder } from './db-helpers.js'
|
|
55
39
|
export { parseUri, deriveLoginUrl } from './url.js'
|
|
56
40
|
|
|
41
|
+
export const EMBEDDED_ONLY_MESSAGE =
|
|
42
|
+
'remote URIs are not supported in @reddb-io/sdk; install @reddb-io/client for grpc/http/red transports'
|
|
43
|
+
|
|
44
|
+
const MIN_INSERT_ID_ENGINE_VERSION = '1.0.9'
|
|
45
|
+
|
|
57
46
|
/**
|
|
58
47
|
* Connect to a RedDB instance.
|
|
59
48
|
*
|
|
@@ -69,11 +58,12 @@ export { parseUri, deriveLoginUrl } from './url.js'
|
|
|
69
58
|
*/
|
|
70
59
|
export async function connect(uri, options = {}) {
|
|
71
60
|
const parsed = parseUri(uri)
|
|
72
|
-
|
|
61
|
+
rejectRemoteUri(parsed)
|
|
73
62
|
|
|
74
63
|
// Embedded modes: spawn the binary with stdio JSON-RPC. Auth is
|
|
75
64
|
// not applicable (caller already has filesystem privileges).
|
|
76
65
|
if (parsed.kind === 'embedded') {
|
|
66
|
+
const merged = mergeAuthFromUri(parsed, options.auth)
|
|
77
67
|
if (merged.token || merged.username) {
|
|
78
68
|
throw new RedDBError(
|
|
79
69
|
'AUTH_NOT_APPLICABLE',
|
|
@@ -85,95 +75,130 @@ export async function connect(uri, options = {}) {
|
|
|
85
75
|
const child = await spawnRed(binary, args)
|
|
86
76
|
const client = new RpcClient(child)
|
|
87
77
|
await client.call('version', {})
|
|
88
|
-
return new RedDB(client)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// HTTP / HTTPS: speak directly to the server via fetch().
|
|
92
|
-
if (parsed.kind === 'http' || parsed.kind === 'https') {
|
|
93
|
-
const baseUrl = `${parsed.kind}://${parsed.host}:${parsed.port}`
|
|
94
|
-
let token = merged.token
|
|
95
|
-
if (!token && merged.username && merged.password) {
|
|
96
|
-
const loginUrl = merged.loginUrl ?? `${baseUrl}/auth/login`
|
|
97
|
-
const session = await login(loginUrl, {
|
|
98
|
-
username: merged.username,
|
|
99
|
-
password: merged.password,
|
|
100
|
-
})
|
|
101
|
-
token = session.token
|
|
102
|
-
}
|
|
103
|
-
const client = new HttpRpcClient({ baseUrl, token })
|
|
104
|
-
// Sanity check before returning the handle.
|
|
105
|
-
await client.call('health', {})
|
|
106
|
-
return new RedDB(client)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// gRPC / gRPCs / RedWire (default for grpc-shaped URIs):
|
|
110
|
-
// speak the RedWire binary protocol natively via TCP. No spawn, no
|
|
111
|
-
// gRPC bridge. Resolves bearer auth from username/password via
|
|
112
|
-
// HTTP /auth/login first when needed.
|
|
113
|
-
//
|
|
114
|
-
// The server multiplexes RedWire on the same port as gRPC and HTTP
|
|
115
|
-
// via the service router's 0xFE detector, so pure grpc:// URLs
|
|
116
|
-
// still flow through RedWire because it wins on perf and parity.
|
|
117
|
-
if (parsed.kind === 'grpc' || parsed.kind === 'grpcs') {
|
|
118
|
-
let token = merged.token
|
|
119
|
-
if (!token && merged.username && merged.password) {
|
|
120
|
-
const loginUrl = merged.loginUrl ?? deriveLoginUrl(parsed)
|
|
121
|
-
const session = await login(loginUrl, {
|
|
122
|
-
username: merged.username,
|
|
123
|
-
password: merged.password,
|
|
124
|
-
})
|
|
125
|
-
token = session.token
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Honour `proto=spawn-grpc` as an escape hatch for callers that
|
|
129
|
-
// explicitly want the legacy stdio→gRPC bridge. Default is the
|
|
130
|
-
// RedWire transport.
|
|
131
|
-
const protoOverride = parsed.params?.get?.('proto') ?? ''
|
|
132
|
-
if (protoOverride === 'spawn-grpc') {
|
|
133
|
-
const args = grpcArgs(parsed, token)
|
|
134
|
-
const binary = options.binary ?? resolveSdkBinary()
|
|
135
|
-
const child = await spawnRed(binary, args)
|
|
136
|
-
const legacy = new RpcClient(child)
|
|
137
|
-
await legacy.call('version', {})
|
|
138
|
-
return new RedDB(legacy)
|
|
139
|
-
}
|
|
78
|
+
return new RedDB(client, { transport: 'embedded' })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
140
81
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
82
|
+
// Coerce a JS query parameter to a JSON-serializable shape the server
|
|
83
|
+
// understands. Values JSON cannot represent losslessly use the
|
|
84
|
+
// stdio/HTTP query parameter envelopes.
|
|
85
|
+
function serializeParam(value) {
|
|
86
|
+
assertSupportedParam(value)
|
|
87
|
+
if (value instanceof Float32Array || value instanceof Float64Array) {
|
|
88
|
+
return Array.from(value)
|
|
89
|
+
}
|
|
90
|
+
if (value instanceof Date) {
|
|
91
|
+
return { $ts: String(BigInt(value.getTime()) * 1_000_000n) }
|
|
92
|
+
}
|
|
93
|
+
if (value instanceof Uint8Array || (typeof Buffer !== 'undefined' && value instanceof Buffer)) {
|
|
94
|
+
return { $bytes: bytesToBase64(value) }
|
|
150
95
|
}
|
|
96
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
97
|
+
if (Number.isNaN(value)) return { $float: 'NaN' }
|
|
98
|
+
return { $float: value > 0 ? 'Infinity' : '-Infinity' }
|
|
99
|
+
}
|
|
100
|
+
if (typeof value === 'string' && isUuidString(value)) {
|
|
101
|
+
return { $uuid: value }
|
|
102
|
+
}
|
|
103
|
+
return value
|
|
104
|
+
}
|
|
151
105
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
106
|
+
function assertSupportedParam(value) {
|
|
107
|
+
if (value == null) return
|
|
108
|
+
if (
|
|
109
|
+
typeof value === 'boolean'
|
|
110
|
+
|| typeof value === 'number'
|
|
111
|
+
|| typeof value === 'string'
|
|
112
|
+
) {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (value instanceof Date) {
|
|
116
|
+
if (Number.isNaN(value.getTime())) {
|
|
117
|
+
throw new RedDBError('UNSUPPORTED_PARAM', 'cannot encode invalid Date query parameter')
|
|
118
|
+
}
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
value instanceof Uint8Array
|
|
123
|
+
|| value instanceof Float32Array
|
|
124
|
+
|| value instanceof Float64Array
|
|
125
|
+
|| (typeof Buffer !== 'undefined' && value instanceof Buffer)
|
|
126
|
+
) {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
if (Array.isArray(value)) {
|
|
130
|
+
if (value.every((item) => typeof item === 'number')) return
|
|
156
131
|
throw new RedDBError(
|
|
157
|
-
'
|
|
158
|
-
|
|
159
|
-
+ "the JS driver doesn't bundle one yet. Use a separate `pg` package "
|
|
160
|
-
+ 'against the same host:port for now, or open an issue if you want it built in.',
|
|
132
|
+
'UNSUPPORTED_PARAM',
|
|
133
|
+
'array query parameters must contain only numbers',
|
|
161
134
|
)
|
|
162
135
|
}
|
|
163
|
-
|
|
136
|
+
if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
137
|
+
return
|
|
138
|
+
}
|
|
164
139
|
throw new RedDBError(
|
|
165
|
-
'
|
|
166
|
-
`
|
|
140
|
+
'UNSUPPORTED_PARAM',
|
|
141
|
+
`cannot encode query parameter of type ${typeof value}`,
|
|
167
142
|
)
|
|
168
143
|
}
|
|
169
144
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
145
|
+
function normalizeQueryParams(args) {
|
|
146
|
+
if (args.length === 0) return null
|
|
147
|
+
if (args.length === 1 && Array.isArray(args[0])) return args[0].map(serializeParam)
|
|
148
|
+
return args.map(serializeParam)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function bytesToBase64(value) {
|
|
152
|
+
const bytes = value instanceof Uint8Array
|
|
153
|
+
? value
|
|
154
|
+
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
|
155
|
+
if (typeof Buffer !== 'undefined') {
|
|
156
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64')
|
|
157
|
+
}
|
|
158
|
+
let text = ''
|
|
159
|
+
for (const byte of bytes) text += String.fromCharCode(byte)
|
|
160
|
+
// eslint-disable-next-line no-undef
|
|
161
|
+
return btoa(text)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function base64ToBytes(value) {
|
|
165
|
+
if (typeof Buffer !== 'undefined') {
|
|
166
|
+
const buf = Buffer.from(value, 'base64')
|
|
167
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
|
|
168
|
+
}
|
|
169
|
+
// eslint-disable-next-line no-undef
|
|
170
|
+
const text = atob(value)
|
|
171
|
+
const out = new Uint8Array(text.length)
|
|
172
|
+
for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i)
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isUuidString(value) {
|
|
177
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function normalizeResult(value) {
|
|
181
|
+
if (Array.isArray(value)) return value.map(normalizeResult)
|
|
182
|
+
if (value && typeof value === 'object') {
|
|
183
|
+
const keys = Object.keys(value)
|
|
184
|
+
if (keys.length === 1) {
|
|
185
|
+
if (typeof value.$bytes === 'string') return base64ToBytes(value.$bytes)
|
|
186
|
+
if (typeof value.$uuid === 'string') return value.$uuid
|
|
187
|
+
if (typeof value.$float === 'string') {
|
|
188
|
+
if (value.$float === 'NaN') return Number.NaN
|
|
189
|
+
if (value.$float === 'Infinity' || value.$float === '+Infinity') return Infinity
|
|
190
|
+
if (value.$float === '-Infinity') return -Infinity
|
|
191
|
+
}
|
|
192
|
+
if (typeof value.$ts === 'string' || typeof value.$ts === 'number') {
|
|
193
|
+
const raw = typeof value.$ts === 'string'
|
|
194
|
+
? BigInt(value.$ts)
|
|
195
|
+
: BigInt(Math.trunc(value.$ts))
|
|
196
|
+
return new Date(Number(raw / 1_000_000n))
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const out = {}
|
|
200
|
+
for (const [key, item] of Object.entries(value)) out[key] = normalizeResult(item)
|
|
201
|
+
return out
|
|
177
202
|
}
|
|
178
203
|
return value
|
|
179
204
|
}
|
|
@@ -183,51 +208,11 @@ function embeddedArgs(parsed) {
|
|
|
183
208
|
return ['rpc', '--stdio']
|
|
184
209
|
}
|
|
185
210
|
|
|
186
|
-
function grpcArgs(parsed, token) {
|
|
187
|
-
const scheme = parsed.kind === 'grpcs' ? 'grpcs' : 'grpc'
|
|
188
|
-
const url = `${scheme}://${parsed.host}:${parsed.port}${parsed.path ?? ''}`
|
|
189
|
-
const args = ['rpc', '--stdio', '--connect', url]
|
|
190
|
-
if (token) args.push('--token', token)
|
|
191
|
-
return args
|
|
192
|
-
}
|
|
193
|
-
|
|
194
211
|
/**
|
|
195
212
|
* Merge `options.auth` (legacy `{ token, apiKey, username, password }`
|
|
196
213
|
* shape) with credentials lifted from the URI itself. Explicit
|
|
197
214
|
* `options.auth` always wins to keep behaviour predictable.
|
|
198
215
|
*/
|
|
199
|
-
/**
|
|
200
|
-
* Resolve TLS options for a redwire(s) connection.
|
|
201
|
-
*
|
|
202
|
-
* Sources, in priority order:
|
|
203
|
-
* - `options.tls` from the caller (object form), wins everything
|
|
204
|
-
* - `parsed.kind === 'grpcs'` (i.e. `redwires://` or `?proto=grpcs`)
|
|
205
|
-
* - `?tls=true` in the URL params
|
|
206
|
-
* - `?ca=`, `?cert=`, `?key=`, `?servername=`,
|
|
207
|
-
* `?rejectUnauthorized=false` URL params (paths or PEM strings)
|
|
208
|
-
*
|
|
209
|
-
* Returns `null` when TLS isn't requested.
|
|
210
|
-
*/
|
|
211
|
-
function buildTlsOpts(parsed, callerTls) {
|
|
212
|
-
if (callerTls && typeof callerTls === 'object') {
|
|
213
|
-
return callerTls
|
|
214
|
-
}
|
|
215
|
-
const params = parsed.params
|
|
216
|
-
const wantsTls =
|
|
217
|
-
parsed.kind === 'grpcs'
|
|
218
|
-
|| params?.get?.('tls') === 'true'
|
|
219
|
-
|| params?.get?.('tls') === '1'
|
|
220
|
-
if (!wantsTls) return null
|
|
221
|
-
return {
|
|
222
|
-
ca: params?.get?.('ca') ?? undefined,
|
|
223
|
-
cert: params?.get?.('cert') ?? undefined,
|
|
224
|
-
key: params?.get?.('key') ?? undefined,
|
|
225
|
-
servername: params?.get?.('servername') ?? undefined,
|
|
226
|
-
rejectUnauthorized:
|
|
227
|
-
params?.get?.('rejectUnauthorized') === 'false' ? false : true,
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
216
|
function mergeAuthFromUri(parsed, optionAuth) {
|
|
232
217
|
const out = {
|
|
233
218
|
token: parsed.token ?? parsed.apiKey ?? null,
|
|
@@ -269,53 +254,6 @@ function mergeAuthFromUri(parsed, optionAuth) {
|
|
|
269
254
|
return out
|
|
270
255
|
}
|
|
271
256
|
|
|
272
|
-
/**
|
|
273
|
-
* Exchange username + password for a bearer token by hitting the
|
|
274
|
-
* server's `POST /auth/login` HTTP endpoint, then return that token
|
|
275
|
-
* for use with subsequent `connect({ auth: { token } })` calls.
|
|
276
|
-
*
|
|
277
|
-
* Why a separate function: the gRPC surface does not currently
|
|
278
|
-
* expose `auth.login` as an RPC, so the driver can't piggyback on
|
|
279
|
-
* the binary spawn for password auth. The HTTP listener does
|
|
280
|
-
* expose it, and is the canonical login site (the same endpoint
|
|
281
|
-
* the dashboard uses).
|
|
282
|
-
*
|
|
283
|
-
* @param {string} loginUrl Full URL of the server's auth endpoint
|
|
284
|
-
* (e.g. `https://reddb.example.com/auth/login`).
|
|
285
|
-
* @param {{ username: string, password: string }} credentials
|
|
286
|
-
* @returns {Promise<{ token: string, username: string, role: string, expires_at: number }>}
|
|
287
|
-
*/
|
|
288
|
-
export async function login(loginUrl, { username, password }) {
|
|
289
|
-
if (typeof loginUrl !== 'string' || !loginUrl.startsWith('http')) {
|
|
290
|
-
throw new TypeError("login() requires an http(s):// URL pointing at /auth/login")
|
|
291
|
-
}
|
|
292
|
-
if (typeof username !== 'string' || username.length === 0) {
|
|
293
|
-
throw new TypeError('login() requires a non-empty username')
|
|
294
|
-
}
|
|
295
|
-
if (typeof password !== 'string' || password.length === 0) {
|
|
296
|
-
throw new TypeError('login() requires a non-empty password')
|
|
297
|
-
}
|
|
298
|
-
const response = await fetch(loginUrl, {
|
|
299
|
-
method: 'POST',
|
|
300
|
-
headers: { 'content-type': 'application/json' },
|
|
301
|
-
body: JSON.stringify({ username, password }),
|
|
302
|
-
})
|
|
303
|
-
const body = await response.json().catch(() => ({}))
|
|
304
|
-
if (!response.ok || body.ok === false) {
|
|
305
|
-
const code = body.error_code || `HTTP_${response.status}`
|
|
306
|
-
const message = body.error || `auth/login returned ${response.status}`
|
|
307
|
-
throw new RedDBError(code, message, body)
|
|
308
|
-
}
|
|
309
|
-
if (typeof body.token !== 'string') {
|
|
310
|
-
throw new RedDBError(
|
|
311
|
-
'AUTH_LOGIN_BAD_RESPONSE',
|
|
312
|
-
'auth/login response missing string token',
|
|
313
|
-
body,
|
|
314
|
-
)
|
|
315
|
-
}
|
|
316
|
-
return body
|
|
317
|
-
}
|
|
318
|
-
|
|
319
257
|
/**
|
|
320
258
|
* Backwards-compatible shim: translate a URI into argv for
|
|
321
259
|
* `red rpc --stdio`. New code should call `parseUri` directly and
|
|
@@ -325,14 +263,12 @@ export async function login(loginUrl, { username, password }) {
|
|
|
325
263
|
export function uriToArgs(uri, auth = null) {
|
|
326
264
|
const parsed = parseUri(uri)
|
|
327
265
|
if (parsed.kind === 'embedded') return embeddedArgs(parsed)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
`uriToArgs() supports embedded + grpc kinds; for '${parsed.kind}' use connect() directly.`,
|
|
335
|
-
)
|
|
266
|
+
rejectRemoteUri(parsed)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function rejectRemoteUri(parsed) {
|
|
270
|
+
if (parsed.kind === 'embedded') return
|
|
271
|
+
throw new RedDBError('EMBEDDED_ONLY', EMBEDDED_ONLY_MESSAGE)
|
|
336
272
|
}
|
|
337
273
|
|
|
338
274
|
|
|
@@ -340,10 +276,18 @@ export function uriToArgs(uri, auth = null) {
|
|
|
340
276
|
* Connection handle. Methods map 1:1 to JSON-RPC methods on the binary.
|
|
341
277
|
*/
|
|
342
278
|
export class RedDB {
|
|
343
|
-
/**
|
|
344
|
-
|
|
279
|
+
/**
|
|
280
|
+
* @param {RpcClient} client
|
|
281
|
+
* @param {object} [opts]
|
|
282
|
+
* @param {string} [opts.transport] Underlying transport label
|
|
283
|
+
* (normally 'embedded'). Used to gate calls that the embedded
|
|
284
|
+
* stdio bridge does not serve, like `cache.*`.
|
|
285
|
+
*/
|
|
286
|
+
constructor(client, opts = {}) {
|
|
345
287
|
this.client = client
|
|
346
|
-
this.
|
|
288
|
+
this.transport = opts.transport ?? null
|
|
289
|
+
this.cache = new CacheClient(client, this.transport)
|
|
290
|
+
this.queue = new QueueClient(client)
|
|
347
291
|
const defaultKv = new KvClient(client)
|
|
348
292
|
this.kv = Object.assign((collection = 'kv_default') => new KvClient(client, collection), {
|
|
349
293
|
put: defaultKv.put.bind(defaultKv),
|
|
@@ -360,32 +304,49 @@ export class RedDB {
|
|
|
360
304
|
*
|
|
361
305
|
* Two signatures:
|
|
362
306
|
* - `query(sql)` — legacy single-arg form.
|
|
363
|
-
* - `query(sql, params)` — positional `$N` bind values.
|
|
364
|
-
*
|
|
365
|
-
* int/float / text / null). Indices in the SQL are 1-based
|
|
366
|
-
* (`$1`, `$2`, ...), `params` is 0-based JS-style.
|
|
307
|
+
* - `query(sql, ...params)` — positional `$N` bind values.
|
|
308
|
+
* - `query(sql, paramsArray)` — legacy array form.
|
|
367
309
|
*
|
|
368
310
|
* Returns `{ statement, affected, columns, rows }`.
|
|
369
311
|
*/
|
|
370
|
-
query(sql, params) {
|
|
371
|
-
|
|
372
|
-
|
|
312
|
+
query(sql, ...params) {
|
|
313
|
+
const wireParams = normalizeQueryParams(params)
|
|
314
|
+
if (wireParams == null) {
|
|
315
|
+
return this.client.call('query', { sql }).then(normalizeResult)
|
|
373
316
|
}
|
|
374
|
-
|
|
375
|
-
throw new TypeError('query: `params` must be an array')
|
|
376
|
-
}
|
|
377
|
-
const wireParams = params.map(serializeParam)
|
|
378
|
-
return this.client.call('query', { sql, params: wireParams })
|
|
317
|
+
return this.client.call('query', { sql, params: wireParams }).then(normalizeResult)
|
|
379
318
|
}
|
|
380
319
|
|
|
381
|
-
/**
|
|
382
|
-
|
|
383
|
-
return this.
|
|
320
|
+
/** Execute a SQL statement. Alias for `query`, including parameter binding. */
|
|
321
|
+
execute(sql, ...params) {
|
|
322
|
+
return this.query(sql, ...params)
|
|
384
323
|
}
|
|
385
324
|
|
|
386
|
-
/** Insert
|
|
387
|
-
|
|
388
|
-
|
|
325
|
+
/** Insert one row. Returns `{ affected, id }`. */
|
|
326
|
+
async insert(collection, payload) {
|
|
327
|
+
const result = await this.client.call('insert', { collection, payload })
|
|
328
|
+
return requireInsertId(result, 'insert')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Insert many rows in one call. Returns `{ affected, ids }`. */
|
|
332
|
+
async bulkInsert(collection, payloads) {
|
|
333
|
+
const result = await this.client.call('bulk_insert', { collection, payloads })
|
|
334
|
+
return requireInsertIds(result, payloads.length)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Return true when a collection is visible in the catalog. */
|
|
338
|
+
exists(collection) {
|
|
339
|
+
return collectionExists(this, collection)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** List visible collections using SHOW COLLECTIONS. */
|
|
343
|
+
list() {
|
|
344
|
+
return listCollections(this)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Return a caller-typed query builder for a collection. */
|
|
348
|
+
from(collection) {
|
|
349
|
+
return new TypedQueryBuilder(this, collection)
|
|
389
350
|
}
|
|
390
351
|
|
|
391
352
|
/** Get an entity by id. Returns `{ entity }` (entity is `null` if not found). */
|
|
@@ -409,18 +370,14 @@ export class RedDB {
|
|
|
409
370
|
}
|
|
410
371
|
|
|
411
372
|
// ---------------------------------------------------------------
|
|
412
|
-
// Auth surface — these are
|
|
373
|
+
// Auth surface — these are not available in embedded mode because the
|
|
413
374
|
// bridge layer doesn't expose `auth.*` JSON-RPC methods locally.
|
|
414
|
-
//
|
|
375
|
+
// Use @reddb-io/client for remote authenticated servers.
|
|
415
376
|
// ---------------------------------------------------------------
|
|
416
377
|
|
|
417
378
|
/**
|
|
418
|
-
* Exchange username + password for a bearer token
|
|
419
|
-
*
|
|
420
|
-
* routes to `POST /auth/login`.
|
|
421
|
-
*
|
|
422
|
-
* Prefer the `auth: { username, password }` form on `connect()`
|
|
423
|
-
* — it does the same exchange + caches the token transparently.
|
|
379
|
+
* Exchange username + password for a bearer token when the underlying
|
|
380
|
+
* client supports auth RPCs. Embedded SDK connections do not.
|
|
424
381
|
*/
|
|
425
382
|
login(username, password) {
|
|
426
383
|
return this.client.call('auth.login', { username, password })
|
|
@@ -459,3 +416,29 @@ export class RedDB {
|
|
|
459
416
|
return this.client.close()
|
|
460
417
|
}
|
|
461
418
|
}
|
|
419
|
+
|
|
420
|
+
function requireInsertId(result, method) {
|
|
421
|
+
if (!result || typeof result !== 'object' || result.id == null) {
|
|
422
|
+
throw new RedDBError(
|
|
423
|
+
'ENGINE_TOO_OLD',
|
|
424
|
+
`${method}() requires RedDB engine >= ${MIN_INSERT_ID_ENGINE_VERSION} with insert id support`,
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
return result
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function requireInsertIds(result, expected) {
|
|
431
|
+
if (!result || typeof result !== 'object' || !Array.isArray(result.ids)) {
|
|
432
|
+
throw new RedDBError(
|
|
433
|
+
'ENGINE_TOO_OLD',
|
|
434
|
+
`bulkInsert() requires RedDB engine >= ${MIN_INSERT_ID_ENGINE_VERSION} with bulk insert id support`,
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
if (result.ids.length !== expected) {
|
|
438
|
+
throw new RedDBError(
|
|
439
|
+
'INVALID_RESPONSE',
|
|
440
|
+
`bulkInsert() expected ${expected} ids, got ${result.ids.length}`,
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
return result
|
|
444
|
+
}
|
package/drivers/js/src/kv.js
CHANGED
|
@@ -17,6 +17,20 @@ export class KvClient {
|
|
|
17
17
|
})
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
async get(key, options = {}) {
|
|
21
|
+
const collection = options.collection ?? this.collection
|
|
22
|
+
const result = await this.client.call('query', {
|
|
23
|
+
sql: `KV GET ${kvPath(collection, key)}`,
|
|
24
|
+
})
|
|
25
|
+
return result?.rows?.[0]?.value ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async getMany(keys, options = {}) {
|
|
29
|
+
const values = []
|
|
30
|
+
for (const key of keys) values.push(await this.get(key, options))
|
|
31
|
+
return values
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
async invalidateTags(tags, options = {}) {
|
|
21
35
|
const collection = options.collection ?? this.collection
|
|
22
36
|
const result = await this.client.call('query', {
|
|
@@ -56,7 +70,15 @@ function kvPath(collection, key) {
|
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
function kvIdentifier(value) {
|
|
59
|
-
|
|
73
|
+
const ident = String(value)
|
|
74
|
+
const invalid = ident.match(/[^A-Za-z0-9_]/)
|
|
75
|
+
if (invalid) {
|
|
76
|
+
throw new RedDBError(
|
|
77
|
+
'INVALID_KV_KEY',
|
|
78
|
+
`invalid KV key "${ident}": character "${invalid[0]}" is not supported`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
return ident
|
|
60
82
|
}
|
|
61
83
|
|
|
62
84
|
function kvValueLiteral(value) {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { RedDBError } from './protocol.js'
|
|
2
|
+
|
|
3
|
+
export class QueueClient {
|
|
4
|
+
constructor(client) {
|
|
5
|
+
this.client = client
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
push(queue, value, options = {}) {
|
|
9
|
+
const priority = options.priority != null ? ` PRIORITY ${queuePriority(options.priority)}` : ''
|
|
10
|
+
return this.client.call('query', {
|
|
11
|
+
sql: `QUEUE PUSH ${queueIdentifier(queue)} ${queueValueLiteral(value)}${priority}`,
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async pop(queue, count) {
|
|
16
|
+
const result = await this.client.call('query', {
|
|
17
|
+
sql: `QUEUE POP ${queueIdentifier(queue)}${queueCount(count)}`,
|
|
18
|
+
})
|
|
19
|
+
return queuePayloads(result)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async peek(queue, count) {
|
|
23
|
+
const result = await this.client.call('query', {
|
|
24
|
+
sql: `QUEUE PEEK ${queueIdentifier(queue)}${queueCount(count)}`,
|
|
25
|
+
})
|
|
26
|
+
return queuePayloads(result)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async len(queue) {
|
|
30
|
+
const result = await this.client.call('query', {
|
|
31
|
+
sql: `QUEUE LEN ${queueIdentifier(queue)}`,
|
|
32
|
+
})
|
|
33
|
+
return Number(result?.rows?.[0]?.len ?? 0)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
purge(queue) {
|
|
37
|
+
return this.client.call('query', {
|
|
38
|
+
sql: `QUEUE PURGE ${queueIdentifier(queue)}`,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function queueIdentifier(value) {
|
|
44
|
+
const ident = String(value)
|
|
45
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ident)) {
|
|
46
|
+
throw new RedDBError(
|
|
47
|
+
'INVALID_QUEUE_NAME',
|
|
48
|
+
`invalid queue name "${ident}": expected an SQL identifier`,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
return ident
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function queueCount(count) {
|
|
55
|
+
if (count == null) return ''
|
|
56
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
57
|
+
throw new RedDBError('INVALID_QUEUE_COUNT', 'queue count must be a non-negative integer')
|
|
58
|
+
}
|
|
59
|
+
return ` COUNT ${count}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function queuePriority(priority) {
|
|
63
|
+
if (!Number.isInteger(priority)) {
|
|
64
|
+
throw new RedDBError('INVALID_QUEUE_PRIORITY', 'queue priority must be an integer')
|
|
65
|
+
}
|
|
66
|
+
return String(priority)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function queueValueLiteral(value) {
|
|
70
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
71
|
+
if (value == null) return 'NULL'
|
|
72
|
+
if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`
|
|
73
|
+
return JSON.stringify(value)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function queuePayloads(result) {
|
|
77
|
+
return Array.isArray(result?.rows) ? result.rows.map((row) => row.payload) : []
|
|
78
|
+
}
|
|
@@ -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,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') {
|