feat-sdk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96fb093819af9d5ad6b3caf1e2d7dbe8bb54615c72c96d44c54a50e90a5b2ec4
4
- data.tar.gz: 62b5a48dfc8bd4917567aff1c43ca75907b9f474f3ae60500b73d53b58dd57c2
3
+ metadata.gz: 4dfb1cd9905d46890db88dea9411a6dc3755208286fbbec6565d8742459d5008
4
+ data.tar.gz: 63c030396f376ff362b3d0d5b7c07a7cc926b4dd5141c9516988dbcb87a3419f
5
5
  SHA512:
6
- metadata.gz: 8cc1f3d77862b0fa9adde4d8c5a766bc2ab17e040cd791054c8fbde8199e9764067412e48d451283c8a3d47c6484cb317b83aefb63efda48a1249e65eca2859a
7
- data.tar.gz: c6f7fac4513cbcdcdcd605d8a326ce858638a2a8da6f9bed01f834f696c5d792323471fe6d77ec631ec671307da82bd54b6fa3587374f66cded36c75919d794f
6
+ metadata.gz: 347f444e5f8354f9b4b4d8957c812bde443a502b7c6cc54623df05983abe332cafd77f37ffebbb01d8cd8e8940d280a8004068e16403398721b2bb6bcc2ec4c6
7
+ data.tar.gz: e192084e64f93ff51ab4e14aa4384ec0954be13f6e4996c00371185deee30a9fe67b718d3065a07b64d2bd15dd98a4bfcf0677bb54e67a0959015ad542504fee
data/lib/feat/client.rb CHANGED
@@ -135,9 +135,36 @@ module Feat
135
135
  api_key: @api_key,
136
136
  transport: transport,
137
137
  on_put: ->(parsed) { store_datafile(parsed, parsed["etag"]) },
138
+ on_patch: ->(parsed) { apply_patch(parsed) },
138
139
  )
139
140
  end
140
141
 
142
+ # Apply a streamed `patch` delta. Version-gated: the delta is merged only
143
+ # when the in-memory datafile's version equals the patch's +from+, so the
144
+ # result is exactly the +to+ snapshot. On any gap or mismatch the patch is
145
+ # ignored - a reconnect reseeds a full `put` and the safety poll backstops.
146
+ # Runs under the same mutex as store_datafile, so a subsequent evaluation
147
+ # sees the merged delta atomically. Returns true when applied.
148
+ def apply_patch(patch)
149
+ from = patch["from"]
150
+ to = patch["to"]
151
+ # Reject malformed or out-of-order deltas before touching the datafile:
152
+ # both bounds must be integers and the patch must move strictly forward.
153
+ # Without the `to > from` guard a backward `to` would roll the in-memory
154
+ # version backward and break version ordering.
155
+ return false unless from.is_a?(Integer) && to.is_a?(Integer) && to > from
156
+
157
+ @mutex.synchronize do
158
+ current = @datafile
159
+ return false if current.nil?
160
+ return false unless current.version == from
161
+
162
+ @datafile = Datafile.merge_patch(current, patch)
163
+ @etag = patch["etag"] if patch["etag"]
164
+ end
165
+ true
166
+ end
167
+
141
168
  def poll_loop
142
169
  until @stop
143
170
  interruptible_sleep(@poll_interval) { @stop }
data/lib/feat/datafile.rb CHANGED
@@ -49,6 +49,36 @@ module Feat
49
49
  )
50
50
  end
51
51
 
