catence 0.2.1-beta.2 → 0.2.1-beta.21

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 (67) hide show
  1. package/README.md +4 -4
  2. package/config.example.json +1 -0
  3. package/dist/contracts/release.d.ts +1 -1
  4. package/dist/contracts/release.js +1 -1
  5. package/dist/contracts/runtime.d.ts +4 -0
  6. package/dist/core/query/catalog.d.ts +1 -1
  7. package/dist/core/query/catalog.js +16 -10
  8. package/dist/core/query/repository.d.ts +1 -1
  9. package/dist/core/query/repository.js +31 -2
  10. package/dist/core/retrieval/index.js +23 -5
  11. package/dist/core/runtime/athlete-file.d.ts +49 -0
  12. package/dist/core/runtime/athlete-file.js +177 -0
  13. package/dist/core/runtime/configuration.js +2 -0
  14. package/dist/core/runtime/console-accounts.d.ts +52 -0
  15. package/dist/core/runtime/console-accounts.js +103 -0
  16. package/dist/core/runtime/mcp-keys.d.ts +65 -0
  17. package/dist/core/runtime/mcp-keys.js +171 -0
  18. package/dist/core/runtime/oauth/model.d.ts +44 -0
  19. package/dist/core/runtime/oauth/model.js +345 -0
  20. package/dist/core/runtime/oauth/public-client.d.ts +28 -0
  21. package/dist/core/runtime/oauth/public-client.js +35 -0
  22. package/dist/core/runtime/oauth/settings.d.ts +33 -0
  23. package/dist/core/runtime/oauth/settings.js +96 -0
  24. package/dist/core/runtime/secrets.d.ts +15 -0
  25. package/dist/core/runtime/secrets.js +48 -7
  26. package/dist/elt/application/autosync.d.ts +71 -0
  27. package/dist/elt/application/autosync.js +173 -0
  28. package/dist/elt/application/detached-sync.d.ts +7 -2
  29. package/dist/elt/application/detached-sync.js +10 -4
  30. package/dist/elt/application/management.d.ts +7 -0
  31. package/dist/elt/application/management.js +103 -7
  32. package/dist/elt/ingestion/importer.d.ts +11 -1
  33. package/dist/elt/ingestion/importer.js +63 -14
  34. package/dist/elt/ingestion/providers/intervals.d.ts +7 -0
  35. package/dist/elt/ingestion/providers/intervals.js +21 -2
  36. package/dist/elt/normalization/normalizers.js +122 -0
  37. package/dist/elt/storage/database.d.ts +6 -1
  38. package/dist/elt/storage/database.js +7 -1
  39. package/dist/elt/storage/migrations.js +45 -1
  40. package/dist/elt/storage/progress-sidecar.d.ts +9 -0
  41. package/dist/elt/storage/progress-sidecar.js +31 -16
  42. package/dist/interfaces/cli/commands.js +117 -2
  43. package/dist/interfaces/http/cli-options.d.ts +64 -1
  44. package/dist/interfaces/http/cli-options.js +214 -2
  45. package/dist/interfaces/http/oauth.d.ts +16 -0
  46. package/dist/interfaces/http/oauth.js +228 -0
  47. package/dist/interfaces/http/server.d.ts +60 -1
  48. package/dist/interfaces/http/server.js +810 -16
  49. package/dist/interfaces/mcp/main.js +35 -3
  50. package/dist/interfaces/mcp/server.d.ts +21 -1
  51. package/dist/interfaces/mcp/server.js +107 -21
  52. package/dist/runtime/index.d.ts +15 -3
  53. package/dist/runtime/index.js +9 -2
  54. package/docs/architecture.md +1 -1
  55. package/docs/configuration.md +76 -6
  56. package/docs/deployment/docker.md +173 -15
  57. package/docs/deployment/local-console.md +132 -21
  58. package/docs/deployment/local-mcp.md +190 -11
  59. package/docs/development.md +5 -3
  60. package/docs/llm-providers.md +4 -4
  61. package/package.json +11 -4
  62. package/pyproject.toml +1 -1
  63. package/python/catence/providers/garmin/cli.py +55 -7
  64. package/python/catence/providers/garmin/progress.py +71 -35
  65. package/python/catence/providers/garmin/staging.py +11 -4
  66. package/python/catence/providers/garmin/worker.py +420 -206
  67. package/uv.lock +3 -3
@@ -1,9 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from concurrent import futures
3
4
  from datetime import date, timedelta
4
5
  from pathlib import Path
5
6
  import hashlib
6
7
  import json
8
+ import random
9
+ import threading
10
+ import time
7
11
  from typing import Any, Callable, Mapping
8
12
 
9
13
  from .progress import ProgressReporter
@@ -121,6 +125,11 @@ SCHEDULED_WORKOUTS_MONTHS_AHEAD = 12
121
125
  # window predates this span).
122
126
  EVENTS_PAST_DAYS = 30
123
127
 
128
+ # Garmin answers data-fetch bursts with HTTP 429. Back off cooperatively: a
129
+ # rate-limit hit parks every in-flight request until a jittered deadline, and
130
+ # the same call is retried on an escalating schedule before it fails.
131
+ RATE_LIMIT_BACKOFF_SECONDS = (5.0, 15.0, 45.0)
132
+
124
133
 
125
134
  def shift_month(base: date, offset: int) -> date:
126
135
  """Return the first day of the month `offset` months from `base`'s month."""
@@ -150,13 +159,28 @@ def payload_date(payload: Mapping[str, Any]) -> str | None:
150
159
  class GarminStagingWorker:
151
160
  """Extract Garmin data through the explicit read-only registry into JSONL/Parquet staging."""
152
161
 
