Files
miti99bot/plans/260411-0853-telegram-bot-plugin-framework/phase-03-db-abstraction.md
tiennm99 c4314f21df feat: scaffold plug-n-play telegram bot on cloudflare workers
grammY-based bot with a module plugin system loaded from the MODULES env
var. Three command visibility levels (public/protected/private) share a
unified command namespace with conflict detection at registry build.

- 4 initial modules (util, wordle, loldle, misc); util fully implemented,
  others are stubs proving the plugin system end-to-end
- util: /info (chat/thread/sender ids) + /help (pure renderer over the
  registry, HTML parse mode, escapes user-influenced strings)
- KVStore interface with CFKVStore and a per-module prefixing factory;
  getJSON/putJSON convenience helpers; other backends drop in via one file
- Webhook at POST /webhook with secret-token validation via grammY's
  webhookCallback; no admin HTTP surface
- Post-deploy register script (npm run deploy = wrangler deploy && node
  --env-file=.env.deploy scripts/register.js) for setWebhook and
  setMyCommands; --dry-run flag for preview
- 56 vitest unit tests across 7 suites covering registry, db wrapper,
  dispatcher, help renderer, validators, and HTML escaper
- biome for lint + format; phased implementation plan under plans/
2026-04-11 09:49:06 +07:00

7.7 KiB

Phase 03 — DB abstraction

Overview

  • Priority: P1
  • Status: pending
  • Description: define minimal KV-like interface (get/put/delete/list + getJSON/putJSON convenience) via JSDoc, implement CFKVStore against KVNamespace, expose createStore(moduleName) factory that auto-prefixes keys with <module>:.

Key Insights

  • Interface is deliberately small. YAGNI: no metadata, no bulk ops, no transactions.
  • expirationTtl IS exposed on put() — useful for cooldowns / easter-egg throttling.
  • getJSON / putJSON are thin wrappers (JSON.parse / JSON.stringify). Included because every planned module stores structured state — DRY, not speculative. getJSON returns null when the key is missing OR when the stored value fails to parse (log + swallow), so callers can treat both as "no record".
  • list() returns a normalized shape { keys: string[], cursor?: string, done: boolean } — strip KV-specific fields. Cursor enables pagination for modules that grow past 1000 keys.
  • Factory-based prefixing means swapping CFKVStoreD1Store or RedisStore later is one-file change. NO module touches KV directly.
  • When a module calls list({ prefix: "games:" }), the wrapper concatenates to "wordle:games:" before calling KV and strips "wordle:" from returned keys so the module sees its own namespace.
  • KV hot-key limit (1 write/sec per key) — document in JSDoc so module authors are aware.

Requirements

Functional

  • Interface exposes: get(key), put(key, value, options?), delete(key), list(options?), getJSON(key), putJSON(key, value, options?).
  • options on put / putJSON: { expirationTtl?: number } (seconds).
  • options on list: { prefix?: string, limit?: number, cursor?: string }.
  • createStore(moduleName, env) returns a prefixed store bound to env.KV.
  • get returns string | null.
  • put accepts string only — structured data goes through putJSON.
  • getJSON(key)any | null. On missing key: null. On malformed JSON: log a warning and return null (do NOT throw — a single corrupt record must not crash the bot).
  • putJSON(key, value, opts?) → serializes with JSON.stringify then delegates to put. Throws if value is undefined or contains a cycle.
  • list returns { keys: string[], cursor?: string, done: boolean }, with module prefix stripped. cursor is passed through from KV so callers can paginate.

Non-functional

  • JSDoc @typedef for KVStore interface so IDE completion works without TS.
  • src/db/ total < 150 LOC.

Architecture

module code
  │  createStore("wordle", env)
  ▼
PrefixedStore  ── prefixes all keys with "wordle:" ──┐
  │                                                   ▼
  └──────────────────────────────────────────► CFKVStore (wraps env.KV)
                                                      │
                                                      ▼
                                                  env.KV (binding)

Create

  • src/db/kv-store-interface.js — JSDoc @typedef only, no runtime code
  • src/db/cf-kv-store.jsCFKVStore class wrapping KVNamespace
  • src/db/create-store.jscreateStore(moduleName, env) prefixing factory

Modify

  • none

