Skip to content
5 min read · 1,015 words

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 and asCallable()
  • vs.schema (the knex-shaped builder) and vs.migrate
  • protected encode(texts, kind) — the text→vector resolver that throws E_VECTOR_STORE_ENCODER_REQUIRED when there's no encoder and no built-in encoding
  • transaction() — throws E_VECTOR_STORE_TRANSACTIONS_UNSUPPORTED by 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:

typescript
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 neutral evaluateFilter over 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.ts unit test like Qdrant/OpenSearch have.
  • Recompute score via normalizeScore (from helpers.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_*_FAILED exception with the original on cause, and use isInstanceOf (not bare instanceof) 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 attach score only on a similarity search.
  • Declare capabilities truthfully. If your backend isn't ACID, leave transactions: false and 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.

typescript
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:

typescript
runVectorStoreConformance(label, makeStore, dim, opts)
  • dim (default 3) — for a backend with a dimension floor. The harness pads every test vector to dim. Cloudflare Vectorize requires 32–1536, so its spec runs at dim: 32 with paddedStubEncoder(32).
  • opts.retry / opts.timeout — for an aggressively eventually-consistent backend whose read-after-write flaps for seconds. retry re-runs a flaked attempt (re-clearing via makeStore) 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):

typescript
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 import

Because 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