153
- def __init__(self, api: Any, writer: StagingWriter, data_dir: Path, known_activity_hashes: Mapping[str, str] | None = None, progress: ProgressReporter | None = None) -> None:
162
+ def __init__(
163
+ self,
164
+ api: Any,
165
+ writer: StagingWriter,
166
+ data_dir: Path,
167
+ known_activity_hashes: Mapping[str, str] | None = None,
168
+ progress: ProgressReporter | None = None,
169
+ concurrency: int = 1,
170
+ ) -> None:
154
171
  self.api = api
155
172
  self.writer = writer
156
173
  self.data_dir = data_dir
157
174
  self.known_activity_hashes = dict(known_activity_hashes or {})
158
175
  self.progress = progress
176
+ self.concurrency = max(1, concurrency)
159
177
  self._interrupted = False
178
+ self._executor: futures.ThreadPoolExecutor | None = None
179
+ self._rate_limit_lock = threading.Lock()
180
+ self._rate_limit_until = 0.0
181
+ self._counts_lock = threading.Lock()
182
+ self._capture_counts: dict[str, int] = {}
183
+ self._capture_errors: dict[str, int] = {}
160
184
 
161
185
  def request_interrupt(self) -> None:
162
186
  """Ask the worker to stop at the next safe boundary."""
@@ -168,6 +192,105 @@ class GarminStagingWorker:
168
192
  self.progress.finish("interrupted")
169
193
  raise WorkerInterrupted("Sync run interrupted between extraction steps")
170
194
 
195
+ def _wait_for_rate_limit(self) -> None:
196
+ """Block until any shared 429 cooldown expires, staying interrupt-aware."""
197
+ while True:
198
+ with self._rate_limit_lock:
199
+ remaining = self._rate_limit_until - time.monotonic()
200
+ if remaining <= 0:
201
+ return
202
+ if self._interrupted:
203
+ self._check_interrupted()
204
+ time.sleep(min(0.25, remaining))
205
+
206
+ @staticmethod
207
+ def _is_rate_limit(error: Exception) -> bool:
208
+ if "TooManyRequests" in type(error).__name__:
209
+ return True
210
+ if getattr(error, "status_code", None) == 429:
211
+ return True
212
+ try:
213
+ from garminconnect import GarminConnectTooManyRequestsError
214
+ except Exception: # garminconnect is optional in lightweight test contexts.
215
+ return False
216
+ return isinstance(error, GarminConnectTooManyRequestsError)
217
+
218
+ def _request(self, action: Callable[[], Any]) -> Any:
219
+ """Run one Garmin API call with cooperative 429 handling."""
220
+ attempt = 0
221
+ while True:
222
+ self._wait_for_rate_limit()
223
+ try:
224
+ return action()
225
+ except Exception as error:
226
+ if not self._is_rate_limit(error) or attempt >= len(RATE_LIMIT_BACKOFF_SECONDS):
227
+ raise
228
+ delay = RATE_LIMIT_BACKOFF_SECONDS[attempt] * random.uniform(0.5, 1.5)
229
+ with self._rate_limit_lock:
230
+ self._rate_limit_until = max(self._rate_limit_until, time.monotonic() + delay)
231
+ attempt += 1
232
+
233
+ def _note_capture(self, endpoint: str, failed: bool) -> None:
234
+ with self._counts_lock:
235
+ target = self._capture_errors if failed else self._capture_counts
236
+ target[endpoint] = target.get(endpoint, 0) + 1
237
+
238
+ def capture_summary(self) -> dict[str, dict[str, int]]:
239
+ """Per-endpoint capture counts for the CLI's stage-timing summary."""
240
+ with self._counts_lock:
241
+ return {"captures": dict(self._capture_counts), "captureErrors": dict(self._capture_errors)}
242
+
243
+ def _ensure_executor(self) -> futures.ThreadPoolExecutor:
244
+ if self._executor is None:
245
+ self._executor = futures.ThreadPoolExecutor(max_workers=self.concurrency, thread_name_prefix="garmin-sync")
246
+ return self._executor
247
+
248
+ def close(self) -> None:
249
+ """Release the request pool once a sync run ends."""
250
+ executor, self._executor = self._executor, None
251
+ if executor is not None:
252
+ executor.shutdown(wait=False, cancel_futures=True)
253
+
254
+ def _map(self, items: list[Any], worker: Callable[[Any], None], on_done: Callable[[], None] | None = None) -> None:
255
+ """Run `worker` over `items`, bounded-parallel when configured.
256
+
257
+ The serial path (the default) preserves the original in-order request
258
+ flow exactly; concurrency > 1 keeps at most `concurrency` calls in
259
+ flight and reacts as soon as the first one completes.
260
+ """
261
+ if self.concurrency <= 1:
262
+ for item in items:
263
+ self._check_interrupted()
264
+ worker(item)
265
+ if on_done is not None:
266
+ on_done()
267
+ return
268
+ executor = self._ensure_executor()
269
+ pending: set[futures.Future[None]] = set()
270
+ iterator = iter(items)
271
+ try:
272
+ while True:
273
+ while len(pending) < self.concurrency:
274
+ try:
275
+ item = next(iterator)
276
+ except StopIteration:
277
+ break
278
+ pending.add(executor.submit(worker, item))
279
+ if not pending:
280
+ return
281
+ done, _ = futures.wait(pending, timeout=0.5, return_when=futures.FIRST_COMPLETED)
282
+ for future in done:
283
+ pending.discard(future)
284
+ future.result()
285
+ if on_done is not None:
286
+ on_done()
287
+ if self._interrupted:
288
+ raise WorkerInterrupted("Sync run interrupted between extraction steps")
289
+ except BaseException:
290
+ for future in pending:
291
+ future.cancel()
292
+ raise
293
+
171
294
  def sync(
172
295
  self,
173
296
  daily_from_date: date | None,
@@ -180,46 +303,53 @@ class GarminStagingWorker:
180
303
  events_from: str | None = None,
181
304
  ) -> None:
