mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-06 16:20:00 +00:00
chore: drop CF→AWS migration tooling and stale JS-port references
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
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 <inventory|kv-import|trading-audit-dump|convert-value-to-string> [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
|
||||
}
|
||||
@@ -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:<subject>`
|
||||
- `loldle` + `config:<subject>`
|
||||
- `lolschedule` + `subscribers`
|
||||
- `misc` + `last_ping`
|
||||
- `trading` + `user:<telegram_id>`
|
||||
|
||||
## 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:<subject>` | 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:<subject>` | durable player stats |
|
||||
| `loldle:config:*` | 1 | `internal/modules/loldle/state.go` | migrate | `pk=loldle`, `sk=config:<subject>` | 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:<subject>` | 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:<telegram_id>` | 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:<telegram_id>`. 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:<telegram_id>`. Not D1.
|
||||
- **Mapping rule** = identity copy. Read KV value, write to DynamoDB at `(pk=trading, sk=user:<telegram_id>)` 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 <database> --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 <database> --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 <database> --remote \
|
||||
--command "PRAGMA table_info(<table_name>)" \
|
||||
--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:<telegram_id>` 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`).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "<module>:" stripped.
|
||||
// Live runtime stores e.g. wordle/stats:<subject>, not wordle/wordle:stats:<subject>.
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 guess<target, "down" if guess>target).
|
||||
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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -63,8 +63,8 @@ func buildRows(championName string, results []AttributeRow) []guessRow {
|
||||
}
|
||||
|
||||
// formatRowGroups joins one or more guess-row groups into a single <pre>
|
||||
// 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 {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: <ms-since-epoch number> }.
|
||||
// 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: <ms-since-epoch> }.
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -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": <ms-epoch>} 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user