@reddb-io/cli 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.
- package/LICENSE +661 -0
- package/README.md +635 -0
- package/drivers/js/cli-postinstall.js +109 -0
- package/drivers/js/package.json +50 -0
- package/drivers/js/src/binary.js +66 -0
- package/drivers/js/src/cache.js +137 -0
- package/drivers/js/src/cli.js +25 -0
- package/drivers/js/src/config.js +66 -0
- package/drivers/js/src/http.js +200 -0
- package/drivers/js/src/index.js +432 -0
- package/drivers/js/src/internal/asset-fetcher/asset-name.js +37 -0
- package/drivers/js/src/internal/asset-fetcher/checksum.js +23 -0
- package/drivers/js/src/internal/asset-fetcher/download.js +89 -0
- package/drivers/js/src/internal/asset-fetcher/index.js +52 -0
- package/drivers/js/src/internal/bin-resolver/index.js +57 -0
- package/drivers/js/src/internal/version-compare/index.js +163 -0
- package/drivers/js/src/kv.js +70 -0
- package/drivers/js/src/protocol.js +157 -0
- package/drivers/js/src/redwire.js +723 -0
- package/drivers/js/src/spawn.js +177 -0
- package/drivers/js/src/url.js +271 -0
- package/drivers/js/src/vault.js +58 -0
- package/package.json +39 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @reddb-io/internal-version-compare — install/upgrade/skip verdict for
|
|
3
|
+
* the CLI postinstall.
|
|
4
|
+
*
|
|
5
|
+
* Asymmetry vs SDK/client (per ADR 0006):
|
|
6
|
+
* - SDK/client postinstalls *always* download a pinned binary because
|
|
7
|
+
* the wire format is version-coupled to the driver.
|
|
8
|
+
* - The CLI is a launcher. Users typically already have `red` on PATH
|
|
9
|
+
* and would resent each `npm i -g @reddb-io/cli` re-downloading.
|
|
10
|
+
* We compare versions and only fetch when strictly newer.
|
|
11
|
+
*
|
|
12
|
+
* Verdict table:
|
|
13
|
+
* exec throws → action='install' (no binary detected)
|
|
14
|
+
* exec returns unparseable output → action='install' (warn, fetch fresh)
|
|
15
|
+
* PATH version < packageVersion → action='upgrade'
|
|
16
|
+
* PATH version >= packageVersion → action='skip'
|
|
17
|
+
*
|
|
18
|
+
* Prerelease ordering follows semver 2.0.0 §11: a normal release ranks
|
|
19
|
+
* above any prerelease of the same MAJOR.MINOR.PATCH.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const SEMVER_RE = /\b(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?\b/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract a semver-shaped version string from arbitrary `red --version`
|
|
26
|
+
* output. Accepts `reddb 0.2.9`, bare `0.2.9`, or `red 1.0.0-rc.1`.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} text
|
|
29
|
+
* @returns {string|null} normalised `MAJOR.MINOR.PATCH[-prerelease]` or null
|
|
30
|
+
*/
|
|
31
|
+
export function parseVersion(text) {
|
|
32
|
+
if (typeof text !== 'string') return null
|
|
33
|
+
const m = text.match(SEMVER_RE)
|
|
34
|
+
if (!m) return null
|
|
35
|
+
const [, maj, min, pat, pre] = m
|
|
36
|
+
return pre ? `${maj}.${min}.${pat}-${pre}` : `${maj}.${min}.${pat}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* semver 2.0.0 ordering. Returns -1 / 0 / 1.
|
|
41
|
+
*
|
|
42
|
+
* Throws TypeError if either side is not parseable — this is a
|
|
43
|
+
* programmer error, not a runtime fallback path.
|
|
44
|
+
*/
|
|
45
|
+
export function compareSemver(a, b) {
|
|
46
|
+
const pa = parseVersion(a)
|
|
47
|
+
const pb = parseVersion(b)
|
|
48
|
+
if (pa === null) throw new TypeError(`compareSemver: invalid version "${a}"`)
|
|
49
|
+
if (pb === null) throw new TypeError(`compareSemver: invalid version "${b}"`)
|
|
50
|
+
|
|
51
|
+
const [coreA, preA] = splitCore(pa)
|
|
52
|
+
const [coreB, preB] = splitCore(pb)
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < 3; i++) {
|
|
55
|
+
if (coreA[i] !== coreB[i]) return coreA[i] < coreB[i] ? -1 : 1
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Cores equal — apply §11: no-prerelease ranks above any prerelease.
|
|
59
|
+
if (preA === null && preB === null) return 0
|
|
60
|
+
if (preA === null) return 1
|
|
61
|
+
if (preB === null) return -1
|
|
62
|
+
|
|
63
|
+
return comparePrerelease(preA, preB)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function splitCore(v) {
|
|
67
|
+
const dash = v.indexOf('-')
|
|
68
|
+
const core = dash === -1 ? v : v.slice(0, dash)
|
|
69
|
+
const pre = dash === -1 ? null : v.slice(dash + 1)
|
|
70
|
+
const parts = core.split('.').map((n) => Number(n))
|
|
71
|
+
return [parts, pre]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function comparePrerelease(a, b) {
|
|
75
|
+
const sa = a.split('.')
|
|
76
|
+
const sb = b.split('.')
|
|
77
|
+
const len = Math.min(sa.length, sb.length)
|
|
78
|
+
for (let i = 0; i < len; i++) {
|
|
79
|
+
const c = compareIdentifier(sa[i], sb[i])
|
|
80
|
+
if (c !== 0) return c
|
|
81
|
+
}
|
|
82
|
+
if (sa.length === sb.length) return 0
|
|
83
|
+
return sa.length < sb.length ? -1 : 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function compareIdentifier(a, b) {
|
|
87
|
+
const aNum = /^\d+$/.test(a)
|
|
88
|
+
const bNum = /^\d+$/.test(b)
|
|
89
|
+
if (aNum && bNum) {
|
|
90
|
+
const na = Number(a)
|
|
91
|
+
const nb = Number(b)
|
|
92
|
+
return na === nb ? 0 : na < nb ? -1 : 1
|
|
93
|
+
}
|
|
94
|
+
if (aNum) return -1 // numeric < alphanumeric per §11
|
|
95
|
+
if (bNum) return 1
|
|
96
|
+
return a === b ? 0 : a < b ? -1 : 1
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Decide what the CLI postinstall should do.
|
|
101
|
+
*
|
|
102
|
+
* @param {{ packageVersion: string, exec: () => string }} opts
|
|
103
|
+
* `exec` is called with no arguments and is expected to return the
|
|
104
|
+
* stdout of `red --version` (sync). It may throw — that signals "no
|
|
105
|
+
* PATH binary detected" and routes to action='install'.
|
|
106
|
+
* @returns {{ action: 'install'|'upgrade'|'skip', reason: string }}
|
|
107
|
+
*/
|
|
108
|
+
export function compareInstalled(opts) {
|
|
109
|
+
if (!opts || typeof opts !== 'object') {
|
|
110
|
+
throw new TypeError('compareInstalled: options object required')
|
|
111
|
+
}
|
|
112
|
+
const { packageVersion, exec } = opts
|
|
113
|
+
if (typeof packageVersion !== 'string' || packageVersion === '') {
|
|
114
|
+
throw new TypeError('compareInstalled: `packageVersion` must be a non-empty string')
|
|
115
|
+
}
|
|
116
|
+
if (typeof exec !== 'function') {
|
|
117
|
+
throw new TypeError('compareInstalled: `exec` must be a function')
|
|
118
|
+
}
|
|
119
|
+
if (parseVersion(packageVersion) === null) {
|
|
120
|
+
throw new TypeError(`compareInstalled: \`packageVersion\` "${packageVersion}" is not parseable semver`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let raw
|
|
124
|
+
try {
|
|
125
|
+
raw = exec()
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return {
|
|
128
|
+
action: 'install',
|
|
129
|
+
reason: `no PATH \`red\` binary detected (${err.message || 'exec failed'})`,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const installed = parseVersion(typeof raw === 'string' ? raw : '')
|
|
134
|
+
if (installed === null) {
|
|
135
|
+
return {
|
|
136
|
+
action: 'install',
|
|
137
|
+
reason: `unparseable PATH \`red --version\` output (${truncate(String(raw))})`,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const cmp = compareSemver(installed, packageVersion)
|
|
142
|
+
if (cmp < 0) {
|
|
143
|
+
return {
|
|
144
|
+
action: 'upgrade',
|
|
145
|
+
reason: `PATH \`red\` ${installed} is older than package ${packageVersion}`,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (cmp > 0) {
|
|
149
|
+
return {
|
|
150
|
+
action: 'skip',
|
|
151
|
+
reason: `PATH \`red\` ${installed} is newer than package ${packageVersion}`,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
action: 'skip',
|
|
156
|
+
reason: `PATH \`red\` already at ${installed}`,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function truncate(s) {
|
|
161
|
+
const oneLine = s.replace(/\s+/g, ' ').trim()
|
|
162
|
+
return oneLine.length > 80 ? `${oneLine.slice(0, 77)}...` : oneLine
|
|
163
|
+
}
|
|
@@ -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
|
+
}
|