182
305
  assert_read_only_registry()
183
- end = to_date or date.today()
184
- daily_end = daily_to_date or end
185
- activity_end = activity_to_date or end
186
- if include_non_historical:
187
- self._check_interrupted()
188
- self._singletons()
189
- if daily_from_date and daily_from_date <= daily_end:
190
- self._check_interrupted()
191
- self._daily(daily_from_date, daily_end)
192
- self._check_interrupted()
193
- self._range(daily_from_date, daily_end)
194
- self._check_interrupted()
195
- self._cycling_ftp_history(daily_from_date, daily_end)
196
- self._check_interrupted()
197
- self._max_metrics_history(daily_from_date, daily_end)
198
- self._check_interrupted()
199
- self._hrv_history(daily_from_date, daily_end)
200
- self._check_interrupted()
201
- self._score_history(daily_from_date, daily_end)
202
- # Lactate-threshold history uses the widest requested window so a
203
- # single sync can catch stores up that were created before per-day
204
- # LT capture existed; runs even when the daily window is skipped.
205
- lt_windows = [window for window in (daily_from_date, lactate_threshold_history_from) if window is not None]
206
- lt_start = min(lt_windows) if lt_windows else None
207
- if lt_start is not None and lt_start <= daily_end:
208
- self._check_interrupted()
209
- self._lactate_threshold_history(lt_start, daily_end)
210
- if activity_from_date and activity_from_date <= activity_end:
211
- self._check_interrupted()
212
- self._activities(activity_from_date, activity_end)
213
- if include_non_historical:
214
- self._check_interrupted()
215
- self._collections(events_from=events_from)
306
+ try:
307
+ end = to_date or date.today()
308
+ daily_end = daily_to_date or end
309
+ activity_end = activity_to_date or end
310
+ if include_non_historical:
311
+ self._check_interrupted()
312
+ self._singletons()
313
+ if daily_from_date and daily_from_date <= daily_end:
314
+ self._check_interrupted()
315
+ self._daily(daily_from_date, daily_end)
316
+ self._check_interrupted()
317
+ self._range(daily_from_date, daily_end)
318
+ self._check_interrupted()
319
+ self._cycling_ftp_history(daily_from_date, daily_end)
320
+ self._check_interrupted()
321
+ self._max_metrics_history(daily_from_date, daily_end)
322
+ self._check_interrupted()
323
+ self._hrv_history(daily_from_date, daily_end)
324
+ self._check_interrupted()
325
+ self._score_history(daily_from_date, daily_end)
326
+ # Lactate-threshold history uses the widest requested window so a
327
+ # single sync can catch stores up that were created before per-day
328
+ # LT capture existed; runs even when the daily window is skipped.
329
+ lt_windows = [window for window in (daily_from_date, lactate_threshold_history_from) if window is not None]
330
+ lt_start = min(lt_windows) if lt_windows else None
331
+ if lt_start is not None and lt_start <= daily_end:
332
+ self._check_interrupted()
333
+ self._lactate_threshold_history(lt_start, daily_end)
334
+ if activity_from_date and activity_from_date <= activity_end:
335
+ self._check_interrupted()
336
+ self._activities(activity_from_date, activity_end)
337
+ if include_non_historical:
338
+ self._check_interrupted()
339
+ self._collections(events_from=events_from)
340
+ finally:
341
+ self.close()
216
342
 
217
343
  def _capture(self, endpoint: str, action: Callable[[], Any], remote_id_value: str | None = None, scope: dict[str, Any] | None = None) -> tuple[Any | None, str | None]:
218
344
  try:
219
- payload = action()
345
+ payload = self._request(action)
220
346
  raw_hash = self.writer.archive_json(endpoint, remote_id_value, payload, scope)
347
+ self._note_capture(endpoint, False)
221
348
  return payload, raw_hash
349
+ except WorkerInterrupted:
350
+ raise
222
351
  except Exception as error: # Provider availability is intentionally isolated per operation.
352
+ self._note_capture(endpoint, True)
223
353
  self.writer.error(endpoint, str(error), remote_id_value)
224
354
  return None, None
225
355
 
@@ -266,15 +396,23 @@ class GarminStagingWorker:
266
396
  if self.progress is not None:
267
397
  self.progress.set_stage("singletons")
268
398
  self.progress.advance(total=len(calls))
269
- for index, (endpoint, (method, entity_type)) in enumerate(calls.items(), start=1):
270
- self._check_interrupted()
399
+ completed = 0
400
+
401
+ def done() -> None:
402
+ nonlocal completed
403
+ completed += 1
404
+ if self.progress is not None:
405
+ self.progress.advance(completed=completed)
406
+
407
+ def fetch(item: tuple[str, tuple[str, str]]) -> None:
408
+ endpoint, (method, entity_type) = item
271
409
  if self.progress is not None:
272
410
  self.progress.advance(step=endpoint)
273
411
  payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)())
274
412
  if payload is not None:
275
413
  self._entity(endpoint, entity_type, payload, raw_hash)
276
- if self.progress is not None:
277
- self.progress.advance(completed=index)
414
+
415
+ self._map(list(calls.items()), fetch, done)
278
416
 
279
417
  def _daily(self, from_date: date, to_date: date) -> None:
280
418
  calls = {
@@ -292,27 +430,33 @@ class GarminStagingWorker:
292
430
  "nutrition_food_log": ("get_nutrition_daily_food_log", "nutrition_log"), "nutrition_meals": ("get_nutrition_daily_meals", "nutrition_log"),
293
431
  "nutrition_settings": ("get_nutrition_daily_settings", "nutrition_setting"),
294
432
  }
