cricinfo-cli-go 0.1.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.
Files changed (121) hide show
  1. package/AGENTS.md +63 -0
  2. package/CONTRIBUTORS.md +75 -0
  3. package/LICENSE +21 -0
  4. package/Makefile +131 -0
  5. package/README.md +130 -0
  6. package/bin/cricinfo.js +44 -0
  7. package/cmd/cricinfo/main.go +15 -0
  8. package/go.mod +10 -0
  9. package/go.sum +10 -0
  10. package/internal/app/app.go +11 -0
  11. package/internal/app/app_test.go +122 -0
  12. package/internal/buildinfo/buildinfo.go +16 -0
  13. package/internal/cli/analysis.go +262 -0
  14. package/internal/cli/analysis_test.go +175 -0
  15. package/internal/cli/competitions.go +154 -0
  16. package/internal/cli/competitions_test.go +165 -0
  17. package/internal/cli/leagues.go +297 -0
  18. package/internal/cli/leagues_test.go +194 -0
  19. package/internal/cli/matches.go +403 -0
  20. package/internal/cli/matches_test.go +413 -0
  21. package/internal/cli/players.go +263 -0
  22. package/internal/cli/players_test.go +384 -0
  23. package/internal/cli/root.go +141 -0
  24. package/internal/cli/search.go +119 -0
  25. package/internal/cli/teams.go +214 -0
  26. package/internal/cli/teams_test.go +192 -0
  27. package/internal/cricinfo/analysis.go +1401 -0
  28. package/internal/cricinfo/analysis_phase15_test.go +267 -0
  29. package/internal/cricinfo/client.go +471 -0
  30. package/internal/cricinfo/client_test.go +280 -0
  31. package/internal/cricinfo/cmd/fixture-refresh/main.go +145 -0
  32. package/internal/cricinfo/competitions.go +405 -0
  33. package/internal/cricinfo/competitions_phase13_test.go +234 -0
  34. package/internal/cricinfo/coverage_ledger.go +122 -0
  35. package/internal/cricinfo/coverage_ledger_test.go +253 -0
  36. package/internal/cricinfo/decode.go +115 -0
  37. package/internal/cricinfo/decode_test.go +100 -0
  38. package/internal/cricinfo/entity_index.go +618 -0
  39. package/internal/cricinfo/entity_index_test.go +175 -0
  40. package/internal/cricinfo/fixture_matrix.go +243 -0
  41. package/internal/cricinfo/fixture_matrix_test.go +49 -0
  42. package/internal/cricinfo/fixtures_test.go +264 -0
  43. package/internal/cricinfo/historical_hydration.go +1641 -0
  44. package/internal/cricinfo/historical_phase14_test.go +542 -0
  45. package/internal/cricinfo/leagues.go +1210 -0
  46. package/internal/cricinfo/leagues_phase12_test.go +324 -0
  47. package/internal/cricinfo/live_leagues_test.go +169 -0
  48. package/internal/cricinfo/live_matches_test.go +203 -0
  49. package/internal/cricinfo/live_matrix_test.go +118 -0
  50. package/internal/cricinfo/live_players_test.go +122 -0
  51. package/internal/cricinfo/live_search_test.go +86 -0
  52. package/internal/cricinfo/live_smoke_test.go +213 -0
  53. package/internal/cricinfo/live_teams_test.go +104 -0
  54. package/internal/cricinfo/matches.go +1508 -0
  55. package/internal/cricinfo/matches_phase7_test.go +207 -0
  56. package/internal/cricinfo/matches_phase9_test.go +253 -0
  57. package/internal/cricinfo/normalize_entities.go +1727 -0
  58. package/internal/cricinfo/normalize_leagues.go +346 -0
  59. package/internal/cricinfo/players.go +1332 -0
  60. package/internal/cricinfo/players_phase10_test.go +174 -0
  61. package/internal/cricinfo/players_phase11_test.go +373 -0
  62. package/internal/cricinfo/render_contract.go +1088 -0
  63. package/internal/cricinfo/render_phase4_test.go +633 -0
  64. package/internal/cricinfo/renderer.go +1689 -0
  65. package/internal/cricinfo/resolver.go +813 -0
  66. package/internal/cricinfo/resolver_test.go +244 -0
  67. package/internal/cricinfo/teams.go +603 -0
  68. package/internal/cricinfo/teams_phase8_test.go +231 -0
  69. package/internal/cricinfo/testdata/fixtures/README.md +43 -0
  70. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/broadcasts.json +11 -0
  71. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/officials.json +150 -0
  72. package/internal/cricinfo/testdata/fixtures/details-plays/detail-110.json +157 -0
  73. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52545007.json +145 -0
  74. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52559021.json +143 -0
  75. package/internal/cricinfo/testdata/fixtures/details-plays/plays.json +15 -0
  76. package/internal/cricinfo/testdata/fixtures/endpoint-matrix.tsv +19 -0
  77. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow-1.json +12 -0
  78. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow.json +42 -0
  79. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/innings-1-2.json +38 -0
  80. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnership-1.json +31 -0
  81. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnerships.json +42 -0
  82. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-offdays.json +20 -0
  83. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-ondays.json +21 -0
  84. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar.json +14 -0
  85. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-2025.json +13 -0
  86. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-group-1.json +13 -0
  87. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-groups.json +11 -0
  88. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-type-1.json +13 -0
  89. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-types.json +11 -0
  90. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/seasons.json +30 -0
  91. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-item-1.json +72 -0
  92. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-root.json +3 -0
  93. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings.json +15 -0
  94. package/internal/cricinfo/testdata/fixtures/matches-competitions/competition.json +460 -0
  95. package/internal/cricinfo/testdata/fixtures/matches-competitions/event-1529474.json +86 -0
  96. package/internal/cricinfo/testdata/fixtures/matches-competitions/matchcards-1527966.json +368 -0
  97. package/internal/cricinfo/testdata/fixtures/matches-competitions/situation-1529474.json +10 -0
  98. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257-statistics.json +126 -0
  99. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257.json +113 -0
  100. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-1-statistics-0.json +208 -0
  101. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-2-statistics-0.json +252 -0
  102. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores.json +74 -0
  103. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-statistics-0.json +1008 -0
  104. package/internal/cricinfo/testdata/fixtures/root-discovery/events.json +72 -0
  105. package/internal/cricinfo/testdata/fixtures/root-discovery/root.json +28 -0
  106. package/internal/cricinfo/testdata/fixtures/team-competitor/competitor-789643.json +40 -0
  107. package/internal/cricinfo/testdata/fixtures/team-competitor/leaders-789643.json +353 -0
  108. package/internal/cricinfo/testdata/fixtures/team-competitor/records-789643.json +91 -0
  109. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772-object.json +231 -0
  110. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772.json +235 -0
  111. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-789643.json +322 -0
  112. package/internal/cricinfo/testdata/fixtures/team-competitor/scores-789643.json +19 -0
  113. package/internal/cricinfo/testdata/fixtures/team-competitor/statistics-789643.json +629 -0
  114. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643-athletes.json +7 -0
  115. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643.json +67 -0
  116. package/internal/cricinfo/testdata/golden/match-empty.golden +1 -0
  117. package/internal/cricinfo/testdata/golden/match-list.golden +2 -0
  118. package/internal/cricinfo/testdata/golden/match-partial.golden +3 -0
  119. package/internal/cricinfo/types.go +54 -0
  120. package/package.json +51 -0
  121. package/scripts/postinstall.js +153 -0
