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:
2026-06-28 10:50:41 +07:00
parent 24a51f1e86
commit 2090bfc911
3 changed files with 38 additions and 21 deletions
+15 -11
View File
@@ -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(),
}},
)
+20 -7
View File
@@ -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).