qdrant_edge 0.8.0-dev.3
qdrant_edge: ^0.8.0-dev.3 copied to clipboard
On-device vector search for Dart and Flutter, powered by the qdrant-edge Rust engine through UniFFI — the same shared FFI crate as the Swift and Kotlin SDKs.
Qdrant Edge — Dart & Flutter #
On-device vector search for Dart and Flutter, powered by Qdrant Edge — the Qdrant search engine running in-process, no server and no network.
The SDK is a thin layer over the UniFFI binding generated from the shared
qdrant-edge-ffi Rust crate — the same crate the Swift and Kotlin SDKs bind,
so the three stay in lock-step by construction. Like the mobile SDKs, it builds
--no-default-features, so the O(n²) search_matrix op is off the surface.
Quick start #
import 'dart:io';
import 'package:qdrant_edge/qdrant_edge.dart';
final dir = Directory.systemTemp.createTempSync('qdrant_edge');
final shard = EdgeShard.load(
path: dir.path,
config: EdgeConfig(
vectorData: {'': VectorDataConfig(size: 4, distance: Distance.dot)},
),
);
shard.update(operation: UpdateOperation.upsertPoints(points: [
Point(id: NumIdPointId(1), vector: SingleVector([1, 0, 0, 0])),
]));
final hits = shard.search(request: SearchRequest(
query: NearestQuery(vector: DenseNamedVector([1, 0, 0, 0]), using: null),
limit: 10,
));
See example/ for a runnable demo and test/ for load → upsert → search,
persistence, and error-path coverage.
Public API #
lib/qdrant_edge.dart re-exports the generated binding through an explicit
show list, so the public surface is only the domain API (EdgeShard,
EdgeConfig, Point, the query/filter/vector/error types, …). The UniFFI
plumbing (FfiConverter*, RustBuffer, the call-status *ErrorHandlers, the
raw @Native C functions) lives in lib/src/ and is not exported — it stays out of the package's semver
contract, so a future UniFFI bump can reshape it without breaking consumers.
(Swift achieves the same by demoting plumbing to internal; Dart does it for
free with a show list, which Kotlin/JVM cannot.)
Error handling #
Every exception this API throws is catchable by name. Two types cross the boundary, and both are exported:
EdgeException— a domain/engine error. Branch on its concrete subtypes:ShardClosedEdgeException— the shard was unloaded; reopen it.ShardLockedEdgeException— another handle already holds the shard (its WAL is locked). An ordinary, recoverable condition — a second isolate, a store the app forgot to close — and explicitly not corruption: retry or close the other handle. OnlyEdgeShard.loadthrows it.InvalidArgumentEdgeException— bad input; fix it and retry.OperationExceptionEdgeException— any other engine failure (I/O, etc.).
UniffiInternalError— a Rust panic or a bindings/native protocol mismatch surfacing across the FFI boundary. It comes from the generated glue, not the engine, so it is not anEdgeException— but it is exported by name so it is still catchable. Treat it as a bug to report, not a data condition.
There is no third, unnameable type: on EdgeException plus on UniffiInternalError covers everything that can cross this boundary.
try {
final shard = EdgeShard.load(path: dir, config: null);
shard.update(operation: op);
} on ShardLockedEdgeException {
// another handle has it open — retry, or close the other one first
} on EdgeException catch (e) {
// any other domain/engine error
} on UniffiInternalError catch (e) {
// a native panic / protocol mismatch — report it
}
To decide whether to open an existing store or start a fresh one before
calling load, use probeShard(path:) — it reports none / loadable /
unreadable by reading the directory, without opening the shard or taking its
WAL lock. (probe never detects the lock, so a loadable shard can still throw
ShardLockedEdgeException from load if another handle holds it.)
How it works #
- Bindings.
lib/src/qdrant_edge_ffi.dartis generated byuniffi-bindgen-dartin library mode (it reads the UniFFI metadata embedded in the compiled cdylib; the crate usessetup_scaffolding!(), so there is no UDL). It is not committed — it is ~17k lines of machine output,.gitignored and regenerated by./build.sh. Run./build.shonce after a fresh checkout beforedart test/dart analyze(the same way the Swift/Kotlin SDKs need their build script first).uniffi.tomlpins the Dart package name so the generated@Nativeasset ids resolve. Only the small curated facade (lib/qdrant_edge.dart) is committed — it is the reviewed public surface. - Toolchain provenance. The bindgen must be built from the fork that
carries the uniffi-0.32 + library-mode-CLI + enum-collision-fix work (upstream
acterglobal/uniffi-dartlacks it, pending PRs #149 / #151 / #152):github.com/DenisovAV/uniffi-dart@library-mode-cli. PointUNIFFI_BINDGEN_DARTat that build. CI regenerates the binding and diffs the committed facade to guarantee the public surface stays in sync with the crate (the binding itself can't drift — it is always regenerated, never stored). - Native library. The engine is delivered as a
Native Asset:
dart test,dart run, andflutter test/flutter runinvokehook/build.dartautomatically — no--enable-experimentflag on Dart 3.12+.
Distribution #
hook/build.dart provisions the engine three ways, in priority order, so the
same hook serves both an in-tree developer and a pub.dev consumer:
- Local prebuilt —
native/prebuilt/<os_arch>/(or$QDRANT_EDGE_PREBUILT_DIR). A dev/CI override, e.g. dropping in a locally cross-built device library. - From source (host, in-tree) — when the target is the host OS and the
Cargo workspace is present, build with
cargo +nightly(needs the nightly toolchain + protobuf). This is the fast path while developing inside the monorepo; incremental rebuilds re-run only when a source dir changes. - Download — otherwise fetch the per-platform, SHA256-pinned archive
from the GitHub Release (
edge-dart-native-v$VERSION) and cache it under the platform cache dir. This is how a consumer with no Rust toolchain, and every cross-compile (device/emulator) target, is served — the same shape the Kotlin AAR and Swift XCFramework releases use.
.github/workflows/edge-dart-native.yml builds the eight cdylibs (Linux
x86_64/arm64, Windows x86_64, macOS arm64, iOS arm64 device/simulator, Android
arm64/x86_64), packages each as qdrant-edge-ffi-<os_arch>.tar.gz, and publishes
them with a checksums.txt. Those SHA256 lines are pinned into the _sha256
table in hook/build.dart — the download path stays inert until they are, so a
tampered or truncated archive can never be linked in.
The native-prebuilt release (edge-dart-native-v0.8.0) is cut and its checksums
are pinned, so the package is publishable to pub.dev as qdrant_edge. Publishing
regenerates the binding first (make publish runs ./build.sh then
dart pub publish) so the generated glue lands in the archive even though it is
.gitignored — .pubignore replaces .gitignore for pub and does not exclude
it. native/ and the on-device harness stay out (a ~55 KB archive: the Dart glue
ships as source, the native engine is downloaded at build time, never bundled).
Versioning #
The plugin tracks the native qdrant-edge library — lib/edge/VERSION, the same
single source the Swift edge-v$VERSION tag and the Kotlin VERSION_NAME use — on
major.minor, the API/ABI contract shared across every Edge SDK (0.8.x speaks the
same surface on all platforms). lib/edge/VERSION and the qdrant-edge-ffi crate
version are kept identical — they are the native version. The pub.dev version
matches them on major.minor, but its patch floats independently: a Dart-only fix (a
regenerated binding, a hook/build.dart change, docs) can ship as a new patch without
cutting a fresh native release — it keeps downloading the prebuilt pinned by
_releaseTag. So qdrant_edge 0.8.1 may wrap native 0.8.0, exactly as Rust -sys
crates decouple their own version from the C library they bundle. edge-dart.yml
enforces this: VERSION == crate exactly, and both the pubspec version and the
hook's native pin must match VERSION on major.minor.