Skip to content

Commit 39076db

Browse files
authored
feat: add @payloadcms-vectorize/mongodb adapter (#52)
* chore: ignore .worktrees/ directory * docs: add MongoDB adapter deep-dive and unified strategy docs * docs(spec): add MongoDB adapter spec for adapters/mongodb Single adapter targeting Atlas (GA) and self-hosted Community 8.2+ via a unified $vectorSearch API. Documents public API, data layout, method semantics, WHERE clause pre/post-filter split, index lifecycle, dev/CI environment (mongodb-atlas-local Docker image), test plan, and acceptance criteria. Reviewed by spec-document-reviewer subagent (status: Approved); five advisory recommendations folded in. * docs(spec): require mongodb adapter in changeset fixed array * docs(spec): add explicit root package.json scripts for mongodb (test:setup:mongodb, test:adapters:mongodb, build:adapters:mongodb), placed adjacent to existing PG/CF siblings * docs(spec): move test:setup/test:teardown to adapters/mongodb/package.json (local to the adapter, not root) * docs(plan): add MongoDB adapter implementation plan 20 bite-sized TDD tasks covering: package skeleton, escapeRegExp, types, lazy MongoClient, convertWhereToMongo (pre/post-filter split, and/or recursion, id→_id ObjectId casting, evaluatePostFilter), ensureSearchIndex with definition-mismatch detection, storeChunk, search via \$vectorSearch aggregation, deleteChunks/hasEmbeddingVersion, docker-compose, compliance/where/integration suites, README, root build+test wiring, changeset registration, CI job, end-to-end verification. * feat(mongodb): scaffold adapter package skeleton * feat(mongodb): add escapeRegExp utility * feat(mongodb): add public types and config helpers * feat(mongodb): add lazy singleton MongoClient with test close helper * feat(mongodb): convertWhereToMongo handles pre-filter leaf operators * feat(mongodb): convertWhereToMongo routes like/contains/all to post-filter * feat(mongodb): convertWhereToMongo handles and/or composition with pre/post split * feat(mongodb): restore explanatory comments dropped from convertWhere * feat(mongodb): add evaluatePostFilter for runtime post-filter matching * feat(mongodb): ensureSearchIndex with definition-mismatch detection and READY polling * docs(plan): simplify Task 10 embedding coercion (remove redundant ternary) * feat(mongodb): add storeChunk that ensures index then inserts document * fix(adapters): spread extensionFields before reserved fields in storeChunk Both pg and mongodb adapters previously spread data.extensionFields after sourceCollection/docId/chunkIndex/chunkText/embeddingVersion, allowing user-supplied extension data to silently overwrite the canonical reserved values. Move the spread to the front so reserved fields always win at runtime — defense-in-depth on top of the existing config-time field-name collision check in createEmbeddingsCollection. * feat(mongodb): implement search via $vectorSearch with pre/post split * fix(mongodb): throw on missing search score instead of silently producing NaN A search result without a numeric score is meaningless — sort order is load-bearing on it. Replace the silent Number() coercion with an explicit guard that throws if doc.score is not a number, with an actionable message pointing at the $project stage. * feat(mongodb): map Payload id → Mongo _id with ObjectId casting * feat(mongodb): add createMongoVectorIntegration factory and adapter wiring * feat(mongodb): add docker-compose for local mongodb-atlas-local stack * fix(mongodb): ensure collection exists before createSearchIndex; add compliance suite Atlas Local rejects createSearchIndex against a non-existent collection ("Collection 'X' does not exist"). Materialize the collection idempotently via db.createCollection in indexes.ts before the first search-index creation. Atlas Cloud is more lenient, but the adapter must work in both. Also lands the compliance suite (12 tests across getConfigExtension, storeChunk, search, deleteChunks, hasEmbeddingVersion) that surfaced this gap, plus shared dev/specs/{constants,utils}.ts helpers. * test(mongodb): add WHERE-clause + integration suites against live Mongo * docs(plan): record Task 16 fixes — limit/numCandidates, Atlas Local visibility lag, PENDING-state poll Three plan-side bugs surfaced when the WHERE + integration suites ran against the live Atlas Local container: 1. performVectorSearch helper default limit=100 violates Atlas's limit <= numCandidates (pool is numCandidates: 50). Lowered to 10. 2. ~1s eventual-consistency lag between insertOne and $vectorSearch visibility even after index READY — added 1200ms waits in WHERE beforeAll and integration immediate-search test. 3. Manually-created search index returns in PENDING; ensureSearchIndex treats non-READY/BUILDING as unexpected — added poll loop in the conflicting-definition test before triggering the conflict. * test(mongodb): tighten Task 16 — try/finally on ad-hoc client, add length guards before forEach * docs(mongodb): add README walking from install through Atlas + self-hosted * feat(mongodb): wire mongodb adapter into root build/test scripts and changesets * ci(mongodb): add test_adapters_mongodb job using mongodb-atlas-local service * docs(plan): record Task 19 gate wiring — test_adapters_mongodb in aggregate test job's needs array * chore(changeset): add MongoDB adapter (minor) * chore: gitignore adapters/mongodb/dist for parity with pg and cf * fix(mongodb): drop rejected MongoClient promises from cache so next call retries * test(mongodb): assert MongoClient.connect call count to gate the rejected-cache regression * fix(mongodb): promise-singleton ensureSearchIndex; propagate listSearchIndexes errors * test(mongodb): mock listSearchIndexes consistently across concurrent callers to actually gate the cache race * fix(mongodb): default numCandidates to 10× limit, drop arbitrary 100 floor * refactor(mongodb): capture URI in adapter closure; drop credential from payload custom * test(mongodb): lock URI-not-on-config invariant in compliance spec Task 4 stripped uri from _mongoConfig but compliance.spec.ts:29 still asserted ext.custom._mongoConfig.uri === MONGO_URI, breaking the test suite. Replace with a positive `not.toHaveProperty('uri')` assertion so the security invariant is locked: re-introducing uri on the persisted config would now fail this test with a clear "expected ... to not have property 'uri'" message. Manual revert proof performed (uri added back in src/index.ts → test fails with expected message → restored). * test(mongodb): boot real PayloadCMS in adapter specs; remove makeFakePayload shim * chore(mongodb-tests): tighten injectDbName + drop unused import/cast Code-review follow-ups from Task 5: - injectDbName now throws on URIs with a path component (e.g. SRV cluster strings carrying a default DB), instead of silently producing invalid double-path URIs like mongodb+srv://.../myapp/test. - Drop unused dropTestDb import in integration.spec.ts. - Drop the (payload as any).destroy() escape hatch in teardownDbs; BasePayload.destroy() is on the public type. * test(mongodb): assert convertWhereToMongo rejects undeclared filter fields * test(mongodb): add extensionFields spec covering index decl + persistence * test(mongodb): multipools spec — collection+index isolation, cross-pool query isolation * ci: verify @payloadcms-vectorize/mongodb dist/index.d.ts is emitted * docs(mongodb): drop experimental framing — adapter is GA on Atlas, supported on 8.2+ * docs(mongodb): drop remaining experimental wording in README lede + npm keyword * feat(mongodb): validate limit at search entrypoint with clear error Throws "limit must be a positive integer" before reaching $vectorSearch when limit is 0, negative, NaN, or non-integer. Without this guard the driver returns an opaque "Executor error during aggregate command" which is hard to trace back to caller input. * test(mongodb): cover BUILDING -> READY polling transition ensureSearchIndex polls listSearchIndexes every 1s until status === 'READY' after creating a new index. Adds a fake-timers test that drives the mock through []/BUILDING/BUILDING/READY and asserts createSearchIndex is called once and listSearchIndexes is called 4 times. Manual revert proof: relaxing the polling guard to also early-return on BUILDING makes the test fail with "expected spy to be called 4 times, but got 2 times". * fix(mongodb): make definitionsEqual insensitive to mongot field/key ordering mongot is free to return latestDefinition with the fields array in any order and with object property keys in any order. The previous JSON.stringify-based comparison flagged these as 'different definition' and threw, forcing users to drop a perfectly valid index. Canonicalize before compare: - sort all object keys - sort the fields array by canonical-JSON of each field Other arrays (in case the schema grows later) keep their order so a real ordered list can't be silently swallowed. Tests: - positive: a reordered-but-equivalent definition is accepted, no createSearchIndex call. - negative: a definition with a different similarity still throws /different definition/. Manual revert proofs: - restoring JSON.stringify makes the positive test fail with /different definition/. - stubbing definitionsEqual to always-true makes the negative test fail ('promise rejected' vs resolved undefined). * docs(mongodb): expand README — prerequisites, verify-it-works snippet, tuning, contributing, sibling parity Addresses /judge-readme punch list: - Add npm/CI/license/Payload badges and TOC - Add Prerequisites section (Atlas / self-hosted / driver / Payload / Node) - Quick Start now wires plugin + collections and includes a "Verify it works" curl block (ingest + search) - Soften "GA on Atlas" claim — status callout names mongodb-atlas-local CI; surfaced again under Limitations - Reconcile post-filter info: mechanism canonical in "WHERE clause behavior", Limitations links to it - Add "How it works", "Who is this for?", and Tuning numCandidates/forceExact sections - Add Multiple Knowledge Pools worked example - Add Contributing section with test setup/teardown commands and src layout map - Link CHANGELOG.md * docs: surface mongodb adapter in root README and adapters README - Available Adapters table: add mongodb row - Installation snippet: add mongodb pnpm add line - Quick Start adapter docs links: add mongodb pointer - Migrations: note mongodb auto-ensures the $vectorSearch index - Adapter Configuration: link mongodb API Reference - Adapter parity callout: describe mongodb pre/post filter split - Roadmap: move mongodb from "Help wanted" to "Already shipped" - adapters/README.md Available Adapters: add mongodb row
1 parent d4525d1 commit 39076db

38 files changed

Lines changed: 7438 additions & 20 deletions

.changeset/add-mongodb-adapter.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"payloadcms-vectorize": minor
3+
"@payloadcms-vectorize/pg": minor
4+
"@payloadcms-vectorize/cf": minor
5+
"@payloadcms-vectorize/mongodb": minor
6+
---
7+
8+
Add `@payloadcms-vectorize/mongodb` adapter (Atlas + self-hosted Community 8.2+) backed by `$vectorSearch`, with pre/post filter splitting and full WHERE-clause parity across operators (equals, not_equals, in, notIn, like, contains, gt/gte/lt/lte, exists, and/or). Search indexes are auto-ensured on first use.

.changeset/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
],
77
"commit": false,
88
"fixed": [
9-
["payloadcms-vectorize", "@payloadcms-vectorize/pg", "@payloadcms-vectorize/cf"]
9+
["payloadcms-vectorize", "@payloadcms-vectorize/pg", "@payloadcms-vectorize/cf", "@payloadcms-vectorize/mongodb"]
1010
],
1111
"access": "public",
1212
"baseBranch": "main",

