mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-12 08:18:53 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user