Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ As of May 2026, SQLRite has:
- Full-text search + hybrid retrieval (Phase 8 complete): FTS5-style inverted index with BM25 ranking + `fts_match` / `bm25_score` scalar functions + `try_fts_probe` optimizer hook + on-disk persistence with on-demand v4 → v5 file-format bump (8a-8c), a worked hybrid-retrieval example combining BM25 with vector cosine via raw arithmetic (8d), and a `bm25_search` MCP tool symmetric with `vector_search` (8e). See [`docs/fts.md`](fts.md).
- SQL surface + DX follow-ups (Phase 9 complete, v0.2.0 → v0.9.1): DDL completeness — `DEFAULT`, `DROP TABLE` / `DROP INDEX`, `ALTER TABLE` (9a); free-list + manual `VACUUM` (9b) + auto-VACUUM (9c); `IS NULL` / `IS NOT NULL` (9d); `GROUP BY` + aggregates + `DISTINCT` + `LIKE` + `IN` (9e); four flavors of `JOIN` — INNER, LEFT, RIGHT, FULL OUTER (9f); prepared statements + `?` parameter binding with a per-connection LRU plan cache (9g); HNSW probe widened to cosine + dot via `WITH (metric = …)` (9h); `PRAGMA` dispatcher with the `auto_vacuum` knob (9i)
- Benchmarks against SQLite + DuckDB (Phase 10 complete, SQLR-4 / SQLR-16): twelve-workload bench harness with a pluggable `Driver` trait, criterion-driven, pinned-host runs published. See [`docs/benchmarks.md`](benchmarks.md).
- Phase 11 (concurrent writes via MVCC + `BEGIN CONCURRENT`, SQLR-22) is in flight. **11.1 multi-connection foundation: shipped on this branch.** `Connection` is now `Send + Sync` and `Connection::connect()` mints a sibling handle that shares the same backing `Database`. Snapshot-isolation reads + `BEGIN CONCURRENT` writes follow in 11.3 / 11.4. Plan: [`docs/concurrent-writes-plan.md`](concurrent-writes-plan.md).
- A fully-automated release pipeline that ships every product to its registry on every release with one human action — Rust engine + `sqlrite-ask` + `sqlrite-mcp` to crates.io, Python wheels to PyPI (`sqlrite`), Node.js + WASM to npm (`@joaoh82/sqlrite` + `@joaoh82/sqlrite-wasm`), Go module via `sdk/go/v*` git tag, plus C FFI tarballs, MCP binary tarballs, and unsigned desktop installers as GitHub Release assets (Phase 6 complete)

See the [Roadmap](roadmap.md) for the full phase plan.
Expand Down
12 changes: 12 additions & 0 deletions docs/design-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,18 @@ Decisions are grouped by the engine layer they concern: parser, storage, concurr

---

### 12a. `Connection` as a thin handle over `Arc<Mutex<Database>>` (Phase 11.1)

**Decision.** `Connection` no longer owns a `Database` by value; it holds `Arc<Mutex<Database>>` plus a per-handle prepared-statement LRU. A new `Connection::connect()` mints a sibling handle that shares the same backing engine state. The mutex is acquired transparently at the entry of every public method (`execute`, `prepare`, `database()`, accessors); statements release it between calls. `Connection: Send + Sync`.

**Why.** Phase 11 (concurrent writes via MVCC + `BEGIN CONCURRENT`) needs more than one connection to address the same in-memory tables and pager from inside the same process. The previous shape (`Connection { db: Database, … }`) made callers wrap the whole connection in their own `Mutex<Connection>`, which works for single-writer workloads but collapses if the public API needs to grow a "this transaction is concurrent" mode that other handles can observe. Lifting the mutex into the engine itself is the minimum change that lets `BEGIN CONCURRENT` and snapshot-isolated reads hook in cleanly later.

**Cost.** Every public-API call now takes one extra atomic CAS (`Mutex::lock`). Negligible against the work each call does (parser pass + executor + optional fsync). The internal `&mut Database` plumbing is unchanged — over a hundred call sites across the executor, parser, and pager keep the same signatures because they run under the lock the public method already acquired. `Connection::database()` now returns a `MutexGuard<'_, Database>` instead of `&Database`; this is `#[doc(hidden)]` and explicitly unstable, but downstream callers (mainly the MCP server tools and SDK shims) had to bind it to a local first, which they were mostly already doing. The prepared-statement cache deliberately stays per-handle so each thread's hot SQL doesn't fight an extra mutex on every `prepare_cached`.

