Merge pull request #178 from tobi/release/v1.0.0

Release v1.0.0
This commit is contained in:
Tobias Lütke 2026-02-15 22:59:04 -04:00 committed by GitHub
commit 7acba1c451
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 210 additions and 91 deletions

View File

@ -7,20 +7,51 @@ on:
branches: [main]
jobs:
test:
test-node:
name: Node ${{ matrix.node-version }} (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
node-version: ["22", "23"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install SQLite (Ubuntu)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
- name: Install SQLite (macOS)
if: runner.os == 'macOS'
run: brew install sqlite
- run: npm install
- name: Tests
run: npx vitest run --reporter=verbose test/
env:
CI: true
test-bun:
name: Bun (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
bun-version: ["latest", "1.1.0"]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ matrix.bun-version }}
bun-version: latest
- name: Install SQLite (Ubuntu)
if: runner.os == 'Linux'
@ -32,4 +63,9 @@ jobs:
- run: bun install
- run: bun test
- name: Tests
run: bun test --timeout 30000 --preload ./src/test-preload.ts test/
env:
CI: true
DYLD_LIBRARY_PATH: /opt/homebrew/opt/sqlite/lib
LD_LIBRARY_PATH: /usr/lib/x86_64-linux-gnu

View File

@ -2,6 +2,39 @@
All notable changes to QMD will be documented in this file.
## [1.0.0] - 2026-02-15
### Node.js Compatibility
QMD now runs on both **Node.js (>=22)** and **Bun**. Install with `npm install -g @tobilu/qmd` or `bun install -g @tobilu/qmd` — your choice. The `qmd` wrapper auto-detects Node.js via `tsx` and works out of the box with mise, asdf, nvm, and Homebrew installs.
### Performance
- **Parallel embedding & reranking** — multiple contexts split work across CPU cores (or VRAM on GPU), delivering up to **2.7x faster reranking** and significantly faster embedding on multi-core machines
- **Flash attention** — ~20% less VRAM per reranking context, enabling more parallel contexts on GPU
- **Right-sized contexts** — reranker context dropped from 40960 to 2048 tokens (17x less memory), since chunks are capped at ~900 tokens
- **Adaptive parallelism** — automatically scales context count based on available VRAM (GPU) or CPU math cores
- **CPU thread splitting** — each context runs on its own cores for true parallelism instead of contending on a single context
### GPU Auto-Detection
- Probes for CUDA, Metal, and Vulkan at startup — uses the best available backend
- Falls back gracefully to CPU with a warning if GPU init fails
- `qmd status` now shows device info (GPU type, VRAM usage)
### Test Suite
- Tests split into `src/*.test.ts` (unit), `src/models/*.test.ts` (model), and `src/integration/*.test.ts` (CLI/integration)
- Vitest config for Node.js; bun test still works for Bun
- New `eval-bm25` and `store.helpers.unit` test suites
### Fixes
- Prevent VRAM waste from duplicate context creation during concurrent loads
- Collection-aware FTS filtering for scoped keyword search
---
## [0.9.0] - 2026-02-15
Initial public release.
@ -30,5 +63,6 @@ Initial public release.
- BM25 score normalization with Math.abs
- Bun UTF-8 path corruption workaround
[1.0.0]: https://github.com/tobi/qmd/releases/tag/v1.0.0
[0.9.0]: https://github.com/tobi/qmd/releases/tag/v0.9.0

View File

@ -122,23 +122,15 @@ bun src/qmd.ts <command> # Run from source
bun link # Install globally as 'qmd'
```
## Test Layout
## Tests
- `src/*.test.ts` = unit tests
- `src/models/*.test.ts` = tests that require model/runtime setup
- `src/integration/*.test.ts` = integration tests (CLI subprocesses, daemon/server behavior)
### Run Order
All tests live in `test/`. Run everything:
```sh
npx vitest run --reporter=verbose src/*.test.ts
npx vitest run --reporter=verbose src/models/*.test.ts
npx vitest run --reporter=verbose src/integration/*.test.ts
npx vitest run --reporter=verbose test/
bun test --preload ./src/test-preload.ts test/
```
Use this order for faster feedback:
`unit -> models -> integration`.
## Architecture
- SQLite FTS5 for full-text search (BM25)

View File

@ -1,6 +1,6 @@
{
"name": "@tobilu/qmd",
"version": "0.9.9",
"version": "1.0.0",
"description": "Query Markup Documents - On-device hybrid search for markdown files with BM25, vector search, and LLM reranking",
"type": "module",
"bin": {
@ -15,19 +15,7 @@
"CHANGELOG.md"
],
"scripts": {
"test": "vitest run",
"test:unit": "vitest run --reporter=verbose src/*.test.ts",
"test:models": "vitest run --reporter=verbose src/models/*.test.ts",
"test:integration": "vitest run --reporter=verbose src/integration/*.test.ts",
"test:unit:bun": "bun run vitest run --reporter=verbose --testTimeout=120000 src/*.test.ts",
"test:models:bun": "bun run vitest run --reporter=verbose --testTimeout=120000 src/models/*.test.ts",
"test:integration:bun": "bun run vitest run --reporter=verbose --testTimeout=120000 src/integration/*.test.ts",
"test:unit:node": "npx vitest run --reporter=verbose --testTimeout=120000 src/*.test.ts",
"test:models:node": "npx vitest run --reporter=verbose --testTimeout=120000 src/models/*.test.ts",
"test:integration:node": "npx vitest run --reporter=verbose --testTimeout=120000 src/integration/*.test.ts",
"test:ci:bun": "npm run test:unit:bun && npm run test:models:bun && npm run test:integration:bun",
"test:ci:node": "npm run test:unit:node && npm run test:models:node && npm run test:integration:node",
"test:ci": "npm run test:unit && npm run test:models && npm run test:integration",
"test": "vitest run --reporter=verbose test/",
"qmd": "tsx src/qmd.ts",
"index": "tsx src/qmd.ts index",
"vector": "tsx src/qmd.ts vector",

52
src/db.ts Normal file
View File

@ -0,0 +1,52 @@
/**
* db.ts - Cross-runtime SQLite compatibility layer
*
* Provides a unified Database export that works under both Bun (bun:sqlite)
* and Node.js (better-sqlite3). The APIs are nearly identical the main
* difference is the import path.
*/
export const isBun = typeof globalThis.Bun !== "undefined";
let _Database: any;
let _sqliteVecLoad: (db: any) => void;
if (isBun) {
_Database = (await import("bun:sqlite")).Database;
const { getLoadablePath } = await import("sqlite-vec");
_sqliteVecLoad = (db: any) => db.loadExtension(getLoadablePath());
} else {
_Database = (await import("better-sqlite3")).default;
const sqliteVec = await import("sqlite-vec");
_sqliteVecLoad = (db: any) => sqliteVec.load(db);
}
/**
* Open a SQLite database. Works with both bun:sqlite and better-sqlite3.
*/
export function openDatabase(path: string): Database {
return new _Database(path) as Database;
}
/**
* Common subset of the Database interface used throughout QMD.
*/
export interface Database {
exec(sql: string): void;
prepare(sql: string): Statement;
loadExtension(path: string): void;
close(): void;
}
export interface Statement {
run(...params: any[]): { changes: number; lastInsertRowid: number | bigint };
get(...params: any[]): any;
all(...params: any[]): any[];
}
/**
* Load the sqlite-vec extension into a database.
*/
export function loadSqliteVec(db: Database): void {
_sqliteVecLoad(db);
}

View File

@ -8,6 +8,7 @@
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "url";
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@ -553,6 +554,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
const store = createStore();
const mcpServer = createMcpServer(store);
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
enableJsonResponse: true,
});
await mcpServer.connect(transport);

