Skip to content
Current documentation — tracks the engine's development branch (v0.4.0 at the last sync).v0.2.1v0.3.0v0.3.1v0.3.2v0.4.0v0.4.1About these docs

corvid-jvm

corvid-jvm is the Kotlin-first JVM binding: Java users consume the same artifact (the API is plain Kotlin/JVM classes). It links the engine’s published FFI artifacts (the platform cdylib and corvid.h) through a thin hand- written C JNI shim (one file, native/corvid_jni.c) compiled per-platform, with the idiomatic Kotlin API on top. Deliberately the corvid-c/corvid-go pattern (a fetched, checksummed shared library), not the node/python one (Rust-source builds): ./fetch.sh + ./scripts/ build-native.sh download, sha256-verify, and build against the pinned release — no Rust toolchain, no vendored binaries.

When to choose this binding: your project is Kotlin or Java on the JVM (server-side, desktop) and you want corvid embedded with the platform’s own shape — AutoCloseable handles (use blocks), real exceptions carrying the engine’s frozen error codes, and fluent builder chains — with per-call FFI cost kept at JNI’s native-marshaling floor.

Per the repo’s docs/PLAN.md: JNA’s reflection-driven libffi dispatch and boxing are exactly the crossing-cost overhead the engine’s BENCHES discipline polices; a hand-written JNI shim crosses with native argument marshaling and decodes each document in one crossing per row. Kotlin Multiplatform was ruled out too — one platform (the JVM), so no expect/actual ceremony. The nine-rule JNI discipline in the PLAN is the binding’s review center-of-gravity; the shapes it pins:

C ABIcorvid-jvm
opaque handles (corvid_db*, …)Db / Collection / Query / PredicateAutoCloseable, close() idempotent, use is the ownership model; no raw pointers in the public API (long handles live in an internal object)
CORVID_ERR + thread-local last errorCorvidException (a RuntimeException with the frozen ErrCode); the same-call guarantee — JNI runs on the calling Java thread, so the code+message read after a failure is always THIS failure’s
frozen enumsMetric, Quant, FieldType, ErrCode (exact ABI values)
consumed-by-call args (pred trees, builders)marked consumed on the Kotlin side before the native call, whatever its outcome — the double-free UB class cannot happen; a consumed object throws on reuse
borrowed views (_ref buffers, row docs, callback args)copied into JVM memory inside the same native call that observed them — nothing borrowed is ever retained past the call
corvid_update_fn / corvid_scan_fnKotlin lambdas; a throwing callback aborts the engine call (store untouched, ARGUMENT recorded) and the user’s own exception surfaces at the call site — never swallowed, never unwound through C frames
strings / bytes / vectorsStringreal UTF-8 bytes (JNI’s modified UTF-8 never touches the engine side), ByteArray, FloatArray

The raw ABI stays reachable as corvid.jni.Nativesinternal, used by the golden harness for the value-handle exercises exactly the way the engine’s own C harness drives them. Application code should stick to the wrapper types.

dependencies {
implementation("io.github.corvid-db:corvid-jvm:0.4.1")
runtimeOnly("io.github.corvid-db:corvid-jvm:0.4.1:macos-arm64")
// classifiers: macos-arm64 | macos-x64 | linux-x64 | linux-arm64 | windows-x64
}

Published to Maven Central — the jars are self-contained: the platform classifier bundles the JNI shim AND the engine cdylib, and the loader extracts both to a temp dir and System.load()s them, so a consumer needs nothing else (no fetch, no compiler, no java.library.path). The version rides the engine’s release cascade.

Android: the same wrapper ships as an AAR — io.github.corvid-db:corvid-android (published in the same release bundle, same version). ONE dependency, no classifier — the AAR carries the Kotlin classes plus arm64-v8a and x86_64 jniLibs pairs (engine cdylib + JNI shim), Corvid.load() resolves them through Android’s nativeLibraryDir automatically, and minSdk is 26:

dependencies {
implementation("io.github.corvid-db:corvid-android:0.4.1")
}

The API is identical (the SAME Kotlin sources, compiled against android.jar); the engine’s Android cdylibs ship on the engine release since v0.4.1, and the on-device gate is the repo’s instrumented smoke against the arm64 ATD emulator.

Building from source instead (development):

Terminal window
./fetch.sh # fetch + sha256-verify corvid v0.4.1
./scripts/build-native.sh # compile the JNI shim into build/native
./gradlew test # the golden suite (267 executable lines)
./gradlew examples # the six-example tour

