Skip to content

Adapters & queries

An adapter is a Repository<T> implementation in the Infrastructure layer. Services never import one directly — they receive a Repository<T> — so moving a service from SQLite to Postgres to MongoDB is a change to the repository you construct, not to the service.

Package Repository classes Backends
@mantlejs/knex KnexRepository, KnexVectorRepository PostgreSQL (incl. pgvector), MySQL/MariaDB, SQLite, MSSQL
@mantlejs/mongodb MongoRepository, MongoVectorRepository MongoDB, Atlas Vector Search
@mantlejs/supabase SupabaseRepository Supabase (Postgres via PostgREST)
@mantlejs/dynamodb DynamoDbRepository Amazon DynamoDB
@mantlejs/neo4j Neo4jRepository Neo4j (with graph traversal)
@mantlejs/pinecone PineconeRepository Pinecone (vector search)
@mantlejs/qdrant QdrantRepository Qdrant (vector search)
@mantlejs/memory MemoryRepository In-process Map — tests and prototypes

Adapters are configured once as a plugin, then subclassed per table or collection:

import { mantle, RepositoryService } from "@mantlejs/mantle";
import { knex, KnexRepository } from "@mantlejs/knex";
const app = mantle().configure(knex({ client: "pg", connection: process.env.DATABASE_URL }));
class UserRepository extends KnexRepository<User> {
readonly tableName = "users";
}
app.use("users", new RepositoryService(new UserRepository(app)));

Every adapter keeps an escape hatch to its native client (the Knex query builder, the MongoDB collection, Neo4j’s Cypher raw()) for queries the portable API can’t express — put them in a named repository method, not in the service.

find() takes a QueryParams object — where, sort, limit, skip, select — that every adapter translates to its own query language. Over HTTP the same object travels as a bracket-notation query string (?age[$gt]=21&$sort[name]=asc&$limit=10).

The where operators:

  • Equality { field: value }, null { field: null }, not-equal $ne
  • Comparison $lt, $lte, $gt, $gte; inclusion $in, $nin
  • Logical $or, $and (arrays of where clauses)
  • Pattern $like, $notlike, $ilike
  • Containment $contains — the field is a superset of the value (an element, every element, or a recursive key/value match)
  • Nested paths — dot-path keys such as "metadata.owner.name"

Support is uneven by design: some backends can’t represent nested data at all. Rather than silently misbehaving, every adapter rejects an operator it doesn’t support with a BadRequest naming it, and reports exactly what it accepts through describe().capabilities — the same metadata @mantlejs/openapi and @mantlejs/mcp use, so an AI agent is never offered an operator the adapter would reject.

Adapter$containsNested paths ("a.b.c")
memory✅ full (reference implementation)✅ full
supabase✅ jsonb @> (Postgres)✅ PostgREST ->/->>
mongodb✅ native✅ native
knex — PostgreSQL✅ jsonb @>✅ jsonb_path_query_first (via whereJsonPath)
knex — MySQL✅ JSON_CONTAINS✅ JSON_EXTRACT (via whereJsonPath)
knex — SQLite❌ no native JSON-superset function✅ json_extract (via whereJsonPath)
knex — MSSQL❌ no native JSON-superset function✅ JSON_VALUE (via whereJsonPath)
dynamodb✅ contains(), ANDed per element/leaf for array/object operands✅ multi-segment #name0.#name1 attribute paths
qdrant✅ match.value, ANDed per element/leaf (arrays auto-match-any-element)✅ native (dot-path payload keys)
neo4j❌ node properties can't hold nested objects at all❌ same — architectural, not a gap
pinecone❌ metadata values are flat scalars/scalar-arrays only❌ same — architectural, not a gap

Rendered at build time from CLAUDE.md, so it can't drift from the repository.

On knex, capabilities depend on the connected client — check describe().capabilities at runtime rather than assuming every SQL database behaves the same. The semantics in this table are pinned by a shared conformance fixture exported from @mantlejs/mantle (NESTED_QUERY_CASES), which @mantlejs/memory executes as the reference implementation and the other adapters run as translator specs.

RepositoryService covers one repository. When a service has to write to two stores — a record and its embedding, a record and an activity log — write a Service<T> that receives both repositories. Writes across adapters aren’t atomic, so the rule is: the secondary write is an idempotent upsert keyed on the source record’s id, safe to retry, and non-fatal on failure — logged, never rethrown into the primary caller. @mantlejs/embeddings’s embed() hook is the reference implementation of that pattern.