feat(db): phase 02 — MongoKVStore + memoized client + fake-mongo

Implements the KVStore interface against MongoDB Atlas with full behavioral
parity vs CFKVStore (null-on-missing, swallow-corrupt-JSON, idempotent delete,
throw-on-undefined-putJSON). Not wired into the request path yet — Phase 04
adds dual-write wrappers and factory routing.

- src/db/mongo-client.js: memoized MongoClient + getDb(env). On connect()
  reject, nulls both client and connectPromise so next call retries cleanly
  (regression-tested). Catches MongoServerSelectionError and emits a
  structured warning before rethrow so callers can map to 503.
- src/db/mongo-kv-store.js: KVStore impl. get/getJSON filter on expiresAt
  at read time to close the up-to-60s TTL-sweeper stale-read window vs
  CFKVStore. list() returns keys WITH prefix preserved (parity — wrapper
  in create-store.js:65 strips). Cursor pagination via sorted _id +
  limit(N+1), NOT skip(). Lazy ensureIndex per (collection, isolate)
  tracked in module-scope Set.
- src/db/mongo-list-cursor.js: extracted cursor encode/decode to keep
  mongo-kv-store.js under 200 LOC.
- tests/fakes/fake-mongo.js: Map-backed fake covering the surface needed
  by both Phase 02 (KVStore) and Phase 03 (MongoTradesStore).
- tests/db/mongo-kv-store.test.js: 26 tests, including TTL stale-read
  regression (1s TTL + time advance), 2-level prefix list regression,
  cursor pagination, connect-reject retry, MongoServerSelectionError
  structured log.