Requirements: JDK 17+ (CI exercises 21 and 17), Gradle 8.14+ (wrapper-pinned), Kotlin 2.2.x, a C compiler (clang/gcc/MSVC). On Windows: ./fetch.ps1 then ./scripts/build-native.ps1. Consumers point the library loader at the shim directory via -Dcorvid.native.dir=<dir> (or CORVID_NATIVE_DIR).

Engine v0.3.0’s ABI additions are first-class here:

  • Complete map decoding — every decode path (get, scan, page, query rows, geo docs, callback arguments) enumerates map keys through the real corvid_value_map_keys iterator (ascending key-byte order), so documents decode COMPLETE on any database, whatever wrote the data — no candidate-key oracle, ever. Decoded maps are LinkedHashMaps in that engine order.
  • Collection.phraseSearch(field, phrase, k) — the DIRECT positional search: consecutive, in-order analyzed tokens, stop words collapsing out of adjacency, rows carrying the BM25 phrase score (the phrase scale, not the builder’s fused RRF scale); k == 0 answers an empty result — inert, never an error. The TextSearch example demonstrates it, CJK bigram phrases included.

NaN/±inf/-0.0 cross bit-exactly and NaN payloads are preserved — documented, and pinned op by op by the golden suite’s bits: literals.

Six runnable programs under the repo’s examples/ directory (./gradlew example<Name>), executed on every CI leg with deterministic output: Quickstart, Hybrid (the flagship below), VectorIndex (in-memory / on-disk / binary-quantized HNSW vs the exact scan, plus a close/reopen), TextSearch (BM25 incl. CJK bigram segmentation, plus the v0.3.0 direct phraseSearch), Graph (neighbors/traverse + delete cascade), and Geo (radius / bbox / nearest with haversine kilometres). The quickstart and hybrid sources are embedded below — imported from the repo so they cannot drift from what CI executes (scripts/sync-binding-examples.sh; the drift gate reddens docs CI if they diverge).

fun main() {
openMemory().use { db ->
val docs = db.collection("docs")
docs.insert("p1".toByteArray(), mapOf(
"title" to "rust embedded database", "kind" to "doc",
"v" to floatArrayOf(1.0f, 0.0f),
))
docs.insert("p2".toByteArray(), mapOf(
"title" to "python web frameworks", "kind" to "doc",
"v" to floatArrayOf(0.0f, 1.0f),
))
docs.insert("p3".toByteArray(), mapOf(
"title" to "rust again database", "kind" to "doc",
"v" to floatArrayOf(0.9f, 0.1f),
))
// kNN: the 3 nearest documents to (1, 0) under cosine. Project
// the field the printout needs (docs decode in full either way;
// select trims the payload).
val rows = docs.query()
.vector("v", floatArrayOf(1.0f, 0.0f), 3, Metric.COSINE)
.select("title")
.run()
rows.forEachIndexed { rank, r ->
println("%d. %s score=%.6f %s".format(rank + 1, String(r.key), r.score, r.doc))
}
docs.close()
}
}
fun main() {
openMemory().use { db ->
val docs = db.collection("docs")
docs.insert("s1".toByteArray(), mapOf(
"kind" to "doc", "body" to "rust embedded database",
"v" to floatArrayOf(1.0f, 0.0f),
))
docs.insert("s2".toByteArray(), mapOf(
"kind" to "doc", "body" to "python web frameworks",
"v" to floatArrayOf(0.0f, 1.0f),
))
docs.insert("s3".toByteArray(), mapOf(
"kind" to "doc", "body" to "rust again database",
"v" to floatArrayOf(0.9f, 0.1f),
))
docs.insert("m1".toByteArray(), mapOf("kind" to "meta")) // filtered out below
// The flagship query: filter + vector + text, RRF + MMR + limit.
val rows = docs.query()
.filter(field("kind").eq("doc"))
.vector("v", floatArrayOf(1.0f, 0.0f), 2, Metric.COSINE)
.text("body", "rust database", 2)
.fuseRRF(60.0f)
.rerankMMR(1.0f)
.limit(2)
.select("body")
.run()
rows.forEachIndexed { rank, r ->
println("%d. %s score=%.6f %s".format(rank + 1, String(r.key), r.score, r.doc))
}
docs.close()
}
}
fun main() {
val path = Path.of(System.getProperty("java.io.tmpdir"), "corvid-jvm-example-vector-index.redb")
Files.deleteIfExists(path) // reruns start clean (single-file db)
open(path.toString()).use { db ->
val items = db.collection("items")
for ((key, v) in corpus) {
items.insert(key.toByteArray(), mapOf(
"v_mem" to v, "v_disk" to v, "v_q" to v,
))
}
items.createVectorIndex("v_mem", Metric.COSINE)
items.createVectorIndexOnDisk("v_disk", Metric.COSINE)
items.createVectorIndexQuantized("v_q", Metric.COSINE, Quant.BINARY)
println("top-4 nearest to (1,0,0,0) under cosine:")
runQuery(items, "v_mem", false, "exact (scan):")
runQuery(items, "v_mem", true, "ann in-memory HNSW:")
runQuery(items, "v_disk", true, "ann on-disk HNSW:")
runQuery(items, "v_q", true, "ann binary-quantized:")
println("(the quantized lane trades recall for a ~32x smaller index)")
items.close()
}
// Reopen: the on-disk graph reloads (no rebuild) and answers again.
open(path.toString()).use { db ->
val items = db.collection("items")
runQuery(items, "v_disk", true, "ann on-disk after reopen:")
items.close()
}
Files.deleteIfExists(path)
}
fun main() {
openMemory().use { db ->
val notes = db.collection("notes")
for ((key, body) in corpus) {
notes.insert(key.toByteArray(), mapOf("body" to body))
}
notes.createTextIndex("body")
search(notes, "quick fox", "bm25 \"quick fox\":")
search(notes, "quick dog", "bm25 \"quick dog\":")
search(notes, "城市", "bm25 CJK 城市 (city):")
search(notes, "数据库", "bm25 CJK 数据库 (database):")
phrase(notes, "fox jumps over", "phrase \"fox jumps over\":")
phrase(notes, "over jumps fox", "phrase \"over jumps fox\" (reversed — no match):")
phrase(notes, "leaps over a sleeping", "phrase with stop words collapsed:")
notes.close()
}
}

