@reddb-io/cli 1.23.2 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -357,12 +357,15 @@ Same storage format. Start embedded, scale to server, expose to agents — zero
357
357
 
358
358
  ## ⚡ Performance
359
359
 
360
- > **Where RedDB wins:** benchmarks show measurable wins over Postgres and Mongo today:
361
- >
362
- > - `typed_insert` — **16× faster** than PostgreSQL on typed single-row inserts
363
- > - `disk_usage` — **1.5× faster** than MongoDB on compact-write workloads
364
- >
365
- > See [`docs/perf/wins.md`](docs/perf/wins.md) for reproducible benchmarks and [`docs/perf/when-not-reddb.md`](docs/perf/when-not-reddb.md) for the honest gaps where we're still behind.
360
+ > **Performance evidence:** the April/May 2026 results in
361
+ > [`docs/perf/wins.md`](docs/perf/wins.md) are historical and have not been
362
+ > revalidated against the current SDK paths. In particular, the old
363
+ > `typed_insert` label does not establish sequential single-row latency.
364
+ > [Issue #2270](https://github.com/reddb-io/reddb/issues/2270) and the
365
+ > [September competitive audit](.red/researches/2026-09-07-reddb-surrealdb-competitive-audit.md)
366
+ > document current diagnostic evidence and the remaining gaps.
367
+ > [ADR 0076](.red/adr/0076-competitive-leadership-evidence-gate.md) requires
368
+ > equivalent guarantees and a demonstrated 20% margin before claiming a lead.
366
369
 
367
370
  RedDB uses multiple optimization techniques for fast queries at scale:
368
371
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reddb-io/sdk",
3
- "version": "1.23.2",
4
- "description": "Official embedded RedDB SDK — launches a local red binary over stdio JSON-RPC. Use @reddb-io/client for remote HTTP, gRPC, and RedWire.",
3
+ "version": "1.24.0",
4
+ "description": "Official embedded RedDB SDK — launches a local red binary over stdio JSON-RPC. The binary arrives as a per-platform optionalDependency — no install script. Use @reddb-io/client for remote HTTP, gRPC, and RedWire.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -14,13 +14,11 @@
14
14
  "files": [
15
15
  "src/",
16
16
  "index.d.ts",
17
- "postinstall.js",
18
17
  "README.md",
19
18
  "LICENSE"
20
19
  ],
21
20
  "scripts": {
22
- "postinstall": "node postinstall.js",
23
- "test": "node --test test/ask.test.mjs test/cache.test.mjs test/db-helpers.test.mjs test/embedded-only.test.mjs test/helpers.test.mjs test/insert-ids.test.mjs test/kv.test.mjs test/params.test.mjs test/postinstall.test.mjs test/queue.test.mjs test/redwire.params.test.mjs test/transaction.test.mjs test/url.test.mjs && node test/smoke.test.mjs && node test/conformance.test.mjs && node test/readme-examples.test.mjs"
21
+ "test": "node --test test/ask.test.mjs test/cache.test.mjs test/db-helpers.test.mjs test/embedded-only.test.mjs test/helpers.test.mjs test/insert-ids.test.mjs test/kv.test.mjs test/params.test.mjs test/platform-binary.test.mjs test/native-engine.test.mjs test/queue.test.mjs test/redwire.params.test.mjs test/transaction.test.mjs test/url.test.mjs && node test/smoke.test.mjs && node test/conformance.test.mjs && node test/readme-examples.test.mjs"
24
22
  },
25
23
  "engines": {
26
24
  "node": ">=18"
@@ -4,7 +4,8 @@
4
4
  * SDK lookup (`resolveSdkBinary`):
5
5
  * 1. `REDDB_BIN` env var (the canonical override per ADR 0006).
6
6
  * 2. `REDDB_BINARY_PATH` env var (legacy alias, deprecation window).
7
- * 3. `<package>/bin/red[.exe]` — where postinstall.js dropped it.
7
+ * 3. The `@reddb-io/red-<platform>` package npm installed for this host
8
+ * (see `platform-binary.js`).
8
9
  * 4. Otherwise throw an actionable error.
9
10
  *
10
11
  * PATH is **never** consulted. The wire-format coupling between the
@@ -13,19 +14,12 @@
13
14
  *
14
15
  * CLI lookup (`resolveCliBinary`):
15
16
  * 1. `REDDB_BIN` env var.
16
- * 2. `<package>/bin/red[.exe]`.
17
+ * 2. The `@reddb-io/red-<platform>` package.
17
18
  * 3. PATH-resolved bare `red[.exe]` — appropriate for the CLI which
18
19
  * *targets* PATH.
19
20
  */
20
21
 
21
- import { existsSync } from 'node:fs'
22
- import { fileURLToPath } from 'node:url'
23
- import { dirname, resolve, join } from 'node:path'
24
-
25
- import { resolveBin } from './internal/bin-resolver/index.js'
26
-
27
- const HERE = dirname(fileURLToPath(import.meta.url))
28
- const PACKAGE_ROOT = resolve(HERE, '..')
22
+ import { platformPackageNames, resolvePlatformBinary } from './platform-binary.js'
29
23
 
30
24
  function defaultBinaryName() {
31
25
  if (typeof process !== 'undefined' && process.platform === 'win32') {
@@ -36,15 +30,27 @@ function defaultBinaryName() {
36
30
 
37
31
  /** SDK runtime lookup. Throws actionable error when binary cannot be located. */
38
32
  export function resolveSdkBinary() {
33
+ const override = process.env?.REDDB_BIN
34
+ if (typeof override === 'string' && override !== '') {
35
+ return override
36
+ }
39
37
  const legacy = process.env?.REDDB_BINARY_PATH
40
- if (typeof legacy === 'string' && legacy !== '' && !process.env?.REDDB_BIN) {
38
+ if (typeof legacy === 'string' && legacy !== '') {
41
39
  return legacy
42
40
  }
43
- return resolveBin({
44
- name: defaultBinaryName(),
45
- packageRoot: PACKAGE_ROOT,
46
- envVar: 'REDDB_BIN',
47
- })
41
+ const packaged = resolvePlatformBinary()
42
+ if (packaged) {
43
+ return packaged
44
+ }
45
+ const wanted = platformPackageNames()[0]
46
+ throw new Error(
47
+ `reddb: binary "${defaultBinaryName()}" not found.\n` +
48
+ ` expected: the ${wanted} package (an optionalDependency of @reddb-io/sdk)\n` +
49
+ ` override: set REDDB_BIN=/path/to/${defaultBinaryName()}\n` +
50
+ ` fix: reinstall without --no-optional / --omit=optional, and make sure\n` +
51
+ ` ${process.platform}/${process.arch} is a supported platform;\n` +
52
+ ` or build it: cargo build --release --bin red`,
53
+ )
48
54
  }
49
55
 
50
56
  /** CLI runtime lookup. Allowed to fall back to PATH per ADR 0006. */
@@ -53,14 +59,5 @@ export function resolveCliBinary() {
53
59
  if (typeof override === 'string' && override !== '') {
54
60
  return override
55
61
  }
56
- const local = join(PACKAGE_ROOT, 'bin', defaultBinaryName())
57
- if (existsSync(local)) {
58
- return local
59
- }
60
- return defaultBinaryName()
61
- }
62
-
63
- /** Used by postinstall.js to know where to drop the downloaded binary. */
64
- export function packageBinaryDir() {
65
- return join(PACKAGE_ROOT, 'bin')
62
+ return resolvePlatformBinary() ?? defaultBinaryName()
66
63
  }
@@ -2,13 +2,9 @@
2
2
  * Cache client — exposes cache.{get,put,exists,invalidate,invalidatePrefix,
3
3
  * invalidateTags,flushNamespace} via the underlying HTTP transport.
4
4
  *
5
- * NOTE: These methods require server-side HTTP endpoints under /cache/ns/*.
6
- * flushNamespace routes to the existing POST /admin/blob_cache/flush_namespace.
7
- * All others target endpoints planned for a future server release.
8
- *
9
- * Requires an HTTP or gRPC transport. On embedded (stdio JSON-RPC), every
10
- * method throws `UNSUPPORTED_TRANSPORT` before issuing the RPC call —
11
- * the engine's stdio handler doesn't implement `cache.*`.
5
+ * The HTTP data-plane endpoints under /cache/ns/* authorize the caller's
6
+ * tenant and cache namespace. Invalidation is local to the addressed server.
7
+ * Use the HTTP transport for remote cache operations.
12
8
  *
13
9
  * Values are base64-encoded in transit so binary payloads survive JSON.
14
10
  */
@@ -123,7 +119,7 @@ export class CacheClient {
123
119
 
124
120
  /**
125
121
  * Remove all entries in a namespace.
126
- * Routes to POST /admin/blob_cache/flush_namespace (live endpoint).
122
+ * Uses the caller-scoped POST /cache/ns/:namespace/flush endpoint.
127
123
  * @param {string} namespace
128
124
  * @returns {Promise<void>}
129
125
  */
@@ -62,5 +62,5 @@ function keyedValueLiteral(value) {
62
62
  }
63
63
 
64
64
  function keyedStringLiteral(value) {
65
- return `'${String(value).replace(/'/g, "''")}'`
65
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
66
66
  }
@@ -91,5 +91,5 @@ function sqlIdentifier(value) {
91
91
  }
92
92
 
93
93
  function sqlString(value) {
94
- return `'${String(value).replace(/'/g, "''")}'`
94
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
95
95
  }
@@ -127,5 +127,5 @@ function sqlValueLiteral(value) {
127
127
  }
128
128
 
129
129
  function sqlString(value) {
130
- return `'${String(value).replace(/'/g, "''")}'`
130
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
131
131
  }
@@ -27,8 +27,8 @@
27
27
  * cache.exists → GET /cache/ns/:ns/:key/exists
28
28
  * cache.invalidate → DELETE /cache/ns/:ns/:key
29
29
  * cache.invalidate_prefix → DELETE /cache/ns/:ns?prefix=...
30
- * cache.invalidate_tags → DELETE /cache/ns/:ns/tags (body: {tags})
31
- * cache.flush_namespace → POST /admin/blob_cache/flush_namespace
30
+ * cache.invalidate_tags → POST /cache/ns/:ns/invalidate-tags (body: {tags})
31
+ * cache.flush_namespace → POST /cache/ns/:ns/flush
32
32
  *
33
33
  * Auth: every request after construction carries `Authorization:
34
34
  * Bearer <token>` (when set). `setToken(token)` updates it in
@@ -195,11 +195,11 @@ const ROUTES = {
195
195
  init: { method: 'DELETE' },
196
196
  }),
197
197
  'cache.invalidate_tags': (base, { namespace, tags }) => ({
198
- url: `${base}/cache/ns/${encodeURIComponent(namespace)}/tags`,
199
- init: { method: 'DELETE', body: JSON.stringify({ tags }) },
198
+ url: `${base}/cache/ns/${encodeURIComponent(namespace)}/invalidate-tags`,
199
+ init: { method: 'POST', body: JSON.stringify({ tags }) },
200
200
  }),
201
201
  'cache.flush_namespace': (base, { namespace }) => ({
202
- url: `${base}/admin/blob_cache/flush_namespace`,
203
- init: { method: 'POST', body: JSON.stringify({ namespace }) },
202
+ url: `${base}/cache/ns/${encodeURIComponent(namespace)}/flush`,
203
+ init: { method: 'POST' },
204
204
  }),
205
205
  }
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { spawnRed } from './spawn.js'
22
+ import { NativeRpcClient, openNativeEngine, resolveNativeAddon } from './native-engine.js'
22
23
  import { resolveSdkBinary } from './binary.js'
23
24
  import { RpcClient, RedDBError } from './protocol.js'
24
25
  import { parseUri } from './url.js'
@@ -83,6 +84,15 @@ export async function connect(uri, options = {}) {
83
84
  'auth is only meaningful for remote connections; embedded modes inherit caller privileges.',
84
85
  )
85
86
  }
87
+ // In-process addon when one is installed for this host (and the caller
88
+ // did not ask for a specific binary); otherwise the `red rpc --stdio`
89
+ // subprocess. Both serve the same JSON-RPC protocol.
90
+ const addon = options.binary ? null : resolveNativeAddon()
91
+ if (addon) {
92
+ const client = new NativeRpcClient(openNativeEngine(parsed.path || undefined, addon))
93
+ await client.call('version', {})
94
+ return new RedDB(client, { transport: 'embedded' })
95
+ }
86
96
  const args = embeddedArgs(parsed)
87
97
  const binary = options.binary ?? resolveSdkBinary()
88
98
  const child = await spawnRed(binary, args)
@@ -120,16 +120,16 @@ function kvIdentifier(value) {
120
120
  function kvKeySegment(value) {
121
121
  const key = String(value)
122
122
  if (/^[A-Za-z0-9_]+$/.test(key)) return key
123
- return `'${key.replace(/'/g, "''")}'`
123
+ return `'${key.replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
124
124
  }
125
125
 
126
126
  function kvValueLiteral(value) {
127
127
  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
128
128
  if (value == null) return 'NULL'
129
- if (typeof value === 'object') return `'${JSON.stringify(value).replace(/'/g, "''")}'`
130
- return `'${String(value).replace(/'/g, "''")}'`
129
+ if (typeof value === 'object') return `'${JSON.stringify(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
130
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
131
131
  }
132
132
 
133
133
  function kvTagLiteral(value) {
134
- return `'${String(value).replace(/'/g, "''")}'`
134
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
135
135
  }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * In-process embedded engine (the `reddb.node` Node addon).
3
+ *
4
+ * Embedded mode used to spawn `red rpc --stdio` and speak JSON-RPC over the
5
+ * child's pipes. The addon serves the same protocol — same methods, same
6
+ * transaction/cursor semantics — as a function call, so there is no
7
+ * subprocess and no separate `red` binary involved.
8
+ *
9
+ * The addon ships inside the `@reddb-io/red-<platform>` package next to
10
+ * `bin/red` (see `platform-binary.js` for how that package is found).
11
+ * `REDDB_NATIVE_ADDON=/path/to/reddb.node` overrides the lookup.
12
+ */
13
+
14
+ import { existsSync } from 'node:fs'
15
+ import { createRequire } from 'node:module'
16
+ import { dirname, join } from 'node:path'
17
+
18
+ import { RedDBError } from './protocol.js'
19
+ import { platformPackageNames } from './platform-binary.js'
20
+
21
+ const require = createRequire(import.meta.url)
22
+
23
+ /**
24
+ * @returns {string | null} path to `reddb.node`, or null when no addon is
25
+ * installed for this host.
26
+ */
27
+ export function resolveNativeAddon({
28
+ resolve = (id) => require.resolve(id),
29
+ env = process.env,
30
+ names = platformPackageNames(),
31
+ } = {}) {
32
+ const override = env.REDDB_NATIVE_ADDON
33
+ if (typeof override === 'string' && override !== '') return override
34
+ for (const name of names) {
35
+ let manifest
36
+ try {
37
+ manifest = resolve(`${name}/package.json`)
38
+ } catch {
39
+ continue
40
+ }
41
+ const addon = join(dirname(manifest), 'reddb.node')
42
+ if (existsSync(addon)) return addon
43
+ }
44
+ return null
45
+ }
46
+
47
+ /**
48
+ * Open an embedded engine through the addon.
49
+ * @param {string | undefined} path database file, or undefined for in-memory
50
+ * @param {string} addonPath
51
+ */
52
+ export function openNativeEngine(path, addonPath) {
53
+ const { Engine } = require(addonPath)
54
+ return Engine.open(path)
55
+ }
56
+
57
+ /**
58
+ * JSON-RPC client over an in-process engine. Same surface as `RpcClient`
59
+ * (`call`, `close`), so `RedDB` cannot tell the transports apart.
60
+ *
61
+ * Requests are chained so they reach the engine in call order even when the
62
+ * caller does not await each one: the engine's thread pool would otherwise
63
+ * be free to reorder them, which a transaction (`tx.begin`, then writes)
64
+ * must never see. The stdio pipe gave this ordering for free.
65
+ */
66
+ export class NativeRpcClient {
67
+ /** @param {{ call(request: string): Promise<string>, close(): void }} engine */
68
+ constructor(engine) {
69
+ this.engine = engine
70
+ this.nextId = 1
71
+ this.closed = false
72
+ this.closeReason = null
73
+ this.tail = Promise.resolve()
74
+ }
75
+
76
+ call(method, params = {}) {
77
+ if (this.closed) {
78
+ return Promise.reject(
79
+ new RedDBError('CLIENT_CLOSED', `client is closed: ${this.closeReason ?? 'unknown'}`),
80
+ )
81
+ }
82
+ const id = this.nextId++
83
+ const request = JSON.stringify({ jsonrpc: '2.0', id, method, params })
84
+ const run = this.tail.then(() => this.engine.call(request))
85
+ this.tail = run.then(
86
+ () => undefined,
87
+ () => undefined,
88
+ )
89
+ return run.then((line) => {
90
+ const envelope = JSON.parse(line)
91
+ if (method === 'close') this.#shutdown('close requested')
92
+ if (envelope.error) {
93
+ throw new RedDBError(
94
+ envelope.error.code ?? 'UNKNOWN',
95
+ envelope.error.message ?? 'unknown error',
96
+ envelope.error.data,
97
+ )
98
+ }
99
+ return envelope.result
100
+ })
101
+ }
102
+
103
+ /** Send `close` (discarding any open transaction), then release the file lock. */
104
+ async close() {
105
+ if (this.closed) return
106
+ try {
107
+ await this.call('close', {})
108
+ } catch {
109
+ // best effort — the engine may already be shut down
110
+ }
111
+ this.#shutdown('close requested')
112
+ this.engine.close()
113
+ }
114
+
115
+ #shutdown(reason) {
116
+ if (this.closed) return
117
+ this.closed = true
118
+ this.closeReason = reason
119
+ }
120
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Locate the `red` binary shipped as a per-platform npm package.
3
+ *
4
+ * `@reddb-io/cli` and `@reddb-io/sdk` declare every `@reddb-io/red-<key>`
5
+ * package as an `optionalDependency`; the package manager installs only the
6
+ * one matching the host's `os` / `cpu` / `libc`, so there is no install
7
+ * script and no download. `<key>` is `<process.platform>-<process.arch>`,
8
+ * plus `-musl` for the static Linux builds. `scripts/npm-platform-packages.mjs`
9
+ * owns the publish side; a contract test keeps the two key tables identical.
10
+ */
11
+
12
+ import { existsSync } from 'node:fs'
13
+ import { createRequire } from 'node:module'
14
+ import { dirname, join } from 'node:path'
15
+
16
+ const require = createRequire(import.meta.url)
17
+
18
+ /** `glibc` | `musl` on Linux, `null` elsewhere. */
19
+ export function detectLibc(platform = process.platform) {
20
+ if (platform !== 'linux') return null
21
+ const header = process.report?.getReport?.()?.header
22
+ return header?.glibcVersionRuntime ? 'glibc' : 'musl'
23
+ }
24
+
25
+ /**
26
+ * Package names to try, best match first. On Linux the other libc variant is
27
+ * a fallback: package managers that ignore `libc` install both, and the
28
+ * static (musl) binary runs on glibc hosts too.
29
+ */
30
+ export function platformPackageNames({
31
+ platform = process.platform,
32
+ arch = process.arch,
33
+ libc = detectLibc(platform),
34
+ } = {}) {
35
+ const base = `@reddb-io/red-${platform}-${arch}`
36
+ if (platform !== 'linux') return [base]
37
+ return libc === 'musl' ? [`${base}-musl`, base] : [base, `${base}-musl`]
38
+ }
39
+
40
+ /**
41
+ * @returns {string | null} absolute path to the binary, or null when no
42
+ * platform package for this host is installed.
43
+ */
44
+ export function resolvePlatformBinary({
45
+ platform = process.platform,
46
+ arch = process.arch,
47
+ libc = detectLibc(platform),
48
+ resolve = (id) => require.resolve(id),
49
+ } = {}) {
50
+ const binName = platform === 'win32' ? 'red.exe' : 'red'
51
+ for (const name of platformPackageNames({ platform, arch, libc })) {
52
+ let manifest
53
+ try {
54
+ manifest = resolve(`${name}/package.json`)
55
+ } catch {
56
+ continue
57
+ }
58
+ const binary = join(dirname(manifest), 'bin', binName)
59
+ if (existsSync(binary)) return binary
60
+ }
61
+ return null
62
+ }
@@ -127,7 +127,7 @@ function queueValueLiteral(value) {
127
127
  }
128
128
 
129
129
  function queueStringLiteral(value) {
130
- return `'${String(value).replace(/'/g, "''")}'`
130
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
131
131
  }
132
132
 
133
133
  function queuePayloads(result) {
@@ -23,9 +23,13 @@ export class VaultClient {
23
23
  }
24
24
 
25
25
  unseal(key, options = {}) {
26
+ return this.reveal(key, options)
27
+ }
28
+
29
+ reveal(key, options = {}) {
26
30
  const collection = options.collection ?? this.collection
27
31
  return this.client.call('query', {
28
- sql: `UNSEAL VAULT ${keyedIdentifier(collection)}.${keyedIdentifier(key)}`,
32
+ sql: `VAULT REVEAL ${keyedIdentifier(collection)}.${keyedIdentifier(key)}`,
29
33
  })
30
34
  }
31
35
  }
@@ -54,5 +58,5 @@ function keyedValueLiteral(value) {
54
58
  }
55
59
 
56
60
  function keyedStringLiteral(value) {
57
- return `'${String(value).replace(/'/g, "''")}'`
61
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
58
62
  }
package/package.json CHANGED
@@ -1,14 +1,13 @@
1
1
  {
2
2
  "name": "@reddb-io/cli",
3
- "version": "1.23.2",
4
- "description": "CLI launcher for RedDB. The JS/TS app driver is published as @reddb-io/sdk.",
3
+ "version": "1.24.0",
4
+ "description": "CLI launcher for RedDB. The red binary arrives as a per-platform optionalDependency (no install script). The JS/TS app driver is published as @reddb-io/sdk.",
5
5
  "type": "commonjs",
6
6
  "bin": {
7
7
  "reddb-cli": "./drivers/js/src/cli.js"
8
8
  },
9
9
  "files": [
10
10
  "drivers/js/src/",
11
- "drivers/js/cli-postinstall.js",
12
11
  "drivers/js/package.json"
13
12
  ],
14
13
  "engines": {
@@ -34,8 +33,17 @@
34
33
  "@changesets/changelog-github": "^0.7.0",
35
34
  "@changesets/cli": "^2.31.0"
36
35
  },
36
+ "optionalDependencies": {
37
+ "@reddb-io/red-linux-x64": "1.24.0",
38
+ "@reddb-io/red-linux-x64-musl": "1.24.0",
39
+ "@reddb-io/red-linux-arm64": "1.24.0",
40
+ "@reddb-io/red-linux-arm64-musl": "1.24.0",
41
+ "@reddb-io/red-linux-arm": "1.24.0",
42
+ "@reddb-io/red-darwin-x64": "1.24.0",
43
+ "@reddb-io/red-darwin-arm64": "1.24.0",
44
+ "@reddb-io/red-win32-x64": "1.24.0"
45
+ },
37
46
  "scripts": {
38
- "postinstall": "node drivers/js/cli-postinstall.js",
39
47
  "test": "node scripts/docs-analytics-boundaries.test.mjs && node --test scripts/connection-quality.test.mjs && node drivers/js/test/smoke.test.mjs",
40
48
  "typecheck": "cargo check --workspace --locked",
41
49
  "lint": "cargo fmt --all -- --check && cargo clippy --workspace --locked -- -D warnings",
@@ -1,150 +0,0 @@
1
- /**
2
- * cli-postinstall.js — install/upgrade/skip for `@reddb-io/cli`.
3
- *
4
- * Asymmetry vs SDK postinstall (`drivers/js/postinstall.js`):
5
- * - SDK always downloads — wire format is version-coupled to the driver.
6
- * - CLI compares this package's version against `red --version` on PATH:
7
- * absent → install
8
- * older → upgrade (overwrite + one-line log)
9
- * equal → skip
10
- * newer → skip ("PATH binary is newer; leaving in place")
11
- * garbage → install (warn, treat as absent)
12
- *
13
- * See ADR 0007 for the rationale.
14
- *
15
- * Override hooks (env vars):
16
- * REDDB_SKIP_POSTINSTALL=1 do nothing
17
- * REDDB_BIN=/path/to/red skip — caller already has a binary
18
- * REDDB_POSTINSTALL_VERSION=… pull a different release tag
19
- * REDDB_POSTINSTALL_REPO=… pull from a fork (default: reddb-io/reddb)
20
- */
21
-
22
- import { createRequire } from 'node:module'
23
- import { fileURLToPath } from 'node:url'
24
- import { dirname, join, sep } from 'node:path'
25
- import { existsSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs'
26
- import { execSync } from 'node:child_process'
27
-
28
- import { fetchReleaseAsset } from './src/internal/asset-fetcher/index.js'
29
- import { compareInstalled } from './src/internal/version-compare/index.js'
30
-
31
- const HERE = dirname(fileURLToPath(import.meta.url))
32
- const require = createRequire(import.meta.url)
33
- // CLI manifest lives at the repo root (../../package.json), and that is the
34
- // `@reddb-io/cli` package — not the SDK manifest next to this file.
35
- const pkg = require('../../package.json')
36
-
37
- const DEFAULT_REPO = 'reddb-io/reddb'
38
-
39
- if (process.env.REDDB_SKIP_POSTINSTALL === '1') {
40
- process.stdout.write('reddb-cli: postinstall skipped (REDDB_SKIP_POSTINSTALL=1)\n')
41
- process.exit(0)
42
- }
43
-
44
- if (typeof process.env.REDDB_BIN === 'string' && process.env.REDDB_BIN !== '') {
45
- process.stdout.write(
46
- `reddb-cli: postinstall skipped (REDDB_BIN=${process.env.REDDB_BIN})\n`,
47
- )
48
- process.exit(0)
49
- }
50
-
51
- // Workspace-local install detection — see the matching block in
52
- // drivers/js/postinstall.js for the rationale.
53
- if (!HERE.includes(`${sep}node_modules${sep}`)) {
54
- process.stdout.write(
55
- 'reddb-cli: skipping postinstall — running from a workspace checkout (no node_modules/).\n' +
56
- ' Build `red` locally if you need it:\n' +
57
- ' cargo build --release --bin red\n' +
58
- ' export REDDB_BIN="$PWD/target/release/red"\n' +
59
- ' Set REDDB_SKIP_POSTINSTALL=1 to silence this message.\n',
60
- )
61
- process.exit(0)
62
- }
63
-
64
- main().catch((err) => {
65
- process.stderr.write(formatFailure(err))
66
- process.exit(0)
67
- })
68
-
69
- function formatFailure(err) {
70
- const repo = process.env.REDDB_POSTINSTALL_REPO || DEFAULT_REPO
71
- if (err && err.code === 'UNSUPPORTED_PLATFORM') {
72
- return (
73
- `reddb-cli: no prebuilt red binary for ${process.platform}/${process.arch}.\n` +
74
- ` Options:\n` +
75
- ` - build from source: https://github.com/${repo}#build-from-source\n` +
76
- ` - or set REDDB_BIN=/path/to/red.\n`
77
- )
78
- }
79
- if (err && err.code === 'ASSET_NOT_FOUND') {
80
- return (
81
- `reddb-cli: release asset not found at ${err.url}\n` +
82
- ` The GitHub Release for this CLI version likely has not been\n` +
83
- ` published yet. To unblock:\n` +
84
- ` 1. Use the installer (latest stable red on PATH):\n` +
85
- ` curl -fsSL https://raw.githubusercontent.com/${repo}/main/install.sh | bash\n` +
86
- ` 2. Or pull a specific tag and rebuild:\n` +
87
- ` REDDB_POSTINSTALL_VERSION=v1.0.5 npm rebuild @reddb-io/cli\n` +
88
- ` 3. Or point the launcher at an existing binary:\n` +
89
- ` export REDDB_BIN=/path/to/red\n` +
90
- ` Releases: https://github.com/${repo}/releases\n`
91
- )
92
- }
93
- return (
94
- `reddb-cli: postinstall could not download the binary (${err && err.message}).\n` +
95
- ` The package itself still installs. To use the CLI:\n` +
96
- ` - run the installer: curl -fsSL https://raw.githubusercontent.com/${repo}/main/install.sh | bash\n` +
97
- ` - or download manually from https://github.com/${repo}/releases\n` +
98
- ` - or set REDDB_BIN=/path/to/red.\n`
99
- )
100
- }
101
-
102
- async function main() {
103
- const verdict = compareInstalled({
104
- packageVersion: pkg.version,
105
- exec: () => execSync('red --version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }),
106
- })
107
-
108
- if (verdict.action === 'skip') {
109
- process.stdout.write(`reddb-cli: ${verdict.reason} — skipping download\n`)
110
- return
111
- }
112
-
113
- const repo = process.env.REDDB_POSTINSTALL_REPO || DEFAULT_REPO
114
- const tag = process.env.REDDB_POSTINSTALL_VERSION
115
- ? normalizeTag(process.env.REDDB_POSTINSTALL_VERSION)
116
- : `v${pkg.version}`
117
-
118
- const binDir = join(HERE, 'bin')
119
- const binaryPath = join(binDir, defaultBinaryName())
120
-
121
- if (verdict.action === 'install') {
122
- process.stdout.write(`reddb-cli: ${verdict.reason} — installing red ${tag}\n`)
123
- } else {
124
- // upgrade
125
- process.stdout.write(`reddb-cli: ${verdict.reason} — upgrading red to ${tag}\n`)
126
- }
127
-
128
- const body = await fetchReleaseAsset({
129
- repo,
130
- tag,
131
- platform: process.platform,
132
- arch: process.arch,
133
- binName: 'red',
134
- })
135
- mkdirSync(binDir, { recursive: true })
136
- writeFileSync(binaryPath, body)
137
- if (process.platform !== 'win32') {
138
- chmodSync(binaryPath, 0o755)
139
- }
140
- process.stdout.write(`reddb-cli: installed binary at ${binaryPath}\n`)
141
- }
142
-
143
- function defaultBinaryName() {
144
- return process.platform === 'win32' ? 'red.exe' : 'red'
145
- }
146
-
147
- function normalizeTag(value) {
148
- const v = String(value).trim()
149
- return v.startsWith('v') ? v : `v${v}`
150
- }
@@ -1,37 +0,0 @@
1
- /**
2
- * Resolve the GitHub release asset filename for a given Node
3
- * `process.platform` / `process.arch` pair and binary base name.
4
- *
5
- * Mapping is the existing scheme from `drivers/js/postinstall.js`:
6
- * linux + x64 → <bin>-linux-x86_64
7
- * linux + arm64 → <bin>-linux-aarch64
8
- * linux + arm/armv7l→ <bin>-linux-armv7
9
- * darwin + x64 → <bin>-macos-x86_64
10
- * darwin + arm64 → <bin>-macos-aarch64
11
- * win32 + x64 → <bin>-windows-x86_64.exe
12
- *
13
- * Throws `UnsupportedPlatformError` for any other combination.
14
- */
15
-
16
- export class UnsupportedPlatformError extends Error {
17
- constructor(platform, arch) {
18
- super(`unsupported platform/arch combination: ${platform}/${arch}`)
19
- this.name = 'UnsupportedPlatformError'
20
- this.code = 'UNSUPPORTED_PLATFORM'
21
- this.platform = platform
22
- this.arch = arch
23
- }
24
- }
25
-
26
- export function composeAssetName({ platform, arch, binName }) {
27
- if (typeof binName !== 'string' || binName === '') {
28
- throw new TypeError('composeAssetName: `binName` must be a non-empty string')
29
- }
30
- if (platform === 'linux' && arch === 'x64') return `${binName}-linux-x86_64`
31
- if (platform === 'linux' && arch === 'arm64') return `${binName}-linux-aarch64`
32
- if (platform === 'linux' && (arch === 'arm' || arch === 'armv7l')) return `${binName}-linux-armv7`
33
- if (platform === 'darwin' && arch === 'x64') return `${binName}-macos-x86_64`
34
- if (platform === 'darwin' && arch === 'arm64') return `${binName}-macos-aarch64`
35
- if (platform === 'win32' && arch === 'x64') return `${binName}-windows-x86_64.exe`
36
- throw new UnsupportedPlatformError(platform, arch)
37
- }
@@ -1,23 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
-
3
- export class ChecksumMismatchError extends Error {
4
- constructor(expected, actual) {
5
- super(`checksum mismatch: expected sha256=${expected}, got sha256=${actual}`)
6
- this.name = 'ChecksumMismatchError'
7
- this.code = 'CHECKSUM_MISMATCH'
8
- this.expected = expected
9
- this.actual = actual
10
- }
11
- }
12
-
13
- export function sha256Hex(buf) {
14
- return createHash('sha256').update(buf).digest('hex')
15
- }
16
-
17
- export function verifySha256(buf, expected) {
18
- const expectedNorm = String(expected).trim().toLowerCase()
19
- const actual = sha256Hex(buf)
20
- if (actual !== expectedNorm) {
21
- throw new ChecksumMismatchError(expectedNorm, actual)
22
- }
23
- }
@@ -1,89 +0,0 @@
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
- }
@@ -1,52 +0,0 @@
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
- }
@@ -1,57 +0,0 @@
1
- /**
2
- * @reddb-io/internal-bin-resolver — runtime lookup for a pinned binary.
3
- *
4
- * Precedence:
5
- * 1. `process.env[envVar]` if set and non-empty → returned verbatim.
6
- * No existence probe: the env var is the user's "I know what I'm
7
- * doing" override.
8
- * 2. `<packageRoot>/bin/<name>` if it exists.
9
- * 3. Otherwise throw an actionable error naming the env var, the
10
- * expected local path, and a one-line `pnpm install` hint.
11
- *
12
- * `PATH` is deliberately never consulted. SDK/client wire formats are
13
- * version-coupled to the binary; silent fallback to a stale `PATH`
14
- * binary fails as misframed RPC, not "command not found". See ADR 0006.
15
- */
16
-
17
- import { existsSync } from 'node:fs'
18
- import { join } from 'node:path'
19
-
20
- /**
21
- * @param {{ name: string, packageRoot: string, envVar: string }} opts
22
- * @returns {string} absolute path to the binary
23
- * @throws {Error} when neither env override nor local binary is usable
24
- */
25
- export function resolveBin(opts) {
26
- if (!opts || typeof opts !== 'object') {
27
- throw new TypeError('resolveBin: options object required')
28
- }
29
- const { name, packageRoot, envVar } = opts
30
- if (typeof name !== 'string' || name === '') {
31
- throw new TypeError('resolveBin: `name` must be a non-empty string')
32
- }
33
- if (typeof packageRoot !== 'string' || packageRoot === '') {
34
- throw new TypeError('resolveBin: `packageRoot` must be a non-empty string')
35
- }
36
- if (typeof envVar !== 'string' || envVar === '') {
37
- throw new TypeError('resolveBin: `envVar` must be a non-empty string')
38
- }
39
-
40
- const override = process.env?.[envVar]
41
- if (typeof override === 'string' && override !== '') {
42
- return override
43
- }
44
-
45
- const local = join(packageRoot, 'bin', name)
46
- if (existsSync(local)) {
47
- return local
48
- }
49
-
50
- throw new Error(
51
- `reddb: binary "${name}" not found.\n` +
52
- ` expected at: ${local}\n` +
53
- ` override: set ${envVar}=/path/to/${name}\n` +
54
- ` fix: re-run \`pnpm install\` (the postinstall script downloads it),\n` +
55
- ` or check the postinstall log for a download error.`,
56
- )
57
- }
@@ -1,163 +0,0 @@
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
- }