where_is_waldo 0.1.3 → 0.1.4

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: b49938f0c49191db60463517da2d41115da11af0f7bb1c62f27065bb4598afc5
4
- data.tar.gz: a247e74c4c86bb2e3188ad1f6b1b057d890b5daba01442ae0b2fd4b25786b60a
3
+ metadata.gz: 29833d47ae21a1aa5a1882e515152ddde9f7201eb9754cff81545495a0cd6d63
4
+ data.tar.gz: e7d41c790a9abbd4f3bdbd3ced6db38ce335629c189c0bd6404c40ce3874f82d
5
5
  SHA512:
6
- metadata.gz: 740783e4a9a19df3895bb62f113ff711f2d1815101e086388d604194beec71fe996803de241e0370f5685aa3d07f0ec5f1b4eff90fe654d19dd3f4cf9ac70436
7
- data.tar.gz: be170741d609ae6d904c2f29a73637139d71f17bd4d54dfed6a2a8c6c9a3662f0624131c690d5af11a8c6676774bff4c149f9a17134b5bb72080f676c475e7bb
6
+ metadata.gz: f7634cd0688c8f56bb3292ae1f8c9db9146d1775552afb7f7801a5c898a97ab257bdbb80aa012c3a7ef79bfdd714bce7c1bb9a9c486d859e2f7bff5dd70891cc
7
+ data.tar.gz: 59335dc215e4666a577294fd9f383cd178102bf1c8f649b0bf30a738ec63be5078e0d541cb3f6d11b6ed9765b561fa45c17665fdc2fb4a8f8df23f59fc486815
data/CHANGELOG.md CHANGED
@@ -3,6 +3,53 @@
3
3
  Notable changes to where_is_waldo. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/).
5
5
 
6
+ ## 0.1.4
7
+
8
+ ### Fixed
9
+
10
+ - **Roster reads now route through the configured adapter.** Previously
11
+ `Roster.states_for` (which powers `state_for`, `snapshot`, `members_for`,
12
+ every roster delta) queried the `Presence` ActiveRecord model directly.
13
+ Under `adapter = :redis`, writes went to Redis but reads hit an empty (or
14
+ absent) database table — presence dots stayed grey no matter how many
15
+ heartbeats came in. `Roster` now calls `PresenceService.sessions_for_subjects`
16
+ which delegates to whichever adapter is configured, so writes and reads
17
+ share one store on every adapter.
18
+
19
+ ### Added
20
+
21
+ - `Adapters::Base#sessions_for_subjects(subject_ids, timeout:)` — bulk-read
22
+ live sessions grouped by subject id, with a correct-but-unoptimized default
23
+ implementation that fans out over `sessions_for_subject`. `DatabaseAdapter`
24
+ overrides it with a single bulk query (`Presence.where(subject_col => ids)`
25
+ + `includes(:subject)`); `RedisAdapter` overrides with per-subject reads
26
+ filtered by heartbeat threshold. Timeout defaults to `config.timeout`.
27
+ - `PresenceService.sessions_for_subjects` — public delegate so callers stay
28
+ off the adapter directly.
29
+ - `Configuration#suppress_presence_proc` — callable that decides, per
30
+ connection, whether to skip presence registration for that subscriber.
31
+ Receives the ActionCable connection; returns truthy to suppress. A
32
+ suppressed subscriber still subscribes normally (streams from
33
+ `where_is_waldo:subject:<id>`, receives broadcasts, can invoke channel
34
+ actions) — they just don't register a Presence row / heartbeat / roster
35
+ transition. Use for cases where the WebSocket session is legitimate but
36
+ shouldn't be counted as "the subject is present" (e.g. support-user
37
+ impersonation tabs).
38
+
39
+ ```ruby
40
+ config.suppress_presence_proc = ->(connection) {
41
+ connection.request.session[:su_user].present?
42
+ }
43
+ ```
44
+
45
+ ### Changed
46
+
47
+ - `Roster.aggregate` / `session_level` / `platform` now consume presence
48
+ hashes (`session[:tab_visible]`, `session[:metadata]`) — matching the
49
+ shape every adapter already returned — instead of ActiveRecord `Presence`
50
+ method calls. No behavior change for callers; existing per-device and
51
+ per-subject aggregation semantics preserved.
52
+
6
53
  ## 0.1.3