Graph (neighbors, traverse, delete cascade)

Section titled “Graph (neighbors, traverse, delete cascade)”
fun main() {
openMemory().use { db ->
val nodes = db.collection("nodes")
for (key in listOf("ga", "gb", "gc")) {
nodes.insert(key.toByteArray(), mapOf("n" to key))
}
nodes.link("ga".toByteArray(), "parent_of", "gb".toByteArray())
nodes.link("ga".toByteArray(), "parent_of", "gc".toByteArray())
nodes.link("gb".toByteArray(), "parent_of", "gd".toByteArray()) // gd never a document
nodes.linkWeighted("ga".toByteArray(), "route", "gb".toByteArray(), 2.5)
nodes.linkWeighted("ga".toByteArray(), "route", "gd".toByteArray(), 0.75)
val ga = "ga".toByteArray()
val gb = "gb".toByteArray()
show("neighbors(ga)", nodes.neighbors(ga, "parent_of"))
show("in_neighbors(gb)", nodes.inNeighbors(gb, "parent_of"))
val routes = nodes.neighborsWeighted(ga, "route")
.joinToString(" ") { r -> "%s=%.2f".format(String(r.key), r.weight) }
println("%-36s [%s]".format("routes from ga (weighted):", routes))
show("traverse(ga, 1 hop)", nodes.traverse(ga, "parent_of", 1))
show("traverse(ga, 2 hops)", nodes.traverse(ga, "parent_of", 2))
// Delete cascade: remove gc (a document) and gd (never a document).
println("delete gc: existed = " + nodes.delete("gc".toByteArray()))
val existedGd = nodes.delete("gd".toByteArray())
println("delete gd: existed = $existedGd (never a document; its edges still cascade)")
show("neighbors(ga) after deletes", nodes.neighbors(ga, "parent_of"))
show("neighbors(gb) after deletes", nodes.neighbors(gb, "parent_of"))
show("traverse(ga, 2 hops) after", nodes.traverse(ga, "parent_of", 2))
nodes.close()
}
}
fun main() {
openMemory().use { db ->
val places = db.collection("places")
for ((name, lat, lon) in cities) {
places.insert(name.toByteArray(), mapOf(
"name" to name,
"loc" to listOf(lat, lon), // the [lat, lon] array encoding
))
}
places.createGeoIndex("loc")
show("within 600km of Berlin:", places.geoWithinRadius("loc", 52.52, 13.40, 600.0))
show("bbox 47..55N, 5..15E:", places.geoWithinBBox("loc", 47.0, 5.0, 55.0, 15.0))
show("nearest 2 to Berlin:", places.geoNearest("loc", 52.52, 13.40, 2))
places.close()
}
}