View File

@ -1,4 +1,5 @@
import Database from "better-sqlite3";
import { openDatabase } from "./db.js";
import type { Database } from "./db.js";
import fastGlob from "fast-glob";
import { execSync, spawn as nodeSpawn } from "child_process";
import { fileURLToPath } from "url";

View File

@ -11,11 +11,11 @@
* const store = createStore();
*/
import Database from "better-sqlite3";
import { openDatabase, loadSqliteVec } from "./db.js";
import type { Database } from "./db.js";
import picomatch from "picomatch";
import { createHash } from "crypto";
import { realpathSync, statSync, mkdirSync } from "node:fs";
import * as sqliteVec from "sqlite-vec";
import {
LlamaCpp,
getDefaultLlamaCpp,
@ -618,22 +618,16 @@ export function verifySqliteVecLoaded(db: Database): void {
}
}
let _sqliteVecAvailable: boolean | null = null;
function initializeDatabase(db: Database): void {
try {
sqliteVec.load(db);
loadSqliteVec(db);
verifySqliteVecLoaded(db);
} catch (err) {
const message = getErrorMessage(err);
if (message.includes("does not support dynamic extension loading")) {
throw createSqliteVecUnavailableError("SQLite build does not support dynamic extension loading");
}
if (message.includes("sqlite-vec extension is unavailable")) {
throw err;
}
throw err;
_sqliteVecAvailable = true;
} catch {
// sqlite-vec is optional — vector search won't work but FTS is fine
_sqliteVecAvailable = false;
}
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON");
@ -747,7 +741,14 @@ function initializeDatabase(db: Database): void {
}
export function isSqliteVecAvailable(): boolean {
return _sqliteVecAvailable === true;
}
function ensureVecTableInternal(db: Database, dimensions: number): void {
if (!_sqliteVecAvailable) {
throw new Error("sqlite-vec is not available. Vector operations require a SQLite build with extension loading support.");
}
const tableInfo = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get() as { sql: string } | null;
if (tableInfo) {
const match = tableInfo.sql.match(/float\[(\d+)\]/);
@ -845,7 +846,7 @@ export type Store = {
*/
export function createStore(dbPath?: string): Store {
const resolvedPath = dbPath || getDefaultDbPath();
const db = new Database(resolvedPath);
const db = openDatabase(resolvedPath);
initializeDatabase(db);
return {

View File

@ -21,11 +21,10 @@ let testConfigDir: string;
let fixturesDir: string;
let testCounter = 0; // Unique counter for each test run
// Get the directory where this test file lives (same as qmd.ts)
const qmdDir = dirname(fileURLToPath(import.meta.url));
const qmdSrcDir = join(qmdDir, "..");
const projectRoot = join(qmdSrcDir, "..");
const qmdScript = join(qmdSrcDir, "qmd.ts");
// Get the directory where this test file lives
const thisDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(thisDir, "..");
const qmdScript = join(projectRoot, "src", "qmd.ts");
// Resolve tsx binary from project's node_modules (not cwd-dependent)
const tsxBin = (() => {
const candidate = join(projectRoot, "node_modules", ".bin", "tsx");

View File

@ -8,7 +8,7 @@ import { describe, test, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync, rmSync, readFileSync, readdirSync } from "fs";
import { join, dirname } from "path";
import { tmpdir } from "os";
import Database from "better-sqlite3";
import type { Database } from "../src/db.js";
import { createHash } from "crypto";
import { fileURLToPath } from "url";
@ -17,7 +17,7 @@ import {
searchFTS,
insertDocument,
insertContent,
} from "./store";
} from "../src/store";
// Set INDEX_PATH before importing store to prevent using global index
const tempDir = mkdtempSync(join(tmpdir(), "qmd-eval-unit-"));
@ -92,7 +92,7 @@ describe("BM25 Search (FTS)", () => {
db = store.db;
// Load and index eval documents
const evalDocsDir = join(dirname(fileURLToPath(import.meta.url)), "../test/eval-docs");
const evalDocsDir = join(dirname(fileURLToPath(import.meta.url)), "eval-docs");
const files = readdirSync(evalDocsDir).filter(f => f.endsWith(".md"));
for (const file of files) {

View File

@ -14,7 +14,8 @@ import { describe, test, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync, rmSync, readFileSync, readdirSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import Database from "better-sqlite3";
import { openDatabase } from "../src/db.js";
import type { Database } from "../src/db.js";
import { createHash } from "crypto";
import { fileURLToPath } from "url";
import { dirname } from "path";
@ -34,8 +35,8 @@ import {
reciprocalRankFusion,
DEFAULT_EMBED_MODEL,
type RankedResult,
} from "../store";
import { getDefaultLlamaCpp, formatDocForEmbedding, disposeDefaultLlamaCpp } from "../llm";
} from "../src/store";
import { getDefaultLlamaCpp, formatDocForEmbedding, disposeDefaultLlamaCpp } from "../src/llm";
// Eval queries with expected documents
const evalQueries: {
@ -109,7 +110,7 @@ describe("BM25 Search (FTS)", () => {
db = store.db;
// Load and index eval documents
const evalDocsDir = join(dirname(fileURLToPath(import.meta.url)), "../../test/eval-docs");
const evalDocsDir = join(dirname(fileURLToPath(import.meta.url)), "eval-docs");
const files = readdirSync(evalDocsDir).filter(f => f.endsWith(".md"));
for (const file of files) {
@ -181,7 +182,7 @@ describe.skipIf(!!process.env.CI)("Vector Search", () => {
const llm = getDefaultLlamaCpp();
store.ensureVecTable(768); // embeddinggemma uses 768 dimensions
const evalDocsDir = join(dirname(fileURLToPath(import.meta.url)), "../../test/eval-docs");
const evalDocsDir = join(dirname(fileURLToPath(import.meta.url)), "eval-docs");
const files = readdirSync(evalDocsDir).filter(f => f.endsWith(".md"));
for (const file of files) {

View File

@ -27,8 +27,8 @@ import {
documentToXml,
formatDocument,
type MultiGetFile,
} from "./formatter.js";
import type { SearchResult, DocumentResult } from "./store.js";
} from "../src/formatter.js";
import type { SearchResult, DocumentResult } from "../src/store.js";
// =============================================================================
// Test Fixtures

View File

@ -17,7 +17,7 @@ import {
SessionReleasedError,
type RerankDocument,
type ILLMSession,
} from "../llm.js";
} from "../src/llm.js";
// =============================================================================
// Singleton Tests (no model loading required)

View File

@ -6,16 +6,16 @@
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import Database from "better-sqlite3";
import * as sqliteVec from "sqlite-vec";
import { openDatabase, loadSqliteVec } from "../src/db.js";
import type { Database } from "../src/db.js";
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { getDefaultLlamaCpp, disposeDefaultLlamaCpp } from "../llm";
import { getDefaultLlamaCpp, disposeDefaultLlamaCpp } from "../src/llm";
import { mkdtemp, writeFile, readdir, unlink, rmdir } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import YAML from "yaml";
import type { CollectionConfig } from "../collections";
import type { CollectionConfig } from "../src/collections";
// =============================================================================
// Test Database Setup
@ -31,7 +31,7 @@ afterAll(async () => {
});
function initTestDatabase(db: Database): void {
sqliteVec.load(db);
loadSqliteVec(db);
db.exec("PRAGMA journal_mode = WAL");
// Content-addressable storage - the source of truth for document content
@ -192,8 +192,8 @@ import {
DEFAULT_RERANK_MODEL,
DEFAULT_MULTI_GET_MAX_BYTES,
createStore,
} from "../store";
import type { RankedResult } from "../store";
} from "../src/store";
import type { RankedResult } from "../src/store";
// Note: searchResultsToMcpCsv no longer used in MCP - using structuredContent instead
// =============================================================================
@ -226,7 +226,7 @@ describe("MCP Server", () => {
await writeFile(join(testConfigDir, "index.yml"), YAML.stringify(testConfig));
testDbPath = `/tmp/qmd-mcp-test-${Date.now()}.sqlite`;
testDb = new Database(testDbPath);
testDb = openDatabase(testDbPath);
initTestDatabase(testDb);
seedTestData(testDb);
});
@ -306,7 +306,7 @@ describe("MCP Server", () => {
});
test("returns empty when no vector table exists", async () => {
const emptyDb = new Database(":memory:");
const emptyDb = openDatabase(":memory:");
initTestDatabase(emptyDb);
emptyDb.exec("DROP TABLE IF EXISTS vectors_vec");
@ -649,7 +649,7 @@ describe("MCP Server", () => {
WHERE d.path = ? AND d.active = 1
`).get(path) as { filepath: string; display_path: string; body: string } | null;
expect(doc).toBeUndefined();
expect(doc == null).toBe(true); // bun:sqlite returns null, better-sqlite3 returns undefined
});
test("includes context in document body", () => {
@ -865,8 +865,8 @@ describe("MCP Server", () => {
// HTTP Transport Tests
// =============================================================================
import { startMcpHttpServer, type HttpServerHandle } from "../mcp";
import { enableProductionMode } from "../store";
import { startMcpHttpServer, type HttpServerHandle } from "../src/mcp";
import { enableProductionMode } from "../src/store";
describe("MCP HTTP Transport", () => {
let handle: HttpServerHandle;
@ -880,7 +880,7 @@ describe("MCP HTTP Transport", () => {
beforeAll(async () => {
// Create isolated test database with seeded data
httpTestDbPath = `/tmp/qmd-mcp-http-test-${Date.now()}.sqlite`;
const db = new Database(httpTestDbPath);
const db = openDatabase(httpTestDbPath);
initTestDatabase(db);
seedTestData(db);
db.close();
@ -946,17 +946,28 @@ describe("MCP HTTP Transport", () => {
// MCP protocol over HTTP
// ---------------------------------------------------------------------------
/** Track session ID returned by initialize (MCP Streamable HTTP spec) */
let sessionId: string | null = null;
/** Send a JSON-RPC message to /mcp and return the parsed response.
* MCP Streamable HTTP requires Accept header with both JSON and SSE. */
async function mcpRequest(body: object): Promise<{ status: number; json: any; contentType: string | null }> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
};
if (sessionId) headers["mcp-session-id"] = sessionId;
const res = await fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
headers,
body: JSON.stringify(body),
});
// Capture session ID from initialize responses
const sid = res.headers.get("mcp-session-id");
if (sid) sessionId = sid;
const json = await res.json();
return { status: res.status, json, contentType: res.headers.get("content-type") };
}

View File

@ -16,7 +16,7 @@ import {
normalizePathSeparators,
getRelativePathFromPrefix,
resolve,
} from "./store.js";
} from "../src/store.js";
// =============================================================================
// Test Utilities

View File

@ -15,7 +15,7 @@ import {
normalizeDocid,
isDocid,
handelize,
} from "./store";
} from "../src/store";
// =============================================================================
// Path Utilities

View File

@ -7,13 +7,13 @@
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest";
import Database from "better-sqlite3";
import * as sqliteVec from "sqlite-vec";
import { openDatabase, loadSqliteVec } from "../src/db.js";
import type { Database } from "../src/db.js";
import { unlink, mkdtemp, rmdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import YAML from "yaml";
import { disposeDefaultLlamaCpp } from "../llm.js";
import { disposeDefaultLlamaCpp } from "../src/llm.js";
import {
createStore,
verifySqliteVecLoaded,
@ -49,8 +49,8 @@ import {
type DocumentResult,
type SearchResult,
type RankedResult,
} from "../store.js";
import type { CollectionConfig } from "../collections.js";
} from "../src/store.js";
import type { CollectionConfig } from "../src/collections.js";
// =============================================================================
// LlamaCpp Setup
@ -431,7 +431,8 @@ describe("Store Creation", () => {
test("createStore creates a new store with custom path", async () => {
const store = await createTestStore();
expect(store.dbPath).toBe(testDbPath);
expect(store.db).toBeInstanceOf(Database);
expect(store.db).toBeDefined();
expect(typeof store.db.exec).toBe("function");
await cleanupTestDb(store);
});
@ -461,7 +462,7 @@ describe("Store Creation", () => {
});
test("verifySqliteVecLoaded throws when sqlite-vec is not loaded", () => {
const db = new Database(":memory:");
const db = openDatabase(":memory:");
try {
expect(() => verifySqliteVecLoaded(db)).toThrow("sqlite-vec extension is unavailable");
} finally {
@ -470,9 +471,9 @@ describe("Store Creation", () => {
});
test("verifySqliteVecLoaded succeeds when sqlite-vec is loaded", () => {
const db = new Database(":memory:");
const db = openDatabase(":memory:");
try {
sqliteVec.load(db);
loadSqliteVec(db);
expect(() => verifySqliteVecLoaded(db)).not.toThrow();
} finally {
db.close();

View File

@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
testTimeout: 30000,
include: ["test/**/*.test.ts"],
},
});