7
54
 
8
55
  ### Fixed
data/README.md CHANGED
@@ -120,6 +120,16 @@ WhereIsWaldo.configure do |config|
120
120
  config.roster_org = ->(user) { user.account }
121
121
  config.roster_members = ->(org) { org.users.active }
122
122
 
123
+ # Optional: skip presence registration for specific connections while
124
+ # keeping them subscribed for broadcasts. Return truthy to suppress. The
125
+ # subscriber still streams from where_is_waldo:subject:<id> and receives
126
+ # WhereIsWaldo.broadcast_to messages — they just don't count as present.
127
+ # Typical use: support-user impersonation tabs shouldn't light up as the
128
+ # impersonated user in teammates' rosters.
129
+ config.suppress_presence_proc = ->(connection) {
130
+ connection.request.session[:su_user].present?
131
+ }
132
+
123
133
  # Redis adapter
124
134
  # config.redis_client = Redis.new(url: ENV["REDIS_URL"])
125
135
  end
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.3
1
+ 0.1.4
@@ -5,6 +5,13 @@ module WhereIsWaldo
5
5
  def subscribed
6
6
  stream_from subject_stream
7
7
 
8
+ # The subject_stream subscription above is unconditional: even when the
9
+ # subscriber's presence is suppressed (see suppress_presence_proc), the
10
+ # tab is a legitimate consumer of WhereIsWaldo.broadcast_to signaling
11
+ # and shouldn't be cut off from messages just because it doesn't count
12
+ # as "present."
13
+ return if presence_suppressed?
14
+
8
15
  register_presence
9
16
 
10
17
  # Seed the local transition gate, resolve the roster delivery strategy for
@@ -16,6 +23,8 @@ module WhereIsWaldo
16
23
  end
17
24
 
18
25
  def unsubscribed
26
+ return if presence_suppressed?
27
+
19
28
  WhereIsWaldo.disconnect(session_id: waldo_session_id)
20
29
 
21
30
  # Recompute the subject's aggregate (they may still be present in another
@@ -24,6 +33,8 @@ module WhereIsWaldo
24
33
  end
25
34
 
26
35
  def heartbeat(data)
36
+ return if presence_suppressed?
37
+
27
38
  data = data.with_indifferent_access
28
39
 
29
40
  tab_visible = data[:tab_visible] != false
@@ -48,6 +59,22 @@ module WhereIsWaldo
48
59
 
49
60
  private
50
61
 
62
+ # Memoized per-subscription. Host apps configure suppress_presence_proc
63
+ # to gate WHICH subscriptions register presence — e.g. a support-user
64
+ # impersonation tab is a legitimate WS consumer but shouldn't count as
65
+ # the impersonated subject being "here." Nil proc → default behavior
66
+ # (presence always registered).
67
+ def presence_suppressed?
68
+ # Ivars are @wiw_-prefixed throughout this channel to avoid clobbering
69
+ # host-app ActionCable state; keep the prefix over the cop's rename.
70
+ # rubocop:disable Naming/MemoizedInstanceVariableName
71
+ return @wiw_presence_suppressed if defined?(@wiw_presence_suppressed)
72
+
73
+ proc = WhereIsWaldo.config.suppress_presence_proc
74
+ @wiw_presence_suppressed = proc ? !!proc.call(connection) : false
75
+ # rubocop:enable Naming/MemoizedInstanceVariableName
76
+ end
77
+
51
78
  def register_presence