.github/workflows/ci.yml

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,42 @@ jobs:
157157
PAYLOAD_SECRET: test-secret-key
158158
TEST_ENV: 1
159159

160+
test_adapters_mongodb:
161+
runs-on: ubuntu-latest
162+
163+
services:
164+
mongodb:
165+
image: mongodb/mongodb-atlas-local:latest
166+
ports:
167+
- 27018:27017
168+
options: >-
169+
--health-cmd "mongosh --quiet --eval 'db.runCommand({ping:1})'"
170+
--health-interval 5s
171+
--health-timeout 10s
172+
--health-retries 30
173+
174+
steps:
175+
- uses: actions/checkout@v4
176+
177+
- name: Install pnpm
178+
uses: pnpm/action-setup@v4
179+
180+
- name: Setup Node.js
181+
uses: actions/setup-node@v4
182+
with:
183+
node-version: '20'
184+
cache: 'pnpm'
185+
186+
- name: Install dependencies
187+
run: pnpm install
188+
189+
- name: Run mongodb adapter tests
190+
run: pnpm test:adapters:mongodb
191+
env:
192+
PAYLOAD_SECRET: test-secret-key
193+
MONGODB_URI: mongodb://localhost:27018/?directConnection=true
194+
TEST_ENV: 1
195+
160196
test_e2e:
161197
runs-on: ubuntu-latest
162198