@@ -0,0 +1,813 @@
1
+ package cricinfo
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "strconv"
7
+ "strings"
8
+ "time"
9
+ )
10
+
11
+ const (
12
+ defaultResolverEventSeedTTL = 5 * time.Minute
13
+ defaultResolverMaxEventSeeds = 24
14
+ )
15
+
16
+ // ResolverConfig controls entity resolution behavior.
17
+ type ResolverConfig struct {
18
+ Client *Client
19
+ Index *EntityIndex
20
+ IndexPath string
21
+ EventSeedTTL time.Duration
22
+ MaxEventSeed int
23
+ Now func() time.Time
24
+ }
25
+
26
+ // ResolveOptions controls a search invocation.
27
+ type ResolveOptions struct {
28
+ Limit int
29
+ LeagueID string
30
+ MatchID string
31
+ LeagueRef string
32
+ SeasonRef string
33
+ }
34
+
35
+ // SearchResult is a resolver search output with warnings.
36
+ type SearchResult struct {
37
+ Entities []IndexedEntity
38
+ Warnings []string
39
+ }
40
+
41
+ // Resolver resolves players/teams/leagues/matches from ids, refs, and aliases.
42
+ type Resolver struct {
43
+ client *Client
44
+ index *EntityIndex
45
+
46
+ now func() time.Time
47
+ eventSeedTTL time.Duration
48
+ maxEventSeed int
49
+ }
50
+
51
+ // NewResolver creates a resolver with default transport and cache if omitted.
52
+ func NewResolver(cfg ResolverConfig) (*Resolver, error) {
53
+ client := cfg.Client
54
+ if client == nil {
55
+ newClient, err := NewClient(Config{})
56
+ if err != nil {
57
+ return nil, err
58
+ }
59
+ client = newClient
60
+ }
61
+
62
+ index := cfg.Index
63
+ if index == nil {
64
+ opened, err := OpenEntityIndex(cfg.IndexPath)
65
+ if err != nil {
66
+ return nil, err
67
+ }
68
+ index = opened
69
+ }
70
+
71
+ now := cfg.Now
72
+ if now == nil {
73
+ now = func() time.Time { return time.Now().UTC() }
74
+ }
75
+
76
+ eventSeedTTL := cfg.EventSeedTTL
77
+ if eventSeedTTL <= 0 {
78
+ eventSeedTTL = defaultResolverEventSeedTTL
79
+ }
80
+
81
+ maxEventSeed := cfg.MaxEventSeed
82
+ if maxEventSeed <= 0 {
83
+ maxEventSeed = defaultResolverMaxEventSeeds
84
+ }
85
+
86
+ return &Resolver{
87
+ client: client,
88
+ index: index,
89
+ now: now,
90
+ eventSeedTTL: eventSeedTTL,
91
+ maxEventSeed: maxEventSeed,
92
+ }, nil
93
+ }
94
+
95
+ // Close persists the resolver cache to disk.
96
+ func (r *Resolver) Close() error {
97
+ return r.index.Persist()
98
+ }
99
+
100
+ // Search resolves entities for a search command.
101
+ func (r *Resolver) Search(ctx context.Context, kind EntityKind, query string, opts ResolveOptions) (SearchResult, error) {
102
+ query = strings.TrimSpace(query)
103
+ warnings := make([]string, 0)
104
+
105
+ if err := r.seedContext(ctx, opts); err != nil {
106
+ warnings = append(warnings, err.Error())
107
+ }
108
+ if err := r.seedFromEvents(ctx); err != nil {
109
+ warnings = append(warnings, err.Error())
110
+ }
111
+
112
+ if query != "" {
113
+ if isKnownRefQuery(query) {
114
+ if err := r.seedKnownRef(ctx, kind, query, opts); err != nil {
115
+ warnings = append(warnings, err.Error())
116
+ }
117
+ }
118
+ if isNumeric(query) {
119
+ if err := r.seedNumericID(ctx, kind, query, opts); err != nil {
120
+ warnings = append(warnings, err.Error())
121
+ }
122
+ }
123
+ }
124
+
125
+ limit := opts.Limit
126
+ if limit <= 0 {
127
+ limit = 10
128
+ }
129
+
130
+ entities := r.index.Search(kind, query, limit, SearchContext{
131
+ PreferredLeagueID: strings.TrimSpace(opts.LeagueID),
132
+ PreferredMatchID: strings.TrimSpace(opts.MatchID),
133
+ })
134
+
135
+ _ = r.index.Persist()
136
+
137
+ return SearchResult{Entities: entities, Warnings: compactWarnings(warnings)}, nil
138
+ }
139
+
140
+ func (r *Resolver) seedContext(ctx context.Context, opts ResolveOptions) error {
141
+ errs := make([]string, 0)
142
+
143
+ if leagueID := strings.TrimSpace(opts.LeagueID); leagueID != "" {
144
+ if err := r.seedLeagueByID(ctx, leagueID); err != nil {
145
+ errs = append(errs, fmt.Sprintf("league context seed failed for %s: %v", leagueID, err))
146
+ }
147
+ }
148
+ if leagueRef := strings.TrimSpace(opts.LeagueRef); leagueRef != "" {
149
+ if err := r.seedLeagueRef(ctx, leagueRef); err != nil {
150
+ errs = append(errs, fmt.Sprintf("league ref seed failed for %s: %v", leagueRef, err))
151
+ }
152
+ }
153
+ if seasonRef := strings.TrimSpace(opts.SeasonRef); seasonRef != "" {
154
+ ids := refIDs(seasonRef)
155
+ if leagueID := strings.TrimSpace(ids["leagueId"]); leagueID != "" {
156
+ if err := r.seedLeagueByID(ctx, leagueID); err != nil {
157
+ errs = append(errs, fmt.Sprintf("season league seed failed for %s: %v", leagueID, err))
158
+ }
159
+ }
160
+ }
161
+
162
+ if len(errs) == 0 {
163
+ return nil
164
+ }
165
+ return fmt.Errorf("%s", strings.Join(errs, "; "))
166
+ }
167
+
168
+ func (r *Resolver) seedFromEvents(ctx context.Context) error {
169
+ last := r.index.LastEventsSeedAt()
170
+ if !last.IsZero() && r.now().Sub(last) < r.eventSeedTTL {
171
+ return nil
172
+ }
173
+
174
+ resolved, err := r.client.ResolveRefChain(ctx, "/events")
175
+ if err != nil {
176
+ return fmt.Errorf("events seed failed: %w", err)
177
+ }
178
+
179
+ page, err := DecodePage[Ref](resolved.Body)
180
+ if err != nil {
181
+ return fmt.Errorf("decode /events page: %w", err)
182
+ }
183
+
184
+ limit := r.maxEventSeed
185
+ if limit > len(page.Items) {
186
+ limit = len(page.Items)
187
+ }
188
+ successCount := 0
189
+ seedErrors := make([]string, 0)
190
+ for i := 0; i < limit; i++ {
191
+ if err := r.seedEventRef(ctx, page.Items[i].URL); err != nil {
192
+ seedErrors = append(seedErrors, fmt.Sprintf("%s (%v)", page.Items[i].URL, err))
193
+ continue
194
+ }
195
+ successCount++
196
+ }
197
+ if successCount == 0 && len(seedErrors) > 0 {
198
+ return fmt.Errorf("seed events failed: %s", seedErrors[0])
199
+ }
200
+
201
+ r.index.SetLastEventsSeedAt(r.now())
202
+ r.markHydrated(resolved.RequestedRef)
203
+ r.markHydrated(resolved.CanonicalRef)
204
+ return nil
205
+ }
206
+
207
+ func (r *Resolver) seedKnownRef(ctx context.Context, kind EntityKind, ref string, opts ResolveOptions) error {
208
+ ref = strings.TrimSpace(ref)
209
+ if ref == "" {
210
+ return nil
211
+ }
212
+
213
+ switch kind {
214
+ case EntityPlayer:
215
+ return r.seedPlayerRef(ctx, ref, opts.LeagueID, opts.MatchID)
216
+ case EntityTeam:
217
+ ids := refIDs(ref)
218
+ teamID := nonEmpty(ids["teamId"], ids["competitorId"])
219
+ if teamID != "" {
220
+ return r.seedTeamByID(ctx, teamID, opts.LeagueID, opts.MatchID)
221
+ }
222
+ return r.seedTeamRef(ctx, ref, opts.LeagueID, opts.MatchID)
223
+ case EntityLeague:
224
+ return r.seedLeagueRef(ctx, ref)
225
+ case EntityMatch:
226
+ if strings.Contains(ref, "/competitions/") {
227
+ return r.seedCompetitionRef(ctx, ref)
228
+ }
229
+ if strings.Contains(ref, "/events/") {
230
+ return r.seedEventRef(ctx, ref)
231
+ }
232
+ return fmt.Errorf("unsupported match ref %q", ref)
233
+ default:
234
+ return nil
235
+ }
236
+ }
237
+
238
+ func (r *Resolver) seedNumericID(ctx context.Context, kind EntityKind, id string, opts ResolveOptions) error {
239
+ id = strings.TrimSpace(id)
240
+ if id == "" {
241
+ return nil
242
+ }
243
+
244
+ switch kind {
245
+ case EntityPlayer:
246
+ return r.seedPlayerByID(ctx, id, opts.LeagueID, opts.MatchID)
247
+ case EntityTeam:
248
+ return r.seedTeamByID(ctx, id, opts.LeagueID, opts.MatchID)
249
+ case EntityLeague:
250
+ return r.seedLeagueByID(ctx, id)
251
+ case EntityMatch:
252
+ // Match IDs are usually competition IDs and often match event IDs.
253
+ if err := r.seedEventRef(ctx, "/events/"+id); err == nil {
254
+ return nil
255
+ }
256
+ if leagueID := strings.TrimSpace(opts.LeagueID); leagueID != "" {
257
+ competitionRef := fmt.Sprintf("/leagues/%s/events/%s/competitions/%s", leagueID, id, id)
258
+ if err := r.seedCompetitionRef(ctx, competitionRef); err == nil {
259
+ return nil
260
+ }
261
+ }
262
+ return fmt.Errorf("unable to resolve match id %s without known league/event context", id)
263
+ default:
264
+ return nil
265
+ }
266
+ }
267
+
268
+ func (r *Resolver) seedEventRef(ctx context.Context, ref string) error {
269
+ ref = r.absoluteRef(ref)
270
+ if ref == "" {
271
+ return fmt.Errorf("empty event ref")
272
+ }
273
+ if r.isHydrated(ref) {
274
+ return nil
275
+ }
276
+
277
+ resolved, err := r.client.ResolveRefChain(ctx, ref)
278
+ if err != nil {
279
+ return err
280
+ }
281
+
282
+ payload, err := decodePayloadMap(resolved.Body)
283
+ if err != nil {
284
+ return err
285
+ }
286
+
287
+ ids := refIDs(nonEmpty(resolved.CanonicalRef, resolved.RequestedRef, ref))
288
+ eventID := nonEmpty(stringField(payload, "id"), ids["eventId"])
289
+ leagueID := strings.TrimSpace(ids["leagueId"])
290
+
291
+ leagueRefs := mapSliceField(payload, "leagues")
292
+ for _, leagueRaw := range leagueRefs {
293
+ leagueRef := refFromField(leagueRaw, "$ref")
294
+ if leagueRef == "" {
295
+ leagueRef = stringField(leagueRaw, "$ref")
296
+ }
297
+ leagueID = nonEmpty(leagueID, stringField(leagueRaw, "id"), refIDs(leagueRef)["leagueId"])
298
+
299
+ leagueName := nonEmpty(stringField(leagueRaw, "name"), stringField(leagueRaw, "shortName"))
300
+ leagueShort := nonEmpty(stringField(leagueRaw, "abbreviation"), stringField(leagueRaw, "slug"))
301
+ if leagueID != "" {
302
+ _ = r.index.Upsert(IndexedEntity{
303
+ Kind: EntityLeague,
304
+ ID: leagueID,
305
+ Ref: leagueRef,
306
+ Name: leagueName,
307
+ ShortName: leagueShort,
308
+ UpdatedAt: r.now(),
309
+ })
310
+ }
311
+ if leagueRef != "" {
312
+ _ = r.seedLeagueRef(ctx, leagueRef)
313
+ }
314
+ }
315
+
316
+ eventName := nonEmpty(stringField(payload, "shortDescription"), stringField(payload, "description"), stringField(payload, "name"))
317
+
318
+ competitions := mapSliceField(payload, "competitions")
319
+ for _, comp := range competitions {
320
+ if err := r.seedCompetitionMap(ctx, comp, leagueID, eventID, eventName); err != nil {
321
+ return err
322
+ }
323
+ }
324
+
325
+ r.markHydrated(ref)
326
+ r.markHydrated(resolved.RequestedRef)
327
+ r.markHydrated(resolved.CanonicalRef)
328
+ return nil
329
+ }
330
+
331
+ func (r *Resolver) seedCompetitionRef(ctx context.Context, ref string) error {
332
+ ref = r.absoluteRef(ref)
333
+ if ref == "" {
334
+ return fmt.Errorf("empty competition ref")
335
+ }
336
+ if r.isHydrated(ref) {
337
+ return nil
338
+ }
339
+
340
+ resolved, err := r.client.ResolveRefChain(ctx, ref)
341
+ if err != nil {
342
+ return err
343
+ }
344
+
345
+ payload, err := decodePayloadMap(resolved.Body)
346
+ if err != nil {
347
+ return err
348
+ }
349
+
350
+ ids := refIDs(nonEmpty(resolved.CanonicalRef, resolved.RequestedRef, ref))
351
+ if err := r.seedCompetitionMap(ctx, payload, ids["leagueId"], ids["eventId"], ""); err != nil {
352
+ return err
353
+ }
354
+
355
+ r.markHydrated(ref)
356
+ r.markHydrated(resolved.RequestedRef)
357
+ r.markHydrated(resolved.CanonicalRef)
358
+ return nil
359
+ }
360
+
361
+ func (r *Resolver) seedCompetitionMap(ctx context.Context, comp map[string]any, leagueID, eventID, eventName string) error {
362
+ compRef := stringField(comp, "$ref")
363
+ if compRef == "" {
364
+ compRef = r.buildCompetitionRef(leagueID, eventID, stringField(comp, "id"))
365
+ }
366
+ compIDs := refIDs(compRef)
367
+ competitionID := nonEmpty(stringField(comp, "id"), compIDs["competitionId"])
368
+ leagueID = nonEmpty(strings.TrimSpace(leagueID), compIDs["leagueId"])
369
+ eventID = nonEmpty(strings.TrimSpace(eventID), compIDs["eventId"])
370
+
371
+ if competitionID == "" {
372
+ return nil
373
+ }
374
+
375
+ matchName := nonEmpty(stringField(comp, "shortDescription"), stringField(comp, "description"), stringField(comp, "note"), eventName, stringField(comp, "date"))
376
+ matchShort := nonEmpty(stringField(comp, "shortDescription"), eventName)
377
+
378
+ if err := r.index.Upsert(IndexedEntity{
379
+ Kind: EntityMatch,
380
+ ID: competitionID,
381
+ Ref: compRef,
382
+ Name: matchName,
383
+ ShortName: matchShort,
384
+ LeagueID: leagueID,
385
+ EventID: eventID,
386
+ MatchID: competitionID,
387
+ Aliases: []string{
388
+ stringField(comp, "description"),
389
+ stringField(comp, "shortDescription"),
390
+ stringField(comp, "note"),
391
+ eventName,
392
+ competitionID,
393
+ eventID,
394
+ },
395
+ UpdatedAt: r.now(),
396
+ }); err != nil {
397
+ return err
398
+ }
399
+
400
+ competitors := mapSliceField(comp, "competitors")
401
+ for _, competitor := range competitors {
402
+ if err := r.seedCompetitorMap(ctx, competitor, leagueID, eventID, competitionID); err != nil {
403
+ return err
404
+ }
405
+ }
406
+
407
+ if compRef != "" {
408
+ r.markHydrated(r.absoluteRef(compRef))
409
+ }
410
+ return nil
411
+ }
412
+
413
+ func (r *Resolver) seedCompetitorMap(ctx context.Context, competitor map[string]any, leagueID, eventID, matchID string) error {
414
+ competitorRef := stringField(competitor, "$ref")
415
+ teamRef := refFromField(competitor, "team")
416
+ teamIDs := refIDs(teamRef)
417
+ competitorIDs := refIDs(competitorRef)
418
+
419
+ teamID := nonEmpty(teamIDs["teamId"], stringField(mapField(competitor, "team"), "id"), stringField(competitor, "id"), competitorIDs["competitorId"])
420
+ teamName := nonEmpty(stringField(mapField(competitor, "team"), "displayName"), stringField(mapField(competitor, "team"), "name"), stringField(competitor, "displayName"), stringField(competitor, "name"))
421
+ teamShort := nonEmpty(stringField(mapField(competitor, "team"), "shortDisplayName"), stringField(mapField(competitor, "team"), "abbreviation"), stringField(competitor, "abbreviation"))
422
+
423
+ if teamID != "" {
424
+ if err := r.index.Upsert(IndexedEntity{
425
+ Kind: EntityTeam,
426
+ ID: teamID,
427
+ Ref: nonEmpty(teamRef, competitorRef),
428
+ Name: teamName,
429
+ ShortName: teamShort,
430
+ LeagueID: leagueID,
431
+ EventID: eventID,
432
+ MatchID: matchID,
433
+ Aliases: []string{
434
+ teamName,
435
+ teamShort,
436
+ stringField(competitor, "homeAway"),
437
+ teamID,
438
+ },
439
+ UpdatedAt: r.now(),
440
+ }); err != nil {
441
+ return err
442
+ }
443
+
444
+ if teamName == "" {
445
+ _ = r.seedTeamByID(ctx, teamID, leagueID, matchID)
446
+ }
447
+ }
448
+
449
+ rosterRef := refFromField(competitor, "roster")
450
+ if rosterRef != "" {
451
+ if err := r.seedRosterRef(ctx, rosterRef, leagueID, eventID, matchID); err != nil {
452
+ return err
453
+ }
454
+ }
455
+
456
+ return nil
457
+ }
458
+
459
+ func (r *Resolver) seedRosterRef(ctx context.Context, ref, leagueID, eventID, matchID string) error {
460
+ ref = r.absoluteRef(ref)
461
+ if ref == "" {
462
+ return nil
463
+ }
464
+ if r.isHydrated(ref) {
465
+ return nil
466
+ }
467
+
468
+ resolved, err := r.client.ResolveRefChain(ctx, ref)
469
+ if err != nil {
470
+ return err
471
+ }
472
+
473
+ payload, err := decodePayloadMap(resolved.Body)
474
+ if err != nil {
475
+ return err
476
+ }
477
+
478
+ entries := mapSliceField(payload, "entries")
479
+ for _, entry := range entries {
480
+ athleteRef := refFromField(entry, "athlete")
481
+ playerID := nonEmpty(stringField(entry, "playerId"), refIDs(athleteRef)["athleteId"], refIDs(stringField(entry, "$ref"))["athleteId"])
482
+ if playerID == "" {
483
+ continue
484
+ }
485
+
486
+ playerName := nonEmpty(
487
+ stringField(mapField(entry, "athlete"), "displayName"),
488
+ stringField(mapField(entry, "athlete"), "fullName"),
489
+ )
490
+
491
+ if err := r.index.Upsert(IndexedEntity{
492
+ Kind: EntityPlayer,
493
+ ID: playerID,
494
+ Ref: athleteRef,
495
+ Name: playerName,
496
+ LeagueID: leagueID,
497
+ EventID: eventID,
498
+ MatchID: matchID,
499
+ Aliases: []string{playerName, playerID},
500
+ UpdatedAt: r.now(),
501
+ }); err != nil {
502
+ return err
503
+ }
504
+
505
+ if playerName == "" {
506
+ if err := r.seedPlayerByID(ctx, playerID, leagueID, matchID); err != nil {
507
+ return err
508
+ }
509
+ }
510
+ }
511
+
512
+ r.markHydrated(ref)
513
+ r.markHydrated(resolved.RequestedRef)
514
+ r.markHydrated(resolved.CanonicalRef)
515
+ return nil
516
+ }
517
+
518
+ func (r *Resolver) seedPlayerRef(ctx context.Context, ref, leagueID, matchID string) error {
519
+ ids := refIDs(ref)
520
+ if playerID := ids["athleteId"]; playerID != "" {
521
+ return r.seedPlayerByID(ctx, playerID, leagueID, matchID)
522
+ }
523
+
524
+ resolved, err := r.client.ResolveRefChain(ctx, ref)
525
+ if err != nil {
526
+ return err
527
+ }
528
+
529
+ payload, err := decodePayloadMap(resolved.Body)
530
+ if err != nil {
531
+ return err
532
+ }
533
+
534
+ playerID := nonEmpty(stringField(payload, "id"), refIDs(resolved.CanonicalRef)["athleteId"])
535
+ if playerID == "" {
536
+ return nil
537
+ }
538
+
539
+ return r.index.Upsert(IndexedEntity{
540
+ Kind: EntityPlayer,
541
+ ID: playerID,
542
+ Ref: resolved.CanonicalRef,
543
+ Name: nonEmpty(stringField(payload, "displayName"), stringField(payload, "fullName"), stringField(payload, "name")),
544
+ ShortName: stringField(payload, "shortName"),
545
+ LeagueID: strings.TrimSpace(leagueID),
546
+ MatchID: strings.TrimSpace(matchID),
547
+ Aliases: []string{
548
+ stringField(payload, "displayName"),
549
+ stringField(payload, "fullName"),
550
+ stringField(payload, "name"),
551
+ stringField(payload, "battingName"),
552
+ stringField(payload, "fieldingName"),
553
+ },
554
+ UpdatedAt: r.now(),
555
+ })
556
+ }
557
+
558
+ func (r *Resolver) seedPlayerByID(ctx context.Context, id, leagueID, matchID string) error {
559
+ if existing, ok := r.index.FindByID(EntityPlayer, id); ok && (strings.TrimSpace(existing.Name) != "" || strings.TrimSpace(existing.ShortName) != "") {
560
+ return nil
561
+ }
562
+
563
+ resolved, err := r.client.ResolveRefChain(ctx, "/athletes/"+id)
564
+ if err != nil {
565
+ return err
566
+ }
567
+
568
+ payload, err := decodePayloadMap(resolved.Body)
569
+ if err != nil {
570
+ return err
571
+ }
572
+
573
+ if err := r.index.Upsert(IndexedEntity{
574
+ Kind: EntityPlayer,
575
+ ID: nonEmpty(stringField(payload, "id"), id),
576
+ Ref: resolved.CanonicalRef,
577
+ Name: nonEmpty(stringField(payload, "displayName"), stringField(payload, "fullName"), stringField(payload, "name")),
578
+ ShortName: stringField(payload, "shortName"),
579
+ LeagueID: strings.TrimSpace(leagueID),
580
+ MatchID: strings.TrimSpace(matchID),
581
+ Aliases: []string{
582
+ stringField(payload, "displayName"),
583
+ stringField(payload, "fullName"),
584
+ stringField(payload, "name"),
585
+ stringField(payload, "battingName"),
586
+ stringField(payload, "fieldingName"),
587
+ id,
588
+ },
589
+ UpdatedAt: r.now(),
590
+ }); err != nil {
591
+ return err
592
+ }
593
+
594
+ r.markHydrated(resolved.RequestedRef)
595
+ r.markHydrated(resolved.CanonicalRef)
596
+ return nil
597
+ }
598
+
599
+ func (r *Resolver) seedTeamRef(ctx context.Context, ref, leagueID, matchID string) error {
600
+ ids := refIDs(ref)
601
+ teamID := nonEmpty(ids["teamId"], ids["competitorId"])
602
+ if teamID != "" {
603
+ return r.seedTeamByID(ctx, teamID, leagueID, matchID)
604
+ }
605
+
606
+ resolved, err := r.client.ResolveRefChain(ctx, ref)
607
+ if err != nil {
608
+ return err
609
+ }
610
+
611
+ payload, err := decodePayloadMap(resolved.Body)
612
+ if err != nil {
613
+ return err
614
+ }
615
+
616
+ teamID = nonEmpty(stringField(payload, "id"), refIDs(resolved.CanonicalRef)["teamId"], refIDs(resolved.CanonicalRef)["competitorId"])
617
+ if teamID == "" {
618
+ return nil
619
+ }
620
+
621
+ if err := r.index.Upsert(IndexedEntity{
622
+ Kind: EntityTeam,
623
+ ID: teamID,
624
+ Ref: resolved.CanonicalRef,
625
+ Name: nonEmpty(stringField(payload, "displayName"), stringField(payload, "name")),
626
+ ShortName: nonEmpty(stringField(payload, "shortDisplayName"), stringField(payload, "shortName"), stringField(payload, "abbreviation")),
627
+ LeagueID: strings.TrimSpace(leagueID),
628
+ MatchID: strings.TrimSpace(matchID),
629
+ Aliases: []string{
630
+ stringField(payload, "displayName"),
631
+ stringField(payload, "name"),
632
+ stringField(payload, "shortDisplayName"),
633
+ stringField(payload, "shortName"),
634
+ stringField(payload, "abbreviation"),
635
+ },
636
+ UpdatedAt: r.now(),
637
+ }); err != nil {
638
+ return err
639
+ }
640
+
641
+ r.markHydrated(resolved.RequestedRef)
642
+ r.markHydrated(resolved.CanonicalRef)
643
+ return nil
644
+ }
645
+
646
+ func (r *Resolver) seedTeamByID(ctx context.Context, id, leagueID, matchID string) error {
647
+ if existing, ok := r.index.FindByID(EntityTeam, id); ok && strings.TrimSpace(existing.Name) != "" {
648
+ return nil
649
+ }
650
+
651
+ resolved, err := r.client.ResolveRefChain(ctx, "/teams/"+id)
652
+ if err != nil {
653
+ return err
654
+ }
655
+
656
+ payload, err := decodePayloadMap(resolved.Body)
657
+ if err != nil {
658
+ return err
659
+ }
660
+
661
+ if err := r.index.Upsert(IndexedEntity{
662
+ Kind: EntityTeam,
663
+ ID: nonEmpty(stringField(payload, "id"), id),
664
+ Ref: resolved.CanonicalRef,
665
+ Name: nonEmpty(stringField(payload, "displayName"), stringField(payload, "name"), stringField(payload, "shortDisplayName")),
666
+ ShortName: nonEmpty(stringField(payload, "shortDisplayName"), stringField(payload, "shortName"), stringField(payload, "abbreviation"), stringField(payload, "slug")),
667
+ LeagueID: strings.TrimSpace(leagueID),
668
+ MatchID: strings.TrimSpace(matchID),
669
+ Aliases: []string{
670
+ stringField(payload, "displayName"),
671
+ stringField(payload, "name"),
672
+ stringField(payload, "shortDisplayName"),
673
+ stringField(payload, "shortName"),
674
+ stringField(payload, "abbreviation"),
675
+ stringField(payload, "slug"),
676
+ id,
677
+ },
678
+ UpdatedAt: r.now(),
679
+ }); err != nil {
680
+ return err
681
+ }
682
+
683
+ r.markHydrated(resolved.RequestedRef)
684
+ r.markHydrated(resolved.CanonicalRef)
685
+ return nil
686
+ }
687
+
688
+ func (r *Resolver) seedLeagueRef(ctx context.Context, ref string) error {
689
+ ids := refIDs(ref)
690
+ if leagueID := ids["leagueId"]; leagueID != "" {
691
+ return r.seedLeagueByID(ctx, leagueID)
692
+ }
693
+
694
+ resolved, err := r.client.ResolveRefChain(ctx, ref)
695
+ if err != nil {
696
+ return err
697
+ }
698
+
699
+ payload, err := decodePayloadMap(resolved.Body)
700
+ if err != nil {
701
+ return err
702
+ }
703
+
704
+ leagueID := nonEmpty(stringField(payload, "id"), refIDs(resolved.CanonicalRef)["leagueId"])
705
+ if leagueID == "" {
706
+ return nil
707
+ }
708
+
709
+ return r.index.Upsert(IndexedEntity{
710
+ Kind: EntityLeague,
711
+ ID: leagueID,
712
+ Ref: resolved.CanonicalRef,
713
+ Name: nonEmpty(stringField(payload, "name"), stringField(payload, "shortName")),
714
+ ShortName: nonEmpty(stringField(payload, "slug"), stringField(payload, "abbreviation"), stringField(payload, "shortName")),
715
+ Aliases: []string{
716
+ stringField(payload, "name"),
717
+ stringField(payload, "shortName"),
718
+ stringField(payload, "slug"),
719
+ stringField(payload, "abbreviation"),
720
+ leagueID,
721
+ },
722
+ UpdatedAt: r.now(),
723
+ })
724
+ }
725
+
726
+ func (r *Resolver) seedLeagueByID(ctx context.Context, id string) error {
727
+ if existing, ok := r.index.FindByID(EntityLeague, id); ok && strings.TrimSpace(existing.Name) != "" {
728
+ return nil
729
+ }
730
+
731
+ resolved, err := r.client.ResolveRefChain(ctx, "/leagues/"+id)
732
+ if err != nil {
733
+ return err
734
+ }
735
+
736
+ payload, err := decodePayloadMap(resolved.Body)
737
+ if err != nil {
738
+ return err
739
+ }
740
+
741
+ if err := r.index.Upsert(IndexedEntity{
742
+ Kind: EntityLeague,
743
+ ID: nonEmpty(stringField(payload, "id"), id),
744
+ Ref: resolved.CanonicalRef,
745
+ Name: nonEmpty(stringField(payload, "name"), stringField(payload, "shortName")),
746
+ ShortName: nonEmpty(stringField(payload, "slug"), stringField(payload, "abbreviation"), stringField(payload, "shortName")),
747
+ Aliases: []string{
748
+ stringField(payload, "name"),
749
+ stringField(payload, "shortName"),
750
+ stringField(payload, "slug"),
751
+ stringField(payload, "abbreviation"),
752
+ id,
753
+ },
754
+ UpdatedAt: r.now(),
755
+ }); err != nil {
756
+ return err
757
+ }
758
+
759
+ r.markHydrated(resolved.RequestedRef)
760
+ r.markHydrated(resolved.CanonicalRef)
761
+ return nil
762
+ }
763
+
764
+ func (r *Resolver) absoluteRef(ref string) string {
765
+ resolved, err := r.client.resolveRef(ref)
766
+ if err != nil {
767
+ return strings.TrimSpace(ref)
768
+ }
769
+ return resolved
770
+ }
771
+
772
+ func (r *Resolver) markHydrated(ref string) {
773
+ ref = strings.TrimSpace(ref)
774
+ if ref == "" {
775
+ return
776
+ }
777
+ r.index.MarkHydratedRef(ref, r.now())
778
+ }
779
+
780
+ func (r *Resolver) isHydrated(ref string) bool {
781
+ if ref == "" {
782
+ return false
783
+ }
784
+ _, ok := r.index.HydratedRefAt(ref)
785
+ return ok
786
+ }
787
+
788
+ func (r *Resolver) buildCompetitionRef(leagueID, eventID, competitionID string) string {
789
+ leagueID = strings.TrimSpace(leagueID)
790
+ eventID = strings.TrimSpace(eventID)
791
+ competitionID = strings.TrimSpace(competitionID)
792
+ if leagueID == "" || eventID == "" || competitionID == "" {
793
+ return ""
794
+ }
795
+ return fmt.Sprintf("/leagues/%s/events/%s/competitions/%s", leagueID, eventID, competitionID)
796
+ }
797
+
798
+ func isNumeric(raw string) bool {
799
+ raw = strings.TrimSpace(raw)
800
+ if raw == "" {
801
+ return false
802
+ }
803
+ _, err := strconv.ParseInt(raw, 10, 64)
804
+ return err == nil
805
+ }
806
+
807
+ func isKnownRefQuery(raw string) bool {
808
+ raw = strings.TrimSpace(raw)
809
+ if raw == "" {
810
+ return false
811
+ }
812
+ return strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "/")
813
+ }