52
79
  WhereIsWaldo.connect(
53
80
  session_id: waldo_session_id,
@@ -44,6 +44,25 @@ module WhereIsWaldo
44
44
  raise NotImplementedError
45
45
  end
46
46
 
47
+ # Get live sessions for many subjects in one call, grouped by subject id.
48
+ # Subjects with no live sessions are omitted from the returned hash.
49
+ # Adapters should override for efficiency; this default is a correct but
50
+ # unoptimized fan-out over `sessions_for_subject`.
51
+ # @param subject_ids [Array<Integer/String>] Subject identifiers
52
+ # @param timeout [Integer, nil] Seconds threshold; sessions with
53
+ # `last_heartbeat` older than `Time.current - timeout` are excluded
54
+ # @return [Hash{Integer/String => Array<Hash>}] subject_id => sessions
55
+ def sessions_for_subjects(subject_ids, timeout: nil)
56
+ ids = Array(subject_ids).compact.uniq
57
+ return {} if ids.empty?
58
+
59
+ threshold = Time.current - (timeout || default_timeout)
60
+ ids.each_with_object({}) do |sid, memo|
61
+ live = sessions_for_subject(sid).select { |s| s[:last_heartbeat] && s[:last_heartbeat] >= threshold }
62
+ memo[sid] = live if live.any?
63
+ end
64
+ end
65
+
47
66
  # Get session status
48
67
  # @param session_id [String] Session identifier
49
68
  # @return [Hash, nil] Presence record or nil
@@ -77,6 +77,17 @@ module WhereIsWaldo
77
77
  scope.map(&:as_presence_hash)
78
78
  end
79
79
 
80
+ def sessions_for_subjects(subject_ids, timeout: nil)
81
+ ids = Array(subject_ids).compact.uniq
82
+ return {} if ids.empty?
83
+
84
+ threshold = (timeout || default_timeout).seconds.ago
85
+ scope = Presence.where(subject_column => ids).where("last_heartbeat > ?", threshold)
86
+ scope = scope.includes(:subject) if config.subject_class_constant
87
+ scope.group_by { |row| row[subject_column] }
88
+ .transform_values { |rows| rows.map(&:as_presence_hash) }
89
+ end
90
+
80
91
  def session_status(session_id)
81
92
  scope = Presence.where(session_column => session_id)
82
93
  scope = scope.includes(:subject) if config.subject_class_constant
@@ -2,7 +2,9 @@
2
2
 
3
3
  module WhereIsWaldo
4
4
  module Adapters
5
- class RedisAdapter < BaseAdapter
5
+ # Redis backing needs more plumbing (keying, TTLs, pipelines) than the
6
+ # DB adapter, so it legitimately runs past the default class-length limit.
7
+ class RedisAdapter < BaseAdapter # rubocop:disable Metrics/ClassLength
6
8
  def connect(session_id:, subject_id:, metadata: {})
7
9
  now = Time.current.to_i
8
10
 
@@ -93,6 +95,23 @@ module WhereIsWaldo
93
95
  end
94
96
  end
95
97
 
98
+ def sessions_for_subjects(subject_ids, timeout: nil)
99
+ ids = Array(subject_ids).compact.uniq
100
+ return {} if ids.empty?
101
+
102
+ threshold = Time.current.to_i - (timeout || default_timeout)
103
+ ids.each_with_object({}) do |sid, memo|
104
+ session_ids = redis.smembers(subject_sessions_key(sid))
105
+ live = session_ids.filter_map do |cid|
106
+ data = get_presence_data(cid)
107
+ next unless data && data["last_heartbeat"].to_i >= threshold
108
+
109
+ build_presence_hash(data)
110
+ end
111
+ memo[sid] = live if live.any?
112
+ end
113
+ end
114
+
96
115
  def session_status(session_id)
97
116
  data = get_presence_data(session_id)
98
117
  return nil unless data
@@ -74,6 +74,15 @@ module WhereIsWaldo
74
74
  # @return [Array<Hash>] Presence records
75
75
  delegate :sessions_for_subject, to: :adapter
76
76
 
77
+ # Get live sessions for many subjects in one call, grouped by subject id.
78
+ # Preferred over calling `sessions_for_subject` per id when the caller
79
+ # already has an id list (e.g. roster aggregation) — adapters can (and do)
80
+ # implement this as a bulk read.
81
+ # @param subject_ids [Array<Integer/String>] Subject identifiers
82
+ # @param timeout [Integer, nil] Seconds threshold (defaults to config.timeout)
83
+ # @return [Hash{Integer/String => Array<Hash>}] subject_id => sessions
84
+ delegate :sessions_for_subjects, to: :adapter
85
+
77
86
  # Get status of a specific session
78
87
  # @param session_id [String] Session identifier
79
88
  # @return [Hash, nil] Presence record or nil
@@ -165,25 +165,24 @@ module WhereIsWaldo
165
165
 
166
166
  private
167
167
 
168
- # Aggregate state for many subjects in one query, keyed by subject id.
168
+ # Aggregate state for many subjects in one adapter call, keyed by
169
+ # subject id. Routes through PresenceService so it matches whichever
170
+ # adapter is configured — reading Presence directly would only work on
171
+ # `:database`, but the roster snapshot needs to see whatever the writer
172
+ # sees under `:redis` too.
169
173
  def states_for(subject_ids, timeout: nil)
170
174
  ids = Array(subject_ids).compact.uniq
171
175
  return {} if ids.empty?
172
176
 
173
- threshold = (timeout || WhereIsWaldo.config.timeout).seconds.ago
174
- subject_col = Presence.subject_column
175
-
176
- rows = Presence.where(subject_col => ids)
177
- .where("last_heartbeat > ?", threshold)
178
- .to_a
179
-
180
- rows.group_by { |row| row[subject_col] }
181
- .transform_values { |sessions| aggregate(sessions) }
177
+ WhereIsWaldo::PresenceService
178
+ .sessions_for_subjects(ids, timeout: timeout)
179
+ .transform_values { |sessions| aggregate(sessions) }
182
180
  end
183
181
 
184
182
  # Reduce a subject's live sessions to per-device statuses plus an overall
185
183
  # roll-up. Sessions are grouped by platform (so several browser tabs form
186
184
  # one "web" status); the overall status is the highest across platforms.
185
+ # @param sessions [Array<Hash>] presence hashes from the adapter
187
186
  # @return [Hash] { status: "active", devices: { "web" => "active", ... } }
188
187
  def aggregate(sessions)
189
188
  devices = sessions.group_by { |s| platform(s) }
@@ -204,13 +203,13 @@ module WhereIsWaldo
204
203
  # a hidden tab / backgrounded app is :background; a visible/foreground
205
204
  # session is :active when working, else :idle.
206
205
  def session_level(session)
207
- return :background unless session.tab_visible
206
+ return :background unless session[:tab_visible]
208
207
 
209
- session.subject_active ? :active : :idle
208
+ session[:subject_active] ? :active : :idle
210
209
  end
211
210
 
212
211
  def platform(session)
213
- meta = session.metadata
212
+ meta = session[:metadata]
214
213
  value = meta && (meta["platform"] || meta[:platform])
215
214
  (value.presence || DEFAULT_PLATFORM).to_s
216
215
  end
@@ -32,6 +32,23 @@ module WhereIsWaldo
32
32
  # :authenticate_proc - proc to authenticate connection, receives request
33
33
  attr_accessor :channel_name, :authenticate_proc
34
34
 
35
+ # :suppress_presence_proc - callable that decides, per connection, whether
36
+ # to SKIP presence registration for that subscriber. Receives the
37
+ # ActionCable connection (host apps can read session/cookies/env off it,
38
+ # e.g. via `connection.request`). Returns truthy to suppress.
39
+ #
40
+ # A suppressed subscriber still subscribes normally — they receive
41
+ # broadcasts to `where_is_waldo:subject:<id>` (WhereIsWaldo.broadcast_to*
42
+ # signaling) and any other Waldo capability — they just don't register a
43
+ # Presence row / heartbeat / roster transition. Use for cases where the
44
+ # WebSocket session is legitimate but shouldn't be counted as "the subject
45
+ # is present", e.g. support-user impersonation tabs.
46
+ #
47
+ # config.suppress_presence_proc = ->(connection) {
48
+ # connection.request.session[:su_user].present?
49
+ # }
50
+ attr_accessor :suppress_presence_proc
51
+
35
52
  # Default audience resolver for the Broadcastable concern. A lambda that,
36
53
  # given a record, returns the AR scope to broadcast to (e.g. that record's
37
54
  # account members). Set once per app to match its container, e.g.:
@@ -106,6 +123,7 @@ module WhereIsWaldo
106
123
  # ActionCable defaults
107
124
  @channel_name = "WhereIsWaldo::PresenceChannel"
108
125
  @authenticate_proc = nil
126
+ @suppress_presence_proc = nil
109
127
 
110
128
  # Broadcastable default audience (set per app)
111
129
  @broadcast_audience = nil
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: where_is_waldo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Scott Gibson