@@ -231,10 +267,11 @@ jobs:
231267
test -f dist/index.d.ts
232268
test -f adapters/pg/dist/index.d.ts
233269
test -f adapters/cf/dist/index.d.ts
270+
test -f adapters/mongodb/dist/index.d.ts
234271
235272
test:
236273
runs-on: ubuntu-latest
237-
needs: [typecheck, build, test_int, test_adapters_pg, test_adapters_cf, test_e2e]
274+
needs: [typecheck, build, test_int, test_adapters_pg, test_adapters_cf, test_adapters_mongodb, test_e2e]
238275
if: always()
239276
steps:
240277
- name: Check required jobs
@@ -244,6 +281,7 @@ jobs:
244281
[ "${{ needs.test_int.result }}" != "success" ] || \
245282
[ "${{ needs.test_adapters_pg.result }}" != "success" ] || \
246283
[ "${{ needs.test_adapters_cf.result }}" != "success" ] || \
284+
[ "${{ needs.test_adapters_mongodb.result }}" != "success" ] || \
247285
[ "${{ needs.test_e2e.result }}" != "success" ]; then
248286
echo "One or more required jobs failed"
249287
exit 1

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ node_modules/
2222
/dist
2323
/adapters/pg/dist
2424
/adapters/cf/dist
25+
/adapters/mongodb/dist
2526

