@reddb-io/client 1.9.1 → 1.10.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/index.browser.d.ts +7 -6
- package/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/core/url.js +37 -1
- package/src/index.browser.js +23 -0
- package/src/redwire-core.js +1144 -0
- package/src/redwire-ws.js +169 -0
- package/src/redwire.js +62 -1128
package/src/redwire.js
CHANGED
|
@@ -1,769 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* RedWire client for Node / Bun / Deno
|
|
2
|
+
* RedWire client for Node / Bun / Deno — the node-bound shim over the
|
|
3
|
+
* transport-agnostic codec.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* The protocol itself (frame codec, `stream_id` multiplex, handshake,
|
|
6
|
+
* `RedWireClient`) lives in `./redwire-core.js`, which imports **no**
|
|
7
|
+
* `node:` built-in so the exact same code rides into the browser bundle
|
|
8
|
+
* (`./redwire-ws.js`, #937, ADR 0036). This module adds only what needs
|
|
9
|
+
* Node: the TCP / TLS socket openers and the `node:zlib` zstd provider,
|
|
10
|
+
* then exposes the historical `connectRedwire({ host, port, ... })`
|
|
11
|
+
* surface the Node entry and tools depend on.
|
|
8
12
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* - `RedWireClient.query(sql)` → JSON envelope
|
|
12
|
-
* - `RedWireClient.ping()`
|
|
13
|
-
* - `RedWireClient.close()`
|
|
14
|
-
*
|
|
15
|
-
* Auth methods this cut supports: `anonymous`, `bearer`. SCRAM /
|
|
16
|
-
* mTLS / OAuth land in subsequent PRs.
|
|
13
|
+
* Auth methods this cut supports: `anonymous`, `bearer`. SCRAM / mTLS /
|
|
14
|
+
* OAuth land in subsequent PRs.
|
|
17
15
|
*/
|
|
18
16
|
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
QueryWithParams: 0x28,
|
|
49
|
-
// Output/input streaming lifecycle (PRD #759). Mirrors
|
|
50
|
-
// `reddb_wire::redwire::frame::MessageKind` so the JS streaming surface
|
|
51
|
-
// talks the same multiplexed-stream vocabulary as the Rust server.
|
|
52
|
-
RowDescription: 0x24,
|
|
53
|
-
StreamEnd: 0x25,
|
|
54
|
-
OpenStream: 0x29,
|
|
55
|
-
OpenAck: 0x2A,
|
|
56
|
-
StreamChunk: 0x2B,
|
|
57
|
-
StreamError: 0x2C,
|
|
58
|
-
StreamCancel: 0x2D,
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
export const Features = Object.freeze({ PARAMS: 0x0000_0001 })
|
|
62
|
-
|
|
63
|
-
export const ValueTag = Object.freeze({
|
|
64
|
-
Null: 0x00, Bool: 0x01, Int: 0x02, Float: 0x03, Text: 0x04,
|
|
65
|
-
Bytes: 0x05, Vector: 0x06, Json: 0x07, Timestamp: 0x08, Uuid: 0x09,
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Typed value tags for the binary fast path. Identical to the
|
|
70
|
-
* engine-side `wire::protocol::VAL_*` table.
|
|
71
|
-
*/
|
|
72
|
-
export const BinaryTag = Object.freeze({
|
|
73
|
-
Null: 0,
|
|
74
|
-
I64: 1,
|
|
75
|
-
F64: 2,
|
|
76
|
-
Text: 3,
|
|
77
|
-
Bool: 4,
|
|
78
|
-
U64: 5,
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
const KIND_NAME = Object.fromEntries(
|
|
82
|
-
Object.entries(MessageKind).map(([k, v]) => [v, k]),
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
export const Flags = Object.freeze({
|
|
86
|
-
COMPRESSED: 0b00000001,
|
|
87
|
-
MORE_FRAMES: 0b00000010,
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
/** zstd level for outbound compressed frames. Override via env. */
|
|
91
|
-
const ZSTD_LEVEL = (() => {
|
|
92
|
-
const env = typeof process !== 'undefined' ? process.env?.RED_REDWIRE_ZSTD_LEVEL : null
|
|
93
|
-
const n = env ? Number(env) : NaN
|
|
94
|
-
return Number.isFinite(n) && n >= 1 && n <= 22 ? n : 1
|
|
95
|
-
})()
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Compress / decompress shim. Tries Node 22+'s native zstd
|
|
99
|
-
* bindings first; if unavailable returns null and the codec
|
|
100
|
-
* falls back to plaintext (operators still see the
|
|
101
|
-
* COMPRESSED flag bit when peers offer it, but encode is a
|
|
102
|
-
* no-op until the runtime ships native zstd).
|
|
103
|
-
*/
|
|
104
|
-
let _zstdMod = undefined
|
|
105
|
-
async function zstdMod() {
|
|
106
|
-
if (_zstdMod !== undefined) return _zstdMod
|
|
17
|
+
import {
|
|
18
|
+
connectRedwireOverSocket,
|
|
19
|
+
setZstdProvider,
|
|
20
|
+
} from './redwire-core.js'
|
|
21
|
+
|
|
22
|
+
// Re-export the codec surface so existing `./redwire.js` importers
|
|
23
|
+
// (index.js, the redwire-result test, JSDoc type refs) keep resolving.
|
|
24
|
+
export {
|
|
25
|
+
MessageKind,
|
|
26
|
+
Features,
|
|
27
|
+
ValueTag,
|
|
28
|
+
BinaryTag,
|
|
29
|
+
Flags,
|
|
30
|
+
RedWireClient,
|
|
31
|
+
decodeResultPayload,
|
|
32
|
+
encodeQueryWithParams,
|
|
33
|
+
encodeValue,
|
|
34
|
+
connectRedwireOverSocket,
|
|
35
|
+
setZstdProvider,
|
|
36
|
+
} from './redwire-core.js'
|
|
37
|
+
|
|
38
|
+
// Resolve the native zstd binding once per process and install it into
|
|
39
|
+
// the codec. Node 22+ ships `zstdCompressSync`; Deno / older Node lack it
|
|
40
|
+
// and the codec stays plaintext (it still honours the COMPRESSED flag bit
|
|
41
|
+
// peers offer). Cached so repeated connects don't re-import.
|
|
42
|
+
let _zstdLoaded = false
|
|
43
|
+
async function ensureZstd() {
|
|
44
|
+
if (_zstdLoaded) return
|
|
45
|
+
_zstdLoaded = true
|
|
107
46
|
try {
|
|
108
47
|
const zlib = await import('node:zlib')
|
|
109
48
|
if (typeof zlib.zstdCompressSync === 'function') {
|
|
110
|
-
|
|
111
|
-
return _zstdMod
|
|
49
|
+
setZstdProvider(zlib)
|
|
112
50
|
}
|
|
113
51
|
} catch {
|
|
114
52
|
// node:zlib missing — Deno (no-zstd) or restricted runtime.
|
|
115
53
|
}
|
|
116
|
-
_zstdMod = null
|
|
117
|
-
return null
|
|
118
54
|
}
|
|
119
55
|
|
|
120
56
|
/**
|
|
121
|
-
* Open a RedWire connection.
|
|
57
|
+
* Open a RedWire connection over a TCP / TLS / Unix socket.
|
|
122
58
|
*
|
|
123
59
|
* @param {object} opts
|
|
124
60
|
* @param {string} opts.host
|
|
125
61
|
* @param {number} opts.port
|
|
126
62
|
* @param {{ kind: 'anonymous' } | { kind: 'bearer', token: string }} [opts.auth]
|
|
127
63
|
* @param {string} [opts.clientName]
|
|
64
|
+
* @param {object} [opts.socket] Pre-connected duplex to use as-is. When
|
|
65
|
+
* set, `host`/`port`/`tls` are ignored — the byte transport is supplied
|
|
66
|
+
* by the caller (the browser WS transport rides this seam).
|
|
128
67
|
* @param {object} [opts.tls] When set, wraps the socket in TLS.
|
|
129
68
|
* @param {string|Buffer} [opts.tls.ca] Trusted CA bundle (PEM).
|
|
130
69
|
* @param {string|Buffer} [opts.tls.cert] Client cert for mTLS (PEM).
|
|
131
70
|
* @param {string|Buffer} [opts.tls.key] Client key for mTLS (PEM).
|
|
132
71
|
* @param {boolean} [opts.tls.rejectUnauthorized=true] Verify server cert.
|
|
133
72
|
* @param {string} [opts.tls.servername] SNI override.
|
|
134
|
-
* @returns {Promise<RedWireClient>}
|
|
73
|
+
* @returns {Promise<import('./redwire-core.js').RedWireClient>}
|
|
135
74
|
*/
|
|
136
75
|
export async function connectRedwire(opts) {
|
|
137
|
-
|
|
138
|
-
if (typeof host !== 'string' || host.length === 0) {
|
|
139
|
-
throw new TypeError('connectRedwire: host required')
|
|
140
|
-
}
|
|
141
|
-
if (typeof port !== 'number' || port <= 0 || port > 0xffff) {
|
|
142
|
-
throw new TypeError('connectRedwire: port required (1-65535)')
|
|
143
|
-
}
|
|
144
|
-
const auth = opts.auth ?? { kind: 'anonymous' }
|
|
145
|
-
// Resolve the zstd shim once per process — this populates the
|
|
146
|
-
// shared `_zstdMod` cache so encode/decode hot paths are sync.
|
|
147
|
-
await zstdMod()
|
|
148
|
-
|
|
149
|
-
const socket = opts.tls
|
|
150
|
-
? await openTlsSocket(host, port, opts.tls)
|
|
151
|
-
: await openSocket(host, port)
|
|
152
|
-
const reader = new FrameReader(socket)
|
|
153
|
-
|
|
154
|
-
// Discriminator + minor-version byte.
|
|
155
|
-
await writeAll(socket, Uint8Array.from([MAGIC, SUPPORTED_VERSION]))
|
|
156
|
-
|
|
157
|
-
// Hello.
|
|
158
|
-
const methods = auth.kind === 'bearer' ? ['bearer'] : ['anonymous', 'bearer']
|
|
159
|
-
const helloPayload = jsonBytes({
|
|
160
|
-
versions: [SUPPORTED_VERSION],
|
|
161
|
-
auth_methods: methods,
|
|
162
|
-
features: 0,
|
|
163
|
-
client_name: opts.clientName ?? `reddb-js/0.2`,
|
|
164
|
-
})
|
|
165
|
-
await writeFrame(socket, MessageKind.Hello, 1n, helloPayload)
|
|
166
|
-
|
|
167
|
-
const ack = await reader.next()
|
|
168
|
-
if (ack.kind === MessageKind.AuthFail) {
|
|
169
|
-
socket.end()
|
|
170
|
-
const reason = jsonReason(ack.payload) ?? 'AuthFail at HelloAck'
|
|
171
|
-
throw new RedDBError('AUTH_REFUSED', `redwire: ${reason}`)
|
|
172
|
-
}
|
|
173
|
-
if (ack.kind !== MessageKind.HelloAck) {
|
|
174
|
-
socket.end()
|
|
175
|
-
throw new RedDBError(
|
|
176
|
-
'PROTOCOL',
|
|
177
|
-
`expected HelloAck, got ${KIND_NAME[ack.kind] ?? ack.kind}`,
|
|
178
|
-
)
|
|
179
|
-
}
|
|
180
|
-
const ackParsed = jsonOf(ack.payload)
|
|
181
|
-
const chosenAuth = ackParsed?.auth
|
|
182
|
-
if (typeof chosenAuth !== 'string') {
|
|
183
|
-
socket.end()
|
|
184
|
-
throw new RedDBError('PROTOCOL', 'HelloAck missing `auth` field')
|
|
185
|
-
}
|
|
76
|
+
await ensureZstd()
|
|
186
77
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (auth.kind !== 'bearer') {
|
|
193
|
-
socket.end()
|
|
194
|
-
throw new RedDBError(
|
|
195
|
-
'AUTH_REFUSED',
|
|
196
|
-
'server demanded bearer but no token was supplied',
|
|
197
|
-
)
|
|
78
|
+
let socket = opts.socket
|
|
79
|
+
if (!socket) {
|
|
80
|
+
const { host, port } = opts
|
|
81
|
+
if (typeof host !== 'string' || host.length === 0) {
|
|
82
|
+
throw new TypeError('connectRedwire: host required')
|
|
198
83
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
socket.end()
|
|
202
|
-
throw new RedDBError(
|
|
203
|
-
'PROTOCOL',
|
|
204
|
-
`server picked unsupported auth method: ${chosenAuth}`,
|
|
205
|
-
)
|
|
206
|
-
}
|
|
207
|
-
await writeFrame(socket, MessageKind.AuthResponse, 2n, respPayload)
|
|
208
|
-
|
|
209
|
-
const final = await reader.next()
|
|
210
|
-
if (final.kind === MessageKind.AuthFail) {
|
|
211
|
-
socket.end()
|
|
212
|
-
const reason = jsonReason(final.payload) ?? 'auth refused'
|
|
213
|
-
throw new RedDBError('AUTH_REFUSED', reason)
|
|
214
|
-
}
|
|
215
|
-
if (final.kind !== MessageKind.AuthOk) {
|
|
216
|
-
socket.end()
|
|
217
|
-
throw new RedDBError(
|
|
218
|
-
'PROTOCOL',
|
|
219
|
-
`expected AuthOk, got ${KIND_NAME[final.kind] ?? final.kind}`,
|
|
220
|
-
)
|
|
221
|
-
}
|
|
222
|
-
const session = jsonOf(final.payload) ?? {}
|
|
223
|
-
const features = numberOr(session.features, numberOr(ackParsed?.features, 0))
|
|
224
|
-
|
|
225
|
-
return new RedWireClient(socket, reader, session, features)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function numberOr(v, fallback) {
|
|
229
|
-
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Returned by `connectRedwire`. Methods map 1:1 to RedWire frame
|
|
234
|
-
* kinds. Reuses the same `RedDB`-shaped envelope as the other
|
|
235
|
-
* transports so the surface above this is uniform.
|
|
236
|
-
*/
|
|
237
|
-
export class RedWireClient {
|
|
238
|
-
constructor(socket, reader, session, serverFeatures = 0) {
|
|
239
|
-
this.socket = socket
|
|
240
|
-
this.reader = reader
|
|
241
|
-
this.session = session
|
|
242
|
-
this.serverFeatures = serverFeatures >>> 0
|
|
243
|
-
this.nextCorr = 1n
|
|
244
|
-
this.nextStream = 1
|
|
245
|
-
this.closed = false
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** Raw advertised server feature bitmask. */
|
|
249
|
-
features() {
|
|
250
|
-
return this.serverFeatures
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** True when server advertised `FEATURE_PARAMS` (#357). */
|
|
254
|
-
supportsParams() {
|
|
255
|
-
return (this.serverFeatures & Features.PARAMS) === Features.PARAMS
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
async call(method, params = {}) {
|
|
259
|
-
if (method === 'query') return this.#query(params.sql ?? '', params.params)
|
|
260
|
-
if (method === 'insert') return this.#insert({ collection: params.collection, payload: params.payload })
|
|
261
|
-
if (method === 'bulk_insert') return this.#insert({ collection: params.collection, payloads: params.payloads })
|
|
262
|
-
if (method === 'bulk_insert_binary') {
|
|
263
|
-
return this.bulkInsertBinary(params.collection, params.columns, params.rows)
|
|
264
|
-
}
|
|
265
|
-
if (method === 'get') return this.#getOrDelete(MessageKind.Get, MessageKind.Result, params)
|
|
266
|
-
if (method === 'delete') return this.#getOrDelete(MessageKind.Delete, MessageKind.DeleteOk, params)
|
|
267
|
-
if (method === 'health' || method === 'version') return this.#ping()
|
|
268
|
-
throw new RedDBError(
|
|
269
|
-
'UNKNOWN_METHOD',
|
|
270
|
-
`RedWire transport doesn't bridge '${method}' yet`,
|
|
271
|
-
)
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Bulk-insert via the binary fast path (frame kind 0x06).
|
|
276
|
-
* Same hot-loop perf as the engine's `MSG_BULK_INSERT_BINARY`
|
|
277
|
-
* stress tests. Each row is an array of `[tag, value]` pairs
|
|
278
|
-
* matching the column order; tag values come from `BinaryTag`.
|
|
279
|
-
*
|
|
280
|
-
* Example:
|
|
281
|
-
* client.bulkInsertBinary('users', ['name', 'age'], [
|
|
282
|
-
* [[BinaryTag.Text, 'alice'], [BinaryTag.I64, 30n]],
|
|
283
|
-
* [[BinaryTag.Text, 'bob'], [BinaryTag.I64, 25n]],
|
|
284
|
-
* ])
|
|
285
|
-
*/
|
|
286
|
-
async bulkInsertBinary(collection, columns, rows) {
|
|
287
|
-
if (!Array.isArray(columns) || !Array.isArray(rows)) {
|
|
288
|
-
throw new TypeError('bulkInsertBinary: columns and rows must be arrays')
|
|
289
|
-
}
|
|
290
|
-
const buf = encodeBinaryBulk(collection, columns, rows)
|
|
291
|
-
const corr = this.#corr()
|
|
292
|
-
await writeFrame(this.socket, MessageKind.BulkInsertBinary, corr, buf)
|
|
293
|
-
const resp = await this.reader.next()
|
|
294
|
-
if (resp.kind === MessageKind.BulkOk) {
|
|
295
|
-
// v1 BulkOk body is an 8-byte little-endian count.
|
|
296
|
-
if (resp.payload.length < 8) {
|
|
297
|
-
throw new RedDBError('PROTOCOL', 'BulkOk truncated: expected 8-byte count')
|
|
298
|
-
}
|
|
299
|
-
const view = new DataView(
|
|
300
|
-
resp.payload.buffer,
|
|
301
|
-
resp.payload.byteOffset,
|
|
302
|
-
resp.payload.byteLength,
|
|
303
|
-
)
|
|
304
|
-
return Number(view.getBigUint64(0, true))
|
|
305
|
-
}
|
|
306
|
-
if (resp.kind === MessageKind.Error) {
|
|
307
|
-
throw new RedDBError('ENGINE', new TextDecoder().decode(resp.payload))
|
|
308
|
-
}
|
|
309
|
-
throw new RedDBError(
|
|
310
|
-
'PROTOCOL',
|
|
311
|
-
`expected BulkOk/Error, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
|
|
312
|
-
)
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
async #getOrDelete(reqKind, okKind, params) {
|
|
316
|
-
const corr = this.#corr()
|
|
317
|
-
const payload = jsonBytes({ collection: params.collection, id: String(params.id) })
|
|
318
|
-
await writeFrame(this.socket, reqKind, corr, payload)
|
|
319
|
-
const resp = await this.reader.next()
|
|
320
|
-
if (resp.kind === okKind) return jsonOf(resp.payload) ?? {}
|
|
321
|
-
if (resp.kind === MessageKind.Error) {
|
|
322
|
-
throw new RedDBError('ENGINE', new TextDecoder().decode(resp.payload))
|
|
323
|
-
}
|
|
324
|
-
throw new RedDBError(
|
|
325
|
-
'PROTOCOL',
|
|
326
|
-
`expected ${KIND_NAME[okKind]}/Error, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
|
|
327
|
-
)
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
async #insert(body) {
|
|
331
|
-
const corr = this.#corr()
|
|
332
|
-
const payload = jsonBytes(body)
|
|
333
|
-
await writeFrame(this.socket, MessageKind.BulkInsert, corr, payload)
|
|
334
|
-
const resp = await this.reader.next()
|
|
335
|
-
if (resp.kind === MessageKind.BulkOk) {
|
|
336
|
-
return jsonOf(resp.payload) ?? { affected: 0 }
|
|
84
|
+
if (typeof port !== 'number' || port <= 0 || port > 0xffff) {
|
|
85
|
+
throw new TypeError('connectRedwire: port required (1-65535)')
|
|
337
86
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
throw new RedDBError(
|
|
342
|
-
'PROTOCOL',
|
|
343
|
-
`expected BulkOk/Error, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
|
|
344
|
-
)
|
|
87
|
+
socket = opts.tls
|
|
88
|
+
? await openTlsSocket(host, port, opts.tls)
|
|
89
|
+
: await openSocket(host, port)
|
|
345
90
|
}
|
|
346
91
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
let payload
|
|
352
|
-
if (hasParams) {
|
|
353
|
-
if (!this.supportsParams()) {
|
|
354
|
-
throw new RedDBError(
|
|
355
|
-
'PARAMS_UNSUPPORTED',
|
|
356
|
-
'server did not advertise FEATURE_PARAMS — upgrade the server '
|
|
357
|
-
+ 'to one that supports parameterized queries.',
|
|
358
|
-
)
|
|
359
|
-
}
|
|
360
|
-
kind = MessageKind.QueryWithParams
|
|
361
|
-
payload = encodeQueryWithParams(sql, params)
|
|
362
|
-
} else {
|
|
363
|
-
kind = isSelectQuery(sql) ? MessageKind.QueryBinary : MessageKind.Query
|
|
364
|
-
payload = new TextEncoder().encode(sql)
|
|
365
|
-
}
|
|
366
|
-
await writeFrame(this.socket, kind, corr, payload)
|
|
367
|
-
const resp = await this.reader.next()
|
|
368
|
-
if (resp.kind === MessageKind.Result) {
|
|
369
|
-
return decodeResultPayload(resp.payload)
|
|
370
|
-
}
|
|
371
|
-
if (resp.kind === MessageKind.Error) {
|
|
372
|
-
throw new RedDBError(
|
|
373
|
-
'ENGINE',
|
|
374
|
-
new TextDecoder().decode(resp.payload),
|
|
375
|
-
)
|
|
376
|
-
}
|
|
377
|
-
throw new RedDBError(
|
|
378
|
-
'PROTOCOL',
|
|
379
|
-
`expected Result/Error, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
|
|
380
|
-
)
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
async #ping() {
|
|
384
|
-
const corr = this.#corr()
|
|
385
|
-
await writeFrame(this.socket, MessageKind.Ping, corr, new Uint8Array())
|
|
386
|
-
const resp = await this.reader.next()
|
|
387
|
-
if (resp.kind !== MessageKind.Pong) {
|
|
388
|
-
throw new RedDBError(
|
|
389
|
-
'PROTOCOL',
|
|
390
|
-
`expected Pong, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
|
|
391
|
-
)
|
|
392
|
-
}
|
|
393
|
-
return { ok: true }
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Open a streaming read over RedWire. Sends `OpenStream` and returns an
|
|
398
|
-
* async iterable of typed frames (see streaming.js) plus a
|
|
399
|
-
* `cancel(reason)` that emits a `StreamCancel` for this stream_id. The
|
|
400
|
-
* `OpenAck` is consumed internally; rows arrive as `StreamChunk`s and
|
|
401
|
-
* the stream closes on `StreamEnd`. A `StreamError` rejects iteration.
|
|
402
|
-
*
|
|
403
|
-
* @param {{ sql?: string, cursor?: string }} opts
|
|
404
|
-
*/
|
|
405
|
-
async streamSelect({ sql, cursor } = {}) {
|
|
406
|
-
if (cursor != null) {
|
|
407
|
-
throw new RedDBError(
|
|
408
|
-
'STREAM_CURSOR_UNSUPPORTED',
|
|
409
|
-
'resumable cursors are only available over the HTTP transport in this release',
|
|
410
|
-
)
|
|
411
|
-
}
|
|
412
|
-
const streamId = this.#stream()
|
|
413
|
-
const corr = this.#corr()
|
|
414
|
-
await this.#writeStreamFrame(MessageKind.OpenStream, corr, jsonBytes({ sql }), streamId)
|
|
415
|
-
|
|
416
|
-
const reader = this.reader
|
|
417
|
-
const client = this
|
|
418
|
-
return {
|
|
419
|
-
async *[Symbol.asyncIterator]() {
|
|
420
|
-
for (;;) {
|
|
421
|
-
const resp = await reader.next()
|
|
422
|
-
if (resp.streamId !== 0 && resp.streamId !== streamId) {
|
|
423
|
-
continue
|
|
424
|
-
}
|
|
425
|
-
if (resp.kind === MessageKind.OpenAck) {
|
|
426
|
-
continue
|
|
427
|
-
}
|
|
428
|
-
if (resp.kind === MessageKind.StreamChunk) {
|
|
429
|
-
const chunk = jsonOf(resp.payload) ?? {}
|
|
430
|
-
const rows = Array.isArray(chunk.rows) ? chunk.rows : []
|
|
431
|
-
for (const row of rows) {
|
|
432
|
-
yield { type: 'row', value: row }
|
|
433
|
-
}
|
|
434
|
-
continue
|
|
435
|
-
}
|
|
436
|
-
if (resp.kind === MessageKind.StreamEnd) {
|
|
437
|
-
const end = jsonOf(resp.payload) ?? {}
|
|
438
|
-
yield { type: 'end', value: end.stats ?? end }
|
|
439
|
-
return
|
|
440
|
-
}
|
|
441
|
-
if (resp.kind === MessageKind.StreamError || resp.kind === MessageKind.Error) {
|
|
442
|
-
const err = jsonOf(resp.payload) ?? {}
|
|
443
|
-
throw new RedDBError(
|
|
444
|
-
err.code || 'STREAM_ERROR',
|
|
445
|
-
err.message || new TextDecoder().decode(resp.payload),
|
|
446
|
-
err,
|
|
447
|
-
)
|
|
448
|
-
}
|
|
449
|
-
throw new RedDBError(
|
|
450
|
-
'STREAM_PROTOCOL',
|
|
451
|
-
`unexpected frame in stream: ${KIND_NAME[resp.kind] ?? resp.kind}`,
|
|
452
|
-
)
|
|
453
|
-
}
|
|
454
|
-
},
|
|
455
|
-
async cancel(reason) {
|
|
456
|
-
await client.#cancelStream(streamId, reason)
|
|
457
|
-
},
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* Open a streaming write over RedWire. The `OpenStream {direction:"in"}`
|
|
463
|
-
* frame is sent on the first `write()` (so columns can be inferred from
|
|
464
|
-
* the first row); each row is shipped as a one-row `StreamChunk` and the
|
|
465
|
-
* terminal chunk closes the input phase. `close()` resolves with the
|
|
466
|
-
* server's `StreamEnd` stats.
|
|
467
|
-
*
|
|
468
|
-
* @param {{ target: string, columns?: string[] }} opts
|
|
469
|
-
*/
|
|
470
|
-
async streamInput({ target, columns } = {}) {
|
|
471
|
-
const streamId = this.#stream()
|
|
472
|
-
const corr = this.#corr()
|
|
473
|
-
const client = this
|
|
474
|
-
let opened = false
|
|
475
|
-
let seq = 0
|
|
476
|
-
let cols = Array.isArray(columns) && columns.length > 0 ? columns.slice() : null
|
|
477
|
-
|
|
478
|
-
const ensureOpen = async (row) => {
|
|
479
|
-
if (opened) return
|
|
480
|
-
if (!cols) {
|
|
481
|
-
cols = row && typeof row === 'object' ? Object.keys(row) : null
|
|
482
|
-
}
|
|
483
|
-
if (!cols || cols.length === 0) {
|
|
484
|
-
throw new RedDBError(
|
|
485
|
-
'INVALID_STREAM_COLUMNS',
|
|
486
|
-
'inputStream() needs a non-empty column set — pass { columns } or write at least one object row',
|
|
487
|
-
)
|
|
488
|
-
}
|
|
489
|
-
await client.#writeStreamFrame(
|
|
490
|
-
MessageKind.OpenStream,
|
|
491
|
-
corr,
|
|
492
|
-
jsonBytes({ direction: 'in', target, columns: cols }),
|
|
493
|
-
streamId,
|
|
494
|
-
)
|
|
495
|
-
const ack = await client.reader.next()
|
|
496
|
-
if (ack.kind === MessageKind.StreamError || ack.kind === MessageKind.Error) {
|
|
497
|
-
const err = jsonOf(ack.payload) ?? {}
|
|
498
|
-
throw new RedDBError(err.code || 'STREAM_ERROR', err.message || 'input stream refused', err)
|
|
499
|
-
}
|
|
500
|
-
if (ack.kind !== MessageKind.OpenAck) {
|
|
501
|
-
throw new RedDBError(
|
|
502
|
-
'STREAM_PROTOCOL',
|
|
503
|
-
`expected OpenAck, got ${KIND_NAME[ack.kind] ?? ack.kind}`,
|
|
504
|
-
)
|
|
505
|
-
}
|
|
506
|
-
opened = true
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
return {
|
|
510
|
-
async write(row) {
|
|
511
|
-
await ensureOpen(row)
|
|
512
|
-
await client.#writeStreamFrame(
|
|
513
|
-
MessageKind.StreamChunk,
|
|
514
|
-
corr,
|
|
515
|
-
jsonBytes({ seq: seq++, rows: [row], terminal: false }),
|
|
516
|
-
streamId,
|
|
517
|
-
)
|
|
518
|
-
},
|
|
519
|
-
async close() {
|
|
520
|
-
await ensureOpen(null)
|
|
521
|
-
await client.#writeStreamFrame(
|
|
522
|
-
MessageKind.StreamChunk,
|
|
523
|
-
corr,
|
|
524
|
-
jsonBytes({ seq: seq++, rows: [], terminal: true }),
|
|
525
|
-
streamId,
|
|
526
|
-
)
|
|
527
|
-
const end = await client.reader.next()
|
|
528
|
-
if (end.kind === MessageKind.StreamError || end.kind === MessageKind.Error) {
|
|
529
|
-
const err = jsonOf(end.payload) ?? {}
|
|
530
|
-
throw new RedDBError(err.code || 'STREAM_ERROR', err.message || 'input stream failed', err)
|
|
531
|
-
}
|
|
532
|
-
if (end.kind !== MessageKind.StreamEnd) {
|
|
533
|
-
throw new RedDBError(
|
|
534
|
-
'STREAM_PROTOCOL',
|
|
535
|
-
`expected StreamEnd, got ${KIND_NAME[end.kind] ?? end.kind}`,
|
|
536
|
-
)
|
|
537
|
-
}
|
|
538
|
-
const parsed = jsonOf(end.payload) ?? {}
|
|
539
|
-
return parsed.stats ?? parsed
|
|
540
|
-
},
|
|
541
|
-
async cancel(reason) {
|
|
542
|
-
await client.#cancelStream(streamId, reason)
|
|
543
|
-
},
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
async #cancelStream(streamId, reason) {
|
|
548
|
-
if (this.closed) return
|
|
549
|
-
const payload = typeof reason === 'string' && reason.length > 0
|
|
550
|
-
? jsonBytes({ reason })
|
|
551
|
-
: new Uint8Array()
|
|
552
|
-
try {
|
|
553
|
-
await this.#writeStreamFrame(MessageKind.StreamCancel, this.#corr(), payload, streamId)
|
|
554
|
-
} catch {
|
|
555
|
-
// best-effort — the socket may already be torn down.
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
#writeStreamFrame(kind, corr, payload, streamId) {
|
|
560
|
-
const buf = encodeFrame(kind, corr, payload, 0, streamId)
|
|
561
|
-
return writeAll(this.socket, buf)
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
async close() {
|
|
565
|
-
if (this.closed) return
|
|
566
|
-
this.closed = true
|
|
567
|
-
try {
|
|
568
|
-
const corr = this.#corr()
|
|
569
|
-
await writeFrame(this.socket, MessageKind.Bye, corr, new Uint8Array())
|
|
570
|
-
} catch {
|
|
571
|
-
// best-effort
|
|
572
|
-
}
|
|
573
|
-
this.socket.end()
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
#corr() {
|
|
577
|
-
const c = this.nextCorr
|
|
578
|
-
this.nextCorr = this.nextCorr + 1n
|
|
579
|
-
return c
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
#stream() {
|
|
583
|
-
const id = this.nextStream
|
|
584
|
-
// stream_id 0 is reserved for handshake/lifecycle frames; wrap past it.
|
|
585
|
-
this.nextStream = this.nextStream >= 0xffff ? 1 : this.nextStream + 1
|
|
586
|
-
return id
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// ---------------------------------------------------------------------------
|
|
591
|
-
// Framing helpers
|
|
592
|
-
// ---------------------------------------------------------------------------
|
|
593
|
-
|
|
594
|
-
function encodeFrame(kind, correlationId, payload, flags = 0, streamId = 0) {
|
|
595
|
-
if (!(payload instanceof Uint8Array)) {
|
|
596
|
-
payload = new Uint8Array(payload)
|
|
597
|
-
}
|
|
598
|
-
let onWire = payload
|
|
599
|
-
let outFlags = flags & KNOWN_FLAGS
|
|
600
|
-
// We compress synchronously when the flag is set AND the
|
|
601
|
-
// runtime ships native zstd. Async flag-flip happens at
|
|
602
|
-
// session level (see RedWireClient construction); per-frame
|
|
603
|
-
// call here is a fast Buffer roundtrip.
|
|
604
|
-
if (outFlags & Flags.COMPRESSED && _zstdMod && typeof _zstdMod.zstdCompressSync === 'function') {
|
|
605
|
-
try {
|
|
606
|
-
const compressed = _zstdMod.zstdCompressSync(payload, {
|
|
607
|
-
params: { [_zstdMod.constants?.ZSTD_c_compressionLevel ?? 100]: ZSTD_LEVEL },
|
|
608
|
-
})
|
|
609
|
-
onWire = compressed instanceof Uint8Array ? compressed : new Uint8Array(compressed)
|
|
610
|
-
} catch {
|
|
611
|
-
// Fallback: ship plaintext, drop the flag so the peer
|
|
612
|
-
// doesn't try to decompress.
|
|
613
|
-
outFlags &= ~Flags.COMPRESSED
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
const length = FRAME_HEADER_SIZE + onWire.length
|
|
617
|
-
if (length > MAX_FRAME_SIZE) {
|
|
618
|
-
throw new RedDBError('FRAME_TOO_LARGE', `frame ${length} > ${MAX_FRAME_SIZE}`)
|
|
619
|
-
}
|
|
620
|
-
const buf = new Uint8Array(length)
|
|
621
|
-
const view = new DataView(buf.buffer)
|
|
622
|
-
view.setUint32(0, length, true)
|
|
623
|
-
buf[4] = kind
|
|
624
|
-
buf[5] = outFlags
|
|
625
|
-
view.setUint16(6, streamId, true)
|
|
626
|
-
view.setBigUint64(8, BigInt(correlationId), true)
|
|
627
|
-
buf.set(onWire, FRAME_HEADER_SIZE)
|
|
628
|
-
return buf
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
function writeFrame(socket, kind, correlationId, payload) {
|
|
632
|
-
const buf = encodeFrame(kind, correlationId, payload)
|
|
633
|
-
return writeAll(socket, buf)
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
function decodeFrame(buf) {
|
|
637
|
-
if (buf.length < FRAME_HEADER_SIZE) return null
|
|
638
|
-
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
|
639
|
-
const length = view.getUint32(0, true)
|
|
640
|
-
if (length < FRAME_HEADER_SIZE || length > MAX_FRAME_SIZE) {
|
|
641
|
-
throw new RedDBError('FRAME_INVALID_LENGTH', `length=${length}`)
|
|
642
|
-
}
|
|
643
|
-
if (buf.length < length) return null
|
|
644
|
-
const kind = buf[4]
|
|
645
|
-
const flags = buf[5]
|
|
646
|
-
if (flags & ~KNOWN_FLAGS) {
|
|
647
|
-
throw new RedDBError('FRAME_UNKNOWN_FLAGS', `flags=0x${flags.toString(16)}`)
|
|
648
|
-
}
|
|
649
|
-
const streamId = view.getUint16(6, true)
|
|
650
|
-
const correlationId = view.getBigUint64(8, true)
|
|
651
|
-
let payload = buf.slice(FRAME_HEADER_SIZE, length)
|
|
652
|
-
if (flags & Flags.COMPRESSED) {
|
|
653
|
-
if (!_zstdMod || typeof _zstdMod.zstdDecompressSync !== 'function') {
|
|
654
|
-
throw new RedDBError(
|
|
655
|
-
'COMPRESSED_BUT_NO_ZSTD',
|
|
656
|
-
'incoming frame has COMPRESSED flag but runtime has no zstd support — upgrade Node >= 22',
|
|
657
|
-
)
|
|
658
|
-
}
|
|
659
|
-
try {
|
|
660
|
-
const plain = _zstdMod.zstdDecompressSync(payload)
|
|
661
|
-
payload = plain instanceof Uint8Array ? plain : new Uint8Array(plain)
|
|
662
|
-
} catch (err) {
|
|
663
|
-
throw new RedDBError('FRAME_DECOMPRESS_FAILED', err.message)
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
return { kind, flags, streamId, correlationId, payload, consumed: length }
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
/**
|
|
670
|
-
* Buffered frame reader — TCP delivers byte streams, frames may
|
|
671
|
-
* cross or share `data` events. Maintains a rolling accumulator
|
|
672
|
-
* and yields one frame per `next()` call.
|
|
673
|
-
*/
|
|
674
|
-
class FrameReader {
|
|
675
|
-
constructor(socket) {
|
|
676
|
-
this.chunks = []
|
|
677
|
-
this.totalLen = 0
|
|
678
|
-
this.waiters = []
|
|
679
|
-
this.error = null
|
|
680
|
-
this.eof = false
|
|
681
|
-
socket.on('data', (chunk) => {
|
|
682
|
-
// chunk is Buffer (Node) or Uint8Array (Bun/Deno)
|
|
683
|
-
const u8 = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)
|
|
684
|
-
this.chunks.push(u8)
|
|
685
|
-
this.totalLen += u8.length
|
|
686
|
-
this.#tryDeliver()
|
|
687
|
-
})
|
|
688
|
-
socket.on('error', (err) => {
|
|
689
|
-
this.error = err
|
|
690
|
-
this.#flushWaiters()
|
|
691
|
-
})
|
|
692
|
-
socket.on('end', () => {
|
|
693
|
-
this.eof = true
|
|
694
|
-
this.#flushWaiters()
|
|
695
|
-
})
|
|
696
|
-
socket.on('close', () => {
|
|
697
|
-
this.eof = true
|
|
698
|
-
this.#flushWaiters()
|
|
699
|
-
})
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
next() {
|
|
703
|
-
if (this.error) return Promise.reject(this.error)
|
|
704
|
-
return new Promise((resolve, reject) => {
|
|
705
|
-
this.waiters.push({ resolve, reject })
|
|
706
|
-
this.#tryDeliver()
|
|
707
|
-
})
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
#tryDeliver() {
|
|
711
|
-
while (this.waiters.length > 0 && this.totalLen > 0) {
|
|
712
|
-
const flat = this.#flatten()
|
|
713
|
-
let frame
|
|
714
|
-
try {
|
|
715
|
-
frame = decodeFrame(flat)
|
|
716
|
-
} catch (err) {
|
|
717
|
-
const w = this.waiters.shift()
|
|
718
|
-
w.reject(err)
|
|
719
|
-
return
|
|
720
|
-
}
|
|
721
|
-
if (frame == null) {
|
|
722
|
-
// Need more bytes — put the flattened buffer back as a
|
|
723
|
-
// single chunk so we don't keep flattening repeatedly.
|
|
724
|
-
this.chunks = [flat]
|
|
725
|
-
return
|
|
726
|
-
}
|
|
727
|
-
this.chunks = [flat.subarray(frame.consumed)]
|
|
728
|
-
this.totalLen = this.chunks[0].length
|
|
729
|
-
const w = this.waiters.shift()
|
|
730
|
-
w.resolve(frame)
|
|
731
|
-
}
|
|
732
|
-
if (this.eof && this.waiters.length > 0 && this.totalLen === 0) {
|
|
733
|
-
const err = this.error ?? new RedDBError('CONNECTION_CLOSED', 'redwire: connection closed')
|
|
734
|
-
while (this.waiters.length > 0) {
|
|
735
|
-
this.waiters.shift().reject(err)
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
#flushWaiters() {
|
|
741
|
-
if (this.waiters.length === 0) return
|
|
742
|
-
if (this.totalLen > 0) {
|
|
743
|
-
this.#tryDeliver()
|
|
744
|
-
return
|
|
745
|
-
}
|
|
746
|
-
const err = this.error ?? new RedDBError('CONNECTION_CLOSED', 'redwire: connection closed')
|
|
747
|
-
while (this.waiters.length > 0) {
|
|
748
|
-
this.waiters.shift().reject(err)
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
#flatten() {
|
|
753
|
-
if (this.chunks.length === 1) return this.chunks[0]
|
|
754
|
-
const out = new Uint8Array(this.totalLen)
|
|
755
|
-
let off = 0
|
|
756
|
-
for (const c of this.chunks) {
|
|
757
|
-
out.set(c, off)
|
|
758
|
-
off += c.length
|
|
759
|
-
}
|
|
760
|
-
this.chunks = [out]
|
|
761
|
-
return out
|
|
762
|
-
}
|
|
92
|
+
return await connectRedwireOverSocket(socket, {
|
|
93
|
+
auth: opts.auth,
|
|
94
|
+
clientName: opts.clientName,
|
|
95
|
+
})
|
|
763
96
|
}
|
|
764
97
|
|
|
765
98
|
// ---------------------------------------------------------------------------
|
|
766
|
-
// Socket helpers — node:net
|
|
99
|
+
// Socket helpers — node:net / node:tls work on Node, Bun, and Deno via shim
|
|
767
100
|
// ---------------------------------------------------------------------------
|
|
768
101
|
|
|
769
102
|
async function openSocket(host, port) {
|
|
@@ -821,402 +154,3 @@ async function openTlsSocket(host, port, tlsOpts) {
|
|
|
821
154
|
sock.once('secureConnect', onOk)
|
|
822
155
|
})
|
|
823
156
|
}
|
|
824
|
-
|
|
825
|
-
function writeAll(socket, bytes) {
|
|
826
|
-
return new Promise((resolve, reject) => {
|
|
827
|
-
socket.write(bytes, (err) => (err ? reject(err) : resolve()))
|
|
828
|
-
})
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// ---------------------------------------------------------------------------
|
|
832
|
-
// JSON helpers — handshake payloads use JSON for now (CBOR follow-up)
|
|
833
|
-
// ---------------------------------------------------------------------------
|
|
834
|
-
|
|
835
|
-
function jsonBytes(obj) {
|
|
836
|
-
return new TextEncoder().encode(JSON.stringify(obj))
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
function jsonOf(bytes) {
|
|
840
|
-
if (!bytes || bytes.length === 0) return null
|
|
841
|
-
try {
|
|
842
|
-
return JSON.parse(new TextDecoder().decode(bytes))
|
|
843
|
-
} catch {
|
|
844
|
-
return null
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function isSelectQuery(sql) {
|
|
849
|
-
return typeof sql === 'string' && /^\s*select\b/i.test(sql)
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
export function decodeResultPayload(payload) {
|
|
853
|
-
const json = jsonOf(payload)
|
|
854
|
-
if (json) return json
|
|
855
|
-
return decodeBinaryResultPayload(payload)
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
function decodeBinaryResultPayload(payload) {
|
|
859
|
-
if (!(payload instanceof Uint8Array)) {
|
|
860
|
-
payload = new Uint8Array(payload)
|
|
861
|
-
}
|
|
862
|
-
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength)
|
|
863
|
-
const dec = new TextDecoder()
|
|
864
|
-
let pos = 0
|
|
865
|
-
|
|
866
|
-
const read = (n, label) => {
|
|
867
|
-
if (pos + n > payload.length) {
|
|
868
|
-
throw new RedDBError('PROTOCOL', `Result payload truncated while reading ${label}`)
|
|
869
|
-
}
|
|
870
|
-
const start = pos
|
|
871
|
-
pos += n
|
|
872
|
-
return start
|
|
873
|
-
}
|
|
874
|
-
const readU16 = (label) => view.getUint16(read(2, label), true)
|
|
875
|
-
const readU32 = (label) => view.getUint32(read(4, label), true)
|
|
876
|
-
const readI64 = (label) => safeBigIntToJs(view.getBigInt64(read(8, label), true))
|
|
877
|
-
const readU64 = (label) => safeBigIntToJs(view.getBigUint64(read(8, label), true))
|
|
878
|
-
const readF64 = (label) => view.getFloat64(read(8, label), true)
|
|
879
|
-
const readText = (n, label) => dec.decode(payload.subarray(read(n, label), pos))
|
|
880
|
-
|
|
881
|
-
const columnCount = readU16('column count')
|
|
882
|
-
const columns = []
|
|
883
|
-
for (let i = 0; i < columnCount; i += 1) {
|
|
884
|
-
const len = readU16(`column ${i} length`)
|
|
885
|
-
columns.push(readText(len, `column ${i} name`))
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
const rowCount = readU32('row count')
|
|
889
|
-
const rows = []
|
|
890
|
-
for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
|
|
891
|
-
const row = {}
|
|
892
|
-
for (const column of columns) {
|
|
893
|
-
row[column] = readBinaryValue()
|
|
894
|
-
}
|
|
895
|
-
rows.push(row)
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
return {
|
|
899
|
-
ok: true,
|
|
900
|
-
statement: 'SELECT',
|
|
901
|
-
affected: 0,
|
|
902
|
-
columns,
|
|
903
|
-
rows,
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
function readBinaryValue() {
|
|
907
|
-
const tag = payload[read(1, 'value tag')]
|
|
908
|
-
switch (tag) {
|
|
909
|
-
case BinaryTag.Null:
|
|
910
|
-
return null
|
|
911
|
-
case BinaryTag.I64:
|
|
912
|
-
return readI64('i64 value')
|
|
913
|
-
case BinaryTag.U64:
|
|
914
|
-
return readU64('u64 value')
|
|
915
|
-
case BinaryTag.F64:
|
|
916
|
-
return readF64('f64 value')
|
|
917
|
-
case BinaryTag.Text: {
|
|
918
|
-
const len = readU32('text length')
|
|
919
|
-
return readText(len, 'text value')
|
|
920
|
-
}
|
|
921
|
-
case BinaryTag.Bool:
|
|
922
|
-
return payload[read(1, 'bool value')] !== 0
|
|
923
|
-
default:
|
|
924
|
-
throw new RedDBError('PROTOCOL', `Result payload has unknown value tag ${tag}`)
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
function safeBigIntToJs(value) {
|
|
930
|
-
if (
|
|
931
|
-
value >= BigInt(Number.MIN_SAFE_INTEGER)
|
|
932
|
-
&& value <= BigInt(Number.MAX_SAFE_INTEGER)
|
|
933
|
-
) {
|
|
934
|
-
return Number(value)
|
|
935
|
-
}
|
|
936
|
-
return value
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
/**
|
|
940
|
-
* Encode the binary bulk-insert payload body (raw, no RedWire frame
|
|
941
|
-
* header — the body is wrapped by the caller as a `BulkInsertBinary`
|
|
942
|
-
* frame).
|
|
943
|
-
* Layout: `[coll_len u16][coll_bytes][ncols u16]
|
|
944
|
-
* [(name_len u16)(name_bytes)]*ncols
|
|
945
|
-
* [nrows u32]
|
|
946
|
-
* [(tag u8)(value)]*ncols * nrows`
|
|
947
|
-
*/
|
|
948
|
-
function encodeBinaryBulk(collection, columns, rows) {
|
|
949
|
-
const enc = new TextEncoder()
|
|
950
|
-
const collBytes = enc.encode(collection)
|
|
951
|
-
// Pre-encode column names + their length prefixes.
|
|
952
|
-
const colChunks = columns.map((c) => enc.encode(c))
|
|
953
|
-
let total = 2 + collBytes.length + 2
|
|
954
|
-
for (const cb of colChunks) total += 2 + cb.length
|
|
955
|
-
total += 4
|
|
956
|
-
// Estimate row size — we'll resize if needed.
|
|
957
|
-
for (const row of rows) {
|
|
958
|
-
if (!Array.isArray(row) || row.length !== columns.length) {
|
|
959
|
-
throw new TypeError(
|
|
960
|
-
`bulkInsertBinary: each row must be an array of length ${columns.length}`,
|
|
961
|
-
)
|
|
962
|
-
}
|
|
963
|
-
for (const cell of row) {
|
|
964
|
-
total += sizeOfBinaryCell(cell)
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
const buf = new Uint8Array(total)
|
|
968
|
-
const view = new DataView(buf.buffer)
|
|
969
|
-
let pos = 0
|
|
970
|
-
view.setUint16(pos, collBytes.length, true); pos += 2
|
|
971
|
-
buf.set(collBytes, pos); pos += collBytes.length
|
|
972
|
-
view.setUint16(pos, colChunks.length, true); pos += 2
|
|
973
|
-
for (const cb of colChunks) {
|
|
974
|
-
view.setUint16(pos, cb.length, true); pos += 2
|
|
975
|
-
buf.set(cb, pos); pos += cb.length
|
|
976
|
-
}
|
|
977
|
-
view.setUint32(pos, rows.length, true); pos += 4
|
|
978
|
-
for (const row of rows) {
|
|
979
|
-
for (const cell of row) {
|
|
980
|
-
pos = writeBinaryCell(buf, view, pos, cell, enc)
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
return buf
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
function sizeOfBinaryCell(cell) {
|
|
987
|
-
if (!Array.isArray(cell) || cell.length !== 2) {
|
|
988
|
-
throw new TypeError('bulkInsertBinary cell must be [tag, value]')
|
|
989
|
-
}
|
|
990
|
-
const [tag] = cell
|
|
991
|
-
switch (tag) {
|
|
992
|
-
case 0: return 1
|
|
993
|
-
case 1: return 1 + 8
|
|
994
|
-
case 2: return 1 + 8
|
|
995
|
-
case 3: {
|
|
996
|
-
const v = cell[1]
|
|
997
|
-
const bytes = typeof v === 'string' ? new TextEncoder().encode(v).length : 0
|
|
998
|
-
return 1 + 4 + bytes
|
|
999
|
-
}
|
|
1000
|
-
case 4: return 1 + 1
|
|
1001
|
-
case 5: return 1 + 8
|
|
1002
|
-
default: throw new RedDBError('UNKNOWN_BINARY_TAG', `tag=${tag}`)
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
function writeBinaryCell(buf, view, pos, cell, enc) {
|
|
1007
|
-
const [tag, value] = cell
|
|
1008
|
-
buf[pos++] = tag
|
|
1009
|
-
switch (tag) {
|
|
1010
|
-
case 0: // Null
|
|
1011
|
-
return pos
|
|
1012
|
-
case 1: { // I64
|
|
1013
|
-
const bi = typeof value === 'bigint' ? value : BigInt(value)
|
|
1014
|
-
view.setBigInt64(pos, bi, true)
|
|
1015
|
-
return pos + 8
|
|
1016
|
-
}
|
|
1017
|
-
case 2: { // F64
|
|
1018
|
-
view.setFloat64(pos, Number(value), true)
|
|
1019
|
-
return pos + 8
|
|
1020
|
-
}
|
|
1021
|
-
case 3: { // Text
|
|
1022
|
-
const bytes = enc.encode(String(value))
|
|
1023
|
-
view.setUint32(pos, bytes.length, true); pos += 4
|
|
1024
|
-
buf.set(bytes, pos)
|
|
1025
|
-
return pos + bytes.length
|
|
1026
|
-
}
|
|
1027
|
-
case 4: { // Bool
|
|
1028
|
-
buf[pos] = value ? 1 : 0
|
|
1029
|
-
return pos + 1
|
|
1030
|
-
}
|
|
1031
|
-
case 5: { // U64
|
|
1032
|
-
const bi = typeof value === 'bigint' ? value : BigInt(value)
|
|
1033
|
-
view.setBigUint64(pos, bi, true)
|
|
1034
|
-
return pos + 8
|
|
1035
|
-
}
|
|
1036
|
-
default:
|
|
1037
|
-
throw new RedDBError('UNKNOWN_BINARY_TAG', `tag=${tag}`)
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
// ---------------------------------------------------------------------------
|
|
1042
|
-
// QueryWithParams payload codec — mirrors `reddb_wire::query_with_params`
|
|
1043
|
-
// ---------------------------------------------------------------------------
|
|
1044
|
-
|
|
1045
|
-
const MAX_VALUE_PAYLOAD_LEN = MAX_FRAME_SIZE
|
|
1046
|
-
const MAX_PARAM_COUNT = 65_536
|
|
1047
|
-
|
|
1048
|
-
/**
|
|
1049
|
-
* Encode the `QueryWithParams` payload body.
|
|
1050
|
-
* Layout: `[u32 sql_len LE][utf-8 sql][u32 param_count LE][N encoded values]`
|
|
1051
|
-
*/
|
|
1052
|
-
export function encodeQueryWithParams(sql, params) {
|
|
1053
|
-
if (typeof sql !== 'string') throw new TypeError('encodeQueryWithParams: sql must be a string')
|
|
1054
|
-
if (!Array.isArray(params)) throw new TypeError('encodeQueryWithParams: params must be an array')
|
|
1055
|
-
if (params.length > MAX_PARAM_COUNT) {
|
|
1056
|
-
throw new RedDBError('PARAM_COUNT_OVER_LIMIT', `param_count ${params.length} > ${MAX_PARAM_COUNT}`)
|
|
1057
|
-
}
|
|
1058
|
-
const sqlBytes = new TextEncoder().encode(sql)
|
|
1059
|
-
if (sqlBytes.length > MAX_VALUE_PAYLOAD_LEN) {
|
|
1060
|
-
throw new RedDBError('PAYLOAD_TOO_LARGE', `sql_len ${sqlBytes.length} > ${MAX_VALUE_PAYLOAD_LEN}`)
|
|
1061
|
-
}
|
|
1062
|
-
const valueBlobs = params.map(encodeValue)
|
|
1063
|
-
let total = 4 + sqlBytes.length + 4
|
|
1064
|
-
for (const vb of valueBlobs) total += vb.length
|
|
1065
|
-
const buf = new Uint8Array(total)
|
|
1066
|
-
const view = new DataView(buf.buffer)
|
|
1067
|
-
let pos = 0
|
|
1068
|
-
view.setUint32(pos, sqlBytes.length, true); pos += 4
|
|
1069
|
-
buf.set(sqlBytes, pos); pos += sqlBytes.length
|
|
1070
|
-
view.setUint32(pos, valueBlobs.length, true); pos += 4
|
|
1071
|
-
for (const vb of valueBlobs) { buf.set(vb, pos); pos += vb.length }
|
|
1072
|
-
return buf
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
/**
|
|
1076
|
-
* Encode a single wire `Value`. Mirrors `reddb_wire::value::encode`.
|
|
1077
|
-
*
|
|
1078
|
-
* Accepts native JS values + the JSON envelopes produced by
|
|
1079
|
-
* `serializeParam` so the SDK can pass through a single shape:
|
|
1080
|
-
* - `null` / `undefined` → Null
|
|
1081
|
-
* - `boolean` → Bool
|
|
1082
|
-
* - `bigint` → Int (i64)
|
|
1083
|
-
* - `number` integer (safe range) → Int; otherwise → Float
|
|
1084
|
-
* - `string` → Text
|
|
1085
|
-
* - `Uint8Array` / `Buffer` → Bytes
|
|
1086
|
-
* - `Float32Array` / `Array<number>` → Vector (f32)
|
|
1087
|
-
* - `{ $bytes: <base64> }` → Bytes
|
|
1088
|
-
* - `{ $ts: <unix-seconds> }` → Timestamp
|
|
1089
|
-
* - `{ $uuid: <hyphenated> }` → Uuid
|
|
1090
|
-
* - other plain object/array → Json (canonical bytes)
|
|
1091
|
-
*/
|
|
1092
|
-
export function encodeValue(v) {
|
|
1093
|
-
if (v === null || v === undefined) return Uint8Array.of(ValueTag.Null)
|
|
1094
|
-
if (typeof v === 'boolean') return Uint8Array.of(ValueTag.Bool, v ? 1 : 0)
|
|
1095
|
-
if (typeof v === 'bigint') {
|
|
1096
|
-
const out = new Uint8Array(1 + 8)
|
|
1097
|
-
out[0] = ValueTag.Int
|
|
1098
|
-
new DataView(out.buffer).setBigInt64(1, v, true)
|
|
1099
|
-
return out
|
|
1100
|
-
}
|
|
1101
|
-
if (typeof v === 'number') {
|
|
1102
|
-
if (Number.isInteger(v) && v >= -(2 ** 53) && v <= 2 ** 53) {
|
|
1103
|
-
const out = new Uint8Array(1 + 8)
|
|
1104
|
-
out[0] = ValueTag.Int
|
|
1105
|
-
new DataView(out.buffer).setBigInt64(1, BigInt(v), true)
|
|
1106
|
-
return out
|
|
1107
|
-
}
|
|
1108
|
-
const out = new Uint8Array(1 + 8)
|
|
1109
|
-
out[0] = ValueTag.Float
|
|
1110
|
-
new DataView(out.buffer).setFloat64(1, v, true)
|
|
1111
|
-
return out
|
|
1112
|
-
}
|
|
1113
|
-
if (typeof v === 'string') return encodeLenPrefixed(ValueTag.Text, new TextEncoder().encode(v))
|
|
1114
|
-
if (v instanceof Uint8Array) return encodeLenPrefixed(ValueTag.Bytes, v)
|
|
1115
|
-
if (typeof Buffer !== 'undefined' && v instanceof Buffer) {
|
|
1116
|
-
return encodeLenPrefixed(ValueTag.Bytes, new Uint8Array(v.buffer, v.byteOffset, v.byteLength))
|
|
1117
|
-
}
|
|
1118
|
-
if (v instanceof Float32Array) return encodeVector(v)
|
|
1119
|
-
if (v instanceof Float64Array) return encodeVector(Float32Array.from(v))
|
|
1120
|
-
if (Array.isArray(v) && v.every((x) => typeof x === 'number')) {
|
|
1121
|
-
return encodeVector(Float32Array.from(v))
|
|
1122
|
-
}
|
|
1123
|
-
if (typeof v === 'object') {
|
|
1124
|
-
const keys = Object.keys(v)
|
|
1125
|
-
if (keys.length === 1) {
|
|
1126
|
-
const k = keys[0]
|
|
1127
|
-
if (k === '$bytes' && typeof v.$bytes === 'string') {
|
|
1128
|
-
return encodeLenPrefixed(ValueTag.Bytes, base64ToBytes(v.$bytes))
|
|
1129
|
-
}
|
|
1130
|
-
if (k === '$ts' && (
|
|
1131
|
-
(typeof v.$ts === 'number' && Number.isFinite(v.$ts))
|
|
1132
|
-
|| typeof v.$ts === 'string'
|
|
1133
|
-
)) {
|
|
1134
|
-
const out = new Uint8Array(1 + 8)
|
|
1135
|
-
out[0] = ValueTag.Timestamp
|
|
1136
|
-
const raw = typeof v.$ts === 'string' ? BigInt(v.$ts) : BigInt(Math.trunc(v.$ts))
|
|
1137
|
-
new DataView(out.buffer).setBigInt64(1, raw, true)
|
|
1138
|
-
return out
|
|
1139
|
-
}
|
|
1140
|
-
if (k === '$uuid' && typeof v.$uuid === 'string') {
|
|
1141
|
-
const bytes = parseUuidHyphenated(v.$uuid)
|
|
1142
|
-
const out = new Uint8Array(1 + 16)
|
|
1143
|
-
out[0] = ValueTag.Uuid
|
|
1144
|
-
out.set(bytes, 1)
|
|
1145
|
-
return out
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
return encodeLenPrefixed(ValueTag.Json, new TextEncoder().encode(canonicalJson(v)))
|
|
1149
|
-
}
|
|
1150
|
-
throw new RedDBError('UNSUPPORTED_PARAM', `cannot encode param of type ${typeof v}`)
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
function encodeLenPrefixed(tag, bytes) {
|
|
1154
|
-
if (bytes.length > MAX_VALUE_PAYLOAD_LEN) {
|
|
1155
|
-
throw new RedDBError('PAYLOAD_TOO_LARGE', `value len ${bytes.length} > ${MAX_VALUE_PAYLOAD_LEN}`)
|
|
1156
|
-
}
|
|
1157
|
-
const out = new Uint8Array(1 + 4 + bytes.length)
|
|
1158
|
-
out[0] = tag
|
|
1159
|
-
new DataView(out.buffer).setUint32(1, bytes.length, true)
|
|
1160
|
-
out.set(bytes, 5)
|
|
1161
|
-
return out
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
function encodeVector(f32) {
|
|
1165
|
-
if (f32.length * 4 > MAX_VALUE_PAYLOAD_LEN) {
|
|
1166
|
-
throw new RedDBError('PAYLOAD_TOO_LARGE', `vector bytes ${f32.length * 4} > ${MAX_VALUE_PAYLOAD_LEN}`)
|
|
1167
|
-
}
|
|
1168
|
-
const out = new Uint8Array(1 + 4 + f32.length * 4)
|
|
1169
|
-
out[0] = ValueTag.Vector
|
|
1170
|
-
const view = new DataView(out.buffer)
|
|
1171
|
-
view.setUint32(1, f32.length, true)
|
|
1172
|
-
for (let i = 0; i < f32.length; i++) {
|
|
1173
|
-
view.setFloat32(5 + i * 4, f32[i], true)
|
|
1174
|
-
}
|
|
1175
|
-
return out
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
function base64ToBytes(s) {
|
|
1179
|
-
if (typeof Buffer !== 'undefined') {
|
|
1180
|
-
const b = Buffer.from(s, 'base64')
|
|
1181
|
-
return new Uint8Array(b.buffer, b.byteOffset, b.byteLength)
|
|
1182
|
-
}
|
|
1183
|
-
// eslint-disable-next-line no-undef
|
|
1184
|
-
const bin = atob(s)
|
|
1185
|
-
const out = new Uint8Array(bin.length)
|
|
1186
|
-
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
1187
|
-
return out
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
function parseUuidHyphenated(s) {
|
|
1191
|
-
const hex = s.replace(/-/g, '')
|
|
1192
|
-
if (hex.length !== 32 || /[^0-9a-fA-F]/.test(hex)) {
|
|
1193
|
-
throw new RedDBError('UUID_INVALID', `bad uuid: ${s}`)
|
|
1194
|
-
}
|
|
1195
|
-
const out = new Uint8Array(16)
|
|
1196
|
-
for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
|
|
1197
|
-
return out
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
/** Stable JSON serialization with sorted keys — matches the server's
|
|
1201
|
-
* canonical `crate::json` output so round-tripped Json values compare
|
|
1202
|
-
* byte-equal. */
|
|
1203
|
-
function canonicalJson(v) {
|
|
1204
|
-
if (v === null) return 'null'
|
|
1205
|
-
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'null'
|
|
1206
|
-
if (typeof v === 'string') return JSON.stringify(v)
|
|
1207
|
-
if (typeof v === 'boolean') return v ? 'true' : 'false'
|
|
1208
|
-
if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']'
|
|
1209
|
-
if (typeof v === 'object') {
|
|
1210
|
-
const keys = Object.keys(v).sort()
|
|
1211
|
-
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(v[k])).join(',') + '}'
|
|
1212
|
-
}
|
|
1213
|
-
return 'null'
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
function jsonReason(bytes) {
|
|
1217
|
-
const v = jsonOf(bytes)
|
|
1218
|
-
if (v && typeof v === 'object' && typeof v.reason === 'string') {
|
|
1219
|
-
return v.reason
|
|
1220
|
-
}
|
|
1221
|
-
return null
|
|
1222
|
-
}
|