# bison-db v0.3.0 — Secondary indexes + field and range queries
**Queries arrive.** v0.3.0 adds ordered secondary indexes over document fields,
plus `find` (equality) and `range` queries. You can index any number of fields,
and queries work with or without an index — the index only changes the speed, so
it can never change a result. Zero breaking changes against v0.2.0.
## What is bison-db?
An embedded, document-oriented database for Rust: store schemaless documents
entirely in-process, with no server, no network, and no external services. The
whole database is one file, and writes are appended to a checksummed log, so a
crash never leaves a half-written record behind.
## What's new in 0.3.0
### Secondary indexes — index as many fields as you like
`create_index` builds an ordered index over a field by reading each live document
once; from then on the index is maintained automatically on every insert,
update, and delete. There is no cap on the number of indexed fields — call it
once per field.
```rust
use bison_db::{Db, Document, Value};
fn main() -> bison_db::Result<()> {
# let path = std::env::temp_dir().join("bison_db_rel_030.bison");
# let _ = std::fs::remove_file(&path);
let mut db = Db::open(&path)?;
for (name, age) in [("ada", 36_i64), ("grace", 45), ("alan", 29)] {
let mut d = Document::new();
d.set("name", name).set("age", age);
db.insert(d)?;
}
db.create_index("name")?;
db.create_index("age")?;
assert_eq!(db.indexes().count(), 2);
# let _ = std::fs::remove_file(&path);
Ok(())
}
```
Indexes are in-memory and rebuilt per session — they are not written to the file,
which keeps the on-disk format free to evolve until it is frozen in v0.4.0. After
reopening a store, call `create_index` again for the fields you want fast.
### Equality and range queries
`find` returns the ids of documents whose field equals a value; `range` returns
those whose field falls within any `RangeBounds<Value>`. Indexed range results
come back ordered by field value.
```rust
use bison_db::{Db, Document, Value};
fn main() -> bison_db::Result<()> {
# let path = std::env::temp_dir().join("bison_db_rel_030_q.bison");
# let _ = std::fs::remove_file(&path);
let mut db = Db::open(&path)?;
for age in [17_i64, 25, 40, 70] {
db.insert({ let mut d = Document::new(); d.set("age", age); d })?;
}
db.create_index("age")?;
let ada = db.find("age", &Value::from(40_i64))?; // exact
let working_age = db.range("age", Value::from(18_i64)..=Value::from(65_i64))?; // 25, 40
assert_eq!(ada.len(), 1);
assert_eq!(working_age.len(), 2);
# let _ = std::fs::remove_file(&path);
Ok(())
}
```
Both queries also run **without** an index, falling back to a full scan, so they
are always available — declaring an index is a performance decision, not a
correctness one. A property test asserts the indexed and scan paths return the
same set for arbitrary data.
### A total order over `Value`
Index keys need a total order, but `Value` holds `f64` (only partially ordered)
and mixes types. v0.3.0 defines one: first by a fixed per-variant rank
(`null < bool < int < float < string < bytes < array < object`), then by natural
order within a variant, using `f64::total_cmp` for floats. The same function
backs both index ordering and query equality, so the two never disagree. One
consequence: integers and floats sit in separate bands, so index a numeric field
with a consistent type.
## Performance
Indicative single-threaded figures from `cargo bench` on a developer laptop
(Linux/WSL2, x86_64, Rust 1.95), against a real on-disk store:
| `insert` a small document | ~0.8 µs |
| `get` a small document | ~0.3 µs |
| `find` by indexed field (10k-doc store) | ~60 ns |
| `find` by full scan (10k-doc store) | ~1.6 ms |
The last two rows are the same query with and without an index: the B-tree point
lookup is roughly four orders of magnitude faster than scanning the store. These
are 0.x baselines; a populated head-to-head against other stores is planned for
the 1.0 cycle.
## Breaking changes
**None.** The query methods are purely additive. Internally, `update` and
`delete` now read the previous document when one or more indexes exist, to keep
index entries consistent with document contents; behaviour without indexes is
unchanged.
## Verification
Run on Windows x86_64 and Linux (WSL2 Ubuntu), Rust stable 1.95.x; the same
commands run in the CI matrix across Linux, macOS, and Windows on stable and
MSRV 1.85:
```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
cargo test
cargo test --all-features
cargo build --no-default-features
cargo build --examples
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
```
All green. Test counts at this tag (`--all-features`): 40 unit + 9 integration +
3 property + 49 doctests.
## What's next
- **v0.4.0 — Write-ahead log + crash recovery + on-disk format freeze.**
Group-commit durability, a frozen record format, and persistent or
lazily-rebuilt indexes so they need not be re-declared after reopening.
## Installation
```toml
[dependencies]
bison-db = "0.3"
# With serde support for the document model:
bison-db = { version = "0.3", features = ["serde"] }
```
MSRV: Rust 1.85 (2024 edition).
## Documentation
- [README](https://github.com/jamesgober/bison-db/blob/main/README.md)
- [API Reference](https://github.com/jamesgober/bison-db/blob/main/docs/API.md)
- [Roadmap](https://github.com/jamesgober/bison-db/blob/main/dev/ROADMAP.md)
- [CHANGELOG](https://github.com/jamesgober/bison-db/blob/main/CHANGELOG.md)
---
**Full diff:** [`v0.2.0...v0.3.0`](https://github.com/jamesgober/bison-db/compare/v0.2.0...v0.3.0).
**Changelog:** [`CHANGELOG.md`](https://github.com/jamesgober/bison-db/blob/main/CHANGELOG.md#030---2026-06-07).