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. Only EdgeShard.load throws 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 an EdgeException — 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.dart is generated by uniffi-bindgen-dart in library mode (it reads the UniFFI metadata embedded in the compiled cdylib; the crate uses setup_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.sh once after a fresh checkout before dart test / dart analyze (the same way the Swift/Kotlin SDKs need their build script first). uniffi.toml pins the Dart package name so the generated @Native asset 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-dart lacks it, pending PRs #149 / #151 / #152): github.com/DenisovAV/uniffi-dart @ library-mode-cli. Point UNIFFI_BINDGEN_DART at 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, and flutter test/flutter run invoke hook/build.dart automatically — no --enable-experiment flag 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:

  1. Local prebuiltnative/prebuilt/<os_arch>/ (or $QDRANT_EDGE_PREBUILT_DIR). A dev/CI override, e.g. dropping in a locally cross-built device library.
  2. 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.
  3. 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.

Libraries

qdrant_edge
Qdrant Edge — on-device vector search for Dart and Flutter.