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 ​