@reddb-io/client 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ import { request as httpsRequest } from 'node:https'
2
+ import { request as httpRequest } from 'node:http'
3
+
4
+ const MAX_REDIRECTS = 5
5
+
6
+ export class AssetNotFoundError extends Error {
7
+ constructor(url) {
8
+ super(`asset not found (HTTP 404) at ${url}`)
9
+ this.name = 'AssetNotFoundError'
10
+ this.code = 'ASSET_NOT_FOUND'
11
+ this.url = url
12
+ }
13
+ }
14
+
15
+ export class HttpError extends Error {
16
+ constructor(status, url) {
17
+ super(`HTTP ${status} fetching ${url}`)
18
+ this.name = 'HttpError'
19
+ this.code = 'HTTP_ERROR'
20
+ this.status = status
21
+ this.url = url
22
+ }
23
+ }
24
+
25
+ export class TooManyRedirectsError extends Error {
26
+ constructor(url) {
27
+ super(`too many redirects (>${MAX_REDIRECTS}) starting at ${url}`)
28
+ this.name = 'TooManyRedirectsError'
29
+ this.code = 'TOO_MANY_REDIRECTS'
30
+ this.url = url
31
+ }
32
+ }
33
+
34
+ function pickRequest(url) {
35
+ return url.startsWith('http://') ? httpRequest : httpsRequest
36
+ }
37
+
38
+ function resolveLocation(currentUrl, location) {
39
+ if (/^https?:\/\//i.test(location)) return location
40
+ return new URL(location, currentUrl).toString()
41
+ }
42
+
43
+ export function downloadFollowingRedirects(url, { userAgent, originalUrl } = {}, depth = 0) {
44
+ const startUrl = originalUrl || url
45
+ if (depth > MAX_REDIRECTS) {
46
+ return Promise.reject(new TooManyRedirectsError(startUrl))
47
+ }
48
+ const request = pickRequest(url)
49
+ return new Promise((resolve, reject) => {
50
+ const req = request(
51
+ url,
52
+ {
53
+ method: 'GET',
54
+ headers: {
55
+ 'User-Agent': userAgent || 'reddb-internal-asset-fetcher',
56
+ Accept: 'application/octet-stream',
57
+ },
58
+ },
59
+ (res) => {
60
+ const status = res.statusCode || 0
61
+ if (status >= 300 && status < 400 && res.headers.location) {
62
+ res.resume()
63
+ const next = resolveLocation(url, res.headers.location)
64
+ downloadFollowingRedirects(next, { userAgent, originalUrl: startUrl }, depth + 1).then(
65
+ resolve,
66
+ reject,
67
+ )
68
+ return
69
+ }
70
+ if (status === 404) {
71
+ res.resume()
72
+ reject(new AssetNotFoundError(startUrl))
73
+ return
74
+ }
75
+ if (status < 200 || status >= 300) {
76
+ res.resume()
77
+ reject(new HttpError(status, url))
78
+ return
79
+ }
80
+ const chunks = []
81
+ res.on('data', (chunk) => chunks.push(chunk))
82
+ res.on('end', () => resolve(Buffer.concat(chunks)))
83
+ res.on('error', reject)
84
+ },
85
+ )
86
+ req.on('error', reject)
87
+ req.end()
88
+ })
89
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @reddb-io/internal-asset-fetcher — fetch a `red`/`red_client` binary
3
+ * from a GitHub release.
4
+ *
5
+ * Public surface: one function.
6
+ *
7
+ * fetchReleaseAsset({ repo, tag, platform, arch, binName, sha256? }) → Buffer
8
+ *
9
+ * Steps:
10
+ * 1. Map (platform, arch, binName) → asset filename.
11
+ * 2. Compose the GitHub download URL: `https://github.com/<repo>/releases/download/<tag>/<asset>`.
12
+ * 3. Follow up to 5 redirects, returning the final body as a Buffer.
13
+ * 4. If `sha256` was supplied, verify before returning.
14
+ *
15
+ * Errors carry distinct `.code` values so callers can differentiate:
16
+ * - UNSUPPORTED_PLATFORM — no asset for this platform/arch
17
+ * - ASSET_NOT_FOUND — HTTP 404 (release/tag/asset name wrong)
18
+ * - CHECKSUM_MISMATCH — body downloaded but sha256 mismatched
19
+ * - HTTP_ERROR — any other non-2xx status
20
+ * - TOO_MANY_REDIRECTS — redirect chain longer than 5 hops
21
+ *
22
+ * Internal modules (`./asset-name.js`, `./download.js`, `./checksum.js`)
23
+ * are not part of the public contract — only `fetchReleaseAsset` is.
24
+ * They are imported directly in tests for focused coverage.
25
+ */
26
+
27
+ import { composeAssetName } from './asset-name.js'
28
+ import { downloadFollowingRedirects } from './download.js'
29
+ import { verifySha256 } from './checksum.js'
30
+
31
+ export async function fetchReleaseAsset({ repo, tag, platform, arch, binName, sha256 } = {}) {
32
+ if (typeof repo !== 'string' || repo === '') {
33
+ throw new TypeError('fetchReleaseAsset: `repo` must be a non-empty string (e.g. "reddb-io/reddb")')
34
+ }
35
+ if (typeof tag !== 'string' || tag === '') {
36
+ throw new TypeError('fetchReleaseAsset: `tag` must be a non-empty string (e.g. "v0.2.9")')
37
+ }
38
+ if (typeof platform !== 'string' || platform === '') {
39
+ throw new TypeError('fetchReleaseAsset: `platform` must be a non-empty string')
40
+ }
41
+ if (typeof arch !== 'string' || arch === '') {
42
+ throw new TypeError('fetchReleaseAsset: `arch` must be a non-empty string')
43
+ }
44
+
45
+ const assetName = composeAssetName({ platform, arch, binName })
46
+ const url = `https://github.com/${repo}/releases/download/${tag}/${assetName}`
47
+ const body = await downloadFollowingRedirects(url)
48
+ if (sha256) {
49
+ verifySha256(body, sha256)
50
+ }
51
+ return body
52
+ }
package/src/kv.js ADDED
@@ -0,0 +1,70 @@
1
+ import { RedDBError } from './protocol.js'
2
+
3
+ export class KvClient {
4
+ constructor(client, collection = 'kv_default') {
5
+ this.client = client
6
+ this.collection = collection
7
+ }
8
+
9
+ put(key, value, options = {}) {
10
+ const collection = options.collection ?? this.collection
11
+ const tags = Array.isArray(options.tags) && options.tags.length > 0
12
+ ? ` TAGS [${options.tags.map(kvTagLiteral).join(', ')}]`
13
+ : ''
14
+ const expire = options.expireMs != null ? ` EXPIRE ${Number(options.expireMs)} ms` : ''
15
+ return this.client.call('query', {
16
+ sql: `KV PUT ${kvPath(collection, key)} = ${kvValueLiteral(value)}${expire}${tags}`,
17
+ })
18
+ }
19
+
20
+ async invalidateTags(tags, options = {}) {
21
+ const collection = options.collection ?? this.collection
22
+ const result = await this.client.call('query', {
23
+ sql: `INVALIDATE TAGS [${tags.map(kvTagLiteral).join(', ')}] FROM ${kvIdentifier(collection)}`,
24
+ })
25
+ return result.affected ?? result.affected_rows ?? result.rows?.[0]?.invalidated ?? 0
26
+ }
27
+
28
+ async *watch(key, options = {}) {
29
+ if (!this.client.baseUrl) {
30
+ throw new RedDBError('UNSUPPORTED_TRANSPORT', 'kv.watch requires the HTTP transport')
31
+ }
32
+ const collection = options.collection ?? this.collection
33
+ const params = new URLSearchParams()
34
+ if (options.sinceLsn != null) params.set('since_lsn', String(options.sinceLsn))
35
+ if (options.limit != null) params.set('limit', String(options.limit))
36
+ const suffix = params.toString() ? `?${params}` : ''
37
+ const url = `${this.client.baseUrl}/collections/${encodeURIComponent(collection)}/kv/${encodeURIComponent(String(key))}/watch${suffix}`
38
+ const response = await fetch(url, this.client.attachAuth({ method: 'GET' }))
39
+ if (!response.ok) {
40
+ throw new RedDBError('HTTP_ERROR', `kv.watch failed with HTTP ${response.status}`)
41
+ }
42
+ const text = await response.text()
43
+ for (const block of text.split('\n\n')) {
44
+ const line = block.split('\n').find((entry) => entry.startsWith('data: '))
45
+ if (line) yield JSON.parse(line.slice(6))
46
+ }
47
+ }
48
+
49
+ watchPrefix(prefix, options = {}) {
50
+ return this.watch(`${prefix}.*`, options)
51
+ }
52
+ }
53
+
54
+ function kvPath(collection, key) {
55
+ return `${kvIdentifier(collection)}.${kvIdentifier(key)}`
56
+ }
57
+
58
+ function kvIdentifier(value) {
59
+ return String(value).replace(/[^A-Za-z0-9_]/g, '_')
60
+ }
61
+
62
+ function kvValueLiteral(value) {
63
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
64
+ if (value == null) return 'NULL'
65
+ return `'${String(value).replace(/'/g, "''")}'`
66
+ }
67
+
68
+ function kvTagLiteral(value) {
69
+ return `'${String(value).replace(/'/g, "''")}'`
70
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * JSON-RPC 2.0 line-delimited client.
3
+ *
4
+ * Maintains a map of pending requests keyed by id. Reads stdout one
5
+ * line at a time, parses each line as a response envelope, looks up
6
+ * the pending promise by id and resolves/rejects it.
7
+ *
8
+ * Spec: PLAN_DRIVERS.md, "Spec do protocolo stdio".
9
+ */
10
+
11
+ const NEWLINE = 0x0a // '\n'
12
+ const encoder = new TextEncoder()
13
+ const decoder = new TextDecoder('utf-8')
14
+
15
+ /**
16
+ * RedDB-shaped error. Drivers in other languages should expose an
17
+ * equivalent class with the same `code` field.
18
+ */
19
+ export class RedDBError extends Error {
20
+ constructor(code, message, data) {
21
+ super(message)
22
+ this.name = 'RedDBError'
23
+ this.code = code
24
+ this.data = data ?? null
25
+ }
26
+ }
27
+
28
+ export class RpcClient {
29
+ /** @param {import('./spawn.js').RedProcess} child */
30
+ constructor(child) {
31
+ this.child = child
32
+ this.nextId = 1
33
+ /** @type {Map<number|string, { resolve: Function, reject: Function }>} */
34
+ this.pending = new Map()
35
+ this.closed = false
36
+ this.closeReason = null
37
+ this.readerPromise = this.#readLoop()
38
+ }
39
+
40
+ /**
41
+ * Send a JSON-RPC 2.0 request and resolve with the result, or reject
42
+ * with a `RedDBError` if the server returned an error envelope.
43
+ */
44
+ call(method, params = {}) {
45
+ if (this.closed) {
46
+ return Promise.reject(
47
+ new RedDBError('CLIENT_CLOSED', `client is closed: ${this.closeReason ?? 'unknown'}`),
48
+ )
49
+ }
50
+ const id = this.nextId++
51
+ const envelope = JSON.stringify({ jsonrpc: '2.0', id, method, params })
52
+ return new Promise((resolve, reject) => {
53
+ this.pending.set(id, { resolve, reject })
54
+ this.child.stdin.write(encoder.encode(envelope + '\n')).catch((err) => {
55
+ this.pending.delete(id)
56
+ reject(err)
57
+ })
58
+ })
59
+ }
60
+
61
+ /** Drain pending requests, send `close`, wait for the binary to exit. */
62
+ async close() {
63
+ if (this.closed) return
64
+ try {
65
+ await this.call('close', {})
66
+ } catch {
67
+ // best effort — server may already be gone
68
+ }
69
+ this.#shutdown('close requested')
70
+ try {
71
+ this.child.stdin.end()
72
+ } catch {
73
+ // ignore
74
+ }
75
+ await this.child.wait()
76
+ }
77
+
78
+ // -------------------------------------------------------------------------
79
+ // Internal: stdout reader loop
80
+ // -------------------------------------------------------------------------
81
+
82
+ async #readLoop() {
83
+ let buffer = new Uint8Array(0)
84
+ try {
85
+ for await (const chunk of this.child.stdout) {
86
+ const merged = new Uint8Array(buffer.length + chunk.length)
87
+ merged.set(buffer, 0)
88
+ merged.set(chunk, buffer.length)
89
+ buffer = merged
90
+
91
+ // Split on \n, dispatch each complete line.
92
+ let start = 0
93
+ for (let i = 0; i < buffer.length; i++) {
94
+ if (buffer[i] === NEWLINE) {
95
+ const lineBytes = buffer.subarray(start, i)
96
+ this.#dispatchLine(decoder.decode(lineBytes))
97
+ start = i + 1
98
+ }
99
+ }
100
+ if (start > 0) {
101
+ buffer = buffer.subarray(start)
102
+ }
103
+ }
104
+ } catch (err) {
105
+ this.#shutdown(`stdout reader error: ${err.message}`)
106
+ return
107
+ }
108
+ // EOF — server exited.
109
+ this.#shutdown('server stdout closed')
110
+ }
111
+
112
+ #dispatchLine(line) {
113
+ if (!line.trim()) return
114
+ let envelope
115
+ try {
116
+ envelope = JSON.parse(line)
117
+ } catch (err) {
118
+ // Malformed line from the server is fatal to the protocol —
119
+ // we cannot map it to any pending request.
120
+ this.#shutdown(`malformed server response: ${err.message}`)
121
+ return
122
+ }
123
+ const id = envelope.id
124
+ if (id === null || id === undefined) {
125
+ // No id → cannot route. Treat as protocol violation.
126
+ return
127
+ }
128
+ const pending = this.pending.get(id)
129
+ if (!pending) {
130
+ // Unknown id — server bug or duplicate response. Ignore.
131
+ return
132
+ }
133
+ this.pending.delete(id)
134
+ if (envelope.error) {
135
+ pending.reject(
136
+ new RedDBError(
137
+ envelope.error.code ?? 'UNKNOWN',
138
+ envelope.error.message ?? 'unknown error',
139
+ envelope.error.data,
140
+ ),
141
+ )
142
+ } else {
143
+ pending.resolve(envelope.result)
144
+ }
145
+ }
146
+
147
+ #shutdown(reason) {
148
+ if (this.closed) return
149
+ this.closed = true
150
+ this.closeReason = reason
151
+ const err = new RedDBError('CLIENT_CLOSED', reason)
152
+ for (const { reject } of this.pending.values()) {
153
+ reject(err)
154
+ }
155
+ this.pending.clear()
156
+ }
157
+ }