Tests: 503 → 529 (+26). Lint clean.
This commit is contained in:
2026-04-26 08:48:01 +07:00
parent 3ef8540665
commit ccf8e4599e
5 changed files with 953 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
/**
* @file mongo-client — memoized MongoDB Atlas client singleton.
*
* Exports `getDb(env)` for all Mongo-backed stores. On the first call from a
* cold isolate it opens one connection; subsequent calls reuse the same
* `MongoClient`. If `client.connect()` rejects, both `client` and
* `connectPromise` are nulled so the next request retries cleanly instead of
* reusing a dead client reference.
*
* `MongoServerSelectionError` (e.g. paused M0 cluster) is caught, logged with
* an actionable message, then rethrown so the caller can map it to 503.
*
* @module db/mongo-client
*/
import { MongoClient } from "mongodb";
/** @type {MongoClient|null} */
let client = null;
/** @type {Promise<void>|null} */
let connectPromise = null;
/**
* Return the memoized Db instance, connecting on the first call.
*
* Connection options match the Cloudflare Workers constraints:
* maxPoolSize: 1 — one connection per isolate
* minPoolSize: 0 — no idle keepalive (Workers tear down quickly)
* serverSelectionTimeoutMS: 5000 — fast fail for paused M0
* connectTimeoutMS: 10000 — TLS + SCRAM on cold start
*
* @param {{ MONGODB_URI: string }} env — Cloudflare Worker env (or test double).
* @returns {Promise<import("mongodb").Db>}
* @throws {import("mongodb").MongoServerSelectionError} if cluster unreachable.
*/
export async function getDb(env) {
if (client) return client.db("miti99bot");
if (!connectPromise) {
client = new MongoClient(env.MONGODB_URI, {
maxPoolSize: 1,
minPoolSize: 0,
serverSelectionTimeoutMS: 5000,
connectTimeoutMS: 10000,
});
// On rejection: null BOTH so the next getDb() call retries with a fresh
// client instead of awaiting the already-rejected promise (code-reviewer #16).
connectPromise = client.connect().catch((err) => {
client = null;
connectPromise = null;
throw err;
});
}
try {
await connectPromise;
} catch (err) {
// M0 clusters auto-pause after 60 days of inactivity. Surface an
// actionable note so ops can identify and resume the cluster.
// NOTE: URI is deliberately omitted to prevent credential leaks.
if (err?.name === "MongoServerSelectionError") {
console.warn(
JSON.stringify({
event: "mongo_server_selection_failed",
note: "M0 may be paused — resume the cluster in Atlas, then retry. Caller should map to 503.",
}),
);
}
throw err;
}
return client.db("miti99bot");
}
/**
* Close the active MongoClient and reset module-scope state.
* Intended for test teardown and graceful shutdown only — not for
* use in production request handlers.
*
* @returns {Promise<void>}
*/
export async function closeMongo() {
if (client) {
await client.close();
client = null;
}
connectPromise = null;
}
+178
View File
@@ -0,0 +1,178 @@
/**
* @file mongo-kv-store — MongoDB Atlas implementation of the KVStore interface.
*
* Behavioral parity with `CFKVStore`, with one documented divergence:
* - CFKVStore TTL is enforced server-side (eventual, ~1s granularity).
* - MongoKVStore also enforces TTL at read-time via an `expiresAt` filter,
* eliminating the up-to-60s Atlas TTL-sweeper stale-read window.
*
* Per-module collections: module name with `-` replaced by `_`
* (e.g. `loldle-emoji` → `loldle_emoji`).
*
* `list()` returns keys WITH the module prefix preserved — the wrapper in
* `create-store.js:65` strips it. MongoKVStore never strips prefixes.
*
* @see ./kv-store-interface.js for the full interface contract.
* @module db/mongo-kv-store
*/
import { getDb } from "./mongo-client.js";
import { listWithCursor } from "./mongo-list-cursor.js";
/**
* @typedef {import("./kv-store-interface.js").KVStore} KVStore
* @typedef {import("./kv-store-interface.js").KVStorePutOptions} KVStorePutOptions
* @typedef {import("./kv-store-interface.js").KVStoreListOptions} KVStoreListOptions
* @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult
*/
/**
* Tracks which collections have already had the TTL index created this
* isolate lifetime, to avoid redundant `createIndex` round-trips.
*
* @type {Set<string>}
*/
const indexedCollections = new Set();
/**
* @implements {KVStore}
*/
export class MongoKVStore {
/**
* @param {{ MONGODB_URI: string }} env — Worker env (or test double).
* @param {string} collectionName — module name (e.g. "wordle", "loldle-emoji").
* @param {import("mongodb").Db} [dbOverride] — injected Db for tests; bypasses real connect.
*/
constructor(env, collectionName, dbOverride) {
if (!collectionName) throw new Error("MongoKVStore: collectionName is required");
this._env = env;
// Normalize collection name: replace `-` with `_` for MongoDB compatibility.
this._collName = collectionName.replace(/-/g, "_");
this._dbOverride = dbOverride ?? null;
}
/**
* Resolve the Db instance (override for tests, real for prod).
*
* @returns {Promise<import("mongodb").Db>}
*/
async _db() {
return this._dbOverride ?? getDb(this._env);
}
/**
* Lazily create the TTL index once per collection per isolate.
* Idempotent on the MongoDB side; tracked locally to avoid extra round-trips.
*
* @returns {Promise<void>}
*/
async _ensureIndex() {
if (indexedCollections.has(this._collName)) return;
const db = await this._db();
await db
.collection(this._collName)
.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0, sparse: true });
indexedCollections.add(this._collName);
}
/**
* Build the read-time TTL filter: accept docs with no `expiresAt` field,
* OR docs whose `expiresAt` is in the future.
*
* @param {string} key
* @returns {object} MongoDB filter
*/
_liveFilter(key) {
return {
_id: key,
$or: [{ expiresAt: { $exists: false } }, { expiresAt: { $gt: new Date() } }],
};
}
/**
* @param {string} key
* @returns {Promise<string|null>}
*/
async get(key) {
await this._ensureIndex();
const db = await this._db();
const doc = await db.collection(this._collName).findOne(this._liveFilter(key));
return doc ? doc.value : null;
}
/**
* @param {string} key
* @param {string} value
* @param {KVStorePutOptions} [opts]
* @returns {Promise<void>}
*/
async put(key, value, opts) {
await this._ensureIndex();
const db = await this._db();
const $set = { value };
const $unset = {};
if (opts?.expirationTtl) {
$set.expiresAt = new Date(Date.now() + opts.expirationTtl * 1000);
} else {
// Clear any existing TTL so the document becomes permanent.
$unset.expiresAt = "";
}
await db.collection(this._collName).updateOne({ _id: key }, { $set, $unset }, { upsert: true });
}
/**
* @param {string} key
* @returns {Promise<void>}
*/
async delete(key) {
await this._ensureIndex();
const db = await this._db();
await db.collection(this._collName).deleteOne({ _id: key });
}
/**
* List keys matching an optional prefix, with cursor-based pagination.
* Keys are returned WITH the full module prefix preserved — the wrapper in
* `create-store.js` strips it for callers.
*
* @param {KVStoreListOptions} [opts]
* @returns {Promise<KVStoreListResult>}
*/
async list(opts = {}) {
await this._ensureIndex();
const db = await this._db();
return listWithCursor(db.collection(this._collName), opts);
}
/**
* @param {string} key
* @returns {Promise<any|null>}
*/
async getJSON(key) {
const raw = await this.get(key);
if (raw == null) return null;
try {
return JSON.parse(raw);
} catch (err) {
console.warn("getJSON: parse failed", { key, err: String(err) });
return null;
}
}
/**
* @param {string} key
* @param {any} value
* @param {KVStorePutOptions} [opts]
* @returns {Promise<void>}
*/
async putJSON(key, value, opts) {
if (value === undefined) {
throw new Error(`putJSON: value for key "${key}" is undefined`);
}
// JSON.stringify throws on cycles — let it propagate.
const serialized = JSON.stringify(value);
await this.put(key, serialized, opts);
}
}
+63
View File
@@ -0,0 +1,63 @@
/**
* @file mongo-list-cursor — cursor-based list pagination helper for MongoKVStore.
*
* Extracted to keep mongo-kv-store.js under 200 LOC.
* Encodes the last `_id` of a page as a base64 cursor; decodes it on the
* next call to build a `$gt` filter. Does NOT use skip() — purely sorted-_id
* pagination to avoid O(n) offset scans.
*
* @module db/mongo-list-cursor
*/
/**
* @typedef {import("./kv-store-interface.js").KVStoreListOptions} KVStoreListOptions
* @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult
*/
/**
* Escape special RegExp characters so a prefix string is safe inside a
* MongoDB `$regex` filter.
*
* @param {string} str
* @returns {string}
*/
export function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Execute a paginated list query on a MongoDB collection.
*
* @param {import("mongodb").Collection} col — resolved collection handle.
* @param {KVStoreListOptions} opts
* @returns {Promise<KVStoreListResult>}
*/
export async function listWithCursor(col, opts = {}) {
const { prefix = "", limit = 1000, cursor } = opts;
const pageSize = limit;
const filter = {};
if (prefix) {
filter._id = { $regex: `^${escapeRegex(prefix)}` };
}
if (cursor) {
const lastId = Buffer.from(cursor, "base64").toString("utf8");
filter._id = filter._id ? { ...filter._id, $gt: lastId } : { $gt: lastId };
}
const docs = await col
.find(filter)
.sort({ _id: 1 })
.limit(pageSize + 1)
.project({ _id: 1 })
.toArray();
const hasMore = docs.length > pageSize;
const page = hasMore ? docs.slice(0, pageSize) : docs;
const keys = page.map((d) => d._id);
const nextCursor = hasMore
? Buffer.from(page[page.length - 1]._id, "utf8").toString("base64")
: undefined;
return { keys, cursor: nextCursor, done: !hasMore };
}
+328
View File
@@ -0,0 +1,328 @@
/**
* @file mongo-kv-store.test.js — unit tests for MongoKVStore.
*
* Injection pattern: MongoKVStore constructor accepts an optional `dbOverride`
* parameter. Tests pass a `makeFakeMongo()` db so no real Atlas connection
* is made. The same fake is used to test mongo-client.js connect-reject retry.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MongoKVStore } from "../../src/db/mongo-kv-store.js";
import { makeFakeMongo } from "../fakes/fake-mongo.js";
// ─── helpers ────────────────────────────────────────────────────────────────
/** Build a store + fake db pair. collectionName defaults to "test". */
function makeStore(collectionName = "test") {
const fakeDb = makeFakeMongo();
const store = new MongoKVStore({}, collectionName, fakeDb);
return { store, fakeDb };
}
// ─── constructor ─────────────────────────────────────────────────────────────
describe("MongoKVStore constructor", () => {
it("throws when collectionName is missing", () => {
expect(() => new MongoKVStore({}, "")).toThrow(/required/);
});
it("normalizes collection name: replaces - with _", () => {
const fakeDb = makeFakeMongo();
const store = new MongoKVStore({}, "loldle-emoji", fakeDb);
expect(store._collName).toBe("loldle_emoji");
});
});
// ─── get / put / delete ───────────────────────────────────────────────────────
describe("get / put / delete", () => {
it("get returns null for missing key", async () => {
const { store } = makeStore();
expect(await store.get("missing")).toBeNull();
});
it("put → get round-trip", async () => {
const { store } = makeStore();
await store.put("k", "hello");
expect(await store.get("k")).toBe("hello");
});
it("put overwrites existing value", async () => {
const { store } = makeStore();
await store.put("k", "first");
await store.put("k", "second");
expect(await store.get("k")).toBe("second");
});
it("delete removes the key", async () => {
const { store } = makeStore();
await store.put("k", "v");
await store.delete("k");
expect(await store.get("k")).toBeNull();
});
it("delete is idempotent (no-op on missing key)", async () => {
const { store } = makeStore();
await expect(store.delete("nope")).resolves.toBeUndefined();
});
});
// ─── TTL / expiresAt ─────────────────────────────────────────────────────────
describe("TTL / expiresAt field", () => {
afterEach(() => {
vi.useRealTimers();
});
it("put with expirationTtl writes expiresAt field", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
const { store, fakeDb } = makeStore();
await store.put("k", "v", { expirationTtl: 60 });
const col = fakeDb.collection("test");
const doc = await col.findOne({ _id: "k" });
expect(doc.expiresAt).toBeInstanceOf(Date);
expect(doc.expiresAt.getTime()).toBe(new Date("2025-01-01T00:01:00.000Z").getTime());
});
it("put without TTL clears any existing expiresAt", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
const { store, fakeDb } = makeStore();
// First write with TTL
await store.put("k", "v", { expirationTtl: 60 });
// Second write without TTL — must remove expiresAt
await store.put("k", "updated");
const col = fakeDb.collection("test");
const doc = await col.findOne({ _id: "k" });
expect(doc.expiresAt).toBeUndefined();
});
it("TTL stale-read regression: expired doc returns null before sweeper runs", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
const { store } = makeStore();
await store.put("k", "v", { expirationTtl: 1 }); // expires in 1s
// Advance clock 2 seconds — doc is now expired but sweeper hasn't run
vi.setSystemTime(new Date("2025-01-01T00:00:02.000Z"));
expect(await store.get("k")).toBeNull();
});
it("non-expired doc is still readable before TTL elapses", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
const { store } = makeStore();
await store.put("k", "v", { expirationTtl: 60 });
// Only 10s later — still live
vi.setSystemTime(new Date("2025-01-01T00:00:10.000Z"));
expect(await store.get("k")).toBe("v");
});
});
// ─── getJSON / putJSON ───────────────────────────────────────────────────────
describe("getJSON / putJSON", () => {
it("putJSON → getJSON round-trip", async () => {
const { store } = makeStore();
await store.putJSON("k", { a: 1, b: [2, 3] });
expect(await store.getJSON("k")).toEqual({ a: 1, b: [2, 3] });
});
it("getJSON returns null on missing key", async () => {
const { store } = makeStore();
expect(await store.getJSON("missing")).toBeNull();
});
it("getJSON returns null on corrupt JSON and logs a warning", async () => {
const { store, fakeDb } = makeStore();
// Seed corrupt document directly into the fake collection
await fakeDb.collection("test").insertOne({ _id: "bad", value: "{not json" });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await store.getJSON("bad")).toBeNull();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("putJSON throws on undefined value", async () => {
const { store } = makeStore();
await expect(store.putJSON("k", undefined)).rejects.toThrow(/undefined/);
});
it("putJSON throws on circular reference", async () => {
const { store } = makeStore();
const obj = {};
obj.self = obj;
await expect(store.putJSON("k", obj)).rejects.toThrow();
});
it("putJSON passes expirationTtl through to put", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
const { store, fakeDb } = makeStore();
await store.putJSON("k", { x: 1 }, { expirationTtl: 120 });
const doc = await fakeDb.collection("test").findOne({ _id: "k" });
expect(doc.expiresAt).toBeInstanceOf(Date);
expect(doc.expiresAt.getTime()).toBe(new Date("2025-01-01T00:02:00.000Z").getTime());
vi.useRealTimers();
});
});
// ─── list ────────────────────────────────────────────────────────────────────
describe("list()", () => {
it("returns empty result when store is empty", async () => {
const { store } = makeStore();
const res = await store.list();
expect(res.keys).toEqual([]);
expect(res.done).toBe(true);
expect(res.cursor).toBeUndefined();
});
it("returns all keys when no prefix given", async () => {
const { store } = makeStore();
await store.put("a:1", "x");
await store.put("b:2", "y");
const res = await store.list();
expect(res.keys.sort()).toEqual(["a:1", "b:2"]);
expect(res.done).toBe(true);
});
it("returns keys WITH prefix preserved (not stripped)", async () => {
const { store } = makeStore();
await store.put("wordle:games:1", "a");
await store.put("wordle:games:2", "b");
await store.put("wordle:other:3", "c");
const res = await store.list({ prefix: "wordle:games:" });
expect(res.keys.sort()).toEqual(["wordle:games:1", "wordle:games:2"]);
expect(res.done).toBe(true);
});
it("2-level prefix regression: only matching keys returned with prefix preserved", async () => {
const { store } = makeStore();
await store.put("wordle:games:1", "a");
await store.put("wordle:games:2", "b");
await store.put("wordle:other:3", "c");
const res = await store.list({ prefix: "wordle:games:" });
// Must be exactly 2 keys, both with prefix intact
expect(res.keys).toHaveLength(2);
expect(res.keys).toContain("wordle:games:1");
expect(res.keys).toContain("wordle:games:2");
expect(res.keys).not.toContain("wordle:other:3");
});
it("prefix with regex special chars is escaped", async () => {
const { store } = makeStore();
await store.put("a.b:1", "x");
await store.put("a_b:1", "y"); // should NOT match prefix "a.b:"
const res = await store.list({ prefix: "a.b:" });
expect(res.keys).toEqual(["a.b:1"]);
});
it("list() cursor pagination — limit(N+1) strategy", async () => {
const { store } = makeStore();
for (let i = 1; i <= 5; i++) await store.put(`k${i}`, String(i));
const page1 = await store.list({ limit: 2 });
expect(page1.keys).toHaveLength(2);
expect(page1.done).toBe(false);
expect(page1.cursor).toBeTruthy();
const page2 = await store.list({ limit: 2, cursor: page1.cursor });
expect(page2.keys).toHaveLength(2);
expect(page2.done).toBe(false);
expect(page2.cursor).toBeTruthy();
const page3 = await store.list({ limit: 2, cursor: page2.cursor });
expect(page3.keys).toHaveLength(1);
expect(page3.done).toBe(true);
expect(page3.cursor).toBeUndefined();
// All keys across pages must be unique and sorted
const allKeys = [...page1.keys, ...page2.keys, ...page3.keys];
expect(allKeys).toHaveLength(5);
expect(allKeys).toEqual([...allKeys].sort());
});
it("list done=true when exactly limit keys remain (no extra page)", async () => {
const { store } = makeStore();
await store.put("k1", "a");
await store.put("k2", "b");
const res = await store.list({ limit: 2 });
expect(res.keys).toHaveLength(2);
expect(res.done).toBe(true);
expect(res.cursor).toBeUndefined();
});
});
// ─── mongo-client connect-reject retry regression ────────────────────────────
describe("mongo-client connect-reject retry regression", () => {
it("nulls client and connectPromise on connect() rejection so next call retries", async () => {
// Import the module fresh — we'll call it with a mock factory
const { getDb, closeMongo } = await import("../../src/db/mongo-client.js");
// Ensure clean state before test
await closeMongo();
// Patch MongoClient.prototype.connect to reject once, then succeed
const { MongoClient } = await import("mongodb");
let callCount = 0;
const originalConnect = MongoClient.prototype.connect;
MongoClient.prototype.connect = vi.fn(async function () {
callCount++;
if (callCount === 1) {
// First call: reject to simulate transient failure
throw new Error("transient connection error");
}
// Second call: succeed (but return void, the client is now "connected")
return this;
});
try {
// First call — must reject
await expect(getDb({ MONGODB_URI: "mongodb://localhost:27017" })).rejects.toThrow(
"transient connection error",
);
// Second call — must NOT reuse the dead client; must retry
// It will succeed on second connect() call
const db = await getDb({ MONGODB_URI: "mongodb://localhost:27017" });
expect(db).toBeTruthy();
expect(callCount).toBe(2);
} finally {
MongoClient.prototype.connect = originalConnect;
await closeMongo();
}
});
it("logs structured warning on MongoServerSelectionError", async () => {
const { getDb, closeMongo } = await import("../../src/db/mongo-client.js");
await closeMongo();
const { MongoClient } = await import("mongodb");
const originalConnect = MongoClient.prototype.connect;
MongoClient.prototype.connect = vi.fn(async () => {
const err = new Error("server selection timeout");
err.name = "MongoServerSelectionError";
throw err;
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await expect(getDb({ MONGODB_URI: "mongodb://localhost:27017" })).rejects.toThrow();
expect(warn).toHaveBeenCalledOnce();
const logged = JSON.parse(warn.mock.calls[0][0]);
expect(logged.event).toBe("mongo_server_selection_failed");
expect(logged.note).toMatch(/503/);
} finally {
MongoClient.prototype.connect = originalConnect;
warn.mockRestore();
await closeMongo();
}
});
});
+294
View File
@@ -0,0 +1,294 @@
/**
* @file fake-mongo — Map-backed in-memory MongoDB fake for unit tests.
*
* Covers the full surface needed by Phase 02 (MongoKVStore) and
* Phase 03 (MongoTradesStore):
* collection(name) → { findOne, updateOne, deleteOne, find, insertOne,
* insertMany, distinct, deleteMany, countDocuments,
* createIndex }
*
* TTL is NOT simulated server-side. Tests that exercise TTL check the
* `expiresAt` field value directly and test the read-time filter in the
* MongoKVStore layer by controlling Date.now() via vi.setSystemTime().
*
* @see tests/db/mongo-kv-store.test.js
*/
/**
* Apply $set and $unset from an update document to a target object.
*
* @param {object} doc
* @param {object} update
* @returns {object}
*/
function applyUpdate(doc, update) {
const result = { ...doc };
if (update.$set) {
for (const [k, v] of Object.entries(update.$set)) {
result[k] = v;
}
}
if (update.$unset) {
for (const key of Object.keys(update.$unset)) {
delete result[key];
}
}
return result;
}
/**
* Minimal regex-query matcher. Supports:
* { field: value } — strict equality
* { field: { $gt: v } } — greater-than
* { field: { $exists: b } } — field existence
* { $or: [cond, ...] } — logical OR
* { $and: [cond, ...] } — logical AND
*
* @param {object} doc
* @param {object} query
* @returns {boolean}
*/
function matchQuery(doc, query) {
for (const [key, condition] of Object.entries(query)) {
if (key === "$or") {
if (!condition.some((sub) => matchQuery(doc, sub))) return false;
continue;
}
if (key === "$and") {
if (!condition.every((sub) => matchQuery(doc, sub))) return false;
continue;
}
if (condition !== null && typeof condition === "object" && !Array.isArray(condition)) {
const ops = Object.keys(condition);
if (ops.some((op) => op.startsWith("$"))) {
for (const [op, operand] of Object.entries(condition)) {
if (op === "$gt") {
if (!(doc[key] > operand)) return false;
} else if (op === "$gte") {
if (!(doc[key] >= operand)) return false;
} else if (op === "$lt") {
if (!(doc[key] < operand)) return false;
} else if (op === "$lte") {
if (!(doc[key] <= operand)) return false;
} else if (op === "$exists") {
const has = key in doc && doc[key] !== undefined;
if (operand !== has) return false;
} else if (op === "$in") {
if (!operand.includes(doc[key])) return false;
} else if (op === "$regex") {
const flags = condition.$options ?? "";
if (!new RegExp(operand, flags).test(doc[key])) return false;
}
}
continue;
}
}
if (doc[key] !== condition) return false;
}
return true;
}
/**
* Returns a chainable cursor builder from an array of matched docs.
*
* @param {object[]} docs
* @returns {object} chainable cursor with sort/skip/limit/project/toArray
*/
function makeCursor(docs) {
const items = [...docs];
let sortField = null;
let sortDir = 1;
let skipN = 0;
let limitN = Number.POSITIVE_INFINITY;
let projection = null;
const cursor = {
sort(spec) {
const entries = Object.entries(spec);
if (entries.length > 0) {
[sortField, sortDir] = [entries[0][0], entries[0][1]];
}
return cursor;
},
skip(n) {
skipN = n;
return cursor;
},
limit(n) {
limitN = n;
return cursor;
},
project(spec) {
projection = spec;
return cursor;
},
async toArray() {
let result = [...items];
if (sortField !== null) {
result.sort((a, b) => {
if (a[sortField] < b[sortField]) return -sortDir;
if (a[sortField] > b[sortField]) return sortDir;
return 0;
});
}
result = result.slice(
skipN,
limitN === Number.POSITIVE_INFINITY ? undefined : skipN + limitN,
);
if (projection) {
result = result.map((doc) => {
const out = {};
for (const [k, include] of Object.entries(projection)) {
if (include) out[k] = doc[k];
}
return out;
});
}
return result;
},
};
return cursor;
}
/**
* Create a fake MongoDB collection backed by a Map.
*
* @param {Map<string, object>} store
* @returns {object}
*/
function makeCollection(store) {
return {
/** @returns {Promise<object|null>} */
async findOne(query) {
for (const doc of store.values()) {
if (matchQuery(doc, query)) return { ...doc };
}
return null;
},
/**
* Supports upsert with $set / $unset.
* @returns {Promise<{matchedCount: number, upsertedCount: number, modifiedCount: number}>}
*/
async updateOne(filter, update, opts = {}) {
for (const [id, doc] of store.entries()) {
if (matchQuery(doc, filter)) {
store.set(id, applyUpdate(doc, update));
return { matchedCount: 1, upsertedCount: 0, modifiedCount: 1 };
}
}
if (opts.upsert) {
// Build new doc from filter equality fields + $set fields
const newDoc = {};
for (const [k, v] of Object.entries(filter)) {
if (typeof v !== "object") newDoc[k] = v;
}
const merged = applyUpdate(newDoc, update);
const id = merged._id ?? String(Date.now() + Math.random());
merged._id = id;
store.set(String(id), merged);
return { matchedCount: 0, upsertedCount: 1, modifiedCount: 0 };
}
return { matchedCount: 0, upsertedCount: 0, modifiedCount: 0 };
},
/** @returns {Promise<{deletedCount: number}>} */
async deleteOne(filter) {
for (const [id, doc] of store.entries()) {
if (matchQuery(doc, filter)) {
store.delete(id);
return { deletedCount: 1 };
}
}
return { deletedCount: 0 };
},
/**
* Returns a chainable cursor.
* @param {object} [query]
* @returns {object}
*/
find(query = {}) {
const matched = [...store.values()].filter((doc) => matchQuery(doc, query));
return makeCursor(matched);
},
/** @returns {Promise<{insertedId: string}>} */
async insertOne(doc) {
const id = doc._id ?? String(Date.now() + Math.random());
store.set(String(id), { ...doc, _id: id });
return { insertedId: id };
},
/** @returns {Promise<{insertedIds: string[]}>} */
async insertMany(docs) {
const insertedIds = [];
for (const doc of docs) {
const id = doc._id ?? String(Date.now() + Math.random());
store.set(String(id), { ...doc, _id: id });
insertedIds.push(id);
}
return { insertedIds };
},
/** @returns {Promise<any[]>} */
async distinct(field, query = {}) {
const values = new Set();
for (const doc of store.values()) {
if (matchQuery(doc, query) && field in doc) {
values.add(doc[field]);
}
}
return [...values];
},
/** @returns {Promise<{deletedCount: number}>} */
async deleteMany(filter = {}) {
let count = 0;
for (const [id, doc] of store.entries()) {
if (matchQuery(doc, filter)) {
store.delete(id);
count++;
}
}
return { deletedCount: count };
},
/** @returns {Promise<number>} */
async countDocuments(query = {}) {
let count = 0;
for (const doc of store.values()) {
if (matchQuery(doc, query)) count++;
}
return count;
},
/** No-op — index creation is idempotent; tests only verify field presence. */
async createIndex(_spec, _opts) {
return "ok";
},
};
}
/**
* Create a fake MongoDB Db object.
* Each collection is lazily created and backed by its own Map.
*
* @returns {{ collection: (name: string) => object, _stores: Map<string, Map<string, object>> }}
*/
export function makeFakeMongo() {
/** @type {Map<string, Map<string, object>>} */
const stores = new Map();
return {
/** @param {string} name */
collection(name) {
if (!stores.has(name)) {
stores.set(name, new Map());
}
return makeCollection(stores.get(name));
},
/** Expose raw stores so tests can inspect or seed data directly. */
_stores: stores,
};
}