295
- total_days = (to_date - from_date).days + 1
296
- if self.progress is not None:
297
- self.progress.set_stage("daily")
298
- self.progress.advance(total=total_days)
433
+ tasks: list[tuple[str, str, str, str]] = []
299
434
  current = from_date
300
- completed_days = 0
301
435
  while current <= to_date:
302
- self._check_interrupted()
303
436
  day = current.isoformat()
304
- if self.progress is not None:
305
- self.progress.advance(step=day)
306
437
  for endpoint, (method, entity_type) in calls.items():
307
- if self.progress is not None:
308
- self.progress.advance(step=f"{day} {endpoint}")
309
- payload, raw_hash = self._capture(endpoint, lambda method=method, day=day: getattr(self.api, method)(day), day, {"date": day})
310
- if payload is not None:
311
- self._entity(endpoint, entity_type, payload, raw_hash, day)
312
- completed_days += 1
313
- if self.progress is not None:
314
- self.progress.advance(completed=completed_days)
438
+ tasks.append((day, endpoint, method, entity_type))
315
439
  current += timedelta(days=1)
440
+ if self.progress is not None:
441
+ self.progress.set_stage("daily")
442
+ self.progress.advance(total=len(tasks))
443
+ completed = 0
444
+
445
+ def done() -> None:
446
+ nonlocal completed
447
+ completed += 1
448
+ if self.progress is not None:
449
+ self.progress.advance(completed=completed)
450
+
451
+ def fetch(task: tuple[str, str, str, str]) -> None:
452
+ day, endpoint, method, entity_type = task
453
+ if self.progress is not None:
454
+ self.progress.advance(step=f"{day} {endpoint}")
455
+ payload, raw_hash = self._capture(endpoint, lambda method=method, day=day: getattr(self.api, method)(day), day, {"date": day})
456
+ if payload is not None:
457
+ self._entity(endpoint, entity_type, payload, raw_hash, day)
458
+
459
+ self._map(tasks, fetch, done)
316
460
 
317
461
  def _range(self, from_date: date, to_date: date) -> None:
318
462
  calls = {
@@ -322,8 +466,8 @@ class GarminStagingWorker:
322
466
  }
323
467
  if self.progress is not None:
324
468
  self.progress.set_stage("range")
325
- self.progress.advance(total=len(calls))
326
- for index, (endpoint, (method, entity_type)) in enumerate(calls.items(), start=1):
469
+ tasks: list[tuple[str, str, str, str, str]] = []
470
+ for endpoint, (method, entity_type) in calls.items():
327
471
  # Garmin rejects broad body-battery and menstrual-calendar ranges.
328
472
  # Keep every request inside the provider's 92-day limit.
329
473
  window_start = from_date
@@ -331,34 +475,54 @@ class GarminStagingWorker:
331
475
  # ranges. Keep it deliberately small; the captures are
332
476
  # content-addressed so overlapping retry windows are harmless.
333
477
  window_days = 7 if endpoint == "body_battery" else (90 if endpoint == "menstrual_calendar" else (to_date - from_date).days + 1)
334
- if self.progress is not None:
335
- self.progress.advance(step=endpoint)
336
478
  while window_start <= to_date:
337
- self._check_interrupted()
338
479
  window_end = min(to_date, window_start + timedelta(days=window_days - 1))
339
- start, end = window_start.isoformat(), window_end.isoformat()
340
- payload, raw_hash = self._capture(endpoint, lambda method=method, start=start, end=end: getattr(self.api, method)(start, end), None, {"from": start, "to": end})
341
- if payload is not None:
342
- self._entity(endpoint, entity_type, payload, raw_hash, fallback_scope=start)
480
+ tasks.append((endpoint, method, entity_type, window_start.isoformat(), window_end.isoformat()))
343
481
  window_start = window_end + timedelta(days=1)
482
+ if self.progress is not None:
483
+ self.progress.advance(total=len(tasks))
484
+ completed = 0
485
+
486
+ def done() -> None:
487
+ nonlocal completed
488
+ completed += 1
344
489
  if self.progress is not None:
345
- self.progress.advance(completed=index)
490
+ self.progress.advance(completed=completed)
491
+
492
+ def fetch(task: tuple[str, str, str, str, str]) -> None:
493
+ endpoint, method, entity_type, start, end = task
494
+ if self.progress is not None:
495
+ self.progress.advance(step=endpoint)
496
+ payload, raw_hash = self._capture(endpoint, lambda method=method, start=start, end=end: getattr(self.api, method)(start, end), None, {"from": start, "to": end})
497
+ if payload is not None:
498
+ self._entity(endpoint, entity_type, payload, raw_hash, fallback_scope=start)
499
+
500
+ self._map(tasks, fetch, done)
346
501
 
347
502
  def _cycling_ftp_history(self, from_date: date, to_date: date) -> None:
348
503
  """Stage daily cycling FTP setting history from Garmin's range endpoint."""
349
504
  endpoint = "functional_threshold_power_range"
505
+ windows: list[tuple[str, str]] = []
350
506
  window_start = from_date
351
- total_windows = (to_date - from_date).days // 89 + 1
352
- if self.progress is not None:
353
- self.progress.set_stage("ftp_history")
354
- self.progress.advance(total=total_windows)
355
- completed_windows = 0
356
507
  while window_start <= to_date:
357
- self._check_interrupted()
358
508
  # This private Connect endpoint is not documented with a maximum
359
509
  # range. Keep requests aligned with other conservative range calls.
360
510
  window_end = min(to_date, window_start + timedelta(days=89))