2627
# misc
2728
.DS_Store
@@ -55,4 +56,7 @@ yarn-error.log*
5556
*/secret
5657

5758
# Cursor
58-
.cursor/
59+
.cursor/
60+
61+
# Worktrees
62+
.worktrees/

README.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,11 @@ A Payload CMS plugin that adds vector search capabilities to your collections. P
5252

5353
This plugin requires a database adapter for vector storage. Available adapters:
5454

55-
| Adapter | Package | Database | Documentation |
56-
| -------------------- | -------------------------- | --------------------------- | --------------------------------- |
57-
| PostgreSQL | `@payloadcms-vectorize/pg` | PostgreSQL with pgvector | [README](./adapters/pg/README.md) |
58-
| Cloudflare Vectorize | `@payloadcms-vectorize/cf` | Cloudflare Vectorize index | [README](./adapters/cf/README.md) |
55+
| Adapter | Package | Database | Documentation |
56+
| -------------------- | ------------------------------- | --------------------------------------- | -------------------------------------- |
57+
| PostgreSQL | `@payloadcms-vectorize/pg` | PostgreSQL with pgvector | [README](./adapters/pg/README.md) |
58+
| Cloudflare Vectorize | `@payloadcms-vectorize/cf` | Cloudflare Vectorize index | [README](./adapters/cf/README.md) |
59+
| MongoDB | `@payloadcms-vectorize/mongodb` | MongoDB Atlas + self-hosted 8.2+ | [README](./adapters/mongodb/README.md) |
5960

6061
See [adapters/README.md](./adapters/README.md) for information on creating custom adapters.
6162

@@ -72,8 +73,9 @@ See [adapters/README.md](./adapters/README.md) for information on creating custo
7273
pnpm add payloadcms-vectorize
7374

