diff --git a/internal/storage/mongodb_kv.go b/internal/storage/mongodb_kv.go index 653495d..df1e592 100644 --- a/internal/storage/mongodb_kv.go +++ b/internal/storage/mongodb_kv.go @@ -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(), }}, ) diff --git a/internal/storage/mongodb_kv_test.go b/internal/storage/mongodb_kv_test.go index ab7e177..b24803d 100644 --- a/internal/storage/mongodb_kv_test.go +++ b/internal/storage/mongodb_kv_test.go @@ -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"]) } } diff --git a/plans/260627-1849-selfhost-coolify-mongodb/phase-01-mongodb-storage-provider.md b/plans/260627-1849-selfhost-coolify-mongodb/phase-01-mongodb-storage-provider.md index 0317887..1d4db5e 100644 --- a/plans/260627-1849-selfhost-coolify-mongodb/phase-01-mongodb-storage-provider.md +++ b/plans/260627-1849-selfhost-coolify-mongodb/phase-01-mongodb-storage-provider.md @@ -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": "", "value": , "updatedAt": } +{ "_id": "", "value": "", "updatedAt": } ``` -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).