mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-07-25 04:20:35 +00:00
refactor(storage): store mongo value as JSON string, not BinData
Persist the KV value field as a BSON string so it is directly readable in the Atlas/Compass UI, matching DynamoDB's String storage. Reads still accept BSON binary as a backward-compat fallback. Non-UTF-8 callers must encode upstream, same constraint as DynamoDB; all current callers write JSON.
This commit is contained in:
@@ -36,21 +36,23 @@ func NewMongoKVStore(coll *mongo.Collection, moduleName string) *MongoKVStore {
|
||||
}
|
||||
|
||||
// decodeValue extracts the stored value bytes from a decoded document. The
|
||||
// driver decodes a BSON binary into bson.Binary and a BSON string into string;
|
||||
// accept both so values written by any path (or a future schema) round-trip,
|
||||
// mirroring FirestoreKVStore's dual-type handling.
|
||||
// driver decodes a BSON string into string and a BSON binary into bson.Binary;
|
||||
// accept both so values written by any path round-trip. String is the current
|
||||
// encoding (human-readable in the Atlas UI); the binary case is retained for
|
||||
// backward compatibility with any documents written by an earlier build that
|
||||
// stored value as BSON binary.
|
||||
func (s *MongoKVStore) decodeValue(key string, doc bson.M) ([]byte, error) {
|
||||
raw, ok := doc[mongoValueField]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("mongo get %s/%s: missing %q field", s.moduleName, key, mongoValueField)
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
return []byte(v), nil
|
||||
case bson.Binary:
|
||||
return v.Data, nil
|
||||
case []byte:
|
||||
return v, nil
|
||||
case string:
|
||||
return []byte(v), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("mongo get %s/%s: unexpected value type %T", s.moduleName, key, raw)
|
||||
}
|
||||
@@ -85,13 +87,15 @@ func (s *MongoKVStore) GetJSON(ctx context.Context, key string, dst any) error {
|
||||
}
|
||||
|
||||
// doc builds the persisted document for key/val with a fresh updatedAt stamp.
|
||||
// value is stored as BSON binary (generic subtype) so non-UTF-8 payloads
|
||||
// round-trip; updatedAt is int64 unix-nanos to match DynamoDB byte-for-byte
|
||||
// (see dynamodb_kv.go) and keep migration faithful.
|
||||
// value is stored as a BSON string so it is human-readable in the Atlas/Compass
|
||||
// UI (mirrors DynamoDB's String storage, dynamodb_kv.go). Every current caller
|
||||
// writes JSON, which is UTF-8 safe; non-UTF-8 callers must encode upstream
|
||||
// (e.g. base64), same constraint as DynamoDB. updatedAt is int64 unix-nanos to
|
||||
// match DynamoDB and keep migration faithful.
|
||||
func (s *MongoKVStore) doc(key string, val []byte) bson.M {
|
||||
return bson.M{
|
||||
mongoIDField: key,
|
||||
mongoValueField: bson.Binary{Subtype: bson.TypeBinaryGeneric, Data: val},
|
||||
mongoValueField: string(val),
|
||||
mongoUpdatedAtField: time.Now().UTC().UnixNano(),
|
||||
}
|
||||
}
|
||||
@@ -147,10 +151,10 @@ func (s *MongoKVStore) CompareAndSwap(ctx context.Context, key string, expected
|
||||
res, err := s.coll.UpdateOne(ctx,
|
||||
bson.M{
|
||||
mongoIDField: key,
|
||||
mongoValueField: bson.Binary{Subtype: bson.TypeBinaryGeneric, Data: expected},
|
||||
mongoValueField: string(expected),
|
||||
},
|
||||
bson.M{"$set": bson.M{
|
||||
mongoValueField: bson.Binary{Subtype: bson.TypeBinaryGeneric, Data: val},
|
||||
mongoValueField: string(val),
|
||||
mongoUpdatedAtField: time.Now().UTC().UnixNano(),
|
||||
}},
|
||||
)
|
||||
|
||||
@@ -89,21 +89,34 @@ func TestMongoKVStore_GetMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMongoKVStore_NonUTF8RoundTrip(t *testing.T) {
|
||||
s, _, cleanup := mongoLocalSetup(t, "wordle")
|
||||
// TestMongoKVStore_ValueStoredAsViewableString verifies value is persisted as a
|
||||
// BSON string (directly readable in the Atlas/Compass UI), not opaque BinData,
|
||||
// and that UTF-8 JSON round-trips byte-identically.
|
||||
func TestMongoKVStore_ValueStoredAsViewableString(t *testing.T) {
|
||||
s, db, cleanup := mongoLocalSetup(t, "wordle")
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
raw := []byte{0x00, 0xff, 0xfe, 0x01, 0x80}
|
||||
if err := s.Put(ctx, "bin", raw); err != nil {
|
||||
payload := []byte(`{"word":"chào","score":42}`) // multi-byte UTF-8
|
||||
if err := s.Put(ctx, "u1", payload); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
got, err := s.Get(ctx, "bin")
|
||||
got, err := s.Get(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if string(got) != string(raw) {
|
||||
t.Errorf("non-UTF-8 round trip: got %v, want %v", got, raw)
|
||||
if string(got) != string(payload) {
|
||||
t.Errorf("round trip: got %q, want %q", got, payload)
|
||||
}
|
||||
|
||||
// Inspect the raw document: the value field must decode as a Go string
|
||||
// (BSON string type), proving it is viewable directly rather than BinData.
|
||||
var doc bson.M
|
||||
if err := db.Collection("wordle").FindOne(ctx, bson.M{"_id": "u1"}).Decode(&doc); err != nil {
|
||||
t.Fatalf("raw FindOne: %v", err)
|
||||
}
|
||||
if _, ok := doc["value"].(string); !ok {
|
||||
t.Errorf("value stored as %T, want string (directly viewable)", doc["value"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ Reuse the shared helpers already in `internal/storage`:
|
||||
- `collectionNameRe` (in `firestore_provider.go`) — module-name alphabet.
|
||||
- `invalidStore` (in `invalid_store.go`) — returned for bad module names.
|
||||
|
||||
Document shape (parity with firestore `value`/`updatedAt`):
|
||||
Document shape:
|
||||
```
|
||||
{ "_id": "<key>", "value": <BinData>, "updatedAt": <int64 nanos> }
|
||||
{ "_id": "<key>", "value": "<JSON string>", "updatedAt": <int64 nanos> }
|
||||
```
|
||||
Store `value` as BSON binary (`bson.Binary`) so non-UTF-8 round-trips; on read accept both binary and string (firestore does the same dual-type handling). **Store `updatedAt` as int64 unix-nanos (NOT BSON datetime)** — matches DynamoDB exactly (`dynamodb_kv.go:101`), keeps migration byte-faithful, and avoids ms-truncation if a future TTL/sort ever reads it. The migrator (Phase 4) and the provider MUST share one encoding — see Phase 4 (migrator writes through `MongoKVStore.Put`, not raw `UpdateOne`).
|
||||
Store `value` as a **BSON string** (decided 2026-06-28) so it is directly readable in the Atlas/Compass UI — mirroring DynamoDB's String storage (`dynamodb_kv.go:88`). Every caller writes JSON (UTF-8 safe); non-UTF-8 callers must encode upstream (e.g. base64), the same constraint DynamoDB carries. On read, accept both string and binary (the binary case is a backward-compat fallback for any docs written by the original binary build). **Store `updatedAt` as int64 unix-nanos (NOT BSON datetime)** — matches DynamoDB exactly (`dynamodb_kv.go:101`), keeps migration faithful, and avoids ms-truncation if a future TTL/sort ever reads it. The migrator (Phase 4) and the provider MUST share one encoding — see Phase 4 (migrator writes through `MongoKVStore.Put`, not raw `UpdateOne`).
|
||||
|
||||
`CompareAndSwap` mapping — the `expected == nil` branch is a LIVE path (first write of every new coin/gold portfolio, `coin/portfolio.go:81-83`, `gold/portfolio.go:62-80`), not an edge case. Map it to a plain **`InsertOne`** and rely SOLELY on the unique `_id` index for the conflict:
|
||||
- `expected == nil` → `InsertOne({_id, value, updatedAt})`; `mongo.IsDuplicateKeyError(err)` → `ErrConflict`. Do NOT use a `{value:{$exists:false}}` upsert filter (it can false-conflict on a value-less doc and muddies the contract).
|
||||
|
||||
Reference in New Issue
Block a user