**What this doesn't do (yet).** Phase 11.1 is *capability*, not throughput. Two writers from sibling handles still serialize through the per-database mutex (and the existing pager `flock` between processes). The `BEGIN CONCURRENT` SQL surface, the `MvStore` version index, and snapshot-isolation reads land in 11.3–11.4. See [`concurrent-writes-plan.md`](concurrent-writes-plan.md) for the sequenced plan.

---

## Query execution

### 13. `NULL`-as-false in `WHERE` clauses
Expand Down
25 changes: 25 additions & 0 deletions docs/embedding.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,31 @@ if looks_good {

See [Phase 4f notes in roadmap.md](roadmap.md) for the snapshot semantics and the auto-rollback-on-failed-COMMIT guarantee.

### Sharing one database across threads

*Phase 11.1.* `Connection` is a thin handle over the engine state. Call `Connection::connect()` to mint a sibling handle that shares the same in-memory tables and persistent pager — typically one handle per worker thread. `Connection: Send + Sync`, so the handles can be moved across threads without an outer `Mutex`.

```rust
use sqlrite::Connection;

let mut primary = Connection::open("foo.sqlrite")?;
primary.execute("CREATE TABLE log (id INTEGER PRIMARY KEY, who TEXT);")?;

let mut writers = Vec::new();
for tid in 0..4 {
let mut conn = primary.connect(); // sibling handle, same backing DB
writers.push(std::thread::spawn(move || {
conn.execute(&format!("INSERT INTO log (who) VALUES ('t{tid}');"))
}));
}
for h in writers { h.join().unwrap()?; }
# Ok::<(), sqlrite::SQLRiteError>(())
```

Today every commit still serializes through the per-database mutex (and the pager's existing process-level `flock`); the goal of 11.1 is *capability*, not throughput. True multi-writer throughput on disjoint rows arrives with `BEGIN CONCURRENT` in 11.4 — see [`concurrent-writes-plan.md`](concurrent-writes-plan.md).

Per-handle state — the prepared-statement cache (LRU populated by `prepare_cached`), the cache capacity setter — stays on each handle, by design (no extra mutex traffic for a per-thread accelerator). The shared state is the `Database` (tables, pager, transaction snapshot, auto-VACUUM threshold).

### What's deferred

- **Parameter binding.** `stmt.query(&[&"alice"])` is the intended shape but the current implementation takes no arguments — use string interpolation for now. Parameter binding lands with the cursor refactor.
Expand Down
43 changes: 41 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The project is staged in phases. Each phase is shippable on its own, ends with a working build + full test suite + a commit on `main`, and can be paused between. The README's roadmap section is a summary of this doc.

> **Active frontier (May 2026):** Phases 0–10 shipped end-to-end. After Phase 8 closed the v0.1.x cycle, the v0.2.0 → v0.9.1 wave (Phase 9, sub-phases 9a–9i) landed the SQL surface that had been parked under "possible extras": DDL completeness (DEFAULT, DROP TABLE/INDEX, ALTER TABLE), free-list + auto-VACUUM, IS NULL, GROUP BY + aggregates + DISTINCT + LIKE + IN, four flavors of JOIN, prepared statements with parameter binding, HNSW metric extension, and the PRAGMA dispatcher. Phase 10 published the SQLR-4 / SQLR-16 benchmarks against SQLite + DuckDB. **Current head: v0.9.1.** The roadmap from here is the smaller "possible extras" list at the bottom of this doc plus the 5a.2 cursor refactor.
> **Active frontier (May 2026):** Phases 0–10 shipped end-to-end. After Phase 8 closed the v0.1.x cycle, the v0.2.0 → v0.9.1 wave (Phase 9, sub-phases 9a–9i) landed the SQL surface that had been parked under "possible extras": DDL completeness (DEFAULT, DROP TABLE/INDEX, ALTER TABLE), free-list + auto-VACUUM, IS NULL, GROUP BY + aggregates + DISTINCT + LIKE + IN, four flavors of JOIN, prepared statements with parameter binding, HNSW metric extension, and the PRAGMA dispatcher. Phase 10 published the SQLR-4 / SQLR-16 benchmarks against SQLite + DuckDB. **Current head: v0.9.1.** Phase 11 (concurrent writes via MVCC + `BEGIN CONCURRENT`, SQLR-22) is now in flight — the multi-connection foundation (11.1) is the first slice; see [`concurrent-writes-plan.md`](concurrent-writes-plan.md) for the full design.

## ✅ Phase 0 — Modernization

Expand Down Expand Up @@ -581,6 +581,46 @@ Every executable statement accepts `?` placeholders anywhere a value literal is

End-to-end SQLR-4 / SQLR-16 bench harness with twelve workloads across three groups (read-by-PK, transactional CRUD, analytical slices, vector / FTS retrieval). Pluggable `Driver` trait + bundled SQLite + DuckDB drivers; criterion-based; pinned-host runs published at [`docs/benchmarks.md`](benchmarks.md). Excluded from CI (criterion is too noisy on shared runners; `rusqlite-bundled` is heavy). See [`docs/benchmarks-plan.md`](benchmarks-plan.md) for the design and PRs #102–#114 for the staged rollout.

## Phase 11 — Concurrent writes via MVCC + `BEGIN CONCURRENT` *(SQLR-22; in flight — see [`concurrent-writes-plan.md`](concurrent-writes-plan.md))*

Lift SQLRite past SQLite's single-writer ceiling with multi-version concurrency control and a `BEGIN CONCURRENT` transaction mode, modelled on Turso's experimental MVCC. The plan doc internally numbers sub-phases as "Phase 10.x" (its working title before the roadmap renumbering); they're listed under Phase 11 here because Phase 10 already shipped.

### 🚧 Phase 11.1 — Multi-connection foundation *(in progress, plan-doc "Phase 10.1")*

`Connection` is now a thin handle backed by `Arc<Mutex<Database>>`. Call [`Connection::connect`] to mint a sibling that shares the same engine state — typically one per worker thread. The headline contract: `Connection` is `Send + Sync`, and the engine no longer requires the caller to wrap the public API in their own `Mutex`. Today every operation still serializes through the per-database mutex (and the pager's existing process-level flock), so the behaviour change is *capability*, not throughput; concurrent throughput arrives with `BEGIN CONCURRENT` in 11.4.

### Phase 11.2 — Logical clock + active-tx registry *(planned)*

`MvccClock` (`AtomicU64`) hands out begin / commit timestamps; `ActiveTxRegistry` exposes `min_active_begin_ts()` for GC. WAL header bumps from v1 → v2 to persist the high-water mark.

### Phase 11.3 — `MvStore` skeleton + snapshot-isolation reads *(planned)*

In-memory version index + `PRAGMA journal_mode = mvcc` opt-in. Lazy-loads versions from the pager on first touch. Writes still go through the legacy path — only reads change.

### Phase 11.4 — `BEGIN CONCURRENT` writes + commit-time validation *(planned, the meat)*

Parser maps `BEGIN CONCURRENT` to `TxKind::Concurrent`; writes land in the MvStore's write-set; commit walks the write-set checking for newer versions. New `SQLRiteError::Busy` / `BusySnapshot` variants. New WAL log-record frame kind.

### Phase 11.5 — Checkpoint integration + crash recovery *(planned)*

Drains MVCC log-records into the existing bottom-up B-tree rebuild path. Replay on reopen rebuilds the in-memory index.

### Phase 11.6 — Garbage collection *(planned)*

Per-commit sweep over the write-set's chains, plus a background sweep behind `PRAGMA mvcc_gc_interval_ms`.

### Phase 11.7 — Indexes under MVCC *(deferred-by-design, separate later phase)*

Each secondary-index entry becomes its own `RowVersion`. Turso explicitly punted on this; SQLRite's v0 will reject `CREATE INDEX` while `journal_mode = mvcc`.

### Phase 11.8 — SDK + REPL propagation *(planned)*

Surface `Busy` / `BusySnapshot` through the FFI shim and each language SDK. New REPL `.spawn` meta-command + new "N concurrent writers" benchmark workload.

### Phase 11.9 — Docs *(planned)*

Promote the plan to `docs/concurrent-writes.md` and update the cross-references.

## "Possible extras" not pinned to a phase

The remaining items — actually open, not retroactively rewritten:
Expand All @@ -592,7 +632,6 @@ The remaining items — actually open, not retroactively rewritten:
- Multi-column / expression `ORDER BY`, `OFFSET`, `NULLS FIRST/LAST`
- `UNION` / `INTERSECT` / `EXCEPT`, `INSERT ... SELECT`
- Composite + expression indexes
- Concurrent writes via MVCC + `BEGIN CONCURRENT` — design sketch in [`docs/concurrent-writes-plan.md`](concurrent-writes-plan.md)
- `CREATE VIEW`, `CREATE TRIGGER`, `FOREIGN KEY`, `CHECK`, table-level / composite constraints
- Savepoints + isolation-level control (`BEGIN IMMEDIATE` / `BEGIN EXCLUSIVE`)
- Built-in scalar functions (`LENGTH`, `UPPER`, `LOWER`, `COALESCE`, `IFNULL`, date/time, `printf`, …)
Expand Down
3 changes: 2 additions & 1 deletion sdk/nodejs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,8 @@ impl Database {
let conn = borrow
.as_ref()
.ok_or_else(|| napi::Error::from_reason("cannot ask: database is closed"))?;
let resp = ask_with_database(conn.database(), &question, &resolved).map_err(map_err)?;
let db = conn.database();
let resp = ask_with_database(&db, &question, &resolved).map_err(map_err)?;
Ok(AskResponse {
sql: resp.sql,
explanation: resp.explanation,
Expand Down
5 changes: 4 additions & 1 deletion sdk/python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,10 @@ impl Connection {
.lock()
.map_err(|_| SQLRiteError::new_err("connection mutex poisoned"))?;
let resp = py
.allow_threads(|| ask_with_database(locked.database(), question, &resolved))
.allow_threads(|| {
let db = locked.database();
ask_with_database(&db, question, &resolved)
})
.map_err(map_err)?;
Ok(AskResponse::from_rust(resp))
}
Expand Down
4 changes: 3 additions & 1 deletion sdk/wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,9 @@ impl Database {
}
};

let schema = dump_schema_for_database(self.inner.database());
let db = self.inner.database();
let schema = dump_schema_for_database(&db);
drop(db);
let system_blocks = build_system(&schema, cache_marker);
let messages = vec![PromptUserMessage {
role: "user",
Expand Down
11 changes: 8 additions & 3 deletions sqlrite-mcp/src/tools/ask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,14 @@ pub fn handle(args: Value, state: &mut ServerState) -> Result<String, ToolError>
};
}

// Run the LLM call.
let resp: AskResponse = ask_with_database(state.conn.database(), &args.question, &cfg)
.map_err(|e| ToolError::new(format!("ask failed: {e}")))?;
// Run the LLM call. Hold the database guard across the call but
// drop it before any later `state.conn.execute` / `prepare` —
// those re-acquire the same lock.
let resp: AskResponse = {
let db = state.conn.database();
ask_with_database(&db, &args.question, &cfg)
.map_err(|e| ToolError::new(format!("ask failed: {e}")))?
};

let mut result = json!({
"sql": resp.sql,
Expand Down
3 changes: 2 additions & 1 deletion sqlrite-mcp/src/tools/schema_dump.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub fn metadata() -> Value {
}

pub fn handle(_args: Value, state: &mut ServerState) -> Result<String, ToolError> {
let dump = sqlrite::ask::schema::dump_schema_for_database(state.conn.database());
let db = state.conn.database();
let dump = sqlrite::ask::schema::dump_schema_for_database(&db);
Ok(dump)
}
6 changes: 4 additions & 2 deletions src/ask/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ impl ConnectionAskExt for Connection {
/// pick whichever shape reads better at the call site.
#[cfg(feature = "ask")]
pub fn ask(conn: &Connection, question: &str, config: &AskConfig) -> Result<AskResponse, AskError> {
ask_with_database(conn.database(), question, config)
let db = conn.database();
ask_with_database(&db, question, config)
}

/// Same as [`ask`], but takes the engine's `&Database` directly.
Expand All @@ -131,7 +132,8 @@ pub fn ask_with_provider<P: Provider>(
config: &AskConfig,
provider: &P,
) -> Result<AskResponse, AskError> {
ask_with_database_and_provider(conn.database(), question, config, provider)
let db = conn.database();
ask_with_database_and_provider(&db, question, config, provider)
}

/// Lower-level entry taking `&Database` and a provider. Canonical
Expand Down
3 changes: 2 additions & 1 deletion src/ask/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ use crate::sql::db::table::{DataType, Table};
/// sequence of `CREATE TABLE … (…);` statements, sorted
/// alphabetically by table name.
pub fn dump_schema_for_connection(conn: &Connection) -> String {
dump_schema_for_database(conn.database())
let db = conn.database();
dump_schema_for_database(&db)
}

/// Same as [`dump_schema_for_connection`], but takes a `Database`
Expand Down
Loading
Loading