Delete

  • none

Implementation Steps

  1. src/db/kv-store-interface.js:
    /**
     * @typedef {Object} KVStorePutOptions
     * @property {number} [expirationTtl] seconds
     *
     * @typedef {Object} KVStoreListOptions
     * @property {string} [prefix]
     * @property {number} [limit]
     * @property {string} [cursor]
     *
     * @typedef {Object} KVStoreListResult
     * @property {string[]} keys
     * @property {string} [cursor]
     * @property {boolean} done
     *
     * @typedef {Object} KVStore
     * @property {(key: string) => Promise<string|null>} get
     * @property {(key: string, value: string, opts?: KVStorePutOptions) => Promise<void>} put
     * @property {(key: string) => Promise<void>} delete
     * @property {(opts?: KVStoreListOptions) => Promise<KVStoreListResult>} list
     * @property {(key: string) => Promise<any|null>} getJSON
     * @property {(key: string, value: any, opts?: KVStorePutOptions) => Promise<void>} putJSON
     */
    export {};
    
  2. src/db/cf-kv-store.js:
    • export class CFKVStore with constructor(kvNamespace) stashing binding.
    • get(key)this.kv.get(key, { type: "text" }).
    • put(key, value, opts)this.kv.put(key, value, opts?.expirationTtl ? { expirationTtl: opts.expirationTtl } : undefined).
    • delete(key)this.kv.delete(key).
    • list({ prefix, limit, cursor } = {}) → call this.kv.list({ prefix, limit, cursor }), map to normalized shape { keys: result.keys.map(k => k.name), cursor: result.cursor, done: result.list_complete }.
    • getJSON(key)const raw = await this.get(key); if (raw == null) return null; try { return JSON.parse(raw); } catch (e) { console.warn("getJSON parse failed", { key, err: String(e) }); return null; }.
    • putJSON(key, value, opts)if (value === undefined) throw new Error("putJSON: value is undefined"); return this.put(key, JSON.stringify(value), opts);.
  3. src/db/create-store.js:
    • export function createStore(moduleName, env):
      • Validate moduleName is non-empty [a-z0-9_-]+.
      • const base = new CFKVStore(env.KV).
      • const prefix = \${moduleName}:``.
      • Return object:
        • get(key)base.get(prefix + key)
        • put(key, value, opts)base.put(prefix + key, value, opts)
        • delete(key)base.delete(prefix + key)
        • list(opts) → call base.list({ ...opts, prefix: prefix + (opts?.prefix ?? "") }), then strip the <module>: prefix from returned keys before returning.
        • getJSON(key)base.getJSON(prefix + key)
        • putJSON(key, value, opts)base.putJSON(prefix + key, value, opts)
  4. JSDoc every exported function. Types on params + return.
  5. npm run lint.

Todo List

  • src/db/kv-store-interface.js JSDoc types (incl. getJSON / putJSON)
  • src/db/cf-kv-store.js CFKVStore class (incl. getJSON / putJSON)
  • src/db/create-store.js prefixing factory (all six methods)
  • Validate module name in factory
  • JSDoc on every exported symbol
  • Lint clean

Success Criteria

  • All four interface methods round-trip via wrangler dev + preview KV namespace (manual sanity check OK; unit tests land in phase-08).
  • Prefix stripping verified: createStore("wordle").put("k","v") → raw KV key is wordle:k; createStore("wordle").list() returns ["k"].
  • Swapping CFKVStore for a future backend requires changes ONLY inside src/db/.

Risk Assessment

Risk Likelihood Impact Mitigation
KV hot-key limit breaks a module (1 w/s per key) Med Med Document in JSDoc; phase-08 adds a throttle util if needed
Eventual consistency confuses testers High Low README note in phase-09
Corrupt JSON crashes a module handler Low Med getJSON swallows parse errors → null; logs warning
Module name collision with prefix characters Low Med Validate regex ^[a-z0-9_-]+$ in factory

Security Considerations

  • Colon in module names would let a malicious module escape its namespace — regex validation blocks this.
  • Never expose raw env.KV outside src/db/ — enforce by code review (module registry only passes the prefixed store, never the bare binding).

Next Steps

  • Phase 04 consumes createStore from the module registry's init hook.