fix(ruvector-wasm): correct adapter for WASM build's flat-index, distance-score, and metadata gaps (#568)

The published @ruvector/wasm build behaves differently from its generated
.d.ts in three ways that bite consumers:

1. HNSW is not active — the wasm32 target compiles without the `hnsw`
   feature and falls back to a flat (brute-force) index, so search is O(n).
   The O(log n) win is latent until the WASM HNSW lands.
2. `result.score` is a cosine distance (lower is better), not the
   "higher is better" similarity the .d.ts advertises (ordering is correct:
   a, b before c).
3. Metadata does not round-trip — search/get return {}.

Add RuvectorWasmAdapter (@ruvector/wasm/adapter) which wraps VectorDB with:
- a metadata sidecar so inserted metadata round-trips
- similarity = 1 - distance (generalised per metric) with `.score` aliased
  to similarity, plus the raw `distance` preserved
- indexType/usesHnsw + WASM_HNSW_AVAILABLE so callers don't assume HNSW
- client-side metadata filtering with over-fetch

Includes TS declarations with corrected doc comments, a node:test suite
covering all three findings, README guidance, and package exports.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
rUv 2026-06-14 18:32:26 -04:00 committed by GitHub
parent 524751e435
commit 08c0d742c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 688 additions and 0 deletions

View file

@ -115,6 +115,43 @@ results.forEach(result => {
});
```
> ⚠️ **Read this before trusting the raw bindings.** Three behaviours of the
> current WASM build differ from what the generated `.d.ts` advertises:
>
> 1. **HNSW is not active in the WASM build.** It compiles without the `hnsw`
> cargo feature and silently falls back to a brute-force flat index, so search
> is O(n), not O(log n). The HNSW win is latent until the WASM HNSW lands.
> 2. **`result.score` is a cosine *distance* (lower is better)** — the ordering is
> correct, but it is *not* the "higher is better" similarity the `.d.ts`
> describes.
> 3. **Metadata does not round-trip**`search`/`get` return `{}`.
>
> Use the bundled **adapter** instead of the raw `VectorDB` to get these handled
> correctly (see below).
### Recommended: the corrected adapter
`@ruvector/wasm/adapter` wraps `VectorDB` with a metadata sidecar and a real
`similarity = 1 - distance` so the documented "higher is better" contract holds.
```javascript
import { RuvectorWasmAdapter } from '@ruvector/wasm/adapter';
// Loads + inits the WASM module and constructs the VectorDB for you.
const index = await RuvectorWasmAdapter.create({ dimensions: 384, metric: 'cosine' });
index.insert({ id: 'doc_1', vector: embedding, metadata: { title: 'My Document' } });
const results = index.search({ vector: query, k: 10 });
results.forEach(r => {
console.log(r.id, r.similarity); // similarity: higher is better
console.log(r.distance); // raw distance: lower is better
console.log(r.metadata); // round-trips correctly via the sidecar
});
console.log(index.indexType); // 'flat' until WASM HNSW lands
```
### React Integration
```typescript

View file

