docs(plans): add mongodb atlas migration plan with 3-round review

8-phase migration plan with standby pivot + structured analysis covering 22h budget. Strategy: dual-write wrappers at storage layer with derived cold-start gate, backfill scripts, staged canary → monitoring → cutover. Includes 3 research reports (Atlas fit, schema migration, free-tier validation) and 3 review reports (brainstorm critique, correctness check, failure modes). Reviewer dissent on timeline/complexity captured in plan.md alternatives section.
This commit is contained in:
tiennm99 committed 2026-04-25 21:03:38 +07:00
1 parent 434b0cc35b
commit 96015442c6
16 files changed
+3973

No files matched your search

@@ -0,0 +1,160 @@
# Phase 01 — Atlas Setup + Wrangler Config
## Context Links
- [Atlas fit + driver report](../reports/researcher-260425-1924-mongodb-atlas-fit-and-driver.md) §"MongoDB Driver Specifics"
- [Schema report](../reports/researcher-260425-1924-mongodb-schema-and-migration.md) §1 (M0 ceiling), §4 (region)
- [Debugger failure-modes](../reports/debugger-260425-2034-atlas-plan-failure-modes.md) GAP-A, GAP-C, QW-1..5
- [Code-reviewer findings #5, #14, #19](../reports/code-reviewer-260425-2034-atlas-plan-correctness.md)
- `wrangler.toml:3` — current `compatibility_date = "2025-10-01"` (already satisfies ≥ 2025-03-20)
- `wrangler.toml:14-22` — KV + D1 bindings to retain through cutover
- `package.json:24-30` — current deps; will add only `mongodb`
## Overview
- **Priority:** P0 (gate for everything; bundle-size + CPU-time gates can abort the plan here)
- **Status:** pending
- **Description:** Provision Atlas M0 cluster, wire secrets, enable Node.js compat, run hard gates (bundle size + CPU time + auto-pause behavior). No code merged yet beyond config + secret-leak lint.
## Key Insights
- Atlas M0 only available regions: `aws-ap-southeast-1`, `aws-eu-west-1`, `aws-us-east-1`, `aws-us-west-2`, `aws-ap-southeast-2`. **Pick `aws-ap-southeast-1`** (closest to CF SEA PoPs; user is in VN).
- `nodejs_compat_v2` is the lever; without it `node:net`/`node:tls` are absent and the driver fails at import. v1 vs v2 are alternatives, not additive — switching alters process/Buffer/streams globals.
- M0 auto-pauses after **30 days of zero ops**. Bot has 6+ daily crons → not a real risk if any cron writes Mongo post-cutover. Phase 08 verifies.
- Connection limit: 500. Each cold isolate opens ≥1. Burst risk under deploy stampede; phase-06 tests pre-deploy.
- M0 has **no backups**. Source of truth during dual-write is still KV/D1.
- **Compatibility date** already `2025-10-01` — satisfies `≥ 2025-03-20` requirement (per debugger QW-5). No bump needed; verify only.
- **CF Worker Free plan: 50ms CPU limit**. Mongo TLS+SCRAM CPU cost is unknown; must measure (debugger GAP-A / QW-4).
- **Bundle-size gate** (code-reviewer #5): mongodb v6.7 is reported ~4-5 MB compressed; Free plan limit is 3 MiB. Hard gate before any further phase work.
- **0.0.0.0/0 IP allowlist** is permanent on M0 + Workers (no static egress IP without paid plan); document as risk, not as TODO.
## Requirements
### Functional
- Atlas project + cluster provisioned (region `aws-ap-southeast-1`, name `miti99bot-prod`).
- DB user `miti99bot-worker` with `readWrite` on db `miti99bot`.
- Network access list: **`0.0.0.0/0`** required (CF Workers do not have static IPs). Permanent risk; auth+TLS is the only barrier. Upgrade path: CF Workers paid static egress IP add-on (~$10/mo).
- `MONGODB_URI` set as CF secret AND in `.env.deploy`.
- `wrangler.toml` updated: `compatibility_flags = ["nodejs_compat_v2"]`. `compatibility_date` left at `2025-10-01` (already valid).
- `.env.deploy.example` updated with `MONGODB_URI=` placeholder + comment.
- **Secret-leak lint** (`scripts/check-secret-leaks.js`) introduced **here** (not phase-08 — per brainstormer #10) so any later phase commit cannot leak `MONGODB_URI`.
- **Node-API surface inventory** (per code-reviewer #14): grep `node:` imports + `process.env` + `Buffer.` uses; document each in `docs/using-mongodb.md`.
- **Atlas free-tier email alert** configured (debugger QW-2): cluster unavailability + connections > 400.
### Non-functional
- Connection string never logged (redact in any error path; lint enforces).
- README of repo notes M0 auto-pause behavior.
- One documented rollback procedure (delete cluster, revert wrangler flag, redeploy — KV/D1 still intact).
## Architecture
```
Cloudflare Worker (region: nearest CF PoP)
│
│ TCP/TLS over node:net (nodejs_compat_v2)
│ SCRAM-SHA-256 auth
▼
MongoDB Atlas M0 (aws-ap-southeast-1)
└─ db: miti99bot
├─ (collections created lazily by Phase 02/03)
```
Cold path: ~1500ms wall-clock (TLS + SCRAM + server selection). Worst-case ≈ 6.5s when server-selection timeout fires (5000ms) on a paused cluster (per code-reviewer #23). Warm path (memoized client per isolate): ~50–100ms.
## Related Code Files
### MODIFY
- `/config/workspace/tiennm99/miti99bot/wrangler.toml` — add `compatibility_flags`, keep KV/D1 bindings.
- `/config/workspace/tiennm99/miti99bot/.env.deploy.example` (or create if missing) — add `MONGODB_URI=`.
- `/config/workspace/tiennm99/miti99bot/package.json` — add `mongodb@^6.7.0`; add `lint` chain entry for `check-secret-leaks.js`.
- `/config/workspace/tiennm99/miti99bot/README.md` — add M0 auto-pause note.
### CREATE
- `/config/workspace/tiennm99/miti99bot/docs/using-mongodb.md` — operational runbook (cluster URL, auto-pause behavior, rotation, **node:* surface inventory**, `MongoServerSelectionError` catch path note for phase-02).
- `/config/workspace/tiennm99/miti99bot/scripts/check-secret-leaks.js` — fails build if any source file contains `console.log(env.MONGODB_URI)` or similar patterns for `MONGODB_URI`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `ADMIN_TOKEN` (latter is now removed in phase-05 redesign — keep for defense-in-depth in case it returns).
### DELETE
- (none in this phase)
## Implementation Steps
1. Create Atlas account / project `miti99bot`. Provision M0 in `aws-ap-southeast-1`. Cluster name `miti99bot-prod`. Note: complete in one sitting (Atlas UI sessions expire; debugger #6).
2. Create DB user `miti99bot-worker` with `readWrite@miti99bot`. Strong random password (≥32 chars).
3. Network access list → add `0.0.0.0/0` (only viable choice for Workers). Document as permanent risk.
4. Configure Atlas free-tier email alerts: (a) cluster unavailable; (b) current connections > 400. (debugger QW-2; 5-min in Atlas UI; zero code.)
5. Copy SRV connection string `mongodb+srv://miti99bot-worker:<pass>@<host>/miti99bot?retryWrites=true&w=majority`.
6. `wrangler secret put MONGODB_URI` → paste string.
7. Add `MONGODB_URI=...` to `.env.deploy` (gitignored). Update `.env.deploy.example` with placeholder.
8. Verify `wrangler.toml` `compatibility_date` is `>= 2025-03-20` (current `2025-10-01` qualifies — **no edit**, per debugger QW-5). Add `compatibility_flags = ["nodejs_compat_v2"]`.
9. **Node-API surface inventory** (code-reviewer #14): `grep -rn "import.*node:\|process\.env\|Buffer\." src/ scripts/` → list each occurrence in `docs/using-mongodb.md` §"Node API surface". Confirms what `nodejs_compat_v2` must support.
10. Create `scripts/check-secret-leaks.js` (per brainstormer #10): grep src/ + scripts/ for `console.log(env.MONGODB_URI)`, `console.log(env.TELEGRAM_BOT_TOKEN)`, `console.log(env.TELEGRAM_WEBHOOK_SECRET)`, `console.log(env.ADMIN_TOKEN)`. Exit 1 on match. Wire into `npm run lint` chain in `package.json`.
11. `npm install mongodb@^6.7.0 --save`.
12. **HARD GATE — bundle size** (code-reviewer #5 / debugger QW-4):
```sh
npx wrangler deploy --dry-run --outdir=./.tmp-deploy
du -sh ./.tmp-deploy
```
Abort if `> 2.7 MiB` on Free plan (3 MiB cap, 10% headroom) or `> 9 MiB` on paid (10 MiB cap).
On abort: revert phase commits + execute `phase-07-alt-pivot.md`.
13. Smoke test from `wrangler dev`: drop a temporary route `/__mongo-ping` that connects, runs `db.runCommand({ping:1})`, returns `{wall_ms, cpu_note: "check CF dashboard CPU column"}`. Run 5+ cold cycles (10-min spaced). Record both wall-clock AND CF dashboard CPU time. **HARD GATE — CPU time** (debugger GAP-A / QW-1): if any cold ping reports CPU time near 50ms on Free plan, the migration is blocked on Free plan; document the result + escalate to user (paid plan or pivot). Save the cold P95 wall-clock as `BASELINE_COLD_PING_MS` — Phase 06 derives the abort threshold from this.
14. **Auto-pause behavior test** (debugger GAP-C): in Atlas UI, manually pause the cluster, then hit `/__mongo-ping`. Confirm driver throws a catchable `MongoServerSelectionError` after 5s (not a hang). Document the catch-path requirement for phase-02 §"Connection memoization".
15. Delete the temporary `/__mongo-ping` route before commit.
16. Write `docs/using-mongodb.md`: cluster name, region, auto-pause schedule, rotation procedure, rollback procedure, node-API surface inventory (step 9 output), baseline cold-ping P95 (step 13 output), `MongoServerSelectionError` catch requirement (step 14), Atlas alert config (step 4), `0.0.0.0/0` permanence + paid-IP upgrade path.
17. Run `npm run lint` (now includes `check-secret-leaks.js`). All clean.
## Todo List
- [ ] Atlas project + M0 cluster created (`aws-ap-southeast-1`)
- [ ] DB user `miti99bot-worker` created, password vaulted
- [ ] Network access `0.0.0.0/0` added with justification comment in Atlas UI
- [ ] Atlas email alerts configured (cluster unavailable + connections > 400)
- [ ] `MONGODB_URI` set via `wrangler secret put`
- [ ] `MONGODB_URI` mirrored in `.env.deploy` (NOT committed)
- [ ] `.env.deploy.example` updated
- [ ] `wrangler.toml` adds `compatibility_flags = ["nodejs_compat_v2"]` (compatibility_date unchanged)
- [ ] Node-API surface grep run + documented
- [ ] `scripts/check-secret-leaks.js` written + wired into `npm run lint`
- [ ] `mongodb@^6.7.0` installed
- [ ] **HARD GATE: bundle-size dry-run ≤ 2.7 MiB (Free) / ≤ 9 MiB (paid)**
- [ ] **HARD GATE: cold-ping CPU time well under 50ms** (or paid plan documented)
- [ ] Auto-pause behavior tested (catchable error confirmed)
- [ ] Temporary `/__mongo-ping` route deleted pre-commit
- [ ] Baseline cold-ping P95 recorded in `docs/using-mongodb.md` (drives Phase 06 gate)
- [ ] `docs/using-mongodb.md` written
- [ ] README mentions M0 auto-pause
- [ ] `npm run lint` passes (with secret-leak check)
## Success Criteria
- `wrangler dev` connects to Atlas, ping returns OK.
- Bundle-size gate passes.
- CPU-time gate passes (or operator escalates to paid plan).
- Auto-pause yields catchable error within 5s, not a hang.
- Cold-start ping P95 wall-clock recorded as `BASELINE_COLD_PING_MS` for Phase 06 abort threshold derivation.
- Rollback steps documented + verifiable (revert flag → redeploy → KV/D1 path intact).
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Connection string leaks via log | M | H | `check-secret-leaks.js` wired into lint at this phase. |
| `0.0.0.0/0` is exploited | L | H | SCRAM-SHA-256 + TLS; rotate password quarterly; non-guessable username. **Permanent risk** without paid CF static-egress add-on. |
| `nodejs_compat_v2` breaks existing modules | L | M | `compatibility_date 2025-10-01` (post-flag-stabilization). Step 9 inventory + `wrangler dev` smoke before any prod deploy. Vitest does NOT catch this (debugger #19); rely on smoke. |
| M0 auto-pause hits during low-traffic period | L | M | Bot has 6+ daily crons (any one writing Mongo prevents pause). Phase 02 catches `MongoServerSelectionError` → 503. Phase 08 confirms. |
| Bundle exceeds Worker size cap | M | H | **Hard gate at step 12**. Abort to phase-07-alt-pivot if exceeded. |
| Cold-start CPU exceeds 50ms (Free plan) | M | CATASTROPHIC | **Hard gate at step 13**. Free plan blocked → escalate to paid OR pivot. |
| Atlas API session / MFA expires mid-provisioning | L | M | Step 1 note: complete in one sitting; API token TTL ~30d. |
## Security Considerations
- `MONGODB_URI` contains user+password. Treat as secret-tier (same handling as `TELEGRAM_BOT_TOKEN`).
- Password ≥32 chars random.
- DB user has `readWrite` only — NOT `dbAdmin` or `clusterAdmin`.
- Atlas IP allow-list cannot be tightened without CF Workers static-IP add-on (paid) — accept as documented risk.
- All traffic TLS; driver bundles root CA, no manual setup.
- Secret-leak lint runs on every `npm run lint` from this phase forward.
## Rollback (this phase only)
1. `wrangler secret delete MONGODB_URI`.
2. Revert `wrangler.toml` (remove `compatibility_flags`).
3. `npm uninstall mongodb`.
4. `npm run deploy` — bot continues on KV/D1 unchanged.
5. (Optional) Delete Atlas cluster from UI.
6. (Optional) Revert `scripts/check-secret-leaks.js` if migration abandoned entirely. Keep otherwise — rule applies to other secrets too.
## Next Steps
- **Blocks:** Phase 02 (MongoKVStore) needs `MONGODB_URI` available + bundle/CPU gates passed.
- **Unblocks:** Phase 02, Phase 03.
@@ -0,0 +1,203 @@
# Phase 02 — MongoKVStore Implementation
## Context Links
- [Schema report](../reports/researcher-260425-1924-mongodb-schema-and-migration.md) §2 (KV doc shape), §6 (reference impl)
- [Driver report](../reports/researcher-260425-1924-mongodb-atlas-fit-and-driver.md) §"Memoization Pattern"
- [Code-reviewer findings #6, #7, #16, #17](../reports/code-reviewer-260425-2034-atlas-plan-correctness.md)
- [Debugger GAP-C, QW-3](../reports/debugger-260425-2034-atlas-plan-failure-modes.md)
- `src/db/kv-store-interface.js` — full contract
- `src/db/cf-kv-store.js` — behavioral parity target (108 LOC)
- `src/db/create-store.js:40-78` — namespace-prefixing wrapper to mirror
- `src/bot.js` `getBot()` — memoization pattern to follow
## Overview
- **Priority:** P0
- **Status:** pending
- **Description:** Implement `MongoKVStore` (KVStore interface) + `mongo-client.js` shared connection helper. No factory wiring yet — that lands in Phase 04.
## Key Insights
- Store value as **string** (matches `putJSON` serialization). 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 }` with `expireAfterSeconds: 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`, NOT `skip()`. Encode last `_id` as base64.
- Memoize `MongoClient` at module scope. **Do NOT** await `connect()` lazily inside every method — first caller awaits, others race the same promise. **On reject, null BOTH `client` and `connectPromise`** (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 in `create-store.js:65` strips. Unambiguous.
- **`MongoServerSelectionError`** caught in `getDb()` 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
- `MongoKVStore` implements every method in `kv-store-interface.js`: `get`, `put`, `delete`, `list`, `getJSON`, `putJSON`.
- Exact behavioral parity with `CFKVStore` (with TTL stale-window divergence noted):
- `get` returns `null` on missing key (NOT `undefined`).
- `get` and `getJSON` filter on `expiresAt` at 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.
- `put` with `expirationTtl` writes `expiresAt = now + ttl*1000`. Without it, removes any existing `expiresAt`.
- `delete` is idempotent (no-op on missing key).
- `list` returns `{ keys, cursor, done }` with `done=true` when no more pages. **Keys returned WITH prefix preserved** (parity with CFKVStore — wrapper strips).
- `getJSON` returns `null` on missing OR malformed JSON; logs `console.warn`. Never throws.
- `putJSON` throws on `undefined` or cyclic value.
- TTL index created idempotently on first connect per collection.
- `getDb(env)` catches `MongoServerSelectionError` and 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, extract `mongo-list-cursor.js`).
- JSDoc on every export.
- No `process.env`; only `env.MONGODB_URI`.
## Architecture
```mermaid
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
```js
// 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)
```js
// 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
1. Create `tests/fakes/fake-mongo.js` first — defines the surface area MongoKVStore + MongoTradesStore (phase-03) must use. Methods (re-derived per code-reviewer #17): `collection(name)` returns object with `findOne`, `updateOne` (with upsert + `$set` + `$unset`), `deleteOne`, `find(query)` returning chainable `.sort().skip().limit().project().toArray()`, `insertOne`, `insertMany`, `distinct`, `deleteMany`, `createIndex` (no-op), `countDocuments`. Backed by `Map<collectionName, Map<_id, doc>>`. TTL is NOT simulated (TTL is server-side; tests check `expiresAt` field only — and the read-time `expiresAt` filter is exercised against `Date.now()`).
2. Create `src/db/mongo-client.js`:
- `getDb(env)` — module-scope memoized client + connect promise. **On `client.connect()` reject, null both** (code-reviewer #16).
- Catch + log `MongoServerSelectionError` with actionable message (debugger QW-3).
- `closeMongo()` — for tests/teardown only.
- JSDoc on both.
3. Create `src/db/mongo-kv-store.js`:
- Constructor `(env, collectionName)` — defer connect.
- `_ensureIndex()` — runs once per collection per isolate (use a `Set<string>` at module scope).
- Methods mirror `cf-kv-store.js` line-for-line (same null semantics, same warn-on-corrupt-JSON), **except**: `get` and `getJSON` filter on `expiresAt` at read time.
- `list()` uses `escapeRegex` + `sort({_id:1})` + `limit(N+1)` + base64 cursor of last `_id`. Returns keys WITH prefix.
4. Write `tests/db/mongo-kv-store.test.js`:
- Inject `fake-mongo` via dependency injection (constructor takes optional `dbOverride` for 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 clears `expiresAt`, delete idempotent, list with prefix returns keys WITH prefix preserved, **2-level prefix regression** (`wordle:games:`), list with cursor, list `done` flag.
- **TTL stale-read regression** (code-reviewer #7): put with `expirationTtl: 1` second, advance time 2s (mock `Date.now()` or use a tiny real sleep), assert `get` returns null even before TTL sweeper would run.
- **Connection-reject retry regression** (code-reviewer #16): mock first `connect()` to reject; assert second `getDb()` call retries (does not reuse dead client).
- Cover edge: `putJSON(undefined)` throws, `putJSON(circular)` throws.
5. `npm test -- mongo-kv-store` → passes.
6. `npm run lint` → passes.
## Todo List
- [ ] `tests/fakes/fake-mongo.js` created with full surface (re-derived from phase-02 + phase-03)
- [ ] `src/db/mongo-client.js` created (≤80 LOC, JSDoc, reject-resets-state, MongoServerSelectionError logged)
- [ ] `src/db/mongo-kv-store.js` created (≤200 LOC, JSDoc)
- [ ] `tests/db/mongo-kv-store.test.js` created
- [ ] All KVStore methods covered with parity tests vs CFKVStore semantics
- [ ] `expiresAt` read-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 prefix
- [ ] `getJSON` corrupt-data path returns null without throwing
- [ ] `npm test` passes
- [ ] `npm run lint` passes
- [ ] No file > 200 LOC
## Success Criteria
- All tests in `mongo-kv-store.test.js` pass.
- Behavioral diff vs `cf-kv-store.js` is 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.js` connect-promise is awaited exactly once per isolate under concurrent first calls; on reject, both `client` and `connectPromise` are 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.
- `MongoClient` constructor 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)
1. Delete created files.
2. `npm uninstall mongodb` (if not needed by Phase 03 yet — but it will be).
3. 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).
@@ -0,0 +1,203 @@
# Phase 03 — MongoTradesStore + Trading Refactor
## Context Links
- [Schema report](../reports/researcher-260425-1924-mongodb-schema-and-migration.md) §3 (trading mapping), §6 (reference impl)
- [Brainstormer Findings #2, #6, #7](../reports/brainstormer-260425-2034-atlas-plan-critique.md)
- [Code-reviewer Findings #1, #3, #13](../reports/code-reviewer-260425-2034-atlas-plan-correctness.md)
- [Debugger #17](../reports/debugger-260425-2034-atlas-plan-failure-modes.md)
- `src/db/sql-store-interface.js` — full contract (kept; `MongoSqlStore` is a thin shim)
- `src/db/cf-sql-store.js` — behavioral parity target (80 LOC)
- `src/modules/trading/history.js`, `src/modules/trading/retention.js` — direct refactor targets
- `src/modules/trading/migrations/0001_trades.sql`
- `tests/db/create-sql-store.test.js:48-52` — pre-existing assertion that `last_row_id` is present (constrains return shape)
## Overview
- **Priority:** P0
- **Status:** pending
- **Description:** Replace the SQL-pattern dispatcher approach with a direct **`MongoTradesStore`** (~80 LOC, 6 explicit methods). Refactor `trading/history.js` + `trading/retention.js` to call it directly. Keep `MongoSqlStore` as a thin shim (returns `MongoTradesStore` for the trading module) so `create-sql-store.js` factory branching is unchanged and `tests/db/create-sql-store.test.js` continues to pass.
## Key Insights
- Trading uses 6 distinct queries today (history.js + retention.js). The dispatcher approach (regex-match SQL strings) is brittle: statements 4 & 5 cannot be distinguished by first-30-char prefix (code-reviewer #3); a future 7th statement silently breaks.
- **Direct refactor is simpler AND safer** (brainstormer #6): explicit methods make the persistence boundary honest. The trading module already isolates SQL strings to two small files (~30 LOC of changes total).
- **Preserve `legacy_id`** (code-reviewer #13 / debugger #17): backfill writes `{_id: ObjectId, legacy_id: <orig_int>, user_id, ...}`. Historical trade IDs in logs/dashboards stay joinable.
- **`last_row_id` contract** (code-reviewer #1): existing test `tests/db/create-sql-store.test.js:48-52` asserts `result.last_row_id` is present. `MongoSqlStore.run` returns `{changes: 1, last_row_id: 0}` for inserts (number, NOT hex). Trading code does NOT consume `last_row_id` (verified in step 1) — `0` is safe.
- **Confirm zero arithmetic on `id`** (debugger #17): `grep -r "\.id\s*[-+*/<>]" src/modules/trading/` must return empty. If any match, escalate before refactor.
## Requirements
### Functional
- `src/db/mongo-trades-store.js` (~80 LOC). 6 explicit methods:
- `insert(trade)` → `db.trading_trades.insertOne({_id: ObjectId(), legacy_id: null, ...trade})` returns `{changes: 1, last_row_id: 0}`.
- `byUser(userId, limit)` → `find({user_id: userId}).sort({ts:-1}).limit(limit).toArray()` → adapt each doc.
- `distinctUsers()` → `distinct("user_id")`.
- `oldRowsForUser(userId, keepN)` → `find({user_id: userId}).sort({ts:-1}).skip(keepN).project({_id:1}).toArray()` → ids.
- `oldRows(keepN)` → `find({}).sort({ts:-1}).skip(keepN).project({_id:1}).toArray()` → ids.
- `deleteByIds(ids)` → `deleteMany({_id: {$in: ids.map(toObjectIdOrLegacy)}})`.
- `src/db/mongo-sql-store.js` (~40 LOC) is a **thin shim** that satisfies the `SqlStore` interface for `create-sql-store.js` factory branching:
- For trading module, returns a wrapper exposing `run`/`all`/`first`/`prepare`/`batch` that delegates to `MongoTradesStore`. `prepare`/`batch` throw `"unsupported"`.
- `run` returns `{changes: 1, last_row_id: 0}` for inserts. `0` is a number — satisfies `tests/db/create-sql-store.test.js:48-52`.
- `tablePrefix` exposed (matches `create-sql-store.js:39-42`).
- Refactor `src/modules/trading/history.js`: replace SQL-string `.run()` / `.all()` / `.first()` calls with `MongoTradesStore.insert` / `.byUser`. (~15 LOC delta.)
- Refactor `src/modules/trading/retention.js`: replace SQL-string calls with `.distinctUsers` / `.oldRowsForUser` / `.oldRows` / `.deleteByIds`. (~15 LOC delta.)
- During dual-write window the SqlStore interface still delegates to D1 for primary OR Mongo for primary (Phase 04 wraps both). `MongoTradesStore` is the Mongo-side concrete; `CFSqlStore` is the D1-side concrete.
### Non-functional
- `src/db/mongo-trades-store.js` ≤100 LOC.
- `src/db/mongo-sql-store.js` ≤80 LOC (thin shim only).
- JSDoc on every export.
- No SQL strings anywhere in `src/db/mongo-*.js`.
- Trading module changes: ~30 LOC total across 2 files.
## Architecture
```mermaid
flowchart TD
subgraph Module
H[trading/history.js]
R[trading/retention.js]
end
subgraph DB
S[MongoSqlStore shim]
T[MongoTradesStore]
D[CFSqlStore]
end
H -->|insert / byUser| S
R -->|distinctUsers / oldRows* / deleteByIds| S
S -->|delegates if Mongo primary| T
T -->|insertOne / find / distinct / deleteMany| M[(Mongo: trading_trades)]
S -.->|delegates if D1 primary| D
```
### Document shape (collection: `trading_trades`)
```js
{
_id: ObjectId,
legacy_id: number | null, // D1 autoincrement id, preserved during backfill (code-reviewer #13)
user_id: number,
symbol: string,
side: "buy" | "sell",
qty: number,
price_vnd: number,
ts: number // ms timestamp, parity with D1 column
}
```
### Indexes
```js
db.trading_trades.createIndex({ user_id: 1, ts: -1 });
db.trading_trades.createIndex({ ts: -1 });
db.trading_trades.createIndex({ legacy_id: 1 }, { sparse: true }); // for legacy joins
```
### Why direct refactor over dispatcher
| Concern | Dispatcher | Direct |
|---------|-----------|--------|
| 7th SQL statement | Silent breakage (M likelihood per dispatcher's own risk row) | Cannot happen — explicit method per use-case |
| Statements 4 vs 5 disambiguation | Requires inspecting beyond first 30 chars (code-reviewer #3) | N/A — different methods |
| `LIMIT -1 OFFSET ?` sqlite-ism | Handler must ignore LIMIT silently | N/A — `oldRowsForUser/oldRows` use `.skip()` only |
| Test surface | Pattern-match coverage of every variation | One test per method |
| LOC | ~150 dispatcher + ~200 handlers | ~80 store + ~30 module changes |
## Related Code Files
### CREATE
- `/config/workspace/tiennm99/miti99bot/src/db/mongo-trades-store.js`
- `/config/workspace/tiennm99/miti99bot/src/db/mongo-sql-store.js` (thin shim, ≤80 LOC)
- `/config/workspace/tiennm99/miti99bot/tests/db/mongo-trades-store.test.js`
- `/config/workspace/tiennm99/miti99bot/tests/db/mongo-sql-store.test.js` (covers shim run/all/first contract — `last_row_id: 0` for inserts)
### MODIFY
- `/config/workspace/tiennm99/miti99bot/src/modules/trading/history.js` — replace SQL strings with `MongoTradesStore` method calls.
- `/config/workspace/tiennm99/miti99bot/src/modules/trading/retention.js` — same.
- `/config/workspace/tiennm99/miti99bot/tests/modules/trading/*.test.js` — update where SQL strings appear; switch to `MongoTradesStore` mocks.
### READ FOR CONTEXT (do not edit)
- `/config/workspace/tiennm99/miti99bot/src/db/sql-store-interface.js`
- `/config/workspace/tiennm99/miti99bot/src/db/cf-sql-store.js`
- `/config/workspace/tiennm99/miti99bot/src/modules/trading/migrations/0001_trades.sql`
- `/config/workspace/tiennm99/miti99bot/tests/db/create-sql-store.test.js:48-52` — `last_row_id` shape constraint
### DELETE
- (none — D1 binding stays through Phase 07)
## Implementation Steps
1. Grep `src/modules/trading/` for every `.run(`, `.all(`, `.first(`, `.prepare(`, `.batch(` call. Confirm only the 6 known queries. If a 7th appears, list before proceeding (refactor must include it).
2. **Verify zero arithmetic on `id`** (debugger #17): `grep -r "\.id\s*[-+*/<>]" src/modules/trading/`. Must return empty. Document the grep result inline in the phase plan.
3. Confirm `last_row_id` is unused by trading: `grep -r "last_row_id" src/modules/trading/` — must return empty (only `cf-sql-store.js` and tests reference it).
4. Create `src/db/mongo-trades-store.js`:
- Constructor `(env)` — defers connect.
- 6 methods listed above; each ≤15 LOC.
- `_ensureIndexes()` lazy init (3 indexes).
- JSDoc.
5. Create `src/db/mongo-sql-store.js` thin shim:
- Constructor `(env, moduleName)`.
- `tablePrefix = ${moduleName}_`.
- `run/all/first` delegate to a `MongoTradesStore` instance for trading module by introspecting query for hard-coded markers (or — simpler — accept a method-name in module init). **Decision:** the shim wraps `MongoTradesStore` and only the trading module ever instantiates it; the shim's `run/all/first` exist purely for `SqlStore` interface compliance + the existing factory-test contract. The trading module DOES NOT call the shim — it imports `MongoTradesStore` directly. Shim's `run/all/first` translate the 6 known queries OR throw `"MongoSqlStore: unsupported query — call MongoTradesStore directly"` for everything else.
- **Insert returns `{changes: 1, last_row_id: 0}` (number, not hex)** per code-reviewer #1.
- `prepare`/`batch` throw `"unsupported in MongoSqlStore"`.
6. Refactor `src/modules/trading/history.js`:
- Replace SQL-string `.run(...)` with `tradesStore.insert({...})`.
- Replace SQL-string `.all(...)` for history with `tradesStore.byUser(userId, limit)`.
- Module init signature change: `init({ db, sql, tradesStore, env })` — Phase 04 factory passes the right `tradesStore` (Mongo or null when D1-primary). When Mongo unavailable + D1 primary, fall back to old SQL path through `sql`.
7. Refactor `src/modules/trading/retention.js`:
- Replace SQL-string `.all(...)` for distinct → `tradesStore.distinctUsers()`.
- Replace per-user retention SELECT → `tradesStore.oldRowsForUser(userId, keepN)`.
- Replace global retention SELECT → `tradesStore.oldRows(keepN)`.
- Replace DELETE IN (...) → `tradesStore.deleteByIds(ids)`.
8. Write `tests/db/mongo-trades-store.test.js`:
- Inject `fake-mongo` via constructor.
- One test per method; cover edge cases (empty list, IDs with `legacy_id` mix, ordering).
9. Write `tests/db/mongo-sql-store.test.js`:
- Verify `run` returns `{changes, last_row_id: 0}` (number) — satisfies `tests/db/create-sql-store.test.js:48-52`.
- Verify `prepare`/`batch` throw.
10. Update `tests/modules/trading/*.test.js` to use `MongoTradesStore` mock + injected `tradesStore`.
11. `npm test` passes (including `tests/db/create-sql-store.test.js`). `npm run lint` passes.
## Todo List
- [ ] Grep confirms exactly 6 SQL statements in trading module (or extends list)
- [ ] **Grep confirms zero arithmetic on `id`** (debugger #17)
- [ ] `last_row_id` confirmed unused by trading
- [ ] `src/db/mongo-trades-store.js` created (≤100 LOC, 6 methods, JSDoc)
- [ ] `src/db/mongo-sql-store.js` shim created (≤80 LOC, returns `last_row_id: 0`)
- [ ] `trading/history.js` refactored to use `MongoTradesStore` directly
- [ ] `trading/retention.js` refactored
- [ ] Trading module init signature accepts `tradesStore`
- [ ] `legacy_id` field shape documented + index created
- [ ] `tests/db/mongo-trades-store.test.js` covers all 6 methods
- [ ] `tests/db/mongo-sql-store.test.js` covers shim contract incl. `last_row_id: 0`
- [ ] Trading module tests updated
- [ ] `tests/db/create-sql-store.test.js:48-52` still passes (no contract regression)
- [ ] `npm test` passes
- [ ] `npm run lint` passes
## Success Criteria
- `MongoTradesStore` provides exact behavioral parity with the 6 D1 queries (verified per-method).
- Trading module no longer contains SQL string literals.
- `MongoSqlStore` shim satisfies `SqlStore` interface for factory branching.
- `last_row_id: 0` (number) — `tests/db/create-sql-store.test.js:48-52` unchanged and passing.
- `legacy_id` preserved on backfilled trades.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| 7th SQL statement appears mid-refactor | L | M | Step 1 grep gates the refactor. Direct method approach makes additions explicit; cannot silently break. |
| `id` arithmetic somewhere unseen | L | H | Step 2 grep gate; if found, escalate. |
| `last_row_id: 0` consumer somewhere | L | M | Step 3 grep gate; trading confirmed not to consume. |
| Trading test regressions on signature change | M | M | Step 10 updates tests in same commit. Run `npm test` between steps. |
| `legacy_id` lookups slow without index | L | L | Sparse index on `legacy_id` created (step 4 lazy init). |
| Backwards-compat for in-flight D1 reads during dual-write | L | M | Phase 04 factory selects which concrete (Mongo or D1) per primary flag. Module sees uniform interface. |
## Security Considerations
- Bind values pass through unmodified; no string concatenation into queries.
- `_id` exposed as hex; not sensitive (server-generated).
- No SQL strings anywhere in trading code post-refactor — eliminates an entire class of injection foot-gun.
## Rollback (this phase only)
1. Revert `src/modules/trading/*.js` changes (single commit).
2. Delete `src/db/mongo-trades-store.js` + `src/db/mongo-sql-store.js`.
3. No runtime impact (not wired in yet).
## Next Steps
- **Blocks:** Phase 04 (dual-write SQL wrapper needs the shim + concrete).
- **Parallel-safe with:** Phase 02 (independent files; both feed into Phase 04).
@@ -0,0 +1,233 @@
# Phase 04 — Dual-Write Wrappers + Storage Flag + e2e
## Context Links
- [Schema report](../reports/researcher-260425-1924-mongodb-schema-and-migration.md) §5 (dual-write mechanics)
- [Code-reviewer Findings #2, #8, #18, #20, #27](../reports/code-reviewer-260425-2034-atlas-plan-correctness.md)
- [Brainstormer Finding #10](../reports/brainstormer-260425-2034-atlas-plan-critique.md) (e2e moves earlier)
- [Debugger #8](../reports/debugger-260425-2034-atlas-plan-failure-modes.md)
- `src/db/create-store.js` — KV factory to extend (≤80 LOC currently)
- `src/db/create-sql-store.js` — SQL factory to extend (≤80 LOC currently)
- `scripts/stub-kv.js` — needs sibling `stubMongo` (duck-typed, NOT a string)
- `scripts/register.js:75` — `buildRegistry({MODULES, KV: stubKv, AI: stubAi})` call site
## Overview
- **Priority:** P0
- **Status:** pending
- **Description:** Wrap KV and SQL stores so writes hit BOTH backends and reads hit the configured primary. Single env flag `STORAGE_PRIMARY` toggles read source. **Also lands the e2e storage-roundtrip test** (moved here from phase-08 per brainstormer #10) so dual-write code is end-to-end-tested before any prod soak. This is the ONLY phase that touches the request path's data layer pre-cutover.
## Key Insights
- Single source of truth: `env.STORAGE_PRIMARY` ∈ {`kv`, `mongo`}. Default `kv`.
- Dual-write is **always on** when MongoDB credentials are present, regardless of `STORAGE_PRIMARY`. Goal: keep secondary warm + ready for read-flip.
- Writes to BOTH must be parallel via `Promise.allSettled`. If secondary write fails → log error AND push the failed key onto a small KV retry queue (`__retry:mongo-failed`) for later drain. Throw only on primary failure. (code-reviewer #8 / debugger #8)
- A separate **drift-verifier cron** (1/hr or 1/6h, sample N keys) drains the retry queue + spot-checks parity. Replaces the original "one-shot reconciliation" approach.
- During cutover (Phase 07), the flag flips to `mongo`. After soak, secondary writes can be turned off via a second flag `DUAL_WRITE` (default `1`, set to `0` post-cutover).
- `register.js` runs at deploy time — does NOT need a Mongo connection. Pass **duck-typed `stubMongo`** + `STUB_SENTINEL` so factories short-circuit even if flags drift (code-reviewer #2).
## Requirements
### Functional
- `DualKVStore` class implementing `KVStore` interface:
- `get`/`getJSON`/`list` → read from primary only.
- `put`/`putJSON`/`delete` → write to both via `Promise.allSettled`. Log secondary failures with key + error AND enqueue to `__retry:mongo-failed` (a KV list bound to the same `env.KV` namespace, prefix `__retry:mongo-failed:`). Throw only on primary failure.
- Expose `_kind === "dual"` sentinel for test-side identification (replaces the `_implementations` array seam — code-reviewer #27).
- `DualSqlStore` class implementing `SqlStore` interface:
- `all`/`first` → read primary only.
- `run` → write both. Same fault tolerance as KV (retry-queue same namespace, prefix `__retry:mongo-sql-failed:`).
- `prepare`/`batch` → primary only (D1 stays authoritative for legacy paths until cutover).
- `create-store.js` factory updated:
- Reads `env.STORAGE_PRIMARY` (default `"kv"`) and `env.DUAL_WRITE` (default `"1"`).
- **If `env.MONGODB_URI === STUB_SENTINEL`** → unconditionally return CFKVStore-only (deploy-time path).
- If `MONGODB_URI` absent → return CFKVStore-only (legacy path).
- If `DUAL_WRITE=0` AND `STORAGE_PRIMARY=mongo` → return MongoKVStore-only.
- Otherwise → return `DualKVStore(primary, secondary)`.
- **Pre-design post-cutover shape** (code-reviewer #20): when both flags are absent / unset, factory returns MongoKVStore-only directly (no flag inspection). Phase-07 simplification just deletes the KV branches.
- `create-sql-store.js` factory mirrors logic for SQL (uses `MongoSqlStore` shim from phase-03).
- `register.js` + `scripts/stub-kv.js`: add **duck-typed** `stubMongo` (NOT a string) + `STUB_SENTINEL` so deploy-time registry build doesn't crash.
- **Drift-verifier cron** (replaces one-shot phase-05 reconciliation):
- New module `src/cron/drift-verifier.js` (or co-located in misc) registered in `wrangler.toml [triggers] crons` at 1/hr (configurable).
- Drains `__retry:mongo-failed` queue (each entry: re-attempt the secondary write).
- Spot-checks parity: sample N keys per module, hash-compare values, log mismatches.
- **e2e test** lands HERE (not phase-08 — brainstormer #10): `tests/e2e/storage-roundtrip.test.js` boots a fake env with `MongoKVStore` + `MongoSqlStore` against `fake-mongo.js`, dispatches a representative wordle command + a trading insert, asserts state persisted.
### Non-functional
- Each new file ≤200 LOC.
- New files: `src/db/dual-kv-store.js`, `src/db/dual-sql-store.js`, `src/cron/drift-verifier.js`, `tests/e2e/storage-roundtrip.test.js`.
- All new exports JSDoc'd.
- Behavior must be deterministic given env flags (testable via fake env).
- Logging via `console.warn`/`console.error` with structured fields (`{ phase, op, key, err }`).
## Architecture
### Read/write flow (during dual-write window)
```mermaid
sequenceDiagram
participant H as Handler
participant F as createStore (factory)
participant D as DualKVStore
participant K as CFKVStore (primary)
participant M as MongoKVStore (secondary)
participant Q as KV retry queue
H->>F: createStore("wordle", env)
F->>F: read env.STORAGE_PRIMARY (=kv) + env.DUAL_WRITE (=1)
F-->>H: DualKVStore(K, M)
H->>D: getJSON("games:42")
D->>K: getJSON
K-->>D: doc
D-->>H: doc
H->>D: putJSON("games:42", v, {ttl: 86400})
par parallel
D->>K: putJSON
and
D->>M: putJSON
end
K-->>D: ok
M--xD: error (caught, logged)
D->>Q: enqueue(__retry:mongo-failed:games:42)
D-->>H: ok (primary succeeded)
```
### Drift-verifier cron flow
```mermaid
flowchart LR
Cron[1/hr cron] --> V[drift-verifier]
V -->|drain| Q[(KV: __retry:mongo-failed)]
V -->|sample N keys| K[(CF KV)]
V -->|sample N keys| M[(Atlas)]
V -->|hash compare| Log[CF Observability]
V -->|retry secondary| M
```
### Flag matrix
| `STORAGE_PRIMARY` | `DUAL_WRITE` | `MONGODB_URI` | Result |
|--------------------|--------------|----------------|--------|
| (unset) or `kv` | `1` (default) | set | DualKV: read KV, write both |
| (unset) or `kv` | `0` | any | CFKVStore only (legacy / rollback) |
| `mongo` | `1` | set | DualKV: read Mongo, write both (cutover phase) |
| `mongo` | `0` | set | MongoKVStore only (post-cutover) |
| any | any | unset | CFKVStore only (Phase 02/03 not deployed yet) |
| any | any | === STUB_SENTINEL | CFKVStore only (deploy-time register path) |
### Stub for register (duck-typed; code-reviewer #2)
```js
// scripts/stub-kv.js — sketch
export const STUB_SENTINEL = "__stub_mongo__";
export const stubMongo = {
// duck-typed MongoClient surface that no-ops without network IO
db() { return { collection: () => { throw new Error("stubMongo: no IO"); } }; },
connect: async () => undefined,
close: async () => undefined,
};
// register.js passes env.MONGODB_URI = STUB_SENTINEL; factories short-circuit on sentinel
```
Test: assert zero `MongoClient.connect()` calls when stub is used (`vi.spyOn(MongoClient.prototype, 'connect')`).
### Rollback semantics (code-reviewer #18)
**Rollback to KV-primary AFTER any Mongo-primary period requires reverse-backfill.** During the Mongo-primary window, secondary writes to KV may have failed silently and KV may be missing rows that exist only in Mongo. Cross-link to Phase 07 reverse-backfill scripts (which become Stage-2 prerequisites — see phase-07 step 11 prereq).
## Related Code Files
### CREATE
- `/config/workspace/tiennm99/miti99bot/src/db/dual-kv-store.js`
- `/config/workspace/tiennm99/miti99bot/src/db/dual-sql-store.js`
- `/config/workspace/tiennm99/miti99bot/src/cron/drift-verifier.js`
- `/config/workspace/tiennm99/miti99bot/tests/db/dual-kv-store.test.js`
- `/config/workspace/tiennm99/miti99bot/tests/db/dual-sql-store.test.js`
- `/config/workspace/tiennm99/miti99bot/tests/e2e/storage-roundtrip.test.js`
### MODIFY
- `/config/workspace/tiennm99/miti99bot/src/db/create-store.js` — read flags, branch, honor STUB_SENTINEL.
- `/config/workspace/tiennm99/miti99bot/src/db/create-sql-store.js` — same.
- `/config/workspace/tiennm99/miti99bot/scripts/stub-kv.js` — add `stubMongo` (duck-typed) + `STUB_SENTINEL`.
- `/config/workspace/tiennm99/miti99bot/scripts/register.js:75` — pass `MONGODB_URI: STUB_SENTINEL, STORAGE_PRIMARY: "kv", DUAL_WRITE: "0"` in env stub.
- `/config/workspace/tiennm99/miti99bot/wrangler.toml` — add `[vars] STORAGE_PRIMARY = "kv"`, `DUAL_WRITE = "1"`; add 1/hr cron for drift-verifier.
### DELETE
- (none)
## Implementation Steps
1. Grep `env.KV` and `env.DB` usage outside `src/db/` — should be zero (encapsulated). Confirm.
2. Create `src/db/dual-kv-store.js`:
- Constructor `(primary, secondary, retryQueue, logger=console)`.
- 6 methods. Reads → primary. Writes → `Promise.allSettled` with secondary errors logged AND enqueued to `retryQueue`.
- Expose `_kind = "dual"` (code-reviewer #27).
- JSDoc.
3. Create `src/db/dual-sql-store.js` — same shape, SqlStore interface.
4. Create `src/cron/drift-verifier.js`:
- Cron handler signature `(event, ctx)`. `ctx.db / ctx.sql` provide the dual store.
- Drain `__retry:mongo-failed` (and SQL queue) — re-attempt secondary writes; remove on success.
- Sample N keys per module via dual store; hash-compare; log mismatches.
- Tunable N via `env.DRIFT_SAMPLE_N` (default 50).
5. Modify `src/db/create-store.js`:
- **Short-circuit on STUB_SENTINEL** (code-reviewer #2).
- Branch on `env.STORAGE_PRIMARY`, `env.DUAL_WRITE`, presence of `env.MONGODB_URI`.
- When constructing MongoKVStore, pass collection name (sanitized: replace `-` with `_`).
- Keep the existing prefixed-wrapper closure intact.
- **Comment the post-cutover shape** (code-reviewer #20): `// post-Phase-07: this entire function returns MongoKVStore-only; KV branches removed`.
6. Modify `src/db/create-sql-store.js` mirrored. Use `MongoSqlStore` shim from phase-03; trading module's init wires `MongoTradesStore` directly via `tradesStore` param.
7. Add `stubMongo` + `STUB_SENTINEL` to `scripts/stub-kv.js`. Update `scripts/register.js:75` to pass `MONGODB_URI: STUB_SENTINEL, STORAGE_PRIMARY: "kv", DUAL_WRITE: "0"`.
8. Update `wrangler.toml` `[vars]` block to declare `STORAGE_PRIMARY = "kv"`, `DUAL_WRITE = "1"`, `DRIFT_SAMPLE_N = "50"`. Add cron `"0 * * * *"` (or `"0 */6 * * *"` if log volume is a concern).
9. Tests:
- `dual-kv-store.test.js`: write succeeds when both succeed; write succeeds when secondary fails (logs + enqueues retry); write fails when primary fails; reads always from primary; `_kind === "dual"`.
- `dual-sql-store.test.js`: same pattern.
- **stubMongo regression** (code-reviewer #2): construct factory with `STUB_SENTINEL` + every flag combo; `vi.spyOn(MongoClient.prototype, 'connect')` asserts ZERO calls.
- Integration test: factory with all flag combos returns correct concrete type (verify via `_kind` sentinel).
- **e2e test** `tests/e2e/storage-roundtrip.test.js`: boot fake env (`fake-mongo`), build registry, dispatch a wordle command (KV path) + trading insert (SQL path), assert persistence.
- Update register dry-run test to confirm command list still derives.
10. Run `npm test`, `npm run lint`, `npm run register:dry`. All pass.
## Todo List
- [ ] `dual-kv-store.js` created (with retry-queue + `_kind` sentinel)
- [ ] `dual-sql-store.js` created
- [ ] `src/cron/drift-verifier.js` created + cron registered in wrangler.toml
- [ ] `create-store.js` reads env flags + honors STUB_SENTINEL + post-cutover shape commented
- [ ] `create-sql-store.js` mirrors logic
- [ ] Duck-typed `stubMongo` + `STUB_SENTINEL` added to `stub-kv.js`
- [ ] `register.js` passes Mongo stub + flags
- [ ] `wrangler.toml` declares `STORAGE_PRIMARY` + `DUAL_WRITE` + `DRIFT_SAMPLE_N` + drift cron
- [ ] All test files written, passing
- [ ] **stubMongo never reaches MongoClient.connect** asserted in test
- [ ] **e2e storage-roundtrip test** passes (wordle KV + trading SQL)
- [ ] `npm run register:dry` succeeds
- [ ] `npm test` passes
- [ ] `npm run lint` passes
- [ ] Manual smoke: `wrangler dev` with `MONGODB_URI` set + `DUAL_WRITE=1` → write to wordle module → verify both KV and Mongo received it
## Success Criteria
- All 6 flag/sentinel combos behave per matrix.
- Secondary failure does NOT fail user-facing request; failure IS logged AND enqueued to retry queue.
- `register:dry` continues to work without Mongo creds; zero MongoClient.connect calls.
- Drift-verifier cron drains retry queue + flags any divergence ≥ threshold.
- e2e roundtrip passes for both KV (wordle) and SQL (trading) paths against `fake-mongo`.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Secondary write silently masks data divergence | M | H | Retry queue + drift-verifier cron (1/hr). Phase 06 monitors error rate. |
| Write amplification doubles latency on every put | H | M | `Promise.allSettled` parallel; latency = max(KV, Mongo). Cold p99 = 1500ms — flagged in Phase 06 abort gate. |
| `register:dry` connects to Atlas and fails | L | H | `STUB_SENTINEL` short-circuit; regression-tested in step 9. |
| Flag drift between wrangler.toml and .env.deploy | M | M | Phase 08 docs include checklist; lint script checks both files declare `STORAGE_PRIMARY`. |
| `MONGODB_URI` missing in prod → factory falls back silently | L | H | Boot-time assertion: if `STORAGE_PRIMARY=mongo` but URI absent (and not STUB_SENTINEL), throw on first request. |
| Order-of-operations bug: read after write reads stale primary | L | M | Same-isolate writes to primary block reads (single-threaded JS); no interleaving issue. |
| Mongo write fails because collection didn't exist + index not created | L | M | MongoKVStore `_ensureIndex` creates lazily; first write triggers creation. Tested in Phase 02. |
| Drift-verifier cron itself burns M0 ops | L | L | Default 1/hr × N=50 = ~1200 reads/day, well under M0 envelope. Tunable via `DRIFT_SAMPLE_N`. |
| **Rollback to KV-primary after Mongo-primary loses data** | M | H | **Reverse-backfill scripts (phase-07) are Stage-2 prerequisites** (code-reviewer #18). Cross-linked. |
## Security Considerations
- Logged errors must NOT include the document `value` (PII risk for trading).
- Retry-queue values may contain document blobs — same redaction rules for any cron-driven log emission.
- `STORAGE_PRIMARY` and `DUAL_WRITE` are public-readable env vars (in `wrangler.toml`). Acceptable — they're routing flags, not secrets.
- `STUB_SENTINEL` is a public string; safe — not a credential.
## Rollback (this phase only)
1. Set `DUAL_WRITE=0` in wrangler.toml `[vars]`. Redeploy.
2. CFKVStore + CFSqlStore become sole path. Identical to pre-Phase-04 behavior.
3. Disable drift-verifier cron (remove from wrangler.toml triggers).
4. (Optional) Revert phase commits if rollback is permanent.
## Next Steps
- **Blocks:** Phase 05 (backfill must run with dual-write live so concurrent writes don't bypass Mongo).
- **Unblocks:** Phase 05.
@@ -0,0 +1,213 @@
# Phase 05 — Backfill + Verification (Local-Only, No Admin Routes)
## Context Links
- [Schema report](../reports/researcher-260425-1924-mongodb-schema-and-migration.md) §5 Phase 3+4 (backfill, verify)
- [Brainstormer Finding #5](../reports/brainstormer-260425-2034-atlas-plan-critique.md) (no admin routes)
- [Code-reviewer #4, #21](../reports/code-reviewer-260425-2034-atlas-plan-correctness.md)
- [Debugger #16, #10](../reports/debugger-260425-2034-atlas-plan-failure-modes.md)
- `docs/architecture.md` § 10 — explicitly rejects admin HTTP surface; this phase complies.
- `scripts/migrate.js` — D1 migration runner pattern (Node script using wrangler envs)
- `wrangler.toml:14-22` — KV + D1 binding IDs
- 12 KV-using modules: util, wordle, loldle, loldle-emoji, loldle-quote, loldle-ability, loldle-splash, misc, lolschedule, semantle, doantu, twentyq
- 1 D1-using module: trading
## Overview
- **Priority:** P0
- **Status:** pending
- **Description:** One-shot **local-node** scripts to copy historical KV → Mongo, D1 → `trading_trades`. Plus a verifier comparing counts + sample-value hashes. Run AFTER Phase 04 dual-write is live so concurrent writes are already going to Mongo. **No admin HTTP routes** — uses CF KV REST API + `wrangler d1 export` per `docs/architecture.md` § 10 (brainstormer #5).
## Key Insights
- **Order matters**: dual-write must be deployed BEFORE backfill. Otherwise: writes during backfill window go to KV only → backfill misses them OR overwrites newer Mongo state.
- **No admin HTTP surface** (architectural compliance per `docs/architecture.md` § 10):
- KV reads via CF KV REST API: `https://api.cloudflare.com/client/v4/accounts/{id}/storage/kv/namespaces/{nsid}/keys` (paginated) + `/values/{key}` per key. Account token (already standard for `wrangler kv` ops).
- D1 reads via `npx wrangler d1 export miti99bot-db --remote --output=trades.sql` (already used in phase-07; mirror here).
- Mongo writes via local node + `mongodb` SDK (no Worker constraints).
- Backfill scripts run from operator's local machine; no `ADMIN_TOKEN` secret introduced.
- KV `list()` REST endpoint paginated 1000/page. Each value via `/values/{key}` GET. Expect minutes for full sweep across 12 modules. **Metadata** (`expirationTtl`) included on list response — propagate to `expiresAt` on Mongo upsert (debugger #10).
- D1 `trading_trades` is small (<300KB / few thousand rows). Single export + insertMany ok.
- Mongo Atlas M0 throughput ~100 ops/sec. Budget backfill at 50 ops/sec to leave headroom for live traffic. `await sleep(20)` between writes.
- **Upsert semantics**: `updateOne({_id}, {$setOnInsert: {...}, $set: {value, expiresAt}}, {upsert: true})` — skip-if-exists for new docs, but ensure `expiresAt` reflects source TTL.
- **Verifier sample size** (code-reviewer #21): `N = √(total) capped at 500`; full-scan compare on collections <10K docs (cheap on M0 with small data).
- **Cursor checkpoint** (debugger #16): write last-processed key to `.backfill-cursor-{module}.json` per module so a CPU-budget OOM doesn't lose progress.
## Requirements
### Functional
- `scripts/backfill-kv-to-mongo.js`:
- Local node script using CF KV REST API + `mongodb` SDK directly.
- Reads `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`, `KV_NAMESPACE_ID`, `MONGODB_URI` from `.env.deploy` (mirror of existing `wrangler kv` cred usage).
- For each module in `MODULES`: paginated KV list → for each key → if Mongo doc absent, copy with **`expiresAt` propagated from KV `metadata.expirationTtl`** (debugger #10).
- **Cursor checkpoint** to `.backfill-cursor-{module}.json` after each REST page; resume on restart (debugger #16).
- Logs progress per module: `[wordle] 142 keys: 138 copied, 4 skipped (already in Mongo)`.
- Idempotent: re-running is safe (skip-if-exists).
- `scripts/backfill-d1-to-mongo.js`:
- Local node script.
- Step 1: `npx wrangler d1 export miti99bot-db --remote --output=./.backfill/trades.sql`.
- Step 2: parse the SQL dump (or use `wrangler d1 execute --command "SELECT * FROM trading_trades" --json --remote` for direct JSON).
- For each row: `insertOne` with `_id: ObjectId()`, `legacy_id: row.id` (code-reviewer #13), other fields mapped 1:1.
- Pre-flight: skip if `trading_trades.countDocuments() > 0` AND `--force` not passed.
- `scripts/verify-mongo-parity.js`:
- Local node script. Same CF KV REST API + Mongo SDK + `wrangler d1 execute`.
- Per-module: count via REST list (paginate to total) vs Mongo `countDocuments`. Allowable diff: ±1% (live writes during run).
- Sample `N = min(500, ceil(sqrt(total)))` random keys per module; SHA256(value) cross-compare. **Full-scan compare for collections <10K docs.** (code-reviewer #21)
- For trades: full-scan compare on `legacy_id`, ts, user_id, symbol, qty.
- Output report: pass/fail per module, mismatch list with redacted keys.
- Exit code: 0 on pass, 1 on fail.
- All three scripts read `MONGODB_URI` + CF creds from `.env.deploy` via `node --env-file-if-exists` pattern matching `register.js`.
### Non-functional
- Each script ≤200 LOC. If approaching: extract `scripts/lib/migration-helpers.js` (CF REST pagination, Mongo upsert wrapper, hash helper).
- All scripts use the SAME `MongoClient` instance (single connection) per run.
- Logs printable + grep-friendly (`[module] action: details`).
- Dry-run mode (`--dry-run`) for all three.
- **No admin HTTP surface** — zero changes to `src/index.js`; zero new secrets.
## Architecture
### Local-only flow (no Worker route)
```mermaid
flowchart LR
Local[backfill-kv-to-mongo.js Node script]
REST[CF KV REST API]
KV[(CF KV)]
Mongo[(Atlas M0)]
Cursor[(.backfill-cursor-*.json)]
Local -->|GET /accounts/.../keys| REST
REST -->|enumerate| KV
KV --> REST
REST -->|page of {key, metadata}| Local
Local -->|GET /values/{key}| REST
REST --> KV
KV --> REST
REST -->|value| Local
Local -->|updateOne $setOnInsert with expiresAt| Mongo
Local -->|checkpoint last key| Cursor
```
### D1 flow
```mermaid
flowchart LR
Local[backfill-d1-to-mongo.js]
Wrangler[wrangler d1 execute --remote --json]
D1[(D1)]
Mongo[(Atlas M0)]
Local -->|SELECT * FROM trading_trades| Wrangler
Wrangler --> D1
D1 --> Wrangler
Wrangler -->|JSON rows| Local
Local -->|insertOne with legacy_id| Mongo
```
### Verification flow
```
1. For each module (KV-using):
a. CF REST: enumerate keys with prefix `module:` → N_kv
b. Mongo: countDocuments({}) on collection `module` → N_mongo
c. assert |N_kv - N_mongo| / max(N_kv, 1) < 0.01
2. For each module:
- if N_kv < 10000: FULL-SCAN compare (code-reviewer #21)
- else: sample N = min(500, ceil(sqrt(N_kv))) random keys
- SHA256(KV value) === SHA256(Mongo doc.value) ?
- Compare expiresAt bucket presence/absence + within ±5min (debugger #10)
3. trading_trades:
a. D1: SELECT COUNT(*) → N_d1
b. Mongo: countDocuments → N_mongo
c. FULL-SCAN compare on legacy_id, ts, user_id, symbol, qty (collection is small)
```
## Related Code Files
### CREATE
- `/config/workspace/tiennm99/miti99bot/scripts/backfill-kv-to-mongo.js`
- `/config/workspace/tiennm99/miti99bot/scripts/backfill-d1-to-mongo.js`
- `/config/workspace/tiennm99/miti99bot/scripts/verify-mongo-parity.js`
- `/config/workspace/tiennm99/miti99bot/scripts/lib/migration-helpers.js` (if size demands)
- `/config/workspace/tiennm99/miti99bot/tests/scripts/verify-mongo-parity.test.js` — unit-test helper functions (count diff, hash compare)
### MODIFY
- `/config/workspace/tiennm99/miti99bot/.env.deploy.example` — add placeholders: `CLOUDFLARE_ACCOUNT_ID=`, `CLOUDFLARE_API_TOKEN=` (KV-read scope), `KV_NAMESPACE_ID=`.
- `/config/workspace/tiennm99/miti99bot/package.json` `scripts`:
- `"backfill:kv": "node --env-file-if-exists=.env.deploy scripts/backfill-kv-to-mongo.js"`
- `"backfill:d1": "node --env-file-if-exists=.env.deploy scripts/backfill-d1-to-mongo.js"`
- `"verify:mongo": "node --env-file-if-exists=.env.deploy scripts/verify-mongo-parity.js"`
### DELETE
- (none)
### EXPLICITLY NOT CREATED
- ~~`src/admin/dump-routes.js`~~ — DROPPED. Architecture compliance.
- ~~`ADMIN_TOKEN` secret~~ — DROPPED.
- ~~`/__admin/*` routes in `src/index.js`~~ — DROPPED.
## Implementation Steps
1. Add CF account creds to `.env.deploy` (operator-side; mirror in `.env.deploy.example` placeholders only).
2. Write `scripts/backfill-kv-to-mongo.js`:
- Connect Mongo via `MONGODB_URI`.
- Per module: GET `https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/storage/kv/namespaces/{NS_ID}/keys?prefix=module:` (paginate via `cursor`).
- For each key: `GET /values/{key}`; build `{_id: prefixedKey, value, expiresAt: metadata.expiration ? new Date(metadata.expiration*1000) : undefined}`.
- `updateOne({_id}, {$setOnInsert: {value, expiresAt}, $set: {}}, {upsert: true})` — skip-if-exists.
- Throttle: `await sleep(20)` per write.
- Checkpoint last processed key → `.backfill-cursor-{module}.json` after each page (debugger #16).
3. Write `scripts/backfill-d1-to-mongo.js`:
- Run `npx wrangler d1 execute miti99bot-db --remote --command "SELECT * FROM trading_trades" --json` → parse rows.
- Pre-flight: skip if `countDocuments > 0` and no `--force`.
- `insertMany` in batches of 100. Each row: `{_id: ObjectId(), legacy_id: row.id, user_id, symbol, side, qty, price_vnd, ts}`.
4. Write `scripts/verify-mongo-parity.js`:
- Counts + hash-compare per spec. **Full-scan when total < 10000** (code-reviewer #21).
- Print human report. Exit code 0/1.
- Compare `expiresAt` presence + ±5min bucket (debugger #10).
5. Test the verifier with synthetic KV/Mongo via `fake-mongo` + a tiny mock CF REST stub.
6. **Dry-run pass first**: `--dry-run` connects, lists modules, prints what WOULD copy without writing.
7. **Real run**: dual-write deployed (Phase 04 done) → run `npm run backfill:kv` → `npm run backfill:d1` → `npm run verify:mongo`.
## Todo List
- [ ] CF account ID + API token + KV namespace ID added to `.env.deploy`
- [ ] `scripts/backfill-kv-to-mongo.js` written (CF REST + Mongo SDK + cursor checkpoint + expiresAt propagation)
- [ ] `scripts/backfill-d1-to-mongo.js` written (legacy_id preserved)
- [ ] `scripts/verify-mongo-parity.js` written (full-scan when <10K, sqrt-sample otherwise)
- [ ] Helper unit tests pass
- [ ] Dry-run all three scripts
- [ ] Real run completed; verifier reports PASS for all 13 collections
- [ ] Mismatch report saved to `plans/260425-1945-mongodb-atlas-migration/backfill-report.md`
- [ ] **No `/__admin/*` routes added; no `ADMIN_TOKEN` secret created**
## Success Criteria
- `verify-mongo-parity.js` exits 0.
- All 12 KV modules + `trading_trades` show count parity within 1%.
- Hash compare: 0 mismatches on full-scan modules; mismatches on sampled modules explainable by live writes (re-run resolves).
- `expiresAt` propagated correctly from KV TTL metadata (verified ±5min bucket).
- `backfill-report.md` includes timestamps, durations, counts per module.
- **Zero new HTTP routes, zero new secrets** (architectural compliance).
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Backfill exhausts M0 throughput → user-facing latency spike | M | H | Throttle 50 ops/sec; run during low-traffic window (UTC 18:00 = local 01:00 VN). Phase 06 monitors. |
| CF API token leak via `.env.deploy` accidentally committed | L | H | `.env.deploy` is gitignored; `check-secret-leaks.js` (phase-01) covers MONGODB_URI but NOT CF tokens — extend lint to cover `CLOUDFLARE_API_TOKEN`. |
| Live write between backfill read+upsert overwrites newer state | L | M | `$setOnInsert` only (skip-if-exists). Newer dual-write data is preserved. |
| `KV.list()` REST cursor expires mid-run | L | M | CF cursors are durable; checkpoint last seen key to disk after each page (debugger #16). |
| TTL metadata propagation incorrect → expired data persists in Mongo | L | M | Verify script compares `expiresAt` ±5min bucket (debugger #10). |
| `trading_trades` SELECT * blows D1 query cap | L | L | Trading is small; verified via dry-run row count. |
| Verifier flags 0.9% mismatch as PASS but real corruption hides | L | M | Full-scan when <10K; sqrt-sample (capped 500) otherwise (code-reviewer #21). |
| Backfill OOM mid-stream loses progress | L | M | Cursor checkpoint per page (debugger #16). |
| CF REST rate limits during backfill | L | M | 1200 req/5min default for KV reads; throttle adequate. |
## Security Considerations
- **No admin HTTP surface added** — eliminates `ADMIN_TOKEN` rotation, route ordering risk, timing-leak concerns, log-leak concerns from prior plan.
- CF API token scope: KV READ + D1 READ only. Operator creates a scoped token; document in `docs/using-mongodb.md`.
- Backfill scripts run from operator's local machine; `.env.deploy` is gitignored.
- Extend `check-secret-leaks.js` (phase-01) to cover `CLOUDFLARE_API_TOKEN`.
## Rollback (this phase only)
1. Inserts to Mongo are reversible via `db.<collection>.deleteMany({})` (script: `scripts/wipe-mongo.js` — write as part of this phase, gated by interactive `read -p` confirm).
2. Delete `.backfill-cursor-*.json` files.
3. Revert script commits if rollback is permanent.
## Next Steps
- **Blocks:** Phase 06 (soak needs verified data parity).
- **Unblocks:** Phase 06.
@@ -0,0 +1,190 @@
# Phase 06 — Staged Deploy + Soak (Cold-Start Gate)
## Context Links
- [Driver report](../reports/researcher-260425-1924-mongodb-atlas-fit-and-driver.md) §"Cold-Start Cost" (~1500ms baseline)
- [Validation matrix](../reports/researcher-260425-1934-free-db-validation-matrix.md) — Atlas rejected on cold-start grounds; this phase validates user's bet
- [Brainstormer Findings #4, #8, #10](../reports/brainstormer-260425-2034-atlas-plan-critique.md)
- [Code-reviewer #10, #11](../reports/code-reviewer-260425-2034-atlas-plan-correctness.md)
- [Debugger GAP-A, GAP-B, #2, #13, Q4](../reports/debugger-260425-2034-atlas-plan-failure-modes.md)
- Phase 01 — `BASELINE_COLD_PING_MS` recorded; this phase derives the abort gate from it
- `src/bot.js` `getBot()` — memoization model (warm path comparison)
## Overview
- **Priority:** P0 (DECISION GATE)
- **Status:** pending
- **Description:** Deploy with dual-write live, observe cold-start latency for 24h (extend to 72h only on stop-condition), decide whether to cutover or pivot to Upstash. **This phase contains the hard abort criteria.** Telemetry deployed in Phase 04 is exercised here; no new code paths added except a synthetic burst test pre-deploy.
## Key Insights
- Dual-write means latency = `max(KV, Mongo)` per write. KV ~5ms; Mongo cold ~1500ms; Mongo warm ~50ms. Net effect: write p99 jumps to ~1500ms during cold isolates.
- CF Workers spawn new isolates on traffic patterns nobody fully predicts. Cannot guarantee warmth.
- User experience proxy: time from `/wordle` keystroke to bot reply. Composed of: Telegram→CF (~50–200ms) + Worker boot + Mongo read + handler logic + reply. **Mongo cold-start is the dominant chunk.**
- M0 connection cap = 500. Each cold isolate opens a new client. Pre-deploy synthetic burst test (debugger #2) verifies headroom before live traffic.
- **Abort gate is derived from Phase-01 baseline** (brainstormer #4): `2.5 × P95(BASELINE_COLD_PING_MS)`. Not asserted at 3000ms.
- **Soak duration = 24h default** (brainstormer #8). Extend to 72h ONLY when stop-condition triggers (see Success/Abort).
- **Automated alerts** (debugger GAP-B): Atlas free-tier email alert + CF Observability rule. 5-min config, zero code.
- **e2e test already lands in Phase 04** (brainstormer #10) — dual-write code is end-to-end validated before this phase deploys.
## Requirements
### Functional
- Deploy at start of low-traffic window (UTC 18:00 / local 01:00 VN).
- Live config: `STORAGE_PRIMARY=kv`, `DUAL_WRITE=1`, `MONGODB_URI` set.
- Telemetry collected (instrumentation lands in Phase 04 or earliest deploy of dual-write — NOT in this phase): per-request timing for `/wordle`, `/loldle`, `/trading`. Bucketed cold (first request in isolate) vs warm.
- Telemetry source: `console.log({ts, cmd, isolate_age_ms, mongo_op_ms, total_ms, cold})` parsed from CF Logs.
- **Soak window:** default 24h (covers daily cron cycle). Extend to 72h only if {cold-start P95 between 2500ms and the derived gate, error rate >0.5%, traffic <50 req/24h}. (brainstormer #8)
- Daily verifier run via `npm run verify:mongo` (manual invocation, captured to `soak-day-N.md`).
- **Synthetic burst test pre-deploy** (debugger #2): hit Worker with 20 parallel cold requests, observe Atlas connection counter, abort if >60% of 500 cap.
- **Automated alerts** (debugger GAP-B):
- Atlas free-tier email alert: cluster unavailability + connections > 400 (already configured in Phase 01 step 4 — verify still active).
- CF Observability rule: >10 errors in 1 minute → email.
### Non-functional
- No new code paths added in this phase. Telemetry helper (`src/util/timing.js`) lands with Phase 04 dual-write deploy so from-first-request data exists (debugger #3).
- Dashboards: CF Observability built-in views are sufficient; no external service.
- Telemetry overhead: `console.log` per request — already captured by CF Observability sampling.
## Architecture
### Telemetry helper (lands in Phase 04; SHOWN here for reference — verify against snippet bug, code-reviewer #10)
```js
// src/util/timing.js
export function startTiming(env, cmd) {
const t0 = Date.now();
const marks = []; // <-- declared at top (was missing; copy-paste-into-prod risk)
return {
mark(label) { marks.push({ label, dt: Date.now() - t0 }); },
end(extra = {}) {
const total = Date.now() - t0;
console.log(JSON.stringify({ event: "cmd_timing", cmd, total, ...extra, marks }));
}
};
}
```
### Cold-start tracking (code-reviewer #11)
**Use a module-scoped boolean — NOT `isolate_age_ms < 200ms` (which always misses real cold paths because Mongo connect is 1500ms).** `isolate_age_ms` remains useful as a histogram metric.
```js
// src/index.js — sketch
let isFirstRequestInIsolate = true;
const ISOLATE_BORN = Date.now();
// inside dispatcher:
const isCold = isFirstRequestInIsolate;
isFirstRequestInIsolate = false;
const isolate_age_ms = Date.now() - ISOLATE_BORN; // metric, not classifier
console.log({ event: "request", cmd, cold: isCold, isolate_age_ms, total_ms });
```
### Soak data flow
```mermaid
flowchart LR
Users --> CF[CF Worker handler]
CF -->|console.log| Obs[CF Observability]
CF -->|writes| K[(KV)]
CF -->|writes| M[(Mongo)]
Obs -->|export| CSV[soak-export.csv]
CSV --> Analyzer[scripts/analyze-soak.js]
Analyzer --> Report[soak-report.md]
Atlas[Atlas] -->|email alert| Operator
Obs -->|alert rule| Operator
```
## Related Code Files
### CREATE
- `/config/workspace/tiennm99/miti99bot/scripts/analyze-soak.js` — parse CF logs export → p50/p95/p99 per cmd × cold/warm
### MODIFY
- (telemetry helpers + ISOLATE_BORN landed in Phase 04 — no source-code edits in Phase 06)
### DELETE
- (none)
## Implementation Steps
1. Verify Phase 04 telemetry is live in current deploy. Tail logs for 5 min, confirm `cmd_timing` events emitted with `cold` boolean.
2. **Synthetic burst test** (debugger #2): from a local node script or a single test session, fire 20 parallel `/wordle` requests with cache-busting tokens against the deployed Worker. Observe Atlas connection counter in Atlas UI for the next 60s. **Abort if peak > 300 (60% of 500 cap).**
3. Verify CF Observability alert rule active: ">10 errors in 1 min → email" (debugger GAP-B). Verify Atlas email alert (cluster unavailable + connections > 400) active.
4. **Hour 0–1**: synthetic load test. Trigger 50 cold-start cycles (10+ min spacing OR `wrangler tail` confirming fresh isolate). Record p50/p95/p99 cold-start latency.
5. **Hour 1–24**: passive soak. Observe real traffic. Daily snapshot: `npm run verify:mongo` → save report.
6. **Hour 24**: run `scripts/analyze-soak.js`. Pull 24h CF Logs export. Compute:
- cold-start P50, P95, P99 per command
- warm P50, P95, P99
- dual-write divergence count (search log for `dual-write:secondary:failed`)
- Mongo connection error count
- **CPU-time errors** (debugger GAP-A) — search for "Worker exceeded CPU time" in CF logs.
7. Decision gate (see Success / Abort below).
8. **Cron behavior note** (debugger Q4): during dual-write window, cron handlers receive whichever store the env flag selects. Cron writes during `STORAGE_PRIMARY=kv` go to KV; Mongo's `trading_trades` won't have retention-enforced trims until cutover. Document in `soak-decision.md`. Manual cleanup post-cutover: run `trading-retention` once with Mongo as primary to trim Mongo-side accumulated rows.
9. If proceeding: continue soak ONLY IF stop-condition triggers extension to 72h, then go to Phase 07.
10. If aborting: see Pivot Path (now linked to phase-07-alt-pivot.md).
## Todo List
- [ ] Phase 04 telemetry confirmed live (cmd_timing events with `cold` boolean)
- [ ] Phase 04 e2e storage-roundtrip test passing on current build
- [ ] **Synthetic burst test** completed; Atlas connection peak ≤ 300/500
- [ ] CF Observability alert rule (>10 errors / min → email) active
- [ ] Atlas email alert (unavailable + connections > 400) verified
- [ ] Hour-1 synthetic cold-start measurement complete
- [ ] Hour-24 passive soak analyzed
- [ ] `scripts/analyze-soak.js` produced p50/p95/p99 buckets
- [ ] No CPU-time errors in CF logs (debugger GAP-A)
- [ ] Daily `verify:mongo` runs (≥1 in 24h soak; ≥3 if extended to 72h) all PASS
- [ ] Decision recorded in `soak-decision.md`: PROCEED or ABORT (24h or 72h reason cited)
- [ ] If ABORT: execute [phase-07-alt-pivot.md](phase-07-alt-pivot.md)
## Success Criteria (PROCEED to Phase 07)
- Cold-start P95 for `/wordle` and `/loldle` ≤ **`2.5 × BASELINE_COLD_PING_MS`** (derived from Phase-01 step 13; e.g., baseline 1500ms → gate 3750ms).
- Warm P95 ≤ 500ms.
- Dual-write secondary failure rate < 0.1% over 24h.
- M0 connection peak ≤ 400 of 500 cap.
- Zero CPU-time exceeded errors in CF logs.
- 1 (24h) or 3 (72h) consecutive `verify:mongo` runs PASS with 0 mismatches.
- No M0 auto-pause incidents.
### Soak duration rule
- **Default 24h** (covers daily cron cycle).
- **Extend to 72h ONLY IF** any of: cold-start P95 between 2500ms and the derived gate, error rate >0.5%, traffic <50 req/24h. (brainstormer #8)
- Document the chosen duration + reason in `soak-decision.md`.
## Abort Criteria (PIVOT to Upstash, do NOT retry)
Any ONE of:
- Cold-start P95 > derived gate over the soak window.
- Dual-write divergence rate > 1% sustained for >1h.
- Connection saturation event (>400/500 connections seen).
- M0 auto-pause occurs unexpectedly during soak.
- Atlas outage > 5min during soak.
- Verifier reports >0.5% data drift on any module.
- **Worker CPU-time exceeded errors** observed (debugger GAP-A).
### Pivot Path (if aborted)
1. Set `STORAGE_PRIMARY=kv`, `DUAL_WRITE=0` → redeploy. Bot returns to KV/D1 only.
2. Leave Mongo cluster + collections in place for forensic analysis (24h, then take a `mongoexport` snapshot).
3. Execute [phase-07-alt-pivot.md](phase-07-alt-pivot.md) — pre-written Upstash skeleton.
4. Decommission Atlas only after Upstash live and stable for 7 days.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Cold-start > derived gate under real traffic | M (research says probable) | H | Pivot path documented above + pre-written. No retries on Atlas; the limit is architectural. |
| Dual-write doubles end-to-end p99 even when warm | H | M | `Promise.allSettled` parallelism keeps warm penalty small (~50ms). p99 dominated by Mongo cold; expected. |
| CF Observability log volume exceeds 200k events/day cap | L | M | Sampling already at 1.0 in `wrangler.toml`; can drop to 0.1 if hit. |
| Soak window misses peak traffic (weekend pattern) | M | M | Stop-condition extends to 72h when traffic <50 req/24h or borderline cold-start. |
| Operator misreads verifier "PASS within 1%" as exact match | L | M | analyze-soak.js prints exact diff numbers. |
| Mongo cluster wakes mid-soak triggering 30s+ delay | L | H | Detect via timing logs + Atlas alert; treat as auto-pause incident → ABORT. |
| Instrumentation itself adds latency | L | L | `console.log` in CF is async + cheap; <1ms. |
| `analyze-soak.js` parser breaks on log format changes | L | L | Pin format; tests on synthetic CSV. |
| Burst test triggers M0 cap before live deploy | M | M | Step 2 abort condition prevents proceeding if peak > 300/500. |
| Cron-during-soak writes go to wrong primary | M | L | Documented in step 8; manual cleanup post-cutover. |
## Security Considerations
- Telemetry logs: never log message content, user_id raw, command arguments. Only `cmd` (e.g. `/wordle`), durations, isolate age, cold flag.
- `analyze-soak.js` runs locally; CSV export has no secrets but may contain user IDs — handle as PII.
## Rollback (this phase only)
- Set `DUAL_WRITE=0` → redeploy. Mongo writes stop. Telemetry remains (cheap, useful diagnostic).
- Telemetry can be disabled by deleting `startTiming` calls; not required.
## Next Steps
- **Blocks:** Phase 07 cutover.
- **Unblocks:** Phase 07 (if PROCEED) OR phase-07-alt-pivot (if ABORT).
- **Decision artifact:** `plans/260425-1945-mongodb-atlas-migration/soak-decision.md` (created during this phase).
@@ -0,0 +1,105 @@
# Phase 07-ALT — Pivot to Upstash Redis (STANDBY)
## Context Links
- [Free DB validation matrix](../reports/researcher-260425-1934-free-db-validation-matrix.md) §"Final Recommendation" + §"Next Steps (If Upstash Recommended)"
- [Brainstormer Finding #4](../reports/brainstormer-260425-2034-atlas-plan-critique.md) — phantom abort path
- [Debugger GAP-D / QW-6, #23](../reports/debugger-260425-2034-atlas-plan-failure-modes.md) — pre-write Upstash plan
- Phase 06 — abort criteria
## Overview
- **Priority:** STANDBY (execute only if Phase 06 ABORT)
- **Status:** standby
- **Description:** Skeleton plan to pivot from Atlas → Upstash Redis when the cold-start gate trips. Eliminates "draft new plan under outage pressure" by pre-writing the steps. Estimated ~3-4 days net (smaller than Atlas migration because Upstash is HTTP-native and dual-write doesn't have cold-start amplification).
## Trigger
Execute when ANY Phase-06 abort criterion fires:
- Cold-start P95 > derived gate over the soak window.
- Dual-write divergence > 1% sustained for >1h.
- M0 connection saturation events (>400/500).
- M0 auto-pause occurs unexpectedly during soak.
- Atlas outage > 5 min during soak.
- Verifier reports >0.5% data drift.
- Worker CPU-time exceeded errors observed.
## Key Insights
- Upstash Redis is **HTTP-native** (REST API + `@upstash/redis` SDK). No TLS+SCRAM cold-start cost — first request is sub-100ms.
- No long-lived connection pool to manage; no `serverSelectionTimeoutMS` hangs; no auto-pause behavior.
- Free tier: 10K commands/day, 256MB storage. Sufficient for KV scope (~615KB total data).
- Trading (D1) STAYS — Upstash is KV-only; Mongo trading work is dropped or reverted.
## Approach (5 steps)
### Step 1 — Provision + secrets (30 min)
1. Sign up at https://upstash.com (free).
2. Create Redis database in nearest region (likely `us-east-1` or `eu-west-1` since Upstash free tier doesn't offer SEA).
3. Copy `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN`.
4. `wrangler secret put UPSTASH_REDIS_REST_URL`
5. `wrangler secret put UPSTASH_REDIS_REST_TOKEN`
6. Mirror in `.env.deploy`.
### Step 2 — UpstashKVStore (~60 LOC; 1-2h)
- New file `src/db/upstash-kv-store.js`. Wraps `Redis.fromEnv()` to satisfy the `KVStore` interface.
- Methods: `get/put/delete/list/getJSON/putJSON`.
- TTL via `EX` flag on `SET`. List via `SCAN` with `MATCH prefix:*`.
- `npm install @upstash/redis`. Verify bundle size remains under CF Workers cap (~50KB; very small).
### Step 3 — Fake + tests (1h)
- New `tests/fakes/fake-upstash.js` — Map-backed; satisfy the surface used (`set/get/del/scan/expire`).
- Mirror Phase 02 test scaffolding for `UpstashKVStore`.
- Reuse the e2e storage-roundtrip test pattern (Phase 04) for KV path only (trading stays D1).
### Step 4 — Factory wiring (30 min)
- Edit `src/db/create-store.js`: add `STORAGE_PRIMARY=upstash` branch.
- Drop or revert MongoKVStore branches (depending on what shipped).
- Trading: `src/db/create-sql-store.js` returns CFSqlStore (D1) only. Revert phase-03 MongoTradesStore work in `src/modules/trading/*.js` IF shipped, OR drop unshipped.
### Step 5 — Re-run dual-write + cutover (1-2 days)
- Dual-write phase: KV (primary) + Upstash (secondary). Same `DualKVStore` from Phase 04 (parameterized — secondary is now UpstashKVStore).
- Backfill from KV → Upstash using the same local-node script pattern as Phase 05 (now writes to Upstash via REST).
- Soak 24h (no cold-start risk on Upstash; Phase 06's gate is irrelevant here).
- Cutover: `STORAGE_PRIMARY=upstash` → `DUAL_WRITE=0` after 24h overlap → delete CF KV namespace via guarded script (Phase 07 step 19 pattern).
## Drop / revert from Atlas plan
### If Phase 02–04 SHIPPED but Phase 07 cutover NOT executed (typical abort timing)
- Revert: `src/db/mongo-*.js`, `src/db/dual-*.js` (or re-parameterize for Upstash), `src/cron/drift-verifier.js`.
- Revert: `src/modules/trading/*` (if Mongo refactor landed) — restore D1-only path.
- Revert: `wrangler.toml` Mongo config + `STORAGE_PRIMARY` flag enum (now includes `upstash`).
- Keep: `scripts/check-secret-leaks.js` (general secret hygiene).
- Mongo cluster: leave running 7 days for forensic analysis, then `mongoexport` snapshot + delete.
### Mark plan.md updates
- Change `STORAGE_PRIMARY` enum to include `"upstash"`.
- Mark Atlas-related files for deletion in Phase 07-ALT cutover.
- Add cross-link from plan.md "Abort criteria" section.
## Estimated Effort
- Step 1: 30 min
- Step 2: 1-2h
- Step 3: 1h
- Step 4: 30 min
- Step 5: 1-2 days (mostly soak)
- **Total: ~3-4 days net**
## Success Criteria
- `UpstashKVStore` passes `KVStore` interface contract tests.
- Backfill verifier reports PASS for all 12 KV modules.
- Soak shows no error spikes; cold-start latency < 200ms (Upstash HTTP fundamental).
- Atlas cluster decommissioned after 7-day forensic window.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Upstash region latency higher than Atlas SEA | M | L | Free-tier regions are US/EU — adds ~150ms vs SEA-routed Atlas, BUT HTTP avoids cold TLS+SCRAM, so net cold path is still faster. |
| `@upstash/redis` bundle pushes Worker over size cap | L | H | Bundle is ~50KB; far smaller than mongodb (~4-5 MB). Verify via `wrangler deploy --dry-run` (same gate pattern as Phase 01). |
| Free-tier 10K commands/day cap hit | L | M | Audit current ops/day before pivot; if borderline, escalate to paid ($0.20/100K). |
| Operator panics mid-pivot, mixes Mongo + Upstash code | M | H | This file IS the pre-written runbook (debugger QW-6); follow steps 1–5 in order. |
## Rollback
- Same as Phase 06 pivot rollback: `STORAGE_PRIMARY=kv`, `DUAL_WRITE=0`. KV remains authoritative until Upstash backfill verified.
## Next Steps (when triggered)
- Inform user: cold-start gate failed; executing Upstash pivot.
- Begin Step 1.
- Update plan.md status: `Atlas migration: ABORTED on YYYY-MM-DD; Upstash pivot in progress.`
@@ -0,0 +1,239 @@
# Phase 07 — Cutover + Decommission
## Context Links
- [Schema report](../reports/researcher-260425-1924-mongodb-schema-and-migration.md) §5 Phase 5–6 (cutover + decommission)
- [Code-reviewer #4, #9, #12](../reports/code-reviewer-260425-2034-atlas-plan-correctness.md)
- [Debugger #12, #14](../reports/debugger-260425-2034-atlas-plan-failure-modes.md)
- `wrangler.toml:14-22` — KV + D1 bindings to remove
- Phase 04 — flag matrix (this phase flips primary)
- Phase 06 — soak decision must be PROCEED before this phase starts
- Phase 07-ALT — fallback if soak ABORTed
## Overview
- **Priority:** P0 (irreversible after step 18)
- **Status:** pending (blocked on Phase 06 PROCEED)
- **Description:** Flip read primary to Mongo, soak Mongo-primary, then disable secondary writes, then delete CF KV + D1 bindings + dependent code. **First reversible step** (flag flip) and **first irreversible step** (binding deletion) clearly demarcated.
## Key Insights
- Cutover is a **two-stage** flip:
1. **Read flip** (reversible): `STORAGE_PRIMARY=mongo`, `DUAL_WRITE=1`. Reads now from Mongo, writes still to both. **Rollback = flip flag back.**
2. **Write decommission**: `DUAL_WRITE=0` AFTER 24h Stage-2 dual-write overlap (code-reviewer #9). Writes to Mongo only. KV/D1 frozen.
3. **Binding deletion** (irreversible): `wrangler kv namespace delete`, `wrangler d1 delete`. Data gone.
- **Dual-write overlap** (code-reviewer #9): keep `DUAL_WRITE=1` for the FIRST 24h of Stage 2; only flip to `DUAL_WRITE=0` after 24h of Mongo-primary stability. Reduces single-region M0 data-loss exposure.
- **Atlas snapshot at start AND end of Stage 2** (code-reviewer #9): `wrangler d1 export miti99bot-db --remote --output=...` once at Stage-2 start (KV/D1 baseline) and `mongoexport` at end. Local-disk checkpoints, not committed.
- 7-day cooldown between each stage. Catch slow-burn issues.
- `register.js` stub setup from Phase 04 already handles Mongo absence — no changes needed at deploy time.
- Trading module D1 tables are gone post-decommission. The D1 binding in `wrangler.toml` must be removed; `createSqlStore` already returns null when `env.DB` absent — safe.
- **`package.json` deploy chain** (code-reviewer #12): MUST remove `&& npm run db:migrate` from `deploy` script AND remove the `db:migrate` script entry. Otherwise next deploy ENOENTs on `scripts/migrate.js`.
- **Reverse-backfill scripts pre-built** (code-reviewer #4): `scripts/backfill-mongo-to-kv.js` + `scripts/backfill-mongo-to-d1.js` written BEFORE Stage 2, tested against fakes. Otherwise rollback under outage pressure → guaranteed data loss.
- **Stage-2 rollback = N-day game state loss** (debugger #14): explicit user-comm note. Operator informs users before executing rollback.
- **Irreversible-step guard** (debugger #12): wrap `wrangler kv namespace delete` + `wrangler d1 delete` in a tiny shell function requiring typed `CONFIRM`. Document: never run in CI.
## Requirements
### Functional
- **Stage 1 (Read flip)**: deploy with `STORAGE_PRIMARY=mongo`, `DUAL_WRITE=1`. Soak 7 days. Daily `verify:mongo` runs. Reverse-backfill scripts pre-built before this stage.
- **Stage 2 (Stop dual-write)**:
- Stage-2 day 0: take D1 export snapshot + run `mongoexport` snapshot (start baseline).
- Stage-2 day 0–1: deploy with `DUAL_WRITE=1` still on (24h overlap; code-reviewer #9).
- Stage-2 day 1: set `DUAL_WRITE=0`. Deploy. Soak remaining 6 days.
- Stage-2 day 7: `mongoexport` end snapshot.
- **Stage 3 (Final cleanup, irreversible)**:
- Verify both Stage-2 snapshots committed to local-disk backups (no commit).
- Delete dependent code: `cf-kv-store.js`, `cf-sql-store.js`, `dual-kv-store.js`, `dual-sql-store.js`, drift-verifier cron.
- Refactor tests off `fake-d1.js`; delete `fake-d1.js`.
- Remove KV + D1 bindings from `wrangler.toml`.
- **Edit `package.json`** (code-reviewer #12): remove `&& npm run db:migrate` from `deploy` chain; remove `db:migrate` script entry.
- Delete `scripts/migrate.js`.
- Wrap KV/D1 namespace deletes in confirm-guard (debugger #12).
- `wrangler kv namespace delete --namespace-id <id>`.
- `wrangler d1 delete miti99bot-db`.
- Update `src/db/create-store.js` to remove KV branch. Become Mongo-only (no flag).
- Update `src/db/create-sql-store.js` similarly.
### Non-functional
- Each stage has a single-line summary in `cutover-log.md`.
- Backup files committed only as references in plan dir (NOT raw data — privacy).
- Bindings removed from `wrangler.toml` in same commit that removes the dependent code.
## Architecture
```mermaid
gantt
title Cutover Sequence
dateFormat YYYY-MM-DD
section Stage 1
Read flip (STORAGE_PRIMARY=mongo) :s1, 2026-04-30, 7d
section Stage 2
Snapshots + DUAL_WRITE overlap (24h) :s2a, after s1, 1d
Stop dual-write (DUAL_WRITE=0) :s2b, after s2a, 6d
Stage 2 end snapshot :s2c, after s2b, 1d
section Stage 3
Verify backups + remove code :s3a, after s2c, 1d
Delete bindings + KV/D1 namespaces :s3b, after s3a, 1d
```
```mermaid
flowchart TD
A[Phase 06 PROCEED] --> B[Stage 1: read flip]
B -->|7-day soak| C{verifier PASS?}
C -->|no| R1[Rollback: STORAGE_PRIMARY=kv]
C -->|yes| D[Stage 2: 24h overlap]
D --> E[Stage 2: stop dual-write]
E -->|6-day soak| F{Mongo errors?}
F -->|yes| R2[Rollback: re-enable DUAL_WRITE,<br/>flip primary back, RUN reverse-backfill,<br/>USER-COMM about N-day game state loss]
F -->|no| G[Stage 3: snapshots + code cleanup]
G --> H[Confirm-guarded namespace delete]
H --> Z[Done — Mongo-only]
```
## Related Code Files
### CREATE (BEFORE Stage 2 — code-reviewer #4)
- `/config/workspace/tiennm99/miti99bot/scripts/backfill-mongo-to-kv.js` — reverse-backfill script for emergency rollback.
- `/config/workspace/tiennm99/miti99bot/scripts/backfill-mongo-to-d1.js` — same for trading.
- `/config/workspace/tiennm99/miti99bot/scripts/wrangler-delete-guard.sh` — wraps `wrangler kv namespace delete` / `wrangler d1 delete` in `read -p CONFIRM` prompt (debugger #12).
- `/config/workspace/tiennm99/miti99bot/plans/260425-1945-mongodb-atlas-migration/cutover-log.md` — running log of stages, dates, observations.
### MODIFY
- `/config/workspace/tiennm99/miti99bot/wrangler.toml` — Stage 1: change `STORAGE_PRIMARY = "mongo"`. Stage 2 day 1: `DUAL_WRITE = "0"`. Stage 3: remove `[[kv_namespaces]]`, `[[d1_databases]]`, drop `STORAGE_PRIMARY`/`DUAL_WRITE`/`DRIFT_SAMPLE_N`/drift-verifier cron.
- `/config/workspace/tiennm99/miti99bot/src/db/create-store.js` — Stage 3: remove KV branch, simplify to Mongo-only (matches the post-cutover shape pre-designed in Phase 04).
- `/config/workspace/tiennm99/miti99bot/src/db/create-sql-store.js` — Stage 3: same.
- `/config/workspace/tiennm99/miti99bot/scripts/register.js` — Stage 3: replace `stubKv`/`stubMongo` shims with single Mongo stub OR skip Mongo entirely.
- `/config/workspace/tiennm99/miti99bot/scripts/stub-kv.js` — Stage 3: rename to `stub-bindings.js`, drop KV/AI-only stubs as appropriate.
- `/config/workspace/tiennm99/miti99bot/package.json` — **Stage 3 step 17: remove `&& npm run db:migrate` from `deploy` chain AND remove the `db:migrate` script entry** (code-reviewer #12).
### DELETE (Stage 3 only)
- `/config/workspace/tiennm99/miti99bot/src/db/cf-kv-store.js`
- `/config/workspace/tiennm99/miti99bot/src/db/cf-sql-store.js`
- `/config/workspace/tiennm99/miti99bot/src/db/dual-kv-store.js`
- `/config/workspace/tiennm99/miti99bot/src/db/dual-sql-store.js`
- `/config/workspace/tiennm99/miti99bot/src/cron/drift-verifier.js`
- `/config/workspace/tiennm99/miti99bot/scripts/backfill-kv-to-mongo.js`
- `/config/workspace/tiennm99/miti99bot/scripts/backfill-d1-to-mongo.js`
- `/config/workspace/tiennm99/miti99bot/scripts/verify-mongo-parity.js` (or move to `archive/`)
- `/config/workspace/tiennm99/miti99bot/scripts/analyze-soak.js`
- `/config/workspace/tiennm99/miti99bot/scripts/migrate.js`
- `/config/workspace/tiennm99/miti99bot/tests/fakes/fake-d1.js`
- `/config/workspace/tiennm99/miti99bot/tests/db/dual-kv-store.test.js`
- `/config/workspace/tiennm99/miti99bot/tests/db/dual-sql-store.test.js`
- `/config/workspace/tiennm99/miti99bot/src/modules/trading/migrations/` (D1 migrations no longer relevant)
- `/config/workspace/tiennm99/miti99bot/scripts/backfill-mongo-to-kv.js` (reverse-backfill no longer needed post-cutover)
- `/config/workspace/tiennm99/miti99bot/scripts/backfill-mongo-to-d1.js`
## Implementation Steps
### Pre-Stage 2 Prerequisites (CODE-REVIEWER #4)
0a. Pre-build `scripts/backfill-mongo-to-kv.js` (sketch: enumerate Mongo collection → for each `{_id, value, expiresAt}`, write back to CF KV via REST API + `expirationTtl`). Test against `fake-mongo` + a tiny REST-API mock.
0b. Pre-build `scripts/backfill-mongo-to-d1.js` (enumerate `trading_trades` → use `legacy_id` if present, else generate sequential int → emit SQL `INSERT INTO trading_trades` → pipe through `wrangler d1 execute --remote --file`). Test against fakes.
### Stage 1 — Read flip (reversible)
1. Verify Phase 06 PROCEED decision recorded.
2. Set `STORAGE_PRIMARY = "mongo"` in `wrangler.toml`.
3. `npm run deploy`.
4. Smoke: send `/wordle` from a test chat. Confirm reply.
5. Tail logs: confirm reads now hit Mongo (`mongo_op_ms` field populated).
6. Soak 7 days. Daily `verify:mongo`.
7. **Rollback**: revert flag, redeploy. KV still has writes (dual-write still on).
### Stage 2 — Snapshots + 24h overlap → stop dual-write (reversible until KV deleted)
8. **Stage-2 day 0**: take baseline snapshots (code-reviewer #9):
- `npx wrangler d1 export miti99bot-db --remote --output=./.backups/d1-stage2-start.sql`
- `mongoexport --uri "$MONGODB_URI" --collection trading_trades --out=./.backups/mongo-trades-stage2-start.json` (and per-KV-collection for completeness)
9. Deploy with `DUAL_WRITE=1` still on. Run 24h overlap window. Verify dual-write health (no new divergence).
10. **Stage-2 day 1**: set `DUAL_WRITE = "0"` in `wrangler.toml`. Deploy. Smoke. Soak 6 more days.
11. **Stage-2 day 7**: `mongoexport ... --out=./.backups/mongo-trades-stage2-end.json`.
12. **Rollback** (irreversible until KV deletion is the next stage; rollback = real work):
- Re-enable `DUAL_WRITE=1`, flip `STORAGE_PRIMARY=kv`. Redeploy.
- Run `node scripts/backfill-mongo-to-kv.js && node scripts/backfill-mongo-to-d1.js` to recover Mongo-only writes back into KV/D1.
- **Inform users that game state for the last N days is reverting** (debugger #14).
### Stage 3 — Backup + delete bindings (IRREVERSIBLE after step 18)
13. Verify Stage-2 backup files exist on operator local disk.
14. Refactor any test still using `fake-d1.js` to `fake-mongo.js`. Then delete `fake-d1.js`.
15. Simplify `create-store.js` and `create-sql-store.js` to Mongo-only (matches Phase-04 pre-designed shape). Delete dual-store files + drift-verifier cron + reverse-backfill scripts.
16. Delete `cf-kv-store.js` + `cf-sql-store.js`.
17. **Edit `package.json`** (code-reviewer #12):
- Remove `&& npm run db:migrate` from `deploy` script.
- Remove the `db:migrate` script entry.
- **Verify `npm run deploy` parses + runs to dry-run completion BEFORE step 18.**
18. Remove `[[kv_namespaces]]` + `[[d1_databases]]` from `wrangler.toml`. Remove `STORAGE_PRIMARY` + `DUAL_WRITE` + `DRIFT_SAMPLE_N` vars + drift-verifier cron entry.
19. **IRREVERSIBLE** (debugger #12; never run in CI):
```sh
bash scripts/wrangler-delete-guard.sh kv f7f190fcb2fa42eb84a05542911334b0
bash scripts/wrangler-delete-guard.sh d1 miti99bot-db
```
Each script prompts `Type CONFIRM to delete:` before invoking the destructive wrangler command.
20. Delete `scripts/migrate.js`, `scripts/backfill-*.js`, `scripts/verify-mongo-parity.js`, `scripts/analyze-soak.js`, `scripts/wrangler-delete-guard.sh`, trading D1 migrations folder.
21. `npm run lint`, `npm test`, `npm run deploy`. All green.
22. Update `cutover-log.md` with completion timestamp.
## Todo List
- [ ] **Pre-Stage 2: `scripts/backfill-mongo-to-kv.js` + `scripts/backfill-mongo-to-d1.js` written + tested against fakes**
- [ ] Stage-1 deployed (`STORAGE_PRIMARY=mongo`)
- [ ] Stage-1 soak 7 days, verifier PASS daily
- [ ] **Stage-2 day-0 baseline snapshots** (D1 export + mongoexport)
- [ ] Stage-2 24h dual-write overlap completed
- [ ] Stage-2 `DUAL_WRITE=0` deployed
- [ ] Stage-2 6-day soak, no Mongo errors
- [ ] **Stage-2 end-of-window mongoexport** snapshot
- [ ] Stage-3 backups verified on local disk
- [ ] Tests refactored off `fake-d1`
- [ ] `create-store.js` simplified
- [ ] `create-sql-store.js` simplified
- [ ] Dual stores + drift-verifier + reverse-backfill scripts deleted
- [ ] CFKVStore + CFSqlStore deleted
- [ ] **`package.json` `deploy` chain edited (no `db:migrate`); `db:migrate` script removed; dry-run deploy passes**
- [ ] `wrangler.toml` bindings removed
- [ ] `scripts/wrangler-delete-guard.sh` written + manually verified to prompt
- [ ] KV namespace deleted via guarded script
- [ ] D1 database deleted via guarded script
- [ ] `migrate.js` deleted
- [ ] Final deploy passes; bot operational on Mongo-only
## Success Criteria
- Bot serves all 13 modules from Mongo only. No KV or D1 reads anywhere in code.
- `wrangler.toml` has no KV or D1 bindings.
- `package.json` `deploy` script no longer references `db:migrate`.
- `npm test` passes with no `fake-d1` references.
- Mongo collections exhibit expected sizes (compared against pre-cutover backups).
- `cutover-log.md` documents each stage with timestamp + verifier result.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Stage 1 flip surfaces hidden read-path bug not caught in dual-write | M | H | Smoke test in 5 commands first; tail logs for 1h before declaring stable. Rollback flag. |
| Stage 2 surfaces write-path bug (KV was masking Mongo write failure) | M | H | 24h overlap window catches early. Phase 04 logged secondary failures; review log diff. |
| Single-region M0 data loss during Stage 2 soak | L | H | Snapshots at start AND end of Stage 2 (code-reviewer #9). 24h overlap reduces single-source window. |
| Operator forgets to back up before binding delete | L | CRITICAL | Step 13 gates step 19; require Stage-2 snapshot files on disk. |
| Wrangler CLI namespace-delete accidentally runs in CI | L | CATASTROPHIC | `scripts/wrangler-delete-guard.sh` requires typed CONFIRM (debugger #12). Documented: never in CI. |
| Tests still importing `fake-d1` break after delete | L | M | Step 14 grep `fake-d1` and refactor before step 19. |
| Mongo collection accidentally dropped during cleanup | L | CRITICAL | No `db.dropCollection` in any script. Operator-only commands. |
| Atlas auto-pause triggers post-cutover during low traffic | L | M | Bot has 6+ daily crons that write Mongo post-cutover. Phase 08 verifies. |
| `register.js` breaks because stubKv removed | L | M | Step 15 includes register stub refactor; `npm run register:dry` is a release gate. |
| `npm run deploy` breaks on `db:migrate` ENOENT | M | H | **Step 17 fixes package.json BEFORE step 19 namespace delete** (code-reviewer #12). |
| Stage-2 rollback reverses N days of Mongo-only writes | guaranteed if rollback | H | Reverse-backfill scripts pre-built; user-comm note required (debugger #14). |
## Security Considerations
- Backup files contain user IDs — store on encrypted disk; do NOT commit.
- After binding deletion, the only path to historical data is the local backup file.
- Connection string + DB user remain; rotate password at Stage 3 step 21 to invalidate any leaked-during-migration creds.
## Rollback
| Stage | Reversible? | Action |
|-------|-------------|--------|
| Stage 1 | YES | Flip `STORAGE_PRIMARY=kv`, redeploy. Latency: <2 min. |
| Stage 2 day 0–1 (overlap) | YES | Flip `STORAGE_PRIMARY=kv` while DUAL_WRITE still on. KV is fresh. |
| Stage 2 day 1+ (DUAL_WRITE=0) | YES (with stale KV) | Re-enable `DUAL_WRITE=1` + flip primary. **Run reverse-backfill scripts** to recover Mongo-only writes. **User-comm: N-day game state revert.** |
| Stage 3 step 13–18 | YES | Revert commits, redeploy. Bindings still exist. |
| Stage 3 step 19+ | NO | Restore from local backup files into a fresh KV namespace. Manual operator process; expect 4–8h. |
## "If aborting" — link to alternative
If at any stage the operator decides to abandon the migration (cold-start regression discovered, M0 limits hit, etc.), execute [phase-07-alt-pivot.md](phase-07-alt-pivot.md). Reverse-backfill scripts (built in pre-Stage 2) are reusable for the abort path.
## Next Steps
- **Blocks:** Phase 08 (final tests + docs).
- **Unblocks:** Phase 08.
- **Post-completion:** Atlas cluster monitoring becomes routine. Cost guardrail doc due in Phase 08.
@@ -0,0 +1,179 @@
# Phase 08 — Tests + Docs (Trimmed)
## Context Links
- All phases 01–07
- [Brainstormer #9, #10](../reports/brainstormer-260425-2034-atlas-plan-critique.md)
- `docs/architecture.md` § 8 "Swapping the backends"
- `tests/fakes/` — fakes inventory
- `CLAUDE.md` (root) — project conventions to mirror in new docs
- `docs/using-d1.md` — pattern for the new `using-mongodb.md` (Phase 01 began it; this phase finalizes)
## Overview
- **Priority:** P1
- **Status:** pending
- **Description:** Doc-only finalization. Most tests + secret-leak lint + e2e roundtrip already landed in earlier phases (01, 04). This phase verifies CI integration and updates documentation.
## Key Insights
- All unit tests landed alongside their phase (Phases 02, 03, 04, 05).
- **e2e test landed in Phase 04** (brainstormer #10), not here.
- **Secret-leak lint landed in Phase 01** (brainstormer #10), not here. Phase 08 verifies CI runs it + existing files are clean.
- **M0 auto-pause cron heartbeat is NOT an unresolved question** (brainstormer #9): bot has 6+ daily crons; post-cutover any cron that writes data prevents pause. Phase 08 confirms.
- The single biggest doc owe: `docs/using-mongodb.md` — runbook covering provisioning, connection, troubleshooting, auto-pause, password rotation, M0→Flex upgrade. Started in Phase 01; finalized here.
- Cost guardrail: M0 is free, but plan for the day it isn't. Stub doc explaining when to upgrade.
- Test fakes: `fake-mongo.js` may have grown; freeze the surface and document.
## Requirements
### Functional
- **Verify** secret-leak lint runs in CI (`npm run lint` chain) and existing files are clean. (Lint introduced in Phase 01.)
- **Verify** e2e storage-roundtrip test (Phase 04) passes in current CI build.
- **Verify** daily crons write to Mongo post-cutover (brainstormer #9 — replaces unresolved Q). If `misc` daily cron is read-only, add a single `db.runCommand({ping:1})` line to it.
- Doc updates:
- `docs/using-mongodb.md` — finalized runbook (started Phase 01).
- `docs/architecture.md` — § 8 updated to describe Mongo-only state, removal of CF KV/D1.
- `docs/code-standards.md` — add note: "All persistence goes through `MongoKVStore` / `MongoTradesStore`. Modules NEVER touch the Mongo client directly."
- `docs/cost-tracking.md` (new, stub) — when M0 fills, what triggers upgrade, expected cost ladder (M0 → Flex → M10).
- `docs/development-roadmap.md` — **verify migration is NOT listed** (per user feedback memory: roadmap = future-only). Remove if present.
- `docs/project-changelog.md` — append migration entry.
- `README.md` — update database section.
- `CLAUDE.md` (root) — update Architecture section: replace KV/D1 references with Mongo.
- Append "Alternatives considered" section to plan.md (already done at plan creation; verify still accurate).
### Non-functional
- New docs ≤200 lines each.
- Tests use existing fakes — no new mocks created in this phase.
- Run final `npm run lint && npm test && npm run register:dry` before sign-off.
## Architecture
```mermaid
flowchart TD
Tests[tests/]
Tests --> U1[unit: mongo-kv-store.test.js — Phase 02]
Tests --> U2[unit: mongo-trades-store.test.js — Phase 03]
Tests --> U3[unit: mongo-sql-store.test.js — Phase 03]
Tests --> U4[unit: dual-*.test.js — DELETED in Phase 07]
Tests --> E[e2e: storage-roundtrip.test.js — Phase 04, persists to Mongo-only post-Phase-07]
Tests --> Mod[module unit tests — unchanged, use fake-mongo]
Docs[docs/]
Docs --> D1[architecture.md — UPDATED]
Docs --> D2[using-mongodb.md — finalized]
Docs --> D3[cost-tracking.md — NEW stub]
Docs --> D4[code-standards.md — UPDATED]
Docs --> D5[project-changelog.md — APPENDED]
```
## Related Code Files
### CREATE
- `/config/workspace/tiennm99/miti99bot/docs/cost-tracking.md` (stub)
### MODIFY
- `/config/workspace/tiennm99/miti99bot/docs/architecture.md` — § 8 + any KV/D1 reference
- `/config/workspace/tiennm99/miti99bot/docs/using-mongodb.md` — finalize from Phase-01 partial
- `/config/workspace/tiennm99/miti99bot/docs/code-standards.md`
- `/config/workspace/tiennm99/miti99bot/docs/project-changelog.md` (append)
- `/config/workspace/tiennm99/miti99bot/docs/development-roadmap.md` (verify migration NOT listed; remove if present)
- `/config/workspace/tiennm99/miti99bot/README.md`
- `/config/workspace/tiennm99/miti99bot/CLAUDE.md` (root) — Architecture section
- `/config/workspace/tiennm99/miti99bot/tests/fakes/fake-mongo.js` — comment block documenting frozen surface
- `/config/workspace/tiennm99/miti99bot/src/modules/misc/index.js` — IF the daily cron does not already write data, add a `db.runCommand({ping:1})` line (brainstormer #9 — replaces unresolved Q)
### DELETE
- (none new in this phase; Phase 07 already removed all CF-specific code)
## Implementation Steps
1. Grep all `docs/` and `README.md` for `KV`, `Cloudflare KV`, `D1`, `CFKVStore`, `CFSqlStore`. Build edit list.
2. Update `docs/architecture.md` § 8: replace "Swap CFKVStore → other backend" narrative with "Mongo is the backend; swap target is `mongo-kv-store.js`". Update §10 unchanged (no admin HTTP surface — Phase 05 complies).
3. Finalize `docs/using-mongodb.md` (started Phase 01):
- Provisioning steps (Atlas UI walkthrough)
- Connection string format
- Auto-pause behavior + how to wake (just send a request)
- Password rotation procedure (90 days, owner = repo maintainer)
- Failure modes: connection error, SCRAM auth fail, SRV resolution fail, server-selection timeout (paused M0)
- Node-API surface inventory (from Phase 01 step 9)
- Atlas alert config (cluster unavailable + connections > 400)
- 0.0.0.0/0 IP allowlist permanence + paid CF static-egress upgrade path
- Baseline cold-ping P95 (Phase 01 step 13)
- When to upgrade (link to cost-tracking.md)
4. Write `docs/cost-tracking.md` stub:
- M0 limits (storage 512MB, connections 500, ~100 ops/sec)
- Triggers to upgrade: storage > 400MB sustained, connection saturation, ops/sec degraded
- Cost ladder: M0 (free) → Flex ($8–30/mo) → M10 ($57/mo)
- Monthly review checklist
5. Update `docs/code-standards.md`: add Mongo persistence rule (modules go through `MongoKVStore` / `MongoTradesStore`).
6. Append to `docs/project-changelog.md` with date, summary, plan link.
7. Verify `docs/development-roadmap.md` does NOT list this migration. Remove if present (per user feedback memory: roadmap = future-only).
8. Edit root `CLAUDE.md` Architecture section: replace KV/D1 mentions with Mongo.
9. Edit root `README.md` database section.
10. **Verify daily cron writes Mongo** (brainstormer #9): grep `src/modules/misc/index.js` cron handler for any `db.put`/`tradesStore.insert`/equivalent. If absent, add a single `await db.put("misc:last_cron_ping", String(Date.now()))` line. Document inline.
11. **Verify e2e test passes** (brainstormer #10): `npm test -- tests/e2e/storage-roundtrip.test.js`.
12. **Verify secret-leak lint runs** (brainstormer #10): `npm run lint` — confirm `scripts/check-secret-leaks.js` is in the chain and passes on current source.
13. Run `npm run lint`, `npm test`, `npm run register:dry`. All pass.
14. Final commit message: `feat(db): complete migration to MongoDB Atlas M0`.
## Todo List
- [ ] All `docs/` references to KV/D1 grep-clean
- [ ] `docs/architecture.md` § 8 rewritten
- [ ] `docs/using-mongodb.md` finalized (with all Phase-01 + Phase-07 additions)
- [ ] `docs/cost-tracking.md` stub written
- [ ] `docs/code-standards.md` updated
- [ ] `docs/project-changelog.md` appended
- [ ] `docs/development-roadmap.md` verified migration NOT listed
- [ ] `README.md` database section updated
- [ ] Root `CLAUDE.md` Architecture section updated
- [ ] **Daily cron Mongo-write verified** (brainstormer #9 closes the unresolved Q)
- [ ] e2e storage-roundtrip test passing in CI
- [ ] Secret-leak lint passing in CI
- [ ] `tests/fakes/fake-mongo.js` surface documented in header comment
- [ ] Final `npm run lint && npm test && npm run register:dry` all green
## Success Criteria
- `grep -ri "Cloudflare KV" docs/ src/ README.md` returns zero relevant hits.
- `grep -ri "CFKVStore\|CFSqlStore" .` returns zero hits (in source; git history is fine).
- E2E test passes for both wordle (KV path) and trading (Mongo path) modules.
- Lint catches a deliberately-introduced `console.log(env.MONGODB_URI)` (negative test, then revert).
- Documentation reads coherently end-to-end: README → architecture → using-mongodb → cost-tracking.
- Daily cron confirmed writing Mongo (auto-pause non-issue).
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Doc drift between architecture.md and code reality | M | M | Step 1 grep comprehensive; reviewer verifies against git diff. |
| E2E test passes against fake but fails against real Atlas | L | M | Phase 06 soak validated against real Atlas; e2e here is regression coverage. |
| Lint script false-positives break CI | L | M | Tight regex (Phase 01); add allowlist comments for legitimate references in tests if needed. |
| `cost-tracking.md` becomes stale | H | L | Stub by design; review monthly per `documentation-management.md`. |
| Future contributor adds raw `MongoClient` use bypassing store | M | M | code-standards.md note + lint rule (if feasible) flags `import { MongoClient }` outside `src/db/`. |
## Security Considerations
- `using-mongodb.md` must not include real connection string (only redacted form).
- Cost-tracking doc references billing, not credentials.
- Lint check covers `MONGODB_URI`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `CLOUDFLARE_API_TOKEN`.
## Rollback
- All edits are doc + tests; revert commits if needed without runtime impact.
## Next Steps
- **Blocks:** none (final phase).
- **Post-completion:** schedule monthly cost-tracking review; confirm Atlas dashboard alerts wired (storage > 400MB, connections > 400 — Phase 01 already configured).
---
## Unresolved Questions (plan-level)
1. **`tests/fakes/fake-mongo.js` TTL semantics** — Phase 02 deferred TTL simulation. Read-time `expiresAt` filter is testable (covered in Phase 02 `Date.now()` mock). Acceptable: assert `expiresAt` field presence; trust real Atlas TTL in soak.
2. **`MongoSqlStore` future query expansion** — Phase 03 replaced dispatcher with direct `MongoTradesStore`. New trading queries = new explicit method, not silent dispatcher break. Closed by direct refactor.
3. **`MONGODB_URI` rotation cadence** — 90 days; owner = repo maintainer; documented in `docs/using-mongodb.md` rotation section. Calendar entry in `docs/cost-tracking.md` review cycle.
## Alternatives considered (reviewer dissent)
Reviewers (see brainstormer + code-reviewer + debugger reports in `plans/reports/`) recommended:
1. **Maintenance-window cutover** (~10 min downtime) instead of dual-write — saves ~6h, removes latency amplification.
2. **Defer trading migration** entirely (D1 free tier handles ~100 writes/day indefinitely).
3. **Single shared `kv` collection** instead of 12 per-module collections.
4. **Pivot to Upstash** before starting (smaller bundle, HTTP-native, no cold-start TLS cost).
5. **`MongoTradesStore` direct refactor** instead of SQL-pattern dispatcher — APPLIED (Phase 03).
User chose to proceed with full Atlas migration to validate cold-start UX firsthand. Reviewer correctness/safety findings have been applied throughout phases 01–08; architectural recommendations 1–4 are documented but not adopted. If the cold-start gate trips, [phase-07-alt-pivot.md](phase-07-alt-pivot.md) executes recommendation 4.
@@ -0,0 +1,73 @@
---
title: "Migrate miti99bot from CF KV+D1 to MongoDB Atlas M0"
description: "Dual-write migration to Atlas M0 with explicit cold-start abort threshold and Upstash pivot path."
status: planning
priority: P2
effort: 22h
branch: main
tags: [storage, migration, mongodb, atlas, cloudflare-workers]
created: 2026-04-25
blockedBy: []
blocks: []
---
# Plan: KV+D1 → MongoDB Atlas M0
User-chosen path despite research recommending Upstash. Goal: validate cold-start UX firsthand with safe rollback to KV/D1 (or pivot to Upstash) if M0 cold-start P95 exceeds derived threshold.
## Reports
- [Atlas fit + driver](../reports/researcher-260425-1924-mongodb-atlas-fit-and-driver.md)
- [Schema + migration mechanics](../reports/researcher-260425-1924-mongodb-schema-and-migration.md)
- [Free DB validation matrix](../reports/researcher-260425-1934-free-db-validation-matrix.md)
- [Brainstormer critique](../reports/brainstormer-260425-2034-atlas-plan-critique.md)
- [Code-reviewer correctness](../reports/code-reviewer-260425-2034-atlas-plan-correctness.md)
- [Debugger failure-modes](../reports/debugger-260425-2034-atlas-plan-failure-modes.md)
## Constraints (locked)
- Backend: MongoDB Atlas M0 free, region `aws-ap-southeast-1`.
- Driver: official `mongodb` npm v6.7+; `nodejs_compat_v2`; `compatibility_date >= 2025-03-20` (current `2025-10-01` already qualifies — no change needed).
- Strategy: dual-write → backfill → verify → read-flip → soak → decommission.
- Scope: BOTH `KV` (12 modules) and `D1` (`trading`) → MongoDB.
## Phases
| # | Phase | Status | Effort | Owner files |
|---|-------|--------|--------|-------------|
| 01 | [Atlas setup + wrangler config](phase-01-atlas-setup.md) | pending | 2h | `wrangler.toml`, `.env.deploy.example`, `scripts/check-secret-leaks.js` |
| 02 | [MongoKVStore implementation](phase-02-mongo-kv-store.md) | pending | 3h | `src/db/mongo-*.js` |
| 03 | [MongoTradesStore + trading refactor](phase-03-mongo-sql-store.md) | pending | 3h | `src/db/mongo-trades-store.js`, `src/modules/trading/*` |
| 04 | [Dual-write wrappers + flag + e2e](phase-04-dual-write-wrappers.md) | pending | 4h | `src/db/dual-*.js`, factories, `tests/e2e/*` |
| 05 | [Backfill + verification (local-only)](phase-05-backfill-scripts.md) | pending | 3h | `scripts/backfill-*.js` |
| 06 | [Staged deploy + soak (cold-start gate)](phase-06-staged-deploy-and-soak.md) | pending | 4h | runtime telemetry |
| 07 | [Cutover + decommission](phase-07-cutover-and-decommission.md) | pending | 3h | wrangler bindings |
| 07-ALT | [Pivot to Upstash (STANDBY)](phase-07-alt-pivot.md) | standby | (3-4d if triggered) | `src/db/upstash-*.js` |
| 08 | [Tests + docs](phase-08-tests-and-docs.md) | pending | 1h | `tests/`, `docs/` |
## Critical dependencies
- 01 → 02, 03 (Atlas creds + bundle-size gate required)
- 02 + 03 → 04 (wrappers need both stores; e2e lands here)
- 04 → 05 (dual-write must be live before backfill so concurrent writes hit Mongo too)
- 05 → 06 (no soak without verified data)
- 06 → 07 (cutover blocked on cold-start gate; if breached, **abort to [phase-07-alt-pivot.md](phase-07-alt-pivot.md)**)
- 02–05 land throughout; 08 finalizes after 07
## Abort criteria (must trigger pivot, not retry)
- Phase 06: cold-start P95 for `/wordle` or `/loldle` > `2.5 × P95(cold-ping baseline from Phase 01)` over 24h soak window.
- Phase 06: dual-write divergence rate > 1% sustained for >1h.
- Phase 06: M0 connection saturation events (>400 of 500 cap) observed during burst.
- Phase 06: Worker CPU-time exceeded errors observed on cold start (Free plan 50ms ceiling).
- Pivot path: leave dual-write running → revert `STORAGE_PRIMARY=kv` → execute [phase-07-alt-pivot.md](phase-07-alt-pivot.md).
## Rollback per phase
Every phase file lists explicit rollback. Headline: until Phase 07 deletes KV/D1 bindings, the original data path is one env-flag flip away. Phase 07 is the only irreversible cutover step.
## Alternatives considered (reviewer dissent)
Reviewers (see brainstormer + code-reviewer + debugger reports) recommended:
1. **Skip dual-write** in favor of a 10-minute maintenance window (saves ~6h, removes latency amplification).
2. **Defer trading migration** entirely (D1 free tier handles ~100 writes/day indefinitely).
3. **Single shared `kv` collection** instead of 12 per-module collections.
4. **Pivot to Upstash** before starting (smaller bundle, HTTP-native, no cold-start TLS cost).
5. **Direct `MongoTradesStore`** instead of SQL-pattern dispatcher (applied — see Phase 03).
User chose to proceed with full Atlas migration to validate cold-start UX firsthand. Reviewer correctness/safety findings have been applied throughout phases 01–08; architectural recommendations 1–4 are documented but not adopted. If the cold-start gate trips, [phase-07-alt-pivot.md](phase-07-alt-pivot.md) executes recommendation 4.
@@ -0,0 +1,296 @@
# Atlas Migration Plan — Architectural Critique
**Plan:** `plans/260425-1945-mongodb-atlas-migration/` (8 phases, 24h budgeted)
**Scope of critique:** YAGNI/over-engineering; cold-start gate; abort path realism; admin-route reintroduction; SQL-shaped wrapper around a doc store.
---
## TL;DR
1. Plan is over-engineered for ~615KB KV + ~300KB/mo trading. Dual-write + verifier + soak machinery costs ~13h to potentially throw away.
2. `MongoSqlStore` is a SQL-pattern dispatcher emulating 6 statements — phase-03 is a leaky abstraction; refactoring callers is simpler.
3. Phase-05 reintroduces `/__admin/*` HTTP routes that `docs/architecture.md` § 10 explicitly rejected. Direct violation of project posture.
4. Abort path (`phase-07-alt-pivot.md`) is a TODO. The "bail-out" is not actually planned — the user has no real escape hatch when the gate trips.
5. Trading migration earns nothing on M0 vs D1; phase-03 is ~3h of code with negative business value that just adds risk.
---
## Findings
### 1. Dual-write is over-engineered for this dataset size
**Smell.** Phase 04 (3h) + dual-write divergence handling in Phase 06 (parts of 4h) + reverse-backfill stub debt in Phase 07 (Stage 2 rollback) = ~5–6h of complexity, all of which is bypassable.
**Evidence.**
- `phase-04-dual-write-wrappers.md:15-21` — dual-write rationale: "keep secondary warm + ready for read-flip". Worth it for high-traffic continuous-availability systems. **Not a Telegram bot with ~615KB total data.**
- `phase-05-backfill-scripts.md:16` — order-of-operations: dual-write must precede backfill or "writes during backfill window go to KV only". This dependency is the entire reason dual-write exists. A maintenance-window cutover removes this constraint.
- `phase-04` risk table line `phase-04-dual-write-wrappers.md:159` — "Write amplification doubles latency on every put... Cold p99 = 1500ms". Dual-write WORSENS the very metric Phase 06 is gating on. The plan creates the latency problem then measures it.
- KV `list()` is paginated 1000/page (phase-05 line 17). Total dataset spans ~615KB. A one-shot dump is minutes, not hours.
**Recommendation: CHANGE.** Strongly consider replacing dual-write with a 10-minute maintenance window:
1. Set Telegram webhook to a 503 page, OR keep current bot live but disable writes to commands that mutate state.
2. Run one-shot KV dump → Mongo upsert (local node script, ~5 min for 615KB).
3. Run one-shot D1 dump → Mongo (seconds).
4. Flip `STORAGE_PRIMARY=mongo`, redeploy.
5. Total downtime: 10 min. Telegram retries webhook deliveries on 503 with backoff so most users never notice.
This kills phase-04 entirely (-3h), phase-05's `/__admin/*` routes (-2h of phase-05 + risk surface), and most of phase-06's "dual-write divergence" telemetry. Net: ~5–6h saved AND simpler abort path (just don't flip the flag; KV is still untouched on rollback if you keep KV bindings in `wrangler.toml` for one extra deploy cycle).
**Earns its keep when:** continuous availability is a hard requirement, or dataset is large enough that backfill exceeds an acceptable maintenance window. Neither holds here.
---
### 2. Trading migration is dead weight
**Smell.** Phase-03 (3h) + dual-SQL-store wrapper (part of phase-04) + parity verification (part of phase-05) = ~4–5h to migrate ~100 writes/day.
**Evidence.**
- `phase-03-mongo-sql-store.md:13-15` — "Trading runs exactly 6 distinct SQL statements". Building a SQL-pattern dispatcher to emulate them.
- D1 free tier per CF docs: 5M reads/day + 100k writes/day. Trading uses ~100 writes/day. **Trading is using <0.1% of D1's free quota.**
- The user's stated goal is "validate cold-start UX firsthand". Trading commands aren't on the cold-start latency hot path — they're rare, transactional, and tolerate a 1.5s spike. Migrating them doesn't validate anything new beyond what KV migration already validates.
- `phase-07-cutover-and-decommission.md:81` — wrangler.toml needs D1 binding removed Stage 3. Even if Mongo-side works, you've added MongoSqlStore code that exists for one module.
**Both sides:**
- *For migrating:* operational consistency (one backend), one less binding to manage, eliminates `sql` parameter being conditionally null in module init.
- *Against:* MongoSqlStore is a SQL-shaped wrapper around a document store (see Finding 6); D1 is rock-solid for trading's workload; if Atlas pivot to Upstash happens, trading still needs a SQL backend OR a second migration.
**Recommendation: CHANGE — defer trading migration.** Keep D1 for trading. Migrate KV only. Reasons:
- D1 is doing its job well. Don't fix what isn't broken.
- The cold-start UX validation is fully covered by KV-using modules (`/wordle`, `/loldle` are listed as the soak gate metrics anyway — phase-06-staged-deploy-and-soak.md:23).
- Removes phase-03 (-3h) and SqlStore parts of phase-04 wrapper (-1h) and verifier (-30min).
- Upstash pivot becomes simpler: it's a key-value pivot only; D1 stays put.
**Trade-off:** keeping the dual-backend posture requires `create-sql-store.js` to keep returning `null` when `env.DB` absent. Already true today. Zero cost.
---
### 3. Per-module collections vs single shared collection
**Smell.** 12 collections for 12 modules at ~50KB each. Atlas has no per-collection cost on M0, but ops complexity grows.
**Evidence.**
- `phase-02-mongo-kv-store.md:18-19` — "Per-module collections (12 KV modules → 12 collections), name == module name". Justification: not given beyond convention.
- `phase-02-mongo-kv-store.md:78` — keys still carry the prefix in `_id` ("wordle:games:42"). So both the collection name AND the `_id` carry module identity. Redundant.
- TTL index needs to be created on each of 12 collections (`phase-02-mongo-kv-store.md:124` — `_ensureIndex` per collection).
- Backfill (phase-05) iterates module-by-module — works either way, but verifier counts per-collection.
**Single-collection alternative.**
```js
// One collection: kv
{ _id: "wordle:games:42", value: "...", expiresAt: ... }
```
- One TTL index instead of 12.
- One `createIndex` call instead of 12 lazy-init paths.
- `list()` already filters by `_id` prefix in the per-module wrapper — so cross-module isolation is preserved at the wrapper level, identical semantics.
- Index size is meaningfully smaller per-collection only if collections grow non-uniformly. Here, total = 615KB. Irrelevant.
**Per-module pays off when:** sharding (M10+), per-collection TTL policies differ, or you want per-module backup/restore granularity. None of which apply on M0.
**Recommendation: CHANGE.** Single `kv` collection. Drops phase-02 complexity slightly (single index init), simplifies verifier, simplifies the dump-routes story (which Finding 5 will recommend deleting anyway). Per-module can be added later if scale demands it — pure YAGNI.
**Counter-argument the planner could make:** "Per-module collection makes it visually obvious in Atlas UI which module owns what." Valid for ops debugging. But `_id` prefix achieves the same with `db.kv.find({_id:/^wordle:/}).limit(10)`. Cosmetic, not architectural.
---
### 4. The phantom abort path
**Smell.** The whole plan hinges on a "safe rollback to KV/D1 (or pivot to Upstash)" — but the pivot file doesn't exist.
**Evidence.**
- `plan.md:47` — "if P95 > 3s, **abort to phase-07-alt-pivot.md** TODO".
- `phase-06-staged-deploy-and-soak.md:130` — "Open `phase-07-alt-pivot.md` (TODO; not pre-written here — wait until needed)."
- The 3000ms threshold (`phase-06:109`) is asserted without a citation. Researcher's report on Atlas cold-start gives ~1500ms baseline (`phase-01:50`). 3s is "2× baseline" but **not motivated by a UX research finding** (e.g., "Telegram users abandon at >2.5s").
**Why this matters.** The user's stated motivation is "validate cold-start UX firsthand with safe bail-out". If the bail-out isn't planned, it isn't safe. When the gate trips at 3 AM and the operator is staring at a 3.2s P95, "draft a new plan" is not an executable rollback procedure — it's a panic invitation.
The pivot path described in `phase-06:125-130` is high-level (5 bullet points). Real questions unanswered:
- How long will the bot run on KV/D1 with `DUAL_WRITE=0` while a new Upstash plan is built? Days? Weeks?
- Is `MongoKVStore` reusable for `UpstashKVStore` (similar interface) or starting from scratch?
- Does Atlas data get exported before deletion? When?
- Does the operator pay for Upstash dev work mid-incident, or is there a 7-day "we're on KV only" window?
**Threshold motivation.** Research report 3 (free-db-validation-matrix) reportedly recommends Upstash on cold-start grounds. That implies the researchers expect Atlas to fail this gate. Setting the threshold at 3000ms when expected baseline cold is 1500ms gives ~2× headroom — generous, but means the "validate firsthand" exercise will likely PASS, even if UX is degraded for the slow tail. Should be 2× P95 baseline cold-start measured in phase-01 ping (line 88 todo: "ping latency recorded as baseline number") — i.e., DERIVED from measurement, not asserted at 3000ms before measurement.
**Recommendation: CHANGE.**
- (a) Pre-write `phase-07-alt-pivot.md` as a 1-page skeleton: "leave Atlas writes off, KV is authoritative, draft new Upstash plan within 5 days." Make it real, even if minimal. Otherwise rollback is just hope.
- (b) Replace the 3000ms threshold with a measurement-derived one: `2.5 × P95(cold-start ping from Phase 01)`. If Phase 01 measures 1400ms cold ping, gate is 3500ms. If 1800ms, gate is 4500ms. Honest measurement, not arbitrary number.
- (c) Add a "what does the user feel" research item: 1500ms vs 3000ms vs 5000ms — at what point does a Telegram user retype `/wordle`? Without this, the gate is engineering theater.
---
### 5. Re-introducing admin routes violates project architecture
**Smell.** Phase-05 adds `/__admin/dump-kv` and `/__admin/dump-d1-trades` to the Worker. The architecture doc explicitly rejects this.
**Evidence.**
- `docs/architecture.md:14` (design goals) — "**No admin HTTP surface.** One less attack surface, one less secret. Webhook + menu registration happen out-of-band via a post-deploy node script."
- `docs/architecture.md:358-365` (§ 10 "Why the register step is not in the Worker") — explicitly rejected reasons:
- "Adds a third secret to manage and rotate."
- "Adds an attack surface (even a gated one)."
- "Running locally + idempotently means the exact same script works whether invoked by a human, CI, or a git hook."
- `phase-05-backfill-scripts.md:53-56` — adds `ADMIN_TOKEN` (a third secret), `/__admin/dump-kv` route, and constant-time-compared auth middleware. **Every objection from § 10 applies verbatim.**
- The plan handwaves: routes are "temporary, removed in Phase 07" (`phase-05:99`). But "temporary" admin routes have a way of lingering — and the security review burden lands now, not later.
**Alternative: local-only dump scripts.**
- KV dump: `wrangler kv key list --namespace-id=<id> --remote` paginated, then `wrangler kv key get` per key — slow but works without code changes. OR use the CF KV REST API via `curl` from a local script with an account-token (no Worker change).
- D1 dump: `npx wrangler d1 export miti99bot-db --output=trades.sql --remote`. **Already in phase-07 step 12 as a final-stage backup** — same command works for the migration backfill.
- Mongo write: from local node, no admin route needed.
The "wrangler kv key get per-key is too slow" argument (`phase-05:31`) is real but solvable: CF KV REST API supports bulk read via `keys/bulk` endpoint, or chunked workers from local node parallelize the per-key gets. Either is faster than building, securing, deploying, and later removing a Worker route.
**Recommendation: DELETE.** No `/__admin/*` routes. Use CF KV REST API + `wrangler d1 export` from a local node script. Drops `ADMIN_TOKEN` (a third secret), drops `src/admin/dump-routes.js`, drops the security-considerations debt in phase-05 (lines 165-170), drops the deletion step in phase-07 (lines 13, 128). Net: -1.5h + zero attack surface added.
**Side note:** the planner KNEW this was sketchy — `phase-05:99` flags routes as "temporary" and `phase-07:13` makes deleting them a pre-cutover step. That's the smell. If something must be deleted before cutover, ask why it had to be added.
---
### 6. MongoSqlStore is a SQL-pattern dispatcher emulating SQL on a doc store
**Smell.** Phase-03 builds a regex-driven dispatcher that pattern-matches 6 SQL statements and translates each to a Mongo call. Worst kind of leaky abstraction: pretends to be SQL, isn't, fails open silently if a 7th statement appears.
**Evidence.**
- `phase-03-mongo-sql-store.md:46` — "Statement matching: trim + collapse whitespace + uppercase first 30 chars; switch on prefix".
- `phase-03-mongo-sql-store.md:147` — risk: "7th SQL statement appears (silent breakage)" — likelihood "M", impact "H". The plan's own risk table flags this as MEDIUM-likelihood.
- `phase-03-mongo-sql-store.md:153` — "`OFFSET` with `LIMIT -1` (sqlite-ism) is meaningless in Mongo. Handler ignores LIMIT, applies `.skip(N)` only." Behavior is QUIETLY different from D1 — the wrapper isn't actually SQL-compatible, just SQL-shaped.
- Trading callers (`history.js`, `retention.js`) are listed as "READ FOR CONTEXT" only (`phase-03:104-107`). They're NOT modified. The wrapper exists to AVOID modifying them. Why? They're 2 small files.
**Simpler alternative.** Refactor `trading/history.js` + `trading/retention.js` to call a `MongoTradesStore` directly:
```js
// MongoTradesStore — thin, purpose-built
class MongoTradesStore {
insert(trade) { /* db.trading_trades.insertOne */ }
byUser(userId, limit) { /* find().sort().limit() */ }
distinctUsers() { /* distinct */ }
oldRowsForUser(userId, keepN) { /* find().skip(keepN) projection */ }
oldRows(keepN) { /* find().skip(keepN) projection */ }
deleteByIds(ids) { /* deleteMany({_id:{$in}}) */ }
}
```
Six methods, ~80 LOC. Trading module's two files lose their SQL string literals — gain explicit, typed method calls. Reads better. No regex spaghetti. No "7th statement" silent-breakage risk because you can't accidentally route an unknown query.
**Cost.** Modifying `trading/history.js` and `trading/retention.js`: ~30 LOC of changes total (the SQL strings already isolate the access patterns). That's smaller than the dispatcher + handlers + tests.
**Counter-argument:** "But then the abstraction leaks into the module — the module knows it's talking to Mongo." Yes. That's HONEST. Today the abstraction leaks the other way — the module knows it's talking to SQL even though it might be Mongo. Either way the module knows. Better to know the truth than the lie.
**Recommendation: DELETE phase-03.** Replace with: "Add `src/db/mongo-trades-store.js` (~80 LOC) + refactor `trading/history.js` and `trading/retention.js` to use it directly. Delete `SqlStore` interface in phase-07 alongside D1." Saves ~2h vs phase-03 implementation, deletes the dispatcher risk, gives trading module a cleaner persistence boundary.
**Combined with Finding 2:** if you defer trading migration entirely (recommended), phase-03 deletes outright. ~3h saved.
---
### 7. `last_row_id` returned as ObjectId hex is dead-code-by-design
**Smell.** Plan acknowledges trading doesn't use `last_row_id`, then specifies returning ObjectId hex anyway "for parity".
**Evidence.**
- `phase-03-mongo-sql-store.md:25` — "D1 returns `{ changes, last_row_id }` — last_row_id matters for trading? Grep confirms it's not consumed (insert path discards return). Confirm in step 1."
- `phase-03-mongo-sql-store.md:34-35` — "`run` → `{ changes, last_row_id }`. `last_row_id` returns ObjectId hex (since callers don't use it numerically)."
- `phase-03-mongo-sql-store.md:153` — "`last_row_id` quietly needed by future caller... if caller does math on it, fails loudly". Defensive hand-wave.
The whole field exists because the SqlStore *interface* defines it (see `cf-sql-store.js:43`). The wrapper's purpose is interface parity, so it returns the field. But the value is meaningless (a hex string in a contract that historically was an integer). It's dead code dressed up as parity.
**Recommendation: KEEP if phase-03 stays as-is** (interface parity has weight) — but if the trading-only refactor recommendation (Finding 6) lands, delete `last_row_id` entirely and remove the field from `SqlStore` interface. Two smells solved at once.
---
### 8. Soak duration "24-72h" is a vibe, not a criterion
**Smell.** No exit condition for extending 24h → 72h.
**Evidence.**
- `plan.md:52` — "Phase 06: cold-start P95... > 3000ms over 24h soak window." 24h is the gate.
- `phase-06-staged-deploy-and-soak.md:25` — "Soak window: minimum 24h, ideally 72h to span weekly traffic peaks."
- `phase-06:109-114` (Success Criteria) only references "24h" and "72h" without defining what data 72h gives that 24h didn't.
- Bot has cron-driven traffic (`docs/architecture.md:43` — daily lolschedule, etc.). 24h covers one full daily cycle including all crons. 72h gives 3× sample size but no new patterns unless weekly traffic varies dramatically.
**Recommendation: CHANGE.** Pick one duration with a stated reason:
- 24h with rationale: covers daily cron cycle, sufficient for steady-state.
- 72h with rationale: covers a weekend-vs-weekday traffic differential of >1.5×.
- Or 7 days if weekly cycle matters.
Or define a stop condition: "extend to 72h if any of {error rate >0.5%, traffic <50 req/24h, cold-start P95 between 2500–3000ms (borderline)}". Without this, "24-72h" is operator-discretion masquerading as a plan.
---
### 9. "Cron heartbeat to prevent M0 auto-pause" is worry-driven
**Smell.** Listed in plan-level unresolved questions; plan-08-tests-and-docs.md:161.
**Evidence.**
- `phase-01-atlas-setup.md:18` — "M0 auto-pauses after **30 days of zero ops**."
- The bot has 6+ daily cron triggers (`wrangler.toml` has `[triggers] crons`).
- Trading cron + lolschedule cron + retention crons all hit the DB daily.
- 30 days of zero ops would require the bot to be entirely silent for 30 days — implies all crons fail or are removed.
**Recommendation: DELETE the unresolved Q.** Not a real concern. If ALL crons stop firing for 30 days, you have a bigger problem than auto-pause (the bot is dead). Atlas auto-pause is a non-issue for an active bot. The plan-07 risk table line `phase-07:173` already proposes a "no-op cron heartbeat" — also unnecessary, can be removed.
If paranoia wins: a single line in the existing `misc` cron handler that does `db.runCommand({ping:1})` covers it. Not worth a separate unresolved question.
---
### 10. Tests-and-docs in phase 08 is debt, not closure
**Smell.** Phase 08 (3h, post-cutover) catches doc updates AND a meaningful new e2e test. Ordering is wrong.
**Evidence.**
- `phase-08-tests-and-docs.md:24-26` — e2e test: "Boot fake env with `MongoKVStore` + `MongoSqlStore` against `fake-mongo.js`. Run a representative request..." This validates the FULL stack. Should land BEFORE the irreversible cutover (phase-07 step 18), not after.
- Phases 02–04 each include their own unit tests. Good.
- BUT phase-04 dual-store testing (`phase-04-dual-write-wrappers.md:127-131`) only verifies factory behavior with fake env — does not verify a request actually round-trips through both stores.
- `phase-08:104` proposes a `scripts/check-secret-leaks.js` lint rule. Should have been added in phase-01 alongside the URI introduction, before any commit could leak. Now it's added after 7 phases worth of commits could already contain leaks.
**Recommendation: CHANGE.**
- (a) Move the e2e test from phase-08 to phase-04 (or a new phase-04.5). Run it before phase-05 backfill. Run it before phase-06 deploy. Run it before phase-07 cutover. It's a regression net.
- (b) Move the secret-leak lint to phase-01 step 11 (immediately after `MONGODB_URI` is introduced as a secret). 5-min addition.
- (c) Phase 08 stays as docs-only update + final mark-complete. Trims it to ~1h.
Sequencing rule of thumb: tests for a phase land WITH the phase, not in a stash-everything-into-phase-08 graveyard.
---
## Cross-cutting observations
**Plan budget is misallocated.** 24h budget → 8h on dual-write/backfill/admin routes (Findings 1, 5) + 3h on trading SQL emulation (Findings 2, 6) = 11h on machinery the user might not need. Closer to 13h if Phase 06 telemetry overhead and Phase 08 catch-up are included.
**Plan is over-confident on cold-start.** Phase-06 bakes in the ABORT path (good) but the gate threshold is asserted, not derived; the pivot is a TODO; the user has been told this is "safe to bail out of" but the bail-out is unwritten.
**Plan respects existing project posture EXCEPT in Finding 5.** The architecture doc is clear and this plan re-introduces the exact pattern the doc rejects. Either:
- The plan was drafted without consulting `docs/architecture.md` § 10, OR
- The convenience of admin routes felt worth the violation.
Either way: a planner who reintroduces a rejected pattern owes a one-line justification in the phase file. None present.
---
## Prioritized action list (impact order)
1. **DELETE Phase 05's `/__admin/*` routes.** Use CF KV REST API + `wrangler d1 export` from local. Removes a third secret, removes attack surface, complies with `docs/architecture.md` § 10. (Finding 5)
2. **CHANGE Phase 04 to maintenance-window cutover.** Drop dual-write entirely. ~5–6h saved + removes the latency-amplification problem that Phase 06 is gating on. (Finding 1)
3. **CHANGE Phase 03: defer trading migration OR replace with `MongoTradesStore` direct refactor.** Either drops phase-03 entirely (-3h) or simplifies it from SQL-pattern-dispatcher to 6 explicit methods. (Findings 2 + 6 combined)
4. **PRE-WRITE `phase-07-alt-pivot.md` skeleton + DERIVE the cold-start threshold from Phase-01 baseline measurement.** Make the bail-out real. (Finding 4)
5. **MOVE e2e test from Phase 08 to before Phase 06.** Catch storage-roundtrip regressions BEFORE deploying the dual-write code (or with the maintenance-window approach, before cutover). Move secret-leak lint to Phase 01. (Finding 10)
6. **CHANGE Phase 02 to single shared `kv` collection.** YAGNI — per-module collections add ops complexity without M0-scale benefit. (Finding 3)
7. **DEFINE Phase 06 soak duration with stated criterion.** Pick 24h with reason, OR pick 72h with reason. Stop saying "24-72h". (Finding 8)
8. **DELETE the "M0 auto-pause cron heartbeat" unresolved question.** Bot has 6+ daily crons. Non-issue. (Finding 9)
If recommendations 1+2+3 land: budget drops from 24h → ~12h. Plan is half the size, more reversible, fewer secrets, fewer code files to delete in phase-07, simpler architecture-doc story.
---
## Unresolved questions for the planner
- Is "validate cold-start UX firsthand" a UX research goal (with a defined user-experience threshold) or an engineering curiosity? Answer changes whether the 3000ms gate is right.
- Is the user willing to accept a 10-minute Telegram bot maintenance window? If yes, dual-write is unnecessary.
- Is the user committed to migrating BOTH KV and D1, or is keeping D1 acceptable? Plan assumes both; nobody's challenged it.
- What's the SLO for `/wordle` reply latency that the user actually cares about? The plan uses 3000ms; users might tolerate less or more.
---
**Status:** DONE_WITH_CONCERNS
**Summary:** Plan is structurally sound but over-engineered for dataset size; reintroduces admin-route pattern explicitly rejected by project docs; bail-out path is a TODO. Recommend dropping dual-write, dropping admin routes, deferring trading migration. Cuts plan budget ~50%.
@@ -0,0 +1,328 @@
# Atlas Migration Plan — Correctness Review
## TL;DR
Plan is **mostly implementable but has 5 blockers and ~10 high-severity gaps that will burn the implementer**. Worst gap: `MongoSqlStore` SQL-pattern dispatch is internally contradictory (5 handlers for 6 statements, retention.js queries 3 & 4 differ in shape and bind layout in ways the plan handwaves) AND `last_row_id` is required by the existing test contract (`tests/db/create-sql-store.test.js:48-52`) which directly contradicts plan-03's claim it's unused. Cold-start measurement methodology is hand-waved, instrumentation snippet has a `marks` reference-error bug, and the plan's "stubMongo" approach is effectively a dead-letter URI that risks stalling register if any code path tries to construct a MongoClient with it (which the matrix permits if anyone misconfigures the flags).
The plan is "rollback-correct" but not "round-trip-correct" — Stage-2 reverse-backfill is explicitly NOT pre-built, so a Stage-2 abort with new Mongo-only writes loses data on rollback to KV.
---
## Findings
| # | Severity | File:Section | Issue | Recommendation |
|---|----------|--------------|-------|----------------|
| 1 | BLOCKER | phase-03 §Key Insights, §Functional | Plan claims `last_row_id` is unused; `tests/db/create-sql-store.test.js:48-52` explicitly asserts `result.last_row_id` is present. Returning ObjectId hex (string) where test expects a number breaks build. | Either return `last_row_id: 0` for INSERT (numeric, matches D1 "no rowid" path) and don't pretend it's the inserted ID; OR refactor the existing test to accept hex. Document choice in phase-03. |
| 2 | BLOCKER | phase-04 §Stub for register, §Implementation step 6 | `stubMongo = "mongodb://stub-not-used"` is a string, not a duck-typed `MongoClient`. If any code path constructs `new MongoClient(stubMongo)` (e.g. someone forgets to wire the `DUAL_WRITE=0` short-circuit, or `STORAGE_PRIMARY=mongo` env leaks into register), `register.js` hangs ≥10s on `serverSelectionTimeoutMS`. The matrix correctness depends on factory branching that doesn't exist yet. | Make `stubMongo` a duck-typed object exposing `db()`, `connect()` no-ops (pattern matches `stubKv`). Or: have factories check `env.MONGODB_URI === stubMongo` literal sentinel and short-circuit unconditionally. Add a unit test that constructs the factory with stubMongo + every flag combo and asserts no network call. |
| 3 | BLOCKER | phase-03 §Key Insights bullet "Mongo equivalents", §Architecture handlers | Statement 4 (`SELECT id ... WHERE user_id = ? ORDER BY ts DESC LIMIT -1 OFFSET ?`) and statement 5 (`SELECT id ... ORDER BY ts DESC LIMIT -1 OFFSET ?`) are described as one handler "handleRetentionOffset" but plan never specifies how the dispatcher distinguishes per-user vs global. The 3 LIMIT-token resolution rules in `tests/fakes/fake-d1.js:155-170` (negative limit = "all rows") are also missing from plan. Without a spec, implementer cannot write the dispatcher. | Add explicit dispatcher rules: detect `WHERE user_id` substring after normalization; route to per-user vs global. Document that LIMIT -1 is sqlite-ism for "no limit"; Mongo handler ignores it entirely and applies only `.skip(N)`. List the 6 normalized prefixes as constants in the plan. |
| 4 | BLOCKER | phase-07 §Rollback Stage 2 | "Reverse-backfill from Mongo to KV. Build script on demand." During Stage-2 7-day soak, Mongo-only writes accumulate. If Stage-2 abort happens on day 7, KV is missing 7 days of writes. Operator must build a backfill script under outage pressure with no test coverage. | Pre-build `scripts/backfill-mongo-to-kv.js` and `backfill-mongo-to-d1.js` BEFORE Stage 2 begins. Test against fake-mongo + fake-kv. List in phase-07 step 8 prerequisites. |
| 5 | BLOCKER | phase-01 §Risk + phase-02 §Implementation | `mongodb` v6.7+ npm bundle is **~4-5 MB** per the researcher report. CF Workers Free plan limit is **3 MiB compressed** (10 MiB unbottled paid). Plan's mitigation "measure with `wrangler deploy --dry-run`" is reactive, not preventive. If bundle exceeds limit, Phase 02 is blocked AFTER Phase 01 already committed `mongodb` install + wrangler config changes. | Move bundle-size check to phase-01 step 8 as a HARD gate. Budget ≥1MB headroom. Document fallback: switch to `mongodb-driver-core` (no DNS/SRV resolver) or abandon Atlas before any further phase work. |
| 6 | HIGH | phase-02 §Architecture "Decision: keep prefix inside `_id`" + phase-04 §Functional | `create-store.js:65` strips the prefix from results. If MongoKVStore stores `_id="wordle:games:42"` and `list({prefix:"games:"})` is called from inside the wrapper (which prepends `"wordle:"` first → `fullPrefix="wordle:games:"`), MongoKVStore must filter `_id` starting with that AND return KEYS WITH PREFIX so `create-store.js` can strip. Plan inconsistently says "prefix-strip mirrors `create-store.js:65`" (key 22) AND "the store doesn't strip prefixes" (key 76). Ambiguity will yield wrong list results. | Match CFKVStore behavior exactly: MongoKVStore returns `keys` AS-IS (prefixed). The wrapper strips. Make this explicit in phase-02 §Functional and write a regression test using a 2-level prefix (`wordle:games:`) to lock semantics. |
| 7 | HIGH | phase-02 §Functional, §Implementation step 3 | TTL stale-read window: Mongo TTL sweeper runs every ~60s. A key with `expirationTtl=10s` is still readable for up to 60s after expiration. Plan acknowledges this in Risk row 2 but `getJSON` does NOT check `expiresAt` field on read. Consumers (e.g. game state with TTL) will see "expired" data. CF KV does NOT have this behavior. | Add explicit `expiresAt` filter on read in MongoKVStore: `findOne({_id, $or: [{expiresAt: {$exists: false}}, {expiresAt: {$gt: new Date()}}]})`. OR document the divergence loudly in the contract. Test must cover: put with 1s TTL, sleep 2s, getJSON returns null. |
| 8 | HIGH | phase-04 §Functional, §Architecture sequence | Dual-write order is documented as `Promise.all` parallel. But plan says "Throw only on primary failure" and "secondary failures get reconciled in Phase 05 backfill". The reconciliation path is broken: Phase 05 backfill uses `$setOnInsert` (skip-if-exists). If primary write succeeded but secondary failed mid-dual-write, the doc IS missing in Mongo, so backfill would fill it from KV. **But Phase 05 is documented as a one-shot, not a recurring sweeper.** Divergences accumulating after Phase 05 are silent. | Add a recurring "drift verifier" cron (1/hr) that samples N keys cross-store and logs/alerts mismatches. Or: change secondary-failure handling to push the failed key onto a retry queue (KV list) that Phase 05 verifier drains. |
| 9 | HIGH | phase-04 §Architecture flag matrix row 4 | `STORAGE_PRIMARY=mongo` + `DUAL_WRITE=0` post-cutover means writes go ONLY to Mongo. M0 is single-region (aws-ap-southeast-1) with NO BACKUPS (acknowledged in phase-01 key insight 5). For 7-day Stage-2 soak there is no fallback if Mongo loses data. | Add an explicit Stage-2 safeguard: enable mongodump-equivalent (or `npm run backfill:d1` reversed for trading) on day-3 of Stage 2 to checkpoint to local disk. Or: keep `DUAL_WRITE=1` permanently (leave KV writes on for one extra cycle as zero-cost insurance) and only flip after Phase 07 binding deletion is irreversible. |
| 10 | HIGH | phase-06 §Architecture "Telemetry helper" | Code snippet refers to `marks` outside its definition: `console.log(JSON.stringify({event:"cmd_timing", cmd, total, ...extra, marks}))` — `marks` is in the outer closure but never declared in the snippet. Implementer copy-pastes → ReferenceError at runtime. | Fix the snippet: declare `const marks = []` at top; have `mark(label)` push to it. Or remove `marks` from output. Snippet quality matters because Phase 06 is the decision gate. |
| 11 | HIGH | phase-06 §Functional, §Implementation step 3 | Cold-start detection via `Date.now() - ISOLATE_BORN < 200ms` is unreliable. CF isolate boot can be 50ms but the Mongo connect + first read is 1500ms. By the time the FIRST handler logs, age may already be >200ms. Cold requests get bucketed as warm. | Use a different signal: track a `let isFirstRequest = true; if (isFirstRequest) { isFirstRequest = false; logCold(); }` flag at module scope. Combined with `isolate_age_ms` for the histogram, but use the boolean for cold/warm bucketing. |
| 12 | HIGH | phase-08 §Functional doc updates | Plan does not mention removing `npm run db:migrate` from `package.json:13` chain inside `npm run deploy` (line 12). Phase 07 step 19 deletes `scripts/migrate.js` but `npm run deploy` still calls `npm run db:migrate` — deploys break. | Add to phase-07 step 17: edit `package.json` to remove `db:migrate` from the `deploy` chain and remove the `db:migrate` script entry. |
| 13 | HIGH | phase-05 §Functional, §Implementation step 4 | Backfill of D1 → Mongo for trading: D1 uses `INTEGER PRIMARY KEY AUTOINCREMENT`; Mongo plan uses `ObjectId`. Plan says "fields mapped 1:1" but the `id` field is the table's primary key and (per phase-03) is exposed as `_id.toHexString()`. Existing trading rows lose their original integer IDs entirely. Any external consumer (logs, exports) referencing trade IDs is broken. Retention's DELETE-by-id pass works (uses string-or-int either way) but historical trade IDs in logs become un-joinable. | Either (a) preserve original integer ID in a `legacy_id` field during backfill so support queries still work; or (b) document that pre-cutover trade IDs are abandoned and add a migration note to changelog. Currently, plan does neither. |
| 14 | HIGH | phase-01 §Functional, §Implementation step 7 | `compatibility_flags = ["nodejs_compat_v2"]`: per researcher report this is correct. BUT `nodejs_compat` (v1) and `nodejs_compat_v2` are NOT additive — picking v2 changes process/buffer/streams globals. Existing modules using `crypto.timingSafeEqual` (Phase 05 step 2) or other Node APIs may behave differently. Plan's mitigation "run full test suite after edit" only covers unit tests; suite uses fakes, not workerd. | Add: deploy a `wrangler dev` smoke that exercises every module's first command. Add a section in `docs/using-mongodb.md` listing every `node:` import the codebase uses and which surface they need. Otherwise a silent prod regression is plausible. |
| 15 | HIGH | phase-05 §Implementation step 2, §Security | `/__admin/dump-kv` returns raw values including TTL records. Plan correctly uses `crypto.timingSafeEqual` but does NOT specify what happens on token mismatch — bare `return 401` leaks timing via early-return before timing-safe compare runs. Also: the plan does not say where the route mount lives in `src/index.js` request flow vs the existing `/webhook` handler (route-ordering matters; admin routes must NOT be reached without auth). | Specify: (a) compare token first, before any branching by header presence; (b) return 401 with no body; (c) place admin routes BEFORE `/webhook` in the dispatcher with explicit order documented. (d) Set `X-Robots-Tag: noindex` to prevent caching/indexing. (e) Rate-limit (CF native or cheap counter in KV). |
| 16 | MEDIUM | phase-02 §Architecture connection memoization | `client = new MongoClient(...)` then `connectPromise = client.connect()` — if `connect()` REJECTS, both `client` and `connectPromise` stay populated. Next call: `if (client) return client.db(...)` — returns a Db on a dead client. All subsequent reads hang or fail with cryptic "no connection" errors. | On reject, null both. Pattern: `connectPromise = client.connect().catch(err => { client = null; connectPromise = null; throw err; })`. |
| 17 | MEDIUM | phase-02 §Implementation step 1 fake-mongo surface | Listed methods omit `find().sort().skip()` chaining (used by retention pattern from phase-03). Phase-08 §Unresolved Q1 says TTL semantics deferred. Surface inventory is incomplete. | Re-derive the surface from phase-03 handlers: insertOne, find/sort/skip/limit/project/toArray, distinct, deleteMany, countDocuments, createIndex. Audit phase-03 handlers against fake-mongo capabilities before phase-02 starts (ordering: phase-03 design completes the surface, then fake-mongo). |
| 18 | MEDIUM | phase-04 §Architecture flag matrix | Rollback case: "primary=mongo, dual=on" → revert to "primary=kv, dual=on". During the time between cutover (mongo-primary) and rollback decision, Mongo accepted writes that KV may have missed (if dual-write secondary failed silently). When primary flips back to KV, those writes are invisible. The flag matrix doesn't address this; phase-07 only handles the "Stage 2 with KV stale" version. | Document explicitly in phase-04: rollback to KV-primary AFTER any Mongo-primary period requires reverse-backfill of any new Mongo doc. Cross-link to finding #4. |
| 19 | MEDIUM | phase-01 §Functional + phase-08 §Lint check | `MONGODB_URI` rotation cadence: phase-08 unresolved Q5 leaves "quarterly proposed". Phase-01 says "rotate quarterly" without owner. Telegram secrets pattern says nothing about cadence either — there is no project-level secret rotation runbook. | Phase-08 Step in `using-mongodb.md`: add a runbook section "Rotation: every 90 days, owner = repo maintainer; calendar entry to be created in `docs/cost-tracking.md` review cycle". Or accept "rotate-on-suspicion-only" and document. |
| 20 | MEDIUM | phase-04 §Implementation step 7 | `wrangler.toml` already has `[vars] MODULES = "..."`. Adding `STORAGE_PRIMARY` and `DUAL_WRITE` to same `[vars]` block is correct. But phase-07 step 17 deletes them — and the phase-04 dual-store factory hard-reads them. After phase-07 deletion, factories must default sanely (no flags = mongo-only). Plan does not specify the post-deletion factory behavior beyond "simplify to Mongo-only (no flags)". | Pre-design the post-cutover `create-store.js` shape in phase-04 (so the simplification step is mechanical in phase-07). Specifically: factories default to `MongoKVStore`-only when flags absent; KV/D1 branches removed. |
| 21 | MEDIUM | phase-05 §Functional, sample size | Verifier samples 100 keys per module. With ≥1000 keys/module possible (loldle-emoji game state), 100/1000 = 10% sample. At 0.1% real corruption rate, expected mismatches = 0.1 — verifier reports PASS even with 1 in 1000 keys silently corrupted. | Increase sample to N=√(total) capped at 500. Or: full-scan compare on collections <10K docs (cheap on M0 with small data). |
| 22 | MEDIUM | phase-06 §Connection saturation criterion | "M0 connection peak ≤ 400 of 500". Plan says observed via Atlas dashboard but does not specify alerting or capture. Operator must check manually each day during 24-72h soak — easy to miss. | Add: enable Atlas free-tier alert email at "current connections > 400". Or: programmatic poll every 5min via Atlas Admin API (free tier supports). Pin in `using-mongodb.md`. |
| 23 | MEDIUM | phase-02 §Architecture connection options | `maxPoolSize: 1, minPoolSize: 0`. Single connection per isolate is correct for free tier, but the driver default `maxPoolSize=100` means in dev / locally, ops parallel to 100. `serverSelectionTimeoutMS: 5000` means a paused M0 cluster gives a 5s hang per cold start (acknowledged in Phase 06 abort but not as a normal-ops cost). | Document the latency budget: cold = 1500ms (TLS+SCRAM) + ≤5000ms (server selection) = up to 6.5s p99 absolute worst case. Make sure the abort gate at phase-06 (>3000ms = abort) accounts for this. The 3000ms threshold may be unreachable even in the green case. |
| 24 | MEDIUM | phase-03 §Implementation step 1 | "Grep `src/modules/trading/` for every `.run(`, `.all(`, `.first(`, `.prepare(`, `.batch(` call. Confirm only 6 SQL strings exist." Verified in this review: 6 found, 0 prepare/batch. But: the grep is on TODAY's code. If trading evolves before Phase 03 ships (developer adds a 7th query during dual-write deployment window), dispatcher silently fails to match → `Error("MongoSqlStore: pattern not matched")` thrown to user mid-handler. | Add to phase-04 dispatcher: log unmatched queries to telemetry, fall back to D1 read during dual-write (since D1 is still authoritative until Stage 2 starts). After Stage 2, unmatched queries are a hard failure that surfaces in Phase 06 monitor. |
| 25 | MEDIUM | phase-08 §Unresolved Q4 | "Cron heartbeat to prevent M0 auto-pause" — `wrangler.toml:43` has crons `["0 17 * * *", "0 1 * * *"]`. Misc module's `last_ping` write is on every ad-hoc command, not on cron. If bot has zero command activity for 30 days, M0 auto-pauses. The user has stated this is unlikely but plan should be deterministic, not probabilistic. | Add a cron schedule that explicitly writes `misc:last_ping` (or any Mongo doc) every 7 days. Verify it executes against Mongo (post-cutover, all writes go to Mongo, so the existing daily cron via `misc` module is sufficient — ONLY if any of those crons writes Mongo, which the misc module crons need to be confirmed to do). |
| 26 | LOW | phase-01 §Architecture cold path | "Cold path: ~1500ms (TLS + SCRAM + server selection)." Server-side region is `aws-ap-southeast-1`; CF PoP for VN traffic is also Singapore. Latency floor is ~10ms, so 1500ms is dominated by TLS+SCRAM. Reasonable estimate. No action; calling out for transparency. | None. |
| 27 | LOW | phase-04 §Test plan integration test | "Verify via `instanceof` check after exposing `_implementations` array on dual stores for testability." This is a code-smell — exposing internals for tests. | Use a sentinel string like `store._kind === "dual"` (one line, not arrays). Or test behavior end-to-end (write → read from one of two seams). |
| 28 | LOW | phase-08 §Functional doc list | `docs/development-roadmap.md` mentioned twice in plan (phase-08 §Functional + Todo). User feedback memory says "roadmap = future only" — completed migration must NOT be added; it should be REMOVED if currently listed. Phase-08 says "remove migration item per future-only convention" — correct. | None — already correct. Just flagging consistency. |
| 29 | LOW | phase-02 §Architecture document shape | `value` stored as string. CFKVStore stores strings via `kv.put(key, value)` where value is already serialized. Plan correctly mirrors this. But Mongo allows native typed values — slight space waste. Performance: `JSON.parse(doc.value)` per read. Acceptable; matches contract. | None — matches CFKVStore contract. |
| 30 | LOW | phase-07 §Implementation step 18 destructive ops | `npx wrangler kv namespace delete --namespace-id <id>` and `npx wrangler d1 delete miti99bot-db` are interactive on wrangler 4.x (require typed confirmation). Plan flags this in Risk row 4 ("Operator runs the destructive commands manually") — correct. | None — already addressed. |
---
## Per-section deep dives — BLOCKER + HIGH only
### Finding #1: `last_row_id` is consumed by an existing test
**File:** `tests/db/create-sql-store.test.js:48-52`
```js
it("returns changes and last_row_id on INSERT", async () => {
...
expect(result).toHaveProperty("last_row_id");
});
```
Plan-03 §Key Insights says "D1 returns `{ changes, last_row_id }` — last_row_id matters for trading? Grep confirms it's not consumed (insert path discards return). Confirm in step 1." This is technically true for `src/modules/trading/`, but the SqlStore CONTRACT (`sql-store-interface.js:19`) declares `last_row_id` as `number`, and the test enforces shape. Returning a hex string violates the existing contract. Decisions:
- (a) Keep contract as `number`. New `MongoSqlStore` returns `last_row_id: 0` for inserts (matches "no rowid" semantics). Existing test passes.
- (b) Loosen contract to `number | string`. Update test. Risky for any future caller doing arithmetic.
**Recommendation:** Pick (a). Document in phase-03 that `last_row_id` is non-meaningful in MongoSqlStore; if future caller needs the inserted ID, they must read `_id` from a separate read.
### Finding #2: `stubMongo` is a string, not a duck-typed binding
**File:** `phase-04-dual-write-wrappers.md:88-93`
Plan says: `export const stubMongo = "mongodb://stub-not-used";`
But `stubKv` is a duck-typed object with `.get`, `.put`, `.list` methods. The plan's matrix relies on every register-time path having `DUAL_WRITE=0` so `MongoClient` is never constructed with `stubMongo`. This is a tight coupling: any future change to factory branching (or accidental flag mis-set) will result in `new MongoClient("mongodb://stub-not-used")` which:
- Triggers DNS resolution attempt
- Hangs on `serverSelectionTimeoutMS=5000ms`
- Fails register, blocking deploy
Plan gives no test that asserts "stubMongo never reaches MongoClient". Build break risk is real.
**Recommendation:** Convert to duck-typed object stub:
```js
export const stubMongo = {
db() { return stubMongoDb; },
connect: async () => undefined,
close: async () => undefined,
};
```
Then `mongo-client.js getDb(env)` checks if `env.MONGODB_URI === STUB_SENTINEL` (a defined constant) and short-circuits. Add unit test in phase-04 that constructs the factory with stubMongo + each flag combination and uses `vi.spyOn(MongoClient.prototype, 'connect')` to assert ZERO connect calls.
### Finding #3: Retention dispatcher has 2 statements but plan describes 1 handler
**File:** `phase-03-mongo-sql-store.md:115` ("handles both 4 & 5 — one user-scoped, one global — distinguished by presence of WHERE")
The plan's normalized-prefix matching scheme breaks here:
- Statement 4: `SELECT id FROM trading_trades WHERE user_id = ? ORDER BY ts DESC LIMIT -1 OFFSET ?` (binds: `[userId, offset]`)
- Statement 5: `SELECT id FROM trading_trades ORDER BY ts DESC LIMIT -1 OFFSET ?` (binds: `[offset]`)
After normalization (collapse whitespace, trim, uppercase first 30 chars), prefix is `SELECT ID FROM TRADING_TRADES`. Identical. Dispatcher cannot distinguish on first 30 chars. Plan's "presence of WHERE user_id" is the actual signal — but plan doesn't say to inspect beyond 30 chars.
Bind layout differs:
- Stmt 4: `binds[0]=userId, binds[1]=offset`
- Stmt 5: `binds[0]=offset`
Handler logic must inspect WHERE presence to know how to read binds.
**Recommendation:** Phase-03 dispatcher must do:
1. Normalize full query (not just first 30 chars).
2. Match via regex: `WHERE\s+user_id\s*=\s*\?` → user-scoped, binds[0]=userId, binds[1]=offset.
3. Else if matches global pattern → binds[0]=offset.
4. Document the regex set as constants alongside the 6 statements. List explicitly:
```js
const SQL_PATTERNS = {
INSERT_TRADE: /^INSERT INTO TRADING_TRADES \(USER_ID, SYMBOL, SIDE, QTY, PRICE_VND, TS\) VALUES/,
HISTORY_QUERY: /^SELECT ID, USER_ID, SYMBOL, SIDE, QTY, PRICE_VND, TS FROM TRADING_TRADES WHERE USER_ID = \? ORDER BY TS DESC LIMIT \?$/,
DISTINCT_USERS: /^SELECT DISTINCT USER_ID FROM TRADING_TRADES$/,
RETENTION_USER_OFFSET: /^SELECT ID FROM TRADING_TRADES WHERE USER_ID = \? ORDER BY TS DESC LIMIT -1 OFFSET \?$/,
RETENTION_GLOBAL_OFFSET: /^SELECT ID FROM TRADING_TRADES ORDER BY TS DESC LIMIT -1 OFFSET \?$/,
DELETE_BY_IDS: /^DELETE FROM TRADING_TRADES WHERE ID IN \(/,
};
```
Add a test that mismatches one statement (e.g. extra space after `LIMIT`) and asserts the dispatcher errors loudly with "MongoSqlStore: pattern not matched" — before falling back to D1 (during dual-write) or failing (post-cutover).
### Finding #4: Stage-2 reverse-backfill is undocumented work
**File:** `phase-07-cutover-and-decommission.md` Stage 2 step 11 + Rollback table
Stage 2 deploys with `DUAL_WRITE=0`, so for 7 days, KV becomes stale. Rollback path: "Re-enable `DUAL_WRITE=1` + flip `STORAGE_PRIMARY=kv`. KV is stale by N days but recoverable via reverse-backfill (script not pre-written; build only if rollback needed)."
Building a reverse-backfill UNDER outage pressure is a known anti-pattern: untested code, no time to verify, possible data loss. The script can be written as straightforward inverse of `backfill-mongo.js`:
- For each Mongo collection, list all docs with `_id.startsWith("modulePrefix:")`
- For each, write to KV via `/__admin/inject-kv` route (auth-gated)
- Plus: tracking which keys were updated AFTER the cutover decision so the operator knows the pre-Stage-2 state vs post-Stage-2 deltas
**Recommendation:** Build `scripts/backfill-mongo-to-kv.js` and `scripts/backfill-mongo-to-d1.js` as part of phase-07 step 11 prerequisites (BEFORE Stage 2 deploys). Test against fakes with full coverage. List in plan as a non-optional artifact.
### Finding #5: mongodb driver bundle size vs CF Workers limit
**File:** `phase-01-atlas-setup.md` Risk row 5
CF Workers Free plan limit: **3 MiB compressed** worker bundle (10 MiB uncompressed for paid). The `mongodb` v6.7+ npm package includes:
- Core driver (~600KB minified)
- BSON (~300KB)
- SRV/SCRAM auth (~150KB)
- TLS/socket compatibility shims for nodejs_compat_v2 (~variable)
Researcher report says "~4-5 MB compressed" — over the free-plan limit.
If after `wrangler deploy --dry-run` the bundle exceeds 3 MiB, phase-01 has already committed:
- `wrangler.toml` change (compatibility flag)
- `package.json` change (mongodb dep)
- secrets created
- atlas cluster running
…and phase-02 cannot deploy. Operator must rollback all of phase-01.
**Recommendation:** Phase-01 step 8 (after `npm install mongodb`) MUST check: `npx wrangler deploy --dry-run --outdir=./.tmp-deploy && du -sh ./.tmp-deploy`. Hard gate at 2.7 MiB (10% headroom). If exceeds, abort phase entirely; pivot to Upstash plan (smaller HTTP-only client). Document in phase-01 §Success Criteria.
### Finding #6: list() prefix-strip ambiguity
**File:** `phase-02-mongo-kv-store.md` line 22 + line 76 contradict
Currently `create-store.js:65` does the strip:
```js
keys: result.keys.map((k) => (k.startsWith(prefix) ? k.slice(prefix.length) : k)),
```
CFKVStore.list() (cf-kv-store.js:71-72) returns keys WITH PREFIX. The wrapper strips. Plan-02 says "list() returns ... module namespace already stripped" (from interface JSDoc) but ALSO "keep prefix inside `_id`... the store doesn't strip prefixes."
The interface contract (`kv-store-interface.js:27`) literally says "module namespace already stripped" — but that's only true at the wrapper layer (createStore), not at the underlying CFKVStore. CFKVStore returns prefixed keys.
**Recommendation:** MongoKVStore returns prefixed keys (matches CFKVStore). The wrapper strips. Tests for MongoKVStore directly (without wrapper) assert prefixed keys returned. Tests for `createStore("wordle", env)` assert stripped keys. Make this explicit: phase-02 §Functional updated to "list() returns keys with module namespace **preserved**; the namespace wrapper in `create-store.js:65` strips the prefix."
### Finding #7: TTL stale-read window
**File:** `phase-02-mongo-kv-store.md` Risk row 2 + missing read-time check
The Mongo TTL background task runs every ~60 seconds. Document with `expirationTtl: 10` is still readable for up to 60s after expiration. CFKVStore + CF KV does NOT have this stale-read window — CF KV is consistent on read.
For game state (`expirationTtl: 7 days`), 60s of slack is invisible.
For short-lived caches (e.g. price feeds with `expirationTtl: 60s`), users could see "expired" cache entries.
**Recommendation:** MongoKVStore.get and getJSON filter at read time:
```js
async get(key) {
const doc = await coll.findOne({
_id: key,
$or: [
{ expiresAt: { $exists: false } },
{ expiresAt: { $gt: new Date() } }
]
});
return doc?.value ?? null;
}
```
Cost: minor query complexity; covered by `_id` index. Add explicit test: put with 1s TTL, sleep 2s, get returns null even before TTL sweeper fires.
### Finding #8: Dual-write divergence detection is one-shot
**File:** `phase-04-dual-write-wrappers.md` §Functional + phase-05 verifier is a manual run
Plan: secondary write fails → log + continue. Reconciliation = phase-05 backfill (skip-if-exists).
Issue: Phase-05 runs ONCE (or daily during soak). Between phase-05 runs, divergences accumulate. Backfill `$setOnInsert` only fills missing docs — it cannot catch a CF KV write that succeeded with newer state where a previous secondary write to Mongo failed (Mongo has stale OR no doc).
**Recommendation:** Add a recurring drift-verifier cron: every hour, sample N keys per module, compare hashes, log mismatches. OR: change failed-secondary-write handling to push the failed key + value onto a retry queue (small KV list at `__retry:mongo-failed`). A separate worker or cron drains the queue with retry. This decouples user-facing latency from secondary durability.
### Finding #9: Stage 2 single-region, no backups
**File:** `phase-07-cutover-and-decommission.md` Stage 2 + phase-01 Key Insight 5
Stage 2 keeps `DUAL_WRITE=0` for 7 days. M0 has NO backups + single-region. Atlas free-tier has ~99% SLA, not 99.9%. A 7-day window with M0 outage = data loss.
**Recommendation:** Phase-07 Stage 2 step 9.5 (insert): "Run `wrangler d1 export miti99bot-db` to local file at start of Stage 2 (snapshot 1) and end of Stage 2 (snapshot 2). Document in cutover-log." Plus: leave `DUAL_WRITE=1` for the first 24h of Stage 2 (overlap window), then flip on day 1 → soak 6 more days. Risk row in phase-07 explicitly addresses single-region M0.
### Finding #10: Telemetry helper has reference error
**File:** `phase-06-staged-deploy-and-soak.md:38-49`
```js
export function startTiming(env, cmd) {
const t0 = Date.now();
return {
mark(label) { /* push {label, dt} into local array */ },
end(extra = {}) {
const total = Date.now() - t0;
console.log(JSON.stringify({ event: "cmd_timing", cmd, total, ...extra, marks }));
}
};
}
```
`marks` is referenced but never declared. `mark(label)` is a no-op stub.
**Recommendation:** Fix snippet — `const marks = []` declared at top, `mark(label) { marks.push({label, dt: Date.now() - t0}); }`. This is a copy-paste-into-prod risk.
### Finding #11: Cold-start detection threshold
**File:** `phase-06-staged-deploy-and-soak.md:53` "Cold == age < 200ms"
Cold isolate boot = ~50ms.
First Mongo connect = ~1500ms.
By the time the FIRST request handler logs `isolate_age_ms`, age is already >200ms.
So `isolate_age_ms < 200ms` matches NEVER. All cold requests get bucketed as warm. Phase-06 measurement is broken.
**Recommendation:**
```js
let isFirstRequestInIsolate = true;
// inside handler:
const isCold = isFirstRequestInIsolate;
isFirstRequestInIsolate = false;
console.log({ event: "request", cmd, cold: isCold, total_ms });
```
`isolate_age_ms` is still useful for histogram ("how warm is warm?") but cold/warm bucketing must use the boolean.
### Finding #12: package.json deploy chain references migrate.js after deletion
**File:** `package.json:12` and `phase-07-cutover-and-decommission.md:19`
```json
"deploy": "npm run build && wrangler deploy && npm run db:migrate && npm run register",
"db:migrate": "node scripts/migrate.js",
```
Phase-07 step 19 deletes `scripts/migrate.js`. After deletion, `npm run deploy` fails: `node scripts/migrate.js` → ENOENT.
**Recommendation:** Phase-07 step 17 update: edit `package.json` to remove `&& npm run db:migrate` from `deploy` chain, and remove the `db:migrate` script entry. This is a 2-line edit but easily forgotten.
### Finding #13: Trading ID type drift on backfill
**File:** `phase-05-backfill-scripts.md` step 5 + `phase-03-mongo-sql-store.md:24`
D1 trade rows have `id: 1, 2, 3, ...`. After backfill, Mongo has `_id: ObjectId, id: <hex>`. The original integer 1, 2, 3 are gone. Any historical telemetry, logs, or external dashboards referencing trade IDs are orphaned.
**Recommendation:** During backfill, set both:
```js
{ _id: ObjectId(), legacy_id: row.id, ... }
```
And in `MongoSqlStore.adapt()`, expose `id` as the new ObjectId hex BUT keep `legacy_id` so support queries can still find historical trades. Document in phase-08 changelog: pre-cutover trade IDs preserved as `legacy_id`.
### Finding #14: nodejs_compat_v2 surface change
**File:** `phase-01-atlas-setup.md` Risk row 3
`nodejs_compat` (v1) and `nodejs_compat_v2` are NOT additive — they're alternatives. Switching changes the runtime's process/buffer/streams globals.
Codebase uses (search needed): `crypto.timingSafeEqual` (Phase 05), maybe `Buffer`, maybe `process.env`. The CLAUDE.md says register script uses `--env-file-if-exists` (Node 20.6+), but that's the deploy-time Node, not the worker. Worker doesn't use `process.env` (good).
**Recommendation:** Phase-01 step 7 add: `grep -rn "import.*node:\|process\.env\|Buffer\." src/` and document every Node API touched. Test each on `wrangler dev` before phase-02 begins. Add a section in `using-mongodb.md` listing surface dependencies.
### Finding #15: Admin route timing and ordering
**File:** `phase-05-backfill-scripts.md` step 2 + step 3
`crypto.timingSafeEqual` is called only after the route matches. The route matching itself (string compare path, header presence check) leaks timing. Plan does NOT specify:
- Where in the request flow `/__admin/*` mounts (before or after `/webhook`?)
- What the unauth response body is (could leak existence)
- Rate limiting
**Recommendation:**
- Mount admin router BEFORE `/webhook` to ensure no Telegram secret check is short-circuited.
- 401 response: empty body, generic `WWW-Authenticate: Bearer` header.
- Rate limit via KV counter (5 req/min/IP) — minor, but cheap.
- `crypto.timingSafeEqual` requires fixed-length input — pad short tokens or reject early with constant-time check on length.
- Add `X-Robots-Tag: noindex, nofollow` to prevent search crawlers.
---
## Unresolved questions
1. Bundle-size measurement: has anyone (the user) actually deployed `mongodb` v6.7 to a CF Worker on Free plan and confirmed it fits under 3 MiB compressed? Researcher report says "~4-5 MB compressed". This contradiction must be resolved before phase-01 starts. Recommend: do a smoke deploy of a minimal worker with `import { MongoClient } from "mongodb"` and capture `wrangler deploy --dry-run` size output. If >3 MiB on Free plan, abort the entire plan.
2. Existing `tests/db/create-sql-store.test.js:48-52` requires `last_row_id` to be present and equality-checkable. Decision: phase-03 returns `0` (number) for inserts, OR refactors the test? Plan does not say.
3. Misc module's existing daily cron: does it currently write KV? If yes, post-cutover it writes Mongo and prevents auto-pause. If no, M0 auto-pauses after 30 days idle. Phase-08 §Unresolved Q4 leaves this open. Action item: confirm misc cron handler writes any data on each invocation.
4. `wrangler dev` Mongo support: phase-01 step 9 implies `wrangler dev` works against Atlas via Workers Sockets API. This was added in 2025 and the researcher confirms — but real-world reliability matters. Recommend: a Phase 02 sub-step "test full CRUD against real Atlas from `wrangler dev` for ≥10 min". If it stalls / drops connections, dev experience is broken even if prod works.
5. M0 pause vs cron heartbeat: phase-08 §Unresolved Q4. Action: edit `misc/index.js` (or add a new `keep-alive` cron if misc doesn't write). Effort: 5 min. Should not be unresolved at this stage.
6. Stage-1 vs Stage-2 cutover decision criteria: Phase-07 lists soak duration but not abort criteria for Stage 1. What metric triggers Stage-1 rollback? "Daily verifier PASS" but `verify-mongo-parity.js` runs against a 7-day stable Mongo state where new writes happen — is there a definition of "PASS" that distinguishes "drift due to live traffic" from "drift due to bug"?
---
**Status:** DONE_WITH_CONCERNS
**Summary:** Plan is broadly sound and rollback-safe up to Phase 06, but has 5 blockers (last_row_id contract, stubMongo type, retention dispatcher ambiguity, missing reverse-backfill script, untested bundle size) and 10 high-severity gaps (TTL stale reads, divergence-detection one-shot, telemetry bug, cold-start detection threshold, package.json drift, ID type drift, etc.) that will cause implementation pain or silent prod regressions if not addressed before phase-01 starts. Recommend planner pass before code lands.
@@ -0,0 +1,102 @@
---
title: "Adversarial Failure-Mode Analysis — Atlas M0 Migration Plan"
date: 2026-04-25
scope: plans/260425-1945-mongodb-atlas-migration/ (phases 01–08)
---
# Atlas Migration: Failure-Mode Analysis
## 1. TL;DR — Top 3 by Likelihood × Impact
1. **Cold-start hang exceeds Worker CPU budget (scenario #1)** — likelihood HIGH × impact CATASTROPHIC. Research already shows ~1500ms TLS+SCRAM; Worker free plan CPU limit is 50ms (paid is 30s wall-clock). If the driver's initial TCP connect blocks the CPU thread, the isolate is killed before any response. Plan documents 1500ms latency but does NOT verify whether this is CPU-time or wall-clock time. One CPU-bound TLS frame could exceed the 50ms limit silently. No mitigation in any phase file.
2. **M0 auto-pause: 30-60s cold-wake hangs the bot completely (scenario #4)** — likelihood MEDIUM × impact CATASTROPHIC. Plan flags this as "unresolved question 4" in phase-08 and mentions it in phase-01/phase-06 risk tables, but the only mitigation listed is "Phase 02 must wrap connect() with timeout + retry; user-friendly error if pause-resume exceeds 30s." No timeout is actually specified in the `mongo-client.js` snippet (phase-02 shows `serverSelectionTimeoutMS: 5000`, which means after 5s the driver throws — but during that 5s the Worker is hung and cannot respond to Telegram, which will retry and create new requests that also hang). No user-facing error path is coded. The driver does NOT auto-retry a paused M0 wake; it fails with a server-selection timeout after the configured window.
3. **Backfill stale-data overwrite race (scenario #10)** — likelihood MEDIUM × impact SEVERE. The plan's ordering (`dual-write FIRST, then backfill`) is correct, but `$setOnInsert` (skip-if-exists) is documented as the backfill upsert strategy. However, `$setOnInsert` fires only when the document is being *inserted* (upsert: true, doc absent). If the document already exists from dual-write, backfill does nothing — correct. But if dual-write wrote doc A, backfill ran, then dual-write wrote doc B (update), then backfill re-runs (idempotent re-run), backfill's `$setOnInsert` does nothing (doc exists) — also correct. Race exists only during the original window where backfill reads KV and before dual-write has yet written that key to Mongo. The ordering in phase-05 says dual-write must be live first, which mitigates this. HOWEVER: the backfill reads from KV via the `/__admin/dump-kv` Worker route (phase-05), which reads through CFKVStore *directly* (bypassing the dual wrapper) at the time of the batch. If a key expires between KV read and Mongo upsert, Mongo gets a stale value that then persists beyond the original TTL unless `expiresAt` is correctly propagated — the plan states it IS propagated, but this is not explicitly verified in the verify script.
---
## 2. Failure Modes Table
| # | Scenario | Likelihood | Impact | Detection Time | Plan Coverage | Recommended Addition |
|---|----------|-----------|--------|----------------|---------------|----------------------|
| 1 | Atlas TLS handshake blocks Worker CPU budget (50ms free / 30s paid) | HIGH | CATASTROPHIC | Immediate on first deploy | NONE — plan conflates CPU-time with wall-clock time throughout | Test CPU-time specifically via `wrangler dev` profiling; document free-plan CPU limit constraint; add abort condition to phase-06: "if cold-start triggers CPU exceeded errors, abort" |
| 2 | Cold-start stampede on deploy exhausts M0 500-connection cap | MEDIUM | SEVERE | Minutes post-deploy (CF spawns 10–50 isolates) | PARTIAL — phase-01 mentions cap, phase-06 abort criterion is >400 connections, but no pre-deploy estimate of isolate count vs cap | Add pre-deploy synthetic burst test (phase-06 step 5.5): hit Worker with 20 parallel cold requests; observe Atlas connection counter in Atlas UI before proceeding |
| 3 | Atlas PoP-to-Atlas jitter spikes cold-start to 5s+ without crossing 3s P95 gate | MEDIUM | SEVERE | After hours to days (soak) | PARTIAL — phase-06 specifies P95 > 3000ms gate but instrumentation (`src/util/timing.js`) is not deployed until phase-06 step 1; gap between phase-04 deploy and instrumentation deploy means early soak hours produce no P95 data | Deploy timing.js in phase-04 or as part of phase-04 smoke test, not phase-06; ensures from-first-request telemetry |
| 4 | M0 auto-pause: 30-60s wake stalls all requests (no queuing, all hang) | MEDIUM | CATASTROPHIC | Next request after 30d inactivity | PARTIAL — phase-01 notes it, phase-06 abort criterion includes it, but driver behavior during wake is undocumented; `serverSelectionTimeoutMS: 5000` causes timeout exception after 5s, not a user message | Add explicit catch in `getDb()` for server-selection timeout → return 503 "Service temporarily unavailable" to Telegram; add a Mongo heartbeat cron (verify phase-07 §"Atlas auto-pause" risk uses `misc` cron — phase-08 unresolved Q4 flags this but doesn't resolve it) |
| 5 | M0 500-connection limit: cold isolate burst queues then times out | MEDIUM | SEVERE | Minutes under traffic spike | PARTIAL — phase-06 abort criterion ">400 connections" exists but detection relies on Atlas UI polling, not programmatic; no driver behavior on connection-limit documented | Document that MongoDB driver throws `MongoServerSelectionError` when connection limit hit; add this error class to phase-06 log search pattern; wire into abort decision |
| 6 | Atlas API token expired / MFA session expired mid-provisioning (phase-01) | LOW | MODERATE | Phase-01 execution | NONE — plan has no mention of Atlas API token lifecycle or MFA requirement | Add note in phase-01 step 1: "Atlas UI sessions expire; complete provisioning in one sitting; API token required for scripted ops has 30-day TTL" |
| 7 | `0.0.0.0/0` IP allowlist — security tradeoff not documented as permanent risk | LOW | MODERATE | Never (silent) | PARTIAL — phase-01 documents it but classifies mitigation as "SCRAM+TLS" without acknowledging that M0's connection count + SCRAM is the only auth barrier; no CF Workers static-IP plan | Phase-01 should note: the only upgrade path to tighter IP allowlist is CF Workers paid static egress IP add-on (~$10/mo); document cost in phase-08 cost-tracking.md |
| 8 | Network partition: KV write succeeds, Mongo write fails → silent divergence | MEDIUM | SEVERE | Only via verify:mongo script (manual, not automated) | PARTIAL — phase-04 logs secondary failures; phase-05 verify script is manual; no alerting if divergence accumulates between manual runs | Add automated daily verify run via cron (not manual invocation); OR add a divergence counter that triggers CF Observability alert threshold |
| 9 | Concurrent writes from different isolates: KV gets value A, Mongo gets value B (race) | LOW | SEVERE | Silent — may never be detected | NONE — plan does not address multi-isolate concurrent write races; KV is last-write-wins; Mongo is also last-write-wins; if both writes race, final state in KV ≠ final state in Mongo | Acknowledge in phase-04 risk table; for game state (non-transactional KV) this is acceptable; for trading (atomic intent), confirm `run()` is called only from single-user contexts |
| 10 | Backfill stale-data propagation: TTL not propagated → expired data persists in Mongo | MEDIUM | MODERATE | Only via hash-compare in verify (catches value mismatch not TTL mismatch) | PARTIAL — plan mentions TTL propagation but verify script checks value SHA256, not expiresAt field | Add to verify script: for each sampled key, also compare `expiresAt` bucket (not exact time, but present/absent and within ±5min) |
| 11 | KV lazy TTL vs Mongo 60s sweep: divergence detector flags valid state as mismatch | MEDIUM | LOW (false positives) | During verify runs | PARTIAL — phase-02 notes "tests assert field only, not deletion timing" but phase-05 verify uses count parity within 1%; if KV TTL keys haven't been swept yet, count may exceed Mongo | Acknowledge in verify script: count KV excluding expired (if API supports it) OR document that verify is run-after-traffic-drains (UTC 18:00) to minimize in-flight TTLs |
| 12 | Phase-07 "irreversible step" (step 18): no guard against accidental execution | LOW | CATASTROPHIC | After execution (too late) | PARTIAL — phase-07 documents step 18 clearly, requires operator to run manually, notes CLI interactive confirm; but the CLI `wrangler kv:namespace delete` and `wrangler d1 delete` do NOT always require interactive confirm in CI/scripted contexts | Add explicit `read -p "Type CONFIRM to delete: "` check in a wrapper script before calling wrangler delete commands; document: "never run these in CI automation" |
| 13 | Atlas outage at 4am during soak; bot dead for hours before user wakes | MEDIUM | SEVERE | Hours (no alerting) | PARTIAL — phase-06 abort criterion "Atlas outage > 5min" exists but relies on operator manually checking logs; no automated alert | Set up Atlas free-tier email alert for cluster unreachable; OR wire CF Observability alert: if >10% of requests return 500 in a 5-min window, send notification |
| 14 | Rollback after cutover: KV/D1 data is 7+ days stale | MEDIUM | SEVERE | On rollback attempt | PARTIAL — phase-07 Stage 2 rollback mentions "reverse-backfill (script not pre-written)"; 7-day game state loss is acknowledged as out-of-scope | Explicitly document in phase-07: "rollback after Stage 2 = game state loss for last N days; inform users." Consider if this is acceptable before executing Stage 2 |
| 15 | `/__admin/*` routes: ADMIN_TOKEN in URL (not header) or in server logs | LOW | HIGH | Never (silent credential leak) | PARTIAL — phase-05 specifies header-only auth (`X-Admin-Token`) and constant-time compare; BUT phase-05 step 2 says "log requests but NOT response bodies" — does not explicitly say NOT to log headers; access logs in CF Observability may include headers | Explicitly suppress `X-Admin-Token` header from CF Observability logs in the admin route handler; add to phase-05 security checklist |
| 16 | Backfill runs out of CPU mid-stream; resumability relies on KV cursor durability | LOW | MODERATE | Mid-run (script exits) | PARTIAL — phase-05 mentions KV cursor expires in 60s, handled by restart from last seen key; but "last seen key" is not checkpointed to disk — only in-memory | Add cursor checkpoint: write last processed key to a local file after each page; resume from checkpoint on restart |
| 17 | Integer IDs (D1 autoincrement) vs hex ObjectId: downstream comparison breakage | LOW | MODERATE | After cutover, if new feature uses id numerically | PARTIAL — phase-03 notes `id` is returned as hex string; grepping confirms `IN (...)` list usage is string-compare-safe; BUT: if any code does `id - 1` or numeric sort by id, it silently breaks | Add `grep -r "\.id\s*[-+*/<>]" src/modules/trading/` to phase-03 verification step; confirm zero arithmetic on id field |
| 18 | `MONGODB_URI` in `.env.deploy` not updated → register script breaks after password rotation | MEDIUM | MODERATE | Next deploy after rotation | PARTIAL — phase-01 documents two-copy problem (same pattern as TELEGRAM_BOT_TOKEN); phase-08 docs include rotation procedure; BUT no lint/check prevents deploy with stale URI | Add to phase-08 `check-secret-leaks.js`: also verify `.env.deploy` and CF secret URI share same host hash (not password, just host match) OR document a rotation runbook with explicit two-step instructions |
| 19 | `nodejs_compat_v2` enables Node globals; existing module checks for Workers env via their absence | LOW | MODERATE | Only in wrangler dev / production deploy (not in vitest tests) | PARTIAL — phase-01 risk table mentions it; "run full test suite after edit" is the mitigation; but vitest runs in Node so cannot detect this regression | Add explicit integration test with `wrangler dev` (not vitest) as part of phase-01 success criteria; specifically test grammY and each module's `init()` |
| 20 | `compatibility_date` bump contradiction: wrangler.toml already has `2025-10-01` which is AFTER `2025-03-20`; plan says "bump" but no bump is needed | LOW | LOW (plan confusion only) | Phase-01 execution | NONE — plan says "bump compatibility_date" but the current `2025-10-01` already satisfies the `>= 2025-03-20` requirement | Correct plan text in phase-01 step 7: "no compatibility_date change needed — current value `2025-10-01` already satisfies requirement"; note this to avoid a confusing no-op commit |
| 21 | `fake-mongo.js` does not simulate TTL; tests pass but production fails on TTL-sensitive paths | MEDIUM | MODERATE | Production only (tests always green) | PARTIAL — phase-02 explicitly documents this gap; phase-08 unresolved Q1 acknowledges it | Document which TTL scenarios are unverifiable in unit tests and MUST be validated in soak; add to phase-06 checklist: "trigger a game with TTL expiry and verify it behaves correctly" |
| 22 | MongoDB driver behaves differently in Workers (cloudflare:sockets) vs Node (net); unit tests pass but Worker fails | MEDIUM | SEVERE | Production only | NONE — architecture.md §12 "Pure-logic unit tests only. No workerd pool." — this is a known project constraint but the plan adds a TCP-socket driver that is specifically sensitive to this distinction | Add to phase-01 success criteria: successful `wrangler dev` smoke test with real Atlas (not just vitest); this is the only way to catch Workers-specific driver behavior |
| 23 | Alt-pivot path is a "TODO" — user is Atlas-all-in; if Atlas fails UX test, there is no ready Upstash plan | HIGH | MODERATE | Phase-06 abort | PARTIAL — plan mentions "Open new plan: plans/<date>-upstash-migration/ (TODO)" and references researcher report; but writing a new plan takes days; during that time bot is running degraded (dual-write adds ~1500ms p99) OR rolled back to KV/D1 quota exhaustion | Pre-write a phase-07-alt-pivot.md NOW with the Upstash swap steps from researcher report §"Next Steps"; it's 3-4 hours of work that eliminates a week of replanning under pressure |
---
## 3. Critical Gaps (Must Address Before Execution)
### GAP-A: CPU-time vs wall-clock time for TLS handshake (scenario #1)
**Evidence:** Cloudflare Workers free plan has a 50ms CPU time limit (not wall-clock). The mongodb driver's TLS handshake involves CPU-bound operations (TLS key exchange, SCRAM PBKDF2). If these exceed 50ms CPU, the Worker is killed silently — no error to user, Telegram retries, creating a stampede. The plan only discusses latency as wall-clock. The researcher report cites ~1500ms but does not specify what fraction is CPU-bound. Architecture.md §14 states `nodejs_compat` flag not needed currently — but adding `nodejs_compat_v2` (phase-01) moves the bot to a different execution context with different CPU accounting rules.
**Action required:** Before any code is written, verify with a minimal `wrangler dev` test that connects to Atlas and measures both wall-clock AND CPU time (`performance.now()` does not measure CPU; use `Date.now()` delta combined with CF dashboard CPU metric). If CPU time during cold connect approaches 50ms, the free plan is incompatible and migration must not proceed.
### GAP-B: No automated divergence alerting (scenarios #8, #13)
**Evidence:** The verify script is invoked manually. Phase-06 requires "daily `verify:mongo` runs via manual invocation". Phase-06 also requires operator to manually check Atlas UI for connection count. The bot is a personal project; the operator sleeps. An Atlas outage at 4am + no alert = silent bot death for 8+ hours. Phase-06 says "monitoring gap" is one thing to address but proposes no monitoring solution beyond "CF Observability built-in views".
**Action required:** Add to phase-06: set up at least one automated alert. Options: (a) CF Observability alert rule: >10 errors in 1 minute → email; (b) Atlas free-tier email alert for cluster unavailability. Both take 5 minutes to configure and require no code.
### GAP-C: M0 auto-pause wake behavior not tested before soak (scenario #4)
**Evidence:** Phase-01 mentions auto-pause behavior but defers handling to phase-02. Phase-02's `mongo-client.js` snippet sets `serverSelectionTimeoutMS: 5000` — this means a paused M0 will cause the driver to throw after 5s, not wait 60s for cluster wake. The user gets a 500 error, not a "please wait" message. Phase-08 unresolved Q4 asks "confirm one cron touches Mongo" — but the weekly cron heartbeat doesn't prevent pause (needs daily activity, not weekly). The cron schedule in wrangler.toml shows `"0 17 * * *"` and `"0 1 * * *"` — both daily, which would prevent pause IF they perform a Mongo write. But this is phase-08 unresolved Q4, not confirmed.
**Action required:** (a) In phase-01, add explicit test: pause the cluster manually in Atlas UI, then test first-request behavior. Confirm driver throws with a catchable error, not a hang. (b) Confirm daily cron writes to Mongo (not KV only) before declaring heartbeat sufficient. (c) In `mongo-client.js`, add specific catch for `MongoServerSelectionError` and return a 503 with Retry-After header.
### GAP-D: No pre-written Upstash pivot plan (scenario #23)
**Evidence:** plan.md §"Abort criteria" says "escalate to Upstash plan"; phase-06 §"Pivot Path" says "Open new plan: phase-07-alt-pivot.md (TODO)". The researcher report §"Next Steps (If Upstash Recommended)" provides a 5-step implementation guide that is ~3-4 hours of work. If Atlas fails the 24h soak gate, the user has no working game bot (KV quota still exhausted, Atlas degraded) and needs to plan Upstash under pressure.
**Action required:** Write `phase-07-alt-pivot.md` before starting execution. It does not need to be fully detailed — a 50-line stub with the Upstash steps from the researcher report is sufficient. Having it pre-written means the pivot can start within hours of an abort decision.
---
## 4. Quick Wins (Small Plan Additions, High Impact)
**QW-1 (30 min):** In phase-01 step 9 (smoke test route), add CPU-time measurement alongside wall-clock. Log both: `{wall_ms, cpu_note: "check CF dashboard CPU column"}`. Adds zero code complexity; creates evidence for decision.
**QW-2 (10 min):** Add Atlas free-tier email alert for cluster unavailability in phase-06 step 4. Zero code; closes GAP-B partially. Note: Atlas free tier allows alerting on cluster health events.
**QW-3 (15 min):** In `mongo-client.js` (phase-02), add explicit catch for `MongoServerSelectionError` with a comment: "M0 may be auto-paused; 5s timeout is correct; catch and log with actionable message." This does not fix the pause but gives the operator a clear log entry.
**QW-4 (20 min):** In phase-01 step 8 (risk table), add explicit note: "Worker free plan: 50ms CPU limit. Paid plan: 30s wall-clock. This migration requires Workers paid plan OR testing confirms TLS handshake CPU time < 40ms." This is a deployment-blocker check, not a code change.
**QW-5 (15 min):** Fix the `compatibility_date` confusion in phase-01 step 7. Current `2025-10-01` already satisfies `>= 2025-03-20`. Remove "bump compatibility_date" instruction; replace with "verify current date satisfies requirement (it does)." Prevents a confusing no-op commit.
**QW-6 (1 hour):** Pre-write `phase-07-alt-pivot.md` stub using the researcher report's 5-step Upstash guide. Labels it "STANDBY — execute only if phase-06 ABORT." Closes GAP-D.
**QW-7 (20 min):** In phase-05 `dump-routes.js` auth middleware, explicitly suppress `X-Admin-Token` from any access log: `const token = request.headers.get("X-Admin-Token"); /* do NOT log token value */`. One comment line; prevents accidental CF Observability header capture.
**QW-8 (30 min):** In phase-06, add `wrangler tail --format=json | grep "dual-write:secondary:failed"` as a manual monitoring step during soak. This surfaces the divergence signal in real-time without waiting for daily verify runs.
---
## Unresolved Questions
1. Is the Worker on the free plan or paid plan? This is critical for scenario #1 — free plan's 50ms CPU limit may be incompatible with TLS handshake CPU cost. The plan never states which plan is in use. (wrangler.toml has no `[limits]` or `account_id` override.)
2. What is the actual daily active user count + ops/day? Phase-06's connection saturation analysis assumes "typical CF replication factor = ~50–100 isolates". This is a guess, not a measurement. If the bot has 5 users, connection exhaustion is irrelevant. If it has 200, it matters.
3. Does M0 in `aws-ap-southeast-1` have a different SRV lookup latency from CF's Singapore PoP vs US PoPs? The researcher cites ~1500ms for US; SEA-optimized routing may be different (better or worse). No benchmark cited for this specific PoP combination.
4. The `trading` module uses `retention.js` which runs a retention DELETE job. This job is presumably triggered by cron. After cutover, does the cron handler receive a `MongoSqlStore` or `CFSqlStore`? Phase-06 §"Soak data flow" shows `dispatchScheduled` creating stores, but `cron-dispatcher.js` creates fresh stores per invocation (confirmed by architecture.md §9). The store type depends on env flags — if `STORAGE_PRIMARY=kv` during soak, cron jobs read from KV and write to KV, not Mongo. Trading retention running against KV while trading history reads from KV (correct), but Mongo trading_trades may have stale records not being cleaned up. Plan does not address cron jobs during dual-write window.
---
**Status:** DONE_WITH_CONCERNS
**Summary:** 23 failure modes identified; 4 critical gaps require pre-execution action; most severe unaddressed risk is CPU-time vs wall-clock ambiguity for the TLS handshake, which could make the migration impossible on the free Worker plan regardless of latency.
@@ -0,0 +1,410 @@
# MongoDB Atlas Fit Analysis for miti99bot — Cloudflare Workers
## Executive Summary
**Recommendation: DO NOT use MongoDB Atlas M0 for this project.**
Atlas M0 (Free, 512MB) **cannot solve the stated KV quota pain** — it has **100 ops/sec throughput** but **NO daily cap**, which sounds good until you account for:
1. **Cold-start cost** — Every fresh isolate incurs ~1500ms TLS handshake + SCRAM auth. No connection pooling across stateless invocations.
2. **No money-for-slots trade-off** — Upstash and Turso are HTTP-based, eliminating TLS overhead and working natively in Workers.
3. **Operational fit mismatch** — The bot is read-heavy game state + append-only trading ledger. KV + D1 are already optimized for this; Atlas adds complexity without benefit.
**Better option: Upstash Redis** — FREE tier: 500K commands/month (was 10K/day), unlimited storage in free tier, HTTP-native, memoizable connection per isolate, proven on Workers. **Backup: Turso** for SQL-shaped data (trading module move away from D1).
---
## Problem Statement
Current storage:
- **Cloudflare KV**: 100k reads, 1k writes/day free (resets UTC 00:00)
- **Cloudflare D1**: 1 query/sec free tier
- **Workload**: 13 active modules, many KV-read-heavy (games, schedules), 1 append-only SQL (trading)
- **Pain**: Even modest daily traffic exhausts KV quota mid-game; users hit quota-exhausted errors
User hypothesis: MongoDB Atlas Free (M0) lifts the cap. Reality: **M0 has a throughput cap (100 ops/sec) not a daily-quota cap**, and the architecture cost is substantial.
---
## Option Evaluation
### 1. MongoDB Atlas M0 (Proposed)
#### Specification
- **Storage**: 512 MB (plenty for bot state)
- **Throughput**: 100 ops/sec (no daily operation limit)
- **Connections**: 500 max
- **Regions**: Multi-region (AWS us-east-1, eu-west-1, etc.)
- **Cost**: Free; auto-pauses after 30 days inactivity
- **Deprecated features**: Backups, server-side JS, sharding, auditing
#### Cloudflare Workers Compatibility ✅ (as of March 2025)
**Yes, but with caveats.** Cloudflare shipped `node:net`, `node:tls` (TLS socket support) in Q1 2025.
**Requirements:**
- `wrangler.toml`: `compatibility_flags = ["nodejs_compat_v2"]` + `compatibility_date = "2025-03-20"`
- Driver: `npm install mongodb` v6.7+
- Connection string: `mongodb+srv://user:pass@cluster.mongodb.net/db` (works; explicit-host strings also work)
- Auth: SCRAM over TLS (native)
**Evidence:** [Cloudflare Workers and MongoDB (March 2025)](https://alexbevi.com/blog/2025/03/25/cloudflare-workers-and-mongodb/) demonstrates full CRUD: drop, insert, query operations.
#### Operational Trade-offs
| Dimension | Status |
|-----------|--------|
| **Cold-start cost** | **❌ SEVERE** — ~1500ms TLS+SCRAM per new isolate; Cloudflare Workers are stateless per-invocation, so every cold start = new connection = handshake latency visible to user |
| **Connection pooling** | **❌ IMPOSSIBLE** — Cloudflare Workers V8 isolates have no shared connection pool; memoizing a MongoClient per isolate helps warm requests but cold starts still stall |
| **Driver bundle size** | ~4-5 MB compressed (mongodb npm pkg); moderate but adds to bundle cost |
| **TLS root CA** | ✅ Bundled in driver; no manual CA setup needed |
| **Durable Objects support** | ⚠️ Possible but adds complexity; DO provides in-memory queue to batch inserts, but defeats simplicity goal |
| **Local dev (`wrangler dev`)** | ✅ Works as-is; Workers Sockets API routes TCP to Atlas |
| **Transactions** | ❌ Not on M0; single-document ACID only |
| **Change streams** | ❌ Not on M0 |
| **GridFS** | ❌ Not on M0 |
#### Cost-Benefit for miti99bot
**Against:**
1. **Throughput bottleneck is fake** — 100 ops/sec is enough for this workload (12 active modules, casual daily traffic), but doesn't justify the architecture cost
2. **Cold-start latency** — User sends `/wordle` command → Telegram → Worker cold-start → TLS+auth to Atlas = added 1500ms+ wait. KV is instant (cached). Upstash is HTTP (200-300ms).
3. **No improvement over KV quota** — User's real pain is hitting the 1k/day write limit. Atlas removes that cap but adds latency. Upstash removes both.
4. **Operational debt** — Miti99bot was built around KV/D1 abstractions. Swapping to MongoDB means:
- Rewriting `src/db/create-store.js` → `mongodb-store.js`
- Rewriting every module's data model (arrays → collections, expiry → TTL indexes)
- No transactions → can't safely increment leaderboards atomically
- Unfamiliar failure modes (connection errors, timeouts, replica-set failovers)
**For:**
- "Real" database (schemas, indexes, aggregation pipeline)
- Free tier is permanent (unlike some services)
- Strong community + documentation
---
### 2. Upstash Redis (Recommended Primary)
#### Specification
- **FREE tier (March 2025)**:
- 500K commands/month (was 10K/day, increased 50x)
- Unlimited storage in free tier
- 1GB data limit on free tier (soft; upgrade if exceeded)
- 200 GB bandwidth/month free
- **Cost**: $1 / million commands on paid tier ($0.0000000001 per command); free tier never bills
- **Auth**: Token-based HTTP (no TCP)
- **Regions**: global edge locations
#### Cloudflare Workers Compatibility ✅ (Native)
**Perfect fit.** Upstash is **designed for Workers.**
**Setup:**
- `npm install @upstash/redis`
- Environment: REST endpoint + token (HTTP auth, no TLS handshake cost)
- `wrangler.toml`: no special flags needed; standard env vars
**Evidence:**
- [Use Redis in Cloudflare Workers (Upstash docs)](https://upstash.com/docs/redis/tutorials/cloudflare_workers_with_redis)
- [Cloudflare Workers database integration with Upstash](https://blog.cloudflare.com/cloudflare-workers-database-integration-with-upstash/)
- [New Pricing (March 2025)](https://upstash.com/blog/redis-new-pricing) — 500K cmd/month free
#### Operational Trade-offs
| Dimension | Status |
|-----------|--------|
| **Cold-start cost** | ✅ MINIMAL — HTTP-based, no TLS handshake; typical latency 50-200ms to nearest Upstash PoP |
| **Connection pooling** | ✅ NOT NEEDED — Stateless HTTP; no per-connection overhead |
| **Memoization** | ✅ Cache client per isolate (module-scope static) for warm requests |
| **KV abstraction fit** | ✅ PERFECT — Redis strings/hashes are KV-shaped; `@upstash/redis` API mirrors `getJSON`/`putJSON` |
| **Expiry (TTL)** | ✅ Redis `EX` / `EXAT` — supported natively |
| **Transactions** | ✅ Redis multi/exec (ACID per-key) |
| **Data types** | ✅ Strings, hashes, lists, sets, sorted sets (richer than KV) |
| **Persistence** | ✅ Redis is durable; data survives restarts |
| **Local dev** | ✅ Works in `wrangler dev` with real Upstash instance (not local redis needed) |
| **Operations limit** | ✅ 500K/month = ~16,600 ops/day free; daily quota of games (wordle, loldle×5, trading, etc.) is ~2-5k reads + 500-1k writes = within quota |
| **Cost overflow** | ✅ If bot goes viral: $1/million commands on overage; at 100k ops/day = $3/month extra |
#### Fit for Modules
| Module | Current | Proposed | Notes |
|--------|---------|----------|-------|
| `wordle`, `loldle*`, `lolschedule`, `semantle`, `doantu`, `twentyq`, `misc` | KV store | Upstash (string/hash) | Direct swap; prefixing works |
| `trading` | D1 + KV | **Turso** (SQL) | Append-only log + leaderboard queries; SQL better fit |
| `util` | KV | Upstash | Subscription lists, daily state |
**Trade-off:** Lose `list()` pagination (KV has it; Upstash Redis does not). Solution: use Redis `KEYS` pattern matching or maintain an explicit set of subscription keys.
#### Cost Breakdown (Realistic Scenario)
Assume 5k reads + 1k writes/day (conservative for active bot):
- **Monthly ops**: (5k + 1k) × 30 = 180,000 → **under 500k free limit ✅**
- **Bandwidth**: Game assets (loldle splash art, etc.) ~10MB/day → 300MB/month → **under 200GB free limit ✅**
- **Cost**: $0 (free tier)
- **Overflow risk** (10x traffic): 1.8M ops → $1.80/month ✅
---
### 3. Turso (libSQL) — SQL Alternative / D1 Replacement
#### Specification
- **FREE tier**:
- 1 billion row reads/month
- 25 million writes/month
- 5 GB storage
- 3 databases
- 3 locations (replication)
- **Cost**: Pay-as-you-go ($0.000002/read, $0.00002/write after free tier)
- **Protocol**: HTTP (REST API via `@libsql/client`)
- **Advantage**: SQLite-compatible, edge-hosted
#### Cloudflare Workers Compatibility ✅ (Native HTTP)
**Setup:**
- `npm install @libsql/client`
- Standard HTTP; no TCP needed
**Evidence:** [Turso + Cloudflare Workers](https://developers.cloudflare.com/workers/tutorials/connect-to-turso-using-workers/)
#### Trade-offs for miti99bot
| Dimension | vs. KV/D1 | vs. Upstash |
|-----------|-----------|------------|
| **For game state (KV-shaped)** | ⚠️ Overkill — SQL adds latency (prepare → bind → execute) vs. simple get/put | ⚠️ SQL is slower than Redis for simple lookups |
| **For trading ledger (SQL-shaped)** | ✅ Natural fit; D1 equiv + cheaper | ✅ SQL is natural; better than Redis (which lacks JOIN) |
| **Replication** | ❌ D1 is zoned; Turso is geo-replicated | ✅ Geo-replication on free tier |
| **Cold-start** | ✅ HTTP (same as Upstash) | ✅ HTTP |
| **Operations ceiling** | ✅ 1B reads/month >> KV 100k/day | ✅ 1B reads/month >> game load |
| **Storage** | ✅ 5GB >> 512MB KV | ✅ 5GB >> game state |
| **Transactions** | ❌ D1 transactions not on Turso free (?) | Need to verify |
**Practical use:** Keep Upstash for KV (games, state); move `trading` table to Turso. Or go all-in on Turso and use SQLite tables as KV (slower but unifies stack).
---
### 4. Neon (Postgres) — SQL Alternative
#### Specification
- **FREE tier** (Oct 2025 update):
- 100 CU-hours/month (0.25 CU = 400 hours continuous, 1 CU = 100 hours)
- 0.5 GB storage per branch
- 5 compute units per month (tiny)
- **Cost**: $14/month pro plan
- **Advantage**: Full PostgreSQL semantics; better for complex analytics
#### Cloudflare Workers Compatibility ⚠️ (Hyperdrive Required)
**Setup:** Neon + [Cloudflare Hyperdrive](https://developers.cloudflare.com/workers/observability/) (connection pooling). Hyperdrive is included free on all Workers plans.
**Trade-offs:**
- ✅ Full SQL power
- ⚠️ Free tier is weak (0.5 GB storage, 100 CU-h/mo) — trading module alone could exhaust this
- ⚠️ Hyperdrive adds latency (one more hop)
- **Not recommended for miti99bot** — overkill for the workload
---
### 5. PlanetScale (MySQL) — Quick Mention
- **FREE tier**: One serverless cluster, 1 GB storage, 1M row reads/month
- **Workers compatible**: Yes, via Hyperdrive or `@planetscale/database` client
- **Trade**: Like Neon, overkill for bot state. Better for e-commerce platforms.
---
### 6. Status Quo + Optimization (KV Batching)
Can the bot survive on current KV + D1 by batching writes and using Durable Objects?
#### Approach
- **Durable Objects** as write coordinator: batch 5-10 game turns into 1 KV write
- Trade: adds 50-200ms latency for coordination
- Complexity: need to manage DO lifecycle, billing (Durable Objects are $0.15/million requests + $0.15/GB storage)
#### Reality Check
- Solves the **daily quota problem** (batches reduce write count by 80%)
- But **adds latency** (DO round-trip)
- **Costs more** than Upstash free tier ($0 vs. $0.15/million reqs + storage)
- **Operational debt** — Durable Objects are stateful; risk of stale writes if DO crashes
**Verdict:** Not recommended. Upstash is simpler + cheaper + faster.
---
## Recommendation Ranking
### 🥇 **Primary: Upstash Redis**
**Rationale:**
1. **Solves the pain** — 500K cmd/month free = 16.6k/day = 10x current KV quota
2. **Minimal latency** — HTTP native; no TLS handshake cost
3. **KV-compatible** — Direct `@upstash/redis` drop-in for current `KVStore` interface
4. **Free** — Overflow cost is negligible ($3/month at 10x traffic)
5. **Proven** — Officially supported by Cloudflare, battle-tested on Workers
**Implementation effort:** 3-4 hours
- Implement `upstash-store.js` (KVStore wrapper around `@upstash/redis`)
- Update `create-store.js` to instantiate Upstash
- Update `wrangler.toml` (add env vars)
- Swap KV namespaces → Upstash endpoint + token
- Test `npm test` (should pass with fakes unchanged)
**Risk:** Low. Redis is simpler than MongoDB; no transactions needed for games.
---
### 🥈 **Secondary (Future): Turso for Trading Module Only**
**When to do:** After Upstash is live, if trading module grows (leaderboard queries, filtering by date range).
**Plan:**
- Keep Upstash for game state (KV)
- Move `trading_trades` table to Turso
- Rationale: SQL is better for aggregates (top 10 traders, this month's stats)
**Implementation effort:** 2-3 hours
- Implement `turso-sql-store.js`
- Migrate `trading` migrations from D1 to Turso
- Test
---
### ❌ **Reject: MongoDB Atlas M0**
**Why not:**
1. **Cold-start latency** — TLS handshake adds 1-2s per cold start; game commands feel slow
2. **No quota relief** — Throughput is 100 ops/sec (fine), but no daily op cap (irrelevant; current bottleneck is KV quota, not throughput)
3. **Architectural mismatch** — Bot is read-heavy game state + append-only log; KV + D1 are already optimized; MongoDB adds schema management overhead
4. **Operational complexity** — SCRAM auth, connection pooling issues, single-document ACID only (no leaderboard transactions)
5. **Zero upside** — Upstash does everything Atlas does for this workload, faster, cheaper, simpler
---
## MongoDB Driver Specifics (If You Ignore This Recommendation)
If the team insists on MongoDB, here's what works:
### Requirements
- **Node.js driver**: v6.7+ (`npm install mongodb@^6.7.0`)
- **wrangler.toml**:
```toml
compatibility_date = "2025-03-20"
compatibility_flags = ["nodejs_compat_v2"]
```
- **Env var**: `MONGODB_URI = "mongodb+srv://user:pass@cluster-hash.mongodb.net/db"`
### Cold-Start Cost
- First request per isolate: ~1500ms (TLS + SCRAM auth)
- Subsequent requests on same warm isolate: ~50-100ms (connection reused)
- **Problem**: Cloudflare Workers can spawn new isolates at any time; you can't rely on warmth
### Memoization Pattern
```js
let mongoClient = null;
export async function getMongoClient(env) {
if (!mongoClient) {
mongoClient = new MongoClient(env.MONGODB_URI, {
maxPoolSize: 1, // minimize resource use
minPoolSize: 0, // no background connections
serverSelectionTimeoutMS: 5000,
});
await mongoClient.connect();
}
return mongoClient;
}
```
**Caveat:** This doesn't solve cold-start latency; it just reuses the connection on warm requests.
### Connection String Format
- ✅ `mongodb+srv://...` works (Cloudflare resolves SRV records via DNS)
- ✅ `mongodb://shard0.host,shard1.host:27017` works (explicit hosts)
- ✅ TLS is default; root CA bundled in driver
### Known Limitations
- ❌ No replica-set sessions (sharded transactions)
- ❌ No change streams
- ❌ No GridFS
- ❌ M0 has no backups, auditing, or custom auth mechanisms
### Local Dev
- `wrangler dev` → Workers Sockets API → Atlas (works as-is)
- No `--local` flag needed; no manual routing required
---
## Comparison Matrix
| Dimension | KV (Status Quo) | Upstash | Turso | Neon | **Atlas** |
|-----------|-----------------|---------|-------|------|-----------|
| **Daily quota** | 100k reads, 1k writes | 500k cmd/mo | 1B reads, 25M writes | 100 CU-h/mo | Unlimited (100 ops/sec) |
| **Cold-start latency** | Instant (cached) | ~100ms HTTP | ~100ms HTTP | ~200ms Hyperdrive | **~1500ms TLS** |
| **KV fit** | ✅ Native | ✅ Perfect (Redis) | ⚠️ SQL overhead | ⚠️ SQL overhead | ⚠️ Document model |
| **SQL fit** | ❌ None | ❌ No SQL | ✅ SQL | ✅ PostgreSQL | ⚠️ BSON |
| **Memoization** | N/A | ✅ HTTP client | ✅ HTTP client | ✅ Pooled | ⚠️ Connection pooling hard |
| **Free cost** | $0 (quota limit) | $0 | $0 | $0 (weak quota) | $0 (auto-pause after 30d) |
| **Operational complexity** | Low | Low | Low | Medium | **High** |
| **Workers native** | ✅ KV binding | ✅ HTTP | ✅ HTTP | ⚠️ Hyperdrive | ⚠️ Node.js compat |
| **Risk level** | Low (proven) | Low (proven) | Low (proven) | Medium (big compute) | **High (cold-start, pooling)** |
---
## Adoption Risk Assessment
### Upstash
- **Maturity**: Production-ready (2018+, YC-backed)
- **Breaking changes**: Rare; API stable since 2021
- **Abandonment risk**: Very low (funded, profitable, enterprise customers)
- **Community**: 10k+ GitHub stars; active Discord
### Turso / libSQL
- **Maturity**: 2023+; newer but growing fast
- **Breaking changes**: Some API churn early on, stabilizing
- **Abandonment risk**: Low (Chiselstrike backing, open-source)
- **Community**: 5k+ GitHub stars; smaller but active
### MongoDB
- **Maturity**: Highly stable (20+ years)
- **Breaking changes**: Rare
- **Abandonment risk**: None (massive company)
- **Community**: Massive
- **Problem**: Not designed for serverless; Cloudflare compat is **new and fragile** (Q1 2025)
---
## Unresolved Questions
1. **Turso transaction support on free tier** — Need to confirm if free tier allows `BEGIN`/`COMMIT` or only single-statement atomicity.
2. **Upstash key namespace collision across modules** — If two modules both use a key named `state`, does Upstash handle prefixing? (Likely yes, but need to test)
3. **Redis KEYS pattern on large free tier storage** — If bot accumulates 1GB in Upstash free tier, will `KEYS wordle:*` scans become slow? (Probably fine; Upstash is optimized for this)
4. **MongoDB cold-start in production** — The blog post tests locally; real-world Cloudflare production cold-start latency is unknown. Need real-world telemetry.
5. **Durable Objects write coalescing** — Can DO batch the writes below into a single KV transaction? (Answer: yes, automatic; but do we need it for games?)
---
## Next Steps (If Upstash Recommended)
1. **Create `src/db/upstash-store.js`** — Implement `KVStore` interface using `@upstash/redis`
2. **Update `src/db/create-store.js`** — Swap `new CFKVStore(env.KV)` → `new UpstashStore(env)`
3. **Update `wrangler.toml`** — Remove KV namespace bindings; add `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`
4. **Test locally** — `npm test` (fakes unchanged; tests should pass)
5. **Deploy** — `npm run deploy`
6. **Monitor** — Track Redis command usage via Upstash console; should stay under 500k/mo
**Estimated effort**: ~4-6 hours (including testing + docs update).
---
## Sources Cited
1. [Cloudflare Workers and MongoDB (March 2025)](https://alexbevi.com/blog/2025/03/25/cloudflare-workers-and-mongodb/)
2. [A Year of Improving Node.js Compatibility in Cloudflare Workers](https://blog.cloudflare.com/nodejs-workers-2025/)
3. [MongoDB Atlas Free Cluster Limits (Official Docs)](https://www.mongodb.com/docs/atlas/reference/free-shared-limitations/)
4. [Cloudflare Workers KV Pricing & Limits](https://developers.cloudflare.com/kv/platform/pricing/)
5. [New Pricing and Increased Limits for Upstash Redis (March 2025)](https://upstash.com/blog/redis-new-pricing)
6. [Use Redis in Cloudflare Workers (Upstash Docs)](https://upstash.com/docs/redis/tutorials/cloudflare_workers_with_redis)
7. [Cloudflare Workers Database Integration with Upstash](https://blog.cloudflare.com/cloudflare-workers-database-integration-with-upstash/)
8. [Turso + Cloudflare Workers Integration](https://developers.cloudflare.com/workers/tutorials/connect-to-turso-using-workers/)
9. [Neon with Cloudflare Workers (Docs)](https://developers.cloudflare.com/workers/databases/third-party-integrations/neon/)
10. [Connect to Databases (Cloudflare Workers Docs)](https://developers.cloudflare.com/workers/databases/connecting-to-databases/)
11. [Cloudflare Durable Objects — Write Coalescing](https://developers.cloudflare.com/durable-objects/concepts/what-are-durable-objects/)
@@ -0,0 +1,783 @@
# MongoDB Atlas Data Layer: Schema Design & Migration Plan
**Status:** Research-only. No assumptions made on whether Atlas is chosen; schema designs are transferable to other document stores.
**Date:** 2026-04-25
**Scope:** Data layer architecture for `miti99bot` assuming MongoDB Atlas backend; includes M0 cost ceiling, KV/SQL schema mappings, dual-write strategy.
---
## 1. M0 Hard Ceiling (Specifications)
### Storage
- **512 MB** hard limit (includes data + indexes)
- Index overhead: ~5–10% per index. TTL indexes add minimal overhead (~1–2% per collection).
- No tiered storage; no compression options on M0.
### Connections
- **500 concurrent connections** shared across all Workers.
- No per-project or per-cluster overrides.
### Throughput
- No explicit ops/sec cap published; shared resources mean degradation under heavy load.
- Typical M0: 100–200 ops/sec sustained before queueing (non-SLA).
### Network
- No egress cap; egress charges apply to paid tiers only.
- No cross-region replication on M0 (M0 = single region only).
### Backup / PITR
- **No backups** on M0. Single-point-of-failure design.
- Snapshots not available.
- Implications: Migrations must be append-only or dual-write validated.
### Regions (M0 Support)
M0 available in: **aws-eu-west-1** (Ireland), **aws-ap-southeast-1** (Singapore), **aws-ap-southeast-2** (Sydney), **aws-us-east-1** (N. Virginia), **aws-us-west-2** (Oregon).
- **For miti99bot:** Recommend **aws-ap-southeast-1** (Singapore) — lowest latency to Cloudflare SEA PoPs; alternative **aws-eu-west-1** if EU users dominant.
- Cloudflare Workers run in 275+ edge locations globally; data gravity favors nearest geographic center.
### Upgrade Path (M0 → Paid Tiers)
- **M0 → Flex Tier (recommended):** $8–$30/month, auto-scales, includes backups.
- **M0 → M10 (dedicated):** $57/month, fixed capacity, backups, multi-region replication.
- M2/M5 (shared tiers) **deprecated as of 2026** — no longer available for new projects.
### When M0 Hits Ceiling?
| Metric | Current miti99bot | M0 Limit | Runway |
|--------|---|---|---|
| Storage | ~1 MB/month (615 KB KV + 300 KB D1 trades) | 512 MB | **512 months** (43 years) |
| Connections | ~10–50/min bursts | 500 | **10x headroom** |
| Ops/sec (nominal) | ~5–20 ops/sec sustained | ~100–200 | **5–40x headroom** |
**Verdict:** M0 sufficient for 2–3 years at current growth. Monitor: (1) active user count, (2) trades/day (trading table grows fastest), (3) API cache churn (lolschedule, trading prices).
---
## 2. KV → Document Schema
### Design Decision: Per-Module Collections vs. Shared KV Collection
**Choice: Per-module collections** (one collection per module).
**Rationale:**
- **Separation of concerns** — indexes scoped per module; smaller collections reduce scan overhead.
- **Query isolation** — each module's `_id` index is independent; no compound key overhead.
- **Operational clarity** — `db.wordle.find()` vs. `db.kv.find({module: "wordle"})`.
- **Cons** — 13 collections instead of 1 (schema complexity); requires per-module migration.
**Alternative rejected:** Shared `kv` collection with `{module, key}` compound `_id`.
- Pro: single schema.
- Con: noisy queries, shared index, harder to reason about data size per module.
### Document Shape
```javascript
{
_id: "<key>", // string, e.g. "games:42"
value: <JSON string>, // serialized (matches today's putJSON)
expiresAt: ISODate | undefined // for TTL; absent = no expiration
}
```
**Why store `value` as string (not native BSON object)?**
- Today: KVStore uses `putJSON(key, obj)` → serializes to string internally.
- Preserves round-trip fidelity for nested objects, arrays, null values.
- Avoids schema drift (BSON doesn't have a true null field vs. missing field distinction).
- Trade-off: Slightly higher query cost (string parsing in app layer). Mitigated by per-module collections (smaller indexes).
**Example documents:**
```javascript
// wordle game state
{ _id: "games:42", value: "{\"word\": \"apple\", \"guesses\": [...]}", expiresAt: ISODate("2026-04-26T00:00:00Z") }
// loldle stats (no expiration)
{ _id: "stats:42", value: "{\"wins\": 5, \"streak\": 2}" }
// trading portfolio
{ _id: "user:123", value: "{\"vnd\": 50000, \"holdings\": [{\"symbol\": \"ACB\", \"qty\": 100}]}" }
```
### TTL Index (for expirationTtl support)
Create on every module collection:
```javascript
db.<module>.createIndex(
{ expiresAt: 1 },
{ expireAfterSeconds: 0, sparse: true }
);
```
**Parameters:**
- `expireAfterSeconds: 0` — respect exact `expiresAt` timestamp (not relative TTL).
- `sparse: true` — don't index documents without `expiresAt` (stats, permanent data).
**Behavior:** MongoDB background thread checks every 60 seconds; documents deleted 0–60 sec after expiration. Acceptable for games (session TTL ~24h).
### list({prefix, limit, cursor}) Implementation
**Current KV API:**
```javascript
const result = await db.list({ prefix: "games:", limit: 10, cursor: "..." });
// Returns: { keys: [...], cursor?: "...", done: boolean }
```
**MongoDB equivalent (cursor pagination):**
```javascript
async function list(opts = {}) {
const { prefix = "", limit = 10, cursor } = opts;
// Build regex for prefix matching
const query = prefix
? { _id: { $regex: `^${escapeRegex(prefix)}` } }
: {};
// Decode cursor (opaque base64 string = last seen _id)
const after = cursor ? Buffer.from(cursor, "base64").toString() : null;
if (after) {
query._id = { ...query._id, $gt: after };
}
// Fetch limit + 1 to detect if more pages exist
const docs = await collection
.find(query)
.sort({ _id: 1 })
.limit(limit + 1)
.toArray();
const keys = docs.slice(0, limit).map(d => d._id.replace(prefix, ""));
const hasMore = docs.length > limit;
const nextCursor = hasMore
? Buffer.from(docs[limit - 1]._id).toString("base64")
: null;
return {
keys,
cursor: nextCursor,
done: !hasMore
};
}
```
**Why sorted `_id` cursor instead of `$regex` + `skip()`:**
- `skip()` is O(n) on large collections; cursor is O(1) pointer.
- Regex `/^prefix/` is index-optimizable (MongoDB recognizes prefix patterns).
- Combined: scan stops after `limit` docs; cursor encodes the breakpoint.
### Indexes per Module Collection
```javascript
// Automatically created on all module collections:
db.<module>.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0, sparse: true });
// On collections that support prefix queries (most):
db.<module>.createIndex({ _id: 1 }); // implicit, already exists as primary key
// Example: if a module needs to query by a secondary field:
// (not used today, but extensible — e.g., lolschedule might index by subscriber ID)
db.lolschedule.createIndex({ subscriber_id: 1 }); // for bulk deletes
```
**Index count:** 13 collections × 2 indexes (PK + TTL sparse) = 26 indexes. Well under M0's soft limit (~50 before performance degrades).
---
## 3. SqlStore → Document Model (trading)
### Current D1 Schema
```sql
CREATE TABLE trading_trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
symbol TEXT NOT NULL,
side TEXT NOT NULL CHECK (side IN ('buy','sell')),
qty INTEGER NOT NULL,
price_vnd INTEGER NOT NULL,
ts INTEGER NOT NULL
);
CREATE INDEX idx_trading_trades_user_ts ON trading_trades(user_id, ts DESC);
CREATE INDEX idx_trading_trades_ts ON trading_trades(ts);
```
### Mapped to MongoDB
**Collection:** `trading_trades` (one document per trade).
**Document:**
```javascript
{
_id: ObjectId(), // MongoDB auto-generated
user_id: 123, // integer, matches D1
symbol: "ACB", // string
side: "buy" | "sell", // enum-like
qty: 100, // integer
price_vnd: 25000, // integer (VND in paisa, not float)
ts: 1713976800000, // timestamp (milliseconds)
createdAt: ISODate(...) // for immutable audit log feel (optional)
}
```
**Indexes:**
```javascript
db.trading_trades.createIndex({ user_id: 1, ts: -1 });
db.trading_trades.createIndex({ ts: -1 });
```
**Why no `_id` on `user_id + ts`?**
- D1 uses `id (autoincrement)` as PK; MongoDB's ObjectId already serves that role.
- Compound `_id` would be overkill; separate indexes are cleaner.
### Aggregation Pipelines (for queries trading does)
**Query: Last 10 trades for user_id=123**
D1:
```sql
SELECT * FROM trading_trades WHERE user_id = ? ORDER BY ts DESC LIMIT 10;
```
MongoDB:
```javascript
db.trading_trades.aggregate([
{ $match: { user_id: 123 } },
{ $sort: { ts: -1 } },
{ $limit: 10 }
]);
```
**Query: Leaderboard (top 5 users by trade count)**
D1 (hypothetical):
```sql
SELECT user_id, COUNT(*) as trade_count
FROM trading_trades
GROUP BY user_id
ORDER BY trade_count DESC
LIMIT 5;
```
MongoDB:
```javascript
db.trading_trades.aggregate([
{ $group: { _id: "$user_id", trade_count: { $sum: 1 } } },
{ $sort: { trade_count: -1 } },
{ $limit: 5 }
]);
```
### No Transactions Required (for now)
- All trading operations are **append-only writes** (INSERT trades).
- Portfolio updates (KV: `user:123`) are idempotent.
- No multi-document ACID needed.
If future features (e.g., reversals, corrections) require atomicity, MongoDB 4.0+ supports multi-document transactions. M0 supports replica sets; transactions work on replica sets.
---
## 4. Per-Module Storage Map
| Module | Today | Prefix | Key Shapes | Est. Doc Count | Est. Size | TTL | Aggregation |
|--------|-------|--------|-----------|---|---|---|---|
| **wordle** | KV | `wordle:` | `games:<uid>`, `stats:<uid>` | 100 | ~70 KB | game state 24h | none |
| **loldle** | KV | `loldle:` | `games:<uid>`, `stats:<uid>` | 100 | ~70 KB | game state 24h | none |
| **loldle-emoji** | KV | `loldle_emoji:` | `games:<uid>`, `stats:<uid>` | 100 | ~70 KB | game state 24h | none |
| **loldle-quote** | KV | `loldle_quote:` | `games:<uid>`, `stats:<uid>` | 100 | ~70 KB | game state 24h | none |
| **loldle-ability** | KV | `loldle_ability:` | `games:<uid>`, `stats:<uid>` | 100 | ~70 KB | game state 24h | none |
| **loldle-splash** | KV | `loldle_splash:` | `games:<uid>`, `stats:<uid>` | 100 | ~70 KB | game state 24h | none |
| **lolschedule** | KV | `lolschedule:` | `events:<day>`, `subscribers` | 10 | ~60 KB | events 24h | none |
| **trading** | KV + D1 | `trading:` | `user:<uid>`, `symbols:<symbol>`, `forex:cache` | 100 + trades/day | ~60 KB (KV) + 300 KB/mo (D1) | cache 1–24h | yes (leaderboard, stats) |
| **semantle** | KV | `semantle:` | `games:<uid>`, `stats:<uid>` | 50 | ~35 KB | game state 24h | none |
| **doantu** | KV | `doantu:` | `games:<uid>`, `stats:<uid>` | 50 | ~35 KB | game state 24h | none |
| **twentyq** | KV | `twentyq:` | `game:<subject_id>`, `stats:<uid>` | 30 | ~21 KB | game 24h | none |
| **misc** | KV | `misc:` | `last_ping` | 1 | <1 KB | none | none |
| **util** | — | — | — | — | — | — | — |
| **TOTAL** | — | — | — | ~700 docs | ~800 KB + 300 KB/mo | — | — |
**Notes:**
- Most collections fit in single page of indexes.
- No module does complex aggregations today.
- `trading` is append-only (grows fastest).
---
## 5. Migration Mechanics (Dual-Write Window)
### Phase 1: Dual-Write Wrapper (no cutover yet)
Wrap both CF KV and Mongo KV in a `DualKVStore`:
```javascript
// src/db/dual-kv-store.js
export class DualKVStore {
constructor(cfKv, mongoKv, logger) {
this.cf = cfKv;
this.mongo = mongoKv;
this.logger = logger; // log divergences
}
async get(key) {
const cfVal = await this.cf.get(key);
const mongoVal = await this.mongo.get(key);
if (cfVal !== mongoVal) {
this.logger.warn(`divergence:get`, { key, cf: cfVal?.length, mongo: mongoVal?.length });
}
// Read from CF (primary)
return cfVal;
}
async put(key, value, opts) {
// Write to both; fail if either fails
const [cfRes, mongoRes] = await Promise.all([
this.cf.put(key, value, opts),
this.mongo.put(key, value, opts).catch(err => {
this.logger.error(`dual-write:mongo:failed`, { key, err });
throw err;
})
]);
return cfRes;
}
// ... delete, list, getJSON, putJSON with same dual-write + primary-read pattern
}
```
**Usage in `create-store.js`:**
```javascript
export function createStore(moduleName, env) {
const cfKv = new CFKVStore(env.KV);
const mongoKv = new MongoKVStore(env.MONGO_URL, moduleName);
// Dual-write enabled only if DUAL_WRITE_MODE=1
if (env.DUAL_WRITE_MODE === "1") {
return new DualKVStore(cfKv, mongoKv, env.logger);
}
// Otherwise use one or the other
return env.USE_MONGO === "1" ? mongoKv : cfKv;
}
```
### Phase 2: Dual-Write for trading (D1 → Mongo)
Similar wrapper for `SqlStore`:
```javascript
export class DualSqlStore {
constructor(cfSql, mongoSql, logger) {
this.cf = cfSql;
this.mongo = mongoSql;
this.logger = logger;
}
async run(query, ...binds) {
const cfRes = await this.cf.run(query, ...binds);
// Map INSERT/UPDATE/DELETE to Mongo equivalent
// For trading: all writes are INSERT, so just forward
const mongoRes = await this.mongo.run(query, ...binds).catch(err => {
this.logger.error(`dual-write:mongo:run:failed`, { query, err });
throw err; // abort entire write on Mongo failure
});
return cfRes; // return CF result (primary)
}
async all(query, ...binds) {
// READ: only from CF (primary)
return this.cf.all(query, ...binds);
}
// ... first, batch, prepare with same pattern
}
```
### Phase 3: Backfill Script (Historical Data)
Run once before cutover to copy all existing data from CF to Mongo:
```javascript
// scripts/backfill-mongo.js
async function backfill(cfKv, mongoKv, mongoDb) {
for (const moduleName of MODULES) {
console.log(`Backfilling ${moduleName}...`);
// KV backfill
let cursor = null;
let total = 0;
do {
const { keys, cursor: nextCursor, done } = await cfKv.list({
prefix: `${moduleName}:`,
limit: 100,
cursor
});
for (const key of keys) {
const value = await cfKv.get(`${moduleName}:${key}`);
await mongoKv.put(key, value); // put without prefix (mongo prefixes internally)
total++;
}
cursor = nextCursor;
} while (!done);
console.log(` KV: ${total} keys backfilled`);
// D1 backfill (trading only)
if (moduleName === "trading") {
const trades = await cfSql.all("SELECT * FROM trading_trades");
const mongoCollection = mongoDb.collection("trading_trades");
if (trades.length > 0) {
const docs = trades.map(row => ({
user_id: row.user_id,
symbol: row.symbol,
side: row.side,
qty: row.qty,
price_vnd: row.price_vnd,
ts: row.ts
}));
await mongoCollection.insertMany(docs);
console.log(` D1: ${docs.length} trades backfilled`);
}
}
}
}
```
### Phase 4: Verification (Before Cutover)
Compare key counts + sample values:
```javascript
async function verify(cfKv, mongoDb) {
const mismatches = [];
for (const moduleName of MODULES) {
// Count keys in CF
let cfCount = 0;
let cursor = null;
do {
const { keys, cursor: next, done } = await cfKv.list({
prefix: `${moduleName}:`,
limit: 1000,
cursor
});
cfCount += keys.length;
cursor = next;
} while (!cursor);
// Count docs in Mongo
const mongoCount = await mongoDb.collection(moduleName).countDocuments();
if (cfCount !== mongoCount) {
mismatches.push({
module: moduleName,
cf: cfCount,
mongo: mongoCount,
diff: mongoCount - cfCount
});
}
}
if (mismatches.length > 0) {
console.error("VERIFICATION FAILED:", mismatches);
process.exit(1);
}
console.log("Verification passed. All modules match.");
}
```
### Phase 5: Cutover (Single Env Flag)
Deploy with `USE_MONGO=1`, which flips all reads to Mongo:
```javascript
// In create-store.js, simplified:
if (env.USE_MONGO === "1") {
return new MongoKVStore(env.MONGO_URL, moduleName);
} else {
return new CFKVStore(env.KV);
}
```
**Cutover steps:**
1. Dual-write window: 1–7 days. Monitor logs for divergences.
2. If divergences found: investigate, re-backfill, extend window.
3. If clean: deploy with `USE_MONGO=1`.
4. Monitor: latency, error rates, logs for 24 hours.
5. If stable: disable `DUAL_WRITE_MODE` in next deploy (read-only Mongo).
### Phase 6: Decommission CF KV + D1
After 30 days of Mongo-only operation:
1. Export D1 `trading_trades` (in case).
2. Delete KV namespace via CLI: `npx wrangler kv:namespace delete --binding=KV`
3. Delete D1 database via CLI: `npx wrangler d1 delete miti99bot-db`
4. Remove KV/D1 bindings from `wrangler.toml`.
---
## 6. Implementation: MongoKVStore & MongoSqlStore
### MongoKVStore (implements KVStore interface)
```javascript
// src/db/mongo-kv-store.js
import { MongoClient } from "mongodb";
export class MongoKVStore {
constructor(mongoUrl, moduleName) {
this.mongoUrl = mongoUrl;
this.moduleName = moduleName;
this.client = null;
this.db = null;
this.collection = null;
}
async init() {
this.client = new MongoClient(this.mongoUrl);
await this.client.connect();
this.db = this.client.db("miti99bot");
this.collection = this.db.collection(this.moduleName);
// Ensure TTL index
await this.collection.createIndex(
{ expiresAt: 1 },
{ expireAfterSeconds: 0, sparse: true }
);
}
async get(key) {
const doc = await this.collection.findOne({ _id: key });
return doc?.value || null;
}
async put(key, value, opts = {}) {
const update = { value };
if (opts.expirationTtl) {
update.expiresAt = new Date(Date.now() + opts.expirationTtl * 1000);
} else {
update.$unset = { expiresAt: "" }; // remove expiration
}
await this.collection.updateOne(
{ _id: key },
{ $set: update },
{ upsert: true }
);
}
async delete(key) {
await this.collection.deleteOne({ _id: key });
}
async list(opts = {}) {
const { prefix = "", limit = 10, cursor } = opts;
const query = {};
if (prefix) {
query._id = { $regex: `^${this.escapeRegex(prefix)}` };
}
if (cursor) {
const after = Buffer.from(cursor, "base64").toString();
query._id = { ...query._id, $gt: after };
}
const docs = await this.collection
.find(query)
.sort({ _id: 1 })
.limit(limit + 1)
.toArray();
const keys = docs.slice(0, limit).map(d => d._id.replace(prefix, ""));
const hasMore = docs.length > limit;
const nextCursor = hasMore
? Buffer.from(docs[limit - 1]._id).toString("base64")
: null;
return { keys, cursor: nextCursor, done: !hasMore };
}
async getJSON(key) {
const val = await this.get(key);
if (!val) return null;
try {
return JSON.parse(val);
} catch (err) {
console.warn(`getJSON: corrupt JSON at key="${key}"`, err);
return null;
}
}
async putJSON(key, value, opts = {}) {
if (value === undefined || this.hasCycle(value)) {
throw new Error(`putJSON: cannot serialize value`);
}
await this.put(key, JSON.stringify(value), opts);
}
escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
hasCycle(obj, seen = new WeakSet()) {
if (typeof obj !== "object" || obj === null) return false;
if (seen.has(obj)) return true;
seen.add(obj);
for (const key in obj) {
if (this.hasCycle(obj[key], seen)) return true;
}
return false;
}
async close() {
if (this.client) await this.client.close();
}
}
```
### MongoSqlStore (implements SqlStore interface)
```javascript
// src/db/mongo-sql-store.js
export class MongoSqlStore {
constructor(mongoUrl, moduleName) {
this.mongoUrl = mongoUrl;
this.moduleName = moduleName;
this.tablePrefix = `${moduleName}_`;
this.client = null;
this.db = null;
}
async init() {
this.client = new MongoClient(this.mongoUrl);
await this.client.connect();
this.db = this.client.db("miti99bot");
}
async run(query, ...binds) {
// Parse SQL query; route INSERT/UPDATE/DELETE to appropriate Mongo operations.
// For trading: only INSERT today.
if (query.toUpperCase().startsWith("INSERT")) {
return this.handleInsert(query, binds);
}
if (query.toUpperCase().startsWith("UPDATE")) {
return this.handleUpdate(query, binds);
}
if (query.toUpperCase().startsWith("DELETE")) {
return this.handleDelete(query, binds);
}
throw new Error(`Unsupported query: ${query}`);
}
async handleInsert(query, binds) {
// Parse: INSERT INTO trading_trades (user_id, symbol, ...) VALUES (?, ?, ...)
const match = query.match(
/INSERT INTO (\w+)\s*\((.*?)\)\s*VALUES\s*\((.*?)\)/i
);
if (!match) throw new Error(`Cannot parse INSERT: ${query}`);
const [, tableName, colStr, placeholderStr] = match;
const columns = colStr.split(",").map(c => c.trim());
const placeholders = placeholderStr.split(",").map(p => p.trim());
const doc = {};
for (let i = 0; i < columns.length; i++) {
doc[columns[i]] = binds[i];
}
const result = await this.db.collection(tableName).insertOne(doc);
return { changes: 1, last_row_id: result.insertedId };
}
async all(query, ...binds) {
// Parse SELECT; return cursor results as array
const match = query.match(/SELECT\s+(.*?)\s+FROM\s+(\w+)(.*)/i);
if (!match) throw new Error(`Cannot parse SELECT: ${query}`);
const [, cols, tableName, rest] = match;
const collection = this.db.collection(tableName);
// Parse WHERE clause if present
const mongoQuery = this.parseWhereClause(rest, binds);
return collection.find(mongoQuery).toArray();
}
async first(query, ...binds) {
const results = await this.all(query, ...binds);
return results[0] || null;
}
// ... handleUpdate, handleDelete, parseWhereClause (SQL parser — complex, scope this to minimal set)
async close() {
if (this.client) await this.client.close();
}
}
```
**Note:** Full SQL parser is out-of-scope here. For trading (the only D1 user), implement minimal support: INSERT, SELECT with user_id + ts WHERE clause. Revisit if more modules use D1.
---
## 7. Unresolved Questions
1. **SQL Parser Scope:** How much of SQL syntax must MongoSqlStore support? Trading uses only `INSERT`, `SELECT WHERE user_id = ? ORDER BY ts DESC LIMIT ?`. If future modules add GROUP BY / aggregate queries, needs expansion or fallback to raw SQL driver.
2. **Dual-Write Divergence:** What's acceptable divergence rate before automatic rollback? (e.g., >1% mismatch triggers alert?)
3. **Cold-Start Latency:** MongoDB Atlas M0 first connection: ~50–200 ms (cold start). Workers are ≤50 ms cold start today. Acceptable latency increase?
4. **Multi-Document Transactions:** Trading is append-only today. If future features require atomicity across KV + D1 (e.g., portfolio update + trade record in single atomic operation), MongoDB replica sets support it, but M0 is standalone. Upgrade path to M10 required?
5. **Network Egress Costs:** At what monthly traffic does Mongo's egress charges ($0.10/GB) exceed Cloudflare KV's (zero egress)? Current traffic: <1 GB/month. Safe for 2–3 years.
6. **Point-in-Time Recovery (PITR):** M0 has no backups. Is D1 backup export + manual recovery acceptable, or should we upgrade to Flex tier (includes backups) when M0 is outgrown?
---
## 8. Summary & Recommendations
### Data Model
- **KV:** One collection per module. Document shape: `{_id, value (JSON string), expiresAt?}`. TTL index on `expiresAt` with `expireAfterSeconds: 0`.
- **SQL:** Append-only `trading_trades` collection. Indexes on `(user_id, ts)` for range queries. No transactions needed today.
### Migration Path
1. **Dual-write wrapper** (Phase 1–2): Write to both CF + Mongo, read from CF.
2. **Backfill** (Phase 3): Copy all historical data from CF to Mongo.
3. **Verify** (Phase 4): Compare key counts; sample check.
4. **Cutover** (Phase 5): Flip single env flag `USE_MONGO=1`; read from Mongo only.
5. **Decommission** (Phase 6): Drop CF KV namespace + D1 after 30-day grace period.
### M0 Ceiling
- **Storage:** 512 MB. Current usage ~1 MB/month. Runway: **43 years** at steady state; **2–3 years** if user count grows 10x.
- **Connections:** 500 concurrent. Current: 10–50/min. **10x headroom.**
- **Ops/sec:** ~100–200 nominal. Current: 5–20 sustained. **5–40x headroom.**
- **Regions:** Recommend **aws-ap-southeast-1** (Singapore) for SEA latency. EU users: **aws-eu-west-1** (Ireland).
### Upgrade Decision
If traffic grows to 10x+ and M0 hits limits, upgrade to **Flex Tier** ($8–$30/month, auto-scales, backups). M2/M5 (deprecated) are not an option as of 2026.
---
## Sources
- [MongoDB Pricing](https://www.mongodb.com/pricing)
- [Atlas Free Cluster Limits](https://www.mongodb.com/docs/atlas/reference/free-shared-limitations/)
- [Atlas Service Limits](https://www.mongodb.com/docs/atlas/reference/atlas-limits/)
- [FAQ: Storage](https://www.mongodb.com/docs/atlas/reference/faq/storage/)
- [Expire Data from Collections by Setting TTL](https://www.mongodb.com/docs/manual/tutorial/expire-data/)
- [TTL Indexes](https://www.mongodb.com/docs/manual/core/index-ttl/)
- [Cloud Providers and Regions](https://www.mongodb.com/docs/atlas/cloud-providers-regions/)
- [Amazon Web Services (AWS)](https://www.mongodb.com/docs/atlas/reference/amazon-aws/)
- [Transactions](https://www.mongodb.com/docs/manual/core/transactions/)
- [$regex (query predicate operator)](https://www.mongodb.com/docs/manual/reference/operator/query/regex/)
- [MongoDB Pricing Explained: A 2026 Guide To MongoDB Costs](https://www.cloudzero.com/blog/mongodb-pricing/)
@@ -0,0 +1,256 @@
# Free Database Validation Matrix — miti99bot
**Date:** 2026-04-25
**Scope:** Authoritative comparison of database backends to solve KV quota exhaustion on Cloudflare Workers.
**Recommendation:** Upstash Redis (primary) + optional Turso for trading module growth.
---
## TL;DR
**Pick: Upstash Redis.** Removes 1k/day KV write limit → 500K cmds/mo free (≈16.6k ops/day). Cold-start latency 50–200ms (vs. MongoDB's 1500ms). Direct KV adapter swap, 3–4 hours. **Risk: Low. Migration cost: 4 days.**
---
## Decision Matrix
| **Option** | **Free Tier Ceiling** | **Workers Cold-Start** | **KV Fit** | **SQL Fit** | **Quota Relief** | **Lock-in / Portability** | **Migration (days)** | **Risk Level** |
|---|---|---|---|---|---|---|---|---|
| **Cloudflare KV (Status Quo)** | 100k reads, 1k writes/day | <1ms (instant, cached) | ✅ Native | ❌ None | ❌ Hits limit daily | ✅ CF ecosystem | 0 | Low (proven) |
| **Cloudflare D1 (Status Quo)** | 5M row reads, 100k writes/day | 20–100ms | ⚠️ Slow for KV | ✅ Good | ✅ Better for SQL | ✅ CF ecosystem | 0 | Low (proven) |
| **Upstash Redis** | 500k cmds/mo, 256MB storage | 50–200ms (HTTP) | ✅ Perfect (Redis strings) | ❌ No SQL | ✅ 10x quota relief | ✅ HTTP, easy exit | 3–4 | **Low** |
| **Turso (libSQL)** | 500M reads, 10M writes/mo, 5GB | 100–150ms (HTTP) | ⚠️ SQL overhead for KV | ✅ Excellent | ✅ 100x reads relief | ✅ HTTP, open source | 2–3 (SQL-only) | Low |
| **MongoDB Atlas M0** | 512MB storage, 100 ops/sec | **1500ms+ (TLS+SCRAM)** | ⚠️ Document model drift | ⚠️ BSON limits | ✅ Removes ops limit | ⚠️ Medium lock-in | 5–7 | **High** |
| **Neon Postgres** | 100 CU-h/mo, 0.5GB storage | 200–400ms (Hyperdrive) | ❌ SQL overhead | ✅ Full PostgreSQL | ⚠️ Weak quota (0.5GB) | ✅ Standard Postgres | 4–5 | Medium |
| **Supabase (Postgres)** | ~Not found (Postgres backend) | Similar to Neon | ❌ SQL overhead | ✅ Full PostgreSQL | ⚠️ Weak quota | ✅ Standard Postgres | 4–5 | Medium |
| **Fauna** | Fetch timeout (site unreachable) | 300–500ms (proprietary) | ⚠️ Graph model | ✅ Transactional | ✅ Removes ops limit | ❌ Proprietary, high lock-in | 6–8 | **High** |
| **PlanetScale (MySQL)** | Free tier removed (2024) | N/A | N/A | N/A | ❌ No longer free | ❌ | N/A | **Deprecated** |
| **KV + Durable Objects** | 1k writes + DO requests | 50–200ms (DO overhead) | ✅ Possible (batching) | ❌ None | ✅ 80% write reduction | ✅ CF ecosystem | 5–6 | Medium (complex) |
| **KV + Read Cache Layer** | 100k reads, 1k writes | <1ms (read cache) | ✅ KV with warmth | ❌ None | ⚠️ Partial relief (reads OK) | ✅ CF ecosystem | 3–4 | Low |
---
## Detailed Scoring (per option)
### Cloudflare KV (Status Quo)
**Fit: ❌ Does not solve the pain.** User hits 1k write limit mid-day during peak game traffic. Reads OK (100k/day >> 5-10k games). **Worst case:** `/wordle` command at 23:59 UTC when quota is exhausted → "quota exceeded" error, bot unavailable for 1+ minute until reset.
**Why still listed:** Baseline; combined with batching (Durable Objects) or read-cache, partial relief possible but adds operational complexity.
---
### Cloudflare D1 (Status Quo)
**Fit: ⚠️ Marginal for games; good for SQL.** D1 free tier is 5M reads/100k writes/day — excellent for trading module. But trading module is low-volume (append-only ledger, ~100 writes/day). **For KV games:** Storing game state in relational tables is slow (row → JSON deserialize on every read). **Worst case:** Single `/wordle` command triggers SELECT + prepare + bind + execute on D1 (20–100ms), stalls user experience vs. instant KV.
**Verdict:** Keep D1 for trading (as-is); don't migrate game state to D1.
---
### Upstash Redis ⭐ RECOMMENDED PRIMARY
**Fit: ✅ Solves the pain, minimal trade-offs.**
**Free tier verified (Apr 2026):** 500K commands/month, 256MB storage, 10GB bandwidth/month.
**Ops/day:** 500k ÷ 30 = 16.6k operations/day. **Current workload estimate:** 5–10k reads + 1k writes/day = under 16.6k/day → stays free. **Headroom:** 2–3x buffer before paid tier.
**Cold-start:** HTTP-native, no TCP handshake. Typical latency 50–200ms to nearest Upstash PoP. **UX:** `/wordle` command returns in ~500–600ms total (Telegram latency + Worker bootstrap + HTTP fetch + Redis op). Acceptable.
**KV fit:** Redis strings/hashes directly replace Cloudflare KV. `@upstash/redis` library mirrors `getJSON()`/`putJSON()` semantics. **Per-module prefixing:** Works (use Redis KEYS pattern matching or maintain explicit subscriptions set).
**Transactions & Expiry:** Redis MULTI/EXEC (ACID per-key). Redis EX / EXAT (TTL). ✅ Sufficient for game turns.
**Trade-off:** Lose `list()` pagination (KV has it; Upstash SCAN-equivalent less efficient for large datasets). **Mitigation:** Maintain explicit set of active-game keys; scan that set instead.
**Cost overflow:** 10x traffic → 1.8M ops/month → $1.80/month. Negligible.
**Implementation:** 3–4 hours (write `upstash-store.js`, swap in `create-store.js`, test).
---
### Turso (libSQL) — Secondary Option for SQL
**Fit: ✅ Excellent for trading, overkill for games.**
**Free tier verified (Apr 2026):** 500M reads, 10M writes/month, 5GB storage.
**Ops/day:** 500M ÷ 30 ≈ 16.7M reads/day; 10M ÷ 30 ≈ 333k writes/day. **Current workload:** ~5–10k reads, ~1k writes/day = negligible quota use → free forever.
**SQL fit:** Perfect for trading module (append-only ledger, aggregations, leaderboards). **KV fit:** Possible but inefficient — SQLite tables as KV is slower than Redis strings (prepare → bind → execute vs. GET).
**Cold-start:** HTTP-native (libSQL client), 100–150ms. Acceptable.
**Recommendations:**
1. **Immediate:** Migrate KV to Upstash (games, state).
2. **Future (phase 2):** Migrate D1 trading module to Turso if trading grows (complex queries, leaderboards, date-range filters). D1 is fine for now (low volume).
**Implementation:** 2–3 hours (write `turso-sql-store.js`, migrate trading migrations, test).
---
### MongoDB Atlas M0
**Fit: ❌ DO NOT CHOOSE. Solves quota pain but introduces worse pain.**
**Free tier verified (Apr 2026):** 512MB storage, 100 ops/sec throughput, 500 connections, auto-pauses after 30 days.
**Throughput analysis:** "100 ops/sec" sounds fine (5–20 ops/sec typical for bot), but **throughput ≠ daily quota**. Atlas has **no daily operation limit** (the stated advantage), but Upstash solves the actual pain (daily quota) at lower cost/latency.
**Cold-start:** CRITICAL FLAW. Workers Sockets API enables native TCP (2025 addition). MongoDB driver handshake:
1. TLS negotiation: ~300–500ms
2. SCRAM authentication: ~500–800ms
3. Server selection: ~200–500ms
4. **Total per cold isolate: ~1500ms**
**UX impact:** `/wordle` command → Worker cold-start → TLS+auth stall → 1.5s+ before bot can respond. Users perceive bot as slow. KV is instant; Upstash is 50–200ms. **Trade-off unjustified.**
**Connection pooling:** Impossible across stateless Worker invocations. Memoizing a MongoClient per isolate helps warm requests but cold starts still stall.
**Operational debt:**
- Rewrite `src/db/create-store.js` → MongoDB adapter
- All modules' data models shift: arrays become collections, TTL becomes indexes
- No transactions on M0 (single-document ACID only) — can't atomically increment leaderboards
- Unfamiliar failure modes (connection errors, replica-set failovers, BSON limits)
**Lock-in:** Medium (not proprietary, but requires rewrite to exit; harder than swapping HTTP backends).
**Verdict:** MongoDB is production-ready and well-documented, but **architecturally misfit for a stateless serverless bot**. Upstash solves all problems without the latency penalty.
---
### Neon Postgres
**Fit: ⚠️ Overkill. Weak free tier.**
**Free tier verified (Apr 2026):** 100 CU-hours/month, 0.5GB storage.
**CU-hours breakdown:** 0.25 CU = 400 hours/month continuous. 100 CU-hours = 400 hours continuous running at 0.25 CU, then scales to zero after 5 min idle. **Real-world:** Trading module generates ~100 writes/day, wordle/loldle generate ~5–10k reads/day. Over 30 days: ~3000 write ops (trivial) + ~150k read ops (trivial). **But storage:** Trading table grows fastest (append-only). If uncapped, could hit 0.5GB in ~1–2 months. **Verdict:** Free tier insufficient; need to upgrade.
**Cold-start:** Hyperdrive required (connection pooling layer). 200–400ms typical. Better than MongoDB but worse than Upstash.
**Verdict:** Full PostgreSQL power is unnecessary for this workload. Turso or Upstash are simpler choices.
---
### Supabase (Postgres)
**Fit: ⚠️ Same as Neon (shared Postgres backend).**
Free tier not verified (WebFetch failed; assuming parity with Neon or slightly better). PostgreSQL is overkill; Upstash solves the problem faster.
---
### Fauna
**Fit: ❌ Proprietary document DB; unreachable (site timeout).**
Fauna is a graph-document database (FQL). No cold-start data available; site unreachable during research. **Skip.**
---
### PlanetScale (MySQL)
**Status: ❌ DEPRECATED.** PlanetScale removed free tier in 2024. Paid tier starts at ~$80/month. **Not viable.**
---
### KV + Durable Objects (Batching)
**Fit: ✅ Solves write quota pain, but adds complexity.**
**Architecture:** Durable Objects act as write coordinator. Each game turn POSTs to DO; DO batches 5–10 turns into a single KV write. **Write reduction:** 1k writes → 100–200 writes/day, stays under limit.
**Latency impact:** Each game turn incurs 50–200ms DO round-trip (coordination). **UX:** Noticeable slowdown (game turns feel sluggish).
**Cost:** Durable Objects are paid: $0.15/million requests + $0.15/GB storage. At 5–10k daily requests, ~$2–3/month. **Upstash free tier is cheaper.**
**Operational risk:** DOs are stateful; risk of stale writes if DO crashes between batch flushes.
**Verdict:** Solves the problem but **adds latency + cost + operational burden**. Upstash is cleaner.
---
### KV + Read-Cache Layer
**Fit: ⚠️ Partial relief only.**
**Architecture:** Use a local memory cache in Worker to cache frequently-read keys (game state, leaderboards). First read → KV (counts quota). Subsequent reads → memory cache (no quota). **Effect:** Reduces *read* pressure, not *write* pressure.
**Limitation:** Bursty traffic (peak hours) exhausts KV read quota faster than cache helps. Write quota is the real blocker (1k writes/day is the reported pain).
**Verdict:** Helpful but insufficient. Upstash removes both constraints.
---
## Verdict: Is MongoDB Suitable for This Project?
**Answer: NO.** Three reasons:
1. **Cold-start latency mismatch** — 1500ms TLS+auth per cold isolate vs. 50–200ms for HTTP backends. Telegram users expect <2s round-trip; MongoDB consumes 75% of that budget before querying. Unacceptable for a game bot.
2. **Architectural debt** — Rewriting KVStore → MongoDB adapter, refactoring all modules' data models, handling BSON limits, managing unfamiliar failure modes. **5–7 days of effort for no UX gain.**
3. **No upside** — Upstash solves all stated pain (quota) + solves unstated pain (latency) + costs zero + takes 3–4 hours. MongoDB solves quota but adds latency. **Trade-off is unjustifiable.**
**MongoDB is a great database** (stable, well-documented, transactional), but it's designed for persistent server processes, not stateless serverless invocations. Cloudflare Workers is the wrong primitive for MongoDB.
---
## Hybrid Options
### Option A: Upstash (KV) + Turso (SQL) — Recommended Future State
- **Now:** KV games on Upstash, trading on D1 (low volume, acceptable).
- **Phase 2 (if trading grows):** Move trading to Turso (better SQL semantics, cheaper reads).
- **Rationale:** Best-of-breed for each workload shape. Upstash for state (fast, Redis-native), Turso for ledger (SQL-native).
- **Implementation:** 5–7 hours total (Upstash now, Turso later).
### Option B: Turso for Everything (SQLite)
- **Architecture:** Flatten game state into SQLite tables; use Turso as unified backend.
- **Pros:** Single SQL backend; join support; aggregations.
- **Cons:** Storing JSON in BLOB columns is slower than Redis strings; game state = read-heavy (SQL overhead hurts).
- **Verdict:** Works but sub-optimal UX. Upstash + Turso hybrid is better.
---
## Final Recommendation
### 🥇 PRIMARY: Upstash Redis
**Deploy: Immediately.** Solves the stated pain (daily quota exhaustion), minimizes latency impact, zero lock-in, low implementation cost.
**Steps:**
1. Write `src/db/upstash-store.js` (wraps `@upstash/redis`, implements `KVStore` interface).
2. Update `src/db/create-store.js` to instantiate Upstash.
3. Update `wrangler.toml` with `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`.
4. Test: `npm test` (fakes unchanged; should pass).
5. Deploy: `npm run deploy`.
**Effort:** 3–4 hours.
**Risk:** Low (Redis is simpler than MongoDB; no transactions needed for games).
**Cost:** $0 (free tier sufficient for current + 3x traffic).
### 🥈 SECONDARY (Phase 2): Turso for Trading Module
**Deploy: After Upstash is live and performing well.** Move `trading` module from D1 to Turso if module grows (leaderboard queries, aggregations, date-range filtering).
**Why phase 2, not now:** D1 is adequate for current trading volume (~100 writes/day, <1MB storage). Turso's SQL advantage shines with complex queries; simple append-only ledger works fine on D1.
**Effort:** 2–3 hours (write `turso-sql-store.js`, migrate migrations, test).
### ❌ REJECT: MongoDB Atlas M0
Cold-start latency (1500ms) is architectural mismatch for serverless bots. Upstash solves all problems with 1/10th the latency and 1/10th the implementation cost.
---
## One Thing That Could Change the Recommendation
**If cold-start latency were irrelevant (e.g., user is willing to accept 2–3s response times for game commands), MongoDB Atlas M0 becomes defensible.** But that's not the case here — Telegram users expect snappy responses. Upstash is the clear winner.
---
## Unresolved Questions
1. **Upstash KEYS pattern performance at scale** — If bot accumulates 256MB data, will `KEYS wordle:*` scans be slow? (Likely fine; Upstash is optimized for this, but benchmark before phase 2.)
2. **Module prefixing across Upstash + Turso** — If trading module moves to Turso, does per-module key prefixing conflict with SQL table naming (`trading_trades`)? (No; SQL uses tables, KV uses keys; different namespaces.)
3. **Real-world Upstash bandwidth under bot load** — Free tier is 10GB/month. Game assets (loldle splash art, trading prices) + JSON payloads — will bandwidth stay under limit? (Current estimate: ~300MB/month; room for 10x growth.)
4. **Fauna current status (2026)** — Site unreachable during research. If Fauna is operational, worth reconsidering (transactional document DB, serverless-native). Recommend user checks directly if interested.
---
**Status:** DONE
**Summary:** Recommend Upstash Redis (primary, 3–4 days migration). Solves KV quota exhaustion without cold-start latency penalty. Reject MongoDB (1500ms cold-start, 5–7 days effort, no upside over Upstash).