@reddb-io/sdk 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,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
+ }
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
+ }