From ec77a24db7f702c53ae18f140d6ac4a4261a1c19 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 25 May 2026 09:39:17 +0700 Subject: [PATCH] =?UTF-8?q?chore:=20drop=20CF=E2=86=92AWS=20migration=20to?= =?UTF-8?q?oling=20and=20stale=20JS-port=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CF→AWS data migration (closed 2026-05-16) is long done and the tooling isn't wired into any production path. Remove the one-shot binary, its support package, and the migration runbook. In live code, replace 'JS-parity' / 'same shape as JS' / 'cross-runtime KV migration' comments with the real, stable reason for each behavior (wire-format invariant, null-vs-zero distinction, CloudWatch alarm field name, etc.). 24 files touched across lolschedule, loldle, wordle, twentyq, trading, misc, util, server, metrics, ai, keylock. - delete cmd/migrate_cf_data/ - delete internal/migration/ - delete docs/cf-to-aws-migration-runbook.md --- cmd/migrate_cf_data/convert_value.go | 115 --------- cmd/migrate_cf_data/main.go | 243 ------------------ docs/cf-to-aws-migration-runbook.md | 198 -------------- internal/ai/client.go | 5 +- internal/keylock/keylock.go | 5 +- internal/metrics/counters.go | 4 +- internal/migration/cloudflare_d1_client.go | 92 ------- .../migration/cloudflare_d1_client_test.go | 67 ----- internal/migration/cloudflare_kv_client.go | 135 ---------- .../migration/cloudflare_kv_client_test.go | 107 -------- internal/migration/dynamodb_writer.go | 61 ----- internal/migration/policy.go | 99 ------- internal/migration/policy_test.go | 86 ------- internal/migration/report.go | 96 ------- internal/migration/report_test.go | 52 ---- internal/modules/loldle/champions.go | 11 +- internal/modules/loldle/compare.go | 34 +-- internal/modules/loldle/compare_test.go | 4 +- internal/modules/loldle/flavor.go | 10 +- internal/modules/loldle/flavor_test.go | 4 +- internal/modules/loldle/handlers.go | 3 +- internal/modules/loldle/lookup.go | 5 +- internal/modules/loldle/render.go | 4 +- internal/modules/loldle/render_test.go | 3 +- internal/modules/loldle/state.go | 19 +- internal/modules/loldle/state_test.go | 4 +- internal/modules/lolschedule/api_client.go | 18 +- internal/modules/lolschedule/parse_date.go | 3 +- internal/modules/misc/misc.go | 6 +- internal/modules/misc/misc_test.go | 11 +- internal/modules/trading/format.go | 8 +- internal/modules/trading/portfolio.go | 2 +- internal/modules/twentyq/handlers.go | 6 +- internal/modules/twentyq/parser.go | 13 +- internal/modules/twentyq/prompts.go | 4 +- internal/modules/twentyq/state.go | 2 +- internal/modules/twentyq/validate.go | 3 +- .../modules/util/chathelper/chathelper.go | 21 +- internal/modules/util/info.go | 2 +- internal/modules/util/stickerid.go | 3 +- internal/modules/wordle/compare.go | 13 +- internal/modules/wordle/handlers.go | 3 +- internal/modules/wordle/lookup.go | 15 +- internal/modules/wordle/state.go | 20 +- internal/modules/wordle/state_test.go | 7 +- internal/modules/wordle/words.go | 5 +- internal/server/log_middleware.go | 2 +- 47 files changed, 142 insertions(+), 1491 deletions(-) delete mode 100644 cmd/migrate_cf_data/convert_value.go delete mode 100644 cmd/migrate_cf_data/main.go delete mode 100644 docs/cf-to-aws-migration-runbook.md delete mode 100644 internal/migration/cloudflare_d1_client.go delete mode 100644 internal/migration/cloudflare_d1_client_test.go delete mode 100644 internal/migration/cloudflare_kv_client.go delete mode 100644 internal/migration/cloudflare_kv_client_test.go delete mode 100644 internal/migration/dynamodb_writer.go delete mode 100644 internal/migration/policy.go delete mode 100644 internal/migration/policy_test.go delete mode 100644 internal/migration/report.go delete mode 100644 internal/migration/report_test.go diff --git a/cmd/migrate_cf_data/convert_value.go b/cmd/migrate_cf_data/convert_value.go deleted file mode 100644 index e6d824a..0000000 --- a/cmd/migrate_cf_data/convert_value.go +++ /dev/null @@ -1,115 +0,0 @@ -// One-shot rewrite of the table's `value` attribute from Binary (legacy -// shape) to String (current shape). Operator-elective; needed once after the -// runtime swap from MemberB to MemberS in internal/storage/dynamodb_kv.go. -package main - -import ( - "context" - "flag" - "fmt" - "os" - "strconv" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - awsconfig "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/service/dynamodb" - "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" -) - -// signalContext is defined in main.go and gives every subcommand a SIGINT / -// SIGTERM-cancellable context — Ctrl-C mid-scan now propagates as a clean -// context error instead of leaving a half-converted table. -// -// (signature mirrors signal.NotifyContext for documentation purposes; no -// re-declaration here, just a pointer for future readers.) - - -func runConvertValueToString(args []string) error { - fs := flag.NewFlagSet("convert-value-to-string", flag.ExitOnError) - table := fs.String("table", "", "target DynamoDB table (required)") - dryRun := fs.Bool("dry-run", false, "log actions but do not write") - if err := fs.Parse(args); err != nil { - return err - } - if *table == "" { - return fmt.Errorf("--table is required") - } - - ctx, cancel := signalContext() - defer cancel() - cfg, err := awsconfig.LoadDefaultConfig(ctx) - if err != nil { - return fmt.Errorf("aws config: %w", err) - } - client := dynamodb.NewFromConfig(cfg) - - converted, alreadyString, skipped, failed := 0, 0, 0, 0 - pager := dynamodb.NewScanPaginator(client, &dynamodb.ScanInput{TableName: aws.String(*table)}) - for pager.HasMorePages() { - page, err := pager.NextPage(ctx) - if err != nil { - return fmt.Errorf("scan: %w", err) - } - for _, item := range page.Items { - pk, sk, ok := itemPKSK(item) - if !ok { - skipped++ - continue - } - valAttr, ok := item["value"] - if !ok { - skipped++ - continue - } - binAttr, isBinary := valAttr.(*types.AttributeValueMemberB) - if !isBinary { - alreadyString++ - continue - } - if *dryRun { - fmt.Printf(" DRY-RUN would convert pk=%s sk=%s len=%d\n", pk, sk, len(binAttr.Value)) - converted++ - continue - } - if err := putAsString(ctx, client, *table, pk, sk, binAttr.Value); err != nil { - fmt.Fprintf(os.Stderr, " put %s/%s: %v\n", pk, sk, err) - failed++ - continue - } - converted++ - } - } - - fmt.Printf("\nconvert-value-to-string report\n") - fmt.Printf(" converted (B → S): %d\n", converted) - fmt.Printf(" already String: %d\n", alreadyString) - fmt.Printf(" skipped (no pk/sk/value): %d\n", skipped) - fmt.Printf(" failed: %d\n", failed) - return nil -} - -func itemPKSK(item map[string]types.AttributeValue) (string, string, bool) { - pkAttr, ok := item["pk"].(*types.AttributeValueMemberS) - if !ok { - return "", "", false - } - skAttr, ok := item["sk"].(*types.AttributeValueMemberS) - if !ok { - return "", "", false - } - return pkAttr.Value, skAttr.Value, true -} - -func putAsString(ctx context.Context, client *dynamodb.Client, table, pk, sk string, val []byte) error { - _, err := client.PutItem(ctx, &dynamodb.PutItemInput{ - TableName: aws.String(table), - Item: map[string]types.AttributeValue{ - "pk": &types.AttributeValueMemberS{Value: pk}, - "sk": &types.AttributeValueMemberS{Value: sk}, - "value": &types.AttributeValueMemberS{Value: string(val)}, - "updatedAt": &types.AttributeValueMemberN{Value: strconv.FormatInt(time.Now().UTC().UnixNano(), 10)}, - }, - }) - return err -} diff --git a/cmd/migrate_cf_data/main.go b/cmd/migrate_cf_data/main.go deleted file mode 100644 index f4a8460..0000000 --- a/cmd/migrate_cf_data/main.go +++ /dev/null @@ -1,243 +0,0 @@ -// Command migrate_cf_data moves durable data from the legacy Cloudflare -// KV/D1 stack into the live AWS DynamoDB table. Operator-invoked only. -// -// Subcommands: -// -// inventory Read CF KV keys, apply the Phase 01 policy, print -// a classification report. No writes anywhere. -// -// kv-import Copy migrate-action KV keys into DynamoDB. -// Idempotent by default (attribute_not_exists guard). -// Flags: --table, --dry-run, --overwrite. -// -// trading-audit-dump Stream D1 `trading_trades` rows to a JSONL file. -// Audit-only; not an import input. -// Flags: --out (required). -// -// convert-value-to-string -// One-shot rewrite of the table's `value` attribute -// from Binary (legacy shape) to String (current -// shape). Idempotent — items already stored as -// String are skipped. -// Flags: --table, --dry-run. -// -// Required env: -// -// CLOUDFLARE_API_TOKEN — read-scoped token for KV + D1 -// CLOUDFLARE_ACCOUNT_ID — production CF account -// CF_KV_NAMESPACE_ID — production KV namespace -// CF_D1_DATABASE_ID — production D1 database (only needed for -// trading-audit-dump) -// AWS_REGION (or standard AWS SDK env) — only needed for kv-import -package main - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "os" - "os/signal" - "syscall" - - awsconfig "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/service/dynamodb" - "github.com/tiennm99/miti99bot/internal/migration" -) - -// signalContext returns a context cancelled on Ctrl-C / SIGTERM. Used by every -// subcommand so a mid-scan abort leaves a clean error trail instead of a -// half-converted table the operator has to reason about. -func signalContext() (context.Context, context.CancelFunc) { - return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) -} - -func main() { - if len(os.Args) < 2 { - usage() - os.Exit(2) - } - sub := os.Args[1] - args := os.Args[2:] - var err error - switch sub { - case "inventory": - err = runInventory(args) - case "kv-import": - err = runKVImport(args) - case "trading-audit-dump": - err = runTradingAuditDump(args) - case "convert-value-to-string": - err = runConvertValueToString(args) - case "-h", "--help", "help": - usage() - return - default: - usage() - os.Exit(2) - } - if err != nil { - fmt.Fprintln(os.Stderr, "error:", err) - os.Exit(1) - } -} - -func usage() { - fmt.Fprintln(os.Stderr, "usage: migrate_cf_data [flags]") -} - -func runInventory(args []string) error { - fs := flag.NewFlagSet("inventory", flag.ExitOnError) - if err := fs.Parse(args); err != nil { - return err - } - kv, err := newKVClient() - if err != nil { - return err - } - ctx, cancel := signalContext() - defer cancel() - keys, err := kv.ListKeys(ctx) - if err != nil { - return fmt.Errorf("list keys: %w", err) - } - migrate := map[string]int{} - skip := map[string]int{} - for _, k := range keys { - d := migration.Classify(k) - if d.Action == migration.ActionMigrate { - migrate[migration.PrefixOf(k)]++ - } else { - skip[d.Reason]++ - } - } - fmt.Printf("Cloudflare KV namespace contains %d keys.\n\n", len(keys)) - fmt.Println("Migrate-action keys by prefix:") - for p, n := range migrate { - fmt.Printf(" %-30s %d\n", p, n) - } - fmt.Println("\nSkip-action keys by reason:") - for r, n := range skip { - fmt.Printf(" %-30s %d\n", r, n) - } - return nil -} - -func runKVImport(args []string) error { - fs := flag.NewFlagSet("kv-import", flag.ExitOnError) - table := fs.String("table", "", "target DynamoDB table (required)") - dryRun := fs.Bool("dry-run", false, "log actions but do not write") - overwrite := fs.Bool("overwrite", false, "drop attribute_not_exists guard") - if err := fs.Parse(args); err != nil { - return err - } - if *table == "" { - return fmt.Errorf("--table is required") - } - kv, err := newKVClient() - if err != nil { - return err - } - ctx, cancel := signalContext() - defer cancel() - var writer *migration.DynamoDBWriter - if !*dryRun { - cfg, err := awsconfig.LoadDefaultConfig(ctx) - if err != nil { - return fmt.Errorf("aws config: %w", err) - } - writer = migration.NewDynamoDBWriter(dynamodb.NewFromConfig(cfg), *table, *overwrite) - } - keys, err := kv.ListKeys(ctx) - if err != nil { - return fmt.Errorf("list keys: %w", err) - } - report := migration.NewReport() - for _, k := range keys { - d := migration.Classify(k) - if d.Action != migration.ActionMigrate { - report.AddSkippedPolicy(d.Reason) - continue - } - val, err := kv.GetValue(ctx, k) - if err != nil { - fmt.Fprintf(os.Stderr, " get %s: %v\n", k, err) - report.AddFailed(migration.PrefixOf(k)) - continue - } - if *dryRun { - fmt.Printf(" DRY-RUN would write pk=%s sk=%s len=%d\n", d.PK, d.SK, len(val)) - report.AddImported(migration.PrefixOf(k)) - continue - } - switch err := writer.Put(ctx, d.PK, d.SK, val); err { - case nil: - report.AddImported(migration.PrefixOf(k)) - case migration.ErrItemExists: - report.AddSkippedExisting(migration.PrefixOf(k)) - default: - fmt.Fprintf(os.Stderr, " put %s/%s: %v\n", d.PK, d.SK, err) - report.AddFailed(migration.PrefixOf(k)) - } - } - report.Format(os.Stdout) - return nil -} - -func runTradingAuditDump(args []string) error { - fs := flag.NewFlagSet("trading-audit-dump", flag.ExitOnError) - out := fs.String("out", "", "output JSONL file path (required)") - if err := fs.Parse(args); err != nil { - return err - } - if *out == "" { - return fmt.Errorf("--out is required") - } - d1, err := newD1Client() - if err != nil { - return err - } - ctx, cancel := signalContext() - defer cancel() - rows, err := d1.Query(ctx, - "SELECT id, user_id, symbol, side, qty, price_vnd, ts FROM trading_trades ORDER BY id", nil) - if err != nil { - return fmt.Errorf("d1 query: %w", err) - } - f, err := os.Create(*out) - if err != nil { - return err - } - // Surface Close's error: an audit JSONL that fails to flush is silently - // truncated otherwise. Encode succeeding doesn't guarantee fsync — if - // the final Close hits ENOSPC the operator must see it (this file is - // evidence; a partial dump is worse than no dump). - enc := json.NewEncoder(f) - for _, r := range rows { - if err := enc.Encode(r); err != nil { - _ = f.Close() - return err - } - } - if err := f.Close(); err != nil { - return fmt.Errorf("close audit dump: %w", err) - } - fmt.Printf("Wrote %d rows to %s\n", len(rows), *out) - return nil -} - -func newKVClient() (*migration.CloudflareKVClient, error) { - token, account, ns := os.Getenv("CLOUDFLARE_API_TOKEN"), os.Getenv("CLOUDFLARE_ACCOUNT_ID"), os.Getenv("CF_KV_NAMESPACE_ID") - if token == "" || account == "" || ns == "" { - return nil, fmt.Errorf("set CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CF_KV_NAMESPACE_ID") - } - return migration.NewCloudflareKVClient(account, ns, token), nil -} - -func newD1Client() (*migration.CloudflareD1Client, error) { - token, account, db := os.Getenv("CLOUDFLARE_API_TOKEN"), os.Getenv("CLOUDFLARE_ACCOUNT_ID"), os.Getenv("CF_D1_DATABASE_ID") - if token == "" || account == "" || db == "" { - return nil, fmt.Errorf("set CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CF_D1_DATABASE_ID") - } - return migration.NewCloudflareD1Client(account, db, token), nil -} diff --git a/docs/cf-to-aws-migration-runbook.md b/docs/cf-to-aws-migration-runbook.md deleted file mode 100644 index e0efdcc..0000000 --- a/docs/cf-to-aws-migration-runbook.md +++ /dev/null @@ -1,198 +0,0 @@ -# Runbook: Cloudflare data → AWS migration - -This doc is the operator runbook for moving durable Cloudflare KV / D1 data into the live AWS DynamoDB shape used by `miti99bot`. - -> Scope here is only durable state that the current Go runtime still reads. Do not bulk-copy legacy Cloudflare data. - -## Live AWS target shape - -DynamoDB runtime contract: -- partition key: `pk = moduleName` -- sort key: `sk = caller key` -- payload attr: `value` - -Examples: -- `wordle` + `stats:` -- `loldle` + `config:` -- `lolschedule` + `subscribers` -- `misc` + `last_ping` -- `trading` + `user:` - -## Migration matrix (locked from live code AND live CF inventory) - -Live CF inventory taken from production via wrangler on 2026-05-16: -- D1 `miti99bot-db`: only `trading_trades` exists (plus internal `_cf_KV`, `_migrations`, `sqlite_sequence`). No `users` or `holdings` tables. 11 rows, 1 distinct user. -- KV namespace `f7f190fcb2fa42eb84a05542911334b0`: 21 keys total (see breakdown below). - -| Source dataset / prefix | Live keys | Current consumer | Action | AWS target | Notes | -|---|---|---|---|---|---| -| `wordle:stats:*` | 1 | `internal/modules/wordle/state.go` | migrate | `pk=wordle`, `sk=stats:` | durable player stats | -| `wordle:game:*` | 0 | `internal/modules/wordle/state.go` | skip | none | ephemeral; not present | -| `loldle:stats:*` | 4 | `internal/modules/loldle/state.go` | migrate | `pk=loldle`, `sk=stats:` | durable player stats | -| `loldle:config:*` | 1 | `internal/modules/loldle/state.go` | migrate | `pk=loldle`, `sk=config:` | durable per-subject config | -| `loldle:game:*` | 0 | `internal/modules/loldle/state.go` | skip | none | ephemeral; not present | -| `twentyq:stats:*` | 1 | `internal/modules/twentyq/state.go` | migrate | `pk=twentyq`, `sk=stats:` | durable player stats | -| `twentyq:game:*` | 0 | `internal/modules/twentyq/state.go` | skip | none | ephemeral; not present | -| `lolschedule:subscribers` | 1 | `internal/modules/lolschedule/subscribers.go` | migrate | `pk=lolschedule`, `sk=subscribers` | durable subscriber list | -| `lolschedule:matches:*` | 0 | `internal/modules/lolschedule/api_client.go` | skip | none | cache only; not present | -| `misc:last_ping` | 0 | `internal/modules/misc/misc.go` | skip | none | JS Worker never wrote it (KV 404). `/mstats` will start fresh on AWS. | -| `trading:user:*` | 1 | `internal/modules/trading/portfolio.go` | **migrate (flat KV copy)** | `pk=trading`, `sk=user:` | CF KV already holds the exact `Portfolio` JSON shape (`currency`/`assets`/`meta`). No D1 transform required. | -| `trading:sym:*` | 7 | `internal/modules/trading/symbols.go` | skip | none | symbol price cache | -| `trading_trades` (D1) | 11 rows | n/a (no AWS consumer) | archive-only | none | optional cold JSONL export for audit; not import input | -| `doantu:stats:*` | 2 | retired Go module | skip | none | retired; operator chose not to archive | -| `loldle-ability:stats:*` | 1 | retired Go module | skip | none | retired; operator chose not to archive | -| `loldle-emoji:stats:*` | 1 | retired Go module | skip | none | retired; operator chose not to archive | -| `semantle:stats:*` | 1 | retired Go module | skip | none | retired; operator chose not to archive | - -## Trading source — LOCKED 2026-05-16 - -The legacy CF JS Worker already snapshots the user portfolio into KV at `trading:user:`. The stored value is byte-for-byte the JSON shape the Go AWS runtime expects: - -```json -{ - "currency": {"VND": 170850000}, - "assets": {"TCB": 1000, "TCX": 1000, "FPT": 10000, "VCB": 1000}, - "meta": {"invested": 1000000000, "createdAt": 1776743792792} -} -``` - -Decision record: -- **Source** for trading migration = CF KV key `trading:user:`. Not D1. -- **Mapping rule** = identity copy. Read KV value, write to DynamoDB at `(pk=trading, sk=user:)` with the same `value` attribute. -- **`meta.invested`** = read directly from KV; no derivation. -- **`meta.createdAt`** = read directly from KV; no derivation. -- **D1 `trading_trades`** = archive-only audit export (JSONL). Not used by the import path. Only 11 rows / 1 user as of 2026-05-16 — operator may export or skip without runtime impact. - -This invalidates the earlier "Phase 03 D1 transform" framing. Phase 03 is now a plain KV copy plus optional D1 audit dump. - -## Phase 01 operator procedure - -### 1) Inventory D1 tables - -List all tables: - -```sh -wrangler d1 execute --remote \ - --command "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" \ - --json -``` - -Dump table definitions for all discovered tables, then inspect every name that looks trading-, portfolio-, user-, or holding-related: - -```sh -wrangler d1 execute --remote \ - --command "SELECT name, sql FROM sqlite_master WHERE type='table' ORDER BY name" \ - --json -``` - -Inspect columns for every candidate table that could hold portfolio snapshots or metadata: - -```sh -wrangler d1 execute --remote \ - --command "PRAGMA table_info()" \ - --json -``` - -Do not stop at historical names like `trading_trades`, `users`, or `holdings`; the goal is to inspect whatever production D1 actually contains today. - -### 2) Lock the trading source - -Resolved 2026-05-16. CF KV `trading:user:` already holds the exact `Portfolio` JSON shape; no transform path is needed. See "Trading source — LOCKED 2026-05-16" above for the decision record. The historical D1 `trading_trades` table is archive-only audit data. - -### 3) Inventory Cloudflare KV prefixes - -For each durable and skip candidate prefix above, list keys and capture representative values. -Use Wrangler KV listing for at least: -- `wordle:stats:` -- `wordle:game:` -- `loldle:stats:` -- `loldle:config:` -- `loldle:game:` -- `twentyq:stats:` -- `twentyq:game:` -- `lolschedule:subscribers` -- `lolschedule:matches:` -- `misc:last_ping` -- `trading:user:` (candidate only; inspect if a legacy portfolio snapshot prefix exists) -- `trading:sym:` - -### 4) Freeze the matrix - -Do not proceed to import tooling until each discovered dataset is tagged as one of: -- `migrate` -- `skip` -- `archive` - -Anything not in the matrix is out of scope by default. - -## Phase 01 done checklist — CLOSED 2026-05-16 - -- [x] Every live Cloudflare KV prefix is classified as `migrate`, `skip`, or `archive` -- [x] Every migrated KV dataset has an exact DynamoDB `(pk, sk)` target -- [x] Trading source table(s) and column(s) are locked (resolved as KV-only — see "Trading source — LOCKED") -- [x] `meta.invested` source is explicit (KV `trading:user:*`) -- [x] `meta.createdAt` source is explicit (KV `trading:user:*`) -- [x] Retired module namespaces are explicitly excluded from runtime import -- [x] AWS cutover remains gated on a green parity report from the migration plan (`plans/260510-0114-aws-port/phase-07-cutover.md` lines 13, 20, 42, 72) - -## Phase 03 impact - -The D1-transform branch of Phase 03 is dropped. Phase 03 is now: -- flat KV → DynamoDB copy across the 9 durable keys above -- optional one-shot `trading_trades` JSONL audit dump (operator-elective) - -Effort revised from 4-6h to ~2h. - -## Phase 02 toolchain (built 2026-05-16) - -Single Go binary `cmd/migrate_cf_data` with 3 subcommands. No admin HTTP routes. No dependency on the running AWS bot process. Shared helpers live under `internal/migration/`. - -### Required environment - -| Var | Purpose | Used by | -|---|---|---| -| `CLOUDFLARE_API_TOKEN` | read-scope token | inventory, kv-import, trading-audit-dump | -| `CLOUDFLARE_ACCOUNT_ID` | production CF account | all | -| `CF_KV_NAMESPACE_ID` | production KV namespace | inventory, kv-import | -| `CF_D1_DATABASE_ID` | production D1 database | trading-audit-dump | -| `AWS_REGION` (+ standard AWS SDK creds) | DynamoDB writes | kv-import (non-dry-run) | - -### Commands - -Inventory (read-only classification, prints counts; no writes anywhere): - -```sh -go run ./cmd/migrate_cf_data inventory -``` - -KV import dry-run against staging — proves wiring and key-shape mapping without any DynamoDB writes: - -```sh -go run ./cmd/migrate_cf_data kv-import --table=miti99bot-staging --dry-run -``` - -KV import (default idempotent — rejects writes where `(pk, sk)` already exists; rerun is safe): - -```sh -go run ./cmd/migrate_cf_data kv-import --table=miti99bot-staging -``` - -KV import with explicit overwrite (drops the `attribute_not_exists` guard; only use when intentionally re-importing a known-good source after rollback): - -```sh -go run ./cmd/migrate_cf_data kv-import --table=miti99bot-staging --overwrite -``` - -Optional `trading_trades` audit dump (D1 → JSONL, audit-only — not an import input): - -```sh -go run ./cmd/migrate_cf_data trading-audit-dump --out=plans/reports/migration-260515-2250-trading-audit.jsonl -``` - -### Report layout - -Every `kv-import` run prints a Migration report block with four buckets: `Imported`, `Skipped (already present)`, `Skipped (policy)`, `Failed`. Each bucket is keyed by source prefix (or by skip-reason for the policy bucket). Phase 04 parity verification compares Phase 01 inventory counts against this report. - -### Verified end-to-end against production CF on 2026-05-16 - -`inventory` and `kv-import --dry-run` both run cleanly against prod CF. 9 durable keys map to the runtime `(pk, sk)` shape exactly as the policy table predicts. No DynamoDB writes were issued during verification (`--dry-run`). diff --git a/internal/ai/client.go b/internal/ai/client.go index 084702c..8fe232b 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -54,8 +54,9 @@ func NewClient(ctx context.Context, apiKey string) (*Client, error) { // Generate runs a single-turn chat with `system` as the system instruction // and `user` as the user message. Returns the model's text reply. // -// The output cap matches what the JS twentyq prompt expects (≤200 tokens, -// single-line JSON). Temperature 0.7 mirrors the JS code path. +// The output is capped to keep the twentyq prompt's response budget tight +// (≤200 tokens, single-line JSON). Temperature 0.7 trades a little +// variability for hint freshness. func (c *Client) Generate(ctx context.Context, system, user string) (string, error) { if c == nil || c.g == nil { return "", ErrNotConfigured diff --git a/internal/keylock/keylock.go b/internal/keylock/keylock.go index df390ab..631903b 100644 --- a/internal/keylock/keylock.go +++ b/internal/keylock/keylock.go @@ -3,8 +3,9 @@ // // Why a separate package: every game module needs a per-subject mutex to // turn KVStore's single-op atomicity into safe Get→mutate→Put. The bot -// dispatcher runs each Telegram update in its own goroutine, and the JS -// source's Cloudflare Workers serialisation is not a property Go inherits. +// dispatcher runs each Telegram update in its own goroutine, so without +// explicit per-subject serialisation two updates to the same game could +// race and drop one write. // // Trade-off: the underlying sync.Map grows unboundedly with distinct keys // (~32 B each). At 1M keys that's ~32 MB — acceptable for the lifetime of diff --git a/internal/metrics/counters.go b/internal/metrics/counters.go index 960a161..94428c4 100644 --- a/internal/metrics/counters.go +++ b/internal/metrics/counters.go @@ -23,8 +23,8 @@ import ( ) // DefaultFlushInterval is how often Run flushes counters to the log. 60s -// matches the JS source and keeps log volume modest (1 metrics line per -// minute per active instance). +// keeps log volume modest (1 metrics line per minute per active instance) +// while still surfacing minute-scale traffic shifts in CloudWatch. const DefaultFlushInterval = 60 * time.Second // Registry holds named counters across three categories: command diff --git a/internal/migration/cloudflare_d1_client.go b/internal/migration/cloudflare_d1_client.go deleted file mode 100644 index cfdefb4..0000000 --- a/internal/migration/cloudflare_d1_client.go +++ /dev/null @@ -1,92 +0,0 @@ -package migration - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - -// CloudflareD1Client is a thin read-only REST client for the legacy -// Cloudflare D1 database. The migration only uses it for the optional -// trading_trades audit dump in Phase 03; runtime data flow never reads D1. -type CloudflareD1Client struct { - httpClient *http.Client - apiBase string - accountID string - databaseID string - apiToken string -} - -func NewCloudflareD1Client(accountID, databaseID, apiToken string) *CloudflareD1Client { - if accountID == "" || databaseID == "" || apiToken == "" { - panic("migration: CloudflareD1Client requires accountID, databaseID, apiToken") - } - return &CloudflareD1Client{ - httpClient: &http.Client{Timeout: 60 * time.Second}, - apiBase: "https://api.cloudflare.com/client/v4", - accountID: accountID, - databaseID: databaseID, - apiToken: apiToken, - } -} - -func (c *CloudflareD1Client) SetBaseURL(base string) { c.apiBase = base } - -// d1QueryEnvelope matches the D1 query response. Result is an array because -// D1 returns one entry per statement; we only ever send one. -type d1QueryEnvelope struct { - Result []struct { - Results []map[string]any `json:"results"` - Success bool `json:"success"` - } `json:"result"` - Success bool `json:"success"` - Errors []map[string]any `json:"errors"` -} - -// Query runs a single SQL statement and returns the row maps in result-set -// order. params is optional; pass nil for parameterless statements. -func (c *CloudflareD1Client) Query(ctx context.Context, sql string, params []any) ([]map[string]any, error) { - payload := map[string]any{"sql": sql} - if len(params) > 0 { - payload["params"] = params - } - body, err := json.Marshal(payload) - if err != nil { - return nil, err - } - endpoint := fmt.Sprintf("%s/accounts/%s/d1/database/%s/query", - c.apiBase, c.accountID, c.databaseID) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+c.apiToken) - req.Header.Set("Content-Type", "application/json") - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - raw, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("d1 query: status %d: %s", resp.StatusCode, string(raw)) - } - var env d1QueryEnvelope - if err := json.Unmarshal(raw, &env); err != nil { - return nil, fmt.Errorf("d1 query: decode: %w", err) - } - if !env.Success { - return nil, fmt.Errorf("d1 query: api error: %v", env.Errors) - } - if len(env.Result) == 0 { - return nil, nil - } - return env.Result[0].Results, nil -} diff --git a/internal/migration/cloudflare_d1_client_test.go b/internal/migration/cloudflare_d1_client_test.go deleted file mode 100644 index 96e90f7..0000000 --- a/internal/migration/cloudflare_d1_client_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package migration - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestD1QueryHappyPath(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Errorf("method=%s want POST", r.Method) - } - if !strings.HasSuffix(r.URL.Path, "/d1/database/db123/query") { - t.Errorf("path=%s", r.URL.Path) - } - body, _ := io.ReadAll(r.Body) - var got map[string]any - _ = json.Unmarshal(body, &got) - if got["sql"] != "SELECT * FROM trading_trades" { - t.Errorf("sql=%v", got["sql"]) - } - _, _ = w.Write([]byte(`{ - "success": true, - "result": [{ - "results": [ - {"id": 1, "user_id": 100, "symbol": "FPT"}, - {"id": 2, "user_id": 100, "symbol": "TCB"} - ], - "success": true - }] - }`)) - })) - defer srv.Close() - - c := NewCloudflareD1Client("acct", "db123", "tok") - c.SetBaseURL(srv.URL) - - rows, err := c.Query(context.Background(), "SELECT * FROM trading_trades", nil) - if err != nil { - t.Fatalf("query: %v", err) - } - if len(rows) != 2 { - t.Fatalf("got %d rows", len(rows)) - } - if rows[0]["symbol"] != "FPT" || rows[1]["symbol"] != "TCB" { - t.Errorf("rows=%v", rows) - } -} - -func TestD1QueryApiError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"success": false, "errors": [{"code": 8000, "message": "bad sql"}]}`)) - })) - defer srv.Close() - - c := NewCloudflareD1Client("acct", "db", "tok") - c.SetBaseURL(srv.URL) - _, err := c.Query(context.Background(), "SELECT", nil) - if err == nil || !strings.Contains(err.Error(), "api error") { - t.Fatalf("got %v, want api error", err) - } -} diff --git a/internal/migration/cloudflare_kv_client.go b/internal/migration/cloudflare_kv_client.go deleted file mode 100644 index fd98d67..0000000 --- a/internal/migration/cloudflare_kv_client.go +++ /dev/null @@ -1,135 +0,0 @@ -package migration - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "time" -) - -// CloudflareKVClient is a thin read-only REST client for the legacy -// Cloudflare Workers KV namespace. List+Get are the only operations the -// migration needs; mutations stay out of scope. -type CloudflareKVClient struct { - httpClient *http.Client - apiBase string - accountID string - namespaceID string - apiToken string -} - -// NewCloudflareKVClient panics on empty required fields so misconfiguration -// surfaces at startup rather than after partial work. -func NewCloudflareKVClient(accountID, namespaceID, apiToken string) *CloudflareKVClient { - if accountID == "" || namespaceID == "" || apiToken == "" { - panic("migration: CloudflareKVClient requires accountID, namespaceID, apiToken") - } - return &CloudflareKVClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, - apiBase: "https://api.cloudflare.com/client/v4", - accountID: accountID, - namespaceID: namespaceID, - apiToken: apiToken, - } -} - -// SetBaseURL overrides the API base; used by tests with httptest.Server. -func (c *CloudflareKVClient) SetBaseURL(base string) { c.apiBase = base } - -// kvListEnvelope matches the Cloudflare REST list-keys response shape. -// See: https://developers.cloudflare.com/api/operations/workers-kv-namespace-list-a-namespace-s-keys -type kvListEnvelope struct { - Result []struct { - Name string `json:"name"` - } `json:"result"` - Success bool `json:"success"` - Errors []map[string]any `json:"errors"` - ResultInfo struct { - Cursor string `json:"cursor"` - Count int `json:"count"` - } `json:"result_info"` -} - -// ListKeys returns every key in the namespace. The Phase 01 inventory -// proved the namespace fits in well under one REST page (21 keys vs 1000 -// page limit), but pagination is still honored for safety. -func (c *CloudflareKVClient) ListKeys(ctx context.Context) ([]string, error) { - var out []string - cursor := "" - for { - page, next, err := c.listPage(ctx, cursor) - if err != nil { - return nil, err - } - out = append(out, page...) - if next == "" { - return out, nil - } - cursor = next - } -} - -func (c *CloudflareKVClient) listPage(ctx context.Context, cursor string) ([]string, string, error) { - endpoint := fmt.Sprintf("%s/accounts/%s/storage/kv/namespaces/%s/keys", - c.apiBase, c.accountID, c.namespaceID) - if cursor != "" { - endpoint += "?" + url.Values{"cursor": []string{cursor}}.Encode() - } - body, err := c.do(ctx, http.MethodGet, endpoint) - if err != nil { - return nil, "", err - } - var env kvListEnvelope - if err := json.Unmarshal(body, &env); err != nil { - return nil, "", fmt.Errorf("kv list: decode: %w", err) - } - if !env.Success { - return nil, "", fmt.Errorf("kv list: api error: %v", env.Errors) - } - names := make([]string, 0, len(env.Result)) - for _, r := range env.Result { - names = append(names, r.Name) - } - return names, env.ResultInfo.Cursor, nil -} - -// GetValue returns the raw value bytes for one KV key. CF returns 404 as -// errKeyNotFound so callers can decide whether a missing key is fatal. -func (c *CloudflareKVClient) GetValue(ctx context.Context, key string) ([]byte, error) { - endpoint := fmt.Sprintf("%s/accounts/%s/storage/kv/namespaces/%s/values/%s", - c.apiBase, c.accountID, c.namespaceID, url.PathEscape(key)) - return c.do(ctx, http.MethodGet, endpoint) -} - -// ErrKeyNotFound signals a 404 on a value GET. Returned wrapped so callers -// can use errors.Is. -var ErrKeyNotFound = errors.New("cloudflare kv: key not found") - -func (c *CloudflareKVClient) do(ctx context.Context, method, endpoint string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, method, endpoint, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+c.apiToken) - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("%w: %s", ErrKeyNotFound, endpoint) - } - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("cloudflare api %s %s: status %d: %s", - method, endpoint, resp.StatusCode, string(body)) - } - return body, nil -} diff --git a/internal/migration/cloudflare_kv_client_test.go b/internal/migration/cloudflare_kv_client_test.go deleted file mode 100644 index a39100c..0000000 --- a/internal/migration/cloudflare_kv_client_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package migration - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestListKeysSinglePage(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("Authorization"); got != "Bearer test-token" { - t.Errorf("auth header = %q", got) - } - if !strings.HasSuffix(r.URL.Path, "/storage/kv/namespaces/ns123/keys") { - t.Errorf("unexpected path %q", r.URL.Path) - } - _, _ = w.Write([]byte(`{ - "result": [{"name":"wordle:stats:1"},{"name":"trading:sym:FPT"}], - "success": true, - "result_info": {"count": 2, "cursor": ""} - }`)) - })) - defer srv.Close() - - c := NewCloudflareKVClient("acct", "ns123", "test-token") - c.SetBaseURL(srv.URL) - - keys, err := c.ListKeys(context.Background()) - if err != nil { - t.Fatalf("list: %v", err) - } - if len(keys) != 2 || keys[0] != "wordle:stats:1" || keys[1] != "trading:sym:FPT" { - t.Fatalf("got %v", keys) - } -} - -func TestListKeysPaginates(t *testing.T) { - page := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - page++ - switch page { - case 1: - _, _ = w.Write([]byte(`{ - "result": [{"name":"a"}], - "success": true, - "result_info": {"cursor": "p2"} - }`)) - case 2: - if got := r.URL.Query().Get("cursor"); got != "p2" { - t.Errorf("cursor=%q want p2", got) - } - _, _ = w.Write([]byte(`{ - "result": [{"name":"b"}], - "success": true, - "result_info": {"cursor": ""} - }`)) - default: - t.Fatalf("too many pages") - } - })) - defer srv.Close() - - c := NewCloudflareKVClient("acct", "ns", "tok") - c.SetBaseURL(srv.URL) - keys, err := c.ListKeys(context.Background()) - if err != nil { - t.Fatalf("list: %v", err) - } - if len(keys) != 2 || keys[0] != "a" || keys[1] != "b" { - t.Fatalf("got %v", keys) - } -} - -func TestGetValueReturns404AsErrKeyNotFound(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - c := NewCloudflareKVClient("acct", "ns", "tok") - c.SetBaseURL(srv.URL) - _, err := c.GetValue(context.Background(), "misc:last_ping") - if !errors.Is(err, ErrKeyNotFound) { - t.Fatalf("got %v, want ErrKeyNotFound", err) - } -} - -func TestGetValueRawBytes(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"currency":{"VND":100},"meta":{"invested":0}}`)) - })) - defer srv.Close() - - c := NewCloudflareKVClient("acct", "ns", "tok") - c.SetBaseURL(srv.URL) - val, err := c.GetValue(context.Background(), "trading:user:42") - if err != nil { - t.Fatalf("get: %v", err) - } - want := `{"currency":{"VND":100},"meta":{"invested":0}}` - if string(val) != want { - t.Errorf("got %q want %q", string(val), want) - } -} diff --git a/internal/migration/dynamodb_writer.go b/internal/migration/dynamodb_writer.go deleted file mode 100644 index 751f1b7..0000000 --- a/internal/migration/dynamodb_writer.go +++ /dev/null @@ -1,61 +0,0 @@ -package migration - -import ( - "context" - "errors" - "fmt" - "strconv" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/dynamodb" - "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" -) - -// DynamoDBWriter writes migrated KV records into the runtime DynamoDB table -// using the exact attribute shape internal/storage/dynamodb_kv.go expects: -// pk (S), sk (S), value (S), updatedAt (N). -// -// Idempotency: by default the writer attaches a ConditionExpression that -// rejects writes where the (pk, sk) pair already exists. The CLI exposes an -// --overwrite flag that drops the condition for explicit re-imports. -type DynamoDBWriter struct { - client *dynamodb.Client - table string - overwrite bool -} - -func NewDynamoDBWriter(client *dynamodb.Client, table string, overwrite bool) *DynamoDBWriter { - return &DynamoDBWriter{client: client, table: table, overwrite: overwrite} -} - -// ErrItemExists signals a guarded write skipped because (pk, sk) was already -// present. The caller increments the "skipped (already imported)" counter. -var ErrItemExists = errors.New("dynamodb: item exists") - -// Put writes one record. value bytes are stored as a DynamoDB String so the -// payload is human-readable in the AWS console; all current sources are JSON -// and therefore UTF-8 safe. -func (w *DynamoDBWriter) Put(ctx context.Context, pk, sk string, value []byte) error { - in := &dynamodb.PutItemInput{ - TableName: aws.String(w.table), - Item: map[string]types.AttributeValue{ - "pk": &types.AttributeValueMemberS{Value: pk}, - "sk": &types.AttributeValueMemberS{Value: sk}, - "value": &types.AttributeValueMemberS{Value: string(value)}, - "updatedAt": &types.AttributeValueMemberN{Value: strconv.FormatInt(time.Now().UTC().UnixNano(), 10)}, - }, - } - if !w.overwrite { - in.ConditionExpression = aws.String("attribute_not_exists(pk)") - } - _, err := w.client.PutItem(ctx, in) - if err != nil { - var cf *types.ConditionalCheckFailedException - if errors.As(err, &cf) { - return ErrItemExists - } - return fmt.Errorf("dynamodb put %s/%s: %w", pk, sk, err) - } - return nil -} diff --git a/internal/migration/policy.go b/internal/migration/policy.go deleted file mode 100644 index a0d3951..0000000 --- a/internal/migration/policy.go +++ /dev/null @@ -1,99 +0,0 @@ -// Package migration provides operator-run tooling that moves data from the -// legacy Cloudflare KV/D1 stack into the live AWS DynamoDB store. -// -// The package is intentionally small. It exposes: -// - Policy: classify a CF KV key as migrate/skip/archive and resolve its -// target DynamoDB (pk, sk). -// - CloudflareKVClient / CloudflareD1Client: thin REST readers. -// - DynamoDBWriter: idempotent writes against the runtime KV table shape. -// - Report: per-prefix counts for an import run. -// -// The runtime production code (cmd/server) never imports this package. -package migration - -import "strings" - -// Action is the per-source-key migration decision locked in Phase 01. -type Action string - -const ( - ActionMigrate Action = "migrate" - ActionSkip Action = "skip" -) - -// Decision is the resolved migration decision for one CF KV key. -type Decision struct { - Action Action - // PK and SK are populated only when Action == ActionMigrate. - PK string - SK string - // Reason explains a Skip (cache, retired, missing, etc.). - Reason string -} - -// kvRule is one entry in the static allowlist. Order matters: rules are -// matched top-down with HasPrefix so the most specific prefix wins when -// shorter prefixes would otherwise swallow a longer one. -type kvRule struct { - prefix string - module string // DynamoDB pk; empty when action != migrate - skip string // non-empty marks the rule as skip; value is the reason -} - -// kvRules is the locked Phase 01 inventory. Adding a new live CF prefix -// requires re-running the Phase 01 inventory and updating this list. -var kvRules = []kvRule{ - // Durable user data — migrate. - {prefix: "wordle:stats:", module: "wordle"}, - {prefix: "loldle:stats:", module: "loldle"}, - {prefix: "loldle:config:", module: "loldle"}, - {prefix: "twentyq:stats:", module: "twentyq"}, - {prefix: "lolschedule:subscribers", module: "lolschedule"}, - {prefix: "trading:user:", module: "trading"}, - - // Caches — skip. - {prefix: "trading:sym:", skip: "cache"}, - {prefix: "wordle:game:", skip: "ephemeral"}, - {prefix: "loldle:game:", skip: "ephemeral"}, - {prefix: "twentyq:game:", skip: "ephemeral"}, - {prefix: "lolschedule:matches:", skip: "cache"}, - - // Retired modules — operator chose not to archive (Phase 01, 2026-05-16). - {prefix: "doantu:", skip: "retired"}, - {prefix: "loldle-ability:", skip: "retired"}, - {prefix: "loldle-emoji:", skip: "retired"}, - {prefix: "loldle-quote:", skip: "retired"}, - {prefix: "loldle-splash:", skip: "retired"}, - {prefix: "semantle:", skip: "retired"}, -} - -// Classify returns the migration decision for a CF KV key. -// Unknown keys default to skip with reason "unknown" so new upstream prefixes -// are visible in the inventory report instead of being silently imported. -func Classify(cfKey string) Decision { - for _, r := range kvRules { - if !strings.HasPrefix(cfKey, r.prefix) { - continue - } - if r.skip != "" { - return Decision{Action: ActionSkip, Reason: r.skip} - } - // Migrate. Target sk is the CF key with the leading ":" stripped. - // Live runtime stores e.g. wordle/stats:, not wordle/wordle:stats:. - sk := strings.TrimPrefix(cfKey, r.module+":") - return Decision{Action: ActionMigrate, PK: r.module, SK: sk} - } - return Decision{Action: ActionSkip, Reason: "unknown"} -} - -// DurablePrefixes returns the migrate-action prefixes for use by inventory -// and reporting. Order matches kvRules. -func DurablePrefixes() []string { - out := make([]string, 0, len(kvRules)) - for _, r := range kvRules { - if r.skip == "" { - out = append(out, r.prefix) - } - } - return out -} diff --git a/internal/migration/policy_test.go b/internal/migration/policy_test.go deleted file mode 100644 index 46961dc..0000000 --- a/internal/migration/policy_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package migration - -import "testing" - -func TestClassify(t *testing.T) { - cases := []struct { - key string - wantAct Action - wantPK string - wantSK string - wantSkip string - }{ - // Durable migrate paths — these are the 6 prefixes Phase 01 locked. - {"wordle:stats:-1001760292100", ActionMigrate, "wordle", "stats:-1001760292100", ""}, - {"loldle:stats:1064111334", ActionMigrate, "loldle", "stats:1064111334", ""}, - {"loldle:config:-1001760292100", ActionMigrate, "loldle", "config:-1001760292100", ""}, - {"twentyq:stats:-1001760292100", ActionMigrate, "twentyq", "stats:-1001760292100", ""}, - {"lolschedule:subscribers", ActionMigrate, "lolschedule", "subscribers", ""}, - {"trading:user:1064111334", ActionMigrate, "trading", "user:1064111334", ""}, - - // Cache + ephemeral — skip. - {"trading:sym:FPT", ActionSkip, "", "", "cache"}, - {"wordle:game:abc", ActionSkip, "", "", "ephemeral"}, - {"lolschedule:matches:2026", ActionSkip, "", "", "cache"}, - - // Retired modules — skip. - {"doantu:stats:1064111334", ActionSkip, "", "", "retired"}, - {"semantle:stats:-1001760292100", ActionSkip, "", "", "retired"}, - {"loldle-emoji:stats:-1001760292100", ActionSkip, "", "", "retired"}, - - // Unknown prefix — skip with reason "unknown" so it surfaces in reports. - {"newmodule:foo", ActionSkip, "", "", "unknown"}, - } - - for _, c := range cases { - t.Run(c.key, func(t *testing.T) { - got := Classify(c.key) - if got.Action != c.wantAct { - t.Fatalf("action=%v want %v", got.Action, c.wantAct) - } - if got.PK != c.wantPK { - t.Errorf("pk=%q want %q", got.PK, c.wantPK) - } - if got.SK != c.wantSK { - t.Errorf("sk=%q want %q", got.SK, c.wantSK) - } - if got.Reason != c.wantSkip { - t.Errorf("reason=%q want %q", got.Reason, c.wantSkip) - } - }) - } -} - -func TestDurablePrefixes(t *testing.T) { - got := DurablePrefixes() - want := []string{ - "wordle:stats:", - "loldle:stats:", - "loldle:config:", - "twentyq:stats:", - "lolschedule:subscribers", - "trading:user:", - } - if len(got) != len(want) { - t.Fatalf("got %v, want %v", got, want) - } - for i := range want { - if got[i] != want[i] { - t.Errorf("idx %d: got %q want %q", i, got[i], want[i]) - } - } -} - -func TestPrefixOfLongestMatch(t *testing.T) { - // loldle:stats: and loldle:config: both share the loldle: super-prefix. - // Longest match wins so report buckets stay precise. - if got := PrefixOf("loldle:stats:1234"); got != "loldle:stats:" { - t.Errorf("loldle:stats:1234 → %q, want loldle:stats:", got) - } - if got := PrefixOf("loldle:config:5"); got != "loldle:config:" { - t.Errorf("loldle:config:5 → %q, want loldle:config:", got) - } - if got := PrefixOf("zzz:unknown"); got != "unknown" { - t.Errorf("unknown bucket: got %q", got) - } -} diff --git a/internal/migration/report.go b/internal/migration/report.go deleted file mode 100644 index 80b453b..0000000 --- a/internal/migration/report.go +++ /dev/null @@ -1,96 +0,0 @@ -package migration - -import ( - "fmt" - "io" - "sort" -) - -// Report aggregates counts for one migration run. It is intentionally -// per-prefix rather than per-key so the runbook can compare totals to the -// Phase 01 inventory at a glance. -type Report struct { - // Imported is keys actually written to DynamoDB. - Imported map[string]int - // SkippedExisting is keys already present (idempotent rerun). - SkippedExisting map[string]int - // SkippedPolicy is keys rejected by the Phase 01 allowlist; keyed by - // reason (cache, retired, unknown, ephemeral). - SkippedPolicy map[string]int - // Failed is keys that errored mid-import. - Failed map[string]int -} - -func NewReport() *Report { - return &Report{ - Imported: map[string]int{}, - SkippedExisting: map[string]int{}, - SkippedPolicy: map[string]int{}, - Failed: map[string]int{}, - } -} - -func (r *Report) AddImported(prefix string) { r.Imported[prefix]++ } -func (r *Report) AddSkippedExisting(prefix string) { r.SkippedExisting[prefix]++ } -func (r *Report) AddSkippedPolicy(reason string) { r.SkippedPolicy[reason]++ } -func (r *Report) AddFailed(prefix string) { r.Failed[prefix]++ } - -// Format writes a human-readable summary. Stable ordering (alphabetical) so -// rerun diffs stay clean. Write errors are ignored — callers pass os.Stdout -// or *bytes.Buffer, where short writes are not actionable. -func (r *Report) Format(w io.Writer) { - _, _ = fmt.Fprintln(w, "Migration report") - _, _ = fmt.Fprintln(w, "================") - writeSection(w, "Imported", r.Imported) - writeSection(w, "Skipped (already present)", r.SkippedExisting) - writeSection(w, "Skipped (policy)", r.SkippedPolicy) - writeSection(w, "Failed", r.Failed) - _, _ = fmt.Fprintf(w, "TOTAL imported=%d skipped_existing=%d skipped_policy=%d failed=%d\n", - sum(r.Imported), sum(r.SkippedExisting), sum(r.SkippedPolicy), sum(r.Failed)) -} - -func writeSection(w io.Writer, label string, m map[string]int) { - _, _ = fmt.Fprintf(w, "\n%s:\n", label) - if len(m) == 0 { - _, _ = fmt.Fprintln(w, " (none)") - return - } - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - _, _ = fmt.Fprintf(w, " %-30s %d\n", k, m[k]) - } -} - -func sum(m map[string]int) int { - t := 0 - for _, v := range m { - t += v - } - return t -} - -// PrefixOf returns the longest known prefix from kvRules that matches key, -// or "unknown". Used by Report consumers to bucket counts. -func PrefixOf(key string) string { - best := "" - for _, r := range kvRules { - if len(r.prefix) > len(best) && hasPrefix(key, r.prefix) { - best = r.prefix - } - } - if best == "" { - return "unknown" - } - return best -} - -func hasPrefix(s, p string) bool { - if len(s) < len(p) { - return false - } - return s[:len(p)] == p -} diff --git a/internal/migration/report_test.go b/internal/migration/report_test.go deleted file mode 100644 index c1981b4..0000000 --- a/internal/migration/report_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package migration - -import ( - "bytes" - "strings" - "testing" -) - -func TestReportFormat(t *testing.T) { - r := NewReport() - r.AddImported("wordle:stats:") - r.AddImported("wordle:stats:") - r.AddImported("trading:user:") - r.AddSkippedExisting("loldle:stats:") - r.AddSkippedPolicy("cache") - r.AddSkippedPolicy("cache") - r.AddSkippedPolicy("retired") - r.AddFailed("twentyq:stats:") - - var buf bytes.Buffer - r.Format(&buf) - got := buf.String() - - // Spot-check structure and totals. - for _, want := range []string{ - "Imported:", - "wordle:stats: 2", - "trading:user: 1", - "Skipped (already present):", - "loldle:stats: 1", - "Skipped (policy):", - "cache 2", - "retired 1", - "Failed:", - "twentyq:stats: 1", - "TOTAL imported=3 skipped_existing=1 skipped_policy=3 failed=1", - } { - if !strings.Contains(got, want) { - t.Errorf("missing %q in output:\n%s", want, got) - } - } -} - -func TestReportEmptySection(t *testing.T) { - r := NewReport() - r.AddImported("wordle:stats:") - var buf bytes.Buffer - r.Format(&buf) - if !strings.Contains(buf.String(), "Failed:\n (none)") { - t.Errorf("empty section missing (none) marker:\n%s", buf.String()) - } -} diff --git a/internal/modules/loldle/champions.go b/internal/modules/loldle/champions.go index 59cfc91..fc9a705 100644 --- a/internal/modules/loldle/champions.go +++ b/internal/modules/loldle/champions.go @@ -1,4 +1,4 @@ -// Package loldle ports the JS loldle classic mode — guess the League +// Package loldle implements the loldle classic mode — guess the League // champion from attribute hints (gender, species, regions, etc.). package loldle @@ -9,8 +9,8 @@ import ( ) // Champion is one row of champions.json. Field tags match loldle.net's -// scraped schema verbatim so the embedded JSON file lifts unmodified from -// the JS source. +// scraped schema verbatim so the embedded JSON file can be regenerated from +// the upstream scrape without a transform step. type Champion struct { ChampionName string `json:"championName"` Gender string `json:"gender"` @@ -22,9 +22,8 @@ type Champion struct { ReleaseDate string `json:"release_date"` // YYYY-MM-DD } -// rawChampions holds the embedded JSON byte stream. The data file is copied -// byte-for-byte from src/modules/loldle/champions.json in the JS repo so -// dictionaries are identical across runtimes (no normalization at port time). +// rawChampions holds the embedded JSON byte stream — the loldle.net champion +// dictionary scraped offline and checked into data/champions.json. // //go:embed data/champions.json var rawChampions []byte diff --git a/internal/modules/loldle/compare.go b/internal/modules/loldle/compare.go index 9b08fed..a14f214 100644 --- a/internal/modules/loldle/compare.go +++ b/internal/modules/loldle/compare.go @@ -4,8 +4,8 @@ import ( "strings" ) -// AttrType classifies how a single attribute is compared. Matches the JS -// source's "exact" | "multi" | "year" discriminator. +// AttrType classifies how a single attribute is compared: +// "exact" | "multi" | "year". type AttrType string const ( @@ -14,16 +14,18 @@ const ( attrYear AttrType = "year" ) -// Result categories. Match JS strings byte-for-byte (handlers/render rely on -// these literals via the marker maps). +// Result categories. handlers/render rely on these literals via the marker +// maps, so renaming a constant requires updating those maps in lockstep. const ( ResultCorrect = "correct" ResultPartial = "partial" ResultWrong = "wrong" ) -// AttributeRow describes one attribute's comparison output. JS shape: -// {key, label, type, guessValue, targetValue, result, direction?}. +// AttributeRow describes one attribute's comparison output as one row of +// the render board: key/label identify the row, type drives the comparison +// algorithm, result is the rendered marker, direction is set only for +// wrong year-type rows ("up"/"down"). type AttributeRow struct { Key string Label string @@ -35,8 +37,8 @@ type AttributeRow struct { } // classicAttributes is the ordered comparison spec. Order matters for both -// render output and the JS-port test that asserts `compareChampions(...)` -// returns rows in this exact sequence. +// render output and the test that asserts CompareChampions returns rows in +// this exact sequence — reordering changes the on-screen board. var classicAttributes = []AttributeRow{ {Key: "gender", Label: "Gender", Type: attrExact}, {Key: "species", Label: "Species", Type: attrMulti}, @@ -51,7 +53,7 @@ var classicAttributes = []AttributeRow{ // Values are compared per attr.Type: // - exact: case-insensitive string equality. // - multi: set comparison; full match → correct, partial overlap → partial, -// no overlap → wrong. Two empty sets are correct (matches JS). +// no overlap → wrong. Two empty sets are correct. // - year: parses leading 4 digits; equal → correct, else wrong + a // direction hint ("up" if guesstarget). func CompareChampions(guess, target *Champion) []AttributeRow { @@ -126,8 +128,9 @@ func asStringSlice(v any) []string { case []string: return x case string: - // JS toSet falls back to splitting on "," when the value isn't an array. - // Champions.json never produces this branch but parity matters. + // Defensive: champions.json never sends a comma-joined string for a + // multi-valued attribute, but if a future data refresh ever does, + // split on "," instead of treating it as a single token. if x == "" { return nil } @@ -138,7 +141,7 @@ func asStringSlice(v any) []string { // compareMultiValue: full match (case-insensitive, order-independent) → // correct; any intersection → partial; otherwise wrong. Two empty sets -// are correct (matches JS, e.g. for hypothetical "no positions" champs). +// are correct (e.g. for a hypothetical "no positions" champion). func compareMultiValue(guess, target []string) string { g := toLowerSet(guess) t := toLowerSet(target) @@ -182,8 +185,7 @@ func setsEqual(a, b map[string]struct{}) bool { return true } -// parseYear extracts the first 4 digits of s. Returns 0 if absent/non-numeric -// — JS regex parity (`^(\d{4})`). +// parseYear extracts the first 4 digits of s. Returns 0 if absent/non-numeric. func parseYear(s string) int { if len(s) < 4 { return 0 @@ -232,8 +234,8 @@ func compareYear(g, t int) (result, direction string) { return ResultWrong, "down" } -// formatValue mirrors JS's `formatValue`: empty → "—", array → comma-joined, -// otherwise toString. +// formatValue renders a value cell: empty → "—", array → comma-joined, +// otherwise stringified. func formatValue(v any) string { switch x := v.(type) { case nil: diff --git a/internal/modules/loldle/compare_test.go b/internal/modules/loldle/compare_test.go index 6f6eaf6..b0acf93 100644 --- a/internal/modules/loldle/compare_test.go +++ b/internal/modules/loldle/compare_test.go @@ -4,8 +4,8 @@ import ( "testing" ) -// Fixtures lifted from tests/modules/loldle/compare.test.js so any future -// drift between Go and JS scoring shows up as a test failure here. +// Fixtures cover the compare-scoring rules. Drift in the scoring algorithm +// or the canonical attribute order surfaces here as a test failure. var aatrox = &Champion{ ChampionName: "Aatrox", diff --git a/internal/modules/loldle/flavor.go b/internal/modules/loldle/flavor.go index f3d9609..0541dbc 100644 --- a/internal/modules/loldle/flavor.go +++ b/internal/modules/loldle/flavor.go @@ -2,9 +2,9 @@ package loldle import "fmt" -// attemptFlavor returns a one-word reaction to a winning attempt. Branches -// match the JS source exactly (1 → "First try!", 2 → "Sharp!", final → -// "Phew — last one!", final-2 → "Close call!", else "Nice."). +// attemptFlavor returns a one-word reaction to a winning attempt: +// 1 → "First try!", 2 → "Sharp!", final → "Phew — last one!", +// final-2 → "Close call!", else "Nice.". func attemptFlavor(attempt, max int) string { if attempt <= 1 { return "First try!" @@ -27,11 +27,11 @@ func attemptFlavor(attempt, max int) string { // < 60min → "3m 14s" (or "3m" when seconds == 0) // otherwise → "1h 12m" (or "1h" when remaining minutes == 0) // -// JS parity. Negative inputs clamp to 0 (matches JS's Math.max(0, …)). +// Negative inputs clamp to 0. func formatDuration(ms int64) string { total := ms / 1000 if ms%1000 >= 500 { - total++ // round-half-up to match JS Math.round + total++ // round-half-up } if total < 0 { total = 0 diff --git a/internal/modules/loldle/flavor_test.go b/internal/modules/loldle/flavor_test.go index efc0204..62aa7f5 100644 --- a/internal/modules/loldle/flavor_test.go +++ b/internal/modules/loldle/flavor_test.go @@ -13,7 +13,7 @@ func TestAttemptFlavor(t *testing.T) { 6: "Close call!", 7: "Close call!", 8: "Phew — last one!", - 9: "Phew — last one!", // attempt > max — defensive; matches JS >= + 9: "Phew — last one!", // attempt > max — defensive (`>=` branch) } for attempt, want := range cases { if got := attemptFlavor(attempt, maxAttempts); got != want { @@ -28,7 +28,7 @@ func TestFormatDuration(t *testing.T) { want string }{ {0, "0s"}, - {500, "1s"}, // round-half-up to JS Math.round + {500, "1s"}, // round-half-up {499, "0s"}, {42_000, "42s"}, {60_000, "1m"}, diff --git a/internal/modules/loldle/handlers.go b/internal/modules/loldle/handlers.go index 5481103..3e745d1 100644 --- a/internal/modules/loldle/handlers.go +++ b/internal/modules/loldle/handlers.go @@ -37,7 +37,8 @@ func (s *state) findByName(name string) *Champion { // rehydrateGuesses recomputes board rows from the stored championNames. // Champions removed from champions.json since the round started are skipped -// (returns the surviving prefix), matching JS. +// (returns the surviving prefix) so a data refresh never breaks an active +// round. func (s *state) rehydrateGuesses(g *gameState) []boardEntry { target := s.findByName(g.Target) if target == nil { diff --git a/internal/modules/loldle/lookup.go b/internal/modules/loldle/lookup.go index dce9298..e55846f 100644 --- a/internal/modules/loldle/lookup.go +++ b/internal/modules/loldle/lookup.go @@ -3,9 +3,8 @@ package loldle import "strings" // normalizeName folds a name to a comparable form: lowercase, alphanumeric -// only. JS-parity with util/normalize-name.js — `String(s).toLowerCase(). -// replace(/[^a-z0-9]/g, "")`. Used for case/space/punctuation-insensitive -// lookup so "Kai'Sa", "kaisa", and "KAI SA" all collapse to the same key. +// only. Used for case/space/punctuation-insensitive lookup so "Kai'Sa", +// "kaisa", and "KAI SA" all collapse to the same key. func normalizeName(s string) string { lower := strings.ToLower(s) out := make([]byte, 0, len(lower)) diff --git a/internal/modules/loldle/render.go b/internal/modules/loldle/render.go index d65c8be..8e7c0d5 100644 --- a/internal/modules/loldle/render.go +++ b/internal/modules/loldle/render.go @@ -63,8 +63,8 @@ func buildRows(championName string, results []AttributeRow) []guessRow { } // formatRowGroups joins one or more guess-row groups into a single
-// block. Label column width is the max label across ALL groups, so stacked
-// guesses on a board align with each other (JS parity).
+// block. Label column width is the max label across ALL groups so stacked
+// guesses on a board align with each other.
 func formatRowGroups(groups [][]guessRow) string {
 	width := 0
 	for _, g := range groups {
diff --git a/internal/modules/loldle/render_test.go b/internal/modules/loldle/render_test.go
index 541c6c2..a89fda9 100644
--- a/internal/modules/loldle/render_test.go
+++ b/internal/modules/loldle/render_test.go
@@ -7,8 +7,7 @@ import (
 
 // TestRenderGuess_AlignsLabelColumn locks the monospace column-alignment
 // invariant: the label column must be padded to the longest label so stacked
-// guesses on the board line up. Captures the JS render contract that the
-// rest of the test suite never exercises.
+// guesses on the board line up.
 func TestRenderGuess_AlignsLabelColumn(t *testing.T) {
 	rows := []AttributeRow{
 		{Key: "gender", Label: "Gender", Type: attrExact, GuessValue: "Male", Result: ResultCorrect},
diff --git a/internal/modules/loldle/state.go b/internal/modules/loldle/state.go
index c960ed2..936611c 100644
--- a/internal/modules/loldle/state.go
+++ b/internal/modules/loldle/state.go
@@ -15,11 +15,11 @@ const (
 	MaxGuessesCap = 10
 )
 
-// gameState is the per-subject KV record. Field tags match JS exactly so a
-// JS-written round decodes cleanly. StartedAt is *int64 because the JS
-// source initialises it to `null` (timer doesn't start until first guess) —
+// gameState is the per-subject KV record.
+//
+// StartedAt is *int64 because the timer doesn't start until the first guess;
 // using time.Time would marshal as "0001-01-01T00:00:00Z" instead of null
-// and break wire-format parity.
+// and lose that distinction.
 //
 // Guesses is just championNames; comparison rows are recomputed at render
 // time against current champions.json so a weekly data refresh updates
@@ -30,8 +30,9 @@ type gameState struct {
 	StartedAt *int64   `json:"startedAt"` // ms-since-epoch | null
 }
 
-// stats lifetime score. JS shape — note no LastResultAt field (differs from
-// wordle's stats; the JS loldle source omits it, parity dictates we do too).
+// stats lifetime score. No LastResultAt field by design (differs from
+// wordle's Stats — loldle only ever needed running streaks, not "last
+// played at").
 type stats struct {
 	Played     int `json:"played"`
 	Wins       int `json:"wins"`
@@ -80,8 +81,8 @@ func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
 	return nil
 }
 
-// loadStats returns lifetime score; missing → fresh-zero record (matches
-// the JS `?? {…}` fallback).
+// loadStats returns lifetime score; missing → fresh-zero record so callers
+// never need a nil check.
 func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*stats, error) {
 	var s stats
 	err := kv.GetJSON(ctx, statsKey(subject), &s)
@@ -118,7 +119,7 @@ func recordResult(ctx context.Context, kv storage.KVStore, subject string, won b
 
 // getMaxGuesses returns the effective round length: the per-subject override
 // if set and in range, otherwise MaxGuesses. Out-of-range values are
-// silently ignored (matches JS).
+// silently ignored — better to serve the default than 500 the user.
 func getMaxGuesses(ctx context.Context, kv storage.KVStore, subject string) (int, error) {
 	var cfg roundConfig
 	err := kv.GetJSON(ctx, configKey(subject), &cfg)
diff --git a/internal/modules/loldle/state_test.go b/internal/modules/loldle/state_test.go
index 793d47a..d933fdf 100644
--- a/internal/modules/loldle/state_test.go
+++ b/internal/modules/loldle/state_test.go
@@ -31,7 +31,9 @@ func TestGameState_StartedAtAsNumber(t *testing.T) {
 }
 
 func TestStats_NoLastResultAtField(t *testing.T) {
-	// JS loldle stats schema differs from wordle: no lastResultAt. Lock that.
+	// loldle's stats schema deliberately differs from wordle's — no
+	// lastResultAt field. Lock that, since adding the field would silently
+	// change the on-disk shape for every existing player.
 	b, _ := json.Marshal(stats{})
 	want := `{"played":0,"wins":0,"streak":0,"bestStreak":0}`
 	if string(b) != want {
diff --git a/internal/modules/lolschedule/api_client.go b/internal/modules/lolschedule/api_client.go
index 735df35..07d70b3 100644
--- a/internal/modules/lolschedule/api_client.go
+++ b/internal/modules/lolschedule/api_client.go
@@ -1,14 +1,13 @@
-// Package lolschedule ports the JS lolschedule module — LoL esports match
-// schedule via lolesports.com's persisted API.
+// Package lolschedule serves LoL esports match schedules via lolesports.com's
+// persisted API plus a daily push to subscribers.
 //
 // Endpoint: https://esports-api.lolesports.com/persisted/gw/getSchedule
-// Auth: x-api-key header (the public key embedded in lolesports.com's web
-// client — no registration). If Riot ever rotates it, lift the new value
+// Auth: x-api-key header — the public key embedded in lolesports.com's web
+// client (no registration). If Riot ever rotates it, lift the new value
 // from their public JS bundle.
 //
-// Cache strategy: KV-backed, 120s fresh window with 60-minute stale
-// fallback. Same shape as the JS source so cross-runtime KV migration
-// round-trips byte-for-byte.
+// Cache strategy: KV-backed cacheRecord with a 120s fresh window and a
+// 60-minute stale fallback (stale-while-error).
 package lolschedule
 
 import (
@@ -30,7 +29,7 @@ const (
 	apiURL = "https://esports-api.lolesports.com/persisted/gw/getSchedule"
 	// apiKey is the public lolesports.com web client key (not a secret).
 	// gosec flags it as a hardcoded credential; the value is shipped in
-	// Riot's own public JS bundle and serves the live site too.
+	// Riot's own public web bundle and serves the live site too.
 	// #nosec G101
 	apiKey    = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z"
 	userAgent = "miti99bot/0.1 (https://t.me/miti99bot)"
@@ -104,8 +103,7 @@ type schedulePage struct {
 	} `json:"data"`
 }
 
-// cacheRecord is the KV value: timestamp + events. Same shape as JS so KV
-// export/import migration round-trips.
+// cacheRecord is the KV value: fetch timestamp (ms-epoch) + events.
 type cacheRecord struct {
 	Ts     int64           `json:"ts"` // ms-since-epoch when fetched
 	Events []ScheduleEvent `json:"events"`
diff --git a/internal/modules/lolschedule/parse_date.go b/internal/modules/lolschedule/parse_date.go
index 509d81b..3fff097 100644
--- a/internal/modules/lolschedule/parse_date.go
+++ b/internal/modules/lolschedule/parse_date.go
@@ -53,7 +53,8 @@ func addDays(date time.Time, days int) time.Time {
 }
 
 // splitParts breaks the trimmed input into [dd, mm?, yyyy?] string parts.
-// Mirrors JS splitParts: dash- or slash-separated, or 1/2/4/8-digit unbroken.
+// Accepts dash- or slash-separated values, or a 1/2/4/8-digit unbroken
+// run (today, this-month, this-year, full ddmmyyyy).
 func splitParts(trimmed string) ([]string, string) {
 	if strings.ContainsAny(trimmed, "-/") {
 		// Replace both delimiters with a single one, then split.
diff --git a/internal/modules/misc/misc.go b/internal/modules/misc/misc.go
index f2d86e4..d3c01e7 100644
--- a/internal/modules/misc/misc.go
+++ b/internal/modules/misc/misc.go
@@ -31,9 +31,9 @@ const defaultTarget = "VNG"
 // %s slots: target (escaped), sender mention, sender mention.
 const trongTruongHopTemplate = "Trong trường hợp nhóm này bị điều tra bởi %s, %s khẳng định không liên quan tới nhóm hoặc những cá nhân khác trong nhóm này. %s không rõ tại sao lại có mặt ở đây vào thời điểm này, có lẽ tài khoản đã được thêm bởi một bên thứ ba."
 
-// lastPing mirrors the JS bot's wire format: { at:  }.
-// Stored as int64 ms-epoch (not time.Time → RFC3339) so a future cross-runtime
-// KV export/import migration round-trips byte-for-byte.
+// lastPing is the value stored at the `last_ping` key: { at:  }.
+// int64 ms-epoch (not time.Time → RFC3339) keeps the on-disk shape compact
+// and consistent with every other timestamp field in the bot's KV.
 type lastPing struct {
 	At int64 `json:"at"`
 }
diff --git a/internal/modules/misc/misc_test.go b/internal/modules/misc/misc_test.go
index c7b001b..64b0707 100644
--- a/internal/modules/misc/misc_test.go
+++ b/internal/modules/misc/misc_test.go
@@ -45,9 +45,8 @@ func TestPing_WritesLastPingKV(t *testing.T) {
 	ctx := context.Background()
 	kv := storage.NewMemoryKVStore()
 
-	// Drive the KV side directly: lock the wire format (ms-epoch number, not
-	// RFC3339 string). A JS-written {at: 1700000000000} must round-trip into
-	// the Go struct without a custom decoder.
+	// Drive the KV side directly: lock the wire format as a ms-epoch number
+	// (not an RFC3339 string), matching every other timestamp in the bot's KV.
 	if err := kv.PutJSON(ctx, lastPingKey, lastPing{At: time.Now().UTC().UnixMilli()}); err != nil {
 		t.Fatalf("PutJSON: %v", err)
 	}
@@ -60,16 +59,16 @@ func TestPing_WritesLastPingKV(t *testing.T) {
 		t.Errorf("read-back lastPing.At = %d, want positive ms-epoch", got.At)
 	}
 
-	// Also verify a value with the JS-shape decodes correctly.
+	// Verify a hand-written {"at": } document decodes correctly.
 	if err := kv.Put(ctx, lastPingKey, []byte(`{"at":1700000000000}`)); err != nil {
 		t.Fatal(err)
 	}
 	got = lastPing{}
 	if err := kv.GetJSON(ctx, lastPingKey, &got); err != nil {
-		t.Fatalf("GetJSON js-shape: %v", err)
+		t.Fatalf("GetJSON ms-epoch shape: %v", err)
 	}
 	if got.At != 1700000000000 {
-		t.Errorf("js-shape round-trip: At = %d, want 1700000000000", got.At)
+		t.Errorf("ms-epoch round-trip: At = %d, want 1700000000000", got.At)
 	}
 }
 
diff --git a/internal/modules/trading/format.go b/internal/modules/trading/format.go
index 718b8ac..bf3e0c3 100644
--- a/internal/modules/trading/format.go
+++ b/internal/modules/trading/format.go
@@ -1,7 +1,7 @@
 // Package trading is a paper-trading module for VN stocks. Per-user
-// portfolio + buy/sell at market price + stats with P&L. Ported from the
-// JS trading module in miti99bot; SQL-based history + retention cron are
-// out of scope for this v1 port.
+// portfolio + buy/sell at market price + stats with P&L. SQL-based trade
+// history and a retention cron are out of scope today; the current
+// implementation keeps only the live portfolio in KV.
 package trading
 
 import (
@@ -37,7 +37,7 @@ func FormatStock(n float64) string {
 
 // FormatPnL renders a signed VND delta + percentage line, e.g.
 // "+1.234 VND (+12.34%)" or "-500.000 VND (-5.00%)". When invested is zero
-// the percentage is reported as 0.00 to avoid division-by-zero — matches JS.
+// the percentage is reported as 0.00 to avoid division-by-zero.
 func FormatPnL(currentValue, invested float64) string {
 	diff := currentValue - invested
 	pct := 0.0
diff --git a/internal/modules/trading/portfolio.go b/internal/modules/trading/portfolio.go
index 70b8476..6abab49 100644
--- a/internal/modules/trading/portfolio.go
+++ b/internal/modules/trading/portfolio.go
@@ -19,7 +19,7 @@ type Portfolio struct {
 }
 
 // PortfolioMeta tracks invested cost basis for P&L. CreatedAt is purely
-// informational; carried for parity with JS schema.
+// informational (ms-epoch when the portfolio first existed).
 type PortfolioMeta struct {
 	Invested  float64 `json:"invested"`
 	CreatedAt int64   `json:"createdAt"`
diff --git a/internal/modules/twentyq/handlers.go b/internal/modules/twentyq/handlers.go
index d1ebbcb..6fb857f 100644
--- a/internal/modules/twentyq/handlers.go
+++ b/internal/modules/twentyq/handlers.go
@@ -42,7 +42,8 @@ func (s *state) randomSeed() string {
 	return seeds[s.rng.IntN(len(seeds))]
 }
 
-// fallbackRoundStart matches JS roundstart fallback when the model fails.
+// fallbackRoundStart returns a generic category + hint pair the round can
+// start with when the LLM call fails or returns unparseable output.
 func fallbackRoundStart() (string, string) {
 	return "object", "it is something you might encounter in everyday life"
 }
@@ -91,7 +92,8 @@ func (s *state) handleTwentyq(ctx context.Context, b *bot.Bot, update *models.Up
 	if err != nil {
 		return err
 	}
-	// Solved-but-lingering rounds → start fresh transparently (JS-parity).
+	// Solved-but-lingering rounds → start fresh transparently rather than
+	// reject the user with "round already solved".
 	if game != nil && game.Solved {
 		// Best-effort delete: a hard failure here means the solved-state
 		// branch re-enters every call and the user is stuck. Log but keep
diff --git a/internal/modules/twentyq/parser.go b/internal/modules/twentyq/parser.go
index fba2849..07fbdb5 100644
--- a/internal/modules/twentyq/parser.go
+++ b/internal/modules/twentyq/parser.go
@@ -6,8 +6,8 @@ import (
 	"strings"
 )
 
-// Judgement is the canonical shape after parse + normalize. JS-parity field
-// names — IsGuess for "is_guess", Answer ∈ {"yes","no"}.
+// Judgement is the canonical shape after parse + normalize.
+// Answer ∈ {"yes","no"}.
 type Judgement struct {
 	IsGuess bool   `json:"is_guess"`
 	Answer  string `json:"answer"`
@@ -26,7 +26,8 @@ const defaultHint = "I couldn't fully parse that — try a clear yes/no question
 var fenceRe = regexp.MustCompile("(?i)```(?:json)?")
 
 // parseJSON returns the first balanced {...} JSON object found in `text`.
-// Returns nil on parse failure — matches JS parseJudgementJson tolerance.
+// Returns nil on parse failure — tolerant by design since LLM output is
+// untrusted and we'd rather fall back to defaults than 500.
 func parseJSON(text string) map[string]any {
 	if text == "" {
 		return nil
@@ -73,7 +74,7 @@ func parseJSON(text string) map[string]any {
 }
 
 // normalizeJudgement coerces a parsed payload to the canonical Judgement
-// shape with safe defaults. JS-parity behaviour.
+// shape with safe defaults (answer="no", hint=defaultHint, is_guess=false).
 func normalizeJudgement(payload map[string]any) Judgement {
 	out := Judgement{Answer: "no", Hint: defaultHint}
 	if payload == nil {
@@ -110,8 +111,8 @@ func redactSecret(hint, target string) string {
 	return re.ReplaceAllString(hint, "(redacted)")
 }
 
-// parseRoundStart returns (category, initialHint) or zero values + nil on
-// any failure. Caller substitutes JS-parity fallbacks.
+// parseRoundStart returns (category, initialHint, ok). ok=false on any
+// failure so the caller can substitute fallbacks rather than serve garbage.
 func parseRoundStart(payload map[string]any) (string, string, bool) {
 	if payload == nil {
 		return "", "", false
diff --git a/internal/modules/twentyq/prompts.go b/internal/modules/twentyq/prompts.go
index 641b171..3789c20 100644
--- a/internal/modules/twentyq/prompts.go
+++ b/internal/modules/twentyq/prompts.go
@@ -7,8 +7,8 @@ import (
 
 const historyWindow = 5
 
-// buildSystemPrompt: per-turn judge prompt. JS-parity verbatim. The LLM is
-// instructed to emit one-line JSON; parser.go does the parse.
+// buildSystemPrompt: per-turn judge prompt. The LLM is instructed to emit
+// one-line JSON; parser.go does the parse.
 func buildSystemPrompt(g GameState) string {
 	recent := g.Turns
 	if len(recent) > historyWindow {
diff --git a/internal/modules/twentyq/state.go b/internal/modules/twentyq/state.go
index 294d5c9..f657cc4 100644
--- a/internal/modules/twentyq/state.go
+++ b/internal/modules/twentyq/state.go
@@ -8,7 +8,7 @@ import (
 	"github.com/tiennm99/miti99bot/internal/storage"
 )
 
-// Turn is one Q&A entry. JS-parity field names.
+// Turn is one Q&A entry stored in the game's history.
 type Turn struct {
 	Text    string `json:"text"`
 	IsGuess bool   `json:"isGuess"`
diff --git a/internal/modules/twentyq/validate.go b/internal/modules/twentyq/validate.go
index f36b0f7..a62cb9f 100644
--- a/internal/modules/twentyq/validate.go
+++ b/internal/modules/twentyq/validate.go
@@ -11,7 +11,8 @@ const (
 )
 
 // openEndedRe rejects open-ended questions before spending a Gemini call.
-// JS-parity prefix list.
+// The prefix list is intentionally narrow (canonical interrogatives only)
+// to avoid blocking borderline yes/no phrasings.
 var openEndedRe = regexp.MustCompile(`(?i)^\s*(what|how|why|which|who|where|when|tell me|describe|explain)\b`)
 
 // ValidateResult is the public outcome of validateQuestion. Either OK + the
diff --git a/internal/modules/util/chathelper/chathelper.go b/internal/modules/util/chathelper/chathelper.go
index 0e587d5..f9fc4bf 100644
--- a/internal/modules/util/chathelper/chathelper.go
+++ b/internal/modules/util/chathelper/chathelper.go
@@ -15,14 +15,11 @@ import (
 	"github.com/go-telegram/bot/models"
 )
 
-// SubjectFor mirrors JS getSubject: group/supergroup → chat ID (shared game
-// state), otherwise → user ID. Returns "" when no usable id is present
-// (caller should reply with a "cannot identify chat" error). Channels and
-// unknown chat types fall through to From.ID.
-//
-// Canonical shape: the wordle module previously had an explicit
-// ChatTypePrivate branch returning From.ID, which is identical to the
-// default branch — folded together here.
+// SubjectFor returns the identity key per-module state should be scoped by:
+// group/supergroup → chat ID (shared game state), otherwise → user ID.
+// Returns "" when no usable id is present (caller should reply with a
+// "cannot identify chat" error). Channels and unknown chat types fall
+// through to From.ID.
 func SubjectFor(msg *models.Message) string {
 	if msg == nil {
 		return ""
@@ -39,7 +36,7 @@ func SubjectFor(msg *models.Message) string {
 }
 
 // ArgAfterCommand returns everything after the first space in text, trimmed.
-// Works for `/cmd arg`, `/cmd@bot arg`, etc. JS-parity.
+// Works for `/cmd arg`, `/cmd@bot arg`, etc.
 func ArgAfterCommand(text string) string {
 	if text == "" {
 		return ""
@@ -88,9 +85,9 @@ func ReplyHTML(ctx context.Context, b *bot.Bot, msg *models.Message, text string
 }
 
 // WinRate computes wins/played as a percentage rounded to nearest int.
-// math.Round matches JS Math.round (round half away from zero for positive
-// inputs); plain int(...) truncation would render 2/3 as 66% where JS shows
-// 67%. Returns 0 when played == 0 (avoids NaN).
+// Uses math.Round (round half away from zero for positive inputs) so 2/3
+// renders as 67%, not 66% as plain int(...) truncation would give.
+// Returns 0 when played == 0 (avoids NaN).
 func WinRate(wins, played int) int {
 	if played <= 0 {
 		return 0
diff --git a/internal/modules/util/info.go b/internal/modules/util/info.go
index 6c5b3b5..07fc2d9 100644
--- a/internal/modules/util/info.go
+++ b/internal/modules/util/info.go
@@ -32,7 +32,7 @@ func infoCommand() modules.Command {
 			}
 			chatID := fmt.Sprintf("%d", msg.Chat.ID)
 			// Telegram omits message_thread_id outside forum topics, so a 0
-			// here is "no thread", same as JS's `?? "n/a"`.
+			// here means "no thread" — render as "n/a" instead of "0".
 			threadID := "n/a"
 			if msg.MessageThreadID != 0 {
 				threadID = fmt.Sprintf("%d", msg.MessageThreadID)
diff --git a/internal/modules/util/stickerid.go b/internal/modules/util/stickerid.go
index c60b0ae..0e457b4 100644
--- a/internal/modules/util/stickerid.go
+++ b/internal/modules/util/stickerid.go
@@ -57,8 +57,7 @@ func stickerIDCommand() modules.Command {
 	}
 }
 
-// stickerFrom pulls the sticker out of the *replied-to* message, mirroring the
-// JS handler: ctx.message.reply_to_message.sticker.
+// stickerFrom pulls the sticker out of the *replied-to* message.
 func stickerFrom(msg *models.Message) *models.Sticker {
 	if msg.ReplyToMessage == nil {
 		return nil
diff --git a/internal/modules/wordle/compare.go b/internal/modules/wordle/compare.go
index 07147cc..07e64f9 100644
--- a/internal/modules/wordle/compare.go
+++ b/internal/modules/wordle/compare.go
@@ -1,21 +1,20 @@
-// Package wordle ports the JS wordle module — classic 5-letter word-guess
-// game, scored letter-by-letter green/yellow/grey.
+// Package wordle implements the classic 5-letter word-guess game, scored
+// letter-by-letter green/yellow/grey.
 package wordle
 
 // WordLength is wordle's fixed 5. Exposed so render.go and tests can reuse it
 // without magic numbers.
 const WordLength = 5
 
-// LetterResult labels a single guessed letter's state. Values match the JS
-// wire format byte-for-byte: "correct" | "partial" | "wrong".
+// LetterResult labels a single guessed letter's state. Values are part of
+// the stored game's JSON shape: "correct" | "partial" | "wrong".
 const (
 	ResultCorrect = "correct"
 	ResultPartial = "partial"
 	ResultWrong   = "wrong"
 )
 
-// LetterScore is the JSON shape stored in KV per guess. Field tags match JS
-// exactly so a saved JS game round-trips through Go without a custom decoder.
+// LetterScore is the JSON shape stored in KV per guess.
 type LetterScore struct {
 	Letter string `json:"letter"`
 	Result string `json:"result"`
@@ -65,7 +64,7 @@ func CompareWords(guess, target string) []LetterScore {
 
 // indexOfByte returns the first index of c in s, or -1.
 // (bytes.IndexByte gives the same answer; inlined to keep this file
-// dependency-free and emphasize the JS-parity origin.)
+// dependency-free since the scoring algorithm is the whole point.)
 func indexOfByte(s []byte, c byte) int {
 	for i, b := range s {
 		if b == c {
diff --git a/internal/modules/wordle/handlers.go b/internal/modules/wordle/handlers.go
index ffbe27d..2dfffce 100644
--- a/internal/modules/wordle/handlers.go
+++ b/internal/modules/wordle/handlers.go
@@ -22,8 +22,7 @@ type state struct {
 	locks keylock.Map // per-subject mutex; serialises Get→mutate→Put
 }
 
-// rejectMessage maps a validation failure into the user-facing reply. JS
-// parity word-for-word.
+// rejectMessage maps a validation failure into the user-facing reply.
 func rejectMessage(reason rejectReason) string {
 	switch reason {
 	case reasonEmpty:
diff --git a/internal/modules/wordle/lookup.go b/internal/modules/wordle/lookup.go
index a3b0f1c..7decc47 100644
--- a/internal/modules/wordle/lookup.go
+++ b/internal/modules/wordle/lookup.go
@@ -2,8 +2,7 @@ package wordle
 
 import "strings"
 
-// normalizeWord lowercases input and strips anything outside a-z. JS parity:
-// `String(input).toLowerCase().replace(/[^a-z]/g, "")`.
+// normalizeWord lowercases input and strips anything outside a-z.
 func normalizeWord(input string) string {
 	lower := strings.ToLower(input)
 	out := make([]byte, 0, len(lower))
@@ -16,9 +15,9 @@ func normalizeWord(input string) string {
 	return string(out)
 }
 
-// rejectReason classifies why validateGuess returned not-ok. Values match the
-// JS source's discriminated-union strings so the user-facing reply mapping
-// (handlers.rejectReason) stays parallel.
+// rejectReason classifies why validateGuess returned not-ok. The user-facing
+// reply mapping in handlers branches on these values, so renaming a constant
+// here requires updating that mapping.
 type rejectReason string
 
 const (
@@ -27,9 +26,9 @@ const (
 	reasonUnknown rejectReason = "unknown"
 )
 
-// guessResult mirrors JS's `{ok: true, word} | {ok: false, reason, word}`.
-// Word is always populated (the normalized form), even on failure, so callers
-// can include it in error messages if desired.
+// guessResult is the validateGuess outcome. Word is always populated with the
+// normalized form, even on failure, so callers can include it in error
+// messages without re-normalizing.
 type guessResult struct {
 	OK     bool
 	Word   string
diff --git a/internal/modules/wordle/state.go b/internal/modules/wordle/state.go
index 17629ab..80fbb7c 100644
--- a/internal/modules/wordle/state.go
+++ b/internal/modules/wordle/state.go
@@ -11,11 +11,11 @@ import (
 // MaxGuesses is the standard wordle round length.
 const MaxGuesses = 6
 
-// Cloud Firestore has no native per-document TTL equivalent to Cloudflare KV
-// — saved games linger until manually cleaned. Out of scope today; tracked
-// in port plan as a future cron.
+// Note: the KV store has no native per-document TTL — saved games linger
+// until manually cleaned. Out of scope today; could be added via a sweep
+// cron if storage cost ever matters.
 
-// GuessRecord is one entry in a game's history. JSON shape locks JS parity:
+// GuessRecord is one entry in a game's history:
 //
 //	{ "word": "crane", "results": [{"letter":"c","result":"correct"}, ...] }
 type GuessRecord struct {
@@ -24,11 +24,12 @@ type GuessRecord struct {
 }
 
 // GameState is the per-subject KV record for an in-progress (or finished)
-// round. Field tags match JS exactly so a JS-written round decodes cleanly.
+// round.
 //
 // `giveup` is always emitted (initialized to false on /wordle_new). Do NOT
-// add omitempty — the JS source serializes the field unconditionally and
-// cross-runtime migration depends on shape parity.
+// add omitempty — the field is part of the stored document's shape, so
+// emitting it unconditionally keeps already-saved games self-describing
+// when inspected via raw KV dumps.
 type GameState struct {
 	Target    string        `json:"target"`
 	Guesses   []GuessRecord `json:"guesses"`
@@ -38,7 +39,8 @@ type GameState struct {
 }
 
 // Stats is the lifetime score record. lastResultAt is *int64 so an unplayed
-// account marshals as `"lastResultAt": null` matching JS's initial shape.
+// account marshals as `"lastResultAt": null` — distinguishes "never played"
+// from "played at epoch zero".
 type Stats struct {
 	Played       int    `json:"played"`
 	Wins         int    `json:"wins"`
@@ -73,7 +75,7 @@ func saveGame(ctx context.Context, kv storage.KVStore, subject string, g *GameSt
 }
 
 // loadStats returns lifetime stats; missing → fresh-zero record (with
-// LastResultAt=nil), matching the JS `?? {…}` fallback.
+// LastResultAt=nil) so callers never need a nil check.
 func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*Stats, error) {
 	var s Stats
 	err := kv.GetJSON(ctx, statsKey(subject), &s)
diff --git a/internal/modules/wordle/state_test.go b/internal/modules/wordle/state_test.go
index d832ffd..4701be0 100644
--- a/internal/modules/wordle/state_test.go
+++ b/internal/modules/wordle/state_test.go
@@ -9,8 +9,9 @@ import (
 )
 
 func TestStats_DefaultLastResultAtIsNull(t *testing.T) {
-	// JS shape: `{ ..., lastResultAt: null }` — Go's *int64 must marshal
-	// as null when nil to keep cross-runtime KV documents compatible.
+	// Go's *int64 must marshal as null when nil, so unplayed accounts emit
+	// `"lastResultAt": null` and the field stays distinguishable from
+	// "played at ms-epoch 0".
 	s := Stats{}
 	b, err := json.Marshal(s)
 	if err != nil {
@@ -33,7 +34,7 @@ func TestStats_WithResultMarshalsAsNumber(t *testing.T) {
 	}
 }
 
-func TestGameState_JSONShapeMatchesJS(t *testing.T) {
+func TestGameState_JSONShapeIsStable(t *testing.T) {
 	g := GameState{
 		Target: "crane",
 		Guesses: []GuessRecord{
diff --git a/internal/modules/wordle/words.go b/internal/modules/wordle/words.go
index 0ff4076..307bddb 100644
--- a/internal/modules/wordle/words.go
+++ b/internal/modules/wordle/words.go
@@ -5,9 +5,8 @@ import (
 	"strings"
 )
 
-// rawWords holds the raw words.txt bytes embedded at compile time. The file
-// was extracted byte-for-byte from the JS source's words-data.js so the Go
-// and JS bots have identical dictionaries.
+// rawWords holds the raw words.txt bytes embedded at compile time. One word
+// per line, lowercase, exactly WordLength a-z; see loadWords for validation.
 //
 //go:embed data/words.txt
 var rawWords string
diff --git a/internal/server/log_middleware.go b/internal/server/log_middleware.go
index cc3216f..153fe59 100644
--- a/internal/server/log_middleware.go
+++ b/internal/server/log_middleware.go
@@ -36,7 +36,7 @@ func (r *statusRecorder) effectiveStatus() int {
 //	{"msg":"req","method":"POST","path":"/webhook","status":200,"ms":12}
 //
 // CloudWatch Logs filters on `jsonPayload.msg=req AND jsonPayload.status>=500`
-// for 5xx-rate alerting. Mirrors the JS source's index.js shape.
+// for 5xx-rate alerting — keep the field names stable or the alarm goes dark.
 //
 // The req line is emitted from a deferred closure so a panic in a downstream
 // handler still produces an observable log entry — without this, a cron