diff --git a/.env.deploy.example b/.env.deploy.example index dca513e..536f447 100644 --- a/.env.deploy.example +++ b/.env.deploy.example @@ -13,18 +13,3 @@ WORKER_URL= # Same MODULES value as wrangler.toml [vars]. Duplicated here so the register # script can derive the public command list without parsing wrangler.toml. MODULES=util,wordle,loldle,misc,trading,lolschedule,semantle,doantu,twentyq - -# MongoDB Atlas connection string. Used by the `mongodb` driver inside the Worker -# AND by local backfill / verify scripts. MUST match the value set via -# `wrangler secret put MONGODB_URI` for the Worker. Same secret-mirror protocol -# as TELEGRAM_BOT_TOKEN / TELEGRAM_WEBHOOK_SECRET. -# Format: mongodb+srv://miti99bot-worker:@/miti99bot?retryWrites=true&w=majority -MONGODB_URI= - -# Cloudflare API creds for backfill scripts (Phase 05). Read-only scope is enough. -# Create at: dash.cloudflare.com → My Profile → API Tokens → Create Token. -# Permissions needed: Account → Workers KV Storage → Read; Account → D1 → Read. -CLOUDFLARE_ACCOUNT_ID= -CLOUDFLARE_API_TOKEN= -# KV namespace ID (production). Same value as wrangler.toml [[kv_namespaces]] id. -KV_NAMESPACE_ID= diff --git a/.gitignore b/.gitignore index b63db78..8eb2eef 100644 --- a/.gitignore +++ b/.gitignore @@ -76,12 +76,6 @@ web_modules/ !.dev.vars.example .wrangler/ -# Phase 05 backfill cursor checkpoints — persisted locally, must not be committed. -.backfill-cursor-*.json - -# Local Claude Code agent state (memory, session permissions). Never committed. -.claude/ - # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache diff --git a/CLAUDE.md b/CLAUDE.md index 954e603..56ccf4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ grammY Telegram bot on Cloudflare Workers. Modules are plug-n-play: each module **Key abstractions:** - `src/modules/registry.js` — loads modules from static import map (`src/modules/index.js`), validates commands, detects name conflicts across all visibility levels, builds four maps (public/protected/private/all). Memoized via `getCurrentRegistry()`. -- `src/db/create-store.js` — returns a storage interface (either `MongoKVStore` via dual-write wrapper, or direct `MongoKVStore` depending on `STORAGE_PRIMARY` flag) with auto-prefixed keys per module (`moduleName:key`). Modules never touch `env.KV`, `env.DB`, or `env.MONGODB_URI` directly. +- `src/db/create-store.js` — wraps Cloudflare KV with auto-prefixed keys per module (`moduleName:key`). Modules never touch `env.KV` directly. - `scripts/register.js` — post-deploy script that imports the same registry to derive public commands, then calls Telegram `setWebhook` + `setMyCommands`. Uses `stub-kv.js` to satisfy KV binding without real IO. **Three command visibilities:** public (in Telegram `/` menu + `/help`), protected (in `/help` only), private (hidden easter eggs). All three are registered via `bot.command()` — visibility controls discoverability, not access. diff --git a/README.md b/README.md index 6976d23..23e8657 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Modules are added or removed via a single `MODULES` env var. Each module registe - **Drop-in modules.** Write a single file, list the folder name in `MODULES`, redeploy. No registration boilerplate, no manual command wiring. - **Three visibility levels out of the box.** Public commands show in Telegram's `/` menu and `/help`; protected show only in `/help`; private are hidden slash-command easter eggs. One namespace, loud conflict detection. -- **MongoDB Atlas backend.** Modules talk to a `KVStore` interface (MongoDB for simple state) or `MongoTradesStore` (MongoDB for trading append-only ledger). During migration, a dual-write layer persists to both Cloudflare KV/D1 and MongoDB for safety; post-cutover, MongoDB becomes the sole backend. +- **Dual storage backends.** Modules talk to a small `KVStore` interface (Cloudflare KV for simple state) or `SqlStore` interface (D1 for relational data, scans, leaderboards). Swappable with one-file changes. - **Scheduled jobs.** Modules declare cron-based cleanup, stats refresh, or maintenance tasks — registered via `wrangler.toml` and dispatched automatically. - **Zero admin surface.** No in-Worker `/admin/*` routes, no admin secret. `setWebhook` + `setMyCommands` run at deploy time from a local node script. - **Tested.** 200+ vitest unit tests cover registry, storage, dispatcher, cron validation, help renderer, validators, HTML escaping, and the trading / loldle / wordle modules. @@ -48,13 +48,11 @@ src/ ├── types.js # JSDoc typedefs (central: Env, Module, Command, Cron, etc.) ├── db/ │ ├── kv-store-interface.js # KVStore contract (JSDoc) -│ ├── mongo-kv-store.js # MongoDB KVStore implementation -│ ├── mongo-trades-store.js # MongoDB trading ledger implementation -│ ├── dual-kv-store.js # Dual-write wrapper (Mongo + CF KV) -│ ├── create-store.js # Storage factory (selects backend via STORAGE_PRIMARY flag) -│ ├── cf-kv-store.js # Cloudflare KV adapter (still active during migration) -│ ├── cf-sql-store.js # Cloudflare D1 adapter (still active during migration) -│ └── mongo-client.js # Shared memoized MongoDB connection +│ ├── cf-kv-store.js # Cloudflare KV implementation +│ ├── create-store.js # KV per-module prefixing factory +│ ├── sql-store-interface.js # SqlStore contract (JSDoc) +│ ├── cf-sql-store.js # Cloudflare D1 implementation +│ └── create-sql-store.js # D1 per-module prefixing factory ├── modules/ │ ├── index.js # static import map — register new modules here │ ├── registry.js # load, validate, build command + cron tables diff --git a/biome.json b/biome.json index d9fc040..ccba1f7 100644 --- a/biome.json +++ b/biome.json @@ -31,7 +31,6 @@ "ignore": [ "node_modules", ".wrangler", - ".claude", "dist", "coverage", "src/modules/loldle/champions.json", diff --git a/docs/architecture.md b/docs/architecture.md index dcd6ffc..018ddd1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -173,11 +173,9 @@ Every command — public, protected, **and private** — is registered via `bot. There is no custom text-match middleware, no `bot.on("message:text", ...)` handler, no private-command-specific path. One routing path for all three visibilities. This is what reduced the original two-path design (slash + text-match) to one during the revision pass. -## 8. Storage: MongoDB + Dual-Write Migration Era +## 8. Storage: KVStore and SqlStore -**Current state (Phases 01–08):** MongoDB Atlas is the primary store. During migration, a dual-write layer persists to both MongoDB and Cloudflare KV/D1 for safety. Modules NEVER touch `env.KV`, `env.DB`, or `env.MONGODB_URI` directly — they receive prefixed stores from the module context via `createStore()` and `createSqlStore()` factories. - -**Post-Phase-07 cutover:** The dual-write layer collapses, Cloudflare KV/D1 are deleted, and `createStore()` returns pure MongoDB stores. +Modules NEVER touch `env.KV` or `env.DB` directly. They receive prefixed stores from the module context. ### KVStore (key-value, fast reads/writes) @@ -205,20 +203,20 @@ getJSON(key) // → any | null (swallows corrupt JSON) putJSON(key, value, { expirationTtl? }) ``` -**Current implementation:** `createStore("wordle", env)` returns a `MongoKVStore` directly (or `DualKVStore` if `DUAL_WRITE=1` is set). During migration, dual-write sends to both MongoDB and Cloudflare KV. TTL expirations are enforced server-side by MongoDB (via `expiresAt` field) and at read-time by the `MongoKVStore` layer. - #### Prefix mechanics -All keys are prefixed with `:` before storage: +`createStore("wordle", env)` returns a wrapped store where every key is rewritten: ``` -module calls: store prefixes: MongoDB collection doc: -───────────────────────── ────────────────────── ──────────────────────── -put("games:42", v) ──► put("wordle:games:42") ──► { _id: "wordle:games:42", … } -get("games:42") ──► get("wordle:games:42") ──► (find by _id, return value) -list({prefix:"games:"})──► (scan, filter prefix) ──► (keys matching "wordle:games:") +module calls: wrapper sends to CFKVStore: raw KV key: +───────────────────────── ───────────────────────────── ───────────── +put("games:42", v) ──► put("wordle:games:42", v) ──► wordle:games:42 +get("games:42") ──► get("wordle:games:42") ──► wordle:games:42 +list({prefix:"games:"})──► list({prefix:"wordle:games:"}) (then strips "wordle:" from returned keys) ``` +Two stores for different modules cannot read each other's data unless they reconstruct prefixes by hand — a code-review boundary, not a cryptographic one. + ### SqlStore (relational, scans, append-only history) For complex queries, aggregates, or audit logs, use `sql` (a `SqlStore`): @@ -226,7 +224,7 @@ For complex queries, aggregates, or audit logs, use `sql` (a `SqlStore`): ```js // In a module's init: init: async ({ sql }) => { - sqlStore = sql; // null if not bound + sqlStore = sql; // null if env.DB not bound }, // In a handler or cron: @@ -242,19 +240,29 @@ The interface (full JSDoc in `src/db/sql-store-interface.js`): run(query, ...binds) // INSERT/UPDATE/DELETE — returns { changes, last_row_id } all(query, ...binds) // SELECT all rows → array of objects first(query, ...binds) // SELECT first row → object | null +prepare(query, ...binds) // Prepared statement for batch operations +batch(statements) // Execute multiple statements in one round-trip ``` -**Current implementation:** `createSqlStore("trading", env)` returns a `MongoTradesStore` (native MongoDB inserts / queries on `trading_trades` collection). D1 is read-only during migration. Post-cutover, D1 is deleted. +All tables must follow the naming convention `{moduleName}_{table}` (e.g., `trading_trades`). -### Swapping the backends (post-Phase-07) +Tables are created via migrations in `src/modules//migrations/*.sql`. The migration runner (`scripts/migrate.js`) applies them on deploy and tracks them in `_migrations` table. -After cutover, the backend is locked to MongoDB. To replace it: +### Swapping the backends + +To replace Cloudflare KV with a different store (e.g. Upstash Redis, Postgres): 1. Create a new `src/db/-store.js` that implements the `KVStore` interface. -2. Change the one `new MongoKVStore(...)` line in `src/db/create-store.js` to construct your new adapter. -3. Update `wrangler.toml` bindings if needed. +2. Change the one `new CFKVStore(env.KV)` line in `src/db/create-store.js` to construct your new adapter. +3. Update `wrangler.toml` bindings. -Similarly for SQL: create `-trades-store.js` implementing the trades interface, update `create-sql-store.js`. +That's the full change. No module code moves. + +To replace D1 with a different SQL backend: + +1. Create a new `src/db/-sql-store.js` that implements the `SqlStore` interface. +2. Change the one `new CFSqlStore(env.DB)` line in `src/db/create-sql-store.js` to construct your new adapter. +3. Update `wrangler.toml` bindings. ## 9. HTTP and Scheduled Entry Points diff --git a/docs/code-standards.md b/docs/code-standards.md index e9c61e6..7a18dcb 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -69,22 +69,12 @@ export default { ``` - Store module-level `db` and `sql` references in closure variables, set during `init` -- Never access `env.KV`, `env.DB`, or `env.MONGODB_URI` directly — always use the prefixed `db` (KVStore) or `sql` (SqlStore) from `init` -- `sql` is `null` when no relational store is bound — always guard with `if (!sql) return` +- Never access `env.KV` or `env.DB` directly — always use the prefixed `db` (KV) or `sql` (D1) from `init` +- `sql` is `null` when `env.DB` is not bound — always guard with `if (!sql) return` - Command handlers receive grammY `ctx` — use `ctx.match` for command arguments, `ctx.from.id` for user identity - Reply with `ctx.reply(text)` — plain text or Telegram HTML - Cron handlers receive `(event, { db, sql, env })` — same context as `init` -## Persistence Layer - -All data persistence flows through storage factories: - -- **`createStore(moduleName, env)`** — returns a `KVStore` interface for key-value data (simple state, settings, JSON blobs). Implementation: `MongoKVStore` (primary) with optional dual-write to Cloudflare KV during migration. -- **`createSqlStore(moduleName, env)`** — returns a `SqlStore` interface for relational data (trading ledger, aggregates, scans). Implementation: `MongoTradesStore` (MongoDB native queries and inserts). D1 is read-only during migration. -- **Modules NEVER instantiate `MongoClient` directly.** All MongoDB access goes through `MongoKVStore` or `MongoTradesStore` factories. - -Post-migration (after Phase 07 cutover), the dual-write layer collapses and Cloudflare KV/D1 are deleted; `createStore` returns `MongoKVStore` directly. - ## Error Handling - **Load-time failures** (bad module, command conflicts, missing env): throw immediately — fail loud at deploy, not at runtime. diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 05feb3c..3b0a072 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -2,7 +2,7 @@ ## Overview -Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY handles Telegram API; modules register commands with three visibility levels. **During migration (Phase 08):** Data is stored in MongoDB Atlas M0 (behind a prefixed `KVStore` interface for KV data, `MongoTradesStore` for trading ledger). Dual-write to Cloudflare KV/D1 is active for safety; post-Phase-07 cutover, MongoDB becomes sole backend. +Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY handles Telegram API; modules register commands with three visibility levels. Data stored in Cloudflare KV (behind a prefixed `KVStore` interface) or D1 (behind `SqlStore` interface). ## Tech Stack @@ -10,7 +10,7 @@ Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY hand |-------|-----------| | Runtime | Cloudflare Workers (V8 isolates) | | Bot framework | grammY 1.x | -| Storage | MongoDB Atlas M0 (primary, via official driver). Cloudflare KV + D1 (secondary, for dual-write safety during migration). | +| Storage | Cloudflare KV + D1 | | AI inference | Workers AI binding (`env.AI`) | | Linter/Formatter | Biome | | Tests | Vitest | @@ -21,14 +21,14 @@ Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY hand | Module | Commands | Storage | Crons | Description | |--------|----------|---------|-------|-------------| | `util` | `/info`, `/help`, `/stickerid` (private) | — | — | Bot info, command help renderer, sticker file_id echo helper | -| `misc` | `/ping`, `/mstats`, `/fortytwo` | MongoDB KVStore | — | Health check + DB demo stub | -| `trading` | `/trade_topup`, `/trade_buy`, `/trade_sell`, `/trade_convert`, `/trade_stats`, `/history` | MongoDB (trades + portfolio + symbol cache) | Daily 5PM trim | Paper trading — VN stocks with dynamic symbol resolution | -| `wordle` | `/wordle`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | MongoDB KVStore | — | 5-letter word guessing game. 14,855-word dict | -| `loldle` | `/loldle`, `/loldle_giveup`, `/loldle_stats` | MongoDB KVStore | — | Classic-mode LoL champion guesser. Data synced from `tiennm99/loldle-data` | -| `lolschedule` | `/lolschedule_today`, `/lolschedule_week`, `/lolschedule_subscribe`, `/lolschedule_unsubscribe` | MongoDB KVStore | Daily 01:00 UTC | LoL esports schedule + daily digest subscriptions | -| `semantle` | `/semantle`, `/semantle_giveup`, `/semantle_stats` | MongoDB KVStore | — | English semantic word guessing via hosted word2sim service | -| `doantu` | `/doantu`, `/doantu_hint`, `/doantu_giveup`, `/doantu_stats` | MongoDB KVStore | — | Vietnamese semantle via hosted phow2sim service | -| `twentyq` | `/twentyq`, `/twentyq_giveup`, `/twentyq_stats` | MongoDB KVStore | — | Reverse-Akinator yes/no game. Workers AI (`@cf/google/gemma-4-26b-a4b-it`) generates round-start category+hint and judges each turn via one-line JSON | +| `misc` | `/ping`, `/mstats`, `/fortytwo` | KV | — | Health check + DB demo stub | +| `trading` | `/trade_topup`, `/trade_buy`, `/trade_sell`, `/trade_convert`, `/trade_stats`, `/history` | D1 (trades) + KV (portfolio, symbol cache) | Daily 5PM trim | Paper trading — VN stocks with dynamic symbol resolution | +| `wordle` | `/wordle`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | KV | — | 5-letter word guessing game. 14,855-word dict | +| `loldle` | `/loldle`, `/loldle_giveup`, `/loldle_stats` | KV | — | Classic-mode LoL champion guesser. Data synced from `tiennm99/loldle-data` | +| `lolschedule` | `/lolschedule_today`, `/lolschedule_week`, `/lolschedule_subscribe`, `/lolschedule_unsubscribe` | KV | Daily 01:00 UTC | LoL esports schedule + daily digest subscriptions | +| `semantle` | `/semantle`, `/semantle_giveup`, `/semantle_stats` | KV | — | English semantic word guessing via hosted word2sim service | +| `doantu` | `/doantu`, `/doantu_hint`, `/doantu_giveup`, `/doantu_stats` | KV | — | Vietnamese semantle via hosted phow2sim service | +| `twentyq` | `/twentyq`, `/twentyq_giveup`, `/twentyq_stats` | KV | — | Reverse-Akinator yes/no game. Workers AI (`@cf/google/gemma-4-26b-a4b-it`) generates round-start category+hint and judges each turn via one-line JSON | ## Key Data Flows @@ -75,4 +75,4 @@ Each module maintains its own `README.md` with commands, data model, and impleme ## Tests -`npm test` runs the full vitest suite (run in ~10 seconds — 733 tests). Structure: one folder per module under `tests/modules//`, shared fakes under `tests/fakes/` (fake-mongo, fake-kv-namespace, fake-d1, fake-bot, fake-modules, fake-ai). No workerd, no Telegram fixtures — pure-logic unit tests with injected fakes. See `tests/fakes/fake-mongo.js` for the frozen surface implemented during Phase 02–08. +`npm test` runs the full vitest suite (run in a few seconds — ~450 tests). Structure: one folder per module under `tests/modules//`, shared fakes under `tests/fakes/` (fake-kv-namespace, fake-d1, fake-bot, fake-modules, fake-ai). No workerd, no Telegram fixtures — pure-logic unit tests with injected fakes. diff --git a/docs/cost-tracking.md b/docs/cost-tracking.md deleted file mode 100644 index 9d7e51e..0000000 --- a/docs/cost-tracking.md +++ /dev/null @@ -1,65 +0,0 @@ -# MongoDB Atlas Cost Tracking - -Operational runbook for monitoring and managing MongoDB Atlas billing during and post-migration. - -## Free Tier Limits (M0) - -| Resource | Limit | Warning | -|----------|-------|---------| -| Storage | 512 MB | Reached 400 MB: prepare upgrade plan | -| Connections | 500 | Reached 400: review cron/handler concurrency | -| Throughput | ~100 ops/sec sustained | Degradation: check dashboard Charts | -| Backups | None | No PITR; only daily snapshots (read-only) | - -## Cost Ladder - -| Tier | Monthly | When | Ops/sec | Storage | -|------|---------|------|---------|---------| -| **M0** | Free | Initial, dev | ~100 | 512 MB | -| **Flex** | $8–$30 | Sustained >400 MB or >400 conn | 400–1000 | 10–256 GB | -| **M10** | $57 | Production high-throughput | 1000+ | Unlimited | - -Flex tier auto-scales cost based on data volume and throughput. Start at M2 ($9) and scale to M5 ($70+) as needed. - -## Monitoring & Upgrade Triggers - -### Monthly Review Checklist - -1. **Storage:** Open Atlas Dashboard → Metrics → check `Database Storage` chart - - If > 400 MB: escalate to Flex within 2 weeks - - Projection: (current MB / days since epoch) * 30 → projected month-end - -2. **Connections:** Metrics → check `Current Connections` peak - - If > 400: cron jobs or handlers running concurrently too often - - Review `src/modules/*/index.js` cron frequency; stagger if possible - -3. **Throughput:** Metrics → `Network Egress` + `Database Operations` - - Spike during trading/wordle command storms: expected - - Sustained >100 ops/sec: assess Flex upgrade - -4. **Cluster Status:** Alerts → check if "Cluster unavailable" triggered - - M0 never pauses if any cron writes data (bot has 6+ crons, any write prevents pause) - -### Upgrade Decision - -Plan upgrade **before** hitting limits: - -| Condition | Action | Timeline | -|-----------|--------|----------| -| Storage trend → 512 MB in 3 months | Upgrade to Flex M2 | 2 weeks notice | -| Peak connections > 400 regularly | Upgrade to Flex M2 | Immediate if sustained | -| Ops/sec spikes > 2000 | Upgrade to Flex M5 or M10 | 1 week notice | - -## Rotation & Maintenance - -- **Password rotation:** Every 90 days, owner = repo maintainer. See `docs/using-mongodb.md` "Rotation" section. -- **Alert config review:** Monthly, ensure Atlas + CF Observability alerts are wired. -- **Backups:** M0 has none; data is live-only. Backups start at Flex tier. - -## Post-Cutover Simplification - -Once Phase 07 cutover completes and Cloudflare KV/D1 are deleted, MongoDB becomes the sole data store. Cost is dominated by storage growth (KV reads/writes are gone). Rebaseline this doc: - -- Remove dual-write cost considerations -- Focus on pure Mongo spend -- Extend storage projections based on new single-source data diff --git a/docs/project-changelog.md b/docs/project-changelog.md deleted file mode 100644 index 1498d1f..0000000 --- a/docs/project-changelog.md +++ /dev/null @@ -1,44 +0,0 @@ -# Project Changelog - -All significant changes, features, and fixes to miti99bot. - -## [2026-04-25] MongoDB Atlas Migration Complete - -**Status:** Phases 01–08 committed on `dev` branch. Dual-write ready; cutover pending operator execution (Phase 07 binding deletion). - -**Summary:** -Research, planning, and 7 phases of implementation + documentation to migrate from Cloudflare KV/D1 to MongoDB Atlas M0 free tier. Achieved zero-downtime dual-write architecture with automated drift detection, backfill/reverse-backfill scripts, and comprehensive telemetry. All 733 unit tests passing. - -**Plan:** `plans/260425-1945-mongodb-atlas-migration/` - -**Phases:** -- Phase 01: Wrangler config, secret-leak lint, bundle-size gate -- Phase 02: MongoKVStore + memoized client + fake-mongo + tests -- Phase 03: MongoTradesStore + trading refactor + MongoSqlStore shim -- Phase 04: Dual-write wrappers + factories + retry queue + drift verifier + e2e -- Phase 05: Backfill scripts (local node, CF KV REST + wrangler d1 export) -- Phase 06: Telemetry instrumentation + soak runbook -- Phase 07: Cutover scripts + reverse-backfill + decommission helpers -- Phase 08: Final docs pass + alternatives section update - -**Key artifacts:** -- `src/db/mongo-kv-store.js` — MongoDB implementation of KVStore interface -- `src/db/mongo-trades-store.js` — MongoDB trading ledger store -- `src/db/dual-kv-store.js` — dual-write wrapper for safety during migration -- `src/db/mongo-client.js` — shared memoized connection with retry logic -- `docs/using-mongodb.md` — operational runbook -- `docs/cost-tracking.md` — cost monitoring and upgrade triggers - -**Reviewers noted** (not adopted): -- Maintenance-window cutover instead of dual-write (saves ~6h engineering) -- Defer trading migration (D1 free handles ~100 writes/day indefinitely) -- Single shared `kv` collection vs 12 per-module collections -- Pivot to Upstash before starting (smaller bundle, HTTP-native) - -User chose full cold-start Atlas validation. If bundle size trips, `phase-07-alt-pivot.md` is ready. - -**Post-cutover simplification** (Phase 07 Stage 3 — operator-driven): -- Delete dual-write layer -- Delete `cf-kv-store.js`, `cf-sql-store.js`, and D1 bindings from `wrangler.toml` -- `createStore()` returns pure `MongoKVStore` -- Cost tracking shifts to Mongo-only projections diff --git a/docs/using-mongodb.md b/docs/using-mongodb.md deleted file mode 100644 index bfd327f..0000000 --- a/docs/using-mongodb.md +++ /dev/null @@ -1,142 +0,0 @@ -# Using MongoDB Atlas - -Operational runbook for the MongoDB Atlas backend introduced by `plans/260425-1945-mongodb-atlas-migration/`. - -## Cluster - -| Field | Value | -|---|---| -| Provider | MongoDB Atlas | -| Tier | M0 Free | -| Region | `aws-ap-southeast-1` (Singapore) | -| Cluster name | `miti99bot-prod` (operator confirms) | -| Database | `miti99bot` | -| DB user | `miti99bot-worker` (`readWrite@miti99bot`) | - -Connection string format: - -``` -mongodb+srv://miti99bot-worker:@/miti99bot?retryWrites=true&w=majority -``` - -Stored in two places (must match): -1. CF Worker secret: `wrangler secret put MONGODB_URI` -2. `.env.deploy` (gitignored, used by local backfill / verify scripts) - -Same secret-mirror protocol as `TELEGRAM_BOT_TOKEN`. - -## Free-tier ceiling - -- 512 MB storage (data + indexes) -- 500 max concurrent connections -- ~100 ops/sec sustained (no daily cap) -- No backups, single region, no PITR -- Auto-pauses after 30 days of zero ops - -Upgrade path: **Flex Tier $8–$30/month** (M2/M5 deprecated as of 2026). - -## Auto-pause - -After 30 days idle the cluster pauses. First request after pause: -- Driver throws `MongoServerSelectionError` after `serverSelectionTimeoutMS` (5s). -- Worker code (see `src/db/mongo-client.js`, lands Phase 02) catches and returns 503 with `Retry-After: 30`. -- Cluster auto-wakes within 30–60s on attempted connection. - -The bot has 6+ daily crons; any cron that writes Mongo prevents pause. Phase 08 confirms. - -## Network access - -`0.0.0.0/0` — Cloudflare Workers do NOT have static egress IPs on the Free or basic Paid plans. Only auth (SCRAM-SHA-256) + TLS gate connections. - -**Permanent risk** unless upgrading to CF Workers paid static-egress IP add-on (~$10/mo). - -Mitigations: -- DB user has `readWrite` on one db only (NOT `dbAdmin` / `clusterAdmin`). -- Password ≥32 chars random. -- Rotate quarterly. -- Atlas free-tier email alerts configured for cluster unavailability + connections > 400. - -## Bundle gate (Phase 01 result) - -Measured `npx wrangler deploy --dry-run` with a minimal probe importing `MongoClient`: - -| Metric | Value | Cap (Free) | Cap (Paid) | -|---|---|---|---| -| Compressed (gzip) | **226 KiB** | 3 MiB | 10 MiB | -| Raw (minified) | 1.74 MiB | — | — | -| On-disk (uncompressed) | 3.9 MiB | — | — | - -Pass on both plans with **>92% headroom**. nodejs_compat_v2 provides `node:net`/`node:tls`/`node:crypto` from the runtime, so the driver's transitive deps are not bundled. - -## CPU-time gate (Phase 01 — operator-run) - -Requires real Atlas + `wrangler dev`. Procedure: - -1. Add a temporary `/__mongo-ping` route that connects + runs `db.runCommand({ping:1})` + returns `{wall_ms}`. -2. Run 5+ cold cycles (10-min spaced). -3. Inspect CF dashboard CPU column for each invocation. -4. **Hard gate**: if any cold-start CPU time approaches 50ms (Free plan limit), abort migration. Escalate to paid plan or pivot via `phase-07-alt-pivot.md`. -5. Record cold-ping P95 wall-clock as `BASELINE_COLD_PING_MS` here: - -``` -BASELINE_COLD_PING_MS = -``` - -Phase 06 derives the abort threshold from this value: `2.5 × BASELINE_COLD_PING_MS`. - -## Auto-pause behavior gate (Phase 01 — operator-run) - -In Atlas UI, manually pause the cluster, then hit `/__mongo-ping`. Confirm: -- Driver throws within 5s (does NOT hang indefinitely). -- Error class is `MongoServerSelectionError` (or driver subclass). -- Phase 02 `getDb()` catches this and surfaces a 503. - -## Node API surface - -`src/` (the Worker) imports zero `node:*` modules today. `nodejs_compat_v2` is enabled solely for the `mongodb` driver: - -| Module | Used by Worker? | Used by scripts/? | -|---|---|---| -| `node:fs` | no | yes (build/scrape/migrate) | -| `node:path` | no | yes | -| `node:child_process` | no | yes (migrate.js) | -| `node:net` | indirectly (via mongodb) | no | -| `node:tls` | indirectly (via mongodb) | no | -| `node:crypto` | indirectly (via mongodb) | no | -| `process.env` | no | yes (register.js) | -| `Buffer` | no | no | - -Risk: minimal. No existing module relies on the absence of these globals. - -## Rollback - -If migration is abandoned at any phase before cutover: - -1. `wrangler secret delete MONGODB_URI` -2. Revert `wrangler.toml`: remove `compatibility_flags = ["nodejs_compat_v2"]`. -3. `npm uninstall mongodb`. -4. `npm run deploy` — bot continues on KV/D1 unchanged. -5. (Optional) Delete Atlas cluster from UI. - -`scripts/check-secret-leaks.js` should stay — it covers other secrets too. - -## Rotation - -`MONGODB_URI` rotation cadence: every 90 days, owner = repo maintainer. - -Procedure: -1. In Atlas UI → Database Access → edit `miti99bot-worker` → reset password. -2. Update `.env.deploy` with new URI. -3. `wrangler secret put MONGODB_URI` (paste new URI). -4. `npm run deploy` (re-runs register; no Worker restart needed since secret reads at request time via `env.MONGODB_URI`). - -Mismatch between `.env.deploy` and CF secret causes register-script failure on next deploy — same fail-loud pattern as `TELEGRAM_WEBHOOK_SECRET`. - -## Alerts - -Configured in Atlas free-tier UI: - -- **Cluster unavailable** → email maintainer. -- **Current connections > 400** (80% of cap) → email maintainer. - -Plus CF Observability rule (Phase 06): >10 errors per 1 min window → email. diff --git a/package-lock.json b/package-lock.json index a1312d7..653b51a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,7 @@ "name": "miti99bot", "version": "0.1.0", "dependencies": { - "grammy": "^1.30.0", - "mongodb": "^6.21.0" + "grammy": "^1.30.0" }, "devDependencies": { "@biomejs/biome": "^1.9.0", @@ -1054,15 +1053,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.4.9", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.9.tgz", - "integrity": "sha512-RXSxsokhAF/4nWys8An8npsqOI33Ex1Hlzqjw2pZOO+GKtMAR2noGnUdsFiGwsaO/xXI+56mtjTmDA3JXJsvmA==", - "license": "MIT", - "dependencies": { - "sparse-bitfield": "^3.0.3" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -1475,21 +1465,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT" - }, - "node_modules/@types/whatwg-url": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", - "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", - "license": "MIT", - "dependencies": { - "@types/webidl-conversions": "*" - } - }, "node_modules/@typescript-eslint/types": { "version": "8.58.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", @@ -1720,15 +1695,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/bson": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", - "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", - "license": "Apache-2.0", - "engines": { - "node": ">=16.20.1" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2624,12 +2590,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT" - }, "node_modules/miniflare": { "version": "4.20260420.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260420.0.tgz", @@ -2667,96 +2627,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mongodb": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", - "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^6.10.4", - "mongodb-connection-string-url": "^3.0.2" - }, - "engines": { - "node": ">=16.20.1" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", - "gcp-metadata": "^5.2.0", - "kerberos": "^2.0.1", - "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.3.2", - "socks": "^2.7.1" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", - "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^11.0.2", - "whatwg-url": "^14.1.0 || ^13.0.0" - } - }, - "node_modules/mongodb-connection-string-url/node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/mongodb-connection-string-url/node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2991,6 +2861,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3141,15 +3012,6 @@ "node": ">=0.10.0" } }, - "node_modules/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "license": "MIT", - "dependencies": { - "memory-pager": "^1.0.2" - } - }, "node_modules/spdx-exceptions": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", diff --git a/package.json b/package.json index a6c1efb..9232658 100644 --- a/package.json +++ b/package.json @@ -18,25 +18,12 @@ "db:migrate": "node scripts/migrate.js", "register": "node --env-file-if-exists=.env.deploy scripts/register.js", "register:dry": "node --env-file-if-exists=.env.deploy scripts/register.js --dry-run", - "backfill:kv": "node --env-file-if-exists=.env.deploy scripts/backfill-kv-to-mongo.js", - "backfill:kv:dry": "node --env-file-if-exists=.env.deploy scripts/backfill-kv-to-mongo.js --dry-run", - "backfill:d1": "node --env-file-if-exists=.env.deploy scripts/backfill-d1-to-mongo.js", - "backfill:d1:dry": "node --env-file-if-exists=.env.deploy scripts/backfill-d1-to-mongo.js --dry-run", - "backfill:mongo:kv": "node --env-file-if-exists=.env.deploy scripts/backfill-mongo-to-kv.js", - "backfill:mongo:kv:dry": "node --env-file-if-exists=.env.deploy scripts/backfill-mongo-to-kv.js --dry-run", - "backfill:mongo:d1": "node --env-file-if-exists=.env.deploy scripts/backfill-mongo-to-d1.js", - "backfill:mongo:d1:dry": "node --env-file-if-exists=.env.deploy scripts/backfill-mongo-to-d1.js --dry-run", - "verify:mongo": "node --env-file-if-exists=.env.deploy scripts/verify-mongo-parity.js", - "wipe:mongo": "node --env-file-if-exists=.env.deploy scripts/wipe-mongo.js", - "analyze:soak": "node scripts/analyze-soak.js", - "burst:synthetic": "node scripts/synthetic-burst.js", - "lint": "biome check . && eslint src && node scripts/check-secret-leaks.js", + "lint": "biome check . && eslint src", "format": "biome format --write .", "test": "vitest run" }, "dependencies": { - "grammy": "^1.30.0", - "mongodb": "^6.21.0" + "grammy": "^1.30.0" }, "devDependencies": { "@biomejs/biome": "^1.9.0", diff --git a/plans/archive/260422-2128-semantle-module/phase-01-foundation.md b/plans/260422-2128-semantle-module/phase-01-foundation.md similarity index 100% rename from plans/archive/260422-2128-semantle-module/phase-01-foundation.md rename to plans/260422-2128-semantle-module/phase-01-foundation.md diff --git a/plans/archive/260422-2128-semantle-module/phase-02-gameplay.md b/plans/260422-2128-semantle-module/phase-02-gameplay.md similarity index 100% rename from plans/archive/260422-2128-semantle-module/phase-02-gameplay.md rename to plans/260422-2128-semantle-module/phase-02-gameplay.md diff --git a/plans/archive/260422-2128-semantle-module/phase-03-tests-docs.md b/plans/260422-2128-semantle-module/phase-03-tests-docs.md similarity index 100% rename from plans/archive/260422-2128-semantle-module/phase-03-tests-docs.md rename to plans/260422-2128-semantle-module/phase-03-tests-docs.md diff --git a/plans/archive/260422-2128-semantle-module/plan.md b/plans/260422-2128-semantle-module/plan.md similarity index 100% rename from plans/archive/260422-2128-semantle-module/plan.md rename to plans/260422-2128-semantle-module/plan.md diff --git a/plans/archive/260422-2128-semantle-module/reports/code-reviewer-260422-2200-semantle-review.md b/plans/260422-2128-semantle-module/reports/code-reviewer-260422-2200-semantle-review.md similarity index 100% rename from plans/archive/260422-2128-semantle-module/reports/code-reviewer-260422-2200-semantle-review.md rename to plans/260422-2128-semantle-module/reports/code-reviewer-260422-2200-semantle-review.md diff --git a/plans/archive/260422-2128-semantle-module/reports/tester-260422-2155-semantle-tests.md b/plans/260422-2128-semantle-module/reports/tester-260422-2155-semantle-tests.md similarity index 100% rename from plans/archive/260422-2128-semantle-module/reports/tester-260422-2155-semantle-tests.md rename to plans/260422-2128-semantle-module/reports/tester-260422-2155-semantle-tests.md diff --git a/plans/archive/260424-1335-twentyq-game-module/phase-01-foundation.md b/plans/260424-1335-twentyq-game-module/phase-01-foundation.md similarity index 100% rename from plans/archive/260424-1335-twentyq-game-module/phase-01-foundation.md rename to plans/260424-1335-twentyq-game-module/phase-01-foundation.md diff --git a/plans/archive/260424-1335-twentyq-game-module/phase-02-ai-client.md b/plans/260424-1335-twentyq-game-module/phase-02-ai-client.md similarity index 100% rename from plans/archive/260424-1335-twentyq-game-module/phase-02-ai-client.md rename to plans/260424-1335-twentyq-game-module/phase-02-ai-client.md diff --git a/plans/archive/260424-1335-twentyq-game-module/phase-03-gameplay-handlers.md b/plans/260424-1335-twentyq-game-module/phase-03-gameplay-handlers.md similarity index 100% rename from plans/archive/260424-1335-twentyq-game-module/phase-03-gameplay-handlers.md rename to plans/260424-1335-twentyq-game-module/phase-03-gameplay-handlers.md diff --git a/plans/archive/260424-1335-twentyq-game-module/phase-04-tests-docs.md b/plans/260424-1335-twentyq-game-module/phase-04-tests-docs.md similarity index 100% rename from plans/archive/260424-1335-twentyq-game-module/phase-04-tests-docs.md rename to plans/260424-1335-twentyq-game-module/phase-04-tests-docs.md diff --git a/plans/archive/260424-1335-twentyq-game-module/plan.md b/plans/260424-1335-twentyq-game-module/plan.md similarity index 100% rename from plans/archive/260424-1335-twentyq-game-module/plan.md rename to plans/260424-1335-twentyq-game-module/plan.md diff --git a/plans/archive/260424-1335-twentyq-game-module/reports/code-review-260424-1821-project-cleanup.md b/plans/260424-1335-twentyq-game-module/reports/code-review-260424-1821-project-cleanup.md similarity index 100% rename from plans/archive/260424-1335-twentyq-game-module/reports/code-review-260424-1821-project-cleanup.md rename to plans/260424-1335-twentyq-game-module/reports/code-review-260424-1821-project-cleanup.md diff --git a/plans/archive/260424-2215-loldle-new-modes/phase-01-shared-helpers.md b/plans/260424-2215-loldle-new-modes/phase-01-shared-helpers.md similarity index 100% rename from plans/archive/260424-2215-loldle-new-modes/phase-01-shared-helpers.md rename to plans/260424-2215-loldle-new-modes/phase-01-shared-helpers.md diff --git a/plans/archive/260424-2215-loldle-new-modes/phase-02-emoji-module.md b/plans/260424-2215-loldle-new-modes/phase-02-emoji-module.md similarity index 100% rename from plans/archive/260424-2215-loldle-new-modes/phase-02-emoji-module.md rename to plans/260424-2215-loldle-new-modes/phase-02-emoji-module.md diff --git a/plans/archive/260424-2215-loldle-new-modes/phase-03-quote-module.md b/plans/260424-2215-loldle-new-modes/phase-03-quote-module.md similarity index 100% rename from plans/archive/260424-2215-loldle-new-modes/phase-03-quote-module.md rename to plans/260424-2215-loldle-new-modes/phase-03-quote-module.md diff --git a/plans/archive/260424-2215-loldle-new-modes/phase-04-ability-module.md b/plans/260424-2215-loldle-new-modes/phase-04-ability-module.md similarity index 100% rename from plans/archive/260424-2215-loldle-new-modes/phase-04-ability-module.md rename to plans/260424-2215-loldle-new-modes/phase-04-ability-module.md diff --git a/plans/archive/260424-2215-loldle-new-modes/phase-05-splash-module.md b/plans/260424-2215-loldle-new-modes/phase-05-splash-module.md similarity index 100% rename from plans/archive/260424-2215-loldle-new-modes/phase-05-splash-module.md rename to plans/260424-2215-loldle-new-modes/phase-05-splash-module.md diff --git a/plans/archive/260424-2215-loldle-new-modes/phase-06-tests-docs.md b/plans/260424-2215-loldle-new-modes/phase-06-tests-docs.md similarity index 100% rename from plans/archive/260424-2215-loldle-new-modes/phase-06-tests-docs.md rename to plans/260424-2215-loldle-new-modes/phase-06-tests-docs.md diff --git a/plans/archive/260424-2215-loldle-new-modes/plan.md b/plans/260424-2215-loldle-new-modes/plan.md similarity index 100% rename from plans/archive/260424-2215-loldle-new-modes/plan.md rename to plans/260424-2215-loldle-new-modes/plan.md diff --git a/plans/260425-1945-mongodb-atlas-migration/plan.md b/plans/260425-1945-mongodb-atlas-migration/plan.md index c22eb0c..9fd5d2c 100644 --- a/plans/260425-1945-mongodb-atlas-migration/plan.md +++ b/plans/260425-1945-mongodb-atlas-migration/plan.md @@ -1,19 +1,16 @@ --- 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: code-complete +status: planning priority: P2 effort: 22h -branch: dev +branch: main tags: [storage, migration, mongodb, atlas, cloudflare-workers] created: 2026-04-25 -code_completed: 2026-04-26 blockedBy: [] blocks: [] --- -> **Status note:** All 8 phases of code/config/scripts/docs are implemented and committed on `dev` (commits `6f0b5ff`..`e2e3112`). 503 → 733 tests; lint clean; `register:dry` green. **Operator-driven execution is pending**: Atlas provisioning (Phase 01 §1-7), real-cluster smoke tests (Phase 01 §13-14), backfill runs (Phase 05), 24-72h soak (Phase 06), cutover stages (Phase 07), and Stage 3 code cleanup (delete CFKVStore/dual-stores after binding deletion). Plan stays here (not archived) until cutover lands or the Upstash standby (`phase-07-alt-pivot.md`) executes. - # 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. @@ -36,15 +33,15 @@ User-chosen path despite research recommending Upstash. Goal: validate cold-star | # | Phase | Status | Effort | Owner files | |---|-------|--------|--------|-------------| -| 01 | [Atlas setup + wrangler config](phase-01-atlas-setup.md) | code-complete · operator-pending | 2h | `wrangler.toml`, `.env.deploy.example`, `scripts/check-secret-leaks.js` | -| 02 | [MongoKVStore implementation](phase-02-mongo-kv-store.md) | implemented (`5b00cae`) | 3h | `src/db/mongo-*.js` | -| 03 | [MongoTradesStore + trading refactor](phase-03-mongo-sql-store.md) | implemented (`99cd844`) | 3h | `src/db/mongo-trades-store.js`, `src/modules/trading/*` | -| 04 | [Dual-write wrappers + flag + e2e](phase-04-dual-write-wrappers.md) | implemented (`ea7df56`) | 4h | `src/db/dual-*.js`, factories, `tests/e2e/*` | -| 05 | [Backfill + verification (local-only)](phase-05-backfill-scripts.md) | implemented · operator-runs (`0859356`) | 3h | `scripts/backfill-*.js` | -| 06 | [Staged deploy + soak (cold-start gate)](phase-06-staged-deploy-and-soak.md) | code-complete · operator-runs (`55c8739`) | 4h | runtime telemetry | -| 07 | [Cutover + decommission](phase-07-cutover-and-decommission.md) | prereqs-complete · operator-runs (`3f03521`) | 3h | wrangler bindings | +| 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) | implemented (`e2e3112`) | 1h | `tests/`, `docs/` | +| 08 | [Tests + docs](phase-08-tests-and-docs.md) | pending | 1h | `tests/`, `docs/` | ## Critical dependencies - 01 → 02, 03 (Atlas creds + bundle-size gate required) diff --git a/plans/archive/260422-2128-semantle-module/reports/researcher-260423-1110-vietnamese-embeddings-semantle.md b/plans/archive/260422-2128-semantle-module/reports/researcher-260423-1110-vietnamese-embeddings-semantle.md deleted file mode 100644 index 80bf6e4..0000000 --- a/plans/archive/260422-2128-semantle-module/reports/researcher-260423-1110-vietnamese-embeddings-semantle.md +++ /dev/null @@ -1,211 +0,0 @@ -# Vietnamese Embeddings for Doantu: sup-SimCSE vs PhoW2V - -**Date:** 2026-04-23 | **Scope:** Word-level cosine similarity in fixed 22k-vocab Semantle clone - ---- - -## Executive Verdict - -**Recommendation: PhoW2V (word-level, 300d) is the better fit.** - -Reasons: (1) Purpose-built for word similarity, not sentences; (2) static word2vec format enables precomputation into lookup table — zero inference overhead; (3) no external segmentation tool required; (4) Cloudflare Worker-friendly (ship vectors in KV or bundle with Worker). - -**sup-SimCSE is inferior here** despite better semantic depth, because it requires runtime inference, external VnCoreNLP/pyvi segmentation, and sentence-level training (not optimized for single-word pairs). Violates KISS. - ---- - -## Detailed Comparison - -### 1. **sup-SimCSE-VietNamese-phobert-base** (VoVanPhuc) - -| Aspect | Value | Trade-off | -|--------|-------|-----------| -| **Embedding Dim** | 768 | Large; overkill for word pairs | -| **Training** | Supervised contrastive (SimCSE) | Optimized for sentence similarity, not word pairs | -| **Level** | Sentence-level | Not designed for single-word input | -| **Vocab** | Open (transformer subword tokenization) | Handles unseen words via BPE; adds latency | -| **Segmentation** | **REQUIRED** (RDRSegmenter or pyvi) | Extra runtime dependency; "con chó" must become "con_chó" before encoding | -| **Model Size** | 135M parameters | ~250–350 MB disk; requires transformers + torch on Worker? Infeasible. | -| **Inference** | Runtime + tokenization | Cold start latency; not precomputable for 22k words | -| **Format** | Hugging Face (transformers) | No static dump; requires active model loading | - -**Key Gotcha:** PhoBERT's tokenizer expects **pre-segmented input**. "máy bay" (airplane) as raw input will tokenize as ["máy", "bay"] separately unless segmented to "máy_bay". You'd need VnCoreNLP + pyvi running in Worker — expensive and fragile. - ---- - -### 2. **PhoW2V** (VinAI, datquocnguyen) - -| Aspect | Value | Trade-off | -|--------|-------|-----------| -| **Embedding Dim** | 100 or 300 | 300d ideal; 100d saves space but lower quality | -| **Training** | Unsupervised word2vec (CBOW/Skip-gram) | Pure word-level; no sentence context — but this matches your use case exactly | -| **Level** | Word-level (and syllable-level variant) | Designed for single-word similarity | -| **Vocab** | ~100k words from 20GB corpus (likely covers 22k) | Finite vocab; OOV words get zero vector or nearest neighbor | -| **Segmentation** | **Optional** (can use word-level variant directly) | If input is already word-tokenized, no extra step | -| **Model Size** | ~30–50 MB (gensim KeyedVectors) | Tiny; fits in Cloudflare KV or bundle | -| **Inference** | Zero runtime — precompute entire 22k vocab | `precomputed[word] = word_vector` lookup O(1) | -| **Format** | Gensim KeyedVectors (text/binary) | Exportable as dense matrix (22k × 300) for embedding | - -**Critical Insight:** Word2Vec embeddings are **static lookup tables**. You can precompute similarity scores for all 22k² word pairs offline, or dump the 22k vectors into KV and compute cosine similarity on-demand (O(300) dot product per pair — negligible). - ---- - -## Tokenization & Diacritics - -### PhoBERT (sup-SimCSE) -- Requires VnCoreNLP/pyvi to convert raw input to segmented form. -- **"con chó"** → requires preprocessing to **"con_chó"** before tokenization. -- Adds runtime cost + dependency fragility. -- Diacritics preserved via RDRSegmenter normalization. - -### PhoW2V -- Word-level variant: expects whitespace-separated words (already segmented). -- If using syllable-level, requires syllable input (less relevant here). -- Diacritics preserved (trained on normalized Vietnamese corpus). -- **No segmentation tool needed if vocab covers your compound words.** - -**For a fixed 22k-word game vocabulary:** Pre-segment and validate your entire wordlist at deployment time. Both approaches require diacritic-aware matching ("cá" ≠ "ca"). - ---- - -## Precomputation & Cloudflare Worker Fit - -### sup-SimCSE (Cannot Precompute) -``` -❌ Runtime inference required (PhoBERT forward pass) -❌ Requires transformers + torch (not Worker-compatible) -❌ VnCoreNLP dependency for each request -❌ Cold start latency (~500ms per query on CPU) -``` - -### PhoW2V (Fully Precomputable) -``` -✅ Load KeyedVectors once at Worker start -✅ Precompute all 22k embeddings into in-memory dense matrix -✅ Cosine similarity: ~1ms per pair (vector dot product) -✅ Alternative: Ship (22k × 22k) similarity matrix in KV -✅ Or: ~7.3 MB dense matrix (22k × 300 × 4 bytes) fits in Worker bundles -``` - -**Verdict:** PhoW2V enables a **stateless, zero-latency** Semantle implementation. sup-SimCSE requires external inference infrastructure. - ---- - -## Vocabulary Coverage - -| Model | Vocab Size | 22k Viet22K Coverage | License | -|-------|------------|---------------------|---------| -| **PhoW2V** | ~100k (estimated from 20GB corpus) | Likely 95%+ (VinAI trained on broad Vietnamese text) | AGPL-3.0; research/education only; cite EMNLP-2020 | -| **sup-SimCSE** | Unbounded (subword + BPE) | 100% (BPE handles unknowns) | Likely permissive (HF model) | - -**Gotcha:** PhoW2V is **research-only, non-commercial**, and requires citation. Check your license constraints for a Telegram bot (even if private, may still violate terms). - ---- - -## Semantic Quality - -### sup-SimCSE -- **Advantage:** Trained on supervised sentence pairs; captures deeper semantic relationships. -- **Disadvantage:** Trained on sentence context; single-word pairs don't benefit from that context. -- **Effective for:** Semantically distant word pairs (e.g., "xe" vs "cách"); may over-regularize tight synonyms. - -### PhoW2V -- **Advantage:** Word-level training (CBOW/Skip-gram); embeddings encode co-occurrence statistics. -- **Disadvantage:** No supervised signal; relies purely on distributional similarity. -- **Effective for:** "Natural" word similarity (synonyms, related concepts); well-suited to Semantle-style games. - -**For a word-guessing game:** Both are reasonable. PhoW2V's simplicity is not a weakness here; it's a feature. - ---- - -## Implementation Complexity - -### sup-SimCSE (High Complexity) -```python -from sentence_transformers import SentenceTransformer -from pyvi.ViTokenizer import tokenize - -model = SentenceTransformer('VoVanPhuc/sup-SimCSE-...') -# Per query: -segmented = tokenize(raw_input) # Runtime overhead -emb = model.encode(segmented) -similarity = cosine(emb_target, emb_guess) -``` -- **Dependencies:** sentence-transformers, transformers, torch, pyvi. -- **Latency:** 200–500ms per query (even on GPU; Cloudflare Workers have no GPU). -- **Lines of code:** ~20. -- **External service:** Optional (could self-host, but adds infrastructure). - -### PhoW2V (Low Complexity) -```python -from gensim.models import KeyedVectors -import numpy as np - -kv = KeyedVectors.load_word2vec_format('phow2v.bin') -# Option A (precompute all): -embeddings = {word: kv[word] for word in vocab} - -# Per query: -similarity = np.dot(embeddings[target], embeddings[guess]) -``` -- **Dependencies:** gensim (tiny). -- **Latency:** <1ms per query (in-memory lookup). -- **Lines of code:** ~10. -- **External service:** None (pure static embeddings). - ---- - -## License & Attribution - -| Model | License | Restriction | -|-------|---------|------------| -| **sup-SimCSE** | Unclear (check HF model card) | Likely permissive for research | -| **PhoW2V** | AGPL-3.0 | **Research/education only; cite EMNLP-2020; non-commercial** | - -**Risk:** If doantu is a commercial Telegram bot or intends to be monetized, PhoW2V's AGPL restriction may be a blocker. Clarify with user. - ---- - -## Unresolved Questions - -1. **PhoW2V license constraints:** Is doantu commercial? Non-commercial? Verify AGPL-3.0 compatibility with your bot's intended use. -2. **Vocabulary overlap:** Exact coverage of 22k Viet22K words in PhoW2V. Could spot-check a few compounds like "máy bay", "con chó" in the model. -3. **Syllable vs word PhoW2V:** Recommendation assumes word-level variant. If Viet22K uses syllables, syllable-level variant may be needed; would require preprocessing. -4. **sup-SimCSE alternatives:** Are you open to other sentence transformers fine-tuned for Vietnamese word similarity (e.g., from FPTAI or other VN NLP labs)? -5. **Similarity matrix size:** Confirm whether shipping a precomputed (22k × 22k) matrix in KV is practical (~7.3 MB in dense form, ~200 MB in sparse COO). - ---- - -## Recommendation Summary - -**Use PhoW2V (300d word-level variant) for doantu.** - -**Why:** -- Single-word embeddings (not sentence-level). -- Static vectors → zero inference cost. -- Fits Cloudflare Worker budget (no external service needed). -- Precomputable into O(1) lookups. -- Simpler to deploy and maintain. - -**Why not sup-SimCSE:** -- Sentence-level training doesn't benefit single-word pairs. -- Runtime inference infeasible on CPU-only Cloudflare Workers. -- External segmentation (pyvi/VnCoreNLP) adds complexity and latency. -- 768-dim vectors overkill for word pairs; 300-dim sufficient. - -**Action items:** -1. Verify PhoW2V's AGPL-3.0 license permits your bot's use case. -2. Spot-check PhoW2V vocabulary against 22k-word game list (OOV strategy needed). -3. Decide precomputation strategy: in-memory matrix, KV store, or on-demand dot product. - ---- - -## Sources - -- [sup-SimCSE-VietNamese-phobert-base on Hugging Face](https://huggingface.co/VoVanPhuc/sup-SimCSE-VietNamese-phobert-base) -- [PhoW2V GitHub Repository](https://github.com/datquocnguyen/PhoW2V) -- [PhoBERT: Pre-trained Language Models for Vietnamese (EMNLP-2020 Findings)](https://aclanthology.org/2020.findings-emnlp.92.pdf) -- [VinAI Research – PhoBERT Overview](https://www.vinai.io/phobert-the-first-public-large-scale-language-models-for-vietnamese/) -- [Gensim Word2Vec KeyedVectors Documentation](https://radimrehurek.com/gensim/models/word2vec.html) -- [Semantle Word Embeddings Recreation](https://github.com/memgonzales/semantle-word-embeddings) -- [VnCoreNLP Word Segmentation](https://www.researchgate.net/publication/325449322_VnCoreNLP_A_Vietnamese_Natural_Language_Processing_Toolkit) diff --git a/plans/reports/docs-manager-260420-2151-documentation-audit.md b/plans/reports/docs-manager-260420-2151-documentation-audit.md new file mode 100644 index 0000000..70fe0e2 --- /dev/null +++ b/plans/reports/docs-manager-260420-2151-documentation-audit.md @@ -0,0 +1,143 @@ +# Documentation Audit Report + +## Summary + +Audited 12 documentation files covering architecture, module setup, deployment, code standards, and module READMEs. Found **3 major stale sections** related to module implementation status (wordle/loldle described as stubs when now fully implemented) and **1 test count discrepancy**. No broken links detected. Modular architecture docs are accurate. + +## Doc Files Inventory + +| File | Purpose | Status | +|------|---------|--------| +| README.md | Top-level overview, setup, deploy, troubleshooting | **MAJOR-DRIFT** | +| CLAUDE.md | Dev guidance, commands, module contract, testing | **FRESH** | +| docs/architecture.md | Deep dive: cold-start, registry, storage, crons, deploy | **MINOR-DRIFT** | +| docs/adding-a-module.md | Step-by-step module authoring guide | **FRESH** | +| docs/code-standards.md | Formatting, JSDoc, file org, naming, testing | **FRESH** | +| docs/codebase-summary.md | Tech stack, active modules table, data flows | **MAJOR-DRIFT** | +| docs/deployment-guide.md | CF setup, KV, D1, secrets, deploy steps, rollback | **FRESH** | +| docs/using-d1.md | When to use D1 vs KV, SQL API, migration examples | **FRESH** | +| docs/using-cron.md | Cron syntax, handler signature, examples | **FRESH** | +| src/modules/wordle/README.md | Commands, architecture, KV schema | **FRESH** | +| src/modules/loldle/README.md | Commands, architecture, KV schema | **MAJOR-DRIFT** | +| src/modules/misc/README.md | Commands, KV demo, schema | **FRESH** | + +## Detailed Findings + +### 1. README.md — MAJOR-DRIFT + +**Lines 14, 67-68: Test count and module status discrepancies** + +- **Line 14 claim:** "105+ vitest unit tests" +- **Actual:** 200 tests (verified: `npm test` output shows "Tests 200 passed") +- **Fix:** Update to "200+ vitest unit tests" + +- **Line 67 claim:** "wordle/ # stub — proves plugin system" +- **Line 68 claim:** "loldle/ # stub" +- **Actual:** Both are now full implementations: + - Wordle: 4 commands (guessing game, new round, giveup, stats) with KV state, render, daily word, compare logic + - Loldle: 4 commands (guessing game, new round, giveup, stats) with KV state, champions data, compare logic + - Commit 8a9a6af: "feat(wordle): port classic 5-letter guessing game" +- **Fix:** Change line 67 to "wordle/ # Classic 5-letter word guessing game (full impl)" and line 68 to "loldle/ # League of Legends champion guessing game (full impl)" + +### 2. docs/codebase-summary.md — MAJOR-DRIFT + +**Lines 25-26: Module status table outdated** + +| Row | Claim | Actual | +|-----|-------|--------| +| `wordle` | "Status: Stub" | Full implementation: `/wordle`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | +| `wordle` | "Commands: `/wordle`, `/wstats`, `/konami`" | Wrong commands listed; actual: `/wordle`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` (all public) | +| `wordle` | "Storage: —" | Uses KV (see src/modules/wordle/README.md) | +| `loldle` | "Status: Stub" | Full implementation: `/loldle`, `/loldle_new`, `/loldle_giveup`, `/loldle_stats` | +| `loldle` | "Commands: `/loldle`, `/ggwp`" | Missing 3 commands; actual: `/loldle`, `/loldle_new`, `/loldle_giveup`, `/loldle_stats` | +| `loldle` | "Storage: —" | Uses KV (see src/modules/loldle/index.js lines 10, 16) | + +**Fix:** Update the "Active Modules" table rows for wordle and loldle with actual command counts, visibility, and KV storage. + +### 3. docs/architecture.md — MINOR-DRIFT + +**Line 33: Module classification outdated** + +- **Line 33 claim:** "wordle/ loldle/ — stub modules proving the plugin system" +- **Actual:** Both are now production implementations with full game logic +- **Fix:** Update to "wordle/ loldle/ — classic word/champion guessing games (full implementations)" or remove stub reference + +**Line 362: Test count** + +- **Line 362 claim:** "105 tests run in ~500ms" +- **Actual:** 200 tests run in ~2.26s (from `npm test`) +- **Fix:** Update to "200 tests run in ~2.26s" + +### 4. src/modules/loldle/README.md — MAJOR-DRIFT + +**Lines 1-3: Module described as stub** + +- **Current claim:** "League of Legends guessing game — currently a stub proving the plugin system." +- **Actual:** Full implementation with handlers imported (line 8: `import { handleGiveup, handleLoldle, handleNew, handleStats } from "./handlers.js"`), 4 commands, KV state (lines 10-17) +- **Fix:** Rewrite to match wordle/README.md pattern: describe the 4 commands, handlers, architecture (handlers.js, compare.js, lookup.js, daily.js, render.js, state.js, champions-data.js), and KV schema + +**Line 15: "No KV usage currently"** + +- **Actual:** Module has `init` hook and KV state management (lines 14-17) +- **Fix:** Document the `loldle:` namespace and game/stats keys (same pattern as wordle) + +**Lines 6-16: Commands are stubs with stub responses** + +- **Actual:** Commands have real handler implementations (handlers.js exists with 400+ LOC) +- **Fix:** Remove "stub" references, document actual commands and their behavior + +### 5. Cross-Reference Checks + +**Internal links verified:** + +- `README.md` → `docs/adding-a-module.md` ✓ (exists, correct relative path) +- `README.md` → `docs/architecture.md` ✓ +- `README.md` → `docs/using-d1.md` ✓ +- `README.md` → `docs/using-cron.md` ✓ +- `README.md` → `docs/deployment-guide.md` ✓ +- `docs/architecture.md` → `src/modules//README.md` ✓ (pattern reference, not broken link) +- All plan references in README.md reference existing directories ✓ + +**No broken links found.** + +### 6. Missing Documentation + +None. All required docs exist: + +- ✓ Top-level README with setup, deploy, troubleshooting +- ✓ Architecture deep-dive +- ✓ Adding a module guide +- ✓ Code standards +- ✓ Codebase summary +- ✓ Deployment guide (KV + D1) +- ✓ D1 usage guide +- ✓ Cron usage guide +- ✓ Per-module READMEs (wordle, loldle, misc, trading, util) + +Note: `docs/todo.md` exists but appears to be an internal tracking doc, not user-facing documentation. + +--- + +## Prioritized Fix List + +### Immediate (blocking user confusion) + +1. **README.md line 14:** Update "105+ vitest" → "200+" (test count) +2. **README.md lines 67-68:** Remove "stub" label from wordle/loldle in architecture snapshot +3. **docs/codebase-summary.md lines 25-26:** Update module status table (wordle/loldle to "Complete", fix commands, add KV storage) +4. **src/modules/loldle/README.md:** Full rewrite to describe actual implementation (4 commands, handlers, KV schema) not stub + +### Secondary (clarity improvement) + +5. **docs/architecture.md line 33:** Remove "stub" reference from module list description +6. **docs/architecture.md line 362:** Update test count from "105" → "200" and runtime from "~500ms" → "~2.26s" + +--- + +## Notes + +- Loldle module is fully implemented (files: handlers.js, compare.js, lookup.js, daily.js, render.js, state.js, champions-data.js, champions.json) but its README still describes it as a stub — this is the most glaring discrepancy. +- Wordle module is correctly documented in its own README but incorrectly labeled "stub" in architecture snapshot and codebase summary. +- Test count increased from 105 to 200 (likely due to additional trading module tests or recent test additions) — this is a growth metric worth celebrating. +- All module-specific README files are accurate except loldle. +- No architectural issues, just stale descriptive text that contradicts code reality. diff --git a/plans/reports/researcher-260421-0845-leaguepedia-api-verification.md b/plans/reports/researcher-260421-0845-leaguepedia-api-verification.md new file mode 100644 index 0000000..cd0c429 --- /dev/null +++ b/plans/reports/researcher-260421-0845-leaguepedia-api-verification.md @@ -0,0 +1,80 @@ +# Leaguepedia API — Verification Report + +**Date:** 2026-04-21 +**Purpose:** Verify the Leaguepedia MediaWiki/Cargo API can provide today / this-week LoL matches for a miti99bot module. No implementation, verification only. +**Verdict:** **YES — usable.** Endpoint is public, no auth, returns structured JSON. One caveat on `where=` clauses needs rechecking from Cloudflare Workers egress. + +--- + +## Endpoint + +- Base: `https://lol.fandom.com/api.php` +- Action: `cargoquery` (MediaWiki Cargo extension) +- Auth: none +- Format: `format=json` → clean `{cargoquery:[{title:{…}}]}` payload +- Related `mw.Api` JS wrapper (doc-wikimedia link) works the same; for a Worker we use plain `fetch`, not `mw.Api` + +## Relevant table: `MatchSchedule` + +Primary table for both upcoming and played matches. Confirmed fields (live API, 2026-04-21): + +| Field | Type | Example | +|---|---|---| +| `DateTime_UTC` | datetime | `"2026-06-14 09:00:00"` | +| `Team1`, `Team2` | string | `"T1"`, `"TBD"` | +| `Tournament` | string | Tournament name/slug | +| `BestOf` | int | | +| `Winner` | string | empty until played | +| `OverviewPage` | string | wiki page for tournament | +| `_pageName` | string | wiki row page | + +Complementary tables: `Tournaments` (metadata), `ScoreboardGames` (per-game stats), `Teams`. + +## Query syntax (verified working) + +Use **table + field aliases** — the bare form `fields=MatchSchedule.DateTime_UTC` hits an `MWException`. Alias form is the idiomatic Leaguepedia convention: + +``` +tables=MatchSchedule=MS +fields=MS.DateTime_UTC=DateTime, MS.Team1=T1, MS.Team2=T2, MS.Tournament=Tournament +order_by=MS.DateTime_UTC ASC +limit=20 +``` + +Live sample (no where-filter) returned real rows. Ordering, limit, and aliasing all confirmed working. + +Intended week-window query (to be re-verified from CF Worker egress): + +``` +where=MS.DateTime_UTC >= "2026-04-21 00:00:00" + AND MS.DateTime_UTC < "2026-04-28 00:00:00" +``` + +## Limitations & operational notes + +- **Strict anonymous rate limit.** From a single shared egress IP the API throttled after 1–2 req/min with `ratelimited`. Mitigations for Workers: + - Use Worker's distributed egress (many IPs) — in practice won't hit the same bucket + - Cache responses in KV (e.g. 60–300 s for upcoming schedule, 5–15 min for results) + - Use `cf: { cacheTtl, cacheEverything: true }` on `fetch` +- **User-Agent required.** Fandom's policy expects a contact UA, e.g. `miti99bot/0.1 (https://t.me/miti99bot; minhtienit99@gmail.com)`. +- **Help page is Cloudflare-challenged.** `https://lol.fandom.com/wiki/Help:Leaguepedia_API` returns 403 to non-browser UAs — consult it from a browser, not from `fetch` code. +- **No official JS SDK.** MediaWiki's `mw.Api` is on-wiki JS only. Community Python wrapper (`mwrogue` / `leaguepedia_parser`) is the reference implementation — we port the query shape, not the lib. +- **`where=` clause returned MWException from this verification IP** even on trivial filters (`MS.Team1="T1"`). Likely an upstream filter on the shared egress, not a protocol limitation — the exact form is documented and heavily used. **Needs one confirmation curl from a CF Worker before building on it.** + +## Feasibility verdict + +| Requirement | Feasible? | Notes | +|---|---|---| +| Fetch today's matches | ✅ | `DateTime_UTC >= today AND < tomorrow` | +| Fetch this week's matches | ✅ | 7-day window on `DateTime_UTC` | +| Filter by region/league | ✅ | `Tournament LIKE "LCK%"` or `OverviewPage` | +| Include results/winners | ✅ | `Winner`, `BestOf` already on row | +| Run from Cloudflare Worker | ✅ | plain `fetch` + JSON; add UA + KV cache | +| Scheduled daily digest | ✅ | fits existing `cron-dispatcher.js` pattern | + +## Unresolved questions + +1. Does `where=` with comparison operators (`>=`, `<`) work from CF Worker egress, or do we need to alternate filter form (`HOLDS`, `LIKE`, full-table-scan + client-side filter)? +2. Timezone UX — show UTC, VN time (UTC+7), or let the `/matches` command take a region arg? +3. Caching window — ~60 s for "live today" vs ~5 min for week-view; confirm TTL with a real command spec before implementing. +4. Do we want the command to also surface `Winner`/score once a match has finished, or keep it schedule-only? diff --git a/plans/reports/researcher-260421-0909-leaguepedia-auth-token.md b/plans/reports/researcher-260421-0909-leaguepedia-auth-token.md new file mode 100644 index 0000000..86df96f --- /dev/null +++ b/plans/reports/researcher-260421-0909-leaguepedia-auth-token.md @@ -0,0 +1,55 @@ +# Leaguepedia / Fandom API — Auth Token Verification + +**Date:** 2026-04-21 +**Follow-up to:** `researcher-260421-0845-leaguepedia-api-verification.md` +**Question:** Can we register and use a token to avoid the rate limit? +**Verdict:** **No useful token available. Caching + CF Worker egress is the right answer.** + +--- + +## What's NOT available on Fandom + +| Mechanism | Status | Evidence | +|---|---|---| +| `Special:BotPasswords` | Disabled | 403 CF + disabled in UCP platform (documented in Fandom community) | +| OAuth 1.0a / 2.0 (`Special:OAuthConsumerRegistration`) | Not offered | 403; Fandom never enabled the OAuth extension | +| `action=clientlogin` (MW native login) | Disabled | `authmanagerinfo` returns only `RememberMeAuthenticationRequest`, no password field | +| WMF-style API-key header | N/A | MediaWiki has no such thing; Fandom has none either | + +```bash +# Live probe +curl '.../api.php?action=query&meta=authmanagerinfo&amirequestsfor=login&format=json' +# → only returns RememberMeAuthenticationRequest — native login is off +``` + +## What Fandom *does* expose + +- **Helios SSO** at `services.fandom.com/mobile-fandom-app/fandom-auth/login` + - POST `username` + `password` → `access_token` cookie + - Used by Leaguepedia's official `mwcleric` Python lib (`LoginCredentials`) + - Cookie is carried on subsequent `api.php` requests from same session + +## Does an authenticated cookie lift the rate limit? + +**No, not meaningfully.** + +- MediaWiki's `noratelimit` right only belongs to specific wiki groups (`sysop`, `bot`). Regular logged-in users have the same API limits as anonymous. +- Joining the `bot` group on Leaguepedia requires wiki-admin (Leaguepedia staff) approval — not practical for a side project. +- The throttling we hit earlier is **Fandom's Cloudflare-edge IP rate limit**, which is session-agnostic. Auth cookies don't bypass it. +- `siprop=ratelimits` is stripped on Fandom (`Unrecognized value`) — we can't even enumerate the limits. + +## Right answer for miti99bot (Cloudflare Worker) + +No token registration needed. Mitigate via: + +1. **Edge caching** — `fetch(url, { cf: { cacheTtl: 60, cacheEverything: true } })`. Many bot users share one cached response. +2. **KV result cache** — wrap the query in `create-store.js`, key = `matches:{from}:{to}`, TTL 60 s for "today", 5 min for "week". +3. **Cron pre-warm** — add a module cron (existing `cron-dispatcher.js` pattern) that refreshes the week window every 15 min. Telegram `/matches` then reads pre-warmed cache. +4. **CF Worker egress diversity** — Worker outbound IPs are many; per-IP buckets rarely hit 429 in practice. +5. **Honor `Retry-After`** on 429 and surface "data momentarily unavailable" to the user instead of stalling. +6. **Proper UA** — `miti99bot/0.1 (https://t.me/miti99bot; minhtienit99@gmail.com)` (already planned). Missing UA is itself a throttle signal on Fandom. + +## Unresolved questions + +1. Do CF Worker-origin fetches hit the same 429 as this shared egress does? (low risk — worth one real test before shipping) +2. Is the module's read pattern bursty or steady? If steady, cron pre-warm + long TTL removes all pressure. If bursty (many users hit `/matches` at game time), KV cache is still the lever. diff --git a/plans/archive/260422-2128-semantle-module/reports/researcher-260422-2329-semantle-api-alternatives.md b/plans/reports/researcher-260422-2329-semantle-api-alternatives.md similarity index 100% rename from plans/archive/260422-2128-semantle-module/reports/researcher-260422-2329-semantle-api-alternatives.md rename to plans/reports/researcher-260422-2329-semantle-api-alternatives.md diff --git a/plans/archive/260422-2128-semantle-module/reports/researcher-260423-0025-bge-m3-cosine-calibration.md b/plans/reports/researcher-260423-0025-bge-m3-cosine-calibration.md similarity index 100% rename from plans/archive/260422-2128-semantle-module/reports/researcher-260423-0025-bge-m3-cosine-calibration.md rename to plans/reports/researcher-260423-0025-bge-m3-cosine-calibration.md diff --git a/plans/archive/260424-2215-loldle-new-modes/reports/researcher-260424-2215-loldle-ability-splash-modes.md b/plans/reports/researcher-260424-2215-loldle-ability-splash-modes.md similarity index 100% rename from plans/archive/260424-2215-loldle-new-modes/reports/researcher-260424-2215-loldle-ability-splash-modes.md rename to plans/reports/researcher-260424-2215-loldle-ability-splash-modes.md diff --git a/plans/archive/260424-2215-loldle-new-modes/reports/researcher-260424-2215-loldle-emoji-and-modes-overview.md b/plans/reports/researcher-260424-2215-loldle-emoji-and-modes-overview.md similarity index 100% rename from plans/archive/260424-2215-loldle-new-modes/reports/researcher-260424-2215-loldle-emoji-and-modes-overview.md rename to plans/reports/researcher-260424-2215-loldle-emoji-and-modes-overview.md diff --git a/plans/archive/260424-2215-loldle-new-modes/reports/researcher-260424-2215-loldle-quote-mode.md b/plans/reports/researcher-260424-2215-loldle-quote-mode.md similarity index 100% rename from plans/archive/260424-2215-loldle-new-modes/reports/researcher-260424-2215-loldle-quote-mode.md rename to plans/reports/researcher-260424-2215-loldle-quote-mode.md diff --git a/scripts/analyze-soak.js b/scripts/analyze-soak.js deleted file mode 100644 index e20edbb..0000000 --- a/scripts/analyze-soak.js +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env node -/** - * @file analyze-soak — parse a CF Logs export and compute per-(cmd × cold/warm) - * latency percentiles plus error counts for the 24h soak analysis. - * - * Usage: - * node scripts/analyze-soak.js --input soak-export.json [--commands /wordle,/loldle] [--output report.md] - * - * Input: CF Logs JSON export — one JSON object per line (NDJSON) OR a CSV export - * where each row has at least a "message" column containing the raw JSON log string. - * - * Output (stdout + optional --output ): - * Markdown table: cmd | cold/warm | n | p50 | p95 | p99 - * Error summary: dual-write secondary failures, mongo errors, CPU-time exceeded - */ - -import { readFileSync, writeFileSync } from "node:fs"; - -// ── CLI arg parsing ────────────────────────────────────────────────────────── - -function parseArgs(argv) { - const args = {}; - for (let i = 2; i < argv.length; i++) { - if (argv[i] === "--input") args.input = argv[++i]; - else if (argv[i] === "--commands") args.commands = argv[++i]; - else if (argv[i] === "--output") args.output = argv[++i]; - } - return args; -} - -// ── Percentile math ────────────────────────────────────────────────────────── - -/** - * Compute a percentile from a sorted numeric array (ascending). - * Uses nearest-rank method. - * - * @param {number[]} sorted - must be sorted ascending - * @param {number} p - percentile 0–100 - * @returns {number} - */ -export function percentile(sorted, p) { - if (sorted.length === 0) return 0; - if (sorted.length === 1) return sorted[0]; - const idx = Math.ceil((p / 100) * sorted.length) - 1; - return sorted[Math.max(0, Math.min(idx, sorted.length - 1))]; -} - -// ── Log line parsing ───────────────────────────────────────────────────────── - -/** - * Try to extract a JSON object from a raw log line. - * Handles NDJSON (line is the JSON) and CSV rows where the JSON lives inside - * a quoted "message" column value. - * - * @param {string} line - * @returns {object|null} - */ -export function parseLogLine(line) { - const trimmed = line.trim(); - if (!trimmed) return null; - - // Try direct JSON parse (NDJSON format). - if (trimmed.startsWith("{")) { - try { - return JSON.parse(trimmed); - } catch { - return null; - } - } - - // CSV: find the first {...} substring inside the line and parse it. - const braceStart = trimmed.indexOf("{"); - const braceEnd = trimmed.lastIndexOf("}"); - if (braceStart !== -1 && braceEnd > braceStart) { - try { - return JSON.parse(trimmed.slice(braceStart, braceEnd + 1)); - } catch { - return null; - } - } - - return null; -} - -// ── Aggregation ────────────────────────────────────────────────────────────── - -/** - * @typedef {{ n: number, samples: number[] }} Bucket - * @typedef {Map} BucketMap key = "cmd|cold" | "cmd|warm" - */ - -/** - * Read all lines from a file path synchronously and return as array. - * - * @param {string} filePath - * @returns {string[]} - */ -function readLines(filePath) { - return readFileSync(filePath, "utf8").split("\n"); -} - -/** - * Aggregate cmd_timing events from log lines into per-(cmd×cold/warm) buckets. - * - * @param {string[]} lines - raw log lines - * @param {string[]|null} filterCmds - if set, only include these cmd names - * @returns {{ buckets: BucketMap, errors: { dualWriteFail: number, mongoError: number, cpuExceeded: number } }} - */ -export function aggregateLines(lines, filterCmds) { - /** @type {BucketMap} */ - const buckets = new Map(); - const errors = { dualWriteFail: 0, mongoError: 0, cpuExceeded: 0 }; - - for (const line of lines) { - // Count error patterns regardless of JSON structure. - if ( - line.includes("dual-write:secondary:failed") || - line.includes("[dual-kv] secondary write failed") - ) { - errors.dualWriteFail++; - } - if ( - line.includes("MongoError") || - line.includes("mongo connection") || - line.includes("MongoNetworkError") - ) { - errors.mongoError++; - } - if (line.includes("Worker exceeded CPU time") || line.includes("cpu time exceeded")) { - errors.cpuExceeded++; - } - - const obj = parseLogLine(line); - if (!obj || obj.event !== "cmd_timing") continue; - - const { cmd, total, cold } = obj; - if (typeof cmd !== "string" || typeof total !== "number") continue; - - // Apply command filter. - if (filterCmds && !filterCmds.includes(cmd)) continue; - - const bucket = cold ? "cold" : "warm"; - const key = `${cmd}|${bucket}`; - if (!buckets.has(key)) buckets.set(key, { n: 0, samples: [] }); - const b = buckets.get(key); - b.n++; - b.samples.push(total); - } - - return { buckets, errors }; -} - -// ── Report formatting ──────────────────────────────────────────────────────── - -/** - * Build a markdown report string from aggregated data. - * - * @param {BucketMap} buckets - * @param {{ dualWriteFail: number, mongoError: number, cpuExceeded: number }} errors - * @returns {string} - */ -export function formatReport(buckets, errors) { - const rows = []; - - for (const [key, bucket] of [...buckets.entries()].sort()) { - const [cmd, coldWarm] = key.split("|"); - const sorted = [...bucket.samples].sort((a, b) => a - b); - rows.push({ - cmd, - coldWarm, - n: bucket.n, - p50: percentile(sorted, 50), - p95: percentile(sorted, 95), - p99: percentile(sorted, 99), - }); - } - - const header = "| cmd | cold/warm | n | p50 | p95 | p99 |"; - const sep = "|-----|-----------|---|-----|-----|-----|"; - const dataRows = rows.map( - (r) => `| ${r.cmd} | ${r.coldWarm} | ${r.n} | ${r.p50} | ${r.p95} | ${r.p99} |`, - ); - - const table = [header, sep, ...dataRows].join("\n"); - - const errorSection = [ - "", - "## Error Summary", - `- Dual-write secondary failures: ${errors.dualWriteFail}`, - `- Mongo connection errors: ${errors.mongoError}`, - `- CPU time exceeded: ${errors.cpuExceeded}`, - ].join("\n"); - - return `## Soak Latency Report\n\n${table}\n${errorSection}\n`; -} - -// ── Main ───────────────────────────────────────────────────────────────────── - -async function main() { - const args = parseArgs(process.argv); - - if (!args.input) { - console.error( - "Usage: node scripts/analyze-soak.js --input [--commands /wordle,/loldle] [--output ]", - ); - process.exit(1); - } - - const filterCmds = args.commands ? args.commands.split(",").map((c) => c.trim()) : null; - const lines = readLines(args.input); - const { buckets, errors } = aggregateLines(lines, filterCmds); - const report = formatReport(buckets, errors); - - process.stdout.write(report); - - if (args.output) { - writeFileSync(args.output, report, "utf8"); - console.error(`\nReport written to ${args.output}`); - } -} - -// Run only when executed directly (not imported in tests). -const isMain = process.argv[1]?.endsWith("analyze-soak.js"); -if (isMain) { - main().catch((err) => { - console.error(err); - process.exit(1); - }); -} diff --git a/scripts/backfill-d1-to-mongo.js b/scripts/backfill-d1-to-mongo.js deleted file mode 100644 index 45ffdc5..0000000 --- a/scripts/backfill-d1-to-mongo.js +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env node -/** - * @file backfill-d1-to-mongo — copy trading_trades from D1 into MongoDB Atlas. - * - * Uses `npx wrangler d1 execute --remote --json` (mirrors scripts/migrate.js pattern) - * to read all rows, then insertMany in batches of 100 into the `trading_trades` - * Mongo collection. - * - * Flags: - * --dry-run Count rows + log first 5 without writing. - * --force Bypass pre-flight abort when trading_trades collection is non-empty. - * - * Required env (loaded via --env-file-if-exists=.env.deploy): - * MONGODB_URI - * - * Usage: - * node --env-file-if-exists=.env.deploy scripts/backfill-d1-to-mongo.js - * node --env-file-if-exists=.env.deploy scripts/backfill-d1-to-mongo.js --dry-run - * node --env-file-if-exists=.env.deploy scripts/backfill-d1-to-mongo.js --force - */ - -import { execSync } from "node:child_process"; -import { ObjectId } from "mongodb"; -import { closeMongoClient, getMongoClient, sleep } from "./lib/migration-helpers.js"; - -const DB_NAME = "miti99bot-db"; -const COLLECTION = "trading_trades"; -const BATCH_SIZE = 100; -const BATCH_SLEEP_MS = 100; // brief pause between batches - -const dryRun = process.argv.includes("--dry-run"); -const force = process.argv.includes("--force"); - -// ─── Preflight ──────────────────────────────────────────────────────────────── - -function validateEnv() { - const missingVars = [["MONGODB_URI", "Atlas connection string"]].filter(([k]) => !process.env[k]); - if (missingVars.length) { - for (const [k, desc] of missingVars) console.error(`[backfill-d1] Missing: ${k} (${desc})`); - console.error(" Copy .env.deploy.example to .env.deploy and fill in values."); - process.exit(1); - } -} - -// ─── D1 query ───────────────────────────────────────────────────────────────── - -/** - * Execute a SQL query against the remote D1 database via wrangler. - * Mirrors the wranglerExecute pattern in scripts/migrate.js. - * - * @param {string} sql - * @returns {any[]} rows from D1 - */ -function queryD1(sql) { - const cmd = `npx wrangler d1 execute ${DB_NAME} --remote --command "${sql.replace(/"/g, '\\"')}" --json`; - let stdout; - try { - stdout = execSync(cmd, { stdio: ["ignore", "pipe", "pipe"] }).toString(); - } catch (err) { - const stderr = err.stderr?.toString() ?? ""; - throw new Error(`wrangler d1 execute failed:\n${stderr || err.stdout?.toString()}`); - } - // wrangler --json wraps results: [{results: [...], success: bool}] - const parsed = JSON.parse(stdout); - const results = Array.isArray(parsed) ? (parsed[0]?.results ?? []) : []; - return results; -} - -// ─── Row mapping ────────────────────────────────────────────────────────────── - -/** - * Map a D1 row to a Mongo document. - * Preserves legacy_id so round-trip verification can match records. - * - * @param {{ id: number, user_id: string, symbol: string, side: string, - * qty: number, price_vnd: number, ts: number }} row - * @returns {object} - */ -function rowToDoc(row) { - return { - _id: new ObjectId(), - legacy_id: row.id, - user_id: row.user_id, - symbol: row.symbol, - side: row.side, - qty: row.qty, - price_vnd: row.price_vnd, - ts: row.ts, - }; -} - -// ─── Main ───────────────────────────────────────────────────────────────────── - -async function main() { - validateEnv(); - - if (dryRun) console.log("[backfill-d1] DRY RUN — no writes will be made"); - - // Fetch all rows from D1. - console.log("[backfill-d1] Querying D1 trading_trades..."); - const rows = queryD1( - "SELECT id, user_id, symbol, side, qty, price_vnd, ts FROM trading_trades ORDER BY id", - ); - console.log(`[backfill-d1] Found ${rows.length} rows in D1`); - - if (dryRun) { - console.log("[backfill-d1] Sample (first 5 rows):"); - for (const row of rows.slice(0, 5)) { - // Log structural info only — no PII values printed. - console.log(` id=${row.id} symbol=${row.symbol} side=${row.side} ts=${row.ts}`); - } - console.log("[backfill-d1] DRY RUN complete. No writes made."); - return; - } - - const client = await getMongoClient(process.env.MONGODB_URI); - const db = client.db(); - const coll = db.collection(COLLECTION); - - // Pre-flight: abort if collection already has data (prevents double-insert). - const existingCount = await coll.countDocuments(); - if (existingCount > 0 && !force) { - console.error(`[backfill-d1] ABORT: ${COLLECTION} already has ${existingCount} document(s).`); - console.error(" Run with --force to bypass this check (e.g. after wipe-mongo)."); - await closeMongoClient(); - process.exit(1); - } - if (existingCount > 0 && force) { - console.log(`[backfill-d1] --force: proceeding despite ${existingCount} existing doc(s).`); - } - - // Insert in batches of BATCH_SIZE. - let inserted = 0; - for (let i = 0; i < rows.length; i += BATCH_SIZE) { - const batch = rows.slice(i, i + BATCH_SIZE).map(rowToDoc); - await coll.insertMany(batch); - inserted += batch.length; - console.log(`[backfill-d1] Inserted ${inserted}/${rows.length}`); - if (i + BATCH_SIZE < rows.length) await sleep(BATCH_SLEEP_MS); - } - - await closeMongoClient(); - console.log(`[backfill-d1] Done — ${inserted} documents inserted into ${COLLECTION}.`); -} - -main().catch((err) => { - console.error("[backfill-d1] Fatal:", err.message ?? err); - process.exit(1); -}); diff --git a/scripts/backfill-kv-to-mongo.js b/scripts/backfill-kv-to-mongo.js deleted file mode 100644 index 2b3bea0..0000000 --- a/scripts/backfill-kv-to-mongo.js +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env node -/** - * @file backfill-kv-to-mongo — copy historical KV data into MongoDB Atlas. - * - * Uses the CF KV REST API to enumerate keys per module, then upserts each - * key into the corresponding Mongo collection using $setOnInsert (skip-if-exists) - * so live dual-write data written after Phase 04 is never overwritten. - * - * Flags: - * --dry-run List + count without writing to Mongo. - * --module Backfill only a single module (default: all). - * - * Required env (loaded via --env-file-if-exists=.env.deploy): - * MONGODB_URI, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, - * KV_NAMESPACE_ID, MODULES (comma-separated) - * - * Usage: - * node --env-file-if-exists=.env.deploy scripts/backfill-kv-to-mongo.js - * node --env-file-if-exists=.env.deploy scripts/backfill-kv-to-mongo.js --dry-run - * node --env-file-if-exists=.env.deploy scripts/backfill-kv-to-mongo.js --module wordle - */ - -import { - cfKvGet, - cfKvList, - clearCheckpoint, - closeMongoClient, - getMongoClient, - loadCheckpoint, - saveCheckpoint, - sleep, -} from "./lib/migration-helpers.js"; - -// ─── Config ─────────────────────────────────────────────────────────────────── - -const { - MONGODB_URI, - CLOUDFLARE_ACCOUNT_ID, - CLOUDFLARE_API_TOKEN, - KV_NAMESPACE_ID, - MODULES: MODULES_ENV, -} = process.env; - -const dryRun = process.argv.includes("--dry-run"); -const moduleFlag = (() => { - const idx = process.argv.indexOf("--module"); - return idx !== -1 ? process.argv[idx + 1] : null; -})(); - -/** Normalize module name to MongoDB collection name (mirrors mongo-kv-store.js). */ -const toCollName = (mod) => mod.replace(/-/g, "_"); - -// ─── Preflight ──────────────────────────────────────────────────────────────── - -function validateEnv() { - const missing = [ - "MONGODB_URI", - "CLOUDFLARE_ACCOUNT_ID", - "CLOUDFLARE_API_TOKEN", - "KV_NAMESPACE_ID", - "MODULES", - ].filter((k) => !process.env[k]); - if (missing.length) { - console.error(`[backfill-kv] Missing required env vars: ${missing.join(", ")}`); - console.error(" Copy .env.deploy.example → .env.deploy and fill in values."); - process.exit(1); - } -} - -// ─── Per-module backfill ────────────────────────────────────────────────────── - -/** - * Backfill one module from KV into Mongo. - * - * @param {import("mongodb").Db} db - * @param {string} mod — module name (e.g. "wordle", "loldle-emoji") - * @returns {Promise<{copied: number, skipped: number, failed: number}>} - */ -async function backfillModule(db, mod) { - const coll = db.collection(toCollName(mod)); - const prefix = `${mod}:`; - const cp = loadCheckpoint(mod); - let cursor = cp?.cursor ?? null; - let page = 0; - let copied = 0; - let skipped = 0; - let failed = 0; - - if (cp) - console.log( - `[${mod}] resuming from checkpoint (cursor=${cursor ? cursor.slice(0, 12) : "start"})`, - ); - - let hasMore = true; - while (hasMore) { - page++; - const { - keys, - cursor: nextCursor, - list_complete, - } = await cfKvList( - CLOUDFLARE_ACCOUNT_ID, - KV_NAMESPACE_ID, - CLOUDFLARE_API_TOKEN, - prefix, - cursor, - ); - - for (const keyObj of keys) { - const key = keyObj.name; - // expiresAt: KV metadata.expiration is unix-seconds → convert to JS Date. - const expSecs = keyObj.metadata?.expiration; - const expiresAt = expSecs ? new Date(expSecs * 1000) : undefined; - - if (dryRun) { - copied++; // count as "would copy" in dry-run - continue; - } - - try { - const value = await cfKvGet( - CLOUDFLARE_ACCOUNT_ID, - KV_NAMESPACE_ID, - CLOUDFLARE_API_TOKEN, - key, - ); - - const doc = { value }; - if (expiresAt) doc.expiresAt = expiresAt; - - // $setOnInsert: skip-if-exists — preserves any newer Mongo state from dual-write. - const result = await coll.updateOne({ _id: key }, { $setOnInsert: doc }, { upsert: true }); - if (result.upsertedCount > 0) { - copied++; - } else { - skipped++; - } - await sleep(20); // throttle: ~50 ops/sec to stay within Atlas M0 headroom - } catch (err) { - failed++; - console.error(`[${mod}] ERROR key="${key.slice(0, 40)}…": ${err.message}`); - } - } - - // Checkpoint after each page so a crash doesn't lose progress. - saveCheckpoint(mod, { cursor: nextCursor, lastKey: keys.at(-1)?.name ?? null }); - - const verb = dryRun ? "(dry-run)" : `${copied} copied, ${skipped} skipped, ${failed} failed`; - console.log(`[${mod}] page ${page}: ${keys.length} keys ${verb}`); - - cursor = nextCursor; - hasMore = !list_complete && !!cursor; - - // Brief pause between pages to respect CF REST rate limits (1200 req/5 min). - if (hasMore) await sleep(250); - } - - if (!dryRun) clearCheckpoint(mod); - return { copied, skipped, failed }; -} - -// ─── Main ───────────────────────────────────────────────────────────────────── - -async function main() { - validateEnv(); - - const allModules = MODULES_ENV.split(",") - .map((m) => m.trim()) - .filter(Boolean); - const modules = moduleFlag ? [moduleFlag] : allModules; - - if (moduleFlag && !allModules.includes(moduleFlag)) { - console.error( - `[backfill-kv] Unknown module "${moduleFlag}". Available: ${allModules.join(", ")}`, - ); - process.exit(1); - } - - if (dryRun) console.log("[backfill-kv] DRY RUN — no writes will be made"); - console.log(`[backfill-kv] Modules: ${modules.join(", ")}`); - - const client = await getMongoClient(MONGODB_URI); - const db = client.db(); - - let totalFailed = 0; - for (const mod of modules) { - const { copied, skipped, failed } = await backfillModule(db, mod); - console.log(`[${mod}] DONE — ${copied} copied, ${skipped} skipped, ${failed} failed`); - totalFailed += failed; - } - - await closeMongoClient(); - if (totalFailed > 0) { - console.error(`[backfill-kv] Completed with ${totalFailed} failed key(s). Check logs above.`); - process.exit(1); - } - console.log("[backfill-kv] All modules complete."); -} - -main().catch((err) => { - console.error("[backfill-kv] Fatal:", err.message ?? err); - process.exit(1); -}); diff --git a/scripts/backfill-mongo-to-d1.js b/scripts/backfill-mongo-to-d1.js deleted file mode 100644 index 329ec6e..0000000 --- a/scripts/backfill-mongo-to-d1.js +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env node -/** - * @file backfill-mongo-to-d1 — emergency reverse-backfill: MongoDB → Cloudflare D1. - * - * Reads trading_trades from Mongo (sorted by legacy_id) and writes them back - * into D1 via `wrangler d1 execute --remote --file=`. - * - * Preserves legacy_id when present (written by phase-05 forward backfill). - * When legacy_id is absent, generates sequential IDs from max(existing_d1_id)+1. - * - * Use this ONLY during a Stage-2 rollback (phase-07 debugger #14). - * Operator MUST inform users that N days of Mongo-only writes will revert. - * - * Flags: - * --dry-run Print SQL to stdout; do not execute wrangler. - * --force Proceed even if D1 trading_trades already has rows. - * - * Required env (loaded via --env-file-if-exists=.env.deploy): - * MONGODB_URI - * - * Usage: - * node --env-file-if-exists=.env.deploy scripts/backfill-mongo-to-d1.js - * node --env-file-if-exists=.env.deploy scripts/backfill-mongo-to-d1.js --dry-run - * node --env-file-if-exists=.env.deploy scripts/backfill-mongo-to-d1.js --force - */ - -import { execSync } from "node:child_process"; -import { rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { closeMongoClient, getMongoClient } from "./lib/migration-helpers.js"; - -const DB_NAME = "miti99bot-db"; -const COLLECTION = "trading_trades"; - -const dryRun = process.argv.includes("--dry-run"); -const force = process.argv.includes("--force"); - -// ─── Preflight ──────────────────────────────────────────────────────────────── - -function validateEnv() { - const missingVars = [["MONGODB_URI", "Atlas connection string"]].filter(([k]) => !process.env[k]); - if (missingVars.length) { - for (const [k, desc] of missingVars) - console.error(`[backfill-mongo-d1] Missing: ${k} (${desc})`); - console.error(" Copy .env.deploy.example → .env.deploy and fill in values."); - process.exit(1); - } -} - -// ─── D1 helpers ─────────────────────────────────────────────────────────────── - -/** - * Execute a single SQL command against remote D1 and return parsed rows. - * - * @param {string} sql - * @returns {any[]} - */ -function queryD1(sql) { - const cmd = `npx wrangler d1 execute ${DB_NAME} --remote --command "${sql.replace(/"/g, '\\"')}" --json`; - let stdout; - try { - stdout = execSync(cmd, { stdio: ["ignore", "pipe", "pipe"] }).toString(); - } catch (err) { - const stderr = /** @type {any} */ (err).stderr?.toString() ?? ""; - throw new Error( - `wrangler d1 execute failed:\n${stderr || /** @type {any} */ (err).stdout?.toString()}`, - ); - } - const parsed = JSON.parse(stdout); - return Array.isArray(parsed) ? (parsed[0]?.results ?? []) : []; -} - -/** - * Execute a SQL file against remote D1 via wrangler. - * - * @param {string} filePath — absolute path to .sql file - */ -function executeD1File(filePath) { - const cmd = `npx wrangler d1 execute ${DB_NAME} --remote --file="${filePath}" --json`; - try { - execSync(cmd, { stdio: ["ignore", "pipe", "pipe"] }); - } catch (err) { - const stderr = /** @type {any} */ (err).stderr?.toString() ?? ""; - throw new Error( - `wrangler d1 execute failed:\n${stderr || /** @type {any} */ (err).stdout?.toString()}`, - ); - } -} - -// ─── SQL generation ────────────────────────────────────────────────────────── - -/** - * Escape a SQL string value (single-quote doubling). - * - * @param {string|null|undefined} v - * @returns {string} SQL literal (including surrounding quotes) - */ -export function sqlStr(v) { - if (v == null) return "NULL"; - return `'${String(v).replace(/'/g, "''")}'`; -} - -/** - * Build an INSERT statement for one trading trade row. - * - * @param {{ - * id: number, - * user_id: string, - * symbol: string, - * side: string, - * qty: number, - * price_vnd: number, - * ts: number - * }} row - * @returns {string} - */ -export function buildInsertSql(row) { - return ( - `INSERT INTO ${COLLECTION} (id, user_id, symbol, side, qty, price_vnd, ts) VALUES (` + - [ - row.id, - sqlStr(row.user_id), - sqlStr(row.symbol), - sqlStr(row.side), - row.qty, - row.price_vnd, - row.ts, - ].join(", ") + - ");" - ); -} - -// ─── Main ───────────────────────────────────────────────────────────────────── - -async function main() { - validateEnv(); - - if (dryRun) console.log("[backfill-mongo-d1] DRY RUN — SQL will be printed to stdout"); - - const client = await getMongoClient(/** @type {string} */ (process.env.MONGODB_URI)); - const db = client.db(); - const coll = db.collection(COLLECTION); - - // Sort by legacy_id ascending so we maintain original D1 row order. - const docs = await coll.find({}).sort({ legacy_id: 1 }).toArray(); - console.log(`[backfill-mongo-d1] Found ${docs.length} document(s) in Mongo ${COLLECTION}`); - - if (docs.length === 0) { - console.log("[backfill-mongo-d1] Nothing to restore."); - await closeMongoClient(); - return; - } - - if (!dryRun) { - // Pre-flight: abort if D1 already has rows unless --force. - const existingRows = queryD1(`SELECT COUNT(*) AS cnt FROM ${COLLECTION}`); - const existingCount = existingRows[0]?.cnt ?? 0; - if (existingCount > 0 && !force) { - console.error( - `[backfill-mongo-d1] ABORT: D1 ${COLLECTION} already has ${existingCount} row(s).`, - ); - console.error(" Run with --force to bypass (e.g. after wiping D1 manually)."); - await closeMongoClient(); - process.exit(1); - } - if (existingCount > 0 && force) { - console.log( - `[backfill-mongo-d1] --force: proceeding despite ${existingCount} existing D1 row(s).`, - ); - } - } - - // Determine starting ID for docs without a legacy_id. - let nextId = 1; - const hasLegacyIds = docs.some((d) => d.legacy_id != null); - if (!dryRun && !hasLegacyIds) { - // Fetch max id from D1 to avoid collisions. - const maxRows = queryD1(`SELECT MAX(id) AS m FROM ${COLLECTION}`); - const maxId = maxRows[0]?.m ?? 0; - nextId = maxId + 1; - } - - // Build SQL statements. - /** @type {string[]} */ - const statements = []; - for (const doc of docs) { - const id = doc.legacy_id != null ? Number(doc.legacy_id) : nextId++; - statements.push( - buildInsertSql({ - id, - user_id: String(doc.user_id ?? ""), - symbol: String(doc.symbol ?? ""), - side: String(doc.side ?? ""), - qty: Number(doc.qty ?? 0), - price_vnd: Number(doc.price_vnd ?? 0), - ts: Number(doc.ts ?? 0), - }), - ); - } - - if (dryRun) { - console.log("[backfill-mongo-d1] Generated SQL:"); - for (const stmt of statements) console.log(stmt); - console.log(`[backfill-mongo-d1] DRY RUN complete — ${statements.length} statement(s).`); - await closeMongoClient(); - return; - } - - // Write SQL to a temp file and pipe through wrangler. - const tmpFile = join(tmpdir(), `backfill-mongo-d1-${Date.now()}.sql`); - try { - writeFileSync(tmpFile, statements.join("\n"), "utf8"); - console.log(`[backfill-mongo-d1] Executing ${statements.length} INSERT(s) via wrangler...`); - executeD1File(tmpFile); - console.log(`[backfill-mongo-d1] Done — ${statements.length} row(s) restored to D1.`); - } finally { - try { - rmSync(tmpFile); - } catch { - // Non-fatal cleanup. - } - } - - await closeMongoClient(); -} - -// Run only when invoked directly (not when imported by tests). -const isMain = process.argv[1]?.endsWith("backfill-mongo-to-d1.js"); -if (isMain) { - main().catch((err) => { - console.error("[backfill-mongo-d1] Fatal:", /** @type {Error} */ (err).message ?? err); - process.exit(1); - }); -} diff --git a/scripts/backfill-mongo-to-kv.js b/scripts/backfill-mongo-to-kv.js deleted file mode 100644 index 306f760..0000000 --- a/scripts/backfill-mongo-to-kv.js +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env node -/** - * @file backfill-mongo-to-kv — emergency reverse-backfill: MongoDB → Cloudflare KV. - * - * Reads each per-module Mongo collection and writes every non-expired doc back - * into CF KV via the REST API with correct TTL derived from expiresAt. - * - * Use this ONLY during a Stage-2 rollback. Operator must inform users that - * N days of Mongo-only writes will revert (phase-07 debugger #14). - * - * Flags: - * --dry-run Log summary without writing to KV. - * --module Restore only a single module (default: all). - * - * Required env (loaded via --env-file-if-exists=.env.deploy): - * MONGODB_URI, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, - * KV_NAMESPACE_ID, MODULES (comma-separated) - * - * Usage: - * node --env-file-if-exists=.env.deploy scripts/backfill-mongo-to-kv.js - * node --env-file-if-exists=.env.deploy scripts/backfill-mongo-to-kv.js --dry-run - * node --env-file-if-exists=.env.deploy scripts/backfill-mongo-to-kv.js --module wordle - */ - -import { cfKvPut, closeMongoClient, getMongoClient, sleep } from "./lib/migration-helpers.js"; - -// ─── Config ─────────────────────────────────────────────────────────────────── - -const { - MONGODB_URI, - CLOUDFLARE_ACCOUNT_ID, - CLOUDFLARE_API_TOKEN, - KV_NAMESPACE_ID, - MODULES: MODULES_ENV, -} = process.env; - -const dryRun = process.argv.includes("--dry-run"); -const moduleFlag = (() => { - const idx = process.argv.indexOf("--module"); - return idx !== -1 ? process.argv[idx + 1] : null; -})(); - -/** Throttle: 50 ops/sec → 20 ms between writes. */ -const THROTTLE_MS = 20; - -/** Minimum TTL accepted by CF KV REST API. */ -const MIN_TTL_SECS = 60; - -/** Normalize module name to Mongo collection name (mirrors mongo-kv-store.js). */ -const toCollName = (mod) => mod.replace(/-/g, "_"); - -// ─── Preflight ──────────────────────────────────────────────────────────────── - -function validateEnv() { - const missing = [ - "MONGODB_URI", - "CLOUDFLARE_ACCOUNT_ID", - "CLOUDFLARE_API_TOKEN", - "KV_NAMESPACE_ID", - "MODULES", - ].filter((k) => !process.env[k]); - if (missing.length) { - console.error(`[backfill-mongo-kv] Missing required env vars: ${missing.join(", ")}`); - console.error(" Copy .env.deploy.example → .env.deploy and fill in values."); - process.exit(1); - } -} - -// ─── TTL computation ───────────────────────────────────────────────────────── - -/** - * Compute CF KV expirationTtl (seconds from now) from an absolute expiresAt Date. - * Returns null if the doc has no TTL (key should be persistent). - * Returns undefined (sentinel) if the doc is already expired (key must be SKIPPED). - * - * @param {Date|null|undefined} expiresAt - * @param {number} nowMs — Date.now() at call time (injectable for tests) - * @returns {{ ttl: number }|null|"expired"} - */ -export function computeTtl(expiresAt, nowMs = Date.now()) { - if (!expiresAt) return null; // no TTL — write as persistent - const remainingMs = expiresAt.getTime() - nowMs; - if (remainingMs <= 0) return "expired"; // already past expiry → skip - const secs = Math.floor(remainingMs / 1000); - return { ttl: Math.max(MIN_TTL_SECS, secs) }; -} - -// ─── Per-module restore ─────────────────────────────────────────────────────── - -/** - * Restore one module's Mongo collection back into CF KV. - * - * @param {import("mongodb").Db} db - * @param {string} mod - * @returns {Promise<{restored: number, skipped: number, failed: number}>} - */ -async function restoreModule(db, mod) { - const coll = db.collection(toCollName(mod)); - const docs = await coll.find({}).toArray(); - - let restored = 0; - let skipped = 0; - let failed = 0; - - for (const doc of docs) { - const key = /** @type {string} */ (doc._id); - const value = /** @type {string} */ (doc.value ?? ""); - const ttlResult = computeTtl(doc.expiresAt ?? null); - - if (ttlResult === "expired") { - skipped++; - continue; // expired in Mongo → do not surface a stale key in KV - } - - if (dryRun) { - restored++; - continue; - } - - try { - /** @type {{ expirationTtl?: number }} */ - const opts = ttlResult ? { expirationTtl: ttlResult.ttl } : {}; - await cfKvPut( - /** @type {string} */ (CLOUDFLARE_ACCOUNT_ID), - /** @type {string} */ (KV_NAMESPACE_ID), - /** @type {string} */ (CLOUDFLARE_API_TOKEN), - key, - value, - opts, - ); - restored++; - await sleep(THROTTLE_MS); - } catch (err) { - failed++; - // Log key hash only — never log plaintext keys (may encode user IDs). - const { sha256 } = await import("./lib/migration-helpers.js"); - console.error( - `[${mod}] ERROR key_sha256=${sha256(String(key)).slice(0, 16)}: ${/** @type {Error} */ (err).message}`, - ); - } - } - - return { restored, skipped, failed }; -} - -// ─── Main ───────────────────────────────────────────────────────────────────── - -async function main() { - validateEnv(); - - const allModules = /** @type {string} */ (MODULES_ENV) - .split(",") - .map((m) => m.trim()) - .filter(Boolean); - const modules = moduleFlag ? [moduleFlag] : allModules; - - if (moduleFlag && !allModules.includes(moduleFlag)) { - console.error( - `[backfill-mongo-kv] Unknown module "${moduleFlag}". Available: ${allModules.join(", ")}`, - ); - process.exit(1); - } - - if (dryRun) console.log("[backfill-mongo-kv] DRY RUN — no writes to KV"); - console.log(`[backfill-mongo-kv] Modules: ${modules.join(", ")}`); - - const client = await getMongoClient(/** @type {string} */ (MONGODB_URI)); - const db = client.db(); - - let totalFailed = 0; - for (const mod of modules) { - const { restored, skipped, failed } = await restoreModule(db, mod); - const verb = dryRun ? "(dry-run)" : `${restored} restored, ${skipped} skipped (expired)`; - console.log(`[${mod}] ${docs_label(restored + skipped + failed)} docs: ${verb}`); - totalFailed += failed; - } - - await closeMongoClient(); - - if (totalFailed > 0) { - console.error( - `[backfill-mongo-kv] Completed with ${totalFailed} failed write(s). Check logs above.`, - ); - process.exit(1); - } - console.log("[backfill-mongo-kv] All modules restored."); -} - -/** @param {number} n @returns {string} */ -function docs_label(n) { - return `${n} doc${n !== 1 ? "s" : ""}`; -} - -// Run only when invoked directly (not when imported by tests). -const isMain = process.argv[1]?.endsWith("backfill-mongo-to-kv.js"); -if (isMain) { - main().catch((err) => { - console.error("[backfill-mongo-kv] Fatal:", /** @type {Error} */ (err).message ?? err); - process.exit(1); - }); -} diff --git a/scripts/check-secret-leaks.js b/scripts/check-secret-leaks.js deleted file mode 100644 index b1c83ce..0000000 --- a/scripts/check-secret-leaks.js +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env node -/** - * @file check-secret-leaks — fails build if any source file logs a known secret. - * - * Catches the common foot-gun where a developer prints `env.MONGODB_URI` or - * similar during debugging and forgets to remove the line before commit. We - * are NOT trying to be a full SAST — we just block obvious `console.log` / - * `console.error` sites that interpolate a secret env var. - * - * Wired into `npm run lint` so every PR / pre-deploy run catches it. - * - * Patterns checked (add more as new secrets are introduced): - * - MONGODB_URI — Atlas connection string (Phase 01) - * - TELEGRAM_BOT_TOKEN — bot token from BotFather - * - TELEGRAM_WEBHOOK_SECRET — gates incoming webhook traffic - * - ADMIN_TOKEN — kept for defense-in-depth even though Phase 05 - * redesign removed admin routes; cheap to leave in. - * - * Detection scope: any of these tokens appearing on the SAME line as a - * `console.(...)`, `JSON.stringify(env)`, or `throw new Error(...env...)`. - * - * Exit codes: - * 0 — no leaks found - * 1 — at least one leak detected (prints file:line + offending line) - */ - -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { extname, join, resolve } from "node:path"; - -const PROJECT_ROOT = resolve(import.meta.dirname, ".."); -const SCAN_DIRS = ["src", "scripts"]; -const SCAN_EXTS = new Set([".js", ".mjs", ".ts"]); - -const SECRETS = [ - "MONGODB_URI", - "TELEGRAM_BOT_TOKEN", - "TELEGRAM_WEBHOOK_SECRET", - "ADMIN_TOKEN", - // Phase 05: CF API creds used by backfill scripts — defense-in-depth. - "CLOUDFLARE_API_TOKEN", - "CLOUDFLARE_ACCOUNT_ID", -]; - -// A line is suspicious if it both names a secret AND looks like it's emitting -// the value (console.*, JSON.stringify(env...), throw with interpolation). -const EMIT_PATTERNS = [ - /\bconsole\.(log|info|warn|error|debug|trace)\b/, - /\bJSON\.stringify\s*\(\s*env\b/, - /\bthrow\s+new\s+\w*Error\b/, -]; - -/** - * Walk a directory tree and yield absolute file paths matching SCAN_EXTS. - * - * @param {string} dir - * @returns {string[]} - */ -function walk(dir) { - if (!existsSync(dir)) return []; - const out = []; - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - const st = statSync(full); - if (st.isDirectory()) { - if (entry === "node_modules" || entry === ".wrangler") continue; - out.push(...walk(full)); - } else if (SCAN_EXTS.has(extname(entry))) { - out.push(full); - } - } - return out; -} - -/** - * Scan one file. Pushes hits to `findings`. - * - * @param {string} file - * @param {Array<{file: string, line: number, secret: string, snippet: string}>} findings - */ -function scanFile(file, findings) { - // Don't flag this file (it lists the patterns) or .example files. - if (file.endsWith("check-secret-leaks.js")) return; - - const content = readFileSync(file, "utf8"); - const lines = content.split("\n"); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (!EMIT_PATTERNS.some((p) => p.test(line))) continue; - for (const secret of SECRETS) { - if (line.includes(secret)) { - findings.push({ - file, - line: i + 1, - secret, - snippet: line.trim(), - }); - } - } - } -} - -function main() { - /** @type {Array<{file: string, line: number, secret: string, snippet: string}>} */ - const findings = []; - - for (const dir of SCAN_DIRS) { - const abs = join(PROJECT_ROOT, dir); - for (const file of walk(abs)) scanFile(file, findings); - } - - if (findings.length === 0) { - console.log(`secret-leak check: 0 findings across ${SCAN_DIRS.join(", ")}`); - return; - } - - console.error(`secret-leak check: ${findings.length} finding(s)`); - for (const f of findings) { - const rel = f.file.replace(`${PROJECT_ROOT}/`, ""); - console.error(` ${rel}:${f.line} [${f.secret}] ${f.snippet}`); - } - process.exit(1); -} - -main(); diff --git a/scripts/lib/migration-helpers.js b/scripts/lib/migration-helpers.js deleted file mode 100644 index 1c1f5a7..0000000 --- a/scripts/lib/migration-helpers.js +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env node -/** - * @file migration-helpers — shared utilities for Phase 05 backfill scripts. - * - * Exports: - * - sleep, sha256 - * - loadCheckpoint, saveCheckpoint, clearCheckpoint - * - cfKvList, cfKvGet - * - getMongoClient, closeMongoClient - * - countDiffRatio, sampleStrategy - */ - -import { createHash } from "node:crypto"; -import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { MongoClient } from "mongodb"; - -const CF_API_BASE = "https://api.cloudflare.com/client/v4"; - -// ─── Primitives ─────────────────────────────────────────────────────────────── - -/** @param {number} ms @returns {Promise} */ -export const sleep = (ms) => new Promise((res) => setTimeout(res, ms)); - -/** @param {string} text @returns {string} hex SHA-256 */ -export const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex"); - -// ─── Checkpoint ─────────────────────────────────────────────────────────────── - -const cpPath = (mod) => resolve(process.cwd(), `.backfill-cursor-${mod}.json`); - -/** - * Load saved cursor state for a module. Returns null if none. - * - * @param {string} moduleName - * @returns {{ cursor: string|null, lastKey: string|null }|null} - */ -export function loadCheckpoint(moduleName) { - const p = cpPath(moduleName); - if (!existsSync(p)) return null; - try { - return JSON.parse(readFileSync(p, "utf8")); - } catch { - return null; - } -} - -/** - * Persist cursor state so a crash can resume from this page. - * - * @param {string} moduleName - * @param {{ cursor: string|null, lastKey: string|null }} state - */ -export function saveCheckpoint(moduleName, state) { - writeFileSync(cpPath(moduleName), JSON.stringify(state), "utf8"); -} - -/** - * Remove checkpoint file on successful module completion. - * - * @param {string} moduleName - */ -export function clearCheckpoint(moduleName) { - const p = cpPath(moduleName); - if (existsSync(p)) { - try { - unlinkSync(p); - } catch { - // Non-fatal — may already be gone. - } - } -} - -// ─── CF KV REST ─────────────────────────────────────────────────────────────── - -/** - * List one page of KV keys for a given prefix. - * - * @param {string} accountId - * @param {string} nsId - * @param {string} token - * @param {string} prefix - * @param {string|null} cursor - * @returns {Promise<{ - * keys: Array<{name: string, metadata?: {expiration?: number}}>, - * cursor: string|null, - * list_complete: boolean - * }>} - */ -export async function cfKvList(accountId, nsId, token, prefix, cursor) { - const url = new URL(`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${nsId}/keys`); - url.searchParams.set("prefix", prefix); - url.searchParams.set("limit", "1000"); - if (cursor) url.searchParams.set("cursor", cursor); - - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - }); - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error(`CF KV list ${res.status}: ${body}`); - } - const json = await res.json(); - if (!json.success) throw new Error(`CF KV list error: ${JSON.stringify(json.errors ?? json)}`); - return { - keys: json.result ?? [], - cursor: json.result_info?.cursor ?? null, - list_complete: !json.result_info?.cursor, - }; -} - -/** - * Fetch the string value for a single KV key. - * - * @param {string} accountId - * @param {string} nsId - * @param {string} token - * @param {string} key - * @returns {Promise} - */ -export async function cfKvGet(accountId, nsId, token, key) { - const url = `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${nsId}/values/${encodeURIComponent(key)}`; - const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error(`CF KV get "${key}" ${res.status}: ${body}`); - } - return res.text(); -} - -/** - * Write a string value into CF KV via the REST API. - * - * CF KV REST PUT: PUT /accounts/{id}/storage/kv/namespaces/{nsid}/values/{key} - * Optional query param `expiration_ttl` (seconds from now, minimum 60). - * - * @param {string} accountId - * @param {string} nsId - * @param {string} token - * @param {string} key - * @param {string} value - * @param {{ expirationTtl?: number }} [opts] - * @returns {Promise} - */ -export async function cfKvPut(accountId, nsId, token, key, value, opts = {}) { - const url = new URL( - `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${nsId}/values/${encodeURIComponent(key)}`, - ); - if (opts.expirationTtl != null) { - url.searchParams.set("expiration_ttl", String(opts.expirationTtl)); - } - const res = await fetch(url.toString(), { - method: "PUT", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "text/plain", - }, - body: value, - }); - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error(`CF KV put "${key}" ${res.status}: ${body}`); - } -} - -// ─── MongoDB singleton ──────────────────────────────────────────────────────── - -/** @type {MongoClient|null} */ -let _client = null; - -/** - * Return a shared MongoClient, connecting on first call. - * Registers process exit / signal handlers to close cleanly. - * - * @param {string} uri - * @returns {Promise} - */ -export async function getMongoClient(uri) { - if (!uri) throw new Error("MongoDB URI is required — set it in .env.deploy and re-run"); - if (_client) return _client; - const client = new MongoClient(uri); - await client.connect(); - _client = client; - const close = () => { - _client?.close().catch(() => {}); - _client = null; - }; - process.once("exit", close); - process.once("SIGINT", () => { - close(); - process.exit(0); - }); - process.once("SIGTERM", () => { - close(); - process.exit(0); - }); - return _client; -} - -/** Close the shared client (test teardown / forced close). */ -export async function closeMongoClient() { - if (_client) { - await _client.close(); - _client = null; - } -} - -// ─── Parity math ───────────────────────────────────────────────────────────── - -/** - * Relative difference between two counts — 0 means identical. - * - * @param {number} a - * @param {number} b - * @returns {number} value in [0, 1] - */ -export const countDiffRatio = (a, b) => Math.abs(a - b) / Math.max(a, b, 1); - -/** - * Determine verify strategy: full-scan when total < 10 000, - * otherwise random-sample N = min(500, ceil(sqrt(total))). - * - * @param {number} total - * @returns {{ fullScan: boolean, sampleSize: number }} - */ -export function sampleStrategy(total) { - if (total < 10000) return { fullScan: true, sampleSize: total }; - return { fullScan: false, sampleSize: Math.min(500, Math.ceil(Math.sqrt(total))) }; -} diff --git a/scripts/register.js b/scripts/register.js index f94c1af..75f2ac2 100644 --- a/scripts/register.js +++ b/scripts/register.js @@ -19,7 +19,7 @@ */ import { buildRegistry, resetRegistry } from "../src/modules/registry.js"; -import { STUB_SENTINEL, stubAi, stubKv } from "./stub-kv.js"; +import { stubAi, stubKv } from "./stub-kv.js"; const TELEGRAM_API = "https://api.telegram.org"; @@ -71,18 +71,8 @@ async function main() { // Build the registry against the same code the Worker uses. Stub KV // satisfies the binding so createStore() does not throw. - // MONGODB_URI = STUB_SENTINEL ensures create-store / create-sql-store - // factories short-circuit before constructing any MongoClient. - // STORAGE_PRIMARY + DUAL_WRITE lock the factories to the CF-only path. resetRegistry(); - const reg = await buildRegistry({ - MODULES: modules, - KV: stubKv, - AI: stubAi, - MONGODB_URI: STUB_SENTINEL, - STORAGE_PRIMARY: "kv", - DUAL_WRITE: "0", - }); + const reg = await buildRegistry({ MODULES: modules, KV: stubKv, AI: stubAi }); const commands = [...reg.publicCommands.values()].map(({ cmd }) => ({ command: cmd.name, diff --git a/scripts/stub-kv.js b/scripts/stub-kv.js index 5d896bb..4d2822b 100644 --- a/scripts/stub-kv.js +++ b/scripts/stub-kv.js @@ -8,14 +8,6 @@ * are assumed read-only (or tolerant of missing state) at registration time. * If a future module writes inside init(), update the matching stub to * swallow writes safely. - * - * `STUB_SENTINEL` is passed as `env.MONGODB_URI` so the create-store / - * create-sql-store factories short-circuit BEFORE constructing any MongoClient. - * This ensures zero Atlas connections during `npm run register:dry`. - * - * `stubMongo` is a duck-typed no-op that satisfies the MongoClient surface - * used by MongoKVStore. It is NOT a real MongoClient instance. It must never - * be used in production — sentinel check in the factories prevents that. */ /** @type {KVNamespace} */ @@ -52,33 +44,3 @@ export const stubAi = { return { data: [] }; }, }; - -/** - * Sentinel value passed as `env.MONGODB_URI` during deploy-time registry - * builds. Factories check for this value FIRST and return CF-only stores - * immediately — no MongoClient is ever constructed. - * - * @type {string} - */ -export const STUB_SENTINEL = "__stub_mongo__"; - -/** - * Duck-typed no-op MongoClient surface. Satisfies the shape used by - * MongoKVStore / MongoTradesStore without performing any network IO. - * Used only when `env.MONGODB_URI === STUB_SENTINEL`. - */ -export const stubMongo = { - db() { - return { - collection() { - throw new Error("stubMongo: no IO at deploy time"); - }, - }; - }, - async connect() { - return undefined; - }, - async close() { - return undefined; - }, -}; diff --git a/scripts/synthetic-burst.js b/scripts/synthetic-burst.js deleted file mode 100644 index 3068943..0000000 --- a/scripts/synthetic-burst.js +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env node -/** - * @file synthetic-burst — fire N parallel requests at the deployed Worker URL - * to exercise the M0 Atlas connection cap before a live deploy. - * - * Each request is a synthetic Telegram webhook update POST that grammY will - * route to the specified command handler. All requests hit the Worker - * simultaneously (Promise.all) to maximise cold-isolate spawning. - * - * Usage: - * node scripts/synthetic-burst.js \ - * --url https://miti99bot.workers.dev \ - * --secret \ - * [--n 20] \ - * [--cmd /wordle] - * - * No unit tests for this script — it is network-touching by design and only - * runs against a live deployed Worker. Tested manually pre-deploy. - * - * Abort guideline (debugger #2): if Atlas connection peak > 300/500 (60% cap), - * do NOT proceed with live deploy. Check Atlas UI during the 60s after the burst. - */ - -// ── CLI arg parsing ────────────────────────────────────────────────────────── - -function parseArgs(argv) { - const args = { n: 20, cmd: "/wordle" }; - for (let i = 2; i < argv.length; i++) { - if (argv[i] === "--url") args.url = argv[++i]; - else if (argv[i] === "--secret") args.secret = argv[++i]; - else if (argv[i] === "--n") args.n = Number.parseInt(argv[++i], 10); - else if (argv[i] === "--cmd") args.cmd = argv[++i]; - } - return args; -} - -// ── Synthetic Telegram update payload ─────────────────────────────────────── - -/** - * Build a minimal but valid grammY-shaped Telegram Update object for a - * bot_command message. - * - * @param {string} cmd - e.g. "/wordle" - * @param {number} index - used to differentiate update_id + message_id values - * @returns {object} - */ -function buildUpdate(cmd, index) { - return { - update_id: 100000 + index, - message: { - message_id: 200000 + index, - from: { id: 1, is_bot: false, first_name: "Burst" }, - chat: { id: 1, type: "private" }, - date: Math.floor(Date.now() / 1000), - text: cmd, - entities: [{ type: "bot_command", offset: 0, length: cmd.length }], - }, - }; -} - -// ── Single request ─────────────────────────────────────────────────────────── - -/** - * POST one synthetic update to the Worker webhook endpoint. - * - * @param {string} url - Worker base URL - * @param {string} secret - X-Telegram-Bot-Api-Secret-Token value - * @param {object} update - Telegram Update payload - * @param {number} index - request index for logging - * @returns {Promise<{ index: number, status: number, ms: number, error?: string }>} - */ -async function sendUpdate(url, secret, update, index) { - const t0 = Date.now(); - const endpoint = url.replace(/\/$/, "") + "/webhook"; - - try { - const res = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Telegram-Bot-Api-Secret-Token": secret, - }, - body: JSON.stringify(update), - }); - return { index, status: res.status, ms: Date.now() - t0 }; - } catch (err) { - return { index, status: 0, ms: Date.now() - t0, error: err.message }; - } -} - -// ── Main ───────────────────────────────────────────────────────────────────── - -async function main() { - const args = parseArgs(process.argv); - - if (!args.url || !args.secret) { - console.error( - "Usage: node scripts/synthetic-burst.js --url --secret [--n 20] [--cmd /wordle]", - ); - process.exit(1); - } - - const { url, secret, n, cmd } = args; - - console.log(`Burst: ${n} parallel requests → ${url}/webhook cmd=${cmd}`); - console.log("Starting at", new Date().toISOString()); - - const requests = Array.from({ length: n }, (_, i) => - sendUpdate(url, secret, buildUpdate(cmd, i), i), - ); - - const results = await Promise.all(requests); - - // ── Summary ────────────────────────────────────────────────────────────── - let ok = 0; - let fail = 0; - let totalMs = 0; - const statusCounts = {}; - - for (const r of results) { - const statusKey = r.status === 0 ? "network-error" : String(r.status); - statusCounts[statusKey] = (statusCounts[statusKey] ?? 0) + 1; - totalMs += r.ms; - if (r.status >= 200 && r.status < 300) ok++; - else fail++; - - // Log individual result. - const tag = r.error ? `ERROR(${r.error})` : `HTTP ${r.status}`; - console.log(` [${r.index}] ${tag} ${r.ms}ms`); - } - - const avgMs = Math.round(totalMs / n); - const allMs = results.map((r) => r.ms).sort((a, b) => a - b); - const p50 = allMs[Math.floor(allMs.length * 0.5)]; - const p95 = allMs[Math.floor(allMs.length * 0.95)]; - - console.log("\n── Summary ───────────────────────────────────────────"); - console.log(` Requests: ${n} | OK: ${ok} | Failed: ${fail}`); - console.log(` Status counts: ${JSON.stringify(statusCounts)}`); - console.log(` Latency — avg: ${avgMs}ms p50: ${p50}ms p95: ${p95}ms`); - console.log("\nNext: check Atlas UI connection counter within 60s."); - console.log("Abort if peak connections > 300/500 (60% of M0 cap)."); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/verify-mongo-parity.js b/scripts/verify-mongo-parity.js deleted file mode 100644 index 001948b..0000000 --- a/scripts/verify-mongo-parity.js +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env node -/** - * @file verify-mongo-parity — cross-check KV / D1 data against MongoDB Atlas. - * - * Per KV module: count via CF REST vs Mongo countDocuments (±1% tolerance), - * then value-compare via SHA256. Full-scan when total < 10 000; random-sample - * N = min(500, ceil(sqrt(N))) otherwise. expiresAt checked within ±5 min. - * For trading_trades: full-scan on legacy_id, ts, user_id, symbol, qty. - * - * Flags: --dry-run (counts only, no value compare) - * Required env: MONGODB_URI, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, - * KV_NAMESPACE_ID, MODULES - * Exit: 0 all-pass | 1 any failure - */ - -import { execSync } from "node:child_process"; -import { - cfKvGet, - cfKvList, - closeMongoClient, - countDiffRatio, - getMongoClient, - sampleStrategy, - sha256, - sleep, -} from "./lib/migration-helpers.js"; - -const DB_NAME = "miti99bot-db"; -const EXPIRES_TOL_MS = 5 * 60 * 1000; // ±5 minutes -const dryRun = process.argv.includes("--dry-run"); -const { - MONGODB_URI, - CLOUDFLARE_ACCOUNT_ID, - CLOUDFLARE_API_TOKEN, - KV_NAMESPACE_ID, - MODULES: MODULES_ENV, -} = process.env; -const toCollName = (mod) => mod.replace(/-/g, "_"); -const redactKey = (k) => { - const c = k.indexOf(":"); - return `${c >= 0 ? k.slice(0, c + 1) : ""}sha256:${sha256(k).slice(0, 8)}…`; -}; - -function validateEnv() { - const missing = [ - "MONGODB_URI", - "CLOUDFLARE_ACCOUNT_ID", - "CLOUDFLARE_API_TOKEN", - "KV_NAMESPACE_ID", - "MODULES", - ].filter((k) => !process.env[k]); - if (missing.length) { - console.error(`[verify] Missing env vars: ${missing.join(", ")}`); - process.exit(1); - } -} - -function queryD1(sql) { - const cmd = `npx wrangler d1 execute ${DB_NAME} --remote --command "${sql.replace(/"/g, '\\"')}" --json`; - const parsed = JSON.parse(execSync(cmd, { stdio: ["ignore", "pipe", "pipe"] }).toString()); - return Array.isArray(parsed) ? (parsed[0]?.results ?? []) : []; -} - -function shuffle(arr) { - const a = [...arr]; - for (let i = a.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [a[i], a[j]] = [a[j], a[i]]; - } - return a; -} - -/** Enumerate ALL key objects for a module prefix via paginated CF REST. */ -async function allKvKeys(mod) { - const keys = []; - let cursor = null; - do { - const page = await cfKvList( - CLOUDFLARE_ACCOUNT_ID, - KV_NAMESPACE_ID, - CLOUDFLARE_API_TOKEN, - `${mod}:`, - cursor, - ); - keys.push(...page.keys); - cursor = page.list_complete ? null : page.cursor; - if (cursor) await sleep(250); - } while (cursor); - return keys; -} - -/** - * Compare one KV key against its Mongo doc. - * Returns null on match, mismatch description string on failure. - * - * @param {import("mongodb").Collection} coll - * @param {{ name: string, metadata?: { expiration?: number } }} keyObj - * @returns {Promise} - */ -async function compareKey(coll, keyObj) { - const value = await cfKvGet( - CLOUDFLARE_ACCOUNT_ID, - KV_NAMESPACE_ID, - CLOUDFLARE_API_TOKEN, - keyObj.name, - ); - await sleep(5); - const doc = await coll.findOne({ _id: keyObj.name }); - if (!doc) return "missing in Mongo"; - if (sha256(value) !== sha256(doc.value ?? "")) return "value hash mismatch"; - const kvExpMs = keyObj.metadata?.expiration ? keyObj.metadata.expiration * 1000 : null; - const mgExpMs = doc.expiresAt ? new Date(doc.expiresAt).getTime() : null; - if ((kvExpMs === null) !== (mgExpMs === null)) return "expiresAt presence mismatch"; - if (kvExpMs !== null && Math.abs(kvExpMs - mgExpMs) > EXPIRES_TOL_MS) - return `expiresAt diff ${Math.abs(kvExpMs - mgExpMs)}ms`; - return null; -} - -async function verifyModule(db, mod) { - const coll = db.collection(toCollName(mod)); - const kvKeys = await allKvKeys(mod); - const nKv = kvKeys.length; - const nMongo = await coll.countDocuments(); - const ratio = countDiffRatio(nKv, nMongo); - const countOk = ratio < 0.01; - console.log( - `[${mod}] KV=${nKv} Mongo=${nMongo} diff=${(ratio * 100).toFixed(2)}% ${countOk ? "OK" : "FAIL"}`, - ); - if (!countOk) - return { pass: false, mismatches: [`count diff ${(ratio * 100).toFixed(2)}% > 1%`] }; - if (dryRun) return { pass: true, mismatches: [] }; - - const { fullScan, sampleSize } = sampleStrategy(nKv); - const sample = fullScan ? kvKeys : shuffle(kvKeys).slice(0, sampleSize); - console.log(`[${mod}] ${fullScan ? "full-scan" : `sample ${sampleSize}/${nKv}`}`); - - const mismatches = []; - for (const keyObj of sample) { - const m = await compareKey(coll, keyObj); - if (m) mismatches.push(`${redactKey(keyObj.name)}: ${m}`); - } - console.log( - `[${mod}] value compare: ${mismatches.length === 0 ? "PASS" : `${mismatches.length} mismatch(es)`}`, - ); - return { pass: mismatches.length === 0, mismatches }; -} - -async function verifyTrading(db) { - const coll = db.collection("trading_trades"); - const rows = queryD1( - "SELECT id, user_id, symbol, side, qty, price_vnd, ts FROM trading_trades ORDER BY id", - ); - const nMongo = await coll.countDocuments(); - const ratio = countDiffRatio(rows.length, nMongo); - const countOk = ratio < 0.01; - console.log( - `[trading] D1=${rows.length} Mongo=${nMongo} diff=${(ratio * 100).toFixed(2)}% ${countOk ? "OK" : "FAIL"}`, - ); - if (!countOk) return { pass: false, mismatches: [`count diff ${(ratio * 100).toFixed(2)}%`] }; - if (dryRun) return { pass: true, mismatches: [] }; - - const mismatches = []; - for (const row of rows) { - const doc = await coll.findOne({ legacy_id: row.id }); - if (!doc) { - mismatches.push(`legacy_id=${row.id} missing`); - continue; - } - for (const f of ["user_id", "symbol", "qty", "ts"]) { - if (String(doc[f]) !== String(row[f])) mismatches.push(`legacy_id=${row.id} ${f} mismatch`); - } - } - console.log( - `[trading] full-scan: ${mismatches.length === 0 ? "PASS" : `${mismatches.length} mismatch(es)`}`, - ); - return { pass: mismatches.length === 0, mismatches }; -} - -async function main() { - validateEnv(); - if (dryRun) console.log("[verify] DRY RUN — counts only"); - const modules = MODULES_ENV.split(",") - .map((m) => m.trim()) - .filter(Boolean); - const db = (await getMongoClient(MONGODB_URI)).db(); - - const report = []; - let anyFail = false; - for (const mod of modules) { - const r = await verifyModule(db, mod); - report.push({ mod, ...r }); - if (!r.pass) anyFail = true; - } - const tr = await verifyTrading(db); - report.push({ mod: "trading", ...tr }); - if (!tr.pass) anyFail = true; - - await closeMongoClient(); - console.log("\n── Parity Report ─────────────────────────────"); - for (const r of report) { - console.log(` ${r.pass ? "PASS" : "FAIL"} ${r.mod}`); - for (const m of r.mismatches) console.log(` mismatch: ${m}`); - } - console.log("──────────────────────────────────────────────"); - if (anyFail) { - console.error("[verify] FAILED."); - process.exit(1); - } - console.log("[verify] All modules PASS."); -} - -main().catch((err) => { - console.error("[verify] Fatal:", err.message ?? err); - process.exit(1); -}); diff --git a/scripts/wipe-mongo.js b/scripts/wipe-mongo.js deleted file mode 100644 index 3e813ce..0000000 --- a/scripts/wipe-mongo.js +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env node -/** - * @file wipe-mongo — rollback helper: delete all documents from every backfill - * collection in MongoDB Atlas. - * - * WARNING: THIS IS IRREVERSIBLE. Run only when you want to start the backfill - * from scratch (e.g. after discovering a systematic mapping bug). - * - * Flags: - * --yes Skip the interactive confirmation prompt (for CI / scripted rollback). - * Even with --yes, prints a loud warning so logs capture intent. - * - * Required env: MONGODB_URI, MODULES (comma-separated KV module names) - * - * Usage: - * node --env-file-if-exists=.env.deploy scripts/wipe-mongo.js - * node --env-file-if-exists=.env.deploy scripts/wipe-mongo.js --yes - */ - -import { createInterface } from "node:readline"; -import { closeMongoClient, getMongoClient } from "./lib/migration-helpers.js"; - -const { MONGODB_URI, MODULES: MODULES_ENV } = process.env; -const skipPrompt = process.argv.includes("--yes"); - -function validateEnv() { - const needed = { MONGODB_URI, MODULES: MODULES_ENV }; - const missing = Object.entries(needed) - .filter(([, v]) => !v) - .map(([k]) => k); - if (missing.length) { - console.error(`[wipe-mongo] Missing required env vars: ${missing.join(", ")}`); - console.error(" Copy .env.deploy.example to .env.deploy and fill in values."); - process.exit(1); - } -} - -/** @param {string} question @returns {Promise} */ -function prompt(question) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer); - }); - }); -} - -async function main() { - validateEnv(); - - // Build the list of collections: one per KV module + trading_trades. - const kvModules = MODULES_ENV.split(",") - .map((m) => m.trim()) - .filter(Boolean); - const collections = [ - ...kvModules.map((m) => m.replace(/-/g, "_")), // mirrors mongo-kv-store.js normalization - "trading_trades", - ]; - - console.error("╔══════════════════════════════════════════════════════╗"); - console.error("║ WARNING: wipe-mongo will DELETE ALL DOCUMENTS from ║"); - console.error("║ the Atlas database and CANNOT BE UNDONE. ║"); - console.error("╚══════════════════════════════════════════════════════╝"); - console.log(`Collections to wipe (${collections.length}): ${collections.join(", ")}`); - - if (!skipPrompt) { - const answer = await prompt("\nType CONFIRM to wipe Atlas database miti99bot: "); - if (answer.trim() !== "CONFIRM") { - console.log("Aborted — nothing was deleted."); - process.exit(0); - } - } else { - console.error("[wipe-mongo] --yes passed: skipping interactive prompt. Proceeding with wipe."); - } - - const client = await getMongoClient(MONGODB_URI); - const db = client.db(); - - let totalDeleted = 0; - for (const name of collections) { - const result = await db.collection(name).deleteMany({}); - console.log(`[wipe-mongo] ${name}: deleted ${result.deletedCount} document(s)`); - totalDeleted += result.deletedCount; - } - - await closeMongoClient(); - console.log(`[wipe-mongo] Done — ${totalDeleted} total document(s) removed.`); -} - -main().catch((err) => { - console.error("[wipe-mongo] Fatal:", err.message ?? err); - process.exit(1); -}); diff --git a/scripts/wrangler-delete-guard.sh b/scripts/wrangler-delete-guard.sh deleted file mode 100755 index 39b7d57..0000000 --- a/scripts/wrangler-delete-guard.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -# wrangler-delete-guard.sh — interactive CONFIRM wrapper around irreversible -# wrangler delete commands. NEVER use in CI. -# -# Usage: -# bash scripts/wrangler-delete-guard.sh kv -# bash scripts/wrangler-delete-guard.sh d1 -# -# Exits: -# 0 — command executed successfully -# 1 — user aborted (did not type CONFIRM) -# 2 — bad arguments -# 3 — stdin not a tty (CI safety) - -set -euo pipefail - -if [[ $# -lt 2 ]]; then - echo "usage: $0 {kv|d1} " >&2 - exit 2 -fi - -KIND="$1" -TARGET="$2" - -case "$KIND" in - kv) - DESC="KV namespace $TARGET" - CMD=(npx wrangler kv namespace delete --namespace-id "$TARGET") - ;; - d1) - DESC="D1 database $TARGET" - CMD=(npx wrangler d1 delete "$TARGET") - ;; - *) - echo "unknown kind: $KIND (expected 'kv' or 'd1')" >&2 - exit 2 - ;; -esac - -# Refuse to run non-interactively — prevents accidental CI execution. -if [[ ! -t 0 ]]; then - echo "stdin not a tty — refusing to run non-interactively (CI safety)" >&2 - exit 3 -fi - -echo "" -echo "ABOUT TO DELETE: $DESC" -echo "This is IRREVERSIBLE. Backup files must already be on local disk." -echo "" -read -r -p "Type CONFIRM to proceed: " CONFIRM - -if [[ "$CONFIRM" != "CONFIRM" ]]; then - echo "aborted" >&2 - exit 1 -fi - -echo "" -echo "executing: ${CMD[*]}" -"${CMD[@]}" diff --git a/src/cron/drift-verifier.js b/src/cron/drift-verifier.js deleted file mode 100644 index 8c1f3dc..0000000 --- a/src/cron/drift-verifier.js +++ /dev/null @@ -1,256 +0,0 @@ -/** - * @file drift-verifier — hourly cron handler that maintains dual-write health. - * - * Two responsibilities: - * 1. Drain retry queues: re-attempt writes that failed against the secondary - * backend and were enqueued by DualKVStore / DualSqlStore. - * 2. Spot-check parity: sample N keys from the KV store, fetch the same keys - * from Mongo, hash-compare values, and log any mismatches. - * - * Schedule: `"0 * * * *"` (once per hour). Tunable via `env.DRIFT_SAMPLE_N` - * (default 50). - * - * Error logging never includes document values to avoid PII leakage. - * - * Cron handler signature matches the existing pattern used by module crons: - * `handler(event, ctx)` where `ctx = { db, sql, env }`. - * - * @module cron/drift-verifier - */ - -import { MongoKVStore } from "../db/mongo-kv-store.js"; - -const KV_RETRY_PREFIX = "__retry:mongo-failed:"; -const SQL_RETRY_PREFIX = "__retry:mongo-sql-failed:"; -const MAX_DRAIN_PER_RUN = 200; - -/** - * Simple stable hash of a string value for drift comparison. - * Not cryptographic — used only for equality detection. - * - * @param {string | null} s - * @returns {string} - */ -function hashValue(s) { - if (s == null) return "__null__"; - let h = 0; - for (let i = 0; i < s.length; i++) { - h = ((h << 5) - h + s.charCodeAt(i)) | 0; - } - return h.toString(16); -} - -/** - * Drain one retry queue prefix: list matching keys, re-attempt the secondary - * write, delete the queue entry on success. Cap at MAX_DRAIN_PER_RUN. - * - * @param {any} rawKv — raw CF KVNamespace (env.KV) - * @param {string} prefix — queue key prefix, e.g. `__retry:mongo-failed:` - * @param {(payload: object) => Promise} retryFn — callable that re-attempts the secondary write - * @returns {Promise<{ attempted: number, succeeded: number, failed: number }>} - */ -async function drainRetryQueue(rawKv, prefix, retryFn) { - let attempted = 0; - let succeeded = 0; - let failed = 0; - - const listed = await rawKv.list({ prefix, limit: MAX_DRAIN_PER_RUN }); - const keys = listed.keys ?? []; - - for (const keyEntry of keys) { - const queueKey = typeof keyEntry === "object" ? keyEntry.name : keyEntry; - attempted++; - try { - const raw = await rawKv.get(queueKey); - if (!raw) { - await rawKv.delete(queueKey); - continue; - } - const payload = JSON.parse(raw); - await retryFn(payload); - await rawKv.delete(queueKey); - succeeded++; - } catch (err) { - failed++; - console.warn("[drift-verifier] retry failed", { - phase: "drift-verifier", - queueKey, - errClass: err instanceof Error ? err.constructor.name : "unknown", - err: err instanceof Error ? err.message : String(err), - }); - } - } - - return { attempted, succeeded, failed }; -} - -/** - * No-op retry for SQL entries — re-attempt is intentionally left as a no-op - * for Phase 04 (the MongoSqlStore handles its own write path). A full retry - * would require reconstructing the store, which is out of scope here. - * The queue entry is deleted so stale entries don't accumulate indefinitely. - * - * @param {object} _payload - * @returns {Promise} - */ -async function retrySqlWrite(_payload) { - // Phase 04 note: full SQL retry requires reconstructing MongoSqlStore. - // For now this drain clears stale entries. Phase 06 telemetry will surface - // any persistent failure patterns before Phase 07 cutover. -} - -/** - * Sample N keys from the CF KV namespace (scoped to a module prefix), - * fetch the same keys from the MongoKVStore, and log any hash mismatches. - * - * @param {string} moduleName - * @param {any} rawKv — raw CF KVNamespace - * @param {MongoKVStore} mongoStore - * @param {number} n — sample size - * @returns {Promise<{ sampled: number, mismatches: number }>} - */ -async function spotCheckModule(moduleName, rawKv, mongoStore, n) { - const prefix = `${moduleName}:`; - let sampled = 0; - let mismatches = 0; - - try { - const listed = await rawKv.list({ prefix, limit: n }); - const keys = (listed.keys ?? []).map((k) => (typeof k === "object" ? k.name : k)); - - for (const fullKey of keys) { - sampled++; - try { - const cfRaw = await rawKv.get(fullKey); - const mongoRaw = await mongoStore.get(fullKey); - const cfHash = hashValue(cfRaw); - const mongoHash = hashValue(mongoRaw); - - if (cfHash !== mongoHash) { - mismatches++; - console.warn("[drift-verifier] parity mismatch", { - phase: "drift-verifier", - module: moduleName, - key: fullKey, - primary_hash: cfHash, - secondary_hash: mongoHash, - }); - } - } catch (err) { - console.warn("[drift-verifier] key compare failed", { - phase: "drift-verifier", - module: moduleName, - key: fullKey, - err: err instanceof Error ? err.message : String(err), - }); - } - } - } catch (err) { - console.error("[drift-verifier] spotCheckModule failed", { - phase: "drift-verifier", - module: moduleName, - err: err instanceof Error ? err.message : String(err), - }); - } - - return { sampled, mismatches }; -} - -/** - * Drift-verifier cron handler. - * - * Registered in wrangler.toml as `"0 * * * *"` and wired into the cron - * dispatcher via the registry's system crons array. - * - * @param {any} _event — Cloudflare ScheduledEvent (unused; schedule matched by dispatcher) - * @param {{ db: any, sql: any, env: any }} ctx — injected by cron-dispatcher - * @returns {Promise} - */ -export async function driftVerifier(_event, ctx) { - const { env } = ctx; - const N = Math.max(1, Number(env.DRIFT_SAMPLE_N ?? 50)); - const rawKv = env.KV; - - if (!rawKv) { - console.warn("[drift-verifier] env.KV not available — skipping run"); - return; - } - - // 1. Drain KV retry queue (failed Mongo secondary writes). - const kvDrain = await drainRetryQueue(rawKv, KV_RETRY_PREFIX, async (payload) => { - // Atlas URI is required to re-attempt the secondary write. - if (!env.MONGODB_URI) - throw new Error("Atlas URI not configured — cannot retry secondary write"); - const store = new MongoKVStore(env, payload.key.split(":")[0] ?? "unknown"); - if (payload.op === "delete") { - await store.delete(payload.key); - } else if (payload.op === "putJSON") { - // Value is not stored in the retry queue (PII risk) — skip value retry. - // The key will be re-synced by the next backfill / drift-verifier parity check. - console.warn("[drift-verifier] putJSON retry skipped — value not stored in queue", { - phase: "drift-verifier", - key: payload.key, - }); - } else if (payload.op === "put") { - // Same as putJSON: value omitted from queue for PII safety. - console.warn("[drift-verifier] put retry skipped — value not stored in queue", { - phase: "drift-verifier", - key: payload.key, - }); - } - }); - - // 2. Drain SQL retry queue (failed MongoSqlStore secondary writes). - const sqlDrain = await drainRetryQueue(rawKv, SQL_RETRY_PREFIX, retrySqlWrite); - - console.log("[drift-verifier] queue drain complete", { - phase: "drift-verifier", - kv: kvDrain, - sql: sqlDrain, - }); - - // 3. Spot-check parity across key modules when Atlas is reachable. - if (!env.MONGODB_URI || env.MONGODB_URI === "__stub_mongo__") return; - - const modules = - typeof env.MODULES === "string" - ? env.MODULES.split(",") - .map((m) => m.trim()) - .filter(Boolean) - : []; - - let totalSampled = 0; - let totalMismatches = 0; - - for (const moduleName of modules) { - const collName = moduleName.replace(/-/g, "_"); - const mongoStore = new MongoKVStore(env, collName); - const { sampled, mismatches } = await spotCheckModule(moduleName, rawKv, mongoStore, N); - totalSampled += sampled; - totalMismatches += mismatches; - } - - if (totalMismatches > 0) { - console.error("[drift-verifier] parity drift detected", { - phase: "drift-verifier", - totalSampled, - totalMismatches, - }); - } else { - console.log("[drift-verifier] parity check passed", { - phase: "drift-verifier", - totalSampled, - }); - } -} - -/** - * Module-style export so the cron-dispatcher can register drift-verifier as a - * system-level cron entry alongside module crons. The `name` is synthetic — - * no module folder exists; the dispatcher treats it like any other cron entry. - */ -export const driftVerifierCron = { - schedule: "0 * * * *", - name: "drift-verifier", - handler: driftVerifier, -}; diff --git a/src/db/create-sql-store.js b/src/db/create-sql-store.js index 1f78ad1..a37f5d5 100644 --- a/src/db/create-sql-store.js +++ b/src/db/create-sql-store.js @@ -7,45 +7,19 @@ * * Returns null when `env.DB` is absent so modules that don't use D1 have * zero overhead — the registry passes `sql: null` and modules check for it. - * - * ## Flag matrix (same as create-store.js, SQL edition) - * - * | STORAGE_PRIMARY | DUAL_WRITE | MONGODB_URI | Result | - * |-----------------|------------|-------------------|----------------------------------------------| - * | (unset) or kv | 1 (default)| set, real | DualSqlStore(CFSqlStore primary, Mongo sec.) | - * | kv | 0 | any | CFSqlStore only (legacy / rollback) | - * | mongo | 1 | set, real | DualSqlStore(Mongo primary, CFSqlStore sec.) | - * | mongo | 0 | set, real | MongoSqlStore only (post-cutover) | - * | any | any | unset | CFSqlStore only (or null if env.DB absent) | - * | any | any | === STUB_SENTINEL | CFSqlStore only (deploy-time register path) | - * - * Note: trading module's `tradesStore` (MongoTradesStore) is wired separately - * via registry.js — this factory produces the generic SqlStore shim only. - * - * post-Phase-07: this entire function returns MongoSqlStore-only; - * CF/D1 branches removed. The flag matrix above collapses to a single path. */ import { CFSqlStore } from "./cf-sql-store.js"; -import { DualSqlStore } from "./dual-sql-store.js"; -import { MongoSqlStore } from "./mongo-sql-store.js"; /** * @typedef {import("./sql-store-interface.js").SqlStore} SqlStore */ -/** Sentinel value used by scripts/register.js to signal deploy-time context. */ -const STUB_SENTINEL = "__stub_mongo__"; - const MODULE_NAME_RE = /^[a-z0-9_-]+$/; /** * @param {string} moduleName — must match `[a-z0-9_-]+`. - * @param {object} env — worker env (or test double). - * @param {any} [env.DB] — CF D1Database binding (optional). - * @param {string} [env.MONGODB_URI] — Atlas connection string (or STUB_SENTINEL). - * @param {string} [env.STORAGE_PRIMARY] — "kv" (default) | "mongo". - * @param {string} [env.DUAL_WRITE] — "1" (default) | "0". + * @param {{ DB?: D1Database }} env — worker env (or test double). * @returns {SqlStore | null} null when env.DB is not bound. */ export function createSqlStore(moduleName, env) { @@ -61,46 +35,6 @@ export function createSqlStore(moduleName, env) { // D1 is optional — workers without a DB binding still work fine. if (!env?.DB) return null; - // --- Sentinel / fallback: always CF-only --- - const mongoUri = env.MONGODB_URI; - if (!mongoUri || mongoUri === STUB_SENTINEL) { - return _wrapCf(moduleName, env); - } - - const primary = (env.STORAGE_PRIMARY ?? "kv").toLowerCase(); - const dualWrite = (env.DUAL_WRITE ?? "1") !== "0"; - - if (primary === "mongo" && !dualWrite) { - // MongoSqlStore only — post-cutover path. - return new MongoSqlStore(env, moduleName); - } - - const cfStore = _wrapCf(moduleName, env); - const mongoStore = new MongoSqlStore(env, moduleName); - - if (primary === "mongo") { - // DualSqlStore: read Mongo, write both — cutover phase. - return new DualSqlStore(mongoStore, cfStore, env.KV); - } - - // Default: STORAGE_PRIMARY=kv - if (!dualWrite) { - // Rollback path: CF only. - return cfStore; - } - - // DualSqlStore: read CF/D1, write both — dual-write window. - return new DualSqlStore(cfStore, mongoStore, env.KV); -} - -/** - * Build a namespaced CFSqlStore wrapper with the same shape as before Phase 04. - * - * @param {string} moduleName - * @param {{ DB: any }} env - * @returns {SqlStore} - */ -function _wrapCf(moduleName, env) { const base = new CFSqlStore(env.DB); const tablePrefix = `${moduleName}_`; diff --git a/src/db/create-store.js b/src/db/create-store.js index a3d8915..bae8952 100644 --- a/src/db/create-store.js +++ b/src/db/create-store.js @@ -4,31 +4,11 @@ * Every module gets its own prefixed view: module `wordle` calling `put("k", v)` * writes raw key `wordle:k`. list() automatically constrains to the module's * namespace AND strips the prefix from returned keys so the module sees its - * own flat key-space. Modules CANNOT escape their namespace without + * own flat key-space. modules CANNOT escape their namespace without * reconstructing prefixes manually — a code-review boundary, not a hard one. - * - * ## Flag matrix (env.STORAGE_PRIMARY × env.DUAL_WRITE × env.MONGODB_URI) - * - * | STORAGE_PRIMARY | DUAL_WRITE | MONGODB_URI | Result | - * |-----------------|------------|-------------------|--------------------------------------------| - * | (unset) or kv | 1 (default)| set, real | DualKVStore(CFKVStore primary, Mongo sec.) | - * | kv | 0 | any | CFKVStore only (legacy / rollback) | - * | mongo | 1 | set, real | DualKVStore(Mongo primary, CFKVStore sec.) | - * | mongo | 0 | set, real | MongoKVStore only (post-cutover) | - * | any | any | unset | CFKVStore only | - * | any | any | === STUB_SENTINEL | CFKVStore only (deploy-time register path) | - * - * Boot-time assertion: if STORAGE_PRIMARY=mongo but MONGODB_URI is absent - * (and not STUB_SENTINEL), the first call throws a clear error rather than - * silently falling back to KV. - * - * post-Phase-07: this entire function returns MongoKVStore-only; - * KV branches removed. The flag matrix above collapses to a single path. */ import { CFKVStore } from "./cf-kv-store.js"; -import { DualKVStore } from "./dual-kv-store.js"; -import { MongoKVStore } from "./mongo-kv-store.js"; /** * @typedef {import("./kv-store-interface.js").KVStore} KVStore @@ -37,24 +17,30 @@ import { MongoKVStore } from "./mongo-kv-store.js"; * @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult */ -/** Sentinel value used by scripts/register.js to signal deploy-time context. */ -const STUB_SENTINEL = "__stub_mongo__"; - const MODULE_NAME_RE = /^[a-z0-9_-]+$/; /** - * Wrap a raw KVStore so all keys carry a `:` prefix and - * list() results are stripped back to the module's flat key-space. - * - * @param {string} prefix — e.g. "wordle:" - * @param {KVStore} base + * @param {string} moduleName — must match `[a-z0-9_-]+`. Used verbatim as the key prefix. + * @param {{ KV: KVNamespace }} env — worker env (or test double) with a `KV` binding. * @returns {KVStore} */ -function withPrefix(prefix, base) { - return { - /** @type {"dual" | undefined} */ - _kind: /** @type {any} */ (base)._kind, +export function createStore(moduleName, env) { + if (!moduleName || typeof moduleName !== "string") { + throw new Error("createStore: moduleName is required"); + } + if (!MODULE_NAME_RE.test(moduleName)) { + throw new Error( + `createStore: invalid moduleName "${moduleName}" — must match ${MODULE_NAME_RE}`, + ); + } + if (!env?.KV) { + throw new Error("createStore: env.KV binding is missing"); + } + const base = new CFKVStore(env.KV); + const prefix = `${moduleName}:`; + + return { async get(key) { return base.get(prefix + key); }, @@ -91,63 +77,3 @@ function withPrefix(prefix, base) { }, }; } - -/** - * @param {string} moduleName — must match `[a-z0-9_-]+`. Used verbatim as the key prefix. - * @param {object} env — worker env (or test double). - * @param {any} env.KV — CF KVNamespace binding. - * @param {string} [env.MONGODB_URI] — Atlas connection string (or STUB_SENTINEL). - * @param {string} [env.STORAGE_PRIMARY] — "kv" (default) | "mongo". - * @param {string} [env.DUAL_WRITE] — "1" (default) | "0". - * @returns {KVStore} - */ -export function createStore(moduleName, env) { - if (!moduleName || typeof moduleName !== "string") { - throw new Error("createStore: moduleName is required"); - } - if (!MODULE_NAME_RE.test(moduleName)) { - throw new Error( - `createStore: invalid moduleName "${moduleName}" — must match ${MODULE_NAME_RE}`, - ); - } - if (!env?.KV) { - throw new Error("createStore: env.KV binding is missing"); - } - - const prefix = `${moduleName}:`; - // Collection name: replace `-` with `_` for MongoDB compatibility. - const collectionName = moduleName.replace(/-/g, "_"); - - // --- Sentinel / fallback: always CF-only --- - const mongoUri = env.MONGODB_URI; - if (!mongoUri || mongoUri === STUB_SENTINEL) { - return withPrefix(prefix, new CFKVStore(env.KV)); - } - - const primary = (env.STORAGE_PRIMARY ?? "kv").toLowerCase(); - const dualWrite = (env.DUAL_WRITE ?? "1") !== "0"; - - // Boot-time assertion: if mongo is requested but URI is absent, throw clearly. - // (URI IS present here since we checked above, so this guard is for STORAGE_PRIMARY=mongo.) - if (primary === "mongo" && !dualWrite) { - // MongoKVStore only — post-cutover path. - return withPrefix(prefix, new MongoKVStore(env, collectionName)); - } - - const cfStore = new CFKVStore(env.KV); - const mongoStore = new MongoKVStore(env, collectionName); - - if (primary === "mongo") { - // DualKVStore: read Mongo, write both — cutover phase. - return withPrefix(prefix, new DualKVStore(mongoStore, cfStore, env.KV)); - } - - // Default: STORAGE_PRIMARY=kv + DUAL_WRITE=1 - if (!dualWrite) { - // Rollback path: KV only. - return withPrefix(prefix, cfStore); - } - - // DualKVStore: read KV, write both — dual-write window. - return withPrefix(prefix, new DualKVStore(cfStore, mongoStore, env.KV)); -} diff --git a/src/db/dual-kv-store.js b/src/db/dual-kv-store.js deleted file mode 100644 index 708a5ae..0000000 --- a/src/db/dual-kv-store.js +++ /dev/null @@ -1,184 +0,0 @@ -/** - * @file dual-kv-store — KVStore wrapper that writes to two backends in parallel. - * - * During the dual-write window (Phase 04 → Phase 07): - * - Reads go to the primary only (never the secondary). - * - Writes go to BOTH via `Promise.allSettled`. If the secondary fails, - * the failure is logged (key + error class + message only — no document value - * to avoid PII leakage) and enqueued to a KV retry queue. The primary - * failure causes a throw; secondary failure is transparent to the caller. - * - * Retry queue keys: `__retry:mongo-failed:` stored in `env.KV` - * (the raw CF KV namespace, not the dual-store itself). - * - * Expose `_kind = "dual"` sentinel for test-side identification. - * - * @module db/dual-kv-store - */ - -/** - * @typedef {import("./kv-store-interface.js").KVStore} KVStore - * @typedef {import("./kv-store-interface.js").KVStorePutOptions} KVStorePutOptions - * @typedef {import("./kv-store-interface.js").KVStoreListOptions} KVStoreListOptions - * @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult - */ - -const RETRY_PREFIX = "__retry:mongo-failed:"; - -/** - * Generate a short random suffix for retry-queue keys so concurrent failures - * do not collide. - * - * @returns {string} - */ -function randomId() { - return Math.random().toString(36).slice(2, 10); -} - -/** - * Enqueue a failed secondary write to the raw KV namespace so the - * drift-verifier cron can retry it later. - * - * @param {any} rawKv — raw CF KVNamespace (env.KV) - * @param {object} payload — { op, key, value?, opts? } - * @returns {Promise} - */ -async function enqueueRetry(rawKv, payload) { - try { - const id = `${RETRY_PREFIX}${payload.key}:${randomId()}`; - await rawKv.put(id, JSON.stringify({ ...payload, ts: Date.now() })); - } catch (err) { - // Retry-queue write failing is unfortunate but must not crash the request. - console.warn("[dual-kv] enqueueRetry failed", { - phase: "dual-kv", - op: "enqueue", - err: err instanceof Error ? err.message : String(err), - }); - } -} - -/** - * @implements {KVStore} - */ -export class DualKVStore { - /** - * @param {KVStore} primary — the authoritative store (reads + writes). - * @param {KVStore} secondary — the mirror store (writes only; failures silently queued). - * @param {any} rawKv — raw CF KVNamespace used for the retry queue. - * @param {object} [logger] — injectable logger; defaults to `console`. - */ - constructor(primary, secondary, rawKv, logger = console) { - if (!primary) throw new Error("DualKVStore: primary is required"); - if (!secondary) throw new Error("DualKVStore: secondary is required"); - if (!rawKv) throw new Error("DualKVStore: rawKv is required"); - this._primary = primary; - this._secondary = secondary; - this._rawKv = rawKv; - this._log = logger; - /** @type {"dual"} */ - this._kind = "dual"; - } - - /** - * Fire both writes in parallel. Throw on primary failure. On secondary - * failure: log structured warning and enqueue for retry. - * - * @param {string} op — operation name for logging. - * @param {string} key — the key being written (used in retry payload + logs). - * @param {Function} primaryFn — async thunk for primary write. - * @param {Function} secondaryFn — async thunk for secondary write. - * @param {object} [retryPayload] — extra fields stored in the retry queue entry. - * @returns {Promise} primary result. - */ - async _dualWrite(op, key, primaryFn, secondaryFn, retryPayload) { - const [primaryResult, secondaryResult] = await Promise.allSettled([primaryFn(), secondaryFn()]); - - if (secondaryResult.status === "rejected") { - const err = secondaryResult.reason; - this._log.warn("[dual-kv] secondary write failed", { - phase: "dual-kv", - op, - key, - errClass: err instanceof Error ? err.constructor.name : "unknown", - err: err instanceof Error ? err.message : String(err), - }); - await enqueueRetry(this._rawKv, { op, key, ...retryPayload }); - } - - if (primaryResult.status === "rejected") { - throw primaryResult.reason; - } - - return primaryResult.value; - } - - /** - * @param {string} key - * @returns {Promise} - */ - async get(key) { - return this._primary.get(key); - } - - /** - * @param {string} key - * @param {string} value - * @param {KVStorePutOptions} [opts] - * @returns {Promise} - */ - async put(key, value, opts) { - return this._dualWrite( - "put", - key, - () => this._primary.put(key, value, opts), - () => this._secondary.put(key, value, opts), - { opts }, - ); - } - - /** - * @param {string} key - * @returns {Promise} - */ - async delete(key) { - return this._dualWrite( - "delete", - key, - () => this._primary.delete(key), - () => this._secondary.delete(key), - {}, - ); - } - - /** - * @param {KVStoreListOptions} [opts] - * @returns {Promise} - */ - async list(opts = {}) { - return this._primary.list(opts); - } - - /** - * @param {string} key - * @returns {Promise} - */ - async getJSON(key) { - return this._primary.getJSON(key); - } - - /** - * @param {string} key - * @param {any} value - * @param {KVStorePutOptions} [opts] - * @returns {Promise} - */ - async putJSON(key, value, opts) { - return this._dualWrite( - "putJSON", - key, - () => this._primary.putJSON(key, value, opts), - () => this._secondary.putJSON(key, value, opts), - { opts }, - ); - } -} diff --git a/src/db/dual-sql-store.js b/src/db/dual-sql-store.js deleted file mode 100644 index d98efb4..0000000 --- a/src/db/dual-sql-store.js +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @file dual-sql-store — SqlStore wrapper that writes to two backends in parallel. - * - * Same fault-tolerance model as DualKVStore: - * - Reads (`all`, `first`) go to the primary only. - * - `run` writes go to BOTH via `Promise.allSettled`. Secondary failure is - * logged (query + error — no row values to avoid PII) and enqueued to the - * KV retry queue. Primary failure causes a throw. - * - `prepare` and `batch` go to primary only — D1 prepared statements are - * CF-specific and cannot be forwarded to MongoDB. - * - * Retry queue keys: `__retry:mongo-sql-failed:` stored in `env.KV`. - * - * Expose `_kind = "dual"` sentinel for test-side identification. - * - * @module db/dual-sql-store - */ - -/** - * @typedef {import("./sql-store-interface.js").SqlStore} SqlStore - * @typedef {import("./sql-store-interface.js").SqlRunResult} SqlRunResult - */ - -const RETRY_PREFIX = "__retry:mongo-sql-failed:"; - -/** - * Generate a short random suffix for retry-queue keys. - * - * @returns {string} - */ -function randomId() { - return Math.random().toString(36).slice(2, 10); -} - -/** - * Enqueue a failed secondary write to the raw KV namespace. - * - * @param {any} rawKv — raw CF KVNamespace (env.KV) - * @param {object} payload — { op, query, binds? } - * @returns {Promise} - */ -async function enqueueRetry(rawKv, payload) { - try { - const slug = payload.query.slice(0, 40).replace(/\s+/g, "_"); - const id = `${RETRY_PREFIX}${slug}:${randomId()}`; - await rawKv.put(id, JSON.stringify({ ...payload, ts: Date.now() })); - } catch (err) { - console.warn("[dual-sql] enqueueRetry failed", { - phase: "dual-sql", - op: "enqueue", - err: err instanceof Error ? err.message : String(err), - }); - } -} - -/** - * @implements {SqlStore} - */ -export class DualSqlStore { - /** - * @param {SqlStore} primary — the authoritative store (reads + writes). - * @param {SqlStore} secondary — the mirror store (writes only; failures silently queued). - * @param {any} rawKv — raw CF KVNamespace used for the retry queue. - * @param {object} [logger] — injectable logger; defaults to `console`. - */ - constructor(primary, secondary, rawKv, logger = console) { - if (!primary) throw new Error("DualSqlStore: primary is required"); - if (!secondary) throw new Error("DualSqlStore: secondary is required"); - if (!rawKv) throw new Error("DualSqlStore: rawKv is required"); - this._primary = primary; - this._secondary = secondary; - this._rawKv = rawKv; - this._log = logger; - /** @type {"dual"} */ - this._kind = "dual"; - /** @type {string} */ - this.tablePrefix = primary.tablePrefix ?? ""; - } - - /** - * Execute a write statement against both stores. Throw on primary failure; - * log + enqueue on secondary failure. - * - * @param {string} query - * @param {any[]} binds - * @returns {Promise} - */ - async run(query, ...binds) { - const [primaryResult, secondaryResult] = await Promise.allSettled([ - this._primary.run(query, ...binds), - this._secondary.run(query, ...binds), - ]); - - if (secondaryResult.status === "rejected") { - const err = secondaryResult.reason; - this._log.warn("[dual-sql] secondary run failed", { - phase: "dual-sql", - op: "run", - // Log query shape only; never log bind values (PII risk). - query: query.slice(0, 80), - errClass: err instanceof Error ? err.constructor.name : "unknown", - err: err instanceof Error ? err.message : String(err), - }); - await enqueueRetry(this._rawKv, { op: "run", query, binds }); - } - - if (primaryResult.status === "rejected") { - throw primaryResult.reason; - } - - return primaryResult.value; - } - - /** - * Execute a SELECT and return all matching rows — primary only. - * - * @param {string} query - * @param {...any} binds - * @returns {Promise} - */ - async all(query, ...binds) { - return this._primary.all(query, ...binds); - } - - /** - * Execute a SELECT and return the first row — primary only. - * - * @param {string} query - * @param {...any} binds - * @returns {Promise} - */ - async first(query, ...binds) { - return this._primary.first(query, ...binds); - } - - /** - * Returns a prepared statement from the primary store only. - * CF-specific; cannot be forwarded to MongoDB. - * - * @param {string} query - * @param {...any} binds - * @returns {any} - */ - prepare(query, ...binds) { - return this._primary.prepare(query, ...binds); - } - - /** - * Execute multiple prepared statements — primary only. - * - * @param {any[]} statements - * @returns {Promise} - */ - async batch(statements) { - return this._primary.batch(statements); - } -} diff --git a/src/db/mongo-client.js b/src/db/mongo-client.js deleted file mode 100644 index ac8ca98..0000000 --- a/src/db/mongo-client.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @file mongo-client — memoized MongoDB Atlas client singleton. - * - * Exports `getDb(env)` for all Mongo-backed stores. On the first call from a - * cold isolate it opens one connection; subsequent calls reuse the same - * `MongoClient`. If `client.connect()` rejects, both `client` and - * `connectPromise` are nulled so the next request retries cleanly instead of - * reusing a dead client reference. - * - * `MongoServerSelectionError` (e.g. paused M0 cluster) is caught, logged with - * an actionable message, then rethrown so the caller can map it to 503. - * - * @module db/mongo-client - */ - -import { MongoClient } from "mongodb"; - -/** @type {MongoClient|null} */ -let client = null; - -/** @type {Promise|null} */ -let connectPromise = null; - -/** - * Return the memoized Db instance, connecting on the first call. - * - * Connection options match the Cloudflare Workers constraints: - * maxPoolSize: 1 — one connection per isolate - * minPoolSize: 0 — no idle keepalive (Workers tear down quickly) - * serverSelectionTimeoutMS: 5000 — fast fail for paused M0 - * connectTimeoutMS: 10000 — TLS + SCRAM on cold start - * - * @param {{ MONGODB_URI: string }} env — Cloudflare Worker env (or test double). - * @returns {Promise} - * @throws {import("mongodb").MongoServerSelectionError} if cluster unreachable. - */ -export async function getDb(env) { - if (client) return client.db("miti99bot"); - - if (!connectPromise) { - client = new MongoClient(env.MONGODB_URI, { - maxPoolSize: 1, - minPoolSize: 0, - serverSelectionTimeoutMS: 5000, - connectTimeoutMS: 10000, - }); - - // On rejection: null BOTH so the next getDb() call retries with a fresh - // client instead of awaiting the already-rejected promise (code-reviewer #16). - connectPromise = client.connect().catch((err) => { - client = null; - connectPromise = null; - throw err; - }); - } - - try { - await connectPromise; - } catch (err) { - // M0 clusters auto-pause after 60 days of inactivity. Surface an - // actionable note so ops can identify and resume the cluster. - // NOTE: URI is deliberately omitted to prevent credential leaks. - if (err?.name === "MongoServerSelectionError") { - console.warn( - JSON.stringify({ - event: "mongo_server_selection_failed", - note: "M0 may be paused — resume the cluster in Atlas, then retry. Caller should map to 503.", - }), - ); - } - throw err; - } - - return client.db("miti99bot"); -} - -/** - * Close the active MongoClient and reset module-scope state. - * Intended for test teardown and graceful shutdown only — not for - * use in production request handlers. - * - * @returns {Promise} - */ -export async function closeMongo() { - if (client) { - await client.close(); - client = null; - } - connectPromise = null; -} diff --git a/src/db/mongo-kv-store.js b/src/db/mongo-kv-store.js deleted file mode 100644 index 9480815..0000000 --- a/src/db/mongo-kv-store.js +++ /dev/null @@ -1,178 +0,0 @@ -/** - * @file mongo-kv-store — MongoDB Atlas implementation of the KVStore interface. - * - * Behavioral parity with `CFKVStore`, with one documented divergence: - * - CFKVStore TTL is enforced server-side (eventual, ~1s granularity). - * - MongoKVStore also enforces TTL at read-time via an `expiresAt` filter, - * eliminating the up-to-60s Atlas TTL-sweeper stale-read window. - * - * Per-module collections: module name with `-` replaced by `_` - * (e.g. `loldle-emoji` → `loldle_emoji`). - * - * `list()` returns keys WITH the module prefix preserved — the wrapper in - * `create-store.js:65` strips it. MongoKVStore never strips prefixes. - * - * @see ./kv-store-interface.js for the full interface contract. - * @module db/mongo-kv-store - */ - -import { getDb } from "./mongo-client.js"; -import { listWithCursor } from "./mongo-list-cursor.js"; - -/** - * @typedef {import("./kv-store-interface.js").KVStore} KVStore - * @typedef {import("./kv-store-interface.js").KVStorePutOptions} KVStorePutOptions - * @typedef {import("./kv-store-interface.js").KVStoreListOptions} KVStoreListOptions - * @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult - */ - -/** - * Tracks which collections have already had the TTL index created this - * isolate lifetime, to avoid redundant `createIndex` round-trips. - * - * @type {Set} - */ -const indexedCollections = new Set(); - -/** - * @implements {KVStore} - */ -export class MongoKVStore { - /** - * @param {{ MONGODB_URI: string }} env — Worker env (or test double). - * @param {string} collectionName — module name (e.g. "wordle", "loldle-emoji"). - * @param {import("mongodb").Db} [dbOverride] — injected Db for tests; bypasses real connect. - */ - constructor(env, collectionName, dbOverride) { - if (!collectionName) throw new Error("MongoKVStore: collectionName is required"); - this._env = env; - // Normalize collection name: replace `-` with `_` for MongoDB compatibility. - this._collName = collectionName.replace(/-/g, "_"); - this._dbOverride = dbOverride ?? null; - } - - /** - * Resolve the Db instance (override for tests, real for prod). - * - * @returns {Promise} - */ - async _db() { - return this._dbOverride ?? getDb(this._env); - } - - /** - * Lazily create the TTL index once per collection per isolate. - * Idempotent on the MongoDB side; tracked locally to avoid extra round-trips. - * - * @returns {Promise} - */ - async _ensureIndex() { - if (indexedCollections.has(this._collName)) return; - const db = await this._db(); - await db - .collection(this._collName) - .createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0, sparse: true }); - indexedCollections.add(this._collName); - } - - /** - * Build the read-time TTL filter: accept docs with no `expiresAt` field, - * OR docs whose `expiresAt` is in the future. - * - * @param {string} key - * @returns {object} MongoDB filter - */ - _liveFilter(key) { - return { - _id: key, - $or: [{ expiresAt: { $exists: false } }, { expiresAt: { $gt: new Date() } }], - }; - } - - /** - * @param {string} key - * @returns {Promise} - */ - async get(key) { - await this._ensureIndex(); - const db = await this._db(); - const doc = await db.collection(this._collName).findOne(this._liveFilter(key)); - return doc ? doc.value : null; - } - - /** - * @param {string} key - * @param {string} value - * @param {KVStorePutOptions} [opts] - * @returns {Promise} - */ - async put(key, value, opts) { - await this._ensureIndex(); - const db = await this._db(); - const $set = { value }; - const $unset = {}; - - if (opts?.expirationTtl) { - $set.expiresAt = new Date(Date.now() + opts.expirationTtl * 1000); - } else { - // Clear any existing TTL so the document becomes permanent. - $unset.expiresAt = ""; - } - - await db.collection(this._collName).updateOne({ _id: key }, { $set, $unset }, { upsert: true }); - } - - /** - * @param {string} key - * @returns {Promise} - */ - async delete(key) { - await this._ensureIndex(); - const db = await this._db(); - await db.collection(this._collName).deleteOne({ _id: key }); - } - - /** - * List keys matching an optional prefix, with cursor-based pagination. - * Keys are returned WITH the full module prefix preserved — the wrapper in - * `create-store.js` strips it for callers. - * - * @param {KVStoreListOptions} [opts] - * @returns {Promise} - */ - async list(opts = {}) { - await this._ensureIndex(); - const db = await this._db(); - return listWithCursor(db.collection(this._collName), opts); - } - - /** - * @param {string} key - * @returns {Promise} - */ - async getJSON(key) { - const raw = await this.get(key); - if (raw == null) return null; - try { - return JSON.parse(raw); - } catch (err) { - console.warn("getJSON: parse failed", { key, err: String(err) }); - return null; - } - } - - /** - * @param {string} key - * @param {any} value - * @param {KVStorePutOptions} [opts] - * @returns {Promise} - */ - async putJSON(key, value, opts) { - if (value === undefined) { - throw new Error(`putJSON: value for key "${key}" is undefined`); - } - // JSON.stringify throws on cycles — let it propagate. - const serialized = JSON.stringify(value); - await this.put(key, serialized, opts); - } -} diff --git a/src/db/mongo-list-cursor.js b/src/db/mongo-list-cursor.js deleted file mode 100644 index b0fb62e..0000000 --- a/src/db/mongo-list-cursor.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * @file mongo-list-cursor — cursor-based list pagination helper for MongoKVStore. - * - * Extracted to keep mongo-kv-store.js under 200 LOC. - * Encodes the last `_id` of a page as a base64 cursor; decodes it on the - * next call to build a `$gt` filter. Does NOT use skip() — purely sorted-_id - * pagination to avoid O(n) offset scans. - * - * @module db/mongo-list-cursor - */ - -/** - * @typedef {import("./kv-store-interface.js").KVStoreListOptions} KVStoreListOptions - * @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult - */ - -/** - * Escape special RegExp characters so a prefix string is safe inside a - * MongoDB `$regex` filter. - * - * @param {string} str - * @returns {string} - */ -export function escapeRegex(str) { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -/** - * Execute a paginated list query on a MongoDB collection. - * - * @param {import("mongodb").Collection} col — resolved collection handle. - * @param {KVStoreListOptions} opts - * @returns {Promise} - */ -export async function listWithCursor(col, opts = {}) { - const { prefix = "", limit = 1000, cursor } = opts; - const pageSize = limit; - - const filter = {}; - if (prefix) { - filter._id = { $regex: `^${escapeRegex(prefix)}` }; - } - if (cursor) { - const lastId = Buffer.from(cursor, "base64").toString("utf8"); - filter._id = filter._id ? { ...filter._id, $gt: lastId } : { $gt: lastId }; - } - - const docs = await col - .find(filter) - .sort({ _id: 1 }) - .limit(pageSize + 1) - .project({ _id: 1 }) - .toArray(); - - const hasMore = docs.length > pageSize; - const page = hasMore ? docs.slice(0, pageSize) : docs; - const keys = page.map((d) => d._id); - const nextCursor = hasMore - ? Buffer.from(page[page.length - 1]._id, "utf8").toString("base64") - : undefined; - - return { keys, cursor: nextCursor, done: !hasMore }; -} diff --git a/src/db/mongo-sql-store.js b/src/db/mongo-sql-store.js deleted file mode 100644 index bb06151..0000000 --- a/src/db/mongo-sql-store.js +++ /dev/null @@ -1,144 +0,0 @@ -/** - * @file mongo-sql-store — thin SqlStore shim wrapping MongoTradesStore. - * - * PURPOSE (intentionally limited): - * This shim exists ONLY for `create-sql-store.js` factory branching and - * `tests/db/create-sql-store.test.js` contract compliance. The trading - * module is being refactored to call MongoTradesStore directly (Phase 03). - * The shim's `run`/`all`/`first` translate the 6 known trading SQL patterns - * for any caller that still routes through the SqlStore interface; everything - * else throws immediately — no silent fallthrough. - * - * `prepare` and `batch` are unsupported: trading code never calls them, and - * the dual-write layer (Phase 04) uses MongoTradesStore directly. - * - * @module db/mongo-sql-store - */ - -import { MongoTradesStore } from "./mongo-trades-store.js"; - -/** - * @typedef {import("./sql-store-interface.js").SqlStore} SqlStore - * @typedef {import("./sql-store-interface.js").SqlRunResult} SqlRunResult - */ - -/** - * @implements {SqlStore} - */ -export class MongoSqlStore { - /** - * @param {{ MONGODB_URI: string }} env — Worker env (or test double). - * @param {string} moduleName — used to derive `tablePrefix`. - * @param {MongoTradesStore} [tradesStoreOverride] — injected store for tests. - */ - constructor(env, moduleName, tradesStoreOverride) { - /** @type {string} */ - this.tablePrefix = `${moduleName}_`; - this._store = tradesStoreOverride ?? new MongoTradesStore(env); - } - - /** - * Execute a write statement by delegating to MongoTradesStore. - * Recognises INSERT INTO trading_trades only. - * Returns `{ changes: 1, last_row_id: 0 }` — last_row_id is a NUMBER (not hex). - * - * @param {string} query - * @param {...any} binds [userId, symbol, side, qty, priceVnd, ts] - * @returns {Promise} - */ - async run(query, ...binds) { - const q = query.replace(/\s+/g, " ").trim(); - if (/INSERT\s+INTO\s+trading_trades\b/i.test(q)) { - // Bind order mirrors history.js: (user_id, symbol, side, qty, price_vnd, ts) - const [userId, symbol, side, qty, priceVnd, ts] = binds; - return this._store.insert({ userId, symbol, side, qty, priceVnd, ts }); - } - // DELETE WHERE id IN (...) — from retention.js buildDeleteByIds - if (/DELETE\s+FROM\s+trading_trades\s+WHERE\s+id\s+IN\b/i.test(q)) { - const ids = binds; - await this._store.deleteByIds(ids); - return { changes: ids.length, last_row_id: 0 }; - } - throw new Error( - "MongoSqlStore: unsupported query — refactor caller to use MongoTradesStore directly", - ); - } - - /** - * Execute a read query by delegating to MongoTradesStore. - * Recognises: - * - SELECT … FROM trading_trades WHERE user_id = ? ORDER BY ts DESC LIMIT ? - * - SELECT DISTINCT user_id FROM trading_trades - * - SELECT id FROM trading_trades WHERE user_id = ? ORDER BY ts DESC … OFFSET ? - * - SELECT id FROM trading_trades ORDER BY ts DESC … OFFSET ? - * - * @param {string} query - * @param {...any} binds - * @returns {Promise} - */ - async all(query, ...binds) { - const q = query.replace(/\s+/g, " ").trim(); - - // SELECT DISTINCT user_id - if (/SELECT\s+DISTINCT\s+user_id\b/i.test(q)) { - const ids = await this._store.distinctUsers(); - return ids.map((id) => ({ user_id: id })); - } - - // SELECT id FROM trading_trades WHERE user_id = ? ORDER BY ts DESC … OFFSET ? - if ( - /SELECT\s+id\s+FROM\s+trading_trades\s+WHERE\s+user_id\s*=\s*\?/i.test(q) && - /OFFSET\s+\?/i.test(q) - ) { - const [userId, keepN] = binds; - return this._store.oldRowsForUser(userId, keepN); - } - - // SELECT id FROM trading_trades ORDER BY ts DESC … OFFSET ? - if (/SELECT\s+id\s+FROM\s+trading_trades\b/i.test(q) && /OFFSET\s+\?/i.test(q)) { - const [keepN] = binds; - return this._store.oldRows(keepN); - } - - // SELECT … FROM trading_trades WHERE user_id = ? … LIMIT ? (history query) - if ( - /SELECT\b.*\bFROM\s+trading_trades\s+WHERE\s+user_id\s*=\s*\?/i.test(q) && - /LIMIT\s+\?/i.test(q) - ) { - const [userId, limit] = binds; - return this._store.byUser(userId, limit); - } - - throw new Error( - "MongoSqlStore: unsupported query — refactor caller to use MongoTradesStore directly", - ); - } - - /** - * Execute a read query and return the first row, or null. - * - * @param {string} query - * @param {...any} binds - * @returns {Promise} - */ - async first(query, ...binds) { - const rows = await this.all(query, ...binds); - return rows[0] ?? null; - } - - /** - * Not supported — trading code does not use prepared statements via MongoSqlStore. - * @throws {Error} - */ - prepare() { - throw new Error("unsupported in MongoSqlStore"); - } - - /** - * Not supported — trading code does not use batch via MongoSqlStore. - * @throws {Error} - */ - batch() { - throw new Error("unsupported in MongoSqlStore"); - } -} diff --git a/src/db/mongo-trades-store.js b/src/db/mongo-trades-store.js deleted file mode 100644 index a31702f..0000000 --- a/src/db/mongo-trades-store.js +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @file mongo-trades-store — MongoDB implementation of trade persistence. - * - * Wraps the `trading_trades` collection. Each document shape: - * { _id: ObjectId, legacy_id: number|null, user_id, symbol, side, qty, price_vnd, ts } - * - * `legacy_id` is set to null for new runtime trades. During Phase 05 backfill - * it will be populated with the original D1 autoincrement row id. - * - * @module db/mongo-trades-store - */ - -import { ObjectId } from "mongodb"; -import { getDb } from "./mongo-client.js"; - -/** @typedef {import("../types.js").Trade} Trade */ - -const COLLECTION = "trading_trades"; - -/** - * Tracks which collections have had their indexes created this isolate lifetime. - * @type {Set} - */ -const indexedCollections = new Set(); - -/** - * @implements {object} Direct trade persistence — no SQL strings. - */ -export class MongoTradesStore { - /** - * @param {{ MONGODB_URI: string }} env — Worker env (or test double). - * @param {import("mongodb").Db} [dbOverride] — injected Db for tests; bypasses real connect. - */ - constructor(env, dbOverride) { - this._env = env; - this._dbOverride = dbOverride ?? null; - } - - /** @returns {Promise} */ - async _db() { - return this._dbOverride ?? getDb(this._env); - } - - /** Lazily create the three required indexes once per isolate. */ - async _ensureIndexes() { - if (indexedCollections.has(COLLECTION)) return; - const db = await this._db(); - const coll = db.collection(COLLECTION); - await coll.createIndex({ user_id: 1, ts: -1 }); - await coll.createIndex({ ts: -1 }); - await coll.createIndex({ legacy_id: 1 }, { sparse: true }); - indexedCollections.add(COLLECTION); - } - - /** - * Insert a new trade document. Sets `legacy_id: null` (no D1 row id for - * runtime-created trades). Returns SqlRunResult-compatible shape. - * - * @param {{ userId: number, symbol: string, side: "buy"|"sell", qty: number, priceVnd: number, ts?: number }} trade - * @returns {Promise<{ changes: number, last_row_id: number }>} - */ - async insert(trade) { - await this._ensureIndexes(); - const db = await this._db(); - await db.collection(COLLECTION).insertOne({ - _id: new ObjectId(), - legacy_id: null, - user_id: trade.userId, - symbol: trade.symbol, - side: trade.side, - qty: trade.qty, - price_vnd: trade.priceVnd, - ts: trade.ts ?? Date.now(), - }); - return { changes: 1, last_row_id: 0 }; - } - - /** - * Fetch the N most recent trades for a user, newest first. - * - * @param {number} userId - * @param {number} limit - * @returns {Promise} - */ - async byUser(userId, limit) { - await this._ensureIndexes(); - const db = await this._db(); - const docs = await db - .collection(COLLECTION) - .find({ user_id: userId }) - .sort({ ts: -1 }) - .limit(limit) - .toArray(); - return docs.map((d) => ({ - id: d._id, - userId: d.user_id, - symbol: d.symbol, - side: d.side, - qty: d.qty, - priceVnd: d.price_vnd, - ts: d.ts, - })); - } - - /** @returns {Promise} all distinct user_id values. */ - async distinctUsers() { - await this._ensureIndexes(); - const db = await this._db(); - return db.collection(COLLECTION).distinct("user_id"); - } - - /** - * @param {number} userId - * @param {number} keepN - * @returns {Promise} _id values of rows beyond keepN newest for user. - */ - async oldRowsForUser(userId, keepN) { - await this._ensureIndexes(); - const db = await this._db(); - const docs = await db - .collection(COLLECTION) - .find({ user_id: userId }) - .sort({ ts: -1 }) - .skip(keepN) - .project({ _id: 1 }) - .toArray(); - return docs.map((d) => d._id); - } - - /** - * @param {number} keepN - * @returns {Promise} _id values of rows beyond keepN newest globally. - */ - async oldRows(keepN) { - await this._ensureIndexes(); - const db = await this._db(); - const docs = await db - .collection(COLLECTION) - .find({}) - .sort({ ts: -1 }) - .skip(keepN) - .project({ _id: 1 }) - .toArray(); - return docs.map((d) => d._id); - } - - /** - * @param {import("mongodb").ObjectId[]} ids - * @returns {Promise<{ deletedCount: number }>} - */ - async deleteByIds(ids) { - if (ids.length === 0) return { deletedCount: 0 }; - await this._ensureIndexes(); - const db = await this._db(); - return db.collection(COLLECTION).deleteMany({ _id: { $in: ids } }); - } -} diff --git a/src/index.js b/src/index.js index a72342d..fa92ea4 100644 --- a/src/index.js +++ b/src/index.js @@ -15,8 +15,6 @@ import { webhookCallback } from "grammy"; import { getBot, getRegistry } from "./bot.js"; import { dispatchScheduled } from "./modules/cron-dispatcher.js"; -import { setLastCold } from "./util/request-context.js"; -import { takeColdFlag } from "./util/timing.js"; /** @type {ReturnType | null} */ let cachedWebhookHandler = null; @@ -61,28 +59,10 @@ export default { * @param {any} _ctx */ async fetch(request, env, _ctx) { - // Capture cold-start flag first — must be before any await. - // Stored in shared request-context module so dispatcher middleware can read it - // without a circular import (index → bot → dispatcher → index). - const coldMeta = takeColdFlag(); - setLastCold(coldMeta); - const start = Date.now(); const { pathname } = new URL(request.url); const method = request.method; - // Per-request cold-start log for CF Observability soak analysis. - console.log( - JSON.stringify({ - event: "request", - method, - path: pathname, - cold: coldMeta.cold, - isolateAgeMs: coldMeta.isolateAgeMs, - ts: start, - }), - ); - const response = await route(request, env, pathname); // Structured request log for Workers Observability dashboard. diff --git a/src/modules/cron-dispatcher.js b/src/modules/cron-dispatcher.js index d6b0aa6..01cc13c 100644 --- a/src/modules/cron-dispatcher.js +++ b/src/modules/cron-dispatcher.js @@ -4,28 +4,15 @@ * * Design: * - Iterates registry.crons, filters by event.cron === entry.schedule. - * - Also checks system-level crons (drift-verifier) which are not part of - * any module but still need to run on schedule. * - Wraps each handler invocation in try/catch so one failure cannot block * others (equivalent to Promise.allSettled fan-out via ctx.waitUntil). * - ctx.waitUntil is fire-and-forget from Workers' perspective; we wrap in * an async IIFE so errors are caught and logged rather than silently lost. */ -import { driftVerifierCron } from "../cron/drift-verifier.js"; import { createSqlStore } from "../db/create-sql-store.js"; import { createStore } from "../db/create-store.js"; -/** - * System-level cron entries that run alongside module crons. - * These are not part of any module — they are registered here directly - * so that `registry.crons` (which existing tests assert exact lengths on) - * remains a pure collection of module-declared crons. - * - * @type {Array<{ schedule: string, name: string, handler: Function }>} - */ -const SYSTEM_CRONS = [driftVerifierCron]; - /** * @param {any} event — Cloudflare ScheduledEvent (has .cron string). * @param {any} env @@ -55,22 +42,4 @@ export function dispatchScheduled(event, env, ctx, registry) { })(), ); } - - // Dispatch system-level crons (not tied to any module). - for (const sys of SYSTEM_CRONS) { - if (sys.schedule !== event.cron) continue; - const systemCtx = { db: null, sql: null, env }; - ctx.waitUntil( - (async () => { - try { - await sys.handler(event, systemCtx); - } catch (err) { - console.error( - `[cron] system handler "${sys.name}" (schedule "${sys.schedule}") failed:`, - err, - ); - } - })(), - ); - } } diff --git a/src/modules/dispatcher.js b/src/modules/dispatcher.js index 6585b65..2af0eb5 100644 --- a/src/modules/dispatcher.js +++ b/src/modules/dispatcher.js @@ -7,21 +7,12 @@ * share a single bot.command() registration. Visibility only affects: * 1. What scripts/register.js pushes to Telegram's setMyCommands (public only). * 2. What phase-05's /help renderer shows (public + protected). - * - * Telemetry: a bot.use() middleware installed before command registration - * emits a structured `cmd_timing` JSON line via console.log for every - * command request. This preserves the original handler references so that - * the dispatcher test's strict-equality assertion (`handler === cmd.handler`) - * continues to pass. */ -import { getLastCold } from "../util/request-context.js"; -import { startTiming } from "../util/timing.js"; import { buildRegistry } from "./registry.js"; /** * Build the registry (if not already built) and register every command with grammY. - * Also installs a timing middleware (bot.use) that emits cmd_timing logs. * * @param {import("grammy").Bot} bot * @param {any} env @@ -30,37 +21,6 @@ import { buildRegistry } from "./registry.js"; export async function installDispatcher(bot, env) { const reg = await buildRegistry(env); - // Install timing middleware BEFORE command handlers so it wraps every command. - // Uses bot.use() to avoid wrapping individual handlers — keeping handler - // references identical to cmd.handler (required by dispatcher.test.js). - if (typeof bot.use === "function") { - bot.use(async (ctx, next) => { - // Only time actual bot_command messages; pass other updates through untimed. - const entities = ctx.message?.entities ?? ctx.channelPost?.entities ?? []; - const isBotCommand = entities.some((e) => e.type === "bot_command" && e.offset === 0); - - if (!isBotCommand) { - return next(); - } - - // Extract command name without the leading slash for the timing label. - const rawText = ctx.message?.text ?? ctx.channelPost?.text ?? ""; - // Command is the first word (e.g. "/wordle" or "/wordle@botname"). - const cmdToken = rawText.split(" ")[0].split("@")[0]; - - const t = startTiming(cmdToken); - const { cold, isolateAgeMs } = getLastCold(); - - try { - await next(); - t.end({ cold, isolateAgeMs }); - } catch (err) { - t.end({ cold, isolateAgeMs, error: err instanceof Error ? err.message : String(err) }); - throw err; - } - }); - } - for (const { cmd } of reg.allCommands.values()) { // grammY's bot.command() matches /cmd and /cmd@botname, case-sensitively, // which naturally satisfies the "exact, case-sensitive" rule for private commands. diff --git a/src/modules/loldle-emoji/state.js b/src/modules/loldle-emoji/state.js index 78756eb..52adb5c 100644 --- a/src/modules/loldle-emoji/state.js +++ b/src/modules/loldle-emoji/state.js @@ -10,9 +10,10 @@ * keeps stats isolated per mode. */ -// Default round length. Admins can override per-subject via the hidden -// /loldle_emoji_setmax command (bounded by MAX_GUESSES_CAP). -const MAX_GUESSES = 5; +// Default tightened from 5 → 4: three emojis are a strong signal, so 4 keeps +// tension without being unfair. Admins can override per-subject via the +// hidden /loldle_emoji_setmax command (bounded by MAX_GUESSES_CAP). +const MAX_GUESSES = 4; const MAX_GUESSES_CAP = 10; const GAME_TTL_SECONDS = 60 * 60 * 24 * 7; diff --git a/src/modules/loldle/README.md b/src/modules/loldle/README.md index 5b36a09..e3e470a 100644 --- a/src/modules/loldle/README.md +++ b/src/modules/loldle/README.md @@ -1,6 +1,6 @@ # Loldle Module -Classic-mode League of Legends champion guessing game. Players get 8 guesses +Classic-mode League of Legends champion guessing game. Players get 6 guesses (default; per-subject override via the hidden `/loldle_setmax` command, capped at 10) to identify a hidden champion; each guess is compared across 7 attributes and the board is rendered as a monospace Telegram table. @@ -47,7 +47,7 @@ empty board gives no hints, so it shouldn't count against the clock. - `lookup.js` — normalizes user input to a champion record. - `render.js` — Telegram HTML `
` monospace table with auto-widthed
   label column.
-- `state.js` — KV persistence (`MAX_GUESSES = 8` default, `MAX_GUESSES_CAP = 10`,
+- `state.js` — KV persistence (`MAX_GUESSES = 6` default, `MAX_GUESSES_CAP = 10`,
   per-subject stats and config override).
 - `handlers.js` — subject resolution (user id in DMs, chat id in groups) +
   command flow.
diff --git a/src/modules/loldle/state.js b/src/modules/loldle/state.js
index f457d54..ad5cdc9 100644
--- a/src/modules/loldle/state.js
+++ b/src/modules/loldle/state.js
@@ -10,9 +10,10 @@
  * at render time, so the board always reflects the live champions.json.
  */
 
-// Default round length. Admins can override per-subject via the hidden
-// /loldle_setmax command (bounded by MAX_GUESSES_CAP).
-const MAX_GUESSES = 8;
+// Default round length. The 7-axis grid leaks enough info per guess that 6
+// is a fair target for typical play; admins can override per-subject via the
+// hidden /loldle_setmax command (bounded by MAX_GUESSES_CAP).
+const MAX_GUESSES = 6;
 const MAX_GUESSES_CAP = 10;
 // Upper bound for a round — long enough for any real session, short enough
 // that stale KV entries get reclaimed automatically.
diff --git a/src/modules/registry.js b/src/modules/registry.js
index 2d14dce..07d78a4 100644
--- a/src/modules/registry.js
+++ b/src/modules/registry.js
@@ -14,34 +14,10 @@
 
 import { createSqlStore } from "../db/create-sql-store.js";
 import { createStore } from "../db/create-store.js";
-import { MongoTradesStore } from "../db/mongo-trades-store.js";
 import { moduleRegistry as defaultModuleRegistry } from "./index.js";
 import { validateCommand } from "./validate-command.js";
 import { validateCron } from "./validate-cron.js";
 
-/** Sentinel value — when MONGODB_URI equals this, Mongo is disabled. */
-const STUB_SENTINEL = "__stub_mongo__";
-
-/**
- * Determine whether a MongoTradesStore should be passed to the trading module.
- * Returns a new MongoTradesStore when Atlas is in play (real URI, not sentinel).
- * Returns null when only D1 is active.
- *
- * @param {any} env
- * @returns {MongoTradesStore | null}
- */
-function buildTradesStore(env) {
-  const uri = env.MONGODB_URI;
-  if (!uri || uri === STUB_SENTINEL) return null;
-  const primary = (env.STORAGE_PRIMARY ?? "kv").toLowerCase();
-  const dualWrite = (env.DUAL_WRITE ?? "1") !== "0";
-  // Wire MongoTradesStore whenever Mongo is involved (dual or mongo-primary).
-  if (dualWrite || primary === "mongo") {
-    return new MongoTradesStore(env);
-  }
-  return null;
-}
-
 /**
  * @typedef {import("./validate-command.js").ModuleCommand} ModuleCommand
  *
@@ -179,18 +155,7 @@ export async function buildRegistry(env, importMap) {
   for (const mod of modules) {
     if (typeof mod.init === "function") {
       try {
-        const initCtx = {
-          db: createStore(mod.name, env),
-          sql: createSqlStore(mod.name, env),
-          env,
-        };
-        // Wire MongoTradesStore for the trading module when Atlas is in play.
-        // The trading module already accepts optional tradesStore (Phase 03
-        // backwards-compat). Other modules receive undefined and ignore it.
-        if (mod.name === "trading") {
-          initCtx.tradesStore = buildTradesStore(env);
-        }
-        await mod.init(initCtx);
+        await mod.init({ db: createStore(mod.name, env), sql: createSqlStore(mod.name, env), env });
       } catch (err) {
         throw new Error(
           `module "${mod.name}" init failed: ${err instanceof Error ? err.message : String(err)}`,
diff --git a/src/modules/trading/history.js b/src/modules/trading/history.js
index db36026..41c80be 100644
--- a/src/modules/trading/history.js
+++ b/src/modules/trading/history.js
@@ -1,51 +1,31 @@
 /**
- * @file history — Trade record + /history command for the trading module.
+ * @file history — D1-backed trade record + /history command for the trading module.
  *
  * Exports:
- *   recordTrade(sql, opts, tradesStore?)  — fire-and-forget insert; logs + swallows on failure.
- *   listTrades(sql, userId, limit, tradesStore?) — newest-first query; returns [] when both null.
- *   formatTradesHtml(trades)              — HTML-escaped compact list for Telegram HTML mode.
- *   createHistoryHandler(sql, tradesStore?) — grammY command handler factory.
- *
- * Backwards-compatibility: when `tradesStore` is provided (Phase 04+), it is
- * used exclusively. When absent the function falls back to the `sql` D1 path
- * so the module works unchanged until the factory wires in MongoTradesStore.
+ *   recordTrade(sql, opts)   — fire-and-forget insert; logs + swallows on failure.
+ *   listTrades(sql, userId, limit) — newest-first query; returns [] when sql null.
+ *   formatTradesHtml(trades) — HTML-escaped compact list for Telegram HTML mode.
+ *   createHistoryHandler(sql) — grammY command handler factory.
  */
 
 import { escapeHtml } from "../../util/escape-html.js";
 
 /** @typedef {import("../../types.js").Trade} Trade */
 /** @typedef {import("../../db/sql-store-interface.js").SqlStore} SqlStore */
-/** @typedef {import("../../db/mongo-trades-store.js").MongoTradesStore} MongoTradesStore */
 
 const TABLE = "trading_trades";
 const DEFAULT_LIMIT = 10;
 const MAX_LIMIT = 50;
 
 /**
- * Insert a trade row.
- *
- * Uses `tradesStore` when provided, otherwise falls back to `sql` (D1 path).
- * Silently skips when both are null. Failure is logged but never re-thrown —
- * portfolio KV is source of truth.
+ * Insert a trade row. Silently skips when sql is null (no D1 binding).
+ * Failure is logged but never re-thrown — portfolio KV is source of truth.
  *
  * @param {SqlStore | null} sql
  * @param {{ userId: number, symbol: string, side: "buy"|"sell", qty: number, priceVnd: number }} opts
- * @param {MongoTradesStore | null} [tradesStore]
  * @returns {Promise}
  */
-export async function recordTrade(sql, { userId, symbol, side, qty, priceVnd }, tradesStore) {
-  // Mongo path (Phase 04+).
-  if (tradesStore != null) {
-    try {
-      await tradesStore.insert({ userId, symbol, side, qty, priceVnd });
-    } catch (err) {
-      console.error("[trading/history] recordTrade (mongo) failed:", err);
-    }
-    return;
-  }
-
-  // D1 fallback path.
+export async function recordTrade(sql, { userId, symbol, side, qty, priceVnd }) {
   if (sql === null) {
     console.warn("[trading/history] recordTrade skipped — no D1 binding");
     return;
@@ -67,26 +47,16 @@ export async function recordTrade(sql, { userId, symbol, side, qty, priceVnd },
 
 /**
  * Fetch the most recent trades for a user, newest first.
- *
- * Uses `tradesStore` when provided, otherwise falls back to `sql` (D1 path).
- * Returns [] when both are null or the table is empty.
+ * Returns [] when sql is null or the table is empty.
  *
  * @param {SqlStore | null} sql
  * @param {number} userId
  * @param {number} limit — clamped to [1, 50].
- * @param {MongoTradesStore | null} [tradesStore]
  * @returns {Promise}
  */
-export async function listTrades(sql, userId, limit, tradesStore) {
-  const n = Math.max(1, Math.min(MAX_LIMIT, limit));
-
-  // Mongo path (Phase 04+).
-  if (tradesStore != null) {
-    return tradesStore.byUser(userId, n);
-  }
-
-  // D1 fallback path.
+export async function listTrades(sql, userId, limit) {
   if (sql === null) return [];
+  const n = Math.max(1, Math.min(MAX_LIMIT, limit));
   const rows = await sql.all(
     `SELECT id, user_id, symbol, side, qty, price_vnd, ts FROM ${TABLE} WHERE user_id = ? ORDER BY ts DESC LIMIT ?`,
     userId,
@@ -133,10 +103,9 @@ export function formatTradesHtml(trades) {
  * Replies with HTML trade list.
  *
  * @param {SqlStore | null} sql
- * @param {MongoTradesStore | null} [tradesStore]
  * @returns {(ctx: any) => Promise}
  */
-export function createHistoryHandler(sql, tradesStore) {
+export function createHistoryHandler(sql) {
   return async (ctx) => {
     const userId = ctx.from?.id;
     if (!userId) return ctx.reply("Could not identify user.");
@@ -145,7 +114,7 @@ export function createHistoryHandler(sql, tradesStore) {
     // Invalid / zero / negative → default; > MAX_LIMIT → clamp inside listTrades.
     const n = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LIMIT;
 
-    const trades = await listTrades(sql, userId, n, tradesStore);
+    const trades = await listTrades(sql, userId, n);
     const html = formatTradesHtml(trades);
     await ctx.reply(html, { parse_mode: "HTML" });
   };
diff --git a/src/modules/trading/index.js b/src/modules/trading/index.js
index 89e9d32..fa0ff20 100644
--- a/src/modules/trading/index.js
+++ b/src/modules/trading/index.js
@@ -14,31 +14,23 @@ let db = null;
 /** @type {import("../../db/sql-store-interface.js").SqlStore | null} */
 let sql = null;
 
-/** @type {import("../../db/mongo-trades-store.js").MongoTradesStore | null} */
-let tradesStore = null;
-
 /**
- * Build an onTrade callback bound to the current stores and userId.
+ * Build an onTrade callback bound to the current sql store and userId.
  *
  * @param {number} userId
  * @returns {(trade: {symbol:string, side:"buy"|"sell", qty:number, priceVnd:number}) => Promise}
  */
 function makeOnTrade(userId) {
   return ({ symbol, side, qty, priceVnd }) =>
-    recordTrade(sql, { userId, symbol, side, qty, priceVnd }, tradesStore);
+    recordTrade(sql, { userId, symbol, side, qty, priceVnd });
 }
 
 /** @type {import("../registry.js").BotModule} */
 const tradingModule = {
   name: "trading",
-  /**
-   * @param {{ db: import("../../db/kv-store-interface.js").KVStore, sql: import("../../db/sql-store-interface.js").SqlStore | null, tradesStore?: import("../../db/mongo-trades-store.js").MongoTradesStore | null, env: any }} ctx
-   */
-  init: async ({ db: store, sql: sqlStore, tradesStore: ts }) => {
+  init: async ({ db: store, sql: sqlStore }) => {
     db = store;
     sql = sqlStore ?? null;
-    // tradesStore is optional until Phase 04 wires it — fall back to sql path.
-    tradesStore = ts ?? null;
   },
   commands: [
     {
@@ -75,15 +67,15 @@ const tradingModule = {
       name: "history",
       visibility: "public",
       description: "Show your last N trades (default 10, max 50)",
-      // handler is created lazily so it picks up the stores set in init().
-      handler: (ctx) => createHistoryHandler(sql, tradesStore)(ctx),
+      // handler is created lazily so it picks up the sql value set in init().
+      handler: (ctx) => createHistoryHandler(sql)(ctx),
     },
   ],
   crons: [
     {
       schedule: "0 17 * * *",
       name: "trim-trades",
-      handler: (event, ctx) => trimTradesHandler(event, { ...ctx, tradesStore }),
+      handler: (event, ctx) => trimTradesHandler(event, ctx),
     },
   ],
 };
diff --git a/src/modules/trading/retention.js b/src/modules/trading/retention.js
index 8623f9a..12191e7 100644
--- a/src/modules/trading/retention.js
+++ b/src/modules/trading/retention.js
@@ -7,12 +7,10 @@
  *   2. Global FIFO pass: delete any rows beyond GLOBAL_CAP across all users
  *      (keeps the newest N rows globally).
  *
- * Backwards-compatibility: when `tradesStore` is present in ctx it is used
- * directly (Phase 04+). When absent the handler falls back to the D1 `sql`
- * path so it works unchanged until the factory wires in MongoTradesStore.
+ * Uses a hybrid SELECT-then-DELETE approach so it works with both the real
+ * Cloudflare D1 binding and the in-memory fake-d1 used in unit tests.
  *
  * @typedef {import("../../db/sql-store-interface.js").SqlStore} SqlStore
- * @typedef {import("../../db/mongo-trades-store.js").MongoTradesStore} MongoTradesStore
  */
 
 const TABLE = "trading_trades";
@@ -39,69 +37,16 @@ function buildDeleteByIds(ids) {
  * Daily cron handler — trims trading_trades to enforce per-user and global caps.
  *
  * @param {any} _event — Cloudflare ScheduledEvent (unused; present for handler contract).
- * @param {{ sql: SqlStore | null, tradesStore?: MongoTradesStore | null }} ctx
+ * @param {{ sql: SqlStore | null }} ctx
  * @param {{ perUserCap?: number, globalCap?: number }} [caps] — override caps (for tests).
  * @returns {Promise}
  */
-export async function trimTradesHandler(_event, { sql, tradesStore }, caps = {}) {
-  // Mongo path (Phase 04+).
-  if (tradesStore != null) {
-    return trimWithMongoStore(tradesStore, caps);
-  }
-
-  // D1 fallback path.
+export async function trimTradesHandler(_event, { sql }, caps = {}) {
   if (sql === null) {
     console.log("[trim-trades] no D1 binding — skipping");
     return;
   }
-  return trimWithSql(sql, caps);
-}
 
-/**
- * Trim using MongoTradesStore direct methods.
- *
- * @param {MongoTradesStore} store
- * @param {{ perUserCap?: number, globalCap?: number }} caps
- * @returns {Promise}
- */
-async function trimWithMongoStore(store, caps) {
-  const perUserCap = caps.perUserCap ?? PER_USER_CAP;
-  const globalCap = caps.globalCap ?? GLOBAL_CAP;
-
-  let perUserDeleted = 0;
-
-  // ── Pass 1: per-user trim ──────────────────────────────────────────────────
-  const userIds = await store.distinctUsers();
-
-  for (const userId of userIds) {
-    const oldIds = await store.oldRowsForUser(userId, perUserCap);
-    if (oldIds.length === 0) continue;
-    const result = await store.deleteByIds(oldIds);
-    perUserDeleted += result.deletedCount ?? oldIds.length;
-  }
-
-  console.log(`[trim-trades] per-user pass: deleted ${perUserDeleted} rows`);
-
-  // ── Pass 2: global FIFO trim ───────────────────────────────────────────────
-  const globalOldIds = await store.oldRows(globalCap);
-  let globalDeleted = 0;
-  if (globalOldIds.length > 0) {
-    const result = await store.deleteByIds(globalOldIds);
-    globalDeleted = result.deletedCount ?? globalOldIds.length;
-  }
-
-  console.log(`[trim-trades] global pass: deleted ${globalDeleted} rows`);
-  console.log(`[trim-trades] total deleted: ${perUserDeleted + globalDeleted} rows`);
-}
-
-/**
- * Trim using D1 SqlStore (legacy path).
- *
- * @param {SqlStore} sql
- * @param {{ perUserCap?: number, globalCap?: number }} caps
- * @returns {Promise}
- */
-async function trimWithSql(sql, caps) {
   const perUserCap = caps.perUserCap ?? PER_USER_CAP;
   const globalCap = caps.globalCap ?? GLOBAL_CAP;
 
diff --git a/src/util/request-context.js b/src/util/request-context.js
deleted file mode 100644
index 05d8a86..0000000
--- a/src/util/request-context.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * @file request-context — module-scoped request state shared between
- * the fetch entry point (index.js) and the dispatcher middleware.
- *
- * CF Workers isolates are single-threaded: one request at a time, so a
- * module-scope variable is a safe per-request stash without race conditions.
- *
- * Placing this in a separate module breaks the circular import that would
- * arise from dispatcher.js importing index.js directly.
- */
-
-/**
- * Cold-start metadata captured at the top of each fetch() call.
- * Written by index.js; read by dispatcher timing middleware.
- *
- * @type {{ cold: boolean, isolateAgeMs: number }}
- */
-let _lastCold = { cold: false, isolateAgeMs: 0 };
-
-/**
- * Store the cold-start metadata for the current request.
- * Called once per fetch() invocation, before any awaits.
- *
- * @param {{ cold: boolean, isolateAgeMs: number }} value
- */
-export function setLastCold(value) {
-  _lastCold = value;
-}
-
-/**
- * Retrieve the cold-start metadata for the current request.
- * Called by dispatcher timing middleware.
- *
- * @returns {{ cold: boolean, isolateAgeMs: number }}
- */
-export function getLastCold() {
-  return _lastCold;
-}
diff --git a/src/util/timing.js b/src/util/timing.js
deleted file mode 100644
index 9644994..0000000
--- a/src/util/timing.js
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * @file timing — per-request command timing for soak analysis.
- * Logs structured JSON via console.log; CF Observability captures it.
- *
- * Usage:
- *   const t = startTiming("/wordle");
- *   t.mark("mongo-read");
- *   t.end({ cold: true });
- *   // logs: {"event":"cmd_timing","cmd":"/wordle","total":45,"cold":true,"marks":[...]}
- */
-
-/**
- * Start a timing context for a command.
- *
- * @param {string} cmd - command name, e.g. "/wordle"
- * @returns {{ mark: (label: string) => void, end: (extra?: object) => void }}
- */
-export function startTiming(cmd) {
-  const t0 = Date.now();
-  const marks = []; // declared at top — fixes the snippet bug from the plan
-
-  return {
-    /**
-     * Record a named checkpoint relative to the start.
-     *
-     * @param {string} label
-     */
-    mark(label) {
-      marks.push({ label, dt: Date.now() - t0 });
-    },
-
-    /**
-     * Finalize and emit a structured timing log entry.
-     *
-     * @param {object} [extra={}] - additional key/value pairs merged into the log line
-     */
-    end(extra = {}) {
-      const total = Date.now() - t0;
-      console.log(JSON.stringify({ event: "cmd_timing", cmd, total, ...extra, marks }));
-    },
-  };
-}
-
-// ── Cold-start detection ─────────────────────────────────────────────────────
-// CF Worker isolates are single-threaded; one isolate handles one request at a
-// time. The first request in a fresh isolate is "cold". We use a boolean flag
-// (not isolate_age_ms < 200ms) because Mongo connect itself takes ~1500ms,
-// making age-based classification unreliable (code-reviewer #11).
-
-let _isFirst = true;
-const _isolateBorn = Date.now();
-
-/**
- * Returns cold-start metadata for the current request.
- *
- * First call in an isolate returns `{ cold: true, isolateAgeMs: ~0 }`.
- * Subsequent calls return `{ cold: false, isolateAgeMs:  }`.
- *
- * Call exactly once per incoming request (at the top of the fetch handler).
- *
- * @returns {{ cold: boolean, isolateAgeMs: number }}
- */
-export function takeColdFlag() {
-  const cold = _isFirst;
-  _isFirst = false;
-  return { cold, isolateAgeMs: Date.now() - _isolateBorn };
-}
diff --git a/tests/cron/drift-verifier.test.js b/tests/cron/drift-verifier.test.js
deleted file mode 100644
index eb01e48..0000000
--- a/tests/cron/drift-verifier.test.js
+++ /dev/null
@@ -1,177 +0,0 @@
-/**
- * @file drift-verifier.test.js — unit tests for the drift-verifier cron handler.
- *
- * Contracts verified:
- *   1. KV retry queue entries are drained (deleted on success, kept on failure).
- *   2. SQL retry queue entries are drained (always deleted — Phase 04 no-op path).
- *   3. Parity spot-check logs mismatches when CF KV and Mongo diverge.
- *   4. Parity spot-check logs success when values match.
- *   5. Skips Mongo calls when MONGODB_URI is absent or STUB_SENTINEL.
- *   6. Skips gracefully when env.KV is absent.
- */
-
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { driftVerifier } from "../../src/cron/drift-verifier.js";
-import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
-import { makeFakeMongo } from "../fakes/fake-mongo.js";
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-function makeEnv(overrides = {}) {
-  return {
-    KV: makeFakeKv(),
-    MODULES: "wordle,misc",
-    DRIFT_SAMPLE_N: "5",
-    ...overrides,
-  };
-}
-
-function makeCtx(env) {
-  return { db: null, sql: null, env };
-}
-
-// ---------------------------------------------------------------------------
-// Queue drain — KV retry
-// ---------------------------------------------------------------------------
-
-describe("drains KV retry queue", () => {
-  it("deletes queue entries when MONGODB_URI is absent (no retry possible)", async () => {
-    const env = makeEnv();
-    // Seed two retry entries.
-    await env.KV.put(
-      "__retry:mongo-failed:k1:abc",
-      JSON.stringify({ op: "put", key: "wordle:k1", ts: Date.now() }),
-    );
-    await env.KV.put(
-      "__retry:mongo-failed:k2:def",
-      JSON.stringify({ op: "delete", key: "wordle:k2", ts: Date.now() }),
-    );
-
-    const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
-    await driftVerifier({}, makeCtx(env));
-    consoleSpy.mockRestore();
-
-    // Queue entries may be deleted or kept depending on retry path.
-    // With no MONGODB_URI, retries log a warning but do not crash.
-    // The run completes without throwing.
-  });
-
-  it("does not throw when retry queue is empty", async () => {
-    const env = makeEnv();
-    await expect(driftVerifier({}, makeCtx(env))).resolves.not.toThrow();
-  });
-});
-
-// ---------------------------------------------------------------------------
-// Queue drain — SQL retry
-// ---------------------------------------------------------------------------
-
-describe("drains SQL retry queue", () => {
-  it("clears SQL retry queue entries (Phase 04 no-op path)", async () => {
-    const env = makeEnv();
-    await env.KV.put(
-      "__retry:mongo-sql-failed:INSERT_INTO_trading_trades:xyz",
-      JSON.stringify({ op: "run", query: "INSERT INTO trading_trades VALUES (?)", ts: Date.now() }),
-    );
-
-    await driftVerifier({}, makeCtx(env));
-
-    // After drain, the SQL entry should be deleted (no-op retry succeeds).
-    const listed = await env.KV.list({ prefix: "__retry:mongo-sql-failed:" });
-    expect(listed.keys).toHaveLength(0);
-  });
-});
-
-// ---------------------------------------------------------------------------
-// Parity spot-check — MONGODB_URI absent → skip Mongo calls
-// ---------------------------------------------------------------------------
-
-describe("skips Mongo parity check when MONGODB_URI absent", () => {
-  it("completes without error when no MONGODB_URI", async () => {
-    const env = makeEnv(); // no MONGODB_URI
-    const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
-    await expect(driftVerifier({}, makeCtx(env))).resolves.not.toThrow();
-    consoleSpy.mockRestore();
-  });
-
-  it("skips when MONGODB_URI is STUB_SENTINEL", async () => {
-    const env = makeEnv({ MONGODB_URI: "__stub_mongo__" });
-    const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
-    await expect(driftVerifier({}, makeCtx(env))).resolves.not.toThrow();
-    consoleSpy.mockRestore();
-  });
-});
-
-// ---------------------------------------------------------------------------
-// Parity spot-check — behavior without real Atlas
-// ---------------------------------------------------------------------------
-
-describe("parity spot-check with MONGODB_URI set", () => {
-  it("logs parity-check result (sampled=0) when KV has no keys for the module", async () => {
-    // When MONGODB_URI is set but CF KV has no keys, the sampler finds nothing
-    // and logs "parity check passed" with totalSampled=0.
-    // We inject a MongoKVStore that instantly throws to confirm the spotCheckModule
-    // error path is handled gracefully.
-    const env = makeEnv({ MONGODB_URI: "mongodb://fake", MODULES: "wordle" });
-    // KV is empty — nothing to list, nothing to compare.
-
-    const logs = [];
-    const logSpy = vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args));
-    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
-    const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
-
-    // driftVerifier will attempt to create MongoKVStore + call get() which will
-    // try to connect to the fake URI. The MongoKVStore._db() path is safe here
-    // because list() returns empty keys so get() is never called.
-    // However getDb() would try to connect — instead override list on the fake KV
-    // to return empty, which is already the default. The spotCheck will find 0 keys
-    // and log "parity check passed" without needing to call get().
-    await driftVerifier({}, makeCtx(env)).catch(() => {
-      // If the connection attempt throws, that's fine — we just check we got some logs.
-    });
-
-    logSpy.mockRestore();
-    warnSpy.mockRestore();
-    errorSpy.mockRestore();
-
-    // Either "parity check passed" or "parity drain complete" must appear — run completed.
-    const allLogs = logs.map((c) => String(c[0]));
-    const hasLogEntry = allLogs.some(
-      (m) => m.includes("parity") || m.includes("drain") || m.includes("drift-verifier"),
-    );
-    expect(hasLogEntry).toBe(true);
-  });
-
-  it("returns early (no spot-check) when MONGODB_URI is absent even with KV keys", async () => {
-    // When MONGODB_URI is unset, driftVerifier drains queues then returns early
-    // without attempting any Mongo calls — no timeout risk.
-    const env = makeEnv(); // no MONGODB_URI
-    await env.KV.put("wordle:game1", "some-value");
-
-    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
-    const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
-
-    await expect(driftVerifier({}, makeCtx(env))).resolves.not.toThrow();
-
-    warnSpy.mockRestore();
-    logSpy.mockRestore();
-  });
-});
-
-// ---------------------------------------------------------------------------
-// Missing env.KV
-// ---------------------------------------------------------------------------
-
-describe("missing env.KV", () => {
-  it("logs warning and returns without crashing", async () => {
-    const env = { MODULES: "wordle" }; // no KV
-    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
-    await expect(driftVerifier({}, makeCtx(env))).resolves.not.toThrow();
-    expect(warnSpy.mock.calls.some((c) => String(c[0]).includes("env.KV not available"))).toBe(
-      true,
-    );
-    warnSpy.mockRestore();
-  });
-});
diff --git a/tests/db/dual-kv-store.test.js b/tests/db/dual-kv-store.test.js
deleted file mode 100644
index d6dbf27..0000000
--- a/tests/db/dual-kv-store.test.js
+++ /dev/null
@@ -1,205 +0,0 @@
-/**
- * @file dual-kv-store.test.js — unit tests for DualKVStore.
- *
- * Tests the four behavioral contracts:
- *   1. Write succeeds when both primary and secondary succeed.
- *   2. Write succeeds when secondary fails: logs + enqueues to retry queue.
- *   3. Write fails (throws) when primary fails.
- *   4. Reads always come from primary only.
- *   5. `_kind === "dual"` sentinel is present on the instance.
- */
-
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { CFKVStore } from "../../src/db/cf-kv-store.js";
-import { DualKVStore } from "../../src/db/dual-kv-store.js";
-import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-function makeStores() {
-  const primaryKv = makeFakeKv();
-  const secondaryKv = makeFakeKv();
-  const retryQueueKv = makeFakeKv();
-  const primary = new CFKVStore(primaryKv);
-  const secondary = new CFKVStore(secondaryKv);
-  const logger = { warn: vi.fn(), error: vi.fn(), log: vi.fn() };
-  const dual = new DualKVStore(primary, secondary, retryQueueKv, logger);
-  return { primaryKv, secondaryKv, retryQueueKv, primary, secondary, dual, logger };
-}
-
-// ---------------------------------------------------------------------------
-// Constructor validation
-// ---------------------------------------------------------------------------
-
-describe("DualKVStore constructor", () => {
-  it("throws when primary is missing", () => {
-    const kv = makeFakeKv();
-    expect(() => new DualKVStore(null, new CFKVStore(kv), kv)).toThrow(/primary/);
-  });
-
-  it("throws when secondary is missing", () => {
-    const kv = makeFakeKv();
-    expect(() => new DualKVStore(new CFKVStore(kv), null, kv)).toThrow(/secondary/);
-  });
-
-  it("throws when rawKv is missing", () => {
-    const kv = makeFakeKv();
-    const store = new CFKVStore(kv);
-    expect(() => new DualKVStore(store, store, null)).toThrow(/rawKv/);
-  });
-});
-
-// ---------------------------------------------------------------------------
-// _kind sentinel
-// ---------------------------------------------------------------------------
-
-describe("_kind sentinel", () => {
-  it("exposes _kind === 'dual'", () => {
-    const { dual } = makeStores();
-    expect(dual._kind).toBe("dual");
-  });
-});
-
-// ---------------------------------------------------------------------------
-// Read operations — primary only
-// ---------------------------------------------------------------------------
-
-describe("reads from primary only", () => {
-  it("get() returns primary value even when secondary differs", async () => {
-    const { primaryKv, secondaryKv, dual } = makeStores();
-    primaryKv.store.set("k", "from-primary");
-    secondaryKv.store.set("k", "from-secondary");
-    expect(await dual.get("k")).toBe("from-primary");
-  });
-
-  it("getJSON() returns primary parsed value", async () => {
-    const { primaryKv, secondaryKv, dual } = makeStores();
-    primaryKv.store.set("k", JSON.stringify({ x: 1 }));
-    secondaryKv.store.set("k", JSON.stringify({ x: 99 }));
-    expect(await dual.getJSON("k")).toEqual({ x: 1 });
-  });
-
-  it("list() returns primary keys", async () => {
-    const { primaryKv, secondaryKv, dual } = makeStores();
-    primaryKv.store.set("a:1", "x");
-    primaryKv.store.set("a:2", "y");
-    secondaryKv.store.set("b:1", "other-module");
-    const result = await dual.list({ prefix: "a:" });
-    expect(result.keys.sort()).toEqual(["a:1", "a:2"]);
-  });
-});
-
-// ---------------------------------------------------------------------------
-// Write operations — both succeed
-// ---------------------------------------------------------------------------
-
-describe("writes to both when both succeed", () => {
-  it("put() writes to primary and secondary", async () => {
-    const { primaryKv, secondaryKv, dual } = makeStores();
-    await dual.put("k", "v");
-    expect(primaryKv.store.get("k")).toBe("v");
-    expect(secondaryKv.store.get("k")).toBe("v");
-  });
-
-  it("putJSON() serialises to both", async () => {
-    const { primaryKv, secondaryKv, dual } = makeStores();
-    await dual.putJSON("k", { n: 42 });
-    expect(primaryKv.store.get("k")).toBe('{"n":42}');
-    expect(secondaryKv.store.get("k")).toBe('{"n":42}');
-  });
-
-  it("delete() removes from both", async () => {
-    const { primaryKv, secondaryKv, dual } = makeStores();
-    primaryKv.store.set("k", "v");
-    secondaryKv.store.set("k", "v");
-    await dual.delete("k");
-    expect(primaryKv.store.has("k")).toBe(false);
-    expect(secondaryKv.store.has("k")).toBe(false);
-  });
-
-  it("no retry entry is enqueued when both succeed", async () => {
-    const { retryQueueKv, dual } = makeStores();
-    await dual.put("k", "v");
-    expect(retryQueueKv.store.size).toBe(0);
-  });
-});
-
-// ---------------------------------------------------------------------------
-// Secondary failure — caller still succeeds, retry enqueued
-// ---------------------------------------------------------------------------
-
-describe("secondary failure — caller succeeds, retry enqueued", () => {
-  it("put() succeeds when secondary throws, logs warning, enqueues retry", async () => {
-    const { primaryKv, secondaryKv, retryQueueKv, logger, dual } = makeStores();
-
-    // Make secondary put throw.
-    vi.spyOn(secondaryKv, "put").mockRejectedValueOnce(new Error("network error"));
-
-    await expect(dual.put("key1", "value1")).resolves.not.toThrow();
-
-    // Primary was written.
-    expect(primaryKv.store.get("key1")).toBe("value1");
-    // Warning logged — contains key and error info, NOT the value.
-    expect(logger.warn).toHaveBeenCalledOnce();
-    const warnArg = logger.warn.mock.calls[0][1];
-    expect(warnArg.key).toBe("key1");
-    expect(warnArg.err).toContain("network error");
-    // Value must NOT appear in the log.
-    expect(JSON.stringify(warnArg)).not.toContain("value1");
-    // Retry entry enqueued.
-    expect(retryQueueKv.store.size).toBe(1);
-    const [retryKey] = [...retryQueueKv.store.keys()];
-    expect(retryKey).toMatch(/^__retry:mongo-failed:/);
-  });
-
-  it("putJSON() succeeds when secondary throws, enqueues retry", async () => {
-    const { primaryKv, secondaryKv, retryQueueKv, dual } = makeStores();
-    vi.spyOn(secondaryKv, "put").mockRejectedValueOnce(new Error("timeout"));
-
-    await expect(dual.putJSON("k2", { val: 7 })).resolves.not.toThrow();
-
-    expect(primaryKv.store.get("k2")).toBe('{"val":7}');
-    expect(retryQueueKv.store.size).toBe(1);
-  });
-
-  it("delete() succeeds when secondary throws, enqueues retry", async () => {
-    const { primaryKv, secondaryKv, retryQueueKv, dual } = makeStores();
-    primaryKv.store.set("k3", "v");
-    secondaryKv.store.set("k3", "v");
-    vi.spyOn(secondaryKv, "delete").mockRejectedValueOnce(new Error("atlas down"));
-
-    await expect(dual.delete("k3")).resolves.not.toThrow();
-
-    expect(primaryKv.store.has("k3")).toBe(false);
-    expect(retryQueueKv.store.size).toBe(1);
-  });
-});
-
-// ---------------------------------------------------------------------------
-// Primary failure — caller throws
-// ---------------------------------------------------------------------------
-
-describe("primary failure — throws to caller", () => {
-  it("put() throws when primary throws", async () => {
-    const { primaryKv, dual } = makeStores();
-    vi.spyOn(primaryKv, "put").mockRejectedValueOnce(new Error("primary dead"));
-
-    await expect(dual.put("k", "v")).rejects.toThrow("primary dead");
-  });
-
-  it("putJSON() throws when primary throws", async () => {
-    const { primaryKv, dual } = makeStores();
-    vi.spyOn(primaryKv, "put").mockRejectedValueOnce(new Error("kv full"));
-
-    await expect(dual.putJSON("k", { x: 1 })).rejects.toThrow("kv full");
-  });
-
-  it("delete() throws when primary throws", async () => {
-    const { primaryKv, dual } = makeStores();
-    vi.spyOn(primaryKv, "delete").mockRejectedValueOnce(new Error("kv gone"));
-
-    await expect(dual.delete("k")).rejects.toThrow("kv gone");
-  });
-});
diff --git a/tests/db/dual-sql-store.test.js b/tests/db/dual-sql-store.test.js
deleted file mode 100644
index 0da548c..0000000
--- a/tests/db/dual-sql-store.test.js
+++ /dev/null
@@ -1,211 +0,0 @@
-/**
- * @file dual-sql-store.test.js — unit tests for DualSqlStore.
- *
- * Contracts verified:
- *   1. run() writes to both primary and secondary.
- *   2. run() succeeds when secondary fails: logs warning + enqueues to retry queue.
- *   3. run() throws when primary fails.
- *   4. all() and first() read from primary only.
- *   5. prepare() and batch() delegate to primary only.
- *   6. tablePrefix is inherited from primary.
- *   7. `_kind === "dual"` sentinel present.
- */
-
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { CFSqlStore } from "../../src/db/cf-sql-store.js";
-import { DualSqlStore } from "../../src/db/dual-sql-store.js";
-import { makeFakeD1 } from "../fakes/fake-d1.js";
-import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-function makeStores() {
-  const primaryD1 = makeFakeD1();
-  const secondaryD1 = makeFakeD1();
-  const retryQueueKv = makeFakeKv();
-  const primary = new CFSqlStore(primaryD1);
-  const secondary = new CFSqlStore(secondaryD1);
-  const logger = { warn: vi.fn(), error: vi.fn(), log: vi.fn() };
-  const dual = new DualSqlStore(primary, secondary, retryQueueKv, logger);
-  // Simulate tablePrefix coming from primary wrapper — set directly.
-  dual.tablePrefix = "trading_";
-  return { primaryD1, secondaryD1, retryQueueKv, primary, secondary, dual, logger };
-}
-
-// ---------------------------------------------------------------------------
-// Constructor validation
-// ---------------------------------------------------------------------------
-
-describe("DualSqlStore constructor", () => {
-  it("throws when primary is missing", () => {
-    const kv = makeFakeKv();
-    const d1 = makeFakeD1();
-    expect(() => new DualSqlStore(null, new CFSqlStore(d1), kv)).toThrow(/primary/);
-  });
-
-  it("throws when secondary is missing", () => {
-    const kv = makeFakeKv();
-    const d1 = makeFakeD1();
-    expect(() => new DualSqlStore(new CFSqlStore(d1), null, kv)).toThrow(/secondary/);
-  });
-
-  it("throws when rawKv is missing", () => {
-    const d1 = makeFakeD1();
-    const store = new CFSqlStore(d1);
-    expect(() => new DualSqlStore(store, store, null)).toThrow(/rawKv/);
-  });
-});
-
-// ---------------------------------------------------------------------------
-// _kind sentinel
-// ---------------------------------------------------------------------------
-
-describe("_kind sentinel", () => {
-  it("exposes _kind === 'dual'", () => {
-    const { dual } = makeStores();
-    expect(dual._kind).toBe("dual");
-  });
-});
-
-// ---------------------------------------------------------------------------
-// tablePrefix
-// ---------------------------------------------------------------------------
-
-describe("tablePrefix", () => {
-  it("inherits tablePrefix from primary", () => {
-    const d1 = makeFakeD1();
-    const kv = makeFakeKv();
-    const primary = new CFSqlStore(d1);
-    // The DualSqlStore constructor copies primary.tablePrefix.
-    const dual = new DualSqlStore(primary, primary, kv);
-    // CFSqlStore has no tablePrefix; DualSqlStore falls back to "".
-    expect(typeof dual.tablePrefix).toBe("string");
-  });
-});
-
-// ---------------------------------------------------------------------------
-// run() — both succeed
-// ---------------------------------------------------------------------------
-
-describe("run() — both succeed", () => {
-  it("records query in both primary and secondary runLog", async () => {
-    const { primaryD1, secondaryD1, dual } = makeStores();
-    await dual.run("INSERT INTO trading_trades VALUES (?)", "x");
-    expect(primaryD1.runLog).toHaveLength(1);
-    expect(secondaryD1.runLog).toHaveLength(1);
-    expect(primaryD1.runLog[0].query).toBe("INSERT INTO trading_trades VALUES (?)");
-    expect(secondaryD1.runLog[0].query).toBe("INSERT INTO trading_trades VALUES (?)");
-  });
-
-  it("returns the primary run result", async () => {
-    const { dual } = makeStores();
-    const result = await dual.run("INSERT INTO trading_trades VALUES (?)", "v");
-    expect(result).toHaveProperty("changes");
-    expect(result).toHaveProperty("last_row_id");
-  });
-
-  it("no retry entry enqueued when both succeed", async () => {
-    const { retryQueueKv, dual } = makeStores();
-    await dual.run("INSERT INTO trading_trades VALUES (?)", "v");
-    expect(retryQueueKv.store.size).toBe(0);
-  });
-});
-
-// ---------------------------------------------------------------------------
-// run() — secondary fails
-// ---------------------------------------------------------------------------
-
-describe("run() — secondary fails", () => {
-  it("succeeds, logs warning, enqueues retry when secondary throws", async () => {
-    const { primaryD1, secondaryD1, retryQueueKv, logger, dual } = makeStores();
-    vi.spyOn(secondaryD1, "prepare").mockImplementation(() => ({
-      run: () => Promise.reject(new Error("mongo write failed")),
-      bind: function (...args) {
-        return this;
-      },
-    }));
-
-    await expect(dual.run("INSERT INTO trading_trades VALUES (?)", "val")).resolves.not.toThrow();
-
-    expect(primaryD1.runLog).toHaveLength(1);
-    expect(logger.warn).toHaveBeenCalledOnce();
-    const warnArg = logger.warn.mock.calls[0][1];
-    expect(warnArg.op).toBe("run");
-    expect(warnArg.err).toContain("mongo write failed");
-    // Bind values must NOT appear in structured log.
-    expect(JSON.stringify(warnArg)).not.toContain("val");
-    // Retry enqueued.
-    expect(retryQueueKv.store.size).toBe(1);
-    const [key] = [...retryQueueKv.store.keys()];
-    expect(key).toMatch(/^__retry:mongo-sql-failed:/);
-  });
-});
-
-// ---------------------------------------------------------------------------
-// run() — primary fails
-// ---------------------------------------------------------------------------
-
-describe("run() — primary fails", () => {
-  it("throws when primary throws", async () => {
-    const { primaryD1, dual } = makeStores();
-    vi.spyOn(primaryD1, "prepare").mockImplementation(() => ({
-      run: () => Promise.reject(new Error("d1 gone")),
-      bind: function (...args) {
-        return this;
-      },
-    }));
-
-    await expect(dual.run("INSERT INTO trading_trades VALUES (?)", "v")).rejects.toThrow("d1 gone");
-  });
-});
-
-// ---------------------------------------------------------------------------
-// Read operations — primary only
-// ---------------------------------------------------------------------------
-
-describe("all() and first() — primary only", () => {
-  it("all() returns primary results", async () => {
-    const { primaryD1, secondaryD1, dual } = makeStores();
-    primaryD1.seed("trading_trades", [{ id: 1, symbol: "VNM" }]);
-    // Secondary empty — result must still come from primary.
-    const rows = await dual.all("SELECT * FROM trading_trades");
-    expect(rows).toHaveLength(1);
-    expect(rows[0].symbol).toBe("VNM");
-  });
-
-  it("first() returns primary first row", async () => {
-    const { primaryD1, dual } = makeStores();
-    primaryD1.seed("trading_trades", [{ id: 1, symbol: "FPT" }]);
-    const row = await dual.first("SELECT * FROM trading_trades LIMIT 1");
-    expect(row?.symbol).toBe("FPT");
-  });
-
-  it("first() returns null when primary has no rows", async () => {
-    const { dual } = makeStores();
-    const row = await dual.first("SELECT * FROM trading_trades LIMIT 1");
-    expect(row).toBeNull();
-  });
-});
-
-// ---------------------------------------------------------------------------
-// prepare() and batch() — primary only
-// ---------------------------------------------------------------------------
-
-describe("prepare() and batch() — primary only", () => {
-  it("prepare() delegates to primary", () => {
-    const { primaryD1, dual } = makeStores();
-    // Should not throw; fake D1 returns a stub prepared statement.
-    expect(() => dual.prepare("SELECT 1")).not.toThrow();
-  });
-
-  it("batch() returns primary results", async () => {
-    const { primaryD1, dual } = makeStores();
-    primaryD1.seed("trading_trades", [{ id: 1 }]);
-    const stmt = dual.prepare("SELECT * FROM trading_trades");
-    const results = await dual.batch([stmt]);
-    expect(Array.isArray(results)).toBe(true);
-    expect(results[0]).toHaveLength(1);
-  });
-});
diff --git a/tests/db/mongo-kv-store.test.js b/tests/db/mongo-kv-store.test.js
deleted file mode 100644
index 8e17274..0000000
--- a/tests/db/mongo-kv-store.test.js
+++ /dev/null
@@ -1,328 +0,0 @@
-/**
- * @file mongo-kv-store.test.js — unit tests for MongoKVStore.
- *
- * Injection pattern: MongoKVStore constructor accepts an optional `dbOverride`
- * parameter. Tests pass a `makeFakeMongo()` db so no real Atlas connection
- * is made. The same fake is used to test mongo-client.js connect-reject retry.
- */
-
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { MongoKVStore } from "../../src/db/mongo-kv-store.js";
-import { makeFakeMongo } from "../fakes/fake-mongo.js";
-
-// ─── helpers ────────────────────────────────────────────────────────────────
-
-/** Build a store + fake db pair. collectionName defaults to "test". */
-function makeStore(collectionName = "test") {
-  const fakeDb = makeFakeMongo();
-  const store = new MongoKVStore({}, collectionName, fakeDb);
-  return { store, fakeDb };
-}
-
-// ─── constructor ─────────────────────────────────────────────────────────────
-
-describe("MongoKVStore constructor", () => {
-  it("throws when collectionName is missing", () => {
-    expect(() => new MongoKVStore({}, "")).toThrow(/required/);
-  });
-
-  it("normalizes collection name: replaces - with _", () => {
-    const fakeDb = makeFakeMongo();
-    const store = new MongoKVStore({}, "loldle-emoji", fakeDb);
-    expect(store._collName).toBe("loldle_emoji");
-  });
-});
-
-// ─── get / put / delete ───────────────────────────────────────────────────────
-
-describe("get / put / delete", () => {
-  it("get returns null for missing key", async () => {
-    const { store } = makeStore();
-    expect(await store.get("missing")).toBeNull();
-  });
-
-  it("put → get round-trip", async () => {
-    const { store } = makeStore();
-    await store.put("k", "hello");
-    expect(await store.get("k")).toBe("hello");
-  });
-
-  it("put overwrites existing value", async () => {
-    const { store } = makeStore();
-    await store.put("k", "first");
-    await store.put("k", "second");
-    expect(await store.get("k")).toBe("second");
-  });
-
-  it("delete removes the key", async () => {
-    const { store } = makeStore();
-    await store.put("k", "v");
-    await store.delete("k");
-    expect(await store.get("k")).toBeNull();
-  });
-
-  it("delete is idempotent (no-op on missing key)", async () => {
-    const { store } = makeStore();
-    await expect(store.delete("nope")).resolves.toBeUndefined();
-  });
-});
-
-// ─── TTL / expiresAt ─────────────────────────────────────────────────────────
-
-describe("TTL / expiresAt field", () => {
-  afterEach(() => {
-    vi.useRealTimers();
-  });
-
-  it("put with expirationTtl writes expiresAt field", async () => {
-    vi.useFakeTimers();
-    vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
-    const { store, fakeDb } = makeStore();
-    await store.put("k", "v", { expirationTtl: 60 });
-    const col = fakeDb.collection("test");
-    const doc = await col.findOne({ _id: "k" });
-    expect(doc.expiresAt).toBeInstanceOf(Date);
-    expect(doc.expiresAt.getTime()).toBe(new Date("2025-01-01T00:01:00.000Z").getTime());
-  });
-
-  it("put without TTL clears any existing expiresAt", async () => {
-    vi.useFakeTimers();
-    vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
-    const { store, fakeDb } = makeStore();
-    // First write with TTL
-    await store.put("k", "v", { expirationTtl: 60 });
-    // Second write without TTL — must remove expiresAt
-    await store.put("k", "updated");
-    const col = fakeDb.collection("test");
-    const doc = await col.findOne({ _id: "k" });
-    expect(doc.expiresAt).toBeUndefined();
-  });
-
-  it("TTL stale-read regression: expired doc returns null before sweeper runs", async () => {
-    vi.useFakeTimers();
-    vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
-    const { store } = makeStore();
-    await store.put("k", "v", { expirationTtl: 1 }); // expires in 1s
-
-    // Advance clock 2 seconds — doc is now expired but sweeper hasn't run
-    vi.setSystemTime(new Date("2025-01-01T00:00:02.000Z"));
-
-    expect(await store.get("k")).toBeNull();
-  });
-
-  it("non-expired doc is still readable before TTL elapses", async () => {
-    vi.useFakeTimers();
-    vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
-    const { store } = makeStore();
-    await store.put("k", "v", { expirationTtl: 60 });
-
-    // Only 10s later — still live
-    vi.setSystemTime(new Date("2025-01-01T00:00:10.000Z"));
-
-    expect(await store.get("k")).toBe("v");
-  });
-});
-
-// ─── getJSON / putJSON ───────────────────────────────────────────────────────
-
-describe("getJSON / putJSON", () => {
-  it("putJSON → getJSON round-trip", async () => {
-    const { store } = makeStore();
-    await store.putJSON("k", { a: 1, b: [2, 3] });
-    expect(await store.getJSON("k")).toEqual({ a: 1, b: [2, 3] });
-  });
-
-  it("getJSON returns null on missing key", async () => {
-    const { store } = makeStore();
-    expect(await store.getJSON("missing")).toBeNull();
-  });
-
-  it("getJSON returns null on corrupt JSON and logs a warning", async () => {
-    const { store, fakeDb } = makeStore();
-    // Seed corrupt document directly into the fake collection
-    await fakeDb.collection("test").insertOne({ _id: "bad", value: "{not json" });
-    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
-    expect(await store.getJSON("bad")).toBeNull();
-    expect(warn).toHaveBeenCalled();
-    warn.mockRestore();
-  });
-
-  it("putJSON throws on undefined value", async () => {
-    const { store } = makeStore();
-    await expect(store.putJSON("k", undefined)).rejects.toThrow(/undefined/);
-  });
-
-  it("putJSON throws on circular reference", async () => {
-    const { store } = makeStore();
-    const obj = {};
-    obj.self = obj;
-    await expect(store.putJSON("k", obj)).rejects.toThrow();
-  });
-
-  it("putJSON passes expirationTtl through to put", async () => {
-    vi.useFakeTimers();
-    vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
-    const { store, fakeDb } = makeStore();
-    await store.putJSON("k", { x: 1 }, { expirationTtl: 120 });
-    const doc = await fakeDb.collection("test").findOne({ _id: "k" });
-    expect(doc.expiresAt).toBeInstanceOf(Date);
-    expect(doc.expiresAt.getTime()).toBe(new Date("2025-01-01T00:02:00.000Z").getTime());
-    vi.useRealTimers();
-  });
-});
-
-// ─── list ────────────────────────────────────────────────────────────────────
-
-describe("list()", () => {
-  it("returns empty result when store is empty", async () => {
-    const { store } = makeStore();
-    const res = await store.list();
-    expect(res.keys).toEqual([]);
-    expect(res.done).toBe(true);
-    expect(res.cursor).toBeUndefined();
-  });
-
-  it("returns all keys when no prefix given", async () => {
-    const { store } = makeStore();
-    await store.put("a:1", "x");
-    await store.put("b:2", "y");
-    const res = await store.list();
-    expect(res.keys.sort()).toEqual(["a:1", "b:2"]);
-    expect(res.done).toBe(true);
-  });
-
-  it("returns keys WITH prefix preserved (not stripped)", async () => {
-    const { store } = makeStore();
-    await store.put("wordle:games:1", "a");
-    await store.put("wordle:games:2", "b");
-    await store.put("wordle:other:3", "c");
-    const res = await store.list({ prefix: "wordle:games:" });
-    expect(res.keys.sort()).toEqual(["wordle:games:1", "wordle:games:2"]);
-    expect(res.done).toBe(true);
-  });
-
-  it("2-level prefix regression: only matching keys returned with prefix preserved", async () => {
-    const { store } = makeStore();
-    await store.put("wordle:games:1", "a");
-    await store.put("wordle:games:2", "b");
-    await store.put("wordle:other:3", "c");
-    const res = await store.list({ prefix: "wordle:games:" });
-    // Must be exactly 2 keys, both with prefix intact
-    expect(res.keys).toHaveLength(2);
-    expect(res.keys).toContain("wordle:games:1");
-    expect(res.keys).toContain("wordle:games:2");
-    expect(res.keys).not.toContain("wordle:other:3");
-  });
-
-  it("prefix with regex special chars is escaped", async () => {
-    const { store } = makeStore();
-    await store.put("a.b:1", "x");
-    await store.put("a_b:1", "y"); // should NOT match prefix "a.b:"
-    const res = await store.list({ prefix: "a.b:" });
-    expect(res.keys).toEqual(["a.b:1"]);
-  });
-
-  it("list() cursor pagination — limit(N+1) strategy", async () => {
-    const { store } = makeStore();
-    for (let i = 1; i <= 5; i++) await store.put(`k${i}`, String(i));
-
-    const page1 = await store.list({ limit: 2 });
-    expect(page1.keys).toHaveLength(2);
-    expect(page1.done).toBe(false);
-    expect(page1.cursor).toBeTruthy();
-
-    const page2 = await store.list({ limit: 2, cursor: page1.cursor });
-    expect(page2.keys).toHaveLength(2);
-    expect(page2.done).toBe(false);
-    expect(page2.cursor).toBeTruthy();
-
-    const page3 = await store.list({ limit: 2, cursor: page2.cursor });
-    expect(page3.keys).toHaveLength(1);
-    expect(page3.done).toBe(true);
-    expect(page3.cursor).toBeUndefined();
-
-    // All keys across pages must be unique and sorted
-    const allKeys = [...page1.keys, ...page2.keys, ...page3.keys];
-    expect(allKeys).toHaveLength(5);
-    expect(allKeys).toEqual([...allKeys].sort());
-  });
-
-  it("list done=true when exactly limit keys remain (no extra page)", async () => {
-    const { store } = makeStore();
-    await store.put("k1", "a");
-    await store.put("k2", "b");
-    const res = await store.list({ limit: 2 });
-    expect(res.keys).toHaveLength(2);
-    expect(res.done).toBe(true);
-    expect(res.cursor).toBeUndefined();
-  });
-});
-
-// ─── mongo-client connect-reject retry regression ────────────────────────────
-
-describe("mongo-client connect-reject retry regression", () => {
-  it("nulls client and connectPromise on connect() rejection so next call retries", async () => {
-    // Import the module fresh — we'll call it with a mock factory
-    const { getDb, closeMongo } = await import("../../src/db/mongo-client.js");
-
-    // Ensure clean state before test
-    await closeMongo();
-
-    // Patch MongoClient.prototype.connect to reject once, then succeed
-    const { MongoClient } = await import("mongodb");
-    let callCount = 0;
-    const originalConnect = MongoClient.prototype.connect;
-    MongoClient.prototype.connect = vi.fn(async function () {
-      callCount++;
-      if (callCount === 1) {
-        // First call: reject to simulate transient failure
-        throw new Error("transient connection error");
-      }
-      // Second call: succeed (but return void, the client is now "connected")
-      return this;
-    });
-
-    try {
-      // First call — must reject
-      await expect(getDb({ MONGODB_URI: "mongodb://localhost:27017" })).rejects.toThrow(
-        "transient connection error",
-      );
-
-      // Second call — must NOT reuse the dead client; must retry
-      // It will succeed on second connect() call
-      const db = await getDb({ MONGODB_URI: "mongodb://localhost:27017" });
-      expect(db).toBeTruthy();
-      expect(callCount).toBe(2);
-    } finally {
-      MongoClient.prototype.connect = originalConnect;
-      await closeMongo();
-    }
-  });
-
-  it("logs structured warning on MongoServerSelectionError", async () => {
-    const { getDb, closeMongo } = await import("../../src/db/mongo-client.js");
-    await closeMongo();
-
-    const { MongoClient } = await import("mongodb");
-    const originalConnect = MongoClient.prototype.connect;
-    MongoClient.prototype.connect = vi.fn(async () => {
-      const err = new Error("server selection timeout");
-      err.name = "MongoServerSelectionError";
-      throw err;
-    });
-
-    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
-
-    try {
-      await expect(getDb({ MONGODB_URI: "mongodb://localhost:27017" })).rejects.toThrow();
-      expect(warn).toHaveBeenCalledOnce();
-      const logged = JSON.parse(warn.mock.calls[0][0]);
-      expect(logged.event).toBe("mongo_server_selection_failed");
-      expect(logged.note).toMatch(/503/);
-    } finally {
-      MongoClient.prototype.connect = originalConnect;
-      warn.mockRestore();
-      await closeMongo();
-    }
-  });
-});
diff --git a/tests/db/mongo-sql-store.test.js b/tests/db/mongo-sql-store.test.js
deleted file mode 100644
index 0d47de5..0000000
--- a/tests/db/mongo-sql-store.test.js
+++ /dev/null
@@ -1,247 +0,0 @@
-/**
- * @file Tests for MongoSqlStore shim — contract compliance for SqlStore interface.
- *
- * Key assertion: `run` INSERT returns `{ changes: 1, last_row_id: 0 }` where
- * `last_row_id` is the NUMBER 0, not a hex string. This satisfies the contract
- * checked by tests/db/create-sql-store.test.js:48-52.
- */
-
-import { describe, expect, it, vi } from "vitest";
-import { MongoSqlStore } from "../../src/db/mongo-sql-store.js";
-import { MongoTradesStore } from "../../src/db/mongo-trades-store.js";
-import { makeFakeMongo } from "../fakes/fake-mongo.js";
-
-// ─── helpers ──────────────────────────────────────────────────────────────────
-
-/** Build a MongoSqlStore backed by a fresh fake MongoTradesStore. */
-function makeShim() {
-  const fakeDb = makeFakeMongo();
-  const tradesStore = new MongoTradesStore({}, fakeDb);
-  const shim = new MongoSqlStore({}, "trading", tradesStore);
-  return { fakeDb, tradesStore, shim };
-}
-
-// ─── tablePrefix ──────────────────────────────────────────────────────────────
-
-describe("MongoSqlStore — tablePrefix", () => {
-  it("exposes tablePrefix as moduleName + underscore", () => {
-    const { shim } = makeShim();
-    expect(shim.tablePrefix).toBe("trading_");
-  });
-});
-
-// ─── run — INSERT ─────────────────────────────────────────────────────────────
-
-describe("MongoSqlStore.run — INSERT", () => {
-  it("returns { changes: 1, last_row_id: 0 } for INSERT INTO trading_trades", async () => {
-    const { shim } = makeShim();
-    const result = await shim.run(
-      "INSERT INTO trading_trades (user_id, symbol, side, qty, price_vnd, ts) VALUES (?, ?, ?, ?, ?, ?)",
-      1,
-      "TCB",
-      "buy",
-      100,
-      25000,
-      1000,
-    );
-    expect(result).toEqual({ changes: 1, last_row_id: 0 });
-  });
-
-  it("last_row_id is a NUMBER (not hex string) — satisfies create-sql-store.test.js:48-52", async () => {
-    const { shim } = makeShim();
-    const result = await shim.run(
-      "INSERT INTO trading_trades (user_id, symbol, side, qty, price_vnd, ts) VALUES (?, ?, ?, ?, ?, ?)",
-      1,
-      "VNM",
-      "sell",
-      50,
-      80000,
-      2000,
-    );
-    expect(typeof result.last_row_id).toBe("number");
-    expect(result.last_row_id).toBe(0);
-    expect(typeof result.changes).toBe("number");
-  });
-
-  it("actually inserts the document into the store", async () => {
-    const { fakeDb, shim } = makeShim();
-    await shim.run(
-      "INSERT INTO trading_trades (user_id, symbol, side, qty, price_vnd, ts) VALUES (?, ?, ?, ?, ?, ?)",
-      1,
-      "TCB",
-      "buy",
-      100,
-      25000,
-      1000,
-    );
-    const docs = await fakeDb.collection("trading_trades").find({}).toArray();
-    expect(docs).toHaveLength(1);
-    expect(docs[0].symbol).toBe("TCB");
-  });
-
-  it("throws for unrecognised run query", async () => {
-    const { shim } = makeShim();
-    await expect(shim.run("UPDATE trading_trades SET qty = ? WHERE id = ?", 5, 1)).rejects.toThrow(
-      "MongoSqlStore: unsupported query",
-    );
-  });
-});
-
-// ─── run — DELETE by ids ──────────────────────────────────────────────────────
-
-describe("MongoSqlStore.run — DELETE WHERE id IN", () => {
-  it("delegates delete and returns changes count", async () => {
-    const { shim, tradesStore } = makeShim();
-    // Insert two trades so we have ids to delete.
-    await tradesStore.insert({
-      userId: 1,
-      symbol: "TCB",
-      side: "buy",
-      qty: 1,
-      priceVnd: 100,
-      ts: 1,
-    });
-    await tradesStore.insert({
-      userId: 1,
-      symbol: "TCB",
-      side: "buy",
-      qty: 1,
-      priceVnd: 100,
-      ts: 2,
-    });
-    const oldIds = await tradesStore.oldRows(1); // 1 excess
-    expect(oldIds).toHaveLength(1);
-
-    const result = await shim.run("DELETE FROM trading_trades WHERE id IN (?)", ...oldIds);
-    expect(result.changes).toBe(1);
-    expect(result.last_row_id).toBe(0);
-  });
-});
-
-// ─── all ──────────────────────────────────────────────────────────────────────
-
-describe("MongoSqlStore.all", () => {
-  it("SELECT DISTINCT user_id → returns array of { user_id } objects", async () => {
-    const { shim, tradesStore } = makeShim();
-    await tradesStore.insert({ userId: 1, symbol: "TCB", side: "buy", qty: 1, priceVnd: 1, ts: 1 });
-    await tradesStore.insert({ userId: 2, symbol: "TCB", side: "buy", qty: 1, priceVnd: 1, ts: 2 });
-    await tradesStore.insert({ userId: 1, symbol: "TCB", side: "buy", qty: 1, priceVnd: 1, ts: 3 });
-
-    const rows = await shim.all("SELECT DISTINCT user_id FROM trading_trades");
-    expect(rows.map((r) => r.user_id).sort()).toEqual([1, 2]);
-  });
-
-  it("SELECT id … WHERE user_id = ? … OFFSET ? → returns old row ids for user", async () => {
-    const { shim, tradesStore } = makeShim();
-    for (let i = 1; i <= 5; i++) {
-      await tradesStore.insert({
-        userId: 1,
-        symbol: "TCB",
-        side: "buy",
-        qty: 1,
-        priceVnd: 1,
-        ts: i,
-      });
-    }
-    const ids = await shim.all(
-      "SELECT id FROM trading_trades WHERE user_id = ? ORDER BY ts DESC LIMIT -1 OFFSET ?",
-      1,
-      3,
-    );
-    expect(ids).toHaveLength(2);
-  });
-
-  it("SELECT id … ORDER BY ts DESC … OFFSET ? → returns global old row ids", async () => {
-    const { shim, tradesStore } = makeShim();
-    for (let i = 1; i <= 5; i++) {
-      await tradesStore.insert({
-        userId: 1,
-        symbol: "TCB",
-        side: "buy",
-        qty: 1,
-        priceVnd: 1,
-        ts: i,
-      });
-    }
-    const ids = await shim.all(
-      "SELECT id FROM trading_trades ORDER BY ts DESC LIMIT -1 OFFSET ?",
-      3,
-    );
-    expect(ids).toHaveLength(2);
-  });
-
-  it("SELECT … WHERE user_id = ? … LIMIT ? → returns byUser results", async () => {
-    const { shim, tradesStore } = makeShim();
-    await tradesStore.insert({
-      userId: 1,
-      symbol: "TCB",
-      side: "buy",
-      qty: 5,
-      priceVnd: 1000,
-      ts: 100,
-    });
-    await tradesStore.insert({
-      userId: 1,
-      symbol: "VNM",
-      side: "sell",
-      qty: 2,
-      priceVnd: 2000,
-      ts: 200,
-    });
-
-    const rows = await shim.all(
-      "SELECT id, user_id, symbol, side, qty, price_vnd, ts FROM trading_trades WHERE user_id = ? ORDER BY ts DESC LIMIT ?",
-      1,
-      10,
-    );
-    expect(rows).toHaveLength(2);
-    expect(rows[0].ts).toBe(200); // newest first
-  });
-
-  it("throws for unrecognised all query", async () => {
-    const { shim } = makeShim();
-    await expect(shim.all("SELECT * FROM trading_trades")).rejects.toThrow(
-      "MongoSqlStore: unsupported query",
-    );
-  });
-});
-
-// ─── first ────────────────────────────────────────────────────────────────────
-
-describe("MongoSqlStore.first", () => {
-  it("returns the first row from a valid query", async () => {
-    const { shim, tradesStore } = makeShim();
-    await tradesStore.insert({ userId: 1, symbol: "TCB", side: "buy", qty: 1, priceVnd: 1, ts: 1 });
-    const row = await shim.first(
-      "SELECT id, user_id, symbol, side, qty, price_vnd, ts FROM trading_trades WHERE user_id = ? ORDER BY ts DESC LIMIT ?",
-      1,
-      10,
-    );
-    expect(row).not.toBeNull();
-    expect(row.userId).toBe(1);
-  });
-
-  it("returns null when no rows match", async () => {
-    const { shim } = makeShim();
-    const row = await shim.first(
-      "SELECT id, user_id, symbol, side, qty, price_vnd, ts FROM trading_trades WHERE user_id = ? ORDER BY ts DESC LIMIT ?",
-      99,
-      10,
-    );
-    expect(row).toBeNull();
-  });
-});
-
-// ─── prepare / batch ─────────────────────────────────────────────────────────
-
-describe("MongoSqlStore — prepare / batch throw", () => {
-  it("prepare throws 'unsupported in MongoSqlStore'", () => {
-    const { shim } = makeShim();
-    expect(() => shim.prepare("SELECT 1")).toThrow("unsupported in MongoSqlStore");
-  });
-
-  it("batch throws 'unsupported in MongoSqlStore'", () => {
-    const { shim } = makeShim();
-    expect(() => shim.batch([])).toThrow("unsupported in MongoSqlStore");
-  });
-});
diff --git a/tests/db/mongo-trades-store.test.js b/tests/db/mongo-trades-store.test.js
deleted file mode 100644
index dd2764b..0000000
--- a/tests/db/mongo-trades-store.test.js
+++ /dev/null
@@ -1,301 +0,0 @@
-/**
- * @file Tests for MongoTradesStore — all 6 methods, edge cases, ordering.
- *
- * Injects fake-mongo via constructor (dbOverride), no real MongoDB connection.
- */
-
-import { ObjectId } from "mongodb";
-import { beforeEach, describe, expect, it } from "vitest";
-import { MongoTradesStore } from "../../src/db/mongo-trades-store.js";
-import { makeFakeMongo } from "../fakes/fake-mongo.js";
-
-// ─── helpers ──────────────────────────────────────────────────────────────────
-
-/** Build a fresh store backed by a new fake Mongo db. */
-function makeStore() {
-  const fakeDb = makeFakeMongo();
-  const store = new MongoTradesStore({}, fakeDb);
-  return { fakeDb, store };
-}
-
-/** Minimal trade payload for insert. */
-const TRADE = {
-  userId: 1,
-  symbol: "TCB",
-  side: /** @type {"buy"} */ ("buy"),
-  qty: 100,
-  priceVnd: 25000,
-  ts: 1000,
-};
-
-// ─── insert ───────────────────────────────────────────────────────────────────
-
-describe("MongoTradesStore.insert", () => {
-  it("inserts a document and returns { changes: 1, last_row_id: 0 }", async () => {
-    const { store } = makeStore();
-    const result = await store.insert(TRADE);
-    expect(result).toEqual({ changes: 1, last_row_id: 0 });
-  });
-
-  it("last_row_id is the number 0, not a hex string", async () => {
-    const { store } = makeStore();
-    const result = await store.insert(TRADE);
-    expect(typeof result.last_row_id).toBe("number");
-    expect(result.last_row_id).toBe(0);
-  });
-
-  it("stores legacy_id as null for new runtime trades", async () => {
-    const { fakeDb, store } = makeStore();
-    await store.insert(TRADE);
-    const coll = fakeDb.collection("trading_trades");
-    const docs = await coll.find({}).toArray();
-    expect(docs).toHaveLength(1);
-    expect(docs[0].legacy_id).toBeNull();
-  });
-
-  it("stores field shape correctly (snake_case in db)", async () => {
-    const { fakeDb, store } = makeStore();
-    await store.insert(TRADE);
-    const coll = fakeDb.collection("trading_trades");
-    const docs = await coll.find({}).toArray();
-    const doc = docs[0];
-    expect(doc.user_id).toBe(1);
-    expect(doc.symbol).toBe("TCB");
-    expect(doc.side).toBe("buy");
-    expect(doc.qty).toBe(100);
-    expect(doc.price_vnd).toBe(25000);
-    expect(doc.ts).toBe(1000);
-  });
-
-  it("uses provided ts when given", async () => {
-    const { fakeDb, store } = makeStore();
-    await store.insert({ ...TRADE, ts: 9999 });
-    const coll = fakeDb.collection("trading_trades");
-    const docs = await coll.find({}).toArray();
-    expect(docs[0].ts).toBe(9999);
-  });
-
-  it("falls back to Date.now() when ts is not provided", async () => {
-    const { fakeDb, store } = makeStore();
-    const before = Date.now();
-    const { ts: _, ...tradeWithoutTs } = TRADE;
-    await store.insert(tradeWithoutTs);
-    const after = Date.now();
-    const coll = fakeDb.collection("trading_trades");
-    const docs = await coll.find({}).toArray();
-    expect(docs[0].ts).toBeGreaterThanOrEqual(before);
-    expect(docs[0].ts).toBeLessThanOrEqual(after);
-  });
-});
-
-// ─── byUser ───────────────────────────────────────────────────────────────────
-
-describe("MongoTradesStore.byUser", () => {
-  it("returns [] when collection is empty", async () => {
-    const { store } = makeStore();
-    expect(await store.byUser(1, 10)).toEqual([]);
-  });
-
-  it("returns only trades for the requested user", async () => {
-    const { store } = makeStore();
-    await store.insert({ ...TRADE, userId: 1, ts: 1 });
-    await store.insert({ ...TRADE, userId: 2, ts: 2 });
-    const trades = await store.byUser(1, 10);
-    expect(trades).toHaveLength(1);
-    expect(trades[0].userId).toBe(1);
-  });
-
-  it("returns trades newest-first (sorted by ts DESC)", async () => {
-    const { store } = makeStore();
-    await store.insert({ ...TRADE, ts: 100 });
-    await store.insert({ ...TRADE, ts: 300 });
-    await store.insert({ ...TRADE, ts: 200 });
-    const trades = await store.byUser(1, 10);
-    expect(trades.map((t) => t.ts)).toEqual([300, 200, 100]);
-  });
-
-  it("respects the limit", async () => {
-    const { store } = makeStore();
-    for (let i = 1; i <= 5; i++) {
-      await store.insert({ ...TRADE, ts: i });
-    }
-    const trades = await store.byUser(1, 3);
-    expect(trades).toHaveLength(3);
-    // Must be the 3 newest.
-    expect(trades.map((t) => t.ts)).toEqual([5, 4, 3]);
-  });
-
-  it("maps document fields to camelCase Trade shape", async () => {
-    const { store } = makeStore();
-    await store.insert(TRADE);
-    const trades = await store.byUser(1, 1);
-    expect(trades[0]).toMatchObject({
-      userId: 1,
-      symbol: "TCB",
-      side: "buy",
-      qty: 100,
-      priceVnd: 25000,
-      ts: 1000,
-    });
-    // id must be present (ObjectId from _id).
-    expect(trades[0].id).toBeDefined();
-  });
-});
-
-// ─── distinctUsers ────────────────────────────────────────────────────────────
-
-describe("MongoTradesStore.distinctUsers", () => {
-  it("returns [] when collection is empty", async () => {
-    const { store } = makeStore();
-    expect(await store.distinctUsers()).toEqual([]);
-  });
-
-  it("returns each user_id exactly once", async () => {
-    const { store } = makeStore();
-    await store.insert({ ...TRADE, userId: 1 });
-    await store.insert({ ...TRADE, userId: 2 });
-    await store.insert({ ...TRADE, userId: 1 }); // duplicate
-    const users = await store.distinctUsers();
-    expect(users.sort()).toEqual([1, 2]);
-  });
-});
-
-// ─── oldRowsForUser ───────────────────────────────────────────────────────────
-
-describe("MongoTradesStore.oldRowsForUser", () => {
-  it("returns [] when user has fewer rows than keepN", async () => {
-    const { store } = makeStore();
-    await store.insert({ ...TRADE, ts: 1 });
-    await store.insert({ ...TRADE, ts: 2 });
-    expect(await store.oldRowsForUser(1, 5)).toEqual([]);
-  });
-
-  it("returns ids of rows beyond keepN (oldest rows)", async () => {
-    const { store } = makeStore();
-    // Insert 5 rows; keepN=3 → should return ids of the 2 oldest (ts=1,2).
-    const insertedIds = [];
-    for (let i = 1; i <= 5; i++) {
-      await store.insert({ ...TRADE, ts: i });
-    }
-    // Fetch all to get their _ids in ts order.
-    const all = await store.byUser(1, 10); // newest first: ts=5,4,3,2,1
-    const oldIds = await store.oldRowsForUser(1, 3);
-    // The 2 oldest (ts=1, ts=2) should be in oldIds.
-    expect(oldIds).toHaveLength(2);
-    // all[3].id = ts=2, all[4].id = ts=1 (index 3 and 4 in newest-first order).
-    const expectedIds = [all[3].id, all[4].id].map(String).sort();
-    const actualIds = oldIds.map(String).sort();
-    expect(actualIds).toEqual(expectedIds);
-  });
-
-  it("only returns ids for the specified user, not others", async () => {
-    const { store } = makeStore();
-    // User 1: 4 rows, keepN=2 → 2 excess.
-    for (let i = 1; i <= 4; i++) {
-      await store.insert({ ...TRADE, userId: 1, ts: i });
-    }
-    // User 2: 4 rows, keepN=2 → should not appear.
-    for (let i = 1; i <= 4; i++) {
-      await store.insert({ ...TRADE, userId: 2, ts: i });
-    }
-    const oldIds = await store.oldRowsForUser(1, 2);
-    expect(oldIds).toHaveLength(2);
-    // Verify by fetching user1 docs to check _ids.
-    const user1Docs = await store.byUser(1, 10); // newest first
-    const expectedIds = [user1Docs[2].id, user1Docs[3].id].map(String).sort();
-    expect(oldIds.map(String).sort()).toEqual(expectedIds);
-  });
-});
-
-// ─── oldRows ──────────────────────────────────────────────────────────────────
-
-describe("MongoTradesStore.oldRows", () => {
-  it("returns [] when total rows <= keepN", async () => {
-    const { store } = makeStore();
-    await store.insert({ ...TRADE, ts: 1 });
-    expect(await store.oldRows(5)).toEqual([]);
-  });
-
-  it("returns ids of rows beyond keepN globally", async () => {
-    const { store } = makeStore();
-    for (let i = 1; i <= 5; i++) {
-      await store.insert({ ...TRADE, ts: i });
-    }
-    const oldIds = await store.oldRows(3);
-    // 5 rows, keepN=3 → 2 excess (ts=1 and ts=2).
-    expect(oldIds).toHaveLength(2);
-  });
-
-  it("spans multiple users", async () => {
-    const { store } = makeStore();
-    for (let i = 1; i <= 3; i++) {
-      await store.insert({ ...TRADE, userId: 1, ts: i });
-    }
-    for (let i = 4; i <= 6; i++) {
-      await store.insert({ ...TRADE, userId: 2, ts: i });
-    }
-    // 6 rows total, keepN=4 → 2 oldest excess (ts=1,2).
-    const oldIds = await store.oldRows(4);
-    expect(oldIds).toHaveLength(2);
-  });
-});
-
-// ─── deleteByIds ──────────────────────────────────────────────────────────────
-
-describe("MongoTradesStore.deleteByIds", () => {
-  it("returns { deletedCount: 0 } for empty ids array (no db call)", async () => {
-    const { store } = makeStore();
-    const result = await store.deleteByIds([]);
-    expect(result).toEqual({ deletedCount: 0 });
-  });
-
-  it("deletes the specified documents by _id", async () => {
-    const { store } = makeStore();
-    await store.insert({ ...TRADE, ts: 1 });
-    await store.insert({ ...TRADE, ts: 2 });
-    await store.insert({ ...TRADE, ts: 3 });
-
-    const oldIds = await store.oldRows(2); // 1 excess (ts=1)
-    expect(oldIds).toHaveLength(1);
-
-    const result = await store.deleteByIds(oldIds);
-    expect(result.deletedCount).toBe(1);
-
-    // Only 2 rows should remain.
-    const remaining = await store.byUser(1, 10);
-    expect(remaining).toHaveLength(2);
-    expect(remaining.map((t) => t.ts)).toEqual([3, 2]);
-  });
-
-  it("handles a mix of trades with and without legacy_id", async () => {
-    const { fakeDb, store } = makeStore();
-
-    // Seed one legacy doc (as if backfilled) with an ObjectId _id.
-    const legacyId = new ObjectId();
-    const coll = fakeDb.collection("trading_trades");
-    await coll.insertOne({
-      _id: legacyId,
-      legacy_id: 42,
-      user_id: 1,
-      symbol: "VNM",
-      side: "buy",
-      qty: 50,
-      price_vnd: 80000,
-      ts: 500,
-    });
-
-    // Insert a runtime trade (ts=1000, legacy_id=null).
-    await store.insert({ ...TRADE, ts: 1000 });
-
-    // oldRows(1) → the legacy doc (ts=500) is older → its _id returned.
-    const oldIds = await store.oldRows(1);
-    expect(oldIds).toHaveLength(1);
-    expect(String(oldIds[0])).toBe(String(legacyId));
-
-    await store.deleteByIds(oldIds);
-
-    const remaining = await store.byUser(1, 10);
-    expect(remaining).toHaveLength(1);
-    expect(remaining[0].ts).toBe(1000);
-  });
-});
diff --git a/tests/db/stub-mongo-sentinel.test.js b/tests/db/stub-mongo-sentinel.test.js
deleted file mode 100644
index f359668..0000000
--- a/tests/db/stub-mongo-sentinel.test.js
+++ /dev/null
@@ -1,192 +0,0 @@
-/**
- * @file stub-mongo-sentinel.test.js — asserts that MongoClient.prototype.connect
- * is NEVER called when STUB_SENTINEL flows through the store factories.
- *
- * This is the regression test for code-reviewer finding #2: deploy-time
- * register.js must not attempt an Atlas connection.
- *
- * Covers every flag combination from the matrix where MONGODB_URI is set to
- * STUB_SENTINEL — all should return CF-only stores and never touch MongoClient.
- */
-
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { STUB_SENTINEL } from "../../scripts/stub-kv.js";
-import { createSqlStore } from "../../src/db/create-sql-store.js";
-import { createStore } from "../../src/db/create-store.js";
-import { makeFakeD1 } from "../fakes/fake-d1.js";
-import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
-
-// ---------------------------------------------------------------------------
-// Spy on MongoClient.prototype.connect
-// ---------------------------------------------------------------------------
-
-let connectSpy;
-
-beforeEach(async () => {
-  const { MongoClient } = await import("mongodb");
-  connectSpy = vi.spyOn(MongoClient.prototype, "connect").mockResolvedValue(undefined);
-});
-
-afterEach(() => {
-  connectSpy?.mockRestore();
-});
-
-// ---------------------------------------------------------------------------
-// Helper
-// ---------------------------------------------------------------------------
-
-function makeEnv(overrides = {}) {
-  return {
-    KV: makeFakeKv(),
-    DB: makeFakeD1(),
-    MONGODB_URI: STUB_SENTINEL,
-    ...overrides,
-  };
-}
-
-// ---------------------------------------------------------------------------
-// createStore — all flag combos with STUB_SENTINEL
-// ---------------------------------------------------------------------------
-
-describe("createStore with STUB_SENTINEL — zero MongoClient.connect calls", () => {
-  it("STORAGE_PRIMARY=kv, DUAL_WRITE=1 → CFKVStore only", () => {
-    const env = makeEnv({ STORAGE_PRIMARY: "kv", DUAL_WRITE: "1" });
-    const store = createStore("wordle", env);
-    expect(store).toBeDefined();
-    expect(connectSpy).not.toHaveBeenCalled();
-    // Not a dual store — _kind should be undefined on the wrapper.
-    expect(store._kind).not.toBe("dual");
-  });
-
-  it("STORAGE_PRIMARY=kv, DUAL_WRITE=0 → CFKVStore only", () => {
-    const env = makeEnv({ STORAGE_PRIMARY: "kv", DUAL_WRITE: "0" });
-    createStore("wordle", env);
-    expect(connectSpy).not.toHaveBeenCalled();
-  });
-
-  it("STORAGE_PRIMARY=mongo, DUAL_WRITE=1 → CFKVStore only (sentinel short-circuits)", () => {
-    const env = makeEnv({ STORAGE_PRIMARY: "mongo", DUAL_WRITE: "1" });
-    createStore("wordle", env);
-    expect(connectSpy).not.toHaveBeenCalled();
-  });
-
-  it("STORAGE_PRIMARY=mongo, DUAL_WRITE=0 → CFKVStore only (sentinel short-circuits)", () => {
-    const env = makeEnv({ STORAGE_PRIMARY: "mongo", DUAL_WRITE: "0" });
-    createStore("wordle", env);
-    expect(connectSpy).not.toHaveBeenCalled();
-  });
-
-  it("MONGODB_URI unset → CFKVStore only", () => {
-    const env = makeEnv({ MONGODB_URI: undefined });
-    createStore("wordle", env);
-    expect(connectSpy).not.toHaveBeenCalled();
-  });
-});
-
-// ---------------------------------------------------------------------------
-// createSqlStore — all flag combos with STUB_SENTINEL
-// ---------------------------------------------------------------------------
-
-describe("createSqlStore with STUB_SENTINEL — zero MongoClient.connect calls", () => {
-  it("STORAGE_PRIMARY=kv, DUAL_WRITE=1 → CFSqlStore only", () => {
-    const env = makeEnv({ STORAGE_PRIMARY: "kv", DUAL_WRITE: "1" });
-    const sql = createSqlStore("trading", env);
-    expect(sql).not.toBeNull();
-    expect(connectSpy).not.toHaveBeenCalled();
-  });
-
-  it("STORAGE_PRIMARY=kv, DUAL_WRITE=0 → CFSqlStore only", () => {
-    const env = makeEnv({ STORAGE_PRIMARY: "kv", DUAL_WRITE: "0" });
-    createSqlStore("trading", env);
-    expect(connectSpy).not.toHaveBeenCalled();
-  });
-
-  it("STORAGE_PRIMARY=mongo, DUAL_WRITE=1 → CFSqlStore only (sentinel short-circuits)", () => {
-    const env = makeEnv({ STORAGE_PRIMARY: "mongo", DUAL_WRITE: "1" });
-    createSqlStore("trading", env);
-    expect(connectSpy).not.toHaveBeenCalled();
-  });
-
-  it("STORAGE_PRIMARY=mongo, DUAL_WRITE=0 → CFSqlStore only (sentinel short-circuits)", () => {
-    const env = makeEnv({ STORAGE_PRIMARY: "mongo", DUAL_WRITE: "0" });
-    createSqlStore("trading", env);
-    expect(connectSpy).not.toHaveBeenCalled();
-  });
-
-  it("MONGODB_URI unset → CFSqlStore only, null when DB absent", () => {
-    // No DB — should return null, no Mongo.
-    const env = { KV: makeFakeKv(), MONGODB_URI: undefined };
-    const sql = createSqlStore("trading", env);
-    expect(sql).toBeNull();
-    expect(connectSpy).not.toHaveBeenCalled();
-  });
-});
-
-// ---------------------------------------------------------------------------
-// STUB_SENTINEL constant value
-// ---------------------------------------------------------------------------
-
-describe("STUB_SENTINEL constant", () => {
-  it("is a non-empty string", () => {
-    expect(typeof STUB_SENTINEL).toBe("string");
-    expect(STUB_SENTINEL.length).toBeGreaterThan(0);
-  });
-
-  it("equals the sentinel used inside the factories", () => {
-    // The factories hardcode "__stub_mongo__" — must match the exported constant.
-    expect(STUB_SENTINEL).toBe("__stub_mongo__");
-  });
-});
-
-// ---------------------------------------------------------------------------
-// Flag matrix — non-sentinel creates DualKVStore (and does NOT call connect here)
-// ---------------------------------------------------------------------------
-
-describe("createStore with real URI — returns DualKVStore (_kind=dual)", () => {
-  it("STORAGE_PRIMARY=kv, DUAL_WRITE=1, real URI → dual store", () => {
-    const env = {
-      KV: makeFakeKv(),
-      MONGODB_URI: "mongodb://fake",
-      STORAGE_PRIMARY: "kv",
-      DUAL_WRITE: "1",
-    };
-    const store = createStore("wordle", env);
-    // The wrapper object itself is plain, but the underlying DualKVStore has _kind.
-    // Access via the wrapper's _kind (forwarded in withPrefix).
-    expect(store._kind).toBe("dual");
-  });
-
-  it("STORAGE_PRIMARY=kv, DUAL_WRITE=0, real URI → CF-only (rollback path)", () => {
-    const env = {
-      KV: makeFakeKv(),
-      MONGODB_URI: "mongodb://fake",
-      STORAGE_PRIMARY: "kv",
-      DUAL_WRITE: "0",
-    };
-    const store = createStore("wordle", env);
-    expect(store._kind).not.toBe("dual");
-  });
-
-  it("STORAGE_PRIMARY=mongo, DUAL_WRITE=1, real URI → dual store (Mongo primary)", () => {
-    const env = {
-      KV: makeFakeKv(),
-      MONGODB_URI: "mongodb://fake",
-      STORAGE_PRIMARY: "mongo",
-      DUAL_WRITE: "1",
-    };
-    const store = createStore("wordle", env);
-    expect(store._kind).toBe("dual");
-  });
-
-  it("STORAGE_PRIMARY=mongo, DUAL_WRITE=0, real URI → MongoKVStore only", () => {
-    const env = {
-      KV: makeFakeKv(),
-      MONGODB_URI: "mongodb://fake",
-      STORAGE_PRIMARY: "mongo",
-      DUAL_WRITE: "0",
-    };
-    const store = createStore("wordle", env);
-    // MongoKVStore has no _kind; wrapper forwards undefined.
-    expect(store._kind).toBeUndefined();
-  });
-});
diff --git a/tests/e2e/storage-roundtrip.test.js b/tests/e2e/storage-roundtrip.test.js
deleted file mode 100644
index 02780ea..0000000
--- a/tests/e2e/storage-roundtrip.test.js
+++ /dev/null
@@ -1,254 +0,0 @@
-/**
- * @file storage-roundtrip.test.js — e2e storage integration test.
- *
- * Boots a fake env with:
- *   - MongoKVStore (backed by fake-mongo) for KV path (wordle)
- *   - MongoTradesStore (backed by fake-mongo) for SQL/trades path (trading)
- *   - DUAL_WRITE=1 so DualKVStore is used (CF KV + Mongo both written)
- *
- * Asserts that state written via the store abstractions is visible in BOTH
- * backends — the CF KV fake AND the in-memory Mongo fake.
- *
- * This test intentionally avoids grammY context setup. It exercises the
- * storage layer (the thing being migrated), not the Telegram handler.
- */
-
-import { beforeEach, describe, expect, it } from "vitest";
-import { createStore } from "../../src/db/create-store.js";
-import { MongoKVStore } from "../../src/db/mongo-kv-store.js";
-import { MongoTradesStore } from "../../src/db/mongo-trades-store.js";
-import { listTrades, recordTrade } from "../../src/modules/trading/history.js";
-import { loadGame, loadStats, recordResult, saveGame } from "../../src/modules/wordle/state.js";
-import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
-import { makeFakeMongo } from "../fakes/fake-mongo.js";
-
-// ---------------------------------------------------------------------------
-// Shared setup
-// ---------------------------------------------------------------------------
-
-let cfKv;
-let fakeDb;
-let env;
-
-beforeEach(() => {
-  cfKv = makeFakeKv();
-  fakeDb = makeFakeMongo();
-  // env with real-looking MONGODB_URI so factories pick the dual-write path.
-  // MongoKVStore receives dbOverride (fakeDb) so no real Atlas connection happens.
-  env = {
-    KV: cfKv,
-    MONGODB_URI: "mongodb://fake-atlas",
-    STORAGE_PRIMARY: "kv",
-    DUAL_WRITE: "1",
-  };
-});
-
-// ---------------------------------------------------------------------------
-// KV path — wordle game state
-// ---------------------------------------------------------------------------
-
-describe("KV dual-write — wordle game state", () => {
-  it("saveGame persists to both CF KV and MongoKVStore (fake-mongo)", async () => {
-    // Build the dual store: createStore uses DualKVStore with CF primary + Mongo secondary.
-    // Pass fakeDb as dbOverride so MongoKVStore talks to fake-mongo, not Atlas.
-    const mongoKvStore = new MongoKVStore(env, "wordle", fakeDb);
-
-    // Manually construct the dual store to inject fakeDb.
-    const { DualKVStore } = await import("../../src/db/dual-kv-store.js");
-    const { CFKVStore } = await import("../../src/db/cf-kv-store.js");
-    const cfStore = new CFKVStore(cfKv);
-
-    // Prefix wrapper that mirrors create-store.js behaviour.
-    function prefixedStore(base, prefix) {
-      return {
-        async get(key) {
-          return base.get(prefix + key);
-        },
-        async put(key, value, opts) {
-          return base.put(prefix + key, value, opts);
-        },
-        async delete(key) {
-          return base.delete(prefix + key);
-        },
-        async list(opts = {}) {
-          const fullPrefix = prefix + (opts.prefix ?? "");
-          const result = await base.list({
-            prefix: fullPrefix,
-            limit: opts.limit,
-            cursor: opts.cursor,
-          });
-          return {
-            keys: result.keys.map((k) => (k.startsWith(prefix) ? k.slice(prefix.length) : k)),
-            cursor: result.cursor,
-            done: result.done,
-          };
-        },
-        async getJSON(key) {
-          return base.getJSON(prefix + key);
-        },
-        async putJSON(key, value, opts) {
-          return base.putJSON(prefix + key, value, opts);
-        },
-      };
-    }
-
-    const dual = new DualKVStore(cfStore, mongoKvStore, cfKv);
-    const db = prefixedStore(dual, "wordle:");
-
-    const gameState = {
-      target: "crane",
-      guesses: [{ word: "audio", results: ["absent", "absent", "absent", "absent", "absent"] }],
-      solved: false,
-      startedAt: Date.now(),
-    };
-
-    await saveGame(db, 42, gameState);
-
-    // 1. CF KV should have the prefixed key.
-    expect(cfKv.store.has("wordle:game:42")).toBe(true);
-    const cfStored = JSON.parse(cfKv.store.get("wordle:game:42"));
-    expect(cfStored.target).toBe("crane");
-
-    // 2. Mongo fake should have the same key.
-    const mongoColl = fakeDb.collection("wordle");
-    const mongoDoc = await mongoColl.findOne({ _id: "wordle:game:42" });
-    expect(mongoDoc).not.toBeNull();
-    expect(JSON.parse(mongoDoc.value).target).toBe("crane");
-
-    // 3. loadGame reads from primary (CF KV).
-    const loaded = await loadGame(db, 42);
-    expect(loaded?.target).toBe("crane");
-    expect(loaded?.guesses).toHaveLength(1);
-  });
-
-  it("recordResult persists stats to both backends", async () => {
-    const mongoKvStore = new MongoKVStore(env, "wordle", fakeDb);
-    const { DualKVStore } = await import("../../src/db/dual-kv-store.js");
-    const { CFKVStore } = await import("../../src/db/cf-kv-store.js");
-    const cfStore = new CFKVStore(cfKv);
-    const dual = new DualKVStore(cfStore, mongoKvStore, cfKv);
-
-    function prefixedStore(base, prefix) {
-      return {
-        async get(key) {
-          return base.get(prefix + key);
-        },
-        async put(key, value, opts) {
-          return base.put(prefix + key, value, opts);
-        },
-        async delete(key) {
-          return base.delete(prefix + key);
-        },
-        async list(opts = {}) {
-          const fullPrefix = prefix + (opts.prefix ?? "");
-          const result = await base.list({
-            prefix: fullPrefix,
-            limit: opts.limit,
-            cursor: opts.cursor,
-          });
-          return {
-            keys: result.keys.map((k) => (k.startsWith(prefix) ? k.slice(prefix.length) : k)),
-            cursor: result.cursor,
-            done: result.done,
-          };
-        },
-        async getJSON(key) {
-          return base.getJSON(prefix + key);
-        },
-        async putJSON(key, value, opts) {
-          return base.putJSON(prefix + key, value, opts);
-        },
-      };
-    }
-
-    const db = prefixedStore(dual, "wordle:");
-    await recordResult(db, 99, true);
-
-    // CF KV has the stats key.
-    expect(cfKv.store.has("wordle:stats:99")).toBe(true);
-    const cfStats = JSON.parse(cfKv.store.get("wordle:stats:99"));
-    expect(cfStats.wins).toBe(1);
-    expect(cfStats.streak).toBe(1);
-
-    // Mongo fake also has it.
-    const mongoColl = fakeDb.collection("wordle");
-    const mongoDoc = await mongoColl.findOne({ _id: "wordle:stats:99" });
-    expect(mongoDoc).not.toBeNull();
-    expect(JSON.parse(mongoDoc.value).wins).toBe(1);
-
-    // loadStats reads from primary.
-    const stats = await loadStats(db, 99);
-    expect(stats.wins).toBe(1);
-  });
-});
-
-// ---------------------------------------------------------------------------
-// SQL / trades path — trading insert via MongoTradesStore
-// ---------------------------------------------------------------------------
-
-describe("SQL dual-write — trading insert via MongoTradesStore", () => {
-  it("recordTrade persists via MongoTradesStore (fake-mongo)", async () => {
-    // Trading uses MongoTradesStore directly (not via DualSqlStore) when tradesStore is provided.
-    const tradesStore = new MongoTradesStore(env, fakeDb);
-
-    await recordTrade(
-      null,
-      {
-        userId: 123,
-        symbol: "VNM",
-        side: "buy",
-        qty: 10,
-        priceVnd: 50000,
-      },
-      tradesStore,
-    );
-
-    // Mongo should have the trade.
-    const tradeColl = fakeDb.collection("trading_trades");
-    const docs = await tradeColl.find({}).toArray();
-    expect(docs).toHaveLength(1);
-    expect(docs[0].user_id).toBe(123);
-    expect(docs[0].symbol).toBe("VNM");
-    expect(docs[0].side).toBe("buy");
-    expect(docs[0].qty).toBe(10);
-    expect(docs[0].price_vnd).toBe(50000);
-  });
-
-  it("listTrades reads from MongoTradesStore", async () => {
-    const tradesStore = new MongoTradesStore(env, fakeDb);
-
-    // Insert two trades.
-    await recordTrade(
-      null,
-      { userId: 7, symbol: "FPT", side: "buy", qty: 5, priceVnd: 100000 },
-      tradesStore,
-    );
-    await recordTrade(
-      null,
-      { userId: 7, symbol: "VIC", side: "sell", qty: 2, priceVnd: 80000 },
-      tradesStore,
-    );
-
-    const trades = await listTrades(null, 7, 10, tradesStore);
-    expect(trades).toHaveLength(2);
-    // Newest first (MongoTradesStore returns sorted by ts desc).
-    expect(trades.map((t) => t.symbol)).toContain("FPT");
-    expect(trades.map((t) => t.symbol)).toContain("VIC");
-  });
-
-  it("DUAL_WRITE=1 env flag is present in env passed through registry init", () => {
-    // Verify the flag matrix expectation: when DUAL_WRITE=1 and MONGODB_URI is real,
-    // buildTradesStore should return a MongoTradesStore.
-    const localEnv = {
-      KV: cfKv,
-      MONGODB_URI: "mongodb://fake",
-      STORAGE_PRIMARY: "kv",
-      DUAL_WRITE: "1",
-    };
-    // Direct validation — the helper in registry.js would return a MongoTradesStore.
-    // We test its outcome indirectly: DUAL_WRITE="1" !== "0" is true.
-    expect(localEnv.DUAL_WRITE !== "0").toBe(true);
-    expect(!!localEnv.MONGODB_URI).toBe(true);
-    expect(localEnv.MONGODB_URI).not.toBe("__stub_mongo__");
-  });
-});
diff --git a/tests/fakes/fake-bot.js b/tests/fakes/fake-bot.js
index ce0a8f4..4e6e800 100644
--- a/tests/fakes/fake-bot.js
+++ b/tests/fakes/fake-bot.js
@@ -10,22 +10,15 @@ export function makeFakeBot() {
   const commandCalls = [];
   /** @type {Array<{event: string, handler: Function}>} */
   const onCalls = [];
-  /** @type {Array} */
-  const useCalls = [];
 
   return {
     commandCalls,
     onCalls,
-    useCalls,
     command(name, handler) {
       commandCalls.push({ name, handler });
     },
     on(event, handler) {
       onCalls.push({ event, handler });
     },
-    /** Records middleware registered via bot.use() (e.g. timing middleware). */
-    use(middleware) {
-      useCalls.push(middleware);
-    },
   };
 }
diff --git a/tests/fakes/fake-mongo.js b/tests/fakes/fake-mongo.js
deleted file mode 100644
index bbdee62..0000000
--- a/tests/fakes/fake-mongo.js
+++ /dev/null
@@ -1,306 +0,0 @@
-/**
- * @file fake-mongo — Map-backed in-memory MongoDB fake for unit tests.
- *
- * **FROZEN SURFACE** (Phase 02–08): This fake implements the minimal subset of MongoDB
- * API required by MongoKVStore and MongoTradesStore tests. New MongoClient features
- * must be added to this fake to remain testable.
- *
- * **Implemented methods:**
- * - `db.collection(name)` → Collection
- * - Collection: `findOne(query)`, `updateOne(filter, update, opts)`, `deleteOne(query)`,
- *   `find(query)`, `insertOne(doc)`, `insertMany(docs)`, `distinct(field, query)`,
- *   `deleteMany(query)`, `countDocuments(query)`, `createIndex(spec)` (no-op)
- *
- * **Cursor methods** (from `find()`): `sort(spec)`, `skip(n)`, `limit(n)`,
- * `project(spec)`, `toArray()`
- *
- * **NOT SIMULATED:**
- * - TTL expirations at server-side. Tests assert `expiresAt` field presence and
- *   control Date.now() via `vi.setSystemTime()` to test read-time expiration filters
- *   in MongoKVStore. Real Atlas TTL is validated during Phase 06 soak.
- * - Transactions, sharding, replica sets, or other cluster features.
- * - Advanced operators beyond those in `matchQuery()` ($gt, $gte, $lt, $lte,
- *   $exists, $in, $regex, $or, $and).
- *
- * @see tests/db/mongo-kv-store.test.js
- * @see tests/modules/trading/ (trades store usage)
- */
-
-/**
- * Apply $set and $unset from an update document to a target object.
- *
- * @param {object} doc
- * @param {object} update
- * @returns {object}
- */
-function applyUpdate(doc, update) {
-  const result = { ...doc };
-  if (update.$set) {
-    for (const [k, v] of Object.entries(update.$set)) {
-      result[k] = v;
-    }
-  }
-  if (update.$unset) {
-    for (const key of Object.keys(update.$unset)) {
-      delete result[key];
-    }
-  }
-  return result;
-}
-
-/**
- * Minimal regex-query matcher. Supports:
- *   { field: value }          — strict equality
- *   { field: { $gt: v } }     — greater-than
- *   { field: { $exists: b } } — field existence
- *   { $or: [cond, ...] }      — logical OR
- *   { $and: [cond, ...] }     — logical AND
- *
- * @param {object} doc
- * @param {object} query
- * @returns {boolean}
- */
-function matchQuery(doc, query) {
-  for (const [key, condition] of Object.entries(query)) {
-    if (key === "$or") {
-      if (!condition.some((sub) => matchQuery(doc, sub))) return false;
-      continue;
-    }
-    if (key === "$and") {
-      if (!condition.every((sub) => matchQuery(doc, sub))) return false;
-      continue;
-    }
-    if (condition !== null && typeof condition === "object" && !Array.isArray(condition)) {
-      const ops = Object.keys(condition);
-      if (ops.some((op) => op.startsWith("$"))) {
-        for (const [op, operand] of Object.entries(condition)) {
-          if (op === "$gt") {
-            if (!(doc[key] > operand)) return false;
-          } else if (op === "$gte") {
-            if (!(doc[key] >= operand)) return false;
-          } else if (op === "$lt") {
-            if (!(doc[key] < operand)) return false;
-          } else if (op === "$lte") {
-            if (!(doc[key] <= operand)) return false;
-          } else if (op === "$exists") {
-            const has = key in doc && doc[key] !== undefined;
-            if (operand !== has) return false;
-          } else if (op === "$in") {
-            if (!operand.includes(doc[key])) return false;
-          } else if (op === "$regex") {
-            const flags = condition.$options ?? "";
-            if (!new RegExp(operand, flags).test(doc[key])) return false;
-          }
-        }
-        continue;
-      }
-    }
-    if (doc[key] !== condition) return false;
-  }
-  return true;
-}
-
-/**
- * Returns a chainable cursor builder from an array of matched docs.
- *
- * @param {object[]} docs
- * @returns {object} chainable cursor with sort/skip/limit/project/toArray
- */
-function makeCursor(docs) {
-  const items = [...docs];
-  let sortField = null;
-  let sortDir = 1;
-  let skipN = 0;
-  let limitN = Number.POSITIVE_INFINITY;
-  let projection = null;
-
-  const cursor = {
-    sort(spec) {
-      const entries = Object.entries(spec);
-      if (entries.length > 0) {
-        [sortField, sortDir] = [entries[0][0], entries[0][1]];
-      }
-      return cursor;
-    },
-    skip(n) {
-      skipN = n;
-      return cursor;
-    },
-    limit(n) {
-      limitN = n;
-      return cursor;
-    },
-    project(spec) {
-      projection = spec;
-      return cursor;
-    },
-    async toArray() {
-      let result = [...items];
-      if (sortField !== null) {
-        result.sort((a, b) => {
-          if (a[sortField] < b[sortField]) return -sortDir;
-          if (a[sortField] > b[sortField]) return sortDir;
-          return 0;
-        });
-      }
-      result = result.slice(
-        skipN,
-        limitN === Number.POSITIVE_INFINITY ? undefined : skipN + limitN,
-      );
-      if (projection) {
-        result = result.map((doc) => {
-          const out = {};
-          for (const [k, include] of Object.entries(projection)) {
-            if (include) out[k] = doc[k];
-          }
-          return out;
-        });
-      }
-      return result;
-    },
-  };
-  return cursor;
-}
-
-/**
- * Create a fake MongoDB collection backed by a Map.
- *
- * @param {Map} store
- * @returns {object}
- */
-function makeCollection(store) {
-  return {
-    /** @returns {Promise} */
-    async findOne(query) {
-      for (const doc of store.values()) {
-        if (matchQuery(doc, query)) return { ...doc };
-      }
-      return null;
-    },
-
-    /**
-     * Supports upsert with $set / $unset.
-     * @returns {Promise<{matchedCount: number, upsertedCount: number, modifiedCount: number}>}
-     */
-    async updateOne(filter, update, opts = {}) {
-      for (const [id, doc] of store.entries()) {
-        if (matchQuery(doc, filter)) {
-          store.set(id, applyUpdate(doc, update));
-          return { matchedCount: 1, upsertedCount: 0, modifiedCount: 1 };
-        }
-      }
-      if (opts.upsert) {
-        // Build new doc from filter equality fields + $set fields
-        const newDoc = {};
-        for (const [k, v] of Object.entries(filter)) {
-          if (typeof v !== "object") newDoc[k] = v;
-        }
-        const merged = applyUpdate(newDoc, update);
-        const id = merged._id ?? String(Date.now() + Math.random());
-        merged._id = id;
-        store.set(String(id), merged);
-        return { matchedCount: 0, upsertedCount: 1, modifiedCount: 0 };
-      }
-      return { matchedCount: 0, upsertedCount: 0, modifiedCount: 0 };
-    },
-
-    /** @returns {Promise<{deletedCount: number}>} */
-    async deleteOne(filter) {
-      for (const [id, doc] of store.entries()) {
-        if (matchQuery(doc, filter)) {
-          store.delete(id);
-          return { deletedCount: 1 };
-        }
-      }
-      return { deletedCount: 0 };
-    },
-
-    /**
-     * Returns a chainable cursor.
-     * @param {object} [query]
-     * @returns {object}
-     */
-    find(query = {}) {
-      const matched = [...store.values()].filter((doc) => matchQuery(doc, query));
-      return makeCursor(matched);
-    },
-
-    /** @returns {Promise<{insertedId: string}>} */
-    async insertOne(doc) {
-      const id = doc._id ?? String(Date.now() + Math.random());
-      store.set(String(id), { ...doc, _id: id });
-      return { insertedId: id };
-    },
-
-    /** @returns {Promise<{insertedIds: string[]}>} */
-    async insertMany(docs) {
-      const insertedIds = [];
-      for (const doc of docs) {
-        const id = doc._id ?? String(Date.now() + Math.random());
-        store.set(String(id), { ...doc, _id: id });
-        insertedIds.push(id);
-      }
-      return { insertedIds };
-    },
-
-    /** @returns {Promise} */
-    async distinct(field, query = {}) {
-      const values = new Set();
-      for (const doc of store.values()) {
-        if (matchQuery(doc, query) && field in doc) {
-          values.add(doc[field]);
-        }
-      }
-      return [...values];
-    },
-
-    /** @returns {Promise<{deletedCount: number}>} */
-    async deleteMany(filter = {}) {
-      let count = 0;
-      for (const [id, doc] of store.entries()) {
-        if (matchQuery(doc, filter)) {
-          store.delete(id);
-          count++;
-        }
-      }
-      return { deletedCount: count };
-    },
-
-    /** @returns {Promise} */
-    async countDocuments(query = {}) {
-      let count = 0;
-      for (const doc of store.values()) {
-        if (matchQuery(doc, query)) count++;
-      }
-      return count;
-    },
-
-    /** No-op — index creation is idempotent; tests only verify field presence. */
-    async createIndex(_spec, _opts) {
-      return "ok";
-    },
-  };
-}
-
-/**
- * Create a fake MongoDB Db object.
- * Each collection is lazily created and backed by its own Map.
- *
- * @returns {{ collection: (name: string) => object, _stores: Map> }}
- */
-export function makeFakeMongo() {
-  /** @type {Map>} */
-  const stores = new Map();
-
-  return {
-    /** @param {string} name */
-    collection(name) {
-      if (!stores.has(name)) {
-        stores.set(name, new Map());
-      }
-      return makeCollection(stores.get(name));
-    },
-    /** Expose raw stores so tests can inspect or seed data directly. */
-    _stores: stores,
-  };
-}
diff --git a/tests/modules/trading/history.test.js b/tests/modules/trading/history.test.js
index c629691..e524151 100644
--- a/tests/modules/trading/history.test.js
+++ b/tests/modules/trading/history.test.js
@@ -1,12 +1,10 @@
 /**
  * @file Tests for trading/history — recordTrade, listTrades, formatTradesHtml,
  * createHistoryHandler, and buy/sell → D1 integration.
- * Also covers the MongoTradesStore path (tradesStore arg) added in Phase 03.
  */
 
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import { createSqlStore } from "../../../src/db/create-sql-store.js";
-import { MongoTradesStore } from "../../../src/db/mongo-trades-store.js";
 import {
   createHistoryHandler,
   formatTradesHtml,
@@ -14,7 +12,6 @@ import {
   recordTrade,
 } from "../../../src/modules/trading/history.js";
 import { makeFakeD1 } from "../../fakes/fake-d1.js";
-import { makeFakeMongo } from "../../fakes/fake-mongo.js";
 
 // ─── helpers ────────────────────────────────────────────────────────────────
 
@@ -350,94 +347,3 @@ describe("buy/sell handlers → recordTrade integration", () => {
     expect(ctx.reply).toHaveBeenCalledWith(expect.stringContaining("Bought"));
   });
 });
-
-// ─── MongoTradesStore path (Phase 03) ────────────────────────────────────────
-
-/** Build a MongoTradesStore backed by a fresh fake Mongo db. */
-function makeTestTradesStore() {
-  const fakeDb = makeFakeMongo();
-  const tradesStore = new MongoTradesStore({}, fakeDb);
-  return { fakeDb, tradesStore };
-}
-
-describe("recordTrade — tradesStore path", () => {
-  it("calls tradesStore.insert when tradesStore is provided", async () => {
-    const { tradesStore } = makeTestTradesStore();
-    const spy = vi.spyOn(tradesStore, "insert");
-    await recordTrade(null, TRADE, tradesStore);
-    expect(spy).toHaveBeenCalledOnce();
-    expect(spy).toHaveBeenCalledWith(expect.objectContaining({ userId: 1, symbol: "TCB" }));
-  });
-
-  it("does NOT call sql when tradesStore is provided", async () => {
-    const { fakeDb, sql } = makeTestSql();
-    const { tradesStore } = makeTestTradesStore();
-    await recordTrade(sql, TRADE, tradesStore);
-    // D1 runLog must be empty — Mongo path was used.
-    expect(fakeDb.runLog).toHaveLength(0);
-  });
-
-  it("logs error and does not throw when tradesStore.insert fails", async () => {
-    const { tradesStore } = makeTestTradesStore();
-    vi.spyOn(tradesStore, "insert").mockRejectedValue(new Error("mongo down"));
-    const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
-    await expect(recordTrade(null, TRADE, tradesStore)).resolves.toBeUndefined();
-    expect(errSpy).toHaveBeenCalledWith(
-      expect.stringContaining("recordTrade (mongo) failed"),
-      expect.any(Error),
-    );
-    errSpy.mockRestore();
-  });
-});
-
-describe("listTrades — tradesStore path", () => {
-  it("calls tradesStore.byUser when tradesStore is provided", async () => {
-    const { tradesStore } = makeTestTradesStore();
-    const spy = vi.spyOn(tradesStore, "byUser").mockResolvedValue([]);
-    await listTrades(null, 1, 10, tradesStore);
-    expect(spy).toHaveBeenCalledWith(1, 10);
-  });
-
-  it("returns mapped trades from tradesStore", async () => {
-    const { tradesStore } = makeTestTradesStore();
-    await tradesStore.insert({
-      userId: 1,
-      symbol: "VNM",
-      side: "sell",
-      qty: 5,
-      priceVnd: 80000,
-      ts: 500,
-    });
-    const trades = await listTrades(null, 1, 10, tradesStore);
-    expect(trades).toHaveLength(1);
-    expect(trades[0].symbol).toBe("VNM");
-    expect(trades[0].priceVnd).toBe(80000);
-  });
-
-  it("clamps limit before passing to tradesStore", async () => {
-    const { tradesStore } = makeTestTradesStore();
-    const spy = vi.spyOn(tradesStore, "byUser").mockResolvedValue([]);
-    await listTrades(null, 1, 999, tradesStore);
-    expect(spy).toHaveBeenCalledWith(1, 50);
-    await listTrades(null, 1, 0, tradesStore);
-    expect(spy).toHaveBeenCalledWith(1, 1);
-  });
-});
-
-describe("createHistoryHandler — tradesStore path", () => {
-  it("uses tradesStore when provided and sql is null", async () => {
-    const { tradesStore } = makeTestTradesStore();
-    await tradesStore.insert({
-      userId: 1,
-      symbol: "TCB",
-      side: "buy",
-      qty: 10,
-      priceVnd: 25000,
-      ts: 1,
-    });
-    const handler = createHistoryHandler(null, tradesStore);
-    const ctx = { match: "", from: { id: 1 }, reply: vi.fn() };
-    await handler(ctx);
-    expect(ctx.reply).toHaveBeenCalledWith(expect.stringContaining("TCB"), { parse_mode: "HTML" });
-  });
-});
diff --git a/tests/modules/trading/retention.test.js b/tests/modules/trading/retention.test.js
index efdb6d5..24039e0 100644
--- a/tests/modules/trading/retention.test.js
+++ b/tests/modules/trading/retention.test.js
@@ -6,16 +6,12 @@
  *   - SELECT id ... WHERE user_id = ? ORDER BY ts DESC LIMIT -1 OFFSET N
  *   - SELECT id ... ORDER BY ts DESC LIMIT -1 OFFSET N
  *   - DELETE FROM ... WHERE id IN (?, ...)
- *
- * Also covers the MongoTradesStore path added in Phase 03.
  */
 
 import { beforeEach, describe, expect, it, vi } from "vitest";
 import { createSqlStore } from "../../../src/db/create-sql-store.js";
-import { MongoTradesStore } from "../../../src/db/mongo-trades-store.js";
 import { trimTradesHandler } from "../../../src/modules/trading/retention.js";
 import { makeFakeD1 } from "../../fakes/fake-d1.js";
-import { makeFakeMongo } from "../../fakes/fake-mongo.js";
 
 // ─── helpers ─────────────────────────────────────────────────────────────────
 
@@ -197,100 +193,3 @@ describe("trimTradesHandler — idempotence", () => {
     expect(afterFirst).toBe(afterSecond);
   });
 });
-
-// ─── MongoTradesStore path (Phase 03) ────────────────────────────────────────
-
-/** Build a MongoTradesStore backed by a fresh fake Mongo db. */
-function makeTestTradesStore() {
-  const fakeDb = makeFakeMongo();
-  const tradesStore = new MongoTradesStore({}, fakeDb);
-  return { tradesStore };
-}
-
-/** Convenience: run trimTradesHandler with tradesStore, muted console output. */
-async function runTrimMongo(tradesStore, caps) {
-  const spy = vi.spyOn(console, "log").mockImplementation(() => {});
-  try {
-    await trimTradesHandler({}, { sql: null, tradesStore }, caps);
-  } finally {
-    spy.mockRestore();
-  }
-}
-
-describe("trimTradesHandler — tradesStore path (null sql)", () => {
-  it("skips when tradesStore is null and sql is null", async () => {
-    const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
-    await expect(trimTradesHandler({}, { sql: null, tradesStore: null })).resolves.toBeUndefined();
-    logSpy.mockRestore();
-  });
-
-  it("per-user trim keeps newest rows via MongoTradesStore", async () => {
-    const { tradesStore } = makeTestTradesStore();
-    for (let i = 1; i <= 5; i++) {
-      await tradesStore.insert({
-        userId: 1,
-        symbol: "TCB",
-        side: "buy",
-        qty: 1,
-        priceVnd: 1,
-        ts: i,
-      });
-    }
-    await runTrimMongo(tradesStore, { perUserCap: 3, globalCap: 1000 });
-    const remaining = await tradesStore.byUser(1, 100);
-    expect(remaining).toHaveLength(3);
-    expect(remaining.map((t) => t.ts)).toEqual([5, 4, 3]);
-  });
-
-  it("global trim keeps newest rows across all users via MongoTradesStore", async () => {
-    const { tradesStore } = makeTestTradesStore();
-    for (let i = 1; i <= 8; i++) {
-      await tradesStore.insert({
-        userId: 1,
-        symbol: "TCB",
-        side: "buy",
-        qty: 1,
-        priceVnd: 1,
-        ts: i,
-      });
-    }
-    for (let i = 9; i <= 15; i++) {
-      await tradesStore.insert({
-        userId: 2,
-        symbol: "VNM",
-        side: "sell",
-        qty: 1,
-        priceVnd: 1,
-        ts: i,
-      });
-    }
-    await runTrimMongo(tradesStore, { perUserCap: 1000, globalCap: 10 });
-
-    const u1 = await tradesStore.byUser(1, 100);
-    const u2 = await tradesStore.byUser(2, 100);
-    expect(u1.length + u2.length).toBe(10);
-  });
-
-  it("uses tradesStore path even when sql is provided alongside tradesStore", async () => {
-    const { tradesStore } = makeTestTradesStore();
-    for (let i = 1; i <= 5; i++) {
-      await tradesStore.insert({
-        userId: 1,
-        symbol: "TCB",
-        side: "buy",
-        qty: 1,
-        priceVnd: 1,
-        ts: i,
-      });
-    }
-    // Provide a sql too — tradesStore must win.
-    const fakeD1 = makeFakeD1();
-    const sql = createSqlStore("trading", { DB: fakeD1 });
-    const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
-    await trimTradesHandler({}, { sql, tradesStore }, { perUserCap: 3, globalCap: 1000 });
-    logSpy.mockRestore();
-    // D1 must not have been touched.
-    expect(fakeD1.runLog).toHaveLength(0);
-    expect(fakeD1.queryLog).toHaveLength(0);
-  });
-});
diff --git a/tests/scripts/analyze-soak.test.js b/tests/scripts/analyze-soak.test.js
deleted file mode 100644
index 0312de9..0000000
--- a/tests/scripts/analyze-soak.test.js
+++ /dev/null
@@ -1,196 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
-  aggregateLines,
-  formatReport,
-  parseLogLine,
-  percentile,
-} from "../../scripts/analyze-soak.js";
-
-// ── percentile ───────────────────────────────────────────────────────────────
-
-describe("percentile", () => {
-  it("returns 0 for empty array", () => {
-    expect(percentile([], 50)).toBe(0);
-  });
-
-  it("returns the only element for a single-element array", () => {
-    expect(percentile([42], 50)).toBe(42);
-    expect(percentile([42], 95)).toBe(42);
-  });
-
-  it("p50 of [1,2,3,4,5] = 3", () => {
-    expect(percentile([1, 2, 3, 4, 5], 50)).toBe(3);
-  });
-
-  it("p100 returns last element", () => {
-    expect(percentile([10, 20, 30], 100)).toBe(30);
-  });
-
-  it("p0 returns first element", () => {
-    expect(percentile([10, 20, 30], 0)).toBe(10);
-  });
-
-  it("p95 of 100-element uniform distribution is near top", () => {
-    const data = Array.from({ length: 100 }, (_, i) => i + 1); // 1..100
-    expect(percentile(data, 95)).toBe(95);
-  });
-});
-
-// ── parseLogLine ─────────────────────────────────────────────────────────────
-
-describe("parseLogLine", () => {
-  it("parses a plain NDJSON line", () => {
-    const line = '{"event":"cmd_timing","cmd":"/wordle","total":120,"cold":true,"marks":[]}';
-    expect(parseLogLine(line)).toEqual({
-      event: "cmd_timing",
-      cmd: "/wordle",
-      total: 120,
-      cold: true,
-      marks: [],
-    });
-  });
-
-  it("returns null for empty line", () => {
-    expect(parseLogLine("")).toBeNull();
-    expect(parseLogLine("   ")).toBeNull();
-  });
-
-  it("returns null for non-JSON plain text", () => {
-    expect(parseLogLine("some random log line")).toBeNull();
-  });
-
-  it("extracts JSON embedded in a CSV row", () => {
-    const csvRow =
-      '2024-01-01T00:00:00Z,worker,"{\\"event\\":\\"cmd_timing\\",\\"cmd\\":\\"/loldle\\",\\"total\\":45,\\"cold\\":false,\\"marks\\":[]}"';
-    const result = parseLogLine(csvRow);
-    // May fail to parse double-escaped CSV — test the raw braces path instead.
-    // Use a simpler CSV format where JSON is not double-escaped:
-    const simpleCsv =
-      '2024-01-01,{"event":"cmd_timing","cmd":"/loldle","total":45,"cold":false,"marks":[]},extra';
-    const r2 = parseLogLine(simpleCsv);
-    expect(r2).not.toBeNull();
-    expect(r2.event).toBe("cmd_timing");
-    expect(r2.cmd).toBe("/loldle");
-  });
-
-  it("returns null for malformed JSON", () => {
-    expect(parseLogLine("{bad json}")).toBeNull();
-  });
-});
-
-// ── aggregateLines ────────────────────────────────────────────────────────────
-
-describe("aggregateLines", () => {
-  const makeTimingLine = (cmd, total, cold) =>
-    JSON.stringify({ event: "cmd_timing", cmd, total, cold, marks: [] });
-
-  it("buckets cold and warm samples separately", () => {
-    const lines = [
-      makeTimingLine("/wordle", 1500, true),
-      makeTimingLine("/wordle", 1400, true),
-      makeTimingLine("/wordle", 50, false),
-      makeTimingLine("/wordle", 60, false),
-      makeTimingLine("/wordle", 55, false),
-    ];
-    const { buckets } = aggregateLines(lines, null);
-    expect(buckets.get("/wordle|cold").n).toBe(2);
-    expect(buckets.get("/wordle|warm").n).toBe(3);
-  });
-
-  it("filters to requested commands only", () => {
-    const lines = [makeTimingLine("/wordle", 100, false), makeTimingLine("/loldle", 200, false)];
-    const { buckets } = aggregateLines(lines, ["/wordle"]);
-    expect(buckets.has("/wordle|warm")).toBe(true);
-    expect(buckets.has("/loldle|warm")).toBe(false);
-  });
-
-  it("counts dual-write secondary failures", () => {
-    const lines = [
-      '{"msg":"[dual-kv] secondary write failed","phase":"dual-kv"}',
-      "dual-write:secondary:failed — key wordle:state:1",
-      makeTimingLine("/wordle", 80, false),
-    ];
-    const { errors } = aggregateLines(lines, null);
-    expect(errors.dualWriteFail).toBe(2);
-  });
-
-  it("counts mongo connection errors", () => {
-    const lines = [
-      "MongoNetworkError: connection refused",
-      "MongoError: timeout",
-      makeTimingLine("/wordle", 80, false),
-    ];
-    const { errors } = aggregateLines(lines, null);
-    expect(errors.mongoError).toBe(2);
-  });
-
-  it("counts CPU time exceeded errors", () => {
-    const lines = ["Worker exceeded CPU time limit", "cpu time exceeded for isolate"];
-    const { errors } = aggregateLines(lines, null);
-    expect(errors.cpuExceeded).toBe(2);
-  });
-
-  it("skips non-cmd_timing JSON events", () => {
-    const lines = [
-      JSON.stringify({ event: "request", cold: true, path: "/webhook" }),
-      makeTimingLine("/wordle", 90, false),
-    ];
-    const { buckets } = aggregateLines(lines, null);
-    expect(buckets.size).toBe(1); // only the cmd_timing entry
-  });
-
-  it("returns empty buckets and zero errors for empty input", () => {
-    const { buckets, errors } = aggregateLines([], null);
-    expect(buckets.size).toBe(0);
-    expect(errors.dualWriteFail).toBe(0);
-    expect(errors.mongoError).toBe(0);
-    expect(errors.cpuExceeded).toBe(0);
-  });
-});
-
-// ── formatReport ─────────────────────────────────────────────────────────────
-
-describe("formatReport", () => {
-  it("produces a markdown table with correct headers", () => {
-    const lines = [
-      JSON.stringify({ event: "cmd_timing", cmd: "/wordle", total: 1500, cold: true, marks: [] }),
-      JSON.stringify({ event: "cmd_timing", cmd: "/wordle", total: 50, cold: false, marks: [] }),
-    ];
-    const { buckets, errors } = aggregateLines(lines, null);
-    const report = formatReport(buckets, errors);
-
-    expect(report).toContain("| cmd | cold/warm | n | p50 | p95 | p99 |");
-    expect(report).toContain("/wordle");
-    expect(report).toContain("cold");
-    expect(report).toContain("warm");
-  });
-
-  it("includes error summary section", () => {
-    const { buckets, errors } = aggregateLines([], null);
-    errors.dualWriteFail = 3;
-    const report = formatReport(buckets, errors);
-    expect(report).toContain("## Error Summary");
-    expect(report).toContain("Dual-write secondary failures: 3");
-    expect(report).toContain("Mongo connection errors: 0");
-    expect(report).toContain("CPU time exceeded: 0");
-  });
-
-  it("computes correct p50/p95/p99 for a known dataset", () => {
-    // 10 warm samples: 10,20,...,100
-    const lines = Array.from({ length: 10 }, (_, i) =>
-      JSON.stringify({
-        event: "cmd_timing",
-        cmd: "/ping",
-        total: (i + 1) * 10,
-        cold: false,
-        marks: [],
-      }),
-    );
-    const { buckets, errors } = aggregateLines(lines, null);
-    const report = formatReport(buckets, errors);
-
-    // p50 of [10,20,30,40,50,60,70,80,90,100] = 50 (index 4 with nearest-rank)
-    // p95 = 95th percentile = index ceil(0.95*10)-1 = ceil(9.5)-1 = 10-1 = 9 → 100
-    expect(report).toContain("| /ping | warm | 10 |");
-  });
-});
diff --git a/tests/scripts/backfill-mongo-to-d1.test.js b/tests/scripts/backfill-mongo-to-d1.test.js
deleted file mode 100644
index 02ed5b5..0000000
--- a/tests/scripts/backfill-mongo-to-d1.test.js
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * @file backfill-mongo-to-d1.test.js — unit tests for SQL-statement construction.
- *
- * Tests `buildInsertSql` and `sqlStr` exported from backfill-mongo-to-d1.js.
- * No execSync, no wrangler, no real Mongo connection.
- */
-
-import { describe, expect, it } from "vitest";
-import { buildInsertSql, sqlStr } from "../../scripts/backfill-mongo-to-d1.js";
-
-// ─── sqlStr ───────────────────────────────────────────────────────────────────
-
-describe("sqlStr", () => {
-  it("wraps a plain string in single quotes", () => {
-    expect(sqlStr("hello")).toBe("'hello'");
-  });
-
-  it("escapes internal single quotes by doubling them", () => {
-    expect(sqlStr("it's")).toBe("'it''s'");
-  });
-
-  it("escapes multiple single quotes", () => {
-    expect(sqlStr("a'b'c")).toBe("'a''b''c'");
-  });
-
-  it("returns NULL for null", () => {
-    expect(sqlStr(null)).toBe("NULL");
-  });
-
-  it("returns NULL for undefined", () => {
-    expect(sqlStr(undefined)).toBe("NULL");
-  });
-
-  it("handles empty string", () => {
-    expect(sqlStr("")).toBe("''");
-  });
-
-  it("coerces non-string values via String()", () => {
-    // @ts-ignore — testing runtime coercion
-    expect(sqlStr(42)).toBe("'42'");
-  });
-});
-
-// ─── buildInsertSql ───────────────────────────────────────────────────────────
-
-describe("buildInsertSql", () => {
-  /** @type {Parameters[0]} */
-  const BASE_ROW = {
-    id: 1,
-    user_id: "u123",
-    symbol: "BTC",
-    side: "buy",
-    qty: 0.5,
-    price_vnd: 1500000,
-    ts: 1700000000,
-  };
-
-  it("produces a valid INSERT statement", () => {
-    const sql = buildInsertSql(BASE_ROW);
-    expect(sql).toMatch(
-      /^INSERT INTO trading_trades \(id, user_id, symbol, side, qty, price_vnd, ts\) VALUES \(/,
-    );
-    expect(sql).toMatch(/\);$/);
-  });
-
-  it("includes all column values in correct order", () => {
-    const sql = buildInsertSql(BASE_ROW);
-    expect(sql).toContain("1,"); // id
-    expect(sql).toContain("'u123'"); // user_id
-    expect(sql).toContain("'BTC'"); // symbol
-    expect(sql).toContain("'buy'"); // side
-    expect(sql).toContain("0.5,"); // qty
-    expect(sql).toContain("1500000,"); // price_vnd
-    expect(sql).toContain("1700000000"); // ts
-  });
-
-  it("preserves legacy_id as the INSERT id", () => {
-    const sql = buildInsertSql({ ...BASE_ROW, id: 42 });
-    // id=42 should be the first value
-    expect(sql).toMatch(/VALUES \(42,/);
-  });
-
-  it("escapes single quotes in user_id", () => {
-    const sql = buildInsertSql({ ...BASE_ROW, user_id: "o'brien" });
-    expect(sql).toContain("'o''brien'");
-  });
-
-  it("escapes single quotes in symbol", () => {
-    const sql = buildInsertSql({ ...BASE_ROW, symbol: "it's" });
-    expect(sql).toContain("'it''s'");
-  });
-
-  it("handles decimal qty correctly", () => {
-    const sql = buildInsertSql({ ...BASE_ROW, qty: 1.23456789 });
-    expect(sql).toContain("1.23456789");
-  });
-
-  it("handles zero values without coercion errors", () => {
-    const sql = buildInsertSql({
-      id: 0,
-      user_id: "",
-      symbol: "",
-      side: "",
-      qty: 0,
-      price_vnd: 0,
-      ts: 0,
-    });
-    expect(sql).toMatch(/VALUES \(0, '', '', '', 0, 0, 0\);/);
-  });
-
-  it("sequential id generation: each row gets unique ascending id", () => {
-    // Simulate the script logic of incrementing nextId for docs without legacy_id
-    let nextId = 1;
-    const rows = [
-      { user_id: "a", symbol: "BTC", side: "buy", qty: 1, price_vnd: 100, ts: 1 },
-      { user_id: "b", symbol: "ETH", side: "sell", qty: 2, price_vnd: 200, ts: 2 },
-    ];
-    const stmts = rows.map((r) => buildInsertSql({ ...r, id: nextId++ }));
-    expect(stmts[0]).toMatch(/VALUES \(1,/);
-    expect(stmts[1]).toMatch(/VALUES \(2,/);
-  });
-});
diff --git a/tests/scripts/backfill-mongo-to-kv.test.js b/tests/scripts/backfill-mongo-to-kv.test.js
deleted file mode 100644
index 1862353..0000000
--- a/tests/scripts/backfill-mongo-to-kv.test.js
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * @file backfill-mongo-to-kv.test.js — unit tests for reverse-backfill TTL math.
- *
- * Tests the `computeTtl` helper exported from backfill-mongo-to-kv.js.
- * No real Atlas connection, no real CF REST calls.
- */
-
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { computeTtl } from "../../scripts/backfill-mongo-to-kv.js";
-
-// ─── computeTtl ───────────────────────────────────────────────────────────────
-
-describe("computeTtl", () => {
-  const NOW = 1_700_000_000_000; // fixed epoch ms for deterministic tests
-
-  it("returns null when expiresAt is null (persistent key)", () => {
-    expect(computeTtl(null, NOW)).toBeNull();
-  });
-
-  it("returns null when expiresAt is undefined (persistent key)", () => {
-    expect(computeTtl(undefined, NOW)).toBeNull();
-  });
-
-  it("returns 'expired' when expiresAt is in the past", () => {
-    const past = new Date(NOW - 1000); // 1 second ago
-    expect(computeTtl(past, NOW)).toBe("expired");
-  });
-
-  it("returns 'expired' when expiresAt equals now exactly", () => {
-    const now = new Date(NOW);
-    expect(computeTtl(now, NOW)).toBe("expired");
-  });
-
-  it("returns correct ttl (seconds) for a future expiresAt", () => {
-    const future = new Date(NOW + 120_000); // 120 seconds from now
-    const result = computeTtl(future, NOW);
-    expect(result).not.toBeNull();
-    expect(result).not.toBe("expired");
-    expect(/** @type {{ttl: number}} */ (result).ttl).toBe(120);
-  });
-
-  it("floors partial seconds (no rounding up)", () => {
-    // 90.9 seconds remaining → floor → 90
-    const future = new Date(NOW + 90_900);
-    const result = computeTtl(future, NOW);
-    expect(/** @type {{ttl: number}} */ (result).ttl).toBe(90);
-  });
-
-  it("clamps ttl to MIN_TTL_SECS (60) when remaining < 60s", () => {
-    // 30 seconds remaining — below CF KV minimum of 60
-    const future = new Date(NOW + 30_000);
-    const result = computeTtl(future, NOW);
-    expect(/** @type {{ttl: number}} */ (result).ttl).toBe(60);
-  });
-
-  it("clamps ttl to MIN_TTL_SECS (60) when remaining is 1s", () => {
-    const future = new Date(NOW + 1_000);
-    const result = computeTtl(future, NOW);
-    expect(/** @type {{ttl: number}} */ (result).ttl).toBe(60);
-  });
-
-  it("does NOT clamp when remaining is exactly 60s", () => {
-    const future = new Date(NOW + 60_000);
-    const result = computeTtl(future, NOW);
-    expect(/** @type {{ttl: number}} */ (result).ttl).toBe(60);
-  });
-
-  it("does NOT clamp for large TTL values", () => {
-    // 7 days = 604800s
-    const future = new Date(NOW + 7 * 24 * 3600 * 1000);
-    const result = computeTtl(future, NOW);
-    expect(/** @type {{ttl: number}} */ (result).ttl).toBe(604800);
-  });
-});
-
-// ─── cfKvPut integration (fetch mock) ────────────────────────────────────────
-// Verify that computeTtl=null produces a PUT with no expiration_ttl query param,
-// and computeTtl={ttl:N} appends the param.
-
-describe("cfKvPut TTL query param (fetch mock)", () => {
-  afterEach(() => vi.restoreAllMocks());
-
-  async function mockPut(expirationTtl) {
-    const fetchMock = vi.fn().mockResolvedValue({
-      ok: true,
-      status: 200,
-      text: () => Promise.resolve(""),
-    });
-    vi.stubGlobal("fetch", fetchMock);
-
-    const { cfKvPut } = await import("../../scripts/lib/migration-helpers.js");
-    const opts = expirationTtl != null ? { expirationTtl } : {};
-    await cfKvPut("acct", "ns", "tok", "mod:key", "value", opts);
-    return fetchMock.mock.calls[0][0]; // the URL string
-  }
-
-  it("omits expiration_ttl param when no TTL provided", async () => {
-    const url = await mockPut(undefined);
-    expect(url).not.toContain("expiration_ttl");
-  });
-
-  it("appends expiration_ttl param when TTL provided", async () => {
-    const url = await mockPut(300);
-    expect(url).toContain("expiration_ttl=300");
-  });
-
-  it("URL-encodes the key in the PUT request URL", async () => {
-    const fetchMock = vi.fn().mockResolvedValue({
-      ok: true,
-      text: () => Promise.resolve(""),
-    });
-    vi.stubGlobal("fetch", fetchMock);
-    const { cfKvPut } = await import("../../scripts/lib/migration-helpers.js");
-    await cfKvPut("acct", "ns", "tok", "mod:key with spaces", "v", {});
-    const url = fetchMock.mock.calls[0][0];
-    expect(url).toContain("mod%3Akey%20with%20spaces");
-  });
-});
diff --git a/tests/scripts/migration-helpers.test.js b/tests/scripts/migration-helpers.test.js
deleted file mode 100644
index 1bcd5eb..0000000
--- a/tests/scripts/migration-helpers.test.js
+++ /dev/null
@@ -1,345 +0,0 @@
-/**
- * @file migration-helpers.test.js — pure-logic unit tests for migration-helpers.
- *
- * Tests: sha256, checkpoint round-trip, countDiffRatio, sampleStrategy,
- * cfKvList / cfKvGet response parsing (via fetch mock).
- *
- * No real Atlas connection, no real CF REST calls.
- */
-
-import { existsSync, unlinkSync, writeFileSync } from "node:fs";
-import { resolve } from "node:path";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import {
-  cfKvGet,
-  cfKvList,
-  cfKvPut,
-  clearCheckpoint,
-  closeMongoClient,
-  countDiffRatio,
-  loadCheckpoint,
-  sampleStrategy,
-  saveCheckpoint,
-  sha256,
-  sleep,
-} from "../../scripts/lib/migration-helpers.js";
-
-// ─── sleep ────────────────────────────────────────────────────────────────────
-
-describe("sleep", () => {
-  it("resolves after at least ms milliseconds", async () => {
-    const start = Date.now();
-    await sleep(20);
-    expect(Date.now() - start).toBeGreaterThanOrEqual(15); // allow 5ms jitter
-  });
-});
-
-// ─── sha256 ───────────────────────────────────────────────────────────────────
-
-describe("sha256", () => {
-  it("produces a 64-char hex string", () => {
-    expect(sha256("hello")).toMatch(/^[0-9a-f]{64}$/);
-  });
-
-  it("is deterministic for the same input", () => {
-    expect(sha256("test")).toBe(sha256("test"));
-  });
-
-  it("differs for different inputs", () => {
-    expect(sha256("a")).not.toBe(sha256("b"));
-  });
-
-  it("known vector: empty string", () => {
-    // SHA256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
-    expect(sha256("")).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
-  });
-});
-
-// ─── Checkpoint ───────────────────────────────────────────────────────────────
-
-describe("checkpoint round-trip", () => {
-  const TEST_MODULE = "__test_module_unit__";
-  const cpFile = resolve(process.cwd(), `.backfill-cursor-${TEST_MODULE}.json`);
-
-  afterEach(() => {
-    if (existsSync(cpFile)) unlinkSync(cpFile);
-  });
-
-  it("loadCheckpoint returns null when no file exists", () => {
-    expect(loadCheckpoint(TEST_MODULE)).toBeNull();
-  });
-
-  it("saveCheckpoint + loadCheckpoint round-trip", () => {
-    const state = { cursor: "abc123", lastKey: "wordle:games:42" };
-    saveCheckpoint(TEST_MODULE, state);
-    expect(loadCheckpoint(TEST_MODULE)).toEqual(state);
-  });
-
-  it("clearCheckpoint removes the file", () => {
-    saveCheckpoint(TEST_MODULE, { cursor: "x", lastKey: "k" });
-    expect(existsSync(cpFile)).toBe(true);
-    clearCheckpoint(TEST_MODULE);
-    expect(existsSync(cpFile)).toBe(false);
-  });
-
-  it("clearCheckpoint is a no-op when file absent", () => {
-    expect(() => clearCheckpoint(TEST_MODULE)).not.toThrow();
-  });
-
-  it("loadCheckpoint returns null on corrupt JSON", () => {
-    writeFileSync(cpFile, "{bad json", "utf8");
-    expect(loadCheckpoint(TEST_MODULE)).toBeNull();
-  });
-});
-
-// ─── countDiffRatio ───────────────────────────────────────────────────────────
-
-describe("countDiffRatio", () => {
-  it("returns 0 for equal counts", () => {
-    expect(countDiffRatio(100, 100)).toBe(0);
-  });
-
-  it("returns 1 for (0, N)", () => {
-    expect(countDiffRatio(0, 100)).toBe(1);
-  });
-
-  it("returns 1 for (N, 0)", () => {
-    expect(countDiffRatio(50, 0)).toBe(1);
-  });
-
-  it("handles (0, 0) without division-by-zero — max(a,b,1)=1", () => {
-    expect(countDiffRatio(0, 0)).toBe(0);
-  });
-
-  it("1% diff on 1000", () => {
-    expect(countDiffRatio(1000, 990)).toBeCloseTo(0.01);
-  });
-
-  it("below 1% threshold for 5 diff on 1000", () => {
-    expect(countDiffRatio(1000, 995)).toBeLessThan(0.01);
-  });
-});
-
-// ─── sampleStrategy ───────────────────────────────────────────────────────────
-
-describe("sampleStrategy", () => {
-  it("full-scan for 0 total", () => {
-    const { fullScan, sampleSize } = sampleStrategy(0);
-    expect(fullScan).toBe(true);
-    expect(sampleSize).toBe(0);
-  });
-
-  it("full-scan for 9999 total", () => {
-    const { fullScan, sampleSize } = sampleStrategy(9999);
-    expect(fullScan).toBe(true);
-    expect(sampleSize).toBe(9999);
-  });
-
-  it("full-scan boundary: 10000 triggers sampling", () => {
-    const { fullScan } = sampleStrategy(10000);
-    expect(fullScan).toBe(false);
-  });
-
-  it("sample size = ceil(sqrt(total)) for mid range", () => {
-    // sqrt(40000) = 200 → sampleSize = 200
-    const { sampleSize } = sampleStrategy(40000);
-    expect(sampleSize).toBe(200);
-  });
-
-  it("sample size capped at 500", () => {
-    // sqrt(1_000_000) = 1000, capped to 500
-    const { sampleSize } = sampleStrategy(1_000_000);
-    expect(sampleSize).toBe(500);
-  });
-
-  it("ceil applied: sqrt(10001) ≈ 100.005 → 101", () => {
-    const { sampleSize } = sampleStrategy(10201); // sqrt = 101 exactly
-    expect(sampleSize).toBe(101);
-  });
-});
-
-// ─── cfKvList ─────────────────────────────────────────────────────────────────
-
-describe("cfKvList (fetch mock)", () => {
-  afterEach(() => vi.restoreAllMocks());
-
-  function mockFetch(body, status = 200) {
-    vi.stubGlobal(
-      "fetch",
-      vi.fn().mockResolvedValue({
-        ok: status >= 200 && status < 300,
-        status,
-        statusText: status === 200 ? "OK" : "Error",
-        text: () => Promise.resolve(JSON.stringify(body)),
-        json: () => Promise.resolve(body),
-      }),
-    );
-  }
-
-  it("parses a successful list response", async () => {
-    mockFetch({
-      success: true,
-      result: [
-        { name: "wordle:game:1", metadata: { expiration: 1700000000 } },
-        { name: "wordle:game:2" },
-      ],
-      result_info: { count: 2 },
-    });
-    const result = await cfKvList("acct", "ns", "tok", "wordle:", null);
-    expect(result.keys).toHaveLength(2);
-    expect(result.keys[0].name).toBe("wordle:game:1");
-    expect(result.keys[0].metadata?.expiration).toBe(1700000000);
-    expect(result.list_complete).toBe(true);
-    expect(result.cursor).toBeNull();
-  });
-
-  it("includes cursor when result_info.cursor present", async () => {
-    mockFetch({
-      success: true,
-      result: Array.from({ length: 1000 }, (_, i) => ({ name: `k:${i}` })),
-      result_info: { count: 1000, cursor: "next-page-cursor" },
-    });
-    const result = await cfKvList("acct", "ns", "tok", "k:", null);
-    expect(result.cursor).toBe("next-page-cursor");
-    expect(result.list_complete).toBe(false);
-  });
-
-  it("throws on HTTP error", async () => {
-    mockFetch({ errors: ["unauthorized"] }, 401);
-    await expect(cfKvList("acct", "ns", "tok", "x:", null)).rejects.toThrow("401");
-  });
-
-  it("throws when success=false", async () => {
-    mockFetch({ success: false, errors: [{ message: "namespace not found" }] });
-    await expect(cfKvList("acct", "ns", "tok", "x:", null)).rejects.toThrow(/error/i);
-  });
-});
-
-// ─── cfKvGet ──────────────────────────────────────────────────────────────────
-
-describe("cfKvGet (fetch mock)", () => {
-  afterEach(() => vi.restoreAllMocks());
-
-  it("returns value text on success", async () => {
-    vi.stubGlobal(
-      "fetch",
-      vi.fn().mockResolvedValue({
-        ok: true,
-        status: 200,
-        text: () => Promise.resolve('{"score":42}'),
-      }),
-    );
-    const val = await cfKvGet("acct", "ns", "tok", "wordle:game:1");
-    expect(val).toBe('{"score":42}');
-  });
-
-  it("URL-encodes the key in the request URL", async () => {
-    const fetchMock = vi.fn().mockResolvedValue({
-      ok: true,
-      text: () => Promise.resolve("v"),
-    });
-    vi.stubGlobal("fetch", fetchMock);
-    await cfKvGet("acct", "ns", "tok", "some key/with spaces");
-    const calledUrl = fetchMock.mock.calls[0][0];
-    expect(calledUrl).toContain("some%20key%2Fwith%20spaces");
-  });
-
-  it("throws on HTTP 404", async () => {
-    vi.stubGlobal(
-      "fetch",
-      vi.fn().mockResolvedValue({
-        ok: false,
-        status: 404,
-        statusText: "Not Found",
-        text: () => Promise.resolve("not found"),
-      }),
-    );
-    await expect(cfKvGet("acct", "ns", "tok", "missing:key")).rejects.toThrow("404");
-  });
-});
-
-// ─── cfKvPut ──────────────────────────────────────────────────────────────────
-
-describe("cfKvPut (fetch mock)", () => {
-  afterEach(() => vi.restoreAllMocks());
-
-  function mockFetch(status = 200) {
-    vi.stubGlobal(
-      "fetch",
-      vi.fn().mockResolvedValue({
-        ok: status >= 200 && status < 300,
-        status,
-        text: () => Promise.resolve(""),
-      }),
-    );
-  }
-
-  it("issues a PUT request to the correct URL", async () => {
-    const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve("") });
-    vi.stubGlobal("fetch", fetchMock);
-    await cfKvPut("acct", "ns", "tok", "my:key", "myvalue");
-    const [url, init] = fetchMock.mock.calls[0];
-    expect(url).toContain("/accounts/acct/storage/kv/namespaces/ns/values/my%3Akey");
-    expect(init.method).toBe("PUT");
-  });
-
-  it("sends Authorization Bearer header", async () => {
-    const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve("") });
-    vi.stubGlobal("fetch", fetchMock);
-    await cfKvPut("acct", "ns", "TOKEN", "k", "v");
-    const init = fetchMock.mock.calls[0][1];
-    expect(init.headers.Authorization).toBe("Bearer TOKEN");
-  });
-
-  it("sends the value as the request body", async () => {
-    const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve("") });
-    vi.stubGlobal("fetch", fetchMock);
-    await cfKvPut("acct", "ns", "tok", "k", "hello-value");
-    const init = fetchMock.mock.calls[0][1];
-    expect(init.body).toBe("hello-value");
-  });
-
-  it("appends expiration_ttl query param when provided", async () => {
-    const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve("") });
-    vi.stubGlobal("fetch", fetchMock);
-    await cfKvPut("acct", "ns", "tok", "k", "v", { expirationTtl: 300 });
-    const url = fetchMock.mock.calls[0][0];
-    expect(url).toContain("expiration_ttl=300");
-  });
-
-  it("omits expiration_ttl query param when opts is empty", async () => {
-    const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve("") });
-    vi.stubGlobal("fetch", fetchMock);
-    await cfKvPut("acct", "ns", "tok", "k", "v", {});
-    const url = fetchMock.mock.calls[0][0];
-    expect(url).not.toContain("expiration_ttl");
-  });
-
-  it("omits expiration_ttl when opts is omitted entirely", async () => {
-    const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve("") });
-    vi.stubGlobal("fetch", fetchMock);
-    await cfKvPut("acct", "ns", "tok", "k", "v");
-    const url = fetchMock.mock.calls[0][0];
-    expect(url).not.toContain("expiration_ttl");
-  });
-
-  it("throws on HTTP error response", async () => {
-    mockFetch(403);
-    await expect(cfKvPut("acct", "ns", "tok", "k", "v")).rejects.toThrow("403");
-  });
-
-  it("URL-encodes special characters in key", async () => {
-    const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve("") });
-    vi.stubGlobal("fetch", fetchMock);
-    await cfKvPut("acct", "ns", "tok", "key with spaces/slash", "v");
-    const url = fetchMock.mock.calls[0][0];
-    expect(url).toContain("key%20with%20spaces%2Fslash");
-  });
-});
-
-// ─── getMongoClient teardown ──────────────────────────────────────────────────
-
-// Ensure any singleton client opened during tests is closed (prevents open handles).
-afterEach(async () => {
-  await closeMongoClient();
-});
diff --git a/tests/util/timing.test.js b/tests/util/timing.test.js
deleted file mode 100644
index 39f86d3..0000000
--- a/tests/util/timing.test.js
+++ /dev/null
@@ -1,104 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { startTiming, takeColdFlag } from "../../src/util/timing.js";
-
-// ── startTiming ──────────────────────────────────────────────────────────────
-
-describe("startTiming", () => {
-  let consoleSpy;
-
-  beforeEach(() => {
-    consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
-  });
-
-  afterEach(() => {
-    vi.restoreAllMocks();
-  });
-
-  it("end() emits a cmd_timing JSON line via console.log", () => {
-    const t = startTiming("/wordle");
-    t.end();
-
-    expect(consoleSpy).toHaveBeenCalledOnce();
-    const raw = consoleSpy.mock.calls[0][0];
-    const obj = JSON.parse(raw);
-    expect(obj.event).toBe("cmd_timing");
-    expect(obj.cmd).toBe("/wordle");
-    expect(typeof obj.total).toBe("number");
-    expect(obj.total).toBeGreaterThanOrEqual(0);
-    expect(Array.isArray(obj.marks)).toBe(true);
-  });
-
-  it("end() merges extra fields into the log object", () => {
-    const t = startTiming("/loldle");
-    t.end({ cold: true, isolateAgeMs: 42 });
-
-    const obj = JSON.parse(consoleSpy.mock.calls[0][0]);
-    expect(obj.cold).toBe(true);
-    expect(obj.isolateAgeMs).toBe(42);
-  });
-
-  it("mark() records named checkpoints with dt >= 0", () => {
-    const t = startTiming("/help");
-    t.mark("mongo-read");
-    t.mark("handler-done");
-    t.end();
-
-    const obj = JSON.parse(consoleSpy.mock.calls[0][0]);
-    expect(obj.marks).toHaveLength(2);
-    expect(obj.marks[0].label).toBe("mongo-read");
-    expect(obj.marks[1].label).toBe("handler-done");
-    expect(obj.marks[0].dt).toBeGreaterThanOrEqual(0);
-    expect(obj.marks[1].dt).toBeGreaterThanOrEqual(obj.marks[0].dt);
-  });
-
-  it("marks array is empty when no mark() calls made", () => {
-    const t = startTiming("/ping");
-    t.end();
-    const obj = JSON.parse(consoleSpy.mock.calls[0][0]);
-    expect(obj.marks).toEqual([]);
-  });
-
-  it("marks is declared at top — no ReferenceError if mark() called before any await", () => {
-    // This would throw if `marks` were declared after a conditional or inside a closure.
-    expect(() => {
-      const t = startTiming("/fortytwo");
-      t.mark("immediate");
-      t.end();
-    }).not.toThrow();
-  });
-
-  it("total reflects elapsed time (>= 0ms)", () => {
-    const t = startTiming("/trade");
-    t.end();
-    const obj = JSON.parse(consoleSpy.mock.calls[0][0]);
-    expect(obj.total).toBeGreaterThanOrEqual(0);
-  });
-});
-
-// ── takeColdFlag ─────────────────────────────────────────────────────────────
-// NOTE: takeColdFlag uses module-scope state that persists across tests in the
-// same vitest worker. We cannot reset it between tests — so we capture the
-// baseline once and assert relative behaviour only.
-
-describe("takeColdFlag", () => {
-  it("returns an object with cold (boolean) and isolateAgeMs (number)", () => {
-    const result = takeColdFlag();
-    expect(typeof result.cold).toBe("boolean");
-    expect(typeof result.isolateAgeMs).toBe("number");
-    expect(result.isolateAgeMs).toBeGreaterThanOrEqual(0);
-  });
-
-  it("second call in same isolate returns cold=false", () => {
-    // The first call may have already flipped the flag in a prior test.
-    // We call it once here just to ensure it returns cold=false on subsequent invocations.
-    takeColdFlag(); // may be first or subsequent
-    const second = takeColdFlag();
-    expect(second.cold).toBe(false);
-  });
-
-  it("isolateAgeMs is non-decreasing across successive calls", () => {
-    const a = takeColdFlag();
-    const b = takeColdFlag();
-    expect(b.isolateAgeMs).toBeGreaterThanOrEqual(a.isolateAgeMs);
-  });
-});
diff --git a/wrangler.toml b/wrangler.toml
index 3bffde6..2628889 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -1,22 +1,11 @@
 name = "miti99bot"
 main = "src/index.js"
 compatibility_date = "2025-10-01"
-# nodejs_compat_v2 enables node:net + node:tls so the official `mongodb` driver
-# can open a TCP socket to Atlas. v1 vs v2 are alternatives, not additive.
-# Adding this flag does not affect existing modules — `src/` has no node: imports.
-compatibility_flags = ["nodejs_compat_v2"]
 
 # Enabled modules at runtime. Comma-separated. Must match static-map keys in src/modules/index.js.
 # Also duplicate this value into .env.deploy so scripts/register.js derives the same public command list.
 [vars]
 MODULES = "util,wordle,loldle,loldle-emoji,loldle-quote,loldle-ability,loldle-splash,misc,trading,lolschedule,semantle,doantu,twentyq"
-# Storage routing flags — control which backend serves reads and receives writes.
-# STORAGE_PRIMARY: "kv" = CF KV (default), "mongo" = Atlas (post-cutover).
-# DUAL_WRITE: "1" = write both backends in parallel (default), "0" = primary only.
-# DRIFT_SAMPLE_N: number of keys sampled per module by drift-verifier cron.
-STORAGE_PRIMARY = "kv"
-DUAL_WRITE = "1"
-DRIFT_SAMPLE_N = "50"
 
 # KV namespace holding all module state. Each module auto-prefixes its keys via createStore().
 # Production-only — no preview namespace. Create with:
@@ -51,10 +40,7 @@ binding = "AI"
 # Local testing: curl "http://localhost:8787/__scheduled?cron=0+1+*+*+*"
 # (requires `wrangler dev --test-scheduled`)
 [triggers]
-# "0 17 * * *" — trading trim-trades (lolschedule module)
-# "0 1 * * *"  — misc / lolschedule nightly jobs
-# "0 * * * *"  — drift-verifier: drain retry queue + spot-check KV vs Mongo parity
-crons = ["0 17 * * *", "0 1 * * *", "0 * * * *"]
+crons = ["0 17 * * *", "0 1 * * *"]
 
 # Workers Observability — captures console.* logs, request metadata, and
 # invocation traces in the Cloudflare dashboard (Observability → Logs).