jumbotron 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 (161) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +5 -0
  4. data/app/adapters/jumbotron/adapters/base.rb +136 -0
  5. data/app/adapters/jumbotron/adapters/cadence.rb +42 -0
  6. data/app/adapters/jumbotron/adapters/discovery_definition.rb +7 -0
  7. data/app/adapters/jumbotron/adapters/espn/nfl.rb +169 -0
  8. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/acquisition_scope.rb +29 -0
  9. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/competition_odds.rb +185 -0
  10. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/lifecycle.rb +30 -0
  11. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/line_acquisition_scope.rb +26 -0
  12. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/progress.rb +54 -0
  13. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/scoreboard.rb +146 -0
  14. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/season_phase.rb +33 -0
  15. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/teams.rb +35 -0
  16. data/app/adapters/jumbotron/adapters/line_acquisition.rb +19 -0
  17. data/app/adapters/jumbotron/adapters/operation.rb +23 -0
  18. data/app/adapters/jumbotron/adapters/policy_definition.rb +14 -0
  19. data/app/adapters/jumbotron/adapters/registry.rb +78 -0
  20. data/app/adapters/jumbotron/adapters.rb +11 -0
  21. data/app/boot/jumbotron/boot/enqueue_discoveries.rb +41 -0
  22. data/app/clients/jumbotron/clients/espn/competition_odds/get.rb +26 -0
  23. data/app/clients/jumbotron/clients/espn/competition_odds/get_input.rb +16 -0
  24. data/app/clients/jumbotron/clients/espn/scoreboard/get.rb +18 -0
  25. data/app/clients/jumbotron/clients/espn/scoreboard/get_input.rb +17 -0
  26. data/app/clients/jumbotron/clients/espn/teams/get.rb +18 -0
  27. data/app/clients/jumbotron/clients/espn/teams/get_input.rb +15 -0
  28. data/app/clients/jumbotron/clients/espn.rb +44 -0
  29. data/app/clients/jumbotron/clients.rb +8 -0
  30. data/app/deserializers/jumbotron/deserializers/clients/espn/competition.rb +79 -0
  31. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/draw_odds.rb +31 -0
  32. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/get.rb +66 -0
  33. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/item.rb +60 -0
  34. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/listed_provider.rb +31 -0
  35. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/market_window.rb +31 -0
  36. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/point_spread.rb +27 -0
  37. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/price.rb +35 -0
  38. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/side_quote.rb +35 -0
  39. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/team_odds.rb +40 -0
  40. data/app/deserializers/jumbotron/deserializers/clients/espn/competitor.rb +49 -0
  41. data/app/deserializers/jumbotron/deserializers/clients/espn/event.rb +81 -0
  42. data/app/deserializers/jumbotron/deserializers/clients/espn/scoreboard/get.rb +88 -0
  43. data/app/deserializers/jumbotron/deserializers/clients/espn/season.rb +27 -0
  44. data/app/deserializers/jumbotron/deserializers/clients/espn/status.rb +69 -0
  45. data/app/deserializers/jumbotron/deserializers/clients/espn/support.rb +61 -0
  46. data/app/deserializers/jumbotron/deserializers/clients/espn/team.rb +42 -0
  47. data/app/deserializers/jumbotron/deserializers/clients/espn/teams/get.rb +76 -0
  48. data/app/deserializers/jumbotron/deserializers/clients/espn/venue.rb +34 -0
  49. data/app/deserializers/jumbotron/deserializers/clients/espn/week.rb +23 -0
  50. data/app/deserializers/jumbotron/deserializers/public/game.rb +26 -0
  51. data/app/deserializers/jumbotron/deserializers/public/game_ids.rb +28 -0
  52. data/app/deserializers/jumbotron/deserializers/public/includes.rb +32 -0
  53. data/app/deserializers/jumbotron/deserializers/public/schedule.rb +151 -0
  54. data/app/deserializers/jumbotron/deserializers/public/schedule_groups.rb +91 -0
  55. data/app/errors/jumbotron/error.rb +6 -0
  56. data/app/errors/jumbotron/errors/adapters.rb +143 -0
  57. data/app/errors/jumbotron/errors/canonical.rb +67 -0
  58. data/app/errors/jumbotron/errors/public.rb +27 -0
  59. data/app/errors/jumbotron/invalid_request_error.rb +6 -0
  60. data/app/errors/jumbotron/not_found_error.rb +6 -0
  61. data/app/errors/jumbotron/unsupported_capability_error.rb +6 -0
  62. data/app/jobs/jumbotron/application_job.rb +6 -0
  63. data/app/jobs/jumbotron/discovery_job.rb +14 -0
  64. data/app/jobs/jumbotron/game_update_job.rb +12 -0
  65. data/app/jobs/jumbotron/line_update_job.rb +12 -0
  66. data/app/models/jumbotron/application_record.rb +8 -0
  67. data/app/models/jumbotron/bookmaker.rb +11 -0
  68. data/app/models/jumbotron/canonical.rb +128 -0
  69. data/app/models/jumbotron/game.rb +92 -0
  70. data/app/models/jumbotron/game_participant.rb +10 -0
  71. data/app/models/jumbotron/historical_change.rb +9 -0
  72. data/app/models/jumbotron/league.rb +12 -0
  73. data/app/models/jumbotron/line_observation.rb +18 -0
  74. data/app/models/jumbotron/observation_batch.rb +14 -0
  75. data/app/models/jumbotron/provider_identity.rb +32 -0
  76. data/app/models/jumbotron/public/translate_outcome.rb +57 -0
  77. data/app/models/jumbotron/public.rb +88 -0
  78. data/app/models/jumbotron/schedule_group.rb +22 -0
  79. data/app/models/jumbotron/season.rb +13 -0
  80. data/app/models/jumbotron/season_phase.rb +12 -0
  81. data/app/models/jumbotron/sport.rb +10 -0
  82. data/app/models/jumbotron/team.rb +11 -0
  83. data/app/models/jumbotron/venue.rb +10 -0
  84. data/app/providers/jumbotron/providers/espn/cooldown.rb +88 -0
  85. data/app/providers/jumbotron/providers/espn/cooldown_active_error.rb +13 -0
  86. data/app/providers/jumbotron/providers/espn/provider_failure.rb +40 -0
  87. data/app/providers/jumbotron/providers/espn/resources/competition_odds.rb +29 -0
  88. data/app/providers/jumbotron/providers/espn/resources/scoreboard.rb +90 -0
  89. data/app/providers/jumbotron/providers/espn/resources/teams.rb +19 -0
  90. data/app/scheduling/jumbotron/scheduling/backends/sidekiq_cron.rb +70 -0
  91. data/app/scheduling/jumbotron/scheduling/backends/solid_queue.rb +92 -0
  92. data/app/scheduling/jumbotron/scheduling/clock.rb +61 -0
  93. data/app/scheduling/jumbotron/scheduling/desired_schedules.rb +51 -0
  94. data/app/scheduling/jumbotron/scheduling/detect_integration.rb +45 -0
  95. data/app/scheduling/jumbotron/scheduling/materializer.rb +13 -0
  96. data/app/scheduling/jumbotron/scheduling/owned.rb +19 -0
  97. data/app/scheduling/jumbotron/scheduling/schedule_definition.rb +7 -0
  98. data/app/serializers/jumbotron/serializers/clients/espn/competition_odds/get.rb +17 -0
  99. data/app/serializers/jumbotron/serializers/clients/espn/scoreboard/get.rb +22 -0
  100. data/app/serializers/jumbotron/serializers/clients/espn/teams/get.rb +20 -0
  101. data/app/services/jumbotron/services/adapters/build_acquisition_scopes.rb +37 -0
  102. data/app/services/jumbotron/services/adapters/build_line_acquisition_scopes.rb +39 -0
  103. data/app/services/jumbotron/services/adapters/ensure_league.rb +29 -0
  104. data/app/services/jumbotron/services/adapters/execute_endpoint.rb +49 -0
  105. data/app/services/jumbotron/services/adapters/execute_line_acquisition_scope.rb +75 -0
  106. data/app/services/jumbotron/services/adapters/execute_synchronization_scope.rb +64 -0
  107. data/app/services/jumbotron/services/adapters/resolve_discovery.rb +53 -0
  108. data/app/services/jumbotron/services/adapters/resolve_game_update_policy.rb +74 -0
  109. data/app/services/jumbotron/services/adapters/resolve_line_update_policy.rb +74 -0
  110. data/app/services/jumbotron/services/adapters/select_eligible_games.rb +41 -0
  111. data/app/services/jumbotron/services/adapters/validate_sync_request.rb +51 -0
  112. data/app/services/jumbotron/services/canonical/attach_provider_identities.rb +59 -0
  113. data/app/services/jumbotron/services/canonical/derive_consensus_for_games.rb +18 -0
  114. data/app/services/jumbotron/services/canonical/derive_consensus_line.rb +70 -0
  115. data/app/services/jumbotron/services/canonical/ensure_bookmaker.rb +70 -0
  116. data/app/services/jumbotron/services/canonical/ensure_schedule_group.rb +40 -0
  117. data/app/services/jumbotron/services/canonical/ensure_season.rb +22 -0
  118. data/app/services/jumbotron/services/canonical/ensure_season_phase.rb +22 -0
  119. data/app/services/jumbotron/services/canonical/persist_line_observations.rb +145 -0
  120. data/app/services/jumbotron/services/canonical/record_observation_evidence.rb +51 -0
  121. data/app/services/jumbotron/services/canonical/resolve_current_lines.rb +21 -0
  122. data/app/services/jumbotron/services/canonical/resolve_current_lines_for_games.rb +69 -0
  123. data/app/services/jumbotron/services/canonical/upsert_game.rb +186 -0
  124. data/app/services/jumbotron/services/canonical/upsert_game_participant.rb +88 -0
  125. data/app/services/jumbotron/services/canonical/upsert_team.rb +90 -0
  126. data/app/services/jumbotron/services/canonical/upsert_venue.rb +92 -0
  127. data/app/services/jumbotron/services/public/assemble_public_game.rb +131 -0
  128. data/app/services/jumbotron/services/public/assemble_public_schedule.rb +63 -0
  129. data/app/services/jumbotron/services/public/derive_consensus_for_games.rb +21 -0
  130. data/app/services/jumbotron/services/public/load_games_by_ids.rb +18 -0
  131. data/app/services/jumbotron/services/public/load_included_lines.rb +47 -0
  132. data/app/services/jumbotron/services/public/map_public_consensus_lines.rb +44 -0
  133. data/app/services/jumbotron/services/public/map_public_current_lines.rb +27 -0
  134. data/app/services/jumbotron/services/public/read_game.rb +32 -0
  135. data/app/services/jumbotron/services/public/read_schedule.rb +116 -0
  136. data/app/services/jumbotron/services/public/read_schedule_groups.rb +90 -0
  137. data/app/services/jumbotron/services/public/resolve_current_lines_for_games.rb +21 -0
  138. data/app/synchronization/jumbotron/synchronization/lease.rb +127 -0
  139. data/app/workflows/jumbotron/workflows/execute_discovery_workflow.rb +131 -0
  140. data/app/workflows/jumbotron/workflows/execute_game_update_policy_workflow.rb +159 -0
  141. data/app/workflows/jumbotron/workflows/execute_line_update_policy_workflow.rb +156 -0
  142. data/app/workflows/jumbotron/workflows/public_read.rb +29 -0
  143. data/app/workflows/jumbotron/workflows/read_consensus_workflow.rb +37 -0
  144. data/app/workflows/jumbotron/workflows/read_current_lines_workflow.rb +27 -0
  145. data/app/workflows/jumbotron/workflows/read_game_workflow.rb +34 -0
  146. data/app/workflows/jumbotron/workflows/read_schedule_groups_workflow.rb +21 -0
  147. data/app/workflows/jumbotron/workflows/read_schedule_workflow.rb +48 -0
  148. data/app/workflows/jumbotron/workflows/synchronize_adapter_workflow.rb +183 -0
  149. data/db/migrate/20260812000001_create_jumbotron_foundational_catalog.rb +68 -0
  150. data/db/migrate/20260812000002_create_jumbotron_game_graph.rb +70 -0
  151. data/db/migrate/20260812000003_add_freshness_to_jumbotron_provider_mutated_entities.rb +31 -0
  152. data/db/migrate/20260812000004_create_jumbotron_observation_evidence.rb +34 -0
  153. data/db/migrate/20260813000001_create_jumbotron_schedule_groups.rb +24 -0
  154. data/db/migrate/20260814000001_create_jumbotron_line_observations.rb +38 -0
  155. data/db/migrate/20260820000001_add_progress_to_jumbotron_games.rb +14 -0
  156. data/lib/jumbotron/client.rb +40 -0
  157. data/lib/jumbotron/engine.rb +32 -0
  158. data/lib/jumbotron/version.rb +5 -0
  159. data/lib/jumbotron.rb +13 -0
  160. data/lib/tasks/jumbotron_schedules.rake +12 -0
  161. metadata +239 -0
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Adapters
5
+ module Espn
6
+ module NflHelpers
7
+ module Progress
8
+ NIL_LIFECYCLES = %w[scheduled postponed cancelled].freeze
9
+ INTERMISSION_PATTERN = /halftime/i
10
+
11
+ module_function
12
+
13
+ def call(status, lifecycle:)
14
+ return if NIL_LIFECYCLES.include?(lifecycle.to_s)
15
+ return quarter_intermission if intermission?(status)
16
+
17
+ period = status.period
18
+ return if period.nil? || period < 1
19
+
20
+ kind = period <= 4 ? "quarter" : "overtime"
21
+ number = period <= 4 ? period : period - 4
22
+ build(state: "active", kind: kind, number: number, clock: clock_for(status))
23
+ end
24
+
25
+ def intermission?(status)
26
+ [status.type_name, status.type_description, status.type_detail, status.type_short_detail]
27
+ .compact
28
+ .any? { |value| INTERMISSION_PATTERN.match?(value) }
29
+ end
30
+
31
+ def quarter_intermission
32
+ build(state: "intermission", kind: "quarter", number: 2, clock: nil)
33
+ end
34
+
35
+ def clock_for(status)
36
+ seconds = status.clock.is_a?(Integer) ? status.clock : nil
37
+ display = status.display_clock.presence
38
+ return if seconds.nil? && display.nil?
39
+
40
+ Canonical::GameClock.new(mode: "remaining", seconds: seconds, display: display)
41
+ end
42
+
43
+ def build(state:, kind:, number:, clock:)
44
+ Canonical::GameProgress.new(
45
+ state: state,
46
+ segment: Canonical::GameSegment.new(kind: kind, number: number),
47
+ clock: clock
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Jumbotron
6
+ module Adapters
7
+ module Espn
8
+ module NflHelpers
9
+ class Scoreboard
10
+ def self.call(board, observed_at:)
11
+ events = board.respond_to?(:events) ? board.events : Array(board)
12
+ Canonical::SyncInput.new(
13
+ observed_at: observed_at,
14
+ games: events.map { |event| transform_event(event) },
15
+ teams: []
16
+ )
17
+ end
18
+
19
+ def self.transform_event(event)
20
+ competition = event.competitions&.first
21
+ raise TransformError, "event #{event.id} missing competition" if competition.nil?
22
+
23
+ status = competition.status || event.status
24
+ raise TransformError, "event #{event.id} missing status" if status.nil?
25
+
26
+ lifecycle = Lifecycle.call(status.type_name)
27
+ season = event.season
28
+ if season.nil? || season.year.nil? || season.type.nil?
29
+ raise TransformError,
30
+ "event #{event.id} missing season"
31
+ end
32
+
33
+ participants = Array(competition.competitors)
34
+ raise TransformError, "event #{event.id} requires exactly 2 competitors" unless participants.length == 2
35
+
36
+ Canonical::GameInput.new(
37
+ provider_identities: identities_for(event, competition),
38
+ scheduled_at: parse_time(event.date.presence || competition.date.presence || competition.start_date),
39
+ lifecycle: lifecycle,
40
+ neutral_site: competition.neutral_site,
41
+ season_year: season.year.to_i,
42
+ season_phase_key: SeasonPhase.call(season.type),
43
+ schedule_group: schedule_group_for(event),
44
+ venue: transform_venue(competition.venue),
45
+ participants: participants.map { |c| transform_participant(c, lifecycle) },
46
+ progress: Progress.call(status, lifecycle: lifecycle)
47
+ )
48
+ end
49
+ private_class_method :transform_event
50
+
51
+ def self.schedule_group_for(event)
52
+ number = event.week&.number
53
+ return if number.nil?
54
+
55
+ Canonical::ScheduleGroupInput.new(
56
+ kind: "week",
57
+ number: number,
58
+ name: "Week #{number}"
59
+ )
60
+ end
61
+ private_class_method :schedule_group_for
62
+
63
+ def self.identities_for(event, competition)
64
+ ids = []
65
+ if event.id.present?
66
+ ids << Canonical::ProviderIdentityRef.new(provider: "espn", namespace: "event", id: event.id.to_s)
67
+ end
68
+ if competition.id.present?
69
+ ids << Canonical::ProviderIdentityRef.new(provider: "espn", namespace: "competition",
70
+ id: competition.id.to_s)
71
+ end
72
+ raise TransformError, "event/competition provider id required" if ids.empty?
73
+
74
+ ids.uniq
75
+ end
76
+ private_class_method :identities_for
77
+
78
+ def self.transform_venue(venue)
79
+ return nil if venue.nil?
80
+ raise TransformError, "venue id required" if venue.id.blank?
81
+ raise TransformError, "venue name required" if venue.full_name.blank?
82
+
83
+ Canonical::VenueInput.new(
84
+ provider_identities: [
85
+ Canonical::ProviderIdentityRef.new(provider: "espn", namespace: "venue", id: venue.id.to_s)
86
+ ],
87
+ name: venue.full_name.to_s
88
+ )
89
+ end
90
+ private_class_method :transform_venue
91
+
92
+ def self.transform_participant(competitor, lifecycle)
93
+ team = competitor.team
94
+ raise TransformError, "competitor missing team" if team.nil?
95
+ raise TransformError, "team id required" if team.id.blank?
96
+
97
+ name = team.display_name.presence || team.name
98
+ raise TransformError, "team name required" if name.blank?
99
+ raise TransformError, "home_away required" if competitor.home_away.blank?
100
+
101
+ Canonical::ParticipantInput.new(
102
+ provider_identities: [
103
+ Canonical::ProviderIdentityRef.new(provider: "espn", namespace: "team", id: team.id.to_s)
104
+ ],
105
+ team_name: name.to_s,
106
+ role: competitor.home_away.to_s,
107
+ score: coerce_score(competitor.score),
108
+ result: coerce_result(competitor.winner, lifecycle)
109
+ )
110
+ end
111
+ private_class_method :transform_participant
112
+
113
+ def self.coerce_score(raw)
114
+ return nil if raw.nil? || raw == ""
115
+
116
+ Integer(raw)
117
+ rescue ArgumentError, TypeError
118
+ raise TransformError, "invalid score: #{raw.inspect}"
119
+ end
120
+ private_class_method :coerce_score
121
+
122
+ def self.coerce_result(winner, lifecycle)
123
+ return nil unless lifecycle == "completed"
124
+ return nil if winner.nil?
125
+
126
+ winner ? "win" : "loss"
127
+ end
128
+ private_class_method :coerce_result
129
+
130
+ def self.parse_time(raw)
131
+ raise TransformError, "scheduled_at missing" if raw.blank?
132
+
133
+ Time.iso8601(raw.to_s)
134
+ rescue ArgumentError, TypeError
135
+ begin
136
+ Time.zone.parse(raw.to_s) || (raise ArgumentError)
137
+ rescue ArgumentError, TypeError
138
+ raise TransformError, "scheduled_at unparseable: #{raw.inspect}"
139
+ end
140
+ end
141
+ private_class_method :parse_time
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Adapters
5
+ module Espn
6
+ module NflHelpers
7
+ module SeasonPhase
8
+ MAP = {
9
+ 1 => "preseason",
10
+ 2 => "regular_season",
11
+ 3 => "postseason",
12
+ 4 => "offseason"
13
+ }.freeze
14
+
15
+ module_function
16
+
17
+ def call(season_type)
18
+ mapped = MAP[season_type.to_i]
19
+ raise TransformError, "unsupported ESPN season.type: #{season_type.inspect}" if mapped.nil?
20
+
21
+ mapped
22
+ end
23
+
24
+ def espn_season_type(phase_key)
25
+ return if phase_key.nil?
26
+
27
+ MAP.key(phase_key.to_s)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Adapters
5
+ module Espn
6
+ module NflHelpers
7
+ class Teams
8
+ def self.call(teams, observed_at:)
9
+ list = Array(teams)
10
+ Canonical::SyncInput.new(
11
+ observed_at: observed_at,
12
+ games: [],
13
+ teams: list.map { |team| transform_team(team) }
14
+ )
15
+ end
16
+
17
+ def self.transform_team(team)
18
+ raise TransformError, "team id is required" if team.id.nil? || team.id.to_s.empty?
19
+
20
+ name = team.display_name.presence || team.name
21
+ raise TransformError, "team name is required for #{team.id}" if name.nil? || name.to_s.empty?
22
+
23
+ Canonical::TeamInput.new(
24
+ provider_identities: [
25
+ Canonical::ProviderIdentityRef.new(provider: "espn", namespace: "team", id: team.id.to_s)
26
+ ],
27
+ name: name.to_s
28
+ )
29
+ end
30
+ private_class_method :transform_team
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Adapters
5
+ module LineAcquisition
6
+ def line_acquisition_scope_for(_game)
7
+ nil
8
+ end
9
+
10
+ def acquire_line_odds(_acquisition)
11
+ raise ConfigurationError, "#{name} must implement acquire_line_odds"
12
+ end
13
+
14
+ def translate_line_odds(*)
15
+ raise ConfigurationError, "#{name} must implement translate_line_odds"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Adapters
5
+ class Operation
6
+ attr_reader :adapter, :resource, :transformer
7
+
8
+ def initialize(adapter:, resource:, transformer:)
9
+ @adapter = adapter
10
+ @resource = resource
11
+ @transformer = transformer
12
+ end
13
+
14
+ def acquire(**query)
15
+ resource.acquire(adapter: adapter, **query)
16
+ end
17
+
18
+ def transform(typed_payload, observed_at:)
19
+ transformer.call(typed_payload, observed_at: observed_at)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Adapters
5
+ # Frozen registry entry for one adapter-declared update policy. Not a Policy base class.
6
+ PolicyDefinition = Data.define(:id, :type, :cadence, :eligible) do
7
+ def eligible?(game, now:)
8
+ raise ::Jumbotron::Adapters::ConfigurationError, "policy #{id} has no eligible predicate" if eligible.nil?
9
+
10
+ eligible.call(game, now: now)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Jumbotron
6
+ module Adapters
7
+ module Registry
8
+ module_function
9
+
10
+ # Stores the adapter class reference (not a config snapshot).
11
+ # Top-of-class register! is safe: identity is the class, not registry_key.
12
+ def register(adapter_class)
13
+ classes[adapter_class.name] = adapter_class
14
+ adapter_class
15
+ end
16
+
17
+ def registered
18
+ classes.values
19
+ end
20
+
21
+ def registered?(adapter_class)
22
+ classes[adapter_class.name] == adapter_class
23
+ end
24
+
25
+ # Adapter classes register via load-time `register!`. In non-eager-load
26
+ # processes (e.g. Solid Queue workers), nothing may have constantized them
27
+ # yet when jobs resolve by adapter_id string — load them first.
28
+ # If classes are already loaded but the in-memory registry was cleared,
29
+ # re-register Base subclasses (constantize alone does not re-run file side effects).
30
+ def ensure_loaded!
31
+ Dir[Jumbotron::Engine.root.join("app/adapters/jumbotron/adapters/**/*.rb")].each do |path|
32
+ relative = Pathname.new(path).relative_path_from(Jumbotron::Engine.root.join("app/adapters")).to_s
33
+ klass = relative.chomp(".rb").camelize.constantize
34
+ next unless klass.is_a?(Class)
35
+ next unless klass < Base
36
+ next if klass == Base
37
+
38
+ klass.register! unless registered?(klass)
39
+ end
40
+ end
41
+
42
+ def find(adapter_id)
43
+ id = adapter_id.to_s
44
+ registered.find do |adapter|
45
+ adapter.adapter_id == id
46
+ rescue ConfigurationError
47
+ false
48
+ end
49
+ end
50
+
51
+ def reset!
52
+ @classes = {}
53
+ end
54
+
55
+ # Raises if two different registered classes share the same registry_key.
56
+ def assert_unique_keys!
57
+ grouped = registered.group_by do |adapter|
58
+ adapter.registry_key
59
+ rescue ConfigurationError
60
+ :incomplete
61
+ end
62
+ grouped.each do |key, adapters|
63
+ next if key == :incomplete
64
+ next if adapters.uniq.size <= 1
65
+
66
+ raise RegistrationError,
67
+ "conflicting adapter registration for #{key.inspect}: " \
68
+ "#{adapters.map(&:name).join(' vs ')}"
69
+ end
70
+ end
71
+
72
+ def classes
73
+ @classes ||= {}
74
+ end
75
+ private_class_method :classes
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Adapters
5
+ class RegistrationError < StandardError; end
6
+ class TransformError < StandardError; end
7
+ class ConfigurationError < StandardError; end
8
+ class UnknownPolicyError < StandardError; end
9
+ class UnknownDiscoveryError < StandardError; end
10
+ end
11
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Boot
5
+ module EnqueueDiscoveries
6
+ module_function
7
+
8
+ def call
9
+ return unless eligible?
10
+
11
+ enqueue!
12
+ end
13
+
14
+ def eligible?
15
+ return false unless Jumbotron::Engine.config.boot_discovery
16
+ return false if Rails.env.test?
17
+ return false if defined?(Rails::Console)
18
+ return false if rake_environment?
19
+
20
+ true
21
+ end
22
+
23
+ def enqueue!
24
+ Jumbotron::Scheduling::DesiredSchedules.ensure_adapters_loaded!
25
+ Jumbotron::Adapters::Registry.registered.each do |adapter|
26
+ adapter.discovery_ids.each do |discovery_id|
27
+ Jumbotron::DiscoveryJob.perform_later(
28
+ adapter_id: adapter.adapter_id,
29
+ discovery_id: discovery_id.to_s,
30
+ continuation_attempt: 1
31
+ )
32
+ end
33
+ end
34
+ end
35
+
36
+ def rake_environment?
37
+ defined?(Rake) && Rake.respond_to?(:application) && Array(Rake.application.top_level_tasks).any?
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Clients
5
+ class Espn
6
+ module CompetitionOdds
7
+ class Get < CommandTower::Clients::EndpointBase
8
+ authentication :none
9
+ http_method :get
10
+ path do |input|
11
+ relative = [
12
+ "sports", input.sport, "leagues", input.league,
13
+ "events", input.event_id,
14
+ "competitions", input.competition_id,
15
+ "odds"
16
+ ].join("/")
17
+ CommandTower::Clients::Url.join(Jumbotron::Clients::Espn.core_base_url, relative)
18
+ end
19
+ input GetInput
20
+ request_serializer ::Jumbotron::Serializers::Clients::Espn::CompetitionOdds::Get
21
+ response_deserializer ::Jumbotron::Deserializers::Clients::Espn::CompetitionOdds::Get
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Clients
5
+ class Espn
6
+ module CompetitionOdds
7
+ class GetInput < CommandTower::Clients::EndpointInput
8
+ attribute :sport, :string
9
+ attribute :league, :string
10
+ attribute :event_id, :string
11
+ attribute :competition_id, :string
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Clients
5
+ class Espn
6
+ module Scoreboard
7
+ class Get < CommandTower::Clients::EndpointBase
8
+ authentication :none
9
+ http_method :get
10
+ path { |input| "#{input.sport}/#{input.league}/scoreboard" }
11
+ input GetInput
12
+ request_serializer ::Jumbotron::Serializers::Clients::Espn::Scoreboard::Get
13
+ response_deserializer ::Jumbotron::Deserializers::Clients::Espn::Scoreboard::Get
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Clients
5
+ class Espn
6
+ module Scoreboard
7
+ class GetInput < CommandTower::Clients::EndpointInput
8
+ attribute :sport, :string
9
+ attribute :league, :string
10
+ attribute :season_type
11
+ attribute :week
12
+ attribute :dates
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Clients
5
+ class Espn
6
+ module Teams
7
+ class Get < CommandTower::Clients::EndpointBase
8
+ authentication :none
9
+ http_method :get
10
+ path { |input| "#{input.sport}/#{input.league}/teams" }
11
+ input GetInput
12
+ request_serializer ::Jumbotron::Serializers::Clients::Espn::Teams::Get
13
+ response_deserializer ::Jumbotron::Deserializers::Clients::Espn::Teams::Get
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Clients
5
+ class Espn
6
+ module Teams
7
+ class GetInput < CommandTower::Clients::EndpointInput
8
+ attribute :sport, :string
9
+ attribute :league, :string
10
+ attribute :limit
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Jumbotron
6
+ module Clients
7
+ class Espn < CommandTower::Clients::ClientBase
8
+ DEFAULT_BASE_URL = "https://site.api.espn.com/apis/site/v2/sports"
9
+ DEFAULT_CORE_BASE_URL = "https://sports.core.api.espn.com/v2"
10
+
11
+ def self.core_base_url
12
+ ENV.fetch("JUMBOTRON_ESPN_CORE_BASE_URL", DEFAULT_CORE_BASE_URL)
13
+ end
14
+
15
+ def decode_response(response)
16
+ CommandTower::Clients::DecodedResponse.new(
17
+ payload: parse_json(response.body),
18
+ provider_metadata: {}
19
+ )
20
+ end
21
+
22
+ protected
23
+
24
+ def base_url
25
+ ENV.fetch("JUMBOTRON_ESPN_SITE_BASE_URL", DEFAULT_BASE_URL)
26
+ end
27
+
28
+ def default_headers
29
+ { "Accept" => "application/json" }
30
+ end
31
+
32
+ private
33
+
34
+ def parse_json(body)
35
+ JSON.parse(body.to_s)
36
+ rescue JSON::ParserError => e
37
+ raise CommandTower::Clients::Errors::DeserializationError.new(
38
+ message: "ESPN response body is not valid JSON: #{e.message}",
39
+ details: { path: "", expected: "JSON object", actual: "invalid_json", rule: "type", messages: [e.message] }
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ # Product invocation: Jumbotron::Clients.espn.scoreboard.get(...)
5
+ module Clients
6
+ extend CommandTower::Clients::Invocation
7
+ end
8
+ end