diff --git a/key-type-flow.md b/key-type-flow.md
new file mode 100644
index 00000000..4afaac08
--- /dev/null
+++ b/key-type-flow.md
@@ -0,0 +1,250 @@
+# FDv2 Key Type Flow
+
+## Data Pipeline
+
+```mermaid
+graph TD
+ subgraph "Streaming Protocol Layer"
+ A["SSE Stream\n(raw JSON)"] -->|"JSON.parse(symbolize_names: true)"| B["Parsed Hash\nhash keys = SYMBOL\nhash values = STRING\n{key: 'my-flag', version: 1}"]
+ B -->|"PutObject.from_h\nkey.to_sym"| C["PutObject / DeleteObject\n@key = SYMBOL\n:my_flag"]
+ end
+
+ subgraph "ChangeSet Layer"
+ C -->|"add_put / add_delete"| D["Change\n@key = SYMBOL\n:my_flag"]
+ D --> E["ChangeSet\nchanges = [Change...]"]
+ end
+
+ subgraph "Store Layer"
+ E -->|"store.apply(change_set)"| F["changes_to_store_data\nHash keys = SYMBOL\nDELETE value key = .to_s ✅\n{FEATURES => {:my_flag => obj}}"]
+
+ F -->|"memory_store.set_basis /\napply_delta"| G["InMemoryFeatureStoreV2\n@items keys = SYMBOL\n:my_flag"]
+
+ F -->|"decode_collection →\nModel.deserialize"| H["FeatureFlag\n@key = STRING\n'my-flag'\n(extracted from data[:key])"]
+ end
+
+ subgraph "Dependency Tracker"
+ F -->|"key.to_s ✅"| I["DependencyTracker\nfrom_what = {kind:, key: STRING}\n'my-flag'"]
+ H -->|"prereq.key / clause.values"| J["Dependencies\n{kind:, key: STRING}\n'prereq-flag'"]
+ I --- J
+ I -->|"add_affected_items\nlookup @to hash"| K["affected_items\n{kind:, key: STRING}\n'my-flag', 'prereq-flag'"]
+ end
+
+ subgraph "Flag Change Notification"
+ K -->|"FlagChange.new(item[:key])"| L["FlagChange\n@key = STRING ✅\n'my-flag'"]
+ L -->|"broadcaster"| M["FlagValueChangeAdapter\n.to_s comparison ✅\n.to_s on eval_fn call ✅\n.to_s on emitted key ✅"]
+ end
+
+ subgraph "Client API (reads)"
+ N["LDClient.variation('my-flag', ...)\nkey = STRING"] -->|"store.get(FEATURES, key)"| O["InMemoryFeatureStoreV2.get\nkey.to_sym for lookup"]
+ O --> G
+ end
+
+ style I fill:#90EE90
+ style K fill:#90EE90
+ style J fill:#90EE90
+ style L fill:#90EE90
+ style M fill:#90EE90
+ style F fill:#90EE90
+```
+
+## Sequence Diagrams
+
+These show the key type at each handoff point, with `.to_s` conversions marked.
+
+**Legend:** Yellow background = key is a **Symbol**, Blue background = key is a **String**.
+
+### PUT flow (flag received from stream)
+
+```mermaid
+sequenceDiagram
+ participant SSE as SSE Stream
+ participant Parse as JSON Parser
+ participant PO as PutObject
+ participant CS as ChangeSet
+ participant Store as Store
+ participant Mem as InMemoryStore
+ participant DT as DependencyTracker
+ participant BC as Broadcaster
+ participant Adapter as FlagValueChangeAdapter
+
+ SSE->>Parse: raw JSON {"key":"my-flag", ...}
+ rect rgb(255, 248, 200)
+ Note over Parse: JSON.parse(symbolize_names: true)
hash = {key: "my-flag", ...}
hash keys = Symbol, values = String
+ end
+
+ Parse->>PO: PutObject.from_h(hash)
+ rect rgb(255, 248, 200)
+ Note over PO: @key = hash[:key].to_sym
→ :my_flag (SYMBOL)
+ end
+
+ PO->>CS: add_put(:my_flag, 1, obj)
+ rect rgb(255, 248, 200)
+ Note over CS: Change @key = :my_flag (SYMBOL)
+ end
+
+ CS->>Store: apply(change_set)
+ rect rgb(255, 248, 200)
+ Note over Store: changes_to_store_data
hash key = :my_flag (SYMBOL)
value[:key] = "my-flag" (STRING, from obj)
+ end
+
+ Store->>Mem: set_basis / apply_delta
+ rect rgb(255, 248, 200)
+ Note over Mem: @items[:my_flag] = obj
index key = SYMBOL
+ end
+
+ Store->>DT: update_dependencies_from(kind, key.to_s, item)
+ rect rgb(200, 220, 255)
+ Note over DT: from_key = "my-flag" (STRING)
prereq.key = "my-flag" (STRING)
types match for hash lookups
+ end
+
+ DT->>Store: affected_items {kind:, key: "my-flag"}
+ rect rgb(200, 220, 255)
+ Note over Store: item[:key] is already STRING
+ end
+
+ Store->>BC: FlagChange.new("my-flag")
+ rect rgb(200, 220, 255)
+ Note over BC: @key = "my-flag" (STRING)
+ end
+
+ BC->>Adapter: update(flag_change)
+ rect rgb(200, 220, 255)
+ Note over Adapter: flag_change.key.to_s == @flag_key.to_s
tolerates Symbol or String
+ end
+```
+
+### DELETE flow (flag deleted from stream)
+
+```mermaid
+sequenceDiagram
+ participant SSE as SSE Stream
+ participant Parse as JSON Parser
+ participant DO as DeleteObject
+ participant CS as ChangeSet
+ participant Store as Store
+ participant Mem as InMemoryStore
+ participant DT as DependencyTracker
+ participant BC as Broadcaster
+
+ SSE->>Parse: raw JSON {"key":"my-flag", "version":2}
+ rect rgb(255, 248, 200)
+ Note over Parse: hash = {key: "my-flag", version: 2}
+ end
+
+ Parse->>DO: DeleteObject.from_h(hash)
+ rect rgb(255, 248, 200)
+ Note over DO: @key = :my_flag (SYMBOL)
+ end
+
+ DO->>CS: add_delete(:my_flag, 2)
+ rect rgb(255, 248, 200)
+ Note over CS: Change @key = :my_flag (SYMBOL)
+ end
+
+ CS->>Store: apply(change_set)
+ rect rgb(255, 248, 200)
+ Note over Store: changes_to_store_data
hash key = :my_flag (SYMBOL)
+ end
+ rect rgb(200, 220, 255)
+ Note over Store: value = {key: change.key.to_s, ...}
→ {key: "my-flag", ...} (.to_s conversion)
+ end
+
+ Store->>Mem: apply_delta
+ rect rgb(255, 248, 200)
+ Note over Mem: @items[:my_flag] = deleted tombstone
+ end
+
+ Store->>DT: update_dependencies_from(kind, "my-flag", item)
+ rect rgb(200, 220, 255)
+ Note over DT: deleted item → clears deps
key = "my-flag" (STRING)
+ end
+
+ DT->>Store: affected_items {kind:, key: "my-flag"}
+ rect rgb(200, 220, 255)
+ Note over Store: key = "my-flag" (STRING)
+ end
+
+ Store->>BC: FlagChange.new("my-flag")
+ rect rgb(200, 220, 255)
+ Note over BC: @key = "my-flag" (STRING)
+ end
+```
+
+### Dependency propagation (prerequisite change)
+
+```mermaid
+sequenceDiagram
+ participant Store as Store
+ participant DT as DependencyTracker
+ participant BC as Broadcaster
+ participant User as User Listener
+
+ rect rgb(200, 220, 255)
+ Note over Store: Initial state:
flag_b has prerequisite on flag_a
DT tracks: "flag_a" → {"flag_b"} (all STRING)
+ end
+
+ Store->>DT: update_dependencies_from(FLAGS, "flag_a", new_flag_a)
+ rect rgb(200, 220, 255)
+ Note over DT: Refresh flag_a's own deps
key = "flag_a" (STRING)
+ end
+
+ Store->>DT: add_affected_items(set, {kind: FLAGS, key: "flag_a"})
+ rect rgb(200, 220, 255)
+ Note over DT: Walk graph:
1. "flag_a" directly changed
2. "flag_b" depends on "flag_a"
All keys are STRING
+ end
+
+ DT->>Store: affected = {"flag_a", "flag_b"}
+
+ Store->>BC: FlagChange.new("flag_a")
+ Store->>BC: FlagChange.new("flag_b")
+
+ BC->>User: update(FlagChange) for each
+ rect rgb(200, 220, 255)
+ Note over User: .key is STRING
+ end
+```
+
+### Client read path (variation call)
+
+```mermaid
+sequenceDiagram
+ participant App as Application
+ participant Client as LDClient
+ participant Store as Store
+ participant Mem as InMemoryStore
+
+ App->>Client: variation("my-flag", context, default)
+ rect rgb(200, 220, 255)
+ Note over Client: key = "my-flag" (STRING)
+ end
+
+ Client->>Store: get(FEATURES, "my-flag")
+ Store->>Mem: get(FEATURES, "my-flag")
+ rect rgb(255, 248, 200)
+ Note over Mem: lookup: key.to_sym → :my_flag
@items[:my_flag] → FeatureFlag
+ end
+
+ Mem->>Store: FeatureFlag (@key = "my-flag")
+ rect rgb(200, 220, 255)
+ Note over Store: FeatureFlag @key = "my-flag" (STRING)
+ end
+ Store->>Client: FeatureFlag
+ Client->>App: evaluated value
+```
+
+## Two Distinct Key Concepts
+
+| Concept | Type | Example | Where |
+|---|---|---|---|
+| **Hash key** in collections (how items are indexed in the store) | **Symbol** | `:my_flag` | `changes_to_store_data`, `InMemoryFeatureStoreV2.@items` |
+| **Flag key** as a value (the flag's identifier string) | **String** | `"my-flag"` | `FeatureFlag#key`, `Prerequisite#key`, `LDClient.variation`, `FlagChange#key`, `FlagValueChange#key` |
+
+## The Fix
+
+1. **DELETE path** — `changes_to_store_data` now uses `.to_s` on the `:key` value field of fabricated delete hashes, matching PUT objects which carry String keys from JSON.
+
+2. **Dependency tracker boundary** — keys are converted with `.to_s` when passed from the store to the dependency tracker, ensuring hash lookups match between items indexed by `change.key` (Symbol) and dependencies extracted from model objects (String).
+
+3. **FlagValueChangeAdapter** — uses `.to_s` on both sides of comparisons and on the key passed to `eval_fn` and emitted in `FlagValueChange`. Users can pass either Symbol or String to `add_flag_value_change_listener`.
+
+4. **Interface docs** — `FlagChange`, `FlagValueChange`, and `add_flag_value_change_listener` document `[String]` keys. Internal `Change` class documents that its Symbol key is converted to String at user-facing boundaries.
diff --git a/lib/ldclient-rb/impl/data_store/store.rb b/lib/ldclient-rb/impl/data_store/store.rb
index 5fa7fcf4..32f14a03 100644
--- a/lib/ldclient-rb/impl/data_store/store.rb
+++ b/lib/ldclient-rb/impl/data_store/store.rb
@@ -248,9 +248,10 @@ def get_data_store_status_provider
collections.each do |kind, collection|
collection.each do |key, item|
- @dependency_tracker.update_dependencies_from(kind, key, item)
+ string_key = key.to_s
+ @dependency_tracker.update_dependencies_from(kind, string_key, item)
if has_listeners
- @dependency_tracker.add_affected_items(affected_items, { kind: kind, key: key })
+ @dependency_tracker.add_affected_items(affected_items, { kind: kind, key: string_key })
end
end
end
@@ -297,7 +298,7 @@ def get_data_store_status_provider
if change.action == LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT && !change.object.nil?
all_data[kind][change.key] = change.object
elsif change.action == LaunchDarkly::Interfaces::DataSystem::ChangeType::DELETE
- all_data[kind][change.key] = { key: change.key, deleted: true, version: change.version }
+ all_data[kind][change.key] = { key: change.key.to_s, deleted: true, version: change.version }
end
end
@@ -314,7 +315,7 @@ def get_data_store_status_provider
@dependency_tracker.reset
all_data.each do |kind, items|
items.each do |key, item|
- @dependency_tracker.update_dependencies_from(kind, key, item)
+ @dependency_tracker.update_dependencies_from(kind, key.to_s, item)
end
end
end
@@ -356,9 +357,9 @@ def get_data_store_status_provider
# If either is missing or versions differ, it's a change
if old_item.nil? || new_item.nil?
- @dependency_tracker.add_affected_items(affected_items, { kind: kind, key: key })
+ @dependency_tracker.add_affected_items(affected_items, { kind: kind, key: key.to_s })
elsif old_item[:version] != new_item[:version]
- @dependency_tracker.add_affected_items(affected_items, { kind: kind, key: key })
+ @dependency_tracker.add_affected_items(affected_items, { kind: kind, key: key.to_s })
end
end
end
diff --git a/lib/ldclient-rb/impl/flag_tracker.rb b/lib/ldclient-rb/impl/flag_tracker.rb
index 83341118..c7775fd0 100644
--- a/lib/ldclient-rb/impl/flag_tracker.rb
+++ b/lib/ldclient-rb/impl/flag_tracker.rb
@@ -26,7 +26,7 @@ def add_flag_value_change_listener(key, context, listener)
# An adapter which turns a normal flag change listener into a flag value change listener.
#
class FlagValueChangeAdapter
- # @param [Symbol] flag_key
+ # @param [String] flag_key
# @param [LaunchDarkly::LDContext] context
# @param [#update] listener
# @param [#call] eval_fn
@@ -35,22 +35,22 @@ def initialize(flag_key, context, listener, eval_fn)
@context = context
@listener = listener
@eval_fn = eval_fn
- @value = Concurrent::AtomicReference.new(@eval_fn.call(@flag_key, @context))
+ @value = Concurrent::AtomicReference.new(@eval_fn.call(@flag_key.to_s, @context))
end
#
# @param [LaunchDarkly::Interfaces::FlagChange] flag_change
#
def update(flag_change)
- return unless flag_change.key == @flag_key
+ return unless flag_change.key.to_s == @flag_key.to_s
- new_eval = @eval_fn.call(@flag_key, @context)
+ new_eval = @eval_fn.call(@flag_key.to_s, @context)
old_eval = @value.get_and_set(new_eval)
return if new_eval == old_eval
@listener.update(
- LaunchDarkly::Interfaces::FlagValueChange.new(@flag_key, old_eval, new_eval))
+ LaunchDarkly::Interfaces::FlagValueChange.new(@flag_key.to_s, old_eval, new_eval))
end
end
end
diff --git a/lib/ldclient-rb/interfaces/data_system.rb b/lib/ldclient-rb/interfaces/data_system.rb
index 92479b75..65057475 100644
--- a/lib/ldclient-rb/interfaces/data_system.rb
+++ b/lib/ldclient-rb/interfaces/data_system.rb
@@ -206,7 +206,7 @@ class Change
# @return [String] The kind ({ObjectKind})
attr_reader :kind
- # @return [Symbol] The key
+ # @return [Symbol] The key (Symbol for internal store indexing; converted to String at user-facing boundaries)
attr_reader :key
# @return [Integer] The version
@@ -218,7 +218,7 @@ class Change
#
# @param action [String] The action type ({ChangeType})
# @param kind [String] The object kind ({ObjectKind})
- # @param key [Symbol] The key
+ # @param key [Symbol] The key (Symbol for internal store indexing; converted to String at user-facing boundaries)
# @param version [Integer] The version
# @param object [Hash, nil] The object data
#
@@ -510,7 +510,7 @@ def finish(selector)
# Adds a new object to the changeset.
#
# @param kind [String] The object kind ({ObjectKind})
- # @param key [Symbol] The key
+ # @param key [Symbol] The key (Symbol for internal store indexing; converted to String at user-facing boundaries)
# @param version [Integer] The version
# @param obj [Hash] The object data
# @return [void]
@@ -529,7 +529,7 @@ def add_put(kind, key, version, obj)
# Adds a deletion to the changeset.
#
# @param kind [String] The object kind ({ObjectKind})
- # @param key [Symbol] The key
+ # @param key [Symbol] The key (Symbol for internal store indexing; converted to String at user-facing boundaries)
# @param version [Integer] The version
# @return [void]
#
diff --git a/lib/ldclient-rb/interfaces/flag_tracker.rb b/lib/ldclient-rb/interfaces/flag_tracker.rb
index 3c8a8f8c..f32a5f78 100644
--- a/lib/ldclient-rb/interfaces/flag_tracker.rb
+++ b/lib/ldclient-rb/interfaces/flag_tracker.rb
@@ -66,7 +66,7 @@ def remove_listener(listener) end
# The returned listener represents the subscription that was created by this method
# call; to unsubscribe, pass that object (not your listener) to {#remove_listener}.
#
- # @param key [Symbol]
+ # @param key [String]
# @param context [LaunchDarkly::LDContext]
# @param listener [#update]
#
@@ -79,7 +79,7 @@ def add_flag_value_change_listener(key, context, listener) end
class FlagChange
attr_accessor :key
- # @param [Symbol] key
+ # @param [String] key
def initialize(key)
@key = key
end
@@ -93,7 +93,7 @@ class FlagValueChange
attr_accessor :old_value
attr_accessor :new_value
- # @param [Symbol] key
+ # @param [String] key
# @param [Object] old_value
# @param [Object] new_value
def initialize(key, old_value, new_value)
diff --git a/spec/impl/data_store/store_spec.rb b/spec/impl/data_store/store_spec.rb
index b3260ecb..816e54bf 100644
--- a/spec/impl/data_store/store_spec.rb
+++ b/spec/impl/data_store/store_spec.rb
@@ -250,6 +250,200 @@ def reset_tracking
end
end
+ describe "flag change events" do
+ let(:flag_change_broadcaster) { LaunchDarkly::Impl::Broadcaster.new(SynchronousExecutor.new, logger) }
+ let(:change_set_broadcaster) { LaunchDarkly::Impl::Broadcaster.new(SynchronousExecutor.new, logger) }
+ subject { Store.new(flag_change_broadcaster, change_set_broadcaster, logger) }
+
+ it "TRANSFER_FULL fires FlagChange with String keys" do
+ listener = ListenerSpy.new
+ flag_change_broadcaster.add_listener(listener)
+
+ change = LaunchDarkly::Interfaces::DataSystem::Change.new(
+ action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT,
+ kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG,
+ key: :flag_a,
+ version: 1,
+ object: { key: "flag_a", version: 1, on: true, variations: [true, false], fallthrough: { variation: 0 } }
+ )
+
+ change_set = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new(
+ intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL,
+ changes: [change],
+ selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector
+ )
+
+ subject.apply(change_set, false)
+
+ expect(listener.statuses.length).to eq(1)
+ expect(listener.statuses[0]).to be_a(LaunchDarkly::Interfaces::FlagChange)
+ expect(listener.statuses[0].key).to be_a(String)
+ expect(listener.statuses[0].key).to eq("flag_a")
+ end
+
+ it "TRANSFER_CHANGES fires FlagChange with String keys" do
+ # Initialize first (no listeners yet)
+ init_change = LaunchDarkly::Interfaces::DataSystem::Change.new(
+ action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT,
+ kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG,
+ key: :flag_a,
+ version: 1,
+ object: { key: "flag_a", version: 1, on: true, variations: [true, false], fallthrough: { variation: 0 } }
+ )
+
+ init_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new(
+ intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL,
+ changes: [init_change],
+ selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector
+ )
+
+ subject.apply(init_cs, false)
+
+ # Now add listener and apply delta
+ listener = ListenerSpy.new
+ flag_change_broadcaster.add_listener(listener)
+
+ delta_change = LaunchDarkly::Interfaces::DataSystem::Change.new(
+ action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT,
+ kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG,
+ key: :flag_a,
+ version: 2,
+ object: { key: "flag_a", version: 2, on: false, variations: [true, false], fallthrough: { variation: 0 } }
+ )
+
+ delta_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new(
+ intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_CHANGES,
+ changes: [delta_change],
+ selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector
+ )
+
+ subject.apply(delta_cs, false)
+
+ expect(listener.statuses.length).to eq(1)
+ expect(listener.statuses[0].key).to be_a(String)
+ expect(listener.statuses[0].key).to eq("flag_a")
+ end
+
+ it "prerequisite dependency propagation" do
+ # flag_b has prerequisite on flag_a
+ flag_a_data = { key: "flag_a", version: 1, on: true, variations: [true, false], fallthrough: { variation: 0 }, prerequisites: [], rules: [] }
+ flag_b_data = { key: "flag_b", version: 1, on: true, variations: [true, false], fallthrough: { variation: 0 }, prerequisites: [{ key: "flag_a", variation: 0 }], rules: [] }
+
+ init_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new(
+ intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL,
+ changes: [
+ LaunchDarkly::Interfaces::DataSystem::Change.new(action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, key: :flag_a, version: 1, object: flag_a_data),
+ LaunchDarkly::Interfaces::DataSystem::Change.new(action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, key: :flag_b, version: 1, object: flag_b_data),
+ ],
+ selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector
+ )
+
+ subject.apply(init_cs, false)
+
+ # Now listen and change flag_a
+ listener = ListenerSpy.new
+ flag_change_broadcaster.add_listener(listener)
+
+ delta_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new(
+ intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_CHANGES,
+ changes: [
+ LaunchDarkly::Interfaces::DataSystem::Change.new(action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, key: :flag_a, version: 2, object: flag_a_data.merge(version: 2)),
+ ],
+ selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector
+ )
+
+ subject.apply(delta_cs, false)
+
+ changed_keys = listener.statuses.map(&:key)
+ expect(changed_keys).to include("flag_a")
+ expect(changed_keys).to include("flag_b")
+ changed_keys.each { |k| expect(k).to be_a(String) }
+ end
+
+ it "segment dependency propagation" do
+ # flag uses segment via segmentMatch rule
+ segment_data = { key: "seg1", version: 1, included: [], excluded: [], rules: [] }
+ flag_data = {
+ key: "flag_a", version: 1, on: true, variations: [true, false], fallthrough: { variation: 0 },
+ prerequisites: [],
+ rules: [{ id: "rule1", variation: 0, clauses: [{ attribute: "", op: "segmentMatch", values: ["seg1"], negate: false }] }],
+ }
+
+ init_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new(
+ intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL,
+ changes: [
+ LaunchDarkly::Interfaces::DataSystem::Change.new(action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, key: :flag_a, version: 1, object: flag_data),
+ LaunchDarkly::Interfaces::DataSystem::Change.new(action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT, key: :seg1, version: 1, object: segment_data),
+ ],
+ selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector
+ )
+
+ subject.apply(init_cs, false)
+
+ # Now listen and change the segment
+ listener = ListenerSpy.new
+ flag_change_broadcaster.add_listener(listener)
+
+ delta_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new(
+ intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_CHANGES,
+ changes: [
+ LaunchDarkly::Interfaces::DataSystem::Change.new(action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT, key: :seg1, version: 2, object: segment_data.merge(version: 2)),
+ ],
+ selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector
+ )
+
+ subject.apply(delta_cs, false)
+
+ changed_keys = listener.statuses.map(&:key)
+ # Segment change should propagate to the flag
+ expect(changed_keys).to include("flag_a")
+ changed_keys.each { |k| expect(k).to be_a(String) }
+ end
+
+ it "DELETE fires FlagChange with String keys" do
+ # Initialize with a flag
+ init_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new(
+ intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL,
+ changes: [
+ LaunchDarkly::Interfaces::DataSystem::Change.new(
+ action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT,
+ kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG,
+ key: :flag_a,
+ version: 1,
+ object: { key: "flag_a", version: 1, on: true, variations: [true, false], fallthrough: { variation: 0 } }
+ ),
+ ],
+ selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector
+ )
+
+ subject.apply(init_cs, false)
+
+ # Now listen and delete the flag
+ listener = ListenerSpy.new
+ flag_change_broadcaster.add_listener(listener)
+
+ delete_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new(
+ intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_CHANGES,
+ changes: [
+ LaunchDarkly::Interfaces::DataSystem::Change.new(
+ action: LaunchDarkly::Interfaces::DataSystem::ChangeType::DELETE,
+ kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG,
+ key: :flag_a,
+ version: 2,
+ object: nil
+ ),
+ ],
+ selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector
+ )
+
+ subject.apply(delete_cs, false)
+
+ expect(listener.statuses.length).to eq(1)
+ expect(listener.statuses[0].key).to be_a(String)
+ expect(listener.statuses[0].key).to eq("flag_a")
+ end
+ end
+
describe "#commit" do
context "without persistent store" do
it "returns nil and does nothing" do
diff --git a/spec/impl/data_system/fdv2_datasystem_spec.rb b/spec/impl/data_system/fdv2_datasystem_spec.rb
index 44c85548..6aabfc3c 100644
--- a/spec/impl/data_system/fdv2_datasystem_spec.rb
+++ b/spec/impl/data_system/fdv2_datasystem_spec.rb
@@ -65,9 +65,9 @@ def build(_sdk_key, _config)
expect(modified.wait(1)).to be true
expect(changes.length).to eq(3)
- expect(changes[0].key).to eq(:flagkey)
- expect(changes[1].key).to eq(:flagkey)
- expect(changes[2].key).to eq(:flagkey)
+ expect(changes[0].key).to eq("flagkey")
+ expect(changes[1].key).to eq("flagkey")
+ expect(changes[2].key).to eq("flagkey")
fdv2.stop
end
@@ -166,8 +166,8 @@ def build(_sdk_key, _config)
expect(changed.wait(2)).to be true
expect(changes.length).to eq(2)
- expect(changes[0].key).to eq(:flagkey)
- expect(changes[1].key).to eq(:flagkey)
+ expect(changes[0].key).to eq("flagkey")
+ expect(changes[1].key).to eq("flagkey")
fdv2.stop
end
@@ -263,7 +263,7 @@ def build(_sdk_key, _config)
# Verify we got flag changes from FDv1
expect(changes.length).to be > 0
- expect(changes.any? { |change| change.key == :fdv1flag }).to be true
+ expect(changes.any? { |change| change.key == "fdv1flag" }).to be true
fdv2.stop
end
@@ -318,7 +318,7 @@ def build(_sdk_key, _config)
# Verify FDv1 is active and we got both changes
expect(changes.length).to eq(2)
- expect(changes.all? { |change| change.key == :fdv1fallbackflag }).to be true
+ expect(changes.all? { |change| change.key == "fdv1fallbackflag" }).to be true
fdv2.stop
end
@@ -369,8 +369,8 @@ def build(_sdk_key, _config)
# Verify we got changes for both flags
flag_keys = changes.map { |change| change.key }
- expect(flag_keys).to include(:initialflag)
- expect(flag_keys).to include(:fdv1replacementflag)
+ expect(flag_keys).to include("initialflag")
+ expect(flag_keys).to include("fdv1replacementflag")
fdv2.stop
end
diff --git a/spec/impl/data_system/fdv2_persistence_spec.rb b/spec/impl/data_system/fdv2_persistence_spec.rb
index 8eee67cc..d629cd5b 100644
--- a/spec/impl/data_system/fdv2_persistence_spec.rb
+++ b/spec/impl/data_system/fdv2_persistence_spec.rb
@@ -147,7 +147,7 @@ def get_data_snapshot
listener = Object.new
listener.define_singleton_method(:update) do |flag_change|
changes << flag_change
- flag_changed.set if flag_change.key == :newflag
+ flag_changed.set if flag_change.key == "newflag"
end
fdv2.flag_change_broadcaster.add_listener(listener)
@@ -326,7 +326,7 @@ def get_data_snapshot
flag_changed = Concurrent::Event.new
listener = Object.new
listener.define_singleton_method(:update) do |flag_change|
- flag_changed.set if flag_change.key == :flagkey
+ flag_changed.set if flag_change.key == "flagkey"
end
fdv2.flag_change_broadcaster.add_listener(listener)
@@ -412,7 +412,7 @@ def get_data_snapshot
listener = Object.new
listener.define_singleton_method(:update) do |flag_change|
- sync_flag_arrived.set if flag_change.key == :"sync-flag"
+ sync_flag_arrived.set if flag_change.key == "sync-flag"
end
fdv2.flag_change_broadcaster.add_listener(listener)
diff --git a/spec/impl/flag_tracker_spec.rb b/spec/impl/flag_tracker_spec.rb
index 5d830a67..b9b462c3 100644
--- a/spec/impl/flag_tracker_spec.rb
+++ b/spec/impl/flag_tracker_spec.rb
@@ -26,6 +26,71 @@ module Impl
expect(listener.statuses[1].key).to eq(:flag2)
end
+ describe "cross-type key handling" do
+ it "String FlagChange + String listener key" do
+ responses = [:initial, :second]
+ eval_fn = Proc.new { |key, _ctx| responses.shift }
+ tracker = subject.new(broadcaster, eval_fn)
+
+ listener = ListenerSpy.new
+ tracker.add_flag_value_change_listener("flag1", nil, listener)
+
+ broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new("flag1"))
+ expect(listener.statuses.count).to eq(1)
+ expect(listener.statuses[0].key).to eq("flag1")
+ expect(listener.statuses[0].key).to be_a(String)
+ end
+
+ it "String FlagChange + Symbol listener key" do
+ responses = [:initial, :second]
+ eval_fn = Proc.new { |key, _ctx| responses.shift }
+ tracker = subject.new(broadcaster, eval_fn)
+
+ listener = ListenerSpy.new
+ tracker.add_flag_value_change_listener(:flag1, nil, listener)
+
+ broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new("flag1"))
+ expect(listener.statuses.count).to eq(1)
+ expect(listener.statuses[0].key).to eq("flag1")
+ expect(listener.statuses[0].key).to be_a(String)
+ end
+
+ it "Symbol FlagChange + String listener key" do
+ responses = [:initial, :second]
+ eval_fn = Proc.new { |key, _ctx| responses.shift }
+ tracker = subject.new(broadcaster, eval_fn)
+
+ listener = ListenerSpy.new
+ tracker.add_flag_value_change_listener("flag1", nil, listener)
+
+ broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1))
+ expect(listener.statuses.count).to eq(1)
+ expect(listener.statuses[0].key).to eq("flag1")
+ expect(listener.statuses[0].key).to be_a(String)
+ end
+
+ it "eval_fn always receives String key" do
+ received_keys = []
+ eval_fn = Proc.new { |key, _ctx| received_keys << key; :value }
+ tracker = subject.new(broadcaster, eval_fn)
+
+ listener = ListenerSpy.new
+ tracker.add_flag_value_change_listener(:flag1, nil, listener)
+
+ # The initial eval in the constructor should have passed a String
+ expect(received_keys.length).to eq(1)
+ expect(received_keys[0]).to be_a(String)
+ expect(received_keys[0]).to eq("flag1")
+
+ # Trigger an update
+ broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new("flag1"))
+
+ expect(received_keys.length).to eq(2)
+ expect(received_keys[1]).to be_a(String)
+ expect(received_keys[1]).to eq("flag1")
+ end
+ end
+
describe "flag change listener" do
it "listener is notified when value changes" do
responses = [:initial, :second, :second, :final]
@@ -46,11 +111,11 @@ module Impl
broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1))
expect(listener.statuses.count).to eq(2)
- expect(listener.statuses[0].key).to eq(:flag1)
+ expect(listener.statuses[0].key).to eq("flag1")
expect(listener.statuses[0].old_value).to eq(:initial)
expect(listener.statuses[0].new_value).to eq(:second)
- expect(listener.statuses[1].key).to eq(:flag1)
+ expect(listener.statuses[1].key).to eq("flag1")
expect(listener.statuses[1].old_value).to eq(:second)
expect(listener.statuses[1].new_value).to eq(:final)
end