mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
12 KiB
12 KiB
Phase 02 — MongoKVStore Implementation
Context Links
- Schema report §2 (KV doc shape), §6 (reference impl)
- Driver report §"Memoization Pattern"
- Code-reviewer findings #6, #7, #16, #17
- Debugger GAP-C, QW-3
src/db/kv-store-interface.js— full contractsrc/db/cf-kv-store.js— behavioral parity target (108 LOC)src/db/create-store.js:40-78— namespace-prefixing wrapper to mirrorsrc/bot.jsgetBot()— memoization pattern to follow
Overview
- Priority: P0
- Status: pending
- Description: Implement
MongoKVStore(KVStore interface) +mongo-client.jsshared connection helper. No factory wiring yet — that lands in Phase 04.
Key Insights
- Store value as string (matches
putJSONserialization). No double-parse risk; preserves null/array/nested fidelity. - Per-module collections (12 KV modules → 12 collections), name == module name with
-→_(e.g.loldle-emoji→loldle_emoji). (Reviewer #3 recommended single shared collection; user opted to keep per-module.) - TTL index
{ expiresAt: 1 }withexpireAfterSeconds: 0,sparse: true. Sweeper runs every 60s — stale-read window vs CFKVStore documented + filtered at read time (code-reviewer #7). - Cursor pagination via sorted
_id, NOTskip(). Encode last_idas base64. - Memoize
MongoClientat module scope. Do NOT awaitconnect()lazily inside every method — first caller awaits, others race the same promise. On reject, null BOTHclientandconnectPromise(code-reviewer #16) so the next request retries cleanly instead of reusing a dead client. list()prefix-strip behavior (code-reviewer #6): MongoKVStore returns keys WITH prefix preserved (mirrors CFKVStore). The wrapper increate-store.js:65strips. Unambiguous.MongoServerSelectionErrorcaught ingetDb()returning a 503-with-Retry-After path (debugger GAP-C / QW-3) — handles paused-M0 wake without a 5s hang propagating to user.
Requirements
Functional
MongoKVStoreimplements every method inkv-store-interface.js:get,put,delete,list,getJSON,putJSON.- Exact behavioral parity with
CFKVStore(with TTL stale-window divergence noted):getreturnsnullon missing key (NOTundefined).getandgetJSONfilter onexpiresAtat read time (per code-reviewer #7):findOne({_id, $or: [{expiresAt: {$exists: false}}, {expiresAt: {$gt: new Date()}}]}). Closes the up-to-60s TTL-sweeper stale-read gap.putwithexpirationTtlwritesexpiresAt = now + ttl*1000. Without it, removes any existingexpiresAt.deleteis idempotent (no-op on missing key).listreturns{ keys, cursor, done }withdone=truewhen no more pages. Keys returned WITH prefix preserved (parity with CFKVStore — wrapper strips).getJSONreturnsnullon missing OR malformed JSON; logsconsole.warn. Never throws.putJSONthrows onundefinedor cyclic value.
- TTL index created idempotently on first connect per collection.
getDb(env)catchesMongoServerSelectionErrorand rethrows a tagged error so callers can map to 503 + Retry-After.
Non-functional
- File ≤200 LOC. Split into:
src/db/mongo-client.js— singleton client +getDb(env)(≤80 LOC).src/db/mongo-kv-store.js— class itself (≤200 LOC; if approaching limit, extractmongo-list-cursor.js).
- JSDoc on every export.
- No
process.env; onlyenv.MONGODB_URI.
Architecture
sequenceDiagram
participant H as Module Handler
participant W as create-store.js (Phase 04)
participant K as MongoKVStore
participant C as mongo-client.js
participant A as Atlas
H->>W: createStore("wordle", env)
W->>K: new MongoKVStore(env, "wordle")
H->>K: getJSON("games:42")
K->>C: getDb(env)
alt cold isolate
C->>A: TLS + SCRAM (≈1500ms)
C-->>K: Db
else warm isolate
C-->>K: Db (memoized)
else paused M0
C-->>K: throws tagged MongoServerSelectionError → 503
end
K->>K: ensureIndex once
K->>A: findOne({_id: "wordle:games:42", expiresAt-filter})
A-->>K: {value: "{...}"}
K-->>H: parsed object
Document shape
// collection: wordle (per-module)
{ _id: "wordle:games:42", value: "{\"word\":\"apple\"}", expiresAt: ISODate? }
Prefix: the namespace prefix (wordle:) is preserved inside _id AND in the keys returned by list(). The wrapper in create-store.js:65 strips on the way out (parity with CFKVStore). MongoKVStore does not strip prefixes — it stores and returns keys verbatim. Regression test: 2-level prefix (wordle:games:) round-trips through wrapper → stripped to games:.
Connection memoization (with reject-handling)
// mongo-client.js — sketch (NOT for copy-paste; phase-02 step writes the real version)
let client = null;
let connectPromise = null;
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,
});
// Reject path: null BOTH so next call retries cleanly (code-reviewer #16)
connectPromise = client.connect().catch((err) => {
client = null;
connectPromise = null;
throw err;
});
}
try {
await connectPromise;
} catch (err) {
// M0 may be auto-paused; surface actionable log (debugger QW-3)
if (err?.name === "MongoServerSelectionError") {
console.warn(JSON.stringify({ event: "mongo_server_selection_failed", note: "M0 may be paused; caller should map to 503" }));
}
throw err;
}
return client.db("miti99bot");
}
Related Code Files
CREATE
/config/workspace/tiennm99/miti99bot/src/db/mongo-client.js/config/workspace/tiennm99/miti99bot/src/db/mongo-kv-store.js/config/workspace/tiennm99/miti99bot/tests/fakes/fake-mongo.js— surface re-derived from phase-03 + phase-02 (code-reviewer #17):findOne,updateOne(upsert +$set+$unset),deleteOne,find()returning chainable.sort().skip().limit().project().toArray(),insertOne,insertMany,distinct,deleteMany,countDocuments,createIndex(no-op)./config/workspace/tiennm99/miti99bot/tests/db/mongo-kv-store.test.js
MODIFY
- (none in this phase — wiring deferred to Phase 04)
DELETE
- (none)
Implementation Steps
- Create
tests/fakes/fake-mongo.jsfirst — defines the surface area MongoKVStore + MongoTradesStore (phase-03) must use. Methods (re-derived per code-reviewer #17):collection(name)returns object withfindOne,updateOne(with upsert +$set+$unset),deleteOne,find(query)returning chainable.sort().skip().limit().project().toArray(),insertOne,insertMany,distinct,deleteMany,createIndex(no-op),countDocuments. Backed byMap<collectionName, Map<_id, doc>>. TTL is NOT simulated (TTL is server-side; tests checkexpiresAtfield only — and the read-timeexpiresAtfilter is exercised againstDate.now()). - Create
src/db/mongo-client.js:getDb(env)— module-scope memoized client + connect promise. Onclient.connect()reject, null both (code-reviewer #16).- Catch + log
MongoServerSelectionErrorwith actionable message (debugger QW-3). closeMongo()— for tests/teardown only.- JSDoc on both.
- Create
src/db/mongo-kv-store.js:- Constructor
(env, collectionName)— defer connect. _ensureIndex()— runs once per collection per isolate (use aSet<string>at module scope).- Methods mirror
cf-kv-store.jsline-for-line (same null semantics, same warn-on-corrupt-JSON), except:getandgetJSONfilter onexpiresAtat read time. list()usesescapeRegex+sort({_id:1})+limit(N+1)+ base64 cursor of last_id. Returns keys WITH prefix.
- Constructor
- Write
tests/db/mongo-kv-store.test.js:- Inject
fake-mongovia dependency injection (constructor takes optionaldbOverridefor tests). - Cover: get-missing → null, put → get round trip, putJSON → getJSON round trip, getJSON of corrupt → null + warn, put with TTL writes
expiresAt, put without TTL clearsexpiresAt, delete idempotent, list with prefix returns keys WITH prefix preserved, 2-level prefix regression (wordle:games:), list with cursor, listdoneflag. - TTL stale-read regression (code-reviewer #7): put with
expirationTtl: 1second, advance time 2s (mockDate.now()or use a tiny real sleep), assertgetreturns null even before TTL sweeper would run. - Connection-reject retry regression (code-reviewer #16): mock first
connect()to reject; assert secondgetDb()call retries (does not reuse dead client). - Cover edge:
putJSON(undefined)throws,putJSON(circular)throws.
- Inject
npm test -- mongo-kv-store→ passes.npm run lint→ passes.
Todo List
tests/fakes/fake-mongo.jscreated with full surface (re-derived from phase-02 + phase-03)src/db/mongo-client.jscreated (≤80 LOC, JSDoc, reject-resets-state, MongoServerSelectionError logged)src/db/mongo-kv-store.jscreated (≤200 LOC, JSDoc)tests/db/mongo-kv-store.test.jscreated- All KVStore methods covered with parity tests vs CFKVStore semantics
expiresAtread-time filter tested (TTL stale-read regression)- Connect-reject retry regression tested
- 2-level prefix list regression tested
list()cursor pagination tested with > 1 page; keys returned WITH prefixgetJSONcorrupt-data path returns null without throwingnpm testpassesnpm run lintpasses- No file > 200 LOC
Success Criteria
- All tests in
mongo-kv-store.test.jspass. - Behavioral diff vs
cf-kv-store.jsis zero for the 6 KVStore methods (verified by symmetric test cases) except the documented TTL stale-read divergence (which the read-time filter eliminates). mongo-client.jsconnect-promise is awaited exactly once per isolate under concurrent first calls; on reject, bothclientandconnectPromiseare nulled.
Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
JSON.parse throws despite warn-and-null contract |
L | M | Wrap in try/catch identical to cf-kv-store.js:85-91. |
| TTL stale-read window divergence vs CFKVStore | M | L | Read-time expiresAt filter (code-reviewer #7); tested via 1s-TTL + 2s-sleep regression. Functional & risk sections both call out this divergence explicitly. |
Memoized client lingers across hot-reloads in wrangler dev |
M | L | closeMongo() exposed for test teardown; in prod, isolate teardown handles it. |
Concurrent _ensureIndex calls race |
L | L | Idempotent on Mongo side. Track per-collection in module-scope Set to skip extra round-trips. |
Driver throws on absent expiresAt field with sparse index |
L | M | Verified by report §2 (sparse:true). Test covers no-TTL case. |
Dead-client reuse after connect() rejection |
M | H | Reject handler nulls both; regression tested (code-reviewer #16). |
| Paused M0 cluster causes hang | M | H | serverSelectionTimeoutMS: 5000 + caught + logged + caller maps to 503 (debugger GAP-C). |
list() cursor encodes _id containing colon → base64 fine |
L | L | Already opaque per interface contract; no consumer parses cursor. |
Security Considerations
- Connection only via
env.MONGODB_URI— never accept URI from request input. MongoClientconstructor must NOT log URI on failure. Wrap in try/catch with redacted message.- Reads/writes never echo full document into logs (PII risk: trading user_id).
- Test fakes never make network calls.
Rollback (this phase only)
- Delete created files.
npm uninstall mongodb(if not needed by Phase 03 yet — but it will be).- No runtime impact: nothing in Phase 02 is wired into the request path yet.
Next Steps
- Blocks: Phase 04 (dual-write wraps this).
- Unblocks: Phase 03 can proceed in parallel (independent file).