Generated from the binding’s docs/SURFACE.tsv (every engine construct at the pinned tag mapped or N/A with a reason) — regenerated by the docs sync, so it cannot drift.

API groupengine constructsproven by
the Kotlin value mapping (null/Boolean/Long/Double/String/ByteArray/FloatArray/List<Any?>/LinkedHashMap<String,Any?>)10golden:values.txt:VTYPE
FieldExpr.eq/ne/lt/le/gt/ge7golden:queries.txt:QF_*
Predicate via field()/Predicate.not()27golden:queries.txt:QF_* + golden:mutations.txt:DELETE_IN
Metric enum (Metric.COSINE/Metric.DOT/Metric.L2)4golden:queries.txt:QVEC
Quant enum (Quant.NONE/Quant.BINARY/Quant.SCALAR)4golden:schema.txt:IDX_VEC_Q
throws CorvidException1golden:mutations.txt:INSERT_ERR
CorvidException.code (ErrCode table)1ErrCodesTest.errorCodeTableIsFrozen
ErrCode.DATABASE (code 1)1ErrCodesTest.errorCodeTableIsFrozen
ErrCode.TRANSACTION (code 2)1ErrCodesTest.errorCodeTableIsFrozen
ErrCode.TABLE (code 3)1ErrCodesTest.errorCodeTableIsFrozen
ErrCode.STORAGE (code 4)1ErrCodesTest.errorCodeTableIsFrozen
ErrCode.COMMIT (code 5)1ErrCodesTest.errorCodeTableIsFrozen
ErrCode.SET_DURABILITY (code 6)1ErrCodesTest.errorCodeTableIsFrozen
ErrCode.COMPACTION (code 7)1ErrCodesTest.errorCodeTableIsFrozen
ErrCode.DECODE (code 8)1ErrCodesTest.errorCodeTableIsFrozen
ErrCode.CORRUPT_INDEX (code 9)1ErrCodesTest.errorCodeTableIsFrozen
ErrCode.RESERVED_COLLECTION (code 10)1ErrCodesTest.errorCodeTableIsFrozen; golden:mutations.txt:INSERT_ERR(err:10)
ErrCode.INVALID_NAME (code 11)1ErrCodesTest.errorCodeTableIsFrozen; golden:mutations.txt:INSERT_ERR(err:11)
ErrCode.ARGUMENT (code 12)1ErrCodesTest.errorCodeTableIsFrozen; golden:mutations.txt:UPDATE_ABORT(err:12)
ErrCode.INCOMPATIBLE_FORMAT (code 13)1ErrCodesTest.errorCodeTableIsFrozen
ErrCode.EMPTY_INDEX_TRAINING (code 14)1ErrCodesTest.errorCodeTableIsFrozen; golden:schema.txt:IDX_PQ_ERR(err:14)
ErrCode.SCHEMA_VIOLATION (code 15)1ErrCodesTest.errorCodeTableIsFrozen; golden:schema.txt:SCHEMA_ERR(err:15)
ErrCode.INVALID_DUMP (code 16)1ErrCodesTest.errorCodeTableIsFrozen
ErrCode.BACKUP_TARGET_EXISTS (code 17)1ErrCodesTest.errorCodeTableIsFrozen; golden:admin.txt:BACKUP_DUP(err:17)
ErrCode.IO (code 18)1ErrCodesTest.errorCodeTableIsFrozen
Row { key, doc, score }1golden:queries.txt
Query (Collection.query())2golden:queries.txt
Query.filter1golden:queries.txt:QF_COUNT
Query.vector1golden:queries.txt:QVEC
Query.text1golden:queries.txt:QTEXT
Query.fuseRRF1golden:queries.txt:HYBRID_F
Query.rerankMMR1golden:queries.txt:HYBRID
Query.limit1golden:queries.txt:ORDER_BY
Query.offset1golden:queries.txt:ORDER_BY
Query.orderBy1golden:queries.txt:ORDER_BY
Query.approx1golden:queries.txt:APPROX
Query.select1golden:queries.txt:SELECT
Query.count1golden:queries.txt:AGG_COUNT
Query.groupCount1golden:queries.txt:AGG_GCOUNT
Query.sum1golden:queries.txt:AGG_SUM
Query.avg1golden:queries.txt:AGG_AVG
Query.min1golden:queries.txt:AGG_MIN
Query.max1golden:queries.txt:AGG_MAX
Query.countDistinct1golden:queries.txt:AGG_DISTINCT
Query.groupSum1golden:queries.txt:AGG_GSUM
Query.groupAvg1golden:queries.txt:AGG_GAVG
Query.run1golden:queries.txt:QVEC
Db1golden:admin.txt:FILEDB
corvid.open/openMemory + Db.collection/collections/backup/compact6golden:admin.txt (COLLECTIONS/BACKUP/COMPACT)
Collection1golden:mutations.txt:COLL
Collection.insert/update/patch/compareAndSet4golden:mutations.txt (INSERT/UPDATE/PATCH/CAS)
Collection.scan(callback, early stop)1golden:mutations.txt:SCAN/SCAN_STOP
Collection.len (len()==0 for empty)2golden:mutations.txt:LEN
Collection.putMany1golden:mutations.txt:PUTMANY + golden:schema.txt:PUTMANY_ROLLBACK
Collection.insertAuto1golden:mutations.txt:INSERT_AUTO
Collection.get1golden:mutations.txt:GET
Collection.delete/deleteWhere/deleteBatch3golden:mutations.txt (DELETE/DELETE_WHERE/DELETE_BATCH)
Collection.scan1golden:mutations.txt:SCAN
Collection.page -> Page(rows, nextAfter)2golden:mutations.txt:PAGE
Row.score (Query.vector().run())1golden:queries.txt:QVEC
Row.score (Query.text().run())1golden:queries.txt:QTEXT
Collection.phraseSearch(field, phrase, k) — the direct positional search (corvid_phrase_search, v0.3.0) over the rows cursor1golden:queries.txt:PHRASE
Query.fuseRRF default k=601golden:queries.txt:HYBRID
GeoHit { key, doc, distanceKm }1golden:geo.txt:RADIUS/NEAREST/BBOX
Collection.geoWithinRadius/geoNearest/geoWithinBBox/createGeoIndex4golden:geo.txt (RADIUS/NEAREST/BBOX/IDX_GEO)
Collection.link/linkWeighted/unlink/neighbors/inNeighbors/neighborsWeighted/traverse7golden:graph.txt
Collection.createScalarIndex/createCompoundIndex/createTextIndex[/OnDisk]/createGeoIndex/createVectorIndex* (variants)10golden:schema.txt:IDX_*
FieldType enum (FieldType.ANY/BOOL/INT/FLOAT/TEXT/BYTES/VECTOR/ARRAY/MAP)10golden:schema.txt:SET_SCHEMA/SCHEMA
Collection.setSchema/schema + FieldDef(name, type, required, unique)10golden:schema.txt:SET_SCHEMA/SCHEMA/SCHEMA_ERR
Collection.insertTTL/setTTL/getTTL/purgeExpired4golden:mutations.txt (INSERT_TTL/SET_TTL/GET_TTL/PURGE)
Db.dump/load/loadWithRenames3golden:admin.txt (DUMP/LOAD/LOAD_RENAMES)