@ -4,8 +4,20 @@
"description": "High-performance Rust vector database for browsers via WASM",
"main": "pkg/ruvector_wasm.js",
"types": "pkg/ruvector_wasm.d.ts",
"exports": {
".": {
"types": "./pkg/ruvector_wasm.d.ts",
"default": "./pkg/ruvector_wasm.js"
},
"./adapter": {
"types": "./src/adapter.d.ts",
"default": "./src/adapter.js"
}
},
"files": [
"pkg",
"src/adapter.js",
"src/adapter.d.ts",
"src/worker.js",
"src/worker-pool.js",
"src/indexeddb.js"
@ -18,6 +30,7 @@
"build:bundler": "wasm-pack build --target bundler --out-dir pkg-bundler --release",
"build:all": "npm run build && npm run build:node && npm run build:bundler",
"test": "wasm-pack test --headless --chrome",
"test:adapter": "node --test tests/adapter.test.mjs",
"test:firefox": "wasm-pack test --headless --firefox",
"test:node": "wasm-pack test --node",
"size": "npm run build && gzip -c pkg/ruvector_wasm_bg.wasm | wc -c && echo 'bytes (gzipped)'",

132
crates/ruvector-wasm/src/adapter.d.ts vendored Normal file
View file

@ -0,0 +1,132 @@
/**
* Type declarations for the RuvectorWasmAdapter.
*
* Unlike the generated `pkg/ruvector_wasm.d.ts`, the `score` documented here is
* a real similarity (higher is better); the raw distance is exposed separately.
*
* @module @ruvector/wasm/adapter
*/
/**
* Whether the published WASM build ships an active HNSW index.
* `false` today: the WASM `VectorDB` falls back to a flat (brute-force) index.
*/
export const WASM_HNSW_AVAILABLE: boolean;
/**
* Convert a raw distance (lower is better) into a similarity (higher is better).
* @param metric 'cosine' | 'dot' | 'dotproduct' | 'euclidean' | 'manhattan'
* @param distance Raw score returned by the WASM `search`.
*/
export function distanceToSimilarity(metric: string, distance: number): number;
/** A single search result, with similarity and metadata corrected. */
export interface AdapterSearchResult {
/** Vector id. */
id: string;
/** Similarity score — higher is better. */
similarity: number;
/** Raw distance from the underlying index — lower is better. */
distance: number;
/** Alias of `similarity`, so a `.score` read honours "higher is better". */
score: number;
/** Vector data, when returned by the index. */
vector?: Float32Array;
/** Round-tripped metadata from the sidecar. */
metadata?: Record<string, any>;
}
/** Minimal shape of the underlying WASM (or test-double) VectorDB. */
export interface WasmVectorDBLike {
insert(
vector: Float32Array,
id?: string,
metadata?: Record<string, any>
): string;
insertBatch(
entries: Array<{
id?: string;
vector: Float32Array;
metadata?: Record<string, any>;
}>
): string[];
search(
vector: Float32Array,
k: number,
filter?: Record<string, any>
): Array<{
id: string;
score: number;
vector?: Float32Array;
metadata?: Record<string, any>;
}>;
get(
id: string
): { id?: string; vector?: Float32Array; metadata?: Record<string, any> } | null;
delete(id: string): boolean;
len?(): number;
isEmpty?(): boolean;
}
export interface AdapterOptions {
/** Vector dimensions (informational). */
dimensions?: number;
/** Distance metric the db was created with; controls similarity conversion. */
metric?: string;
/** Override the index-type report. Defaults to {@link WASM_HNSW_AVAILABLE}. */
usesHnsw?: boolean;
}
export interface CreateOptions {
/** Vector dimensions (required). */
dimensions: number;
/** Distance metric. Defaults to 'cosine'. */
metric?: string;
/** Requested at the WASM layer (the build falls back to flat regardless). */
useHnsw?: boolean;
/** Pre-imported WASM module; if omitted, `@ruvector/wasm` is imported. */
module?: any;
}
/** Correct wrapper around the generated WASM `VectorDB`. */
export class RuvectorWasmAdapter {
constructor(db: WasmVectorDBLike, options?: AdapterOptions);
static create(options: CreateOptions): Promise<RuvectorWasmAdapter>;
/** `false` for the current WASM build — flat O(n) search. */
readonly usesHnsw: boolean;
/** 'hnsw' | 'flat' — index type backing this adapter. */
readonly indexType: 'hnsw' | 'flat';
insert(entry: {
id?: string;
vector: Float32Array | number[];
metadata?: Record<string, any>;
}): string;
insertBatch(
entries: Array<{
id?: string;
vector: Float32Array | number[];
metadata?: Record<string, any>;
}>
): string[];
search(query: {
vector: Float32Array | number[];
k: number;
filter?: Record<string, any>;
}): AdapterSearchResult[];
get(
id: string
): { id: string; vector?: Float32Array; metadata?: Record<string, any> } | null;
delete(id: string): boolean;
len(): number;
isEmpty(): boolean;
clearMetadata(): void;
}
export default RuvectorWasmAdapter;

View file

@ -0,0 +1,330 @@
/**
* RuvectorWasmAdapter a correct, ergonomic wrapper around the generated
* `@ruvector/wasm` `VectorDB`.
*
* It exists to paper over three behaviours of the current WASM build that bite
* callers who take the raw bindings (and the generated `.d.ts`) at face value:
*
* 1. **HNSW is not active in the WASM build.** The Rust crate compiles the
* `wasm32` target *without* the `hnsw` feature, so `VectorDB` silently falls
* back to a brute-force flat index (`vector_db.rs`:
* `"HNSW requested but not available (WASM build), using flat index"`).
* Results are still correct, but search is O(n), not O(log n). The win is
* latent until the upstream WASM HNSW lands. This adapter surfaces that fact
* via {@link RuvectorWasmAdapter#indexType} / {@link WASM_HNSW_AVAILABLE}
* instead of letting callers assume a logarithmic index.
*
* 2. **`result.score` is a cosine *distance*, not a similarity.** Lower is
* better and the ordering is correct (a, b before c), but that contradicts
* the generated `.d.ts` which advertises a "higher is better" score. This
* adapter exposes both the raw `distance` and a `similarity = 1 - distance`
* (generalised per metric) so "higher is better" actually holds.
*
* 3. **Metadata does not round-trip.** Inserted metadata comes back as `{}`
* (or `undefined`) from the WASM `search`/`get` getters. This adapter keeps
* an in-process **metadata sidecar** keyed by vector id and re-attaches it
* on the way out, so what you put in is what you get back.
*
* The adapter is dependency-injectable: pass a pre-constructed `VectorDB` (real
* or a test double), or use {@link RuvectorWasmAdapter.create} to load and init
* the WASM module for you.
*
* @module @ruvector/wasm/adapter
*/
/**
* Whether the published WASM build ships an active HNSW index.
*
* The crate is compiled for `wasm32` without the `hnsw` cargo feature, so this
* is `false` today: the WASM `VectorDB` uses a flat (brute-force) index. Flip
* to `true` once the WASM build enables HNSW.
*
* @type {boolean}
*/
export const WASM_HNSW_AVAILABLE = false;
/**
* Convert a raw distance score (lower is better) into a similarity where
* higher is better, matching the contract the `.d.ts` advertises.
*
* Mirrors the conversion used by `@ruvector/router` so the whole ecosystem
* agrees on what "score" means.
*
* @param {string} metric - 'cosine' | 'dot' | 'dotproduct' | 'euclidean' | 'manhattan'
* @param {number} distance - Raw score returned by the WASM `search`.
* @returns {number} Similarity, higher is better.
*/
export function distanceToSimilarity(metric, distance) {
switch ((metric || 'cosine').toLowerCase()) {
case 'cosine':
// cosine distance = 1 - cosine_similarity ⇒ similarity = 1 - distance
return 1 - distance;
case 'dot':
case 'dotproduct':
// dot "distance" is stored negated ⇒ similarity = -distance
return -distance;
case 'euclidean':
case 'manhattan':
default:
// unbounded distances: monotonic decreasing map into (0, 1]
return 1 / (1 + distance);
}
}
/**
* @typedef {Object} AdapterSearchResult
* @property {string} id - Vector id.
* @property {number} similarity - Higher is better (see {@link distanceToSimilarity}).
* @property {number} distance - Raw score from the WASM index (lower is better).
* @property {number} score - Alias of `similarity`, so the documented
* "higher is better" score contract holds for callers reading `.score`.
* @property {Float32Array=} vector - Vector data, when returned by the index.
* @property {Record<string, any>=} metadata - Round-tripped metadata from the sidecar.
*/
/**
* Correct wrapper around the generated WASM `VectorDB`.
*/
export class RuvectorWasmAdapter {
/**
* @param {any} db - A constructed WASM `VectorDB` instance (or a compatible
* test double exposing `insert`, `insertBatch`, `search`, `get`, `delete`,
* `len`/`isEmpty`).
* @param {Object} [options]
* @param {number} [options.dimensions] - Vector dimensions (informational).
* @param {string} [options.metric='cosine'] - Distance metric the `db` was
* created with; controls the similarity conversion.
* @param {boolean} [options.usesHnsw] - Override the index-type report. Defaults
* to {@link WASM_HNSW_AVAILABLE}.
*/
constructor(db, options = {}) {
if (!db) {
throw new Error('RuvectorWasmAdapter requires a VectorDB instance');
}
this._db = db;
this._metric = (options.metric || 'cosine').toLowerCase();
this._dimensions = options.dimensions;
this._usesHnsw = options.usesHnsw ?? WASM_HNSW_AVAILABLE;
/**
* Metadata sidecar: id -> metadata. Works around the WASM build not
* round-tripping metadata through `search`/`get`.
* @type {Map<string, Record<string, any>>}
*/
this._metadata = new Map();
}
/**
* Load the WASM module, construct a `VectorDB`, and wrap it.
*
* @param {Object} [options]
* @param {number} options.dimensions - Vector dimensions (required).
* @param {string} [options.metric='cosine'] - Distance metric.
* @param {boolean} [options.useHnsw=true] - Requested at the WASM layer; note
* the WASM build falls back to flat regardless (see {@link WASM_HNSW_AVAILABLE}).
* @param {any} [options.module] - Pre-imported WASM module (exposing `default`
* init and `VectorDB`). If omitted, `@ruvector/wasm` is imported dynamically.
* @returns {Promise<RuvectorWasmAdapter>}
*/
static async create(options = {}) {
const { dimensions, metric = 'cosine', useHnsw = true } = options;
if (!dimensions || dimensions <= 0) {
throw new Error('RuvectorWasmAdapter.create requires positive `dimensions`');
}
const mod = options.module ?? (await import('@ruvector/wasm'));
// `web`/`bundler` targets export a default init() that must run once before
// any class is constructed. `nodejs` targets have no default export.
if (typeof mod.default === 'function') {
await mod.default();
}
const VectorDB = mod.VectorDB;
if (typeof VectorDB !== 'function') {
throw new Error('@ruvector/wasm did not export a VectorDB constructor');
}
const db = new VectorDB(dimensions, metric, useHnsw);
return new RuvectorWasmAdapter(db, { dimensions, metric });
}
/**
* Whether this index is backed by HNSW. `false` for the current WASM build
* search is O(n) flat scan until upstream WASM HNSW lands.
* @returns {boolean}
*/
get usesHnsw() {
return this._usesHnsw;
}
/**
* Index type, for callers that want to reason about search complexity.
* @returns {'hnsw' | 'flat'}
*/
get indexType() {
return this._usesHnsw ? 'hnsw' : 'flat';
}
/**
* Insert a single vector, recording its metadata in the sidecar.
*
* @param {Object} entry
* @param {string} [entry.id] - Optional id (auto-generated by WASM if absent).
* @param {Float32Array | number[]} entry.vector
* @param {Record<string, any>} [entry.metadata]
* @returns {string} The vector id (the WASM-assigned one when not supplied).
*/
insert(entry) {
const vector = toFloat32(entry.vector);
// Still hand metadata to the WASM layer (forward-compat for when it
// round-trips), but the sidecar is the source of truth on the way out.
const id = this._db.insert(vector, entry.id, entry.metadata);
if (entry.metadata !== undefined) {
this._metadata.set(id, entry.metadata);
}
return id;
}
/**
* Insert vectors in a batch, recording metadata in the sidecar.
*
* @param {Array<{ id?: string, vector: Float32Array | number[], metadata?: Record<string, any> }>} entries
* @returns {string[]} Vector ids in the same order as `entries`.
*/
insertBatch(entries) {
const nativeEntries = entries.map((e) => ({
id: e.id,
vector: toFloat32(e.vector),
metadata: e.metadata,
}));
const ids = this._db.insertBatch(nativeEntries);
for (let i = 0; i < ids.length; i++) {
const meta = entries[i] && entries[i].metadata;
if (meta !== undefined) {
this._metadata.set(ids[i], meta);
}
}
return ids;
}
/**
* Search for the `k` nearest vectors.
*
* Returns results ordered best-first by `similarity` (higher is better), with
* the raw `distance` preserved and metadata re-attached from the sidecar.
* When `filter` is supplied it is applied against the sidecar metadata (the
* WASM filter relies on metadata that does not round-trip), over-fetching as
* needed so `k` results survive the filter where possible.
*
* @param {Object} query
* @param {Float32Array | number[]} query.vector
* @param {number} query.k
* @param {Record<string, any>} [query.filter] - Exact-match metadata filter.
* @returns {AdapterSearchResult[]}
*/
search(query) {
const k = query.k;
const vector = toFloat32(query.vector);
const hasFilter = query.filter && Object.keys(query.filter).length > 0;
// Over-fetch when filtering so post-filter results can still reach k.
const fetch = hasFilter ? Math.max(k * 4, k) : k;
const raw = this._db.search(vector, fetch, undefined) || [];
let mapped = raw.map((r) => {
const distance = r.score;
const metadata = this._metadata.has(r.id)
? this._metadata.get(r.id)
: r.metadata;
const similarity = distanceToSimilarity(this._metric, distance);
return {
id: r.id,
similarity,
score: similarity,
distance,
vector: r.vector,
metadata,
};
});
if (hasFilter) {
const entries = Object.entries(query.filter);
mapped = mapped.filter((r) => {
const md = r.metadata;
if (!md) return false;
return entries.every(([key, value]) => md[key] === value);
});
}
// The flat index already orders by ascending distance, but sort defensively
// so a, b come before c regardless of the underlying index's guarantees.
mapped.sort((a, b) => b.similarity - a.similarity);
return mapped.slice(0, k);
}
/**
* Get a vector by id, with metadata re-attached from the sidecar.
*
* @param {string} id
* @returns {{ id: string, vector?: Float32Array, metadata?: Record<string, any> } | null}
*/
get(id) {
const entry = this._db.get(id);
if (!entry) return null;
return {
id: entry.id ?? id,
vector: entry.vector,
metadata: this._metadata.has(id) ? this._metadata.get(id) : entry.metadata,
};
}
/**
* Delete a vector by id, dropping its sidecar metadata.
* @param {string} id
* @returns {boolean}
*/
delete(id) {
const deleted = this._db.delete(id);
if (deleted) {
this._metadata.delete(id);
}
return deleted;
}
/**
* Number of vectors in the index.
* @returns {number}
*/
len() {
if (typeof this._db.len === 'function') return this._db.len();
return this._metadata.size;
}
/**
* Whether the index is empty.
* @returns {boolean}
*/
isEmpty() {
if (typeof this._db.isEmpty === 'function') return this._db.isEmpty();
return this.len() === 0;
}
/**
* Drop all sidecar metadata. Call this when you recreate the underlying db.
*/
clearMetadata() {
this._metadata.clear();
}
}
/**
* Coerce a vector into a `Float32Array` without copying when already one.
* @param {Float32Array | number[]} v
* @returns {Float32Array}
*/
function toFloat32(v) {
return v instanceof Float32Array ? v : new Float32Array(v);
}
export default RuvectorWasmAdapter;

View file

@ -0,0 +1,176 @@
/**
* Tests for RuvectorWasmAdapter.
*
* Uses a FakeVectorDB that reproduces the three problematic behaviours of the
* real WASM build:
* 1. flat (no HNSW) `usesHnsw === false`
* 2. `score` is a cosine *distance* (lower is better)
* 3. metadata does not round-trip (search/get return `{}`)
*
* The adapter must hide all three: similarity higher-is-better with correct
* ordering, and metadata round-tripped via the sidecar.
*
* Run: node --test crates/ruvector-wasm/tests/adapter.test.mjs
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
RuvectorWasmAdapter,
distanceToSimilarity,
WASM_HNSW_AVAILABLE,
} from '../src/adapter.js';
function cosineDistance(a, b) {
let dot = 0;
let na = 0;
let nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
const denom = Math.sqrt(na) * Math.sqrt(nb);
return denom === 0 ? 1 : 1 - dot / denom;
}
/** Mimics the real WASM VectorDB: flat index, distance score, no metadata round-trip. */
class FakeVectorDB {
constructor() {
this.store = new Map();
this._auto = 0;
}
insert(vector, id /*, metadata */) {
const key = id ?? `auto-${this._auto++}`;
// Note: metadata is intentionally dropped — reproduces the WASM bug.
this.store.set(key, Float32Array.from(vector));
return key;
}
insertBatch(entries) {
return entries.map((e) => this.insert(e.vector, e.id, e.metadata));
}
search(vector, k /*, filter */) {
const results = [];
for (const [id, vec] of this.store) {
results.push({ id, score: cosineDistance(vector, vec), metadata: {} });
}
results.sort((a, b) => a.score - b.score); // flat scan, ascending distance
return results.slice(0, k);
}
get(id) {
const vec = this.store.get(id);
return vec ? { id, vector: vec, metadata: {} } : null;
}
delete(id) {
return this.store.delete(id);
}
len() {
return this.store.size;
}
isEmpty() {
return this.store.size === 0;
}
}
test('distanceToSimilarity: cosine distance -> higher-is-better similarity', () => {
assert.equal(distanceToSimilarity('cosine', 0), 1);
assert.equal(distanceToSimilarity('cosine', 0.25), 0.75);
assert.ok(
distanceToSimilarity('cosine', 0.1) > distanceToSimilarity('cosine', 0.4)
);
});
test('finding #2: search returns similarity (higher is better) with a, b before c', () => {
const db = new FakeVectorDB();
const adapter = new RuvectorWasmAdapter(db, { dimensions: 3, metric: 'cosine' });
// a and b are close to the query [1,0,0]; c is orthogonal.
adapter.insert({ id: 'a', vector: [1, 0, 0] });
adapter.insert({ id: 'b', vector: [0.9, 0.1, 0] });
adapter.insert({ id: 'c', vector: [0, 0, 1] });
const results = adapter.search({ vector: [1, 0, 0], k: 3 });
assert.deepEqual(
results.map((r) => r.id),
['a', 'b', 'c']
);
// Higher is better, and the best result outscores the worst.
assert.ok(results[0].similarity >= results[1].similarity);
assert.ok(results[1].similarity >= results[2].similarity);
assert.ok(results[0].similarity > results[2].similarity);
// `.score` honours the documented "higher is better" contract.
assert.equal(results[0].score, results[0].similarity);
// Raw distance preserved (lower is better) and consistent with similarity.
assert.ok(results[0].distance <= results[2].distance);
assert.ok(Math.abs(results[0].similarity - (1 - results[0].distance)) < 1e-6);
});
test('finding #3: metadata round-trips via the sidecar', () => {
const db = new FakeVectorDB();
const adapter = new RuvectorWasmAdapter(db, { dimensions: 3, metric: 'cosine' });
const meta = { title: 'doc-a', tags: ['x', 'y'] };
adapter.insert({ id: 'a', vector: [1, 0, 0], metadata: meta });
adapter.insert({ id: 'b', vector: [0, 1, 0], metadata: { title: 'doc-b' } });
// Raw WASM would return {}; the adapter restores the real metadata.
const [top] = adapter.search({ vector: [1, 0, 0], k: 1 });
assert.deepEqual(top.metadata, meta);
const got = adapter.get('a');
assert.deepEqual(got.metadata, meta);
});
test('insertBatch round-trips metadata in order', () => {
const db = new FakeVectorDB();
const adapter = new RuvectorWasmAdapter(db, { dimensions: 2, metric: 'cosine' });
const ids = adapter.insertBatch([
{ id: 'one', vector: [1, 0], metadata: { n: 1 } },
{ id: 'two', vector: [0, 1], metadata: { n: 2 } },
]);
assert.deepEqual(ids, ['one', 'two']);
assert.deepEqual(adapter.get('two').metadata, { n: 2 });
});
test('filter is applied against sidecar metadata', () => {
const db = new FakeVectorDB();
const adapter = new RuvectorWasmAdapter(db, { dimensions: 2, metric: 'cosine' });
adapter.insert({ id: 'a', vector: [1, 0], metadata: { kind: 'fruit' } });
adapter.insert({ id: 'b', vector: [0.95, 0.05], metadata: { kind: 'veg' } });
adapter.insert({ id: 'c', vector: [0.9, 0.1], metadata: { kind: 'fruit' } });
const results = adapter.search({ vector: [1, 0], k: 2, filter: { kind: 'fruit' } });
assert.deepEqual(
results.map((r) => r.id),
['a', 'c']
);
});
test('finding #1: index type reports flat (HNSW not active in WASM build)', () => {
const db = new FakeVectorDB();
const adapter = new RuvectorWasmAdapter(db, { dimensions: 2 });
assert.equal(WASM_HNSW_AVAILABLE, false);
assert.equal(adapter.usesHnsw, false);
assert.equal(adapter.indexType, 'flat');
});
test('delete drops sidecar metadata and updates length', () => {
const db = new FakeVectorDB();
const adapter = new RuvectorWasmAdapter(db, { dimensions: 2 });
adapter.insert({ id: 'a', vector: [1, 0], metadata: { keep: false } });
assert.equal(adapter.len(), 1);
assert.equal(adapter.delete('a'), true);
assert.equal(adapter.len(), 0);
assert.equal(adapter.get('a'), null);
assert.equal(adapter.isEmpty(), true);
});