where_is_waldo 0.1.1

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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +467 -0
  5. data/VERSION +1 -0
  6. data/app/channels/where_is_waldo/application_cable/channel.rb +19 -0
  7. data/app/channels/where_is_waldo/application_cable/connection.rb +45 -0
  8. data/app/channels/where_is_waldo/jwt_connection.rb +83 -0
  9. data/app/channels/where_is_waldo/presence_channel.rb +86 -0
  10. data/app/channels/where_is_waldo/roster_channel.rb +52 -0
  11. data/app/jobs/where_is_waldo/application_job.rb +6 -0
  12. data/app/jobs/where_is_waldo/presence_cleanup_job.rb +13 -0
  13. data/app/models/concerns/where_is_waldo/broadcastable.rb +85 -0
  14. data/app/models/where_is_waldo/application_record.rb +7 -0
  15. data/app/models/where_is_waldo/presence.rb +87 -0
  16. data/app/services/where_is_waldo/adapters/base_adapter.rb +80 -0
  17. data/app/services/where_is_waldo/adapters/database_adapter.rb +104 -0
  18. data/app/services/where_is_waldo/adapters/redis_adapter.rb +205 -0
  19. data/app/services/where_is_waldo/broadcaster.rb +79 -0
  20. data/app/services/where_is_waldo/presence_service.rb +119 -0
  21. data/app/services/where_is_waldo/roster.rb +246 -0
  22. data/app/services/where_is_waldo/roster_delivery.rb +184 -0
  23. data/lib/generators/where_is_waldo/install/install_generator.rb +103 -0
  24. data/lib/generators/where_is_waldo/install/templates/channel.rb.tt +6 -0
  25. data/lib/generators/where_is_waldo/install/templates/connection.rb.tt +51 -0
  26. data/lib/generators/where_is_waldo/install/templates/create_presences.rb.tt +39 -0
  27. data/lib/generators/where_is_waldo/install/templates/initializer.rb.tt +37 -0
  28. data/lib/generators/where_is_waldo/install/templates/migration.rb.tt +20 -0
  29. data/lib/generators/where_is_waldo/install/templates/presence_channel.rb.tt +37 -0
  30. data/lib/where_is_waldo/configuration.rb +209 -0
  31. data/lib/where_is_waldo/engine.rb +17 -0
  32. data/lib/where_is_waldo/version.rb +5 -0
  33. data/lib/where_is_waldo.rb +117 -0
  34. metadata +249 -0
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ class PresenceChannel < ApplicationCable::Channel
5
+ def subscribed
6
+ stream_from subject_stream
7
+
8
+ register_presence
9
+
10
+ # Seed the local transition gate, resolve the roster delivery strategy for
11
+ # this subject's account once, and announce arrival.
12
+ @wiw_tab_visible = true
13
+ @wiw_subject_active = true
14
+ @wiw_roster_strategy = resolve_roster_strategy
15
+ publish_roster_change
16
+ end
17
+
18
+ def unsubscribed
19
+ WhereIsWaldo.disconnect(session_id: waldo_session_id)
20
+
21
+ # Recompute the subject's aggregate (they may still be present in another
22
+ # tab/device) and announce the change to the org roster.
23
+ publish_roster_change
24
+ end
25
+
26
+ def heartbeat(data)
27
+ data = data.with_indifferent_access
28
+
29
+ tab_visible = data[:tab_visible] != false
30
+ subject_active = data[:subject_active] != false
31
+
32
+ WhereIsWaldo.heartbeat(
33
+ session_id: waldo_session_id,
34
+ tab_visible: tab_visible,
35
+ subject_active: subject_active,
36
+ last_activity_at: data[:last_activity_at],
37
+ metadata: data[:metadata] || {}
38
+ )
39
+
40
+ # Efficiency: only touch the roster when THIS session's activity/visibility
41
+ # actually flips. Steady-state heartbeats (no change) cost zero broadcasts.
42
+ return unless roster_transition?(tab_visible, subject_active)
43
+
44
+ @wiw_tab_visible = tab_visible
45
+ @wiw_subject_active = subject_active
46
+ publish_roster_change
47
+ end
48
+
49
+ private
50
+
51
+ def register_presence
52
+ WhereIsWaldo.connect(
53
+ session_id: waldo_session_id,
54
+ subject_id: waldo_subject_id,
55
+ metadata: params[:metadata] || {}
56
+ )
57
+ end
58
+
59
+ def roster_transition?(tab_visible, subject_active)
60
+ tab_visible != @wiw_tab_visible || subject_active != @wiw_subject_active
61
+ end
62
+
63
+ # Resolve the roster delivery strategy for this subject's account, once, at
64
+ # subscribe. nil when the roster isn't configured or the subject has no org.
65
+ def resolve_roster_strategy
66
+ return nil unless WhereIsWaldo.config.roster_enabled?
67
+
68
+ subject = WhereIsWaldo.config.subject_class_constant&.find_by(id: waldo_subject_id)
69
+ org = subject && WhereIsWaldo.config.resolve_roster_org(subject)
70
+ return nil unless org
71
+
72
+ WhereIsWaldo::RosterDelivery.for(WhereIsWaldo.config.resolve_mode(org))
73
+ end
74
+
75
+ # Let the delivery strategy react to a presence transition. For :poll this
76
+ # is a no-op (clients poll); for :broadcast it pushes a delta to the account
77
+ # stream. Gated to actual transitions by the heartbeat handler.
78
+ def publish_roster_change
79
+ @wiw_roster_strategy&.on_transition(waldo_subject_id)
80
+ end
81
+
82
+ def subject_stream
83
+ "where_is_waldo:subject:#{waldo_subject_id}"
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ # Roster subscription. The delivery mode is resolved per account at subscribe
5
+ # time (config.roster_mode) and dispatched to the matching RosterDelivery
6
+ # strategy; the client is told the mode via the snapshot and adapts (listen
7
+ # vs. poll). A subject can only ever see their OWN account's roster (org is
8
+ # derived from the authenticated connection), so it's safe by construction.
9
+ #
10
+ # Modes:
11
+ # :broadcast - stream_from the shared account stream; deltas are pushed.
12
+ # :poll - no stream; the client calls #poll and gets a filtered diff.
13
+ class RosterChannel < ApplicationCable::Channel
14
+ def subscribed
15
+ subject = current_subject
16
+ mode = resolve_mode(subject)
17
+ return reject unless mode
18
+
19
+ @wiw_subject = subject
20
+ @wiw_strategy = RosterDelivery.for(mode)
21
+ apply_plan(@wiw_strategy.subscribe_plan(subject, waldo_session_id))
22
+ end
23
+
24
+ # Client-driven poll (pull/nudge modes). Transmits a filtered snapshot
25
+ # (first call / resync) or diff since the last poll for this session.
26
+ def poll
27
+ return unless @wiw_strategy
28
+
29
+ @wiw_strategy.poll_messages(@wiw_subject, waldo_session_id).each { |message| transmit(message) }
30
+ end
31
+
32
+ private
33
+
34
+ def resolve_mode(subject)
35
+ org = WhereIsWaldo.config.resolve_roster_org(subject)
36
+ return nil unless org
37
+
38
+ WhereIsWaldo.config.resolve_mode(org)
39
+ end
40
+
41
+ def apply_plan(plan)
42
+ plan[:streams].each { |stream| stream_from(stream) }
43
+ plan[:messages].each { |message| transmit(message) }
44
+ end
45
+
46
+ def current_subject
47
+ return nil unless waldo_subject_id
48
+
49
+ WhereIsWaldo.config.subject_class_constant&.find_by(id: waldo_subject_id)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ class PresenceCleanupJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform(timeout: nil)
8
+ cleaned = PresenceService.cleanup(timeout: timeout)
9
+ Rails.logger.info "[WhereIsWaldo] Cleaned up #{cleaned} stale presence records"
10
+ cleaned
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ # Opt a model into real-time broadcasting. Include the concern and declare
5
+ # which lifecycle events should push over the cable:
6
+ #
7
+ # class Person < ApplicationRecord
8
+ # include WhereIsWaldo::Broadcastable
9
+ # broadcasts_realtime # create + update + destroy
10
+ # end
11
+ #
12
+ # broadcasts_realtime on: %i[create update] # subset
13
+ # broadcasts_realtime on: :update, if: -> { saved_change_to_status? }
14
+ # broadcasts_realtime scope: ->(rec) { rec.organization.users }
15
+ #
16
+ # Event names follow "<model>_<verb>" — created / update / destroyed — e.g.
17
+ # person_created, person_update, person_destroyed (matching the convention
18
+ # issuesbyscott already uses). The client subscribes with useWaldoEvent.
19
+ #
20
+ # Audience defaults to `WhereIsWaldo.configuration.broadcast_audience` (set
21
+ # once per app, e.g. ->(rec) { rec.account.users }); override per model with
22
+ # `scope:`. Payload defaults to a minimal `{ id: }` (so clients refetch);
23
+ # override `realtime_payload` on the model to push a richer shape for
24
+ # patch-in-place rendering.
25
+ module Broadcastable
26
+ extend ActiveSupport::Concern
27
+
28
+ HOOKS = {
29
+ create: %i[after_create_commit created],
30
+ update: %i[after_update_commit update],
31
+ destroy: %i[after_destroy_commit destroyed]
32
+ }.freeze
33
+
34
+ class_methods do
35
+ def broadcasts_realtime(on: %i[create update destroy], scope: nil, **guard)
36
+ guard = guard.slice(:if, :unless)
37
+ Array(on).each do |event|
38
+ hook, verb = HOOKS.fetch(event.to_sym) do
39
+ raise ArgumentError, "broadcasts_realtime: unknown event #{event.inspect}"
40
+ end
41
+ # Block form runs in instance context; `verb`/`scope` are captured.
42
+ public_send(hook, **guard) { wiw_broadcast(verb, scope) }
43
+ end
44
+ end
45
+ end
46
+
47
+ # Override on the model to shape the payload (default: minimal { id: }).
48
+ def realtime_payload
49
+ { id: id }
50
+ end
51
+
52
+ private
53
+
54
+ def wiw_broadcast(verb, scope)
55
+ return unless defined?(::WhereIsWaldo)
56
+
57
+ audience = wiw_audience(scope)
58
+ return if audience.blank?
59
+
60
+ ::WhereIsWaldo.broadcast_to(audience, wiw_event_name(verb), wiw_payload(verb))
61
+ rescue StandardError => e
62
+ Rails.logger&.warn(
63
+ "[WhereIsWaldo::Broadcastable] #{self.class.name}##{id} #{verb} broadcast failed: #{e.class}: #{e.message}"
64
+ )
65
+ end
66
+
67
+ def wiw_audience(scope)
68
+ resolver = scope || ::WhereIsWaldo.configuration.broadcast_audience
69
+ return nil unless resolver
70
+
71
+ instance_exec(self, &resolver)
72
+ end
73
+
74
+ def wiw_event_name(verb)
75
+ # Use the STI base class so subclasses (e.g. Widget::Grid) broadcast the
76
+ # base event name ("widget_update"), not the subclass ("grid_update").
77
+ klass = self.class.respond_to?(:base_class) ? self.class.base_class : self.class
78
+ "#{klass.model_name.element}_#{verb}"
79
+ end
80
+
81
+ def wiw_payload(verb)
82
+ verb == :destroyed ? { id: id } : realtime_payload
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ class Presence < ApplicationRecord
5
+ self.table_name = -> { WhereIsWaldo.config.table_name }
6
+
7
+ # Ensure metadata column works with any database (jsonb, json, or text)
8
+ # This makes the column portable across PostgreSQL, MySQL, and SQLite
9
+ attribute :metadata, :json
10
+
11
+ class << self
12
+ def table_name
13
+ tn = WhereIsWaldo.config.table_name
14
+ tn.respond_to?(:call) ? tn.call : tn
15
+ end
16
+
17
+ def configure_associations!
18
+ subject_class = WhereIsWaldo.config.subject_class_constant
19
+ return unless subject_class
20
+
21
+ subject_col = WhereIsWaldo.config.subject_column
22
+ # rubocop:disable Rails/InverseOf -- dynamic association, inverse not applicable
23
+ belongs_to :subject,
24
+ class_name: subject_class.name,
25
+ foreign_key: subject_col,
26
+ optional: true
27
+ # rubocop:enable Rails/InverseOf
28
+ end
29
+
30
+ # Dynamic column accessors
31
+ def session_column
32
+ WhereIsWaldo.config.session_column
33
+ end
34
+
35
+ def subject_column
36
+ WhereIsWaldo.config.subject_column
37
+ end
38
+ end
39
+
40
+ # Scopes
41
+ scope :online, lambda { |timeout: nil|
42
+ threshold = (timeout || WhereIsWaldo.config.timeout).seconds.ago
43
+ where("last_heartbeat > ?", threshold)
44
+ }
45
+
46
+ scope :for_subject, lambda { |subject_id|
47
+ where(WhereIsWaldo.config.subject_column => subject_id)
48
+ }
49
+
50
+ scope :for_subjects, lambda { |subject_ids|
51
+ where(WhereIsWaldo.config.subject_column => subject_ids)
52
+ }
53
+
54
+ scope :visible_tab, -> { where(tab_visible: true) }
55
+ scope :active, -> { where(subject_active: true) }
56
+
57
+ # Convert to hash for API responses
58
+ def as_presence_hash
59
+ config = WhereIsWaldo.config
60
+
61
+ {
62
+ session_id: self[config.session_column],
63
+ subject_id: self[config.subject_column],
64
+ connected_at: connected_at&.iso8601,
65
+ last_heartbeat: last_heartbeat&.iso8601,
66
+ tab_visible: tab_visible,
67
+ subject_active: subject_active,
68
+ last_activity: last_activity&.iso8601,
69
+ metadata: metadata,
70
+ subject: subject_data
71
+ }
72
+ end
73
+
74
+ # Check if this presence is considered online
75
+ def online?(timeout: nil)
76
+ threshold = (timeout || WhereIsWaldo.config.timeout).seconds.ago
77
+ last_heartbeat && last_heartbeat > threshold
78
+ end
79
+
80
+ private
81
+
82
+ def subject_data
83
+ subject_record = try(:subject)
84
+ WhereIsWaldo.config.build_subject_data(subject_record)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ module Adapters
5
+ class BaseAdapter
6
+ # Register a presence
7
+ # @param session_id [String] Unique session identifier
8
+ # @param subject_id [Integer/String] Subject identifier (user, member, etc.)
9
+ # @param metadata [Hash] Additional data
10
+ # @return [Boolean] success
11
+ def connect(session_id:, subject_id:, metadata: {})
12
+ raise NotImplementedError
13
+ end
14
+
15
+ # Remove a presence
16
+ # @param session_id [String] Session identifier (optional if subject_id provided)
17
+ # @param subject_id [Integer/String] Subject identifier (optional)
18
+ # @return [Boolean] success
19
+ def disconnect(session_id: nil, subject_id: nil)
20
+ raise NotImplementedError
21
+ end
22
+
23
+ # Update heartbeat
24
+ # @param session_id [String] Session identifier
25
+ # @param tab_visible [Boolean] Is tab in foreground
26
+ # @param subject_active [Boolean] Recent activity
27
+ # @param metadata [Hash] Additional data
28
+ # @return [Boolean] success
29
+ def heartbeat(session_id:, tab_visible: true, subject_active: true, metadata: {})
30
+ raise NotImplementedError
31
+ end
32
+
33
+ # Get all online subject IDs
34
+ # @param timeout [Integer] Seconds threshold
35
+ # @return [Array<Integer>] Subject IDs
36
+ def online_subject_ids(timeout: nil)
37
+ raise NotImplementedError
38
+ end
39
+
40
+ # Get all sessions for a subject
41
+ # @param subject_id [Integer/String] Subject identifier
42
+ # @return [Array<Hash>] Presence records
43
+ def sessions_for_subject(subject_id)
44
+ raise NotImplementedError
45
+ end
46
+
47
+ # Get session status
48
+ # @param session_id [String] Session identifier
49
+ # @return [Hash, nil] Presence record or nil
50
+ def session_status(session_id)
51
+ raise NotImplementedError
52
+ end
53
+
54
+ # Remove stale records
55
+ # @param timeout [Integer] Seconds threshold
56
+ # @return [Integer] Number removed
57
+ def cleanup(timeout: nil)
58
+ raise NotImplementedError
59
+ end
60
+
61
+ protected
62
+
63
+ def config
64
+ WhereIsWaldo.config
65
+ end
66
+
67
+ def default_timeout
68
+ config.timeout
69
+ end
70
+
71
+ def session_column
72
+ config.session_column
73
+ end
74
+
75
+ def subject_column
76
+ config.subject_column
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ module Adapters
5
+ class DatabaseAdapter < BaseAdapter
6
+ def connect(session_id:, subject_id:, metadata: {})
7
+ now = Time.current
8
+
9
+ attrs = {
10
+ session_column => session_id,
11
+ subject_column => subject_id,
12
+ connected_at: now,
13
+ last_heartbeat: now,
14
+ tab_visible: true,
15
+ subject_active: true,
16
+ last_activity: now,
17
+ metadata: metadata,
18
+ created_at: now,
19
+ updated_at: now
20
+ }
21
+
22
+ # rubocop:disable Rails/SkipsModelValidations -- intentional for performance
23
+ Presence.upsert(attrs, unique_by: session_column)
24
+ # rubocop:enable Rails/SkipsModelValidations
25
+ true
26
+ rescue StandardError => e
27
+ Rails.logger.error "[WhereIsWaldo] Connect failed: #{e.message}"
28
+ false
29
+ end
30
+
31
+ def disconnect(session_id: nil, subject_id: nil)
32
+ scope = build_lookup_scope(session_id: session_id, subject_id: subject_id)
33
+ scope.delete_all
34
+ true
35
+ rescue StandardError => e
36
+ Rails.logger.error "[WhereIsWaldo] Disconnect failed: #{e.message}"
37
+ false
38
+ end
39
+
40
+ def heartbeat(session_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
41
+ now = Time.current
42
+ updates = {
43
+ last_heartbeat: now,
44
+ tab_visible: tab_visible,
45
+ subject_active: subject_active,
46
+ updated_at: now
47
+ }
48
+ # Update last_activity: use JS timestamp if provided, otherwise use current time when active
49
+ if last_activity_at
50
+ updates[:last_activity] = Time.zone.at(last_activity_at / 1000.0)
51
+ elsif subject_active
52
+ updates[:last_activity] = now
53
+ end
54
+ updates[:metadata] = metadata if metadata.present?
55
+
56
+ scope = Presence.where(session_column => session_id)
57
+
58
+ # rubocop:disable Rails/SkipsModelValidations -- intentional for performance
59
+ scope.update_all(updates).positive?
60
+ # rubocop:enable Rails/SkipsModelValidations
61
+ rescue StandardError => e
62
+ Rails.logger.error "[WhereIsWaldo] Heartbeat failed: #{e.message}"
63
+ false
64
+ end
65
+
66
+ def online_subject_ids(timeout: nil)
67
+ threshold = (timeout || default_timeout).seconds.ago
68
+
69
+ Presence.where("last_heartbeat > ?", threshold)
70
+ .distinct
71
+ .pluck(subject_column)
72
+ end
73
+
74
+ def sessions_for_subject(subject_id)
75
+ scope = Presence.where(subject_column => subject_id)
76
+ scope = scope.includes(:subject) if config.subject_class_constant
77
+ scope.map(&:as_presence_hash)
78
+ end
79
+
80
+ def session_status(session_id)
81
+ scope = Presence.where(session_column => session_id)
82
+ scope = scope.includes(:subject) if config.subject_class_constant
83
+ scope.first&.as_presence_hash
84
+ end
85
+
86
+ def cleanup(timeout: nil)
87
+ threshold = (timeout || default_timeout).seconds.ago
88
+ Presence.where(last_heartbeat: ...threshold).delete_all
89
+ end
90
+
91
+ private
92
+
93
+ def build_lookup_scope(session_id: nil, subject_id: nil)
94
+ if session_id
95
+ Presence.where(session_column => session_id)
96
+ elsif subject_id
97
+ Presence.where(subject_column => subject_id)
98
+ else
99
+ Presence.none
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end