Writing an Adapter
29 backends won't be the last one you want. An adapter is a single class with a small, fixed set of methods, and the base class does most of the work — the callable wiring, the schema and migration builders, the text-to-vector resolver, the transaction guard. You implement the part that actually talks to your backend, and a shared conformance suite tells you, honestly, whether you got it right.
What the base gives you, what you implement
BaseVectorStore (@nhtio/adk/batteries/vector/contract) owns the parts that are the same for every backend:
- the callable
vs(collection)wiring andasCallable() vs.schema(the knex-shaped builder) andvs.migrateprotected encode(texts, kind)— the text→vector resolver that throwsE_VECTOR_STORE_ENCODER_REQUIREDwhen there's no encoder and no built-in encodingtransaction()— throwsE_VECTOR_STORE_TRANSACTIONS_UNSUPPORTEDby default, so you get the "never fake a transaction" behaviour for free unless you override it
You implement the primitives — the data plane, the schema plane, and lifecycle:
import { BaseVectorStore } from '@nhtio/adk/batteries/vector'
export class MyVectorStore extends BaseVectorStore {
readonly capabilities = {
transactions: false,
namedVectors: false,
rename: false,
rawSql: false,
builtInEncoding: false,
consistency: { configurable: false, default: 'strong', modes: ['strong'] },
}
static isAvailable() { return typeof process !== 'undefined' }
isAvailable() { return typeof process !== 'undefined' }
async connect() { /* open the client (idempotent) */ }
async close() { /* tear it down */ }
// data plane — the builder routes here, with fully-formed neutral plans
async executeSearch(plan: SearchPlan): Promise<VectorMatch[]> { /* … */ }
async executeUpsert(plan: UpsertPlan): Promise<void> { /* … */ }
async executeDelete(plan: DeletePlan): Promise<void> { /* … */ }
// schema plane — vs.schema / vs.migrate route here
async createCollection(spec: CollectionSpec, ifNotExists: boolean) { /* … */ }
async dropCollection(collection: string, ifExists: boolean) { /* … */ }
async hasCollection(collection: string): Promise<boolean> { /* … */ }
async renameCollection(from: string, to: string) { /* … */ }
}The key simplification: you consume neutral plans, never the builder. By the time executeSearch is called, the base has already resolved .nearText() to a vector (or a server-text directive), enforced the .select() requirement, and handed you a SearchPlan with a populated projection and a fully-formed near. You read the plan and talk to your driver. You never parse a chain.
The conventions every shipped adapter follows
These aren't enforced by the type system, but matching them is what makes an adapter feel like part of the battery rather than a bolt-on:
- One self-contained file at
src/batteries/vector/<name>/index.ts, opening with a unique@module @nhtio/adk/batteries/vector/<name>JSDoc tag. Vite discovers the subpath from that tag; a duplicate tag throws at build, so the name is the contract. - An options interface
export interface <Name>VectorStoreOptions extends BaseVectorStoreOptions { connection: { … } }. - Lazy driver import in a small getter that throws
E_VECTOR_STORE_DRIVER_UNAVAILABLE(['<driver>'])on failure — so importing the adapter without the peer installed fails with a clear message, not a module-not-found stack. - Metadata as JSON + filter via
evaluateFilter. Store metadata as a JSON string (or native object) and, for filtering, over-fetch candidates then run the neutralevaluateFilterover them. This buys exact cross-adapter parity for free. Only write a native filter translator if your backend makes it cheap and correct — and if you do, add a<name>_filter.node.spec.tsunit test like Qdrant/OpenSearch have. - Recompute
scorevianormalizeScore(fromhelpers.ts) so the[0,1]higher-is-better contract holds regardless of your backend's native metric. Several shipped adapters ignore the backend's raw score entirely and recompute from the stored vector — it's the reliable path. - Wrap driver errors in the relevant
E_VECTOR_STORE_*_FAILEDexception with the original oncause, and useisInstanceOf(not bareinstanceof) to re-throw the battery's own typed exceptions (E_VECTOR_STORE_DIMENSION_MISMATCH, etc.) past your catch block. - Honor the projection — return only the columns named in
plan.projection, and attachscoreonly on a similarity search. - Declare capabilities truthfully. If your backend isn't ACID, leave
transactions: falseand the base throws for you. If it's eventually consistent, say so (configurable: true) and settle-poll on writes. The whole point of capabilities is that they're true.
The closest templates to copy: in_memory for the pure-reference shape, sqlite_vec/pgvector for an ACID SQL backend, qdrant for a server with native filter translation, pinecone/s3vectors/cloudflare for an eventually-consistent managed service, solr/vespa for a pure-fetch HTTP backend with no driver.
Prove it: the conformance harness
An adapter isn't done because it compiles — it's done when it passes the same suite all 29 shipped adapters pass. runVectorStoreConformance, exported from @nhtio/adk/batteries/vector/conformance, is that suite: seven tests covering upsert+search with a [0,1] score, text encoding, the required-.select() throw, filter-scan without score, projection (vector excluded unless selected), delete-by-id, and the transaction-unsupported throw. It's a public, deep-import-only subpath — the same one the kit's own adapters test against — and it imports vitest as an optional peer, so install vitest in your project to run it.
import { describe } from 'vitest'
import { createVectorStore } from '@nhtio/adk/batteries/vector'
import { runVectorStoreConformance, stubEncoder } from '@nhtio/adk/batteries/vector/conformance'
import { MyVectorStore } from './my-vector-store'
import type { CollectionBuilder } from '@nhtio/adk/batteries/vector'
const url = process.env.TEST_VECTOR_MY_URL
const d = url ? describe : describe.skip
d('MyVectorStore (integration)', () => {
const makeStore = async () => {
const vs = await createVectorStore({
client: MyVectorStore,
options: { metric: 'cosine', encoder: stubEncoder, dimensions: 3, connection: { url } },
})
await vs.connect()
await vs.schema.createCollection('docs', (c: CollectionBuilder) =>
c.vector({ dimensions: 3, metric: 'cosine' }),
)
return vs
}
runVectorStoreConformance('MyVectorStore', makeStore)
})makeStore must return a connected store with a 'docs' collection already created at the right dimension — the suite assumes that and drives the rest. Gate the whole describe on TEST_VECTOR_<NAME>_URL so the suite skips green when no backend is configured, and never blocks anyone who doesn't run your backend.
Two escape hatches for awkward backends
The harness takes two optional arguments, both defaulting to no-ops so the 29 existing specs are unaffected:
runVectorStoreConformance(label, makeStore, dim, opts)dim(default 3) — for a backend with a dimension floor. The harness pads every test vector todim. Cloudflare Vectorize requires 32–1536, so its spec runs atdim: 32withpaddedStubEncoder(32).opts.retry/opts.timeout— for an aggressively eventually-consistent backend whose read-after-write flaps for seconds.retryre-runs a flaked attempt (re-clearing viamakeStore) against a more-settled backend, turning transient-consistency flake into deterministic green without weakening a single assertion. Cloudflare uses{ retry: 4, timeout: 90_000 }.
Reach for these only when the backend genuinely forces them — they exist because real managed services are messier than a reference implementation, not as a way to paper over an adapter bug. If your adapter needs a retry to pass against a strongly-consistent backend, the bug is in the adapter.
Registering it
There is no central registry. An adapter is just a class you pass to the factory, which accepts it three ways (see the hub):
const vs = await createVectorStore({ client: MyVectorStore, options }) // the class
const vs = await createVectorStore({ client: () => MyVectorStore, options }) // sync resolver
const vs = await createVectorStore({ client: () => import('./my'), options }) // async resolver / dynamic importBecause the factory validates the resolved constructor against the BaseVectorStore shape, a class that doesn't implement the contract is rejected at construction with E_INVALID_VECTOR_STORE_CONFIG — you find out immediately, not at first query. And because adapters are deep-import-only, yours pulls its driver exactly when someone constructs it, same as every shipped one. You don't need to live in this repo to write an adapter; the contract is public and the base class is exported.
Where to go next
- The Query Builder & Filters — the plans (
SearchPlan/UpsertPlan/DeletePlan) yourexecute*methods receive. - Consistency & Capabilities — declaring
capabilitiesand handling read-after-write honestly. - The Adapter Matrix — the shipped adapters to copy from, by class.