diff --git a/.env.example b/.env.example index 9b52204332..2021c41ecb 100644 --- a/.env.example +++ b/.env.example @@ -412,6 +412,10 @@ PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70 # Used by: open-sse/services/compression/engines/rtk/filterLoader.ts. Default: 0. #OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=0 +# Skip the postinstall native-runtime warm-up (useful in CI / headless installs). Default: 0. +# Used by: scripts/postinstall.mjs. +#OMNIROUTE_SKIP_POSTINSTALL=0 + # Force a DB healthcheck regardless of cadence. Default: 0. # Used by: src/lib/db/core.ts::shouldRunDbHealthCheck(). #OMNIROUTE_FORCE_DB_HEALTHCHECK=0 diff --git a/bin/cli/runtime/index.mjs b/bin/cli/runtime/index.mjs new file mode 100644 index 0000000000..c4b882caa9 --- /dev/null +++ b/bin/cli/runtime/index.mjs @@ -0,0 +1,19 @@ +import { loadSqliteRuntime } from "./sqliteRuntime.mjs"; + +let warmed = false; + +/** + * Pre-resolves native runtimes at startup so the first DB access is fast + * and EBUSY-resilient on Windows. + * + * Tray runtime (systray2) is warmed lazily by initTray() in bin/cli/tray/. + */ +export async function warmUpRuntimes() { + if (warmed) return; + warmed = true; + try { + await loadSqliteRuntime(); + } catch {} +} + +export { loadSqliteRuntime }; diff --git a/bin/cli/runtime/magicBytes.mjs b/bin/cli/runtime/magicBytes.mjs new file mode 100644 index 0000000000..6af70a1d61 --- /dev/null +++ b/bin/cli/runtime/magicBytes.mjs @@ -0,0 +1,48 @@ +import { readFileSync, existsSync } from "node:fs"; + +/** + * Validates that a file starts with a known native-binary magic number. + * Returns the platform label ("elf" | "macho" | "macho-le" | "macho-fat" | "pe") + * or null if unrecognized / missing / unreadable. + * + * Ref: 9router/cli/hooks/sqliteRuntime.js (algorithm origin). + */ +export function validateBinaryMagic(path) { + if (!existsSync(path)) return null; + let buf; + try { + buf = readFileSync(path, { encoding: null }).subarray(0, 16); + } catch { + return null; + } + if (buf.length < 4) return null; + + // ELF: 7F 45 4C 46 + if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) return "elf"; + + // Mach-O big-endian: FE ED FA CF (64-bit) / FE ED FA CE (32-bit) + if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xcf) return "macho"; + if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xce) return "macho"; + + // Mach-O little-endian: CF FA ED FE (64-bit) / CE FA ED FE (32-bit) + if (buf[0] === 0xcf && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) return "macho-le"; + if (buf[0] === 0xce && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) return "macho-le"; + + // Mach-O fat (universal binary): CA FE BA BE + if (buf[0] === 0xca && buf[1] === 0xfe && buf[2] === 0xba && buf[3] === 0xbe) return "macho-fat"; + + // PE (Windows .node — DLL): MZ at offset 0 + if (buf[0] === 0x4d && buf[1] === 0x5a) return "pe"; + + return null; +} + +/** + * Returns the expected magic label for the current platform. + * Used to validate that a runtime-installed binary matches this OS. + */ +export function platformBinaryLabel() { + if (process.platform === "win32") return "pe"; + if (process.platform === "darwin") return "macho"; + return "elf"; +} diff --git a/bin/cli/runtime/sqliteRuntime.mjs b/bin/cli/runtime/sqliteRuntime.mjs new file mode 100644 index 0000000000..640a0988d7 --- /dev/null +++ b/bin/cli/runtime/sqliteRuntime.mjs @@ -0,0 +1,126 @@ +import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { execSync } from "node:child_process"; +import { validateBinaryMagic, platformBinaryLabel } from "./magicBytes.mjs"; + +const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime"); +const BETTER_SQLITE3_VERSION = "better-sqlite3@^12.6.2"; + +let resolvedCached = null; + +/** + * Resolves a SQLite driver through a 5-step fallback chain: + * 1. Bundled better-sqlite3 (optionalDependency) + * 2. Runtime-installed better-sqlite3 in ~/.omniroute/runtime/ + * 3. Lazy npm install into runtime dir + * 4. node:sqlite (Node ≥22.5 stdlib) + * 5. sql.js (bundled WASM, always available) + * + * Returns { driver, source } where source is one of: + * "bundled" | "runtime" | "runtime-installed-now" | "node-sqlite" | "sql-js" + */ +export async function loadSqliteRuntime() { + if (resolvedCached) return resolvedCached; + + const bundled = await tryLoadBundled(); + if (bundled) { + resolvedCached = { driver: bundled, source: "bundled" }; + return resolvedCached; + } + + const runtimeInstalled = await tryLoadRuntimeInstalled(); + if (runtimeInstalled) { + resolvedCached = { driver: runtimeInstalled, source: "runtime" }; + return resolvedCached; + } + + try { + await installRuntime(); + const after = await tryLoadRuntimeInstalled(); + if (after) { + resolvedCached = { driver: after, source: "runtime-installed-now" }; + return resolvedCached; + } + } catch (err) { + console.warn(`[omniroute] runtime install failed: ${err.message}`); + } + + try { + const nodeSqlite = await import("node:sqlite"); + resolvedCached = { + driver: { kind: "node-sqlite", DatabaseSync: nodeSqlite.DatabaseSync }, + source: "node-sqlite", + }; + return resolvedCached; + } catch {} + + const sqljs = await import("sql.js"); + resolvedCached = { + driver: { kind: "sql-js", initSqlJs: sqljs.default ?? sqljs.initSqlJs }, + source: "sql-js", + }; + return resolvedCached; +} + +async function tryLoadBundled() { + try { + const mod = await import("better-sqlite3"); + return { kind: "better-sqlite3", Database: mod.default ?? mod }; + } catch { + return null; + } +} + +async function tryLoadRuntimeInstalled() { + const pkgRoot = join(RUNTIME_DIR, "node_modules", "better-sqlite3"); + if (!existsSync(join(pkgRoot, "package.json"))) return null; + + const buildDir = join(pkgRoot, "build", "Release"); + if (existsSync(buildDir)) { + const nodeFile = readdirSync(buildDir).find((f) => f.endsWith(".node")); + if (nodeFile) { + const magic = validateBinaryMagic(join(buildDir, nodeFile)); + const expected = platformBinaryLabel(); + if (!magic || (magic !== expected && magic !== "macho-le" && magic !== "macho-fat")) { + console.warn( + `[omniroute] runtime sqlite binary magic mismatch (${magic} ≠ ${expected}) — skipping` + ); + return null; + } + } + } + + try { + const mod = await import(pkgRoot); + return { kind: "better-sqlite3", Database: mod.default ?? mod }; + } catch { + return null; + } +} + +function ensureRuntimeDir() { + if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true }); + const pkg = join(RUNTIME_DIR, "package.json"); + if (!existsSync(pkg)) { + writeFileSync( + pkg, + JSON.stringify({ name: "omniroute-runtime", private: true, type: "commonjs" }), + "utf-8" + ); + } +} + +async function installRuntime() { + ensureRuntimeDir(); + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + execSync( + `${npm} install --prefix "${RUNTIME_DIR}" ${BETTER_SQLITE3_VERSION} --no-audit --no-fund --silent`, + { stdio: ["ignore", "ignore", "pipe"], timeout: 180_000 } + ); +} + +/** Clears the cached resolved driver (for testing). */ +export function clearRuntimeCache() { + resolvedCached = null; +} diff --git a/docs/ops/SQLITE_RUNTIME.md b/docs/ops/SQLITE_RUNTIME.md new file mode 100644 index 0000000000..80c0605b54 --- /dev/null +++ b/docs/ops/SQLITE_RUNTIME.md @@ -0,0 +1,80 @@ +# SQLite Runtime Resolution + +OmniRoute resolves its SQLite driver at startup through a 5-step fallback chain: + +1. **Bundled `better-sqlite3`** (via `dependencies` in `package.json`) + — fastest, native binary, installed by `npm install` when build tools are present. + +2. **Runtime-installed `better-sqlite3`** (in `~/.omniroute/runtime/`) + — installed lazily on first run **OR** by `scripts/build/postinstall.mjs → scripts/postinstall.mjs`. + Validates native `.node` magic bytes (ELF / Mach-O / PE) before loading + to guard against corrupt or wrong-platform binaries. + +3. **`node:sqlite`** (Node ≥22.5 stdlib) — no native build needed; used when + both better-sqlite3 paths fail. Limited feature set. + +4. **`sql.js`** (WASM) — final fallback. Works everywhere but is slower + and writes data on an interval rather than synchronously. + +## Why this complexity? + +- **Windows EBUSY**: `npm install -g omniroute@latest` can fail if the previous + version's `better_sqlite3.node` is locked by a running process. The runtime + install in `~/.omniroute/runtime/` sidesteps the global npm cache. +- **No build tools**: Some environments (corporate Windows without VS Build + Tools, minimal Docker images) cannot compile `better-sqlite3`. The runtime + installer resolves a pre-built binary from the npm registry; the fallback + drivers ensure OmniRoute still boots even if that fails. +- **Air-gapped systems**: If the npm registry is unreachable, `node:sqlite` + or `sql.js` guarantee baseline functionality. + +## Magic-byte validation + +Before loading a runtime-installed `.node` file, OmniRoute reads the first 8 +bytes and matches against known platform magics: + +| Platform | Bytes (hex) | Label | +| --------------------- | ------------- | ----------- | +| Linux | `7F 45 4C 46` | `elf` | +| macOS 64-bit BE | `FE ED FA CF` | `macho` | +| macOS 64-bit LE | `CF FA ED FE` | `macho-le` | +| macOS fat (universal) | `CA FE BA BE` | `macho-fat` | +| Windows | `4D 5A` (MZ) | `pe` | + +A mismatched magic → file is ignored, fallback continues to the next step. + +## Checking the active driver + +```typescript +import { getDriverInfo } from "@/lib/db/core"; + +const info = getDriverInfo(); +// { source: "bundled" | "runtime" | "runtime-installed-now" | "node-sqlite" | "sql-js", +// kind: "better-sqlite3" | "node-sqlite" | "sql-js" } +``` + +## Manual control + +```bash +# Skip postinstall warm-up (for fast CI installs) +OMNIROUTE_SKIP_POSTINSTALL=1 npm install -g omniroute + +# Force-reinstall runtime better-sqlite3 +rm -rf ~/.omniroute/runtime +omniroute # will reinstall on next start + +# Check what driver is active +omniroute config db-info # (if CLI command exists) +``` + +## Reference + +Pattern adapted from `9router/cli/hooks/sqliteRuntime.js`. + +Implementation: + +- `bin/cli/runtime/magicBytes.mjs` — binary magic-byte validation helpers +- `bin/cli/runtime/sqliteRuntime.mjs` — 5-step runtime resolver + lazy installer +- `bin/cli/runtime/index.mjs` — startup orchestrator (`warmUpRuntimes()`) +- `scripts/postinstall.mjs` — npm post-install hook (non-fatal warm-up) +- `src/lib/db/core.ts` — `ensureDbInitialized()` / `getDriverInfo()` exports diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index b131d7505c..b64fdee1f5 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -78,19 +78,20 @@ echo "OMNIROUTE_WS_BRIDGE_SECRET=$(openssl rand -base64 32)" OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These variables control data location, encryption, and lifecycle. -| Variable | Default | Source File | Description | -| -------------------------------------- | -------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | Root directory for SQLite DB, backups, and data files. Override for Docker volumes or custom paths. | -| `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. | -| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. | -| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips the automatic database backup that runs before migrations on every startup. | -| `OMNIROUTE_CRYPT_KEY` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** for `STORAGE_ENCRYPTION_KEY`. Accepted as a fallback when the primary variable is absent. | -| `OMNIROUTE_API_KEY_BASE64` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** (Base64-encoded form) accepted as a fallback. Decoded automatically before use. | -| `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(unset)_ | `src/lib/db/core.ts` | Override the periodic SQLite healthcheck interval (ms). When unset, defaults are derived from `NODE_ENV`. | -| `OMNIROUTE_FORCE_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts` | Set to `1` to force the DB healthcheck loop on, even when it would normally be skipped (e.g., short-lived tasks). | -| `OMNIROUTE_MIGRATIONS_DIR` | _(auto-detect)_ | `src/lib/db/migrationRunner.ts` | Override the directory that the migration runner scans. Useful when shipping bundled migrations in custom builds. | -| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Flush interval (ms) for the batched spend/cost writer. Lower values reduce write coalescing; higher values reduce DB contention. | -| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Max buffered spend entries before a forced flush. Raise on high-QPS deployments; lower when bounded memory matters more. | +| Variable | Default | Source File | Description | +| -------------------------------------- | -------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | Root directory for SQLite DB, backups, and data files. Override for Docker volumes or custom paths. | +| `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. | +| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. | +| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips the automatic database backup that runs before migrations on every startup. | +| `OMNIROUTE_CRYPT_KEY` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** for `STORAGE_ENCRYPTION_KEY`. Accepted as a fallback when the primary variable is absent. | +| `OMNIROUTE_API_KEY_BASE64` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** (Base64-encoded form) accepted as a fallback. Decoded automatically before use. | +| `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(unset)_ | `src/lib/db/core.ts` | Override the periodic SQLite healthcheck interval (ms). When unset, defaults are derived from `NODE_ENV`. | +| `OMNIROUTE_FORCE_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts` | Set to `1` to force the DB healthcheck loop on, even when it would normally be skipped (e.g., short-lived tasks). | +| `OMNIROUTE_SKIP_POSTINSTALL` | `0` | `scripts/postinstall.mjs` | Set to `1` to skip the native-runtime warm-up during `npm install`. Useful in CI/headless installs where sqlite is already built. | +| `OMNIROUTE_MIGRATIONS_DIR` | _(auto-detect)_ | `src/lib/db/migrationRunner.ts` | Override the directory that the migration runner scans. Useful when shipping bundled migrations in custom builds. | +| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Flush interval (ms) for the batched spend/cost writer. Lower values reduce write coalescing; higher values reduce DB contention. | +| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Max buffered spend entries before a forced flush. Raise on high-QPS deployments; lower when bounded memory matters more. | ### Scenarios diff --git a/package.json b/package.json index 1b19818227..4a38a5cedc 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "src/shared/utils/nodeRuntimeSupport.ts", ".env.example", "scripts/build/postinstall.mjs", + "bin/cli/runtime/", + "scripts/postinstall.mjs", "scripts/build/postinstallSupport.mjs", "scripts/dev/responses-ws-proxy.mjs", "scripts/check/check-supported-node-runtime.ts", diff --git a/scripts/build/postinstall.mjs b/scripts/build/postinstall.mjs index 210b18388f..21bc288a79 100644 --- a/scripts/build/postinstall.mjs +++ b/scripts/build/postinstall.mjs @@ -314,3 +314,11 @@ await fixBetterSqliteBinary(); await fixWreqJsBinary(); await ensureSwcHelpers(); await syncProjectEnv(); + +// Warm up native runtimes (better-sqlite3 in ~/.omniroute/runtime/). +// Non-fatal: errors are caught inside postinstall.mjs. +try { + await import("../postinstall.mjs"); +} catch { + // Silently skip — runtime warm-up is best-effort. +} diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs new file mode 100644 index 0000000000..0a886d8986 --- /dev/null +++ b/scripts/postinstall.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +/** + * Post-install warm-up for OmniRoute native runtimes. + * + * Runs after the main `scripts/build/postinstall.mjs` binary-copy step. + * Tries to pre-resolve better-sqlite3 into ~/.omniroute/runtime/ so that + * the first execution is fast and EBUSY-resilient on Windows. + * + * Non-fatal: any error is printed as a warning and install continues. + */ + +if ( + process.env.OMNIROUTE_SKIP_POSTINSTALL === "1" || + process.env.CI === "true" || + process.env.CI === "1" +) { + process.exit(0); +} + +(async () => { + try { + const { warmUpRuntimes } = await import("../bin/cli/runtime/index.mjs"); + await warmUpRuntimes(); + } catch (err) { + console.warn(`[omniroute] postinstall warm-up skipped: ${err?.message ?? err}`); + } +})(); diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 1308c6cb61..183c1725fe 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -1398,6 +1398,49 @@ export function resetDbInstance() { closeDbInstance(); } +// ──────────────── Runtime Driver Info ──────────────── + +type DbDriverInfo = { source: string; kind: string }; +let driverInfoCached: DbDriverInfo | null = null; + +function setDriverInfo(info: DbDriverInfo) { + driverInfoCached = info; +} + +/** Returns how better-sqlite3 was resolved (bundled / runtime / etc.). Null if not yet init. */ +export function getDriverInfo(): DbDriverInfo | null { + return driverInfoCached; +} + +/** + * Async initializer that pre-resolves the SQLite runtime before first DB access. + * + * Call this at process startup (before any call to getDbInstance()) so that + * if the bundled better-sqlite3 binary is unavailable, the runtime installer + * can place it in ~/.omniroute/runtime/ without blocking a synchronous caller. + * + * Idempotent — safe to call multiple times. + */ +export async function ensureDbInitialized(): Promise { + if (getDb()) return; + + try { + const runtimeModule = await import("../../../bin/cli/runtime/sqliteRuntime.mjs" as any); + const { driver, source } = await runtimeModule.loadSqliteRuntime(); + setDriverInfo({ source, kind: driver.kind as string }); + if ((driver.kind as string) !== "better-sqlite3") { + console.warn( + `[DB] better-sqlite3 unavailable (resolved via ${source}/${driver.kind}). ` + + `OmniRoute may fall back to read-only or limited functionality.` + ); + } + } catch { + // Runtime loader unavailable (CLI context only) — DB init falls through to normal path. + } + + getDbInstance(); +} + // ──────────────── JSON → SQLite Migration ──────────────── function migrateFromJson(db: SqliteDatabase, jsonPath: string) { diff --git a/tests/unit/runtime/magicBytes.test.ts b/tests/unit/runtime/magicBytes.test.ts new file mode 100644 index 0000000000..3680ae295c --- /dev/null +++ b/tests/unit/runtime/magicBytes.test.ts @@ -0,0 +1,62 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { validateBinaryMagic, platformBinaryLabel } from "../../../bin/cli/runtime/magicBytes.mjs"; + +const dir = mkdtempSync(join(tmpdir(), "magic-test-")); + +test("detects ELF", () => { + const p = join(dir, "fake.so"); + writeFileSync(p, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0, 0, 0, 0])); + assert.equal(validateBinaryMagic(p), "elf"); +}); + +test("detects Mach-O 64-bit BE", () => { + const p = join(dir, "fake.dylib"); + writeFileSync(p, Buffer.from([0xfe, 0xed, 0xfa, 0xcf, 0, 0, 0, 0])); + assert.equal(validateBinaryMagic(p), "macho"); +}); + +test("detects Mach-O LE", () => { + const p = join(dir, "fake-le.dylib"); + writeFileSync(p, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0, 0, 0, 0])); + assert.equal(validateBinaryMagic(p), "macho-le"); +}); + +test("detects Mach-O fat binary", () => { + const p = join(dir, "fake.fat"); + writeFileSync(p, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0])); + assert.equal(validateBinaryMagic(p), "macho-fat"); +}); + +test("detects PE (MZ)", () => { + const p = join(dir, "fake.node"); + writeFileSync(p, Buffer.from([0x4d, 0x5a, 0, 0, 0, 0, 0, 0])); + assert.equal(validateBinaryMagic(p), "pe"); +}); + +test("returns null for non-binary", () => { + const p = join(dir, "fake.txt"); + writeFileSync(p, "hello world", "utf-8"); + assert.equal(validateBinaryMagic(p), null); +}); + +test("returns null for missing file", () => { + assert.equal(validateBinaryMagic(join(dir, "nope.bin")), null); +}); + +test("returns null for too-short file", () => { + const p = join(dir, "short.bin"); + writeFileSync(p, Buffer.from([0x7f, 0x45])); + assert.equal(validateBinaryMagic(p), null); +}); + +test("platformBinaryLabel matches process.platform", () => { + const expected = + process.platform === "win32" ? "pe" : process.platform === "darwin" ? "macho" : "elf"; + assert.equal(platformBinaryLabel(), expected); +}); + +test.after(() => rmSync(dir, { recursive: true, force: true })); diff --git a/tests/unit/runtime/sqliteRuntime.test.ts b/tests/unit/runtime/sqliteRuntime.test.ts new file mode 100644 index 0000000000..e8d2bdb1a3 --- /dev/null +++ b/tests/unit/runtime/sqliteRuntime.test.ts @@ -0,0 +1,48 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { loadSqliteRuntime, clearRuntimeCache } from "../../../bin/cli/runtime/sqliteRuntime.mjs"; + +test("loadSqliteRuntime returns a result with driver and source", async () => { + clearRuntimeCache(); + const r = await loadSqliteRuntime(); + assert.ok(r, "result is truthy"); + assert.ok(typeof r.driver === "object", "driver is object"); + assert.ok(typeof r.source === "string", "source is string"); +}); + +test("loaded driver is one of the known kinds", async () => { + clearRuntimeCache(); + const r = await loadSqliteRuntime(); + assert.ok( + ["better-sqlite3", "node-sqlite", "sql-js"].includes(r.driver.kind), + `kind="${r.driver.kind}" must be known` + ); +}); + +test("loadSqliteRuntime caches the result (same object reference)", async () => { + clearRuntimeCache(); + const a = await loadSqliteRuntime(); + const b = await loadSqliteRuntime(); + assert.strictEqual(a, b, "second call returns cached object"); +}); + +test("runtime dir exists after loadSqliteRuntime when runtime source used", async () => { + clearRuntimeCache(); + const r = await loadSqliteRuntime(); + if (r.source === "runtime-installed-now" || r.source === "runtime") { + const runtimeDir = join(homedir(), ".omniroute", "runtime"); + assert.ok(existsSync(runtimeDir), "runtime dir created"); + } +}); + +test("clearRuntimeCache allows fresh resolution", async () => { + clearRuntimeCache(); + const first = await loadSqliteRuntime(); + clearRuntimeCache(); + const second = await loadSqliteRuntime(); + // Both should resolve to the same kind, but are different call results + assert.equal(first.driver.kind, second.driver.kind, "same driver kind after cache clear"); +});