361
- start, end = window_start.isoformat(), window_end.isoformat()
511
+ windows.append((window_start.isoformat(), window_end.isoformat()))
512
+ window_start = window_end + timedelta(days=1)
513
+ if self.progress is not None:
514
+ self.progress.set_stage("ftp_history")
515
+ self.progress.advance(total=len(windows))
516
+ completed = 0
517
+
518
+ def done() -> None:
519
+ nonlocal completed
520
+ completed += 1
521
+ if self.progress is not None:
522
+ self.progress.advance(completed=completed)
523
+
524
+ def fetch(window: tuple[str, str]) -> None:
525
+ start, end = window
362
526
  if self.progress is not None:
363
527
  self.progress.advance(step=start)
364
528
  payload, raw_hash = self._capture(
@@ -389,24 +553,31 @@ class GarminStagingWorker:
389
553
  raw_hash,
390
554
  occurred_on=occurred_on,
391
555
  )
392
- completed_windows += 1
393
- if self.progress is not None:
394
- self.progress.advance(completed=completed_windows)
395
- window_start = window_end + timedelta(days=1)
556
+
557
+ self._map(windows, fetch, done)
396
558
 
397
559
  def _lactate_threshold_history(self, from_date: date, to_date: date) -> None:
398
560
  """Stage daily running lactate-threshold history from Garmin's range endpoints."""
561
+ windows: list[tuple[str, str]] = []
399
562
  window_start = from_date
400
- total_windows = (to_date - from_date).days // 365 + 1
401
- if self.progress is not None:
402
- self.progress.set_stage("lt_history")
403
- self.progress.advance(total=total_windows)
404
- completed_windows = 0
405
563
  while window_start <= to_date:
406
- self._check_interrupted()
407
564
  # Garmin rejects range windows longer than 366 days.
408
565
  window_end = min(to_date, window_start + timedelta(days=365))
409
- start, end = window_start.isoformat(), window_end.isoformat()
566
+ windows.append((window_start.isoformat(), window_end.isoformat()))
567
+ window_start = window_end + timedelta(days=1)
568
+ if self.progress is not None:
569
+ self.progress.set_stage("lt_history")
570
+ self.progress.advance(total=len(windows))
571
+ completed = 0
572
+
573
+ def done() -> None:
574
+ nonlocal completed
575
+ completed += 1
576
+ if self.progress is not None:
577
+ self.progress.advance(completed=completed)
578
+
579
+ def fetch(window: tuple[str, str]) -> None:
580
+ start, end = window
410
581
  if self.progress is not None:
411
582
  self.progress.advance(step=start)
412
583
  payload, raw_hash = self._capture(
@@ -424,23 +595,31 @@ class GarminStagingWorker:
424
595
  raw_hash,
425
596
  occurred_on=item_day,
426
597
  )
427
- completed_windows += 1
428
- if self.progress is not None:
429
- self.progress.advance(completed=completed_windows)
430
- window_start = window_end + timedelta(days=1)
598
+
599
+ self._map(windows, fetch, done)
431
600
 
432
601
  def _max_metrics_history(self, from_date: date, to_date: date) -> None:
433
602
  """Stage range max-metric responses by their actual calendar date."""
603
+ windows: list[tuple[str, str]] = []
434
604
  window_start = from_date
435
- total_windows = (to_date - from_date).days // 89 + 1
436
- if self.progress is not None:
437
- self.progress.set_stage("max_metrics")
438
- self.progress.advance(total=total_windows)
439
- completed_windows = 0
440
605
  while window_start <= to_date:
441
- self._check_interrupted()
442
606
  window_end = min(to_date, window_start + timedelta(days=89))
443
- start, end = window_start.isoformat(), window_end.isoformat()
607
+ windows.append((window_start.isoformat(), window_end.isoformat()))
608
+ window_start = window_end + timedelta(days=1)
609
+ if self.progress is not None:
610
+ self.progress.set_stage("max_metrics")
611
+ self.progress.advance(total=len(windows))
612
+ completed = 0
613
+ observed_by_window: list[set[str]] = [set() for _ in windows]
614
+
615
+ def done() -> None:
616
+ nonlocal completed
617
+ completed += 1
618
+ if self.progress is not None:
619
+ self.progress.advance(completed=completed)
620
+
621
+ def fetch_range(task: tuple[int, tuple[str, str]]) -> None:
622
+ index, (start, end) = task
444
623
  if self.progress is not None:
445
624
  self.progress.advance(step=start)
446
625
  payload, raw_hash = self._capture(
@@ -449,44 +628,64 @@ class GarminStagingWorker:
449
628
  None,
450
629
  {"from": start, "to": end},
451
630
  )
452
- observed: set[str] = set()
453
631
  if payload is not None:
454
632
  for item in as_items(payload):
455
633
  item_day = payload_date(item)
456
634
  if item_day:
457
- observed.add(item_day)
635
+ observed_by_window[index].add(item_day)
458
636
  self.writer.source_entity("max_metric", f"max_metrics:{item_day}", item, raw_hash, occurred_on=item_day)
459
- current = window_start
637
+
638
+ self._map(list(enumerate(windows)), fetch_range, done)
639
+
640
+ # Days the provider omitted from a range response are retried one by
641
+ # one; a missing range day is authoritative for that window only.
642
+ fallback_days: list[str] = []
643
+ for (start, end), observed in zip(windows, observed_by_window):
644
+ current = date.fromisoformat(start)
645
+ window_end = date.fromisoformat(end)
460
646
  while current <= window_end:
461
647
  day = current.isoformat()
462
648
  if day not in observed:
463
- fallback, fallback_hash = self._capture(
464
- "max_metrics",
465
- lambda day=day: self.api.get_max_metrics(day),
466
- day,
467
- {"date": day, "fallback": "range-missing"},
468
- )
469
- if fallback is not None:
470
- for item in as_items(fallback):
471
- self.writer.source_entity("max_metric", f"max_metrics:{day}", item, fallback_hash, occurred_on=day)
649
+ fallback_days.append(day)
472
650
  current += timedelta(days=1)