52
+ # Merge a patch delta onto an existing File and return a new File. Pure:
53
+ # +current+ is not mutated. Added or changed flags/segments are built
54
+ # from their wire objects and override by key; removed keys are dropped.
55
+ # version, etag, and generatedAt advance to the patch's values (etag and
56
+ # generatedAt fall back to the current ones when the patch omits them).
57
+ # Raises if a flag or segment object is malformed; the caller treats that
58
+ # as a no-op and ignores the patch.
59
+ def self.merge_patch(current, patch)
60
+ flags = current.flags.dup
61
+ (patch["flags"] || {}).each { |k, v| flags[k] = build_flag(v) }
62
+ (patch["removedFlags"] || []).each { |k| flags.delete(k) }
63
+
64
+ segments = current.segments.dup
65
+ (patch["segments"] || {}).each { |k, v| segments[k] = build_segment(v) }
66
+ (patch["removedSegments"] || []).each { |k| segments.delete(k) }
67
+
68
+ File.new(
69
+ schemaVersion: current.schemaVersion,
70
+ envId: current.envId,
71
+ envKey: current.envKey,
72
+ projectId: current.projectId,
73
+ version: patch["to"],
74
+ etag: patch["etag"] || current.etag,
75
+ generatedAt: patch["generatedAt"] || current.generatedAt,
76
+ flags: flags,
77
+ segments: segments,
78
+ contextKinds: current.contextKinds
79
+ )
80
+ end
81
+
52
82
  def self.build_flag(d)
53
83
  FlagSpec.new(
54
84
  id: d["id"],
@@ -87,8 +87,9 @@ module Feat
87
87
  end
88
88
 
89
89
  # Holds a long-lived SSE connection to the datafile stream endpoint and
90
- # invokes +on_put+ with the parsed datafile for every `put` frame. Runs on
91
- # its own thread, reconnects with exponential backoff, and stops cleanly.
90
+ # invokes +on_put+ with the parsed datafile for every `put` frame and
91
+ # +on_patch+ with the parsed delta for every `patch` frame. Runs on its
92
+ # own thread, reconnects with exponential backoff, and stops cleanly.
92
93
  class StreamingClient
93
94
  include InterruptibleSleep
94
95
 
@@ -106,7 +107,7 @@ module Feat
106
107
  TERMINAL_STREAM_CODES = [401, 403].freeze
107
108
  JOIN_TIMEOUT_SECONDS = 5
108
109
 
109
- def initialize(url:, api_key:, transport:, on_put:, on_error: nil,
110
+ def initialize(url:, api_key:, transport:, on_put:, on_patch: nil, on_error: nil,
110
111
  initial_backoff: DEFAULT_INITIAL_BACKOFF,
111
112
  max_backoff: DEFAULT_MAX_BACKOFF,
112
113
  min_uptime: DEFAULT_MIN_UPTIME,
@@ -115,6 +116,7 @@ module Feat
115
116
  @api_key = api_key
116
117
  @transport = transport
117
118
  @on_put = on_put
119
+ @on_patch = on_patch
118
120
  @on_error = on_error
119
121
  @initial_backoff = initial_backoff
120
122
  @max_backoff = max_backoff
@@ -213,8 +215,19 @@ module Feat
213
215
  end
214
216
 
215
217
  def handle_event(event)
216
- return unless event[:event] == "put"
218
+ case event[:event]
219
+ when "put" then with_parsed_payload(event) { |parsed| @on_put.call(parsed) }
220
+ when "patch" then with_parsed_payload(event) { |parsed| @on_patch&.call(parsed) }
221
+ end
222
+ rescue StandardError => e
223
+ notify_error(e)
224
+ end
217
225
 
226
+ # Decode the event's JSON data field and hand it to the block. A missing,
227
+ # empty, or unparseable payload is dropped silently (the next frame or a
228
+ # reconnect recovers); any error the block raises bubbles to the caller's
229
+ # rescue and is surfaced via on_error.
230
+ def with_parsed_payload(event)
218
231
  raw = event[:data]
219
232
  return if raw.nil? || raw.empty?
220
233
 
@@ -225,9 +238,7 @@ module Feat
225
238
  return
226
239
  end
227
240
 
228
- @on_put.call(parsed)
229
- rescue StandardError => e
230
- notify_error(e)
241
+ yield parsed
231
242
  end
232
243
 
233
244
  # We deliberately do not send a Last-Event-ID header to resume. The
data/lib/feat/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Feat
2
- VERSION = "0.2.0".freeze
2
+ VERSION = "0.3.0".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: feat-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - feat HQ