7475
# Install a database adapter (one of the following)
75-
pnpm add @payloadcms-vectorize/pg # PostgreSQL + pgvector
76-
pnpm add @payloadcms-vectorize/cf # Cloudflare Vectorize
76+
pnpm add @payloadcms-vectorize/pg # PostgreSQL + pgvector
77+
pnpm add @payloadcms-vectorize/cf # Cloudflare Vectorize
78+
pnpm add @payloadcms-vectorize/mongodb # MongoDB Atlas + self-hosted 8.2+
7779
```
7880

7981
## Quick Start
@@ -84,6 +86,7 @@ First, configure your database adapter. See the adapter-specific documentation:
8486

8587
- **PostgreSQL**: [@payloadcms-vectorize/pg README](./adapters/pg/README.md) — pgvector setup, schema initialization, and migrations.
8688
- **Cloudflare Vectorize**: [@payloadcms-vectorize/cf README](./adapters/cf/README.md) — index creation, bindings, and known limitations.
89+
- **MongoDB**: [@payloadcms-vectorize/mongodb README](./adapters/mongodb/README.md) — Atlas / self-hosted 8.2+, `filterableFields`, and the `$vectorSearch` index lifecycle.
8790

8891
### 2. Configure the Plugin
8992

@@ -222,6 +225,7 @@ Migration steps depend on your database adapter:
222225
223226
- **PostgreSQL**: [@payloadcms-vectorize/pg README → Migrations](./adapters/pg/README.md#migrations)
224227
- **Cloudflare Vectorize**: index creation is a one-time setup step — see [@payloadcms-vectorize/cf README](./adapters/cf/README.md#1-create-vectorize-index).
228+
- **MongoDB**: no manual migration — the `$vectorSearch` index is auto-ensured on first write. See [@payloadcms-vectorize/mongodb README → Index lifecycle](./adapters/mongodb/README.md#index-lifecycle).
225229
226230
### 4. Search Your Content
227231
@@ -298,6 +302,7 @@ Each adapter has its own configuration shape — this is where index parameters,
298302
299303
- **PostgreSQL** (`dims`, `ivfflatLists`, schema initialization): [@payloadcms-vectorize/pg → Static Configuration](./adapters/pg/README.md#static-configuration)
300304
- **Cloudflare Vectorize** (`dims`, Vectorize binding): [@payloadcms-vectorize/cf → Configuration](./adapters/cf/README.md#configuration)
305+
- **MongoDB** (`uri`, `dbName`, per-pool `dimensions` / `similarity` / `filterableFields` / `numCandidates` / `forceExact`): [@payloadcms-vectorize/mongodb → API Reference](./adapters/mongodb/README.md#api-reference)
301306
302307
The embeddings collection name in Payload will be the same as the knowledge pool name.
303308
@@ -374,7 +379,7 @@ You can filter on:
374379
375380
References to fields that don't exist on the embeddings table are silently dropped (the rest of the clause still applies).
376381
377-
> **Adapter parity.** All operators are implemented in `@payloadcms-vectorize/pg`. The Cloudflare Vectorize adapter has narrower native filtering — see [@payloadcms-vectorize/cf → Known Limitations](./adapters/cf/README.md#metadata-filtering) for what is and isn't supported there.
382+
> **Adapter parity.** All operators are implemented in `@payloadcms-vectorize/pg`. The Cloudflare Vectorize adapter has narrower native filtering — see [@payloadcms-vectorize/cf → Known Limitations](./adapters/cf/README.md#metadata-filtering) for what is and isn't supported there. The MongoDB adapter splits the clause into a native `$vectorSearch` pre-filter and a JS post-filter — `like`/`contains`/`all` and any mixed-pre/post `or` are post-filtered, so they may return fewer than `limit` rows. See [@payloadcms-vectorize/mongodb → WHERE clause behavior](./adapters/mongodb/README.md#where-clause-behavior).
378383
379384
## Chunkers
380385
@@ -1006,15 +1011,15 @@ Common scripts:
10061011
**Already shipped:**
10071012
10081013
- **Multiple Knowledge Pools** — independent configurations and embedding functions per pool.
1009-
- **Database Adapter Architecture** — pluggable backends (PostgreSQL, Cloudflare Vectorize today).
1014+
- **Database Adapter Architecture** — pluggable backends (PostgreSQL, Cloudflare Vectorize, MongoDB today).
10101015
- **More expressive queries** — configurable limits, per-collection scoping, and full Payload-style metadata filtering (see [Metadata Filtering](#metadata-filtering-where)).
10111016
- **Bulk Embed All** — admin button, provider callbacks, and run/batch tracking.
10121017
- **Serverless-friendly job model** — bulk runs are split into small, requeueable units (`prepare-bulk-embedding` and `poll-or-complete-single-batch`) so individual jobs stay well under typical serverless time limits. The `batchLimit` option (see [CollectionVectorizeOption](#collectionvectorizeoption)) lets you cap docs-per-job to fit your platform. Tested locally and on Node-style hosts; deeper Vercel-specific integration testing is on the help-wanted list.
10131018
- **Cloudflare Vectorize adapter** — `@payloadcms-vectorize/cf`.
1019+
- **MongoDB adapter** — `@payloadcms-vectorize/mongodb` (Atlas + self-hosted Community 8.2+ via `$vectorSearch`).
10141020
10151021
**Help wanted** (priority is driven by community demand — open or 👍 an issue to push something up):
10161022
1017-
- **MongoDB adapter** — `@payloadcms-vectorize/mongodb` for MongoDB Atlas Vector Search.
10181023
- **Additional adapters** — Pinecone, Qdrant, SQLite, etc. See [adapters/README.md](./adapters/README.md) for the `DbAdapter` contract.
10191024
- **Vercel CI matrix** — exercising the serverless job model end-to-end on Vercel preview deployments.
10201025

adapters/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,11 @@
3737

3838
## Available Adapters
3939

40-
| Adapter | Package | Database | Version | Status |
41-
| -------------------- | --------------------------------------------- | -------------------------- | -------- | ----------- |
42-
| PostgreSQL | [`@payloadcms-vectorize/pg`](./pg/README.md) | PostgreSQL with `pgvector` | `0.7.2` | Stable |
43-
| Cloudflare Vectorize | [`@payloadcms-vectorize/cf`](./cf/README.md) | Cloudflare Vectorize index | `0.7.2` | Beta |
40+
| Adapter | Package | Database | Version | Status |
41+
| -------------------- | ------------------------------------------------------------- | --------------------------------- | -------- | ----------- |
42+
| PostgreSQL | [`@payloadcms-vectorize/pg`](./pg/README.md) | PostgreSQL with `pgvector` | `0.7.2` | Stable |
43+
| Cloudflare Vectorize | [`@payloadcms-vectorize/cf`](./cf/README.md) | Cloudflare Vectorize index | `0.7.2` | Beta |
44+
| MongoDB | [`@payloadcms-vectorize/mongodb`](./mongodb/README.md) | MongoDB Atlas + self-hosted 8.2+ | `0.7.2` | Beta |
4445

4546
## Architecture
4647

0 commit comments

Comments
 (0)