159 engine constructs are deliberately not exposed (each with its reason in the repo’s docs/SURFACE.tsv).

Dokka-generated reference: corvid-db.github.io/corvid-jvm — published from the same sources the artifact builds.

./gradlew test replays the engine’s entire golden fixture suite — 267 executable lines across 8 files, including the v0.3.0 VMAP_KEYS/GET_KEYS (map-key iteration) and PHRASE/PHRASE_K0 (direct positional search) lines — against the downloaded cdylib, through a line-for-line port of the engine’s C harness (GoldenTest.kt): every counted line must dispatch, the first failure names file:line + OP + expected-vs-got, and the UPDATE_ABORT line pins both halves of the callback ruling (the marker exception surfaces at the call site AND the engine’s abort is readable in the same-thread last-error slot). The fixtures are vendored in the repo and byte-compared against the release’s copies at fetch time (the repo’s .gitattributes pins * -text so Windows checkouts cannot CRLF them), so a bad artifact is a loud fetch failure, never a silent skip.

On top sits docs/SURFACE.tsv — every construct of the engine’s public surface (331 rows at this pin) resolved to the Kotlin API exposing it plus the golden line that proves it, or N/A with the ABI’s §9 reason, gated in CI (scripts/surface-gate.sh).

Android AAR bundling is a documented follow-up with a trigger (first Android consumer request, or the Maven Central publish) — a packaging change only; no API or lifetime semantics move (the repo’s docs/PLAN.md).

Next: the bindings overview.