473
- completed_windows += 1
474
- if self.progress is not None:
475
- self.progress.advance(completed=completed_windows)
476
- window_start = window_end + timedelta(days=1)
651
+ if self.progress is not None:
652
+ self.progress.advance(total=len(windows) + len(fallback_days))
653
+
654
+ def fetch_fallback(day: str) -> None:
655
+ fallback, fallback_hash = self._capture(
656
+ "max_metrics",
657
+ lambda day=day: self.api.get_max_metrics(day),
658
+ day,
659
+ {"date": day, "fallback": "range-missing"},
660
+ )
661
+ if fallback is not None:
662
+ for item in as_items(fallback):
663
+ self.writer.source_entity("max_metric", f"max_metrics:{day}", item, fallback_hash, occurred_on=day)
664
+
665
+ self._map(fallback_days, fetch_fallback, done)
477
666
 
478
667
  def _hrv_history(self, from_date: date, to_date: date) -> None:
479
668
  """Stage the PR-402 HRV range API, retaining daily fallback coverage."""
669
+ windows: list[tuple[str, str]] = []
480
670
  window_start = from_date
481
- total_windows = (to_date - from_date).days // 89 + 1
482
- if self.progress is not None:
483
- self.progress.set_stage("hrv_history")
484
- self.progress.advance(total=total_windows)
485
- completed_windows = 0
486
671
  while window_start <= to_date:
487
- self._check_interrupted()
488
672
  window_end = min(to_date, window_start + timedelta(days=89))
489
- start, end = window_start.isoformat(), window_end.isoformat()
673
+ windows.append((window_start.isoformat(), window_end.isoformat()))
674
+ window_start = window_end + timedelta(days=1)
675
+ if self.progress is not None:
676
+ self.progress.set_stage("hrv_history")
677
+ self.progress.advance(total=len(windows))
678
+ completed = 0
679
+ observed_by_window: list[set[str]] = [set() for _ in windows]
680
+
681
+ def done() -> None:
682
+ nonlocal completed
683
+ completed += 1
684
+ if self.progress is not None:
685
+ self.progress.advance(completed=completed)
686
+
687
+ def fetch_range(task: tuple[int, tuple[str, str]]) -> None:
688
+ index, (start, end) = task
490
689
  if self.progress is not None:
491
690
  self.progress.advance(step=start)
492
691
  payload, raw_hash = self._capture(
@@ -495,65 +694,85 @@ class GarminStagingWorker:
495
694
  None,
496
695
  {"from": start, "to": end},
497
696
  )
498
- observed: set[str] = set()
499
697
  if payload is not None:
500
698
  for item in as_items(payload):
501
699
  item_day = payload_date(item)
502
700
  if item_day:
503
- observed.add(item_day)
701
+ observed_by_window[index].add(item_day)
504
702
  self.writer.source_entity("daily_health", f"hrv:{item_day}", item, raw_hash, occurred_on=item_day)
505
- current = window_start
703
+
704
+ self._map(list(enumerate(windows)), fetch_range, done)
705
+
706
+ fallback_days: list[str] = []
707
+ for (start, end), observed in zip(windows, observed_by_window):
708
+ current = date.fromisoformat(start)
709
+ window_end = date.fromisoformat(end)
506
710
  while current <= window_end:
507
711
  day = current.isoformat()
508
712
  if day not in observed:
509
- fallback, fallback_hash = self._capture("hrv", lambda day=day: self.api.get_hrv_data(day), day, {"date": day, "fallback": "range-missing"})
510
- if fallback is not None:
511
- self.writer.source_entity("daily_health", f"hrv:{day}", as_dict(fallback), fallback_hash, occurred_on=day)
713
+ fallback_days.append(day)
512
714
  current += timedelta(days=1)
513
- completed_windows += 1
514
- if self.progress is not None:
515
- self.progress.advance(completed=completed_windows)
516
- window_start = window_end + timedelta(days=1)
715
+ if self.progress is not None:
716
+ self.progress.advance(total=len(windows) + len(fallback_days))
717
+
718
+ def fetch_fallback(day: str) -> None:
719
+ fallback, fallback_hash = self._capture("hrv", lambda day=day: self.api.get_hrv_data(day), day, {"date": day, "fallback": "range-missing"})
720
+ if fallback is not None:
721
+ self.writer.source_entity("daily_health", f"hrv:{day}", as_dict(fallback), fallback_hash, occurred_on=day)
722
+
723
+ self._map(fallback_days, fetch_fallback, done)
517
724
 
518
725
  def _score_history(self, from_date: date, to_date: date) -> None:
519
726
  """Stage daily-capable endurance-adjacent metrics from range endpoints."""
727
+ windows: list[tuple[str, str]] = []
520
728
  window_start = from_date
521
- total_windows = (to_date - from_date).days // 89 + 1
522
- if self.progress is not None:
523
- self.progress.set_stage("scores")
524
- self.progress.advance(total=total_windows)
525
- completed_windows = 0
526
729
  while window_start <= to_date:
527
- self._check_interrupted()
528
730
  window_end = min(to_date, window_start + timedelta(days=89))
529
- start, end = window_start.isoformat(), window_end.isoformat()
731
+ windows.append((window_start.isoformat(), window_end.isoformat()))
732
+ window_start = window_end + timedelta(days=1)
733
+ tasks = [(endpoint, start, end) for start, end in windows for endpoint in ("hill_score", "running_tolerance")]
734
+ if self.progress is not None:
735
+ self.progress.set_stage("scores")
736
+ self.progress.advance(total=len(tasks))
737
+ completed = 0
738
+
739
+ def done() -> None:
740
+ nonlocal completed
741
+ completed += 1
530
742
  if self.progress is not None:
531
- self.progress.advance(step=start)
532
- for endpoint, method, entity_type in (
533
- ("hill_score", lambda: self.api.get_hill_score(start, end), "hill_score"),
534
- ("running_tolerance", lambda: self.api.get_running_tolerance(start, end, aggregation="daily"), "running_tolerance"),
535
- ):
536
- payload, raw_hash = self._capture(endpoint, method, None, {"from": start, "to": end, "aggregation": "daily"})
537
- if payload is not None:
538
- for index, item in enumerate(as_items(payload)):
539
- item_day = payload_date(item)
540
- if item_day:
541
- self.writer.source_entity(entity_type, f"{endpoint}:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
542
- completed_windows += 1
743
+ self.progress.advance(completed=completed)
744
+
745
+ def fetch(task: tuple[str, str, str]) -> None:
746
+ endpoint, start, end = task
543
747
  if self.progress is not None:
544
- self.progress.advance(completed=completed_windows)
545
- window_start = window_end + timedelta(days=1)
748
+ self.progress.advance(step=start)
749
+ if endpoint == "hill_score":
750
+ method = lambda start=start, end=end: self.api.get_hill_score(start, end)
751
+ entity_type = "hill_score"
752
+ else:
753
+ method = lambda start=start, end=end: self.api.get_running_tolerance(start, end, aggregation="daily")
754
+ entity_type = "running_tolerance"
755
+ payload, raw_hash = self._capture(endpoint, method, None, {"from": start, "to": end, "aggregation": "daily"})
756
+ if payload is not None:
757
+ for index, item in enumerate(as_items(payload)):
758
+ item_day = payload_date(item)
759
+ if item_day:
760
+ self.writer.source_entity(entity_type, f"{endpoint}:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
761
+
762
+ self._map(tasks, fetch, done)
546
763
 
547
764
  # Garmin limits daily race-prediction windows to one year.
765
+ race_windows: list[tuple[str, str]] = []
548
766
  window_start = from_date
549
- total_windows = (to_date - from_date).days // 365 + 1
550
- if self.progress is not None:
551
- self.progress.advance(total=total_windows)
552
- completed_windows = 0
553
767
  while window_start <= to_date:
554
- self._check_interrupted()
555
768
  window_end = min(to_date, window_start + timedelta(days=365))
556
- start, end = window_start.isoformat(), window_end.isoformat()
769
+ race_windows.append((window_start.isoformat(), window_end.isoformat()))
770
+ window_start = window_end + timedelta(days=1)
771
+ if self.progress is not None:
772
+ self.progress.advance(total=len(tasks) + len(race_windows))
773
+
774
+ def fetch_race(window: tuple[str, str]) -> None:
775
+ start, end = window
557
776
  if self.progress is not None:
558
777
  self.progress.advance(step=start)
559
778
  payload, raw_hash = self._capture("race_predictions", lambda start=start, end=end: self.api.get_race_predictions(start, end, "daily"), None, {"from": start, "to": end, "aggregation": "daily"})
@@ -561,10 +780,8 @@ class GarminStagingWorker:
561
780
  for index, item in enumerate(as_items(payload)):
562
781
  item_day = payload_date(item) or start
563
782
  self.writer.source_entity("race_prediction", f"race_prediction:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
564
- completed_windows += 1
565
- if self.progress is not None:
566
- self.progress.advance(completed=completed_windows)
567
- window_start = window_end + timedelta(days=1)
783
+
784
+ self._map(race_windows, fetch_race, done)
568
785
 
569
786
  def _activities(self, from_date: date, to_date: date) -> None:
570
787
  start, end = from_date.isoformat(), to_date.isoformat()
@@ -577,8 +794,15 @@ class GarminStagingWorker:
577
794
  summaries = as_items(payload)
578
795
  if self.progress is not None:
579
796
  self.progress.advance(total=len(summaries))
580
- for index, summary in enumerate(summaries, start=1):
581
- self._check_interrupted()
797
+ completed = 0
798
+
799
+ def done() -> None:
800
+ nonlocal completed
801
+ completed += 1
802
+ if self.progress is not None:
803
+ self.progress.advance(completed=completed)
804
+
805
+ def fetch(summary: dict[str, Any]) -> None:
582
806
  activity_id = remote_id(summary, "unknown")
583
807
  if self.progress is not None:
584
808
  self.progress.advance(step=activity_id)
@@ -587,8 +811,8 @@ class GarminStagingWorker:
587
811
  if details_fetched:
588
812
  self._activity_details(activity_id, summary)
589
813
  self.writer.activity_sync_state(activity_id, summary_hash, details_fetched)
590
- if self.progress is not None:
591
- self.progress.advance(completed=index)
814
+
815
+ self._map(summaries, fetch, done)
592
816
 
593
817
  def _multisport_children(self, parent_activity_id: str, activity: dict[str, Any]) -> None:
594
818
  if not activity.get("isMultiSportParent"):
@@ -669,27 +893,25 @@ class GarminStagingWorker:
669
893
  def _activity_files(self, activity_id: str, summary: dict[str, Any], activity: dict[str, Any] | None, details: dict[str, Any] | None) -> None:
670
894
  try:
671
895
  from garminconnect import Garmin
672
- formats = {
673
- "activity_original": Garmin.ActivityDownloadFormat.ORIGINAL,
674
- "activity_tcx": Garmin.ActivityDownloadFormat.TCX,
675
- "activity_gpx": Garmin.ActivityDownloadFormat.GPX,
676
- "activity_kml": Garmin.ActivityDownloadFormat.KML,
677
- "activity_csv": Garmin.ActivityDownloadFormat.CSV,
678
- }
896
+ original_format = Garmin.ActivityDownloadFormat.ORIGINAL
679
897
  except ImportError as error:
680
898
  self.writer.error("activity_files", str(error), activity_id, False)
681
899
  return
682
900
  original_path: Path | None = None
683
901
  original_hash: str | None = None
684
- for endpoint, format_value in formats.items():
685
- try:
686
- contents = self.api.download_activity(activity_id, format_value)
687
- digest = self.writer.archive_bytes(endpoint, activity_id, contents, "zip" if endpoint == "activity_original" else endpoint.rsplit("_", 1)[1])
688
- if endpoint == "activity_original":
689
- original_path = self.data_dir / "raw" / "garmin" / endpoint / activity_id / f"{digest}.zip"
690
- original_hash = digest
691
- except Exception as error:
692
- self.writer.error(endpoint, str(error), activity_id)
902
+ # Only the ORIGINAL archive (.FIT inside a zip) is retained: streams and
903
+ # swim lengths come from it, and the TCX/GPX/KML/CSV renderings add four
904
+ # requests per changed activity without feeding any importer path.
905
+ try:
906
+ contents = self._request(lambda: self.api.download_activity(activity_id, original_format))
907
+ original_hash = self.writer.archive_bytes("activity_original", activity_id, contents, "zip")
908
+ original_path = self.data_dir / "raw" / "garmin" / "activity_original" / activity_id / f"{original_hash}.zip"
909
+ self._note_capture("activity_original", False)
910
+ except WorkerInterrupted:
911
+ raise
912
+ except Exception as error:
913
+ self._note_capture("activity_original", True)
914
+ self.writer.error("activity_original", str(error), activity_id)
693
915
  samples = activity_details_to_samples(f"garmin:{activity_id}", details or {})
694
916
  if not samples and original_path and original_path.exists():
695
917
  samples = fit_archive_to_samples(f"garmin:{activity_id}", original_path)
@@ -725,22 +947,6 @@ class GarminStagingWorker:
725
947
  occurred_on=start_date[:10],
726
948
  )
727
949
 
728
- def _fetch_garmin_events(self, start_date: str) -> None:
729
- """Fetch calendar events from start_date, following pagination."""
730
- page = 1
731
- while True:
732
- payload, raw_hash = self._capture(
733
- "garmin_events",
734
- lambda page=page: self.api.get_events(start_date, limit=100, page=page),
735
- scope={"startDate": start_date, "page": page},
736
- )
737
- items = payload if isinstance(payload, list) else []
738
- if items:
739
- self._entity("garmin_events", "event", items, raw_hash)
740
- if len(items) < 100:
741
- return
742
- page += 1
743
-
744
950
  def _collections(self, events_from: str | None = None) -> None:
745
951
  today = date.today()
746
952
  calls = {
@@ -752,23 +958,30 @@ class GarminStagingWorker:
752
958
  if self.progress is not None:
753
959
  self.progress.set_stage("collections")
754
960
  self.progress.advance(total=len(calls) + len(months) + 1)
755
- for index, (endpoint, (method, arguments, entity_type)) in enumerate(calls.items(), start=1):
756
- self._check_interrupted()
961
+ completed = 0
962
+
963
+ def done() -> None:
964
+ nonlocal completed
965
+ completed += 1
966
+ if self.progress is not None:
967
+ self.progress.advance(completed=completed)
968
+
969
+ def fetch_call(item: tuple[str, tuple[str, tuple[Any, ...], str]]) -> None:
970
+ endpoint, (method, arguments, entity_type) = item
757
971
  if self.progress is not None:
758
972
  self.progress.advance(step=endpoint)
759
973
  payload, raw_hash = self._capture(endpoint, lambda method=method, arguments=arguments: getattr(self.api, method)(*arguments))
760
974
  if payload is not None:
761
975
  self._entity(endpoint, entity_type, payload, raw_hash)
762
- if self.progress is not None:
763
- self.progress.advance(completed=index)
976
+
977
+ self._map(list(calls.items()), fetch_call, done)
764
978
  # Scheduled workouts span past-to-future months so upcoming sessions
765
979
  # surface ahead of time and recently completed ones stay associatable
766
980
  # with their finished activities. The calendar service answers each
767
981
  # month with a grid wrapper whose items live under `calendarItems`;
768
982
  # empty months must stage nothing, and real items stage one row each
769
983
  # with their own calendar date.
770
- for month_index, month in enumerate(months, start=1):
771
- self._check_interrupted()
984
+ def fetch_month(month: date) -> None:
772
985
  payload, raw_hash = self._capture(
773
986
  "scheduled_workouts",
774
987
  lambda month=month: self.api.get_scheduled_workouts(month.year, month.month),
@@ -782,17 +995,18 @@ class GarminStagingWorker:
782
995
  items = [item for item in items if isinstance(item, Mapping) and item.get("itemType") == "workout"]
783
996
  if items:
784
997
  self._entity("scheduled_workouts", "scheduled_workout", items, raw_hash)
785
- if self.progress is not None:
786
- self.progress.advance(completed=len(calls) + month_index)
998
+
999
+ self._map(months, fetch_month, done)
787
1000
  # Joined events (races etc.) look back only far enough to associate
788
1001
  # finished activities; the parent widens start_date into a one-time
789
1002
  # coverage backfill when the store predates this span.
790
- self._check_interrupted()
791
1003
  if self.progress is not None:
792
1004
  self.progress.advance(step="garmin_events")
793
- self._fetch_garmin_events(events_start)
794
- if self.progress is not None:
795
- self.progress.advance(completed=len(calls) + len(months) + 1)
1005
+
1006
+ def fetch_events(_: str) -> None:
1007
+ self._fetch_garmin_events(events_start)
1008
+
1009
+ self._map([events_start], fetch_events, done)
796
1010
 
797
1011
  def stage_course(self, course_id: str) -> None:
798
1012
  """Fetch one Garmin course by id and stage it as a source entity.