catence 0.2.0-beta.9 → 0.2.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 (71) hide show
  1. package/README.md +38 -180
  2. package/dist/contracts/index.d.ts +1 -0
  3. package/dist/contracts/index.js +1 -0
  4. package/dist/contracts/progress.d.ts +23 -0
  5. package/dist/contracts/progress.js +49 -0
  6. package/dist/contracts/release.d.ts +1 -1
  7. package/dist/contracts/release.js +1 -1
  8. package/dist/core/query/activity-discovery.d.ts +2 -0
  9. package/dist/core/query/activity-discovery.js +1 -1
  10. package/dist/core/query/analytics.d.ts +10 -0
  11. package/dist/core/query/analytics.js +494 -0
  12. package/dist/core/query/catalog.d.ts +1 -1
  13. package/dist/core/query/catalog.js +8 -4
  14. package/dist/core/query/events.d.ts +34 -0
  15. package/dist/core/query/events.js +121 -0
  16. package/dist/core/query/fitness.d.ts +4 -0
  17. package/dist/core/query/fitness.js +60 -0
  18. package/dist/core/query/repository.d.ts +2 -0
  19. package/dist/core/query/repository.js +48 -0
  20. package/dist/core/query/swimming.js +2 -2
  21. package/dist/core/runtime/configuration.d.ts +3 -1
  22. package/dist/core/runtime/configuration.js +5 -1
  23. package/dist/core/runtime/opencode-go.d.ts +58 -0
  24. package/dist/core/runtime/opencode-go.js +246 -0
  25. package/dist/core/runtime/secrets.js +51 -13
  26. package/dist/elt/application/detached-sync.d.ts +50 -0
  27. package/dist/elt/application/detached-sync.js +99 -0
  28. package/dist/elt/application/management.d.ts +15 -0
  29. package/dist/elt/application/management.js +420 -18
  30. package/dist/elt/ingestion/importer.js +1 -1
  31. package/dist/elt/ingestion/providers/intervals.d.ts +9 -0
  32. package/dist/elt/ingestion/providers/intervals.js +33 -1
  33. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  34. package/dist/elt/ingestion/providers/registry.js +19 -1
  35. package/dist/elt/normalization/normalizers.js +207 -15
  36. package/dist/elt/storage/database.d.ts +21 -2
  37. package/dist/elt/storage/database.js +182 -6
  38. package/dist/elt/storage/migrations.js +145 -1
  39. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  40. package/dist/elt/storage/progress-sidecar.js +200 -0
  41. package/dist/interfaces/cli/commands.js +94 -1
  42. package/dist/interfaces/http/generation-status.d.ts +19 -0
  43. package/dist/interfaces/http/generation-status.js +55 -0
  44. package/dist/interfaces/http/server.d.ts +7 -1
  45. package/dist/interfaces/http/server.js +184 -1
  46. package/dist/interfaces/mcp/main.js +33 -1
  47. package/dist/interfaces/mcp/server.d.ts +2 -1
  48. package/dist/interfaces/mcp/server.js +168 -45
  49. package/dist/runtime/index.d.ts +9 -2
  50. package/dist/runtime/index.js +6 -2
  51. package/docs/architecture.md +13 -2
  52. package/docs/assets/image.png +0 -0
  53. package/docs/configuration.md +243 -0
  54. package/docs/deployment/docker.md +528 -0
  55. package/docs/deployment/local-console.md +263 -0
  56. package/docs/deployment/local-mcp.md +329 -0
  57. package/docs/development.md +337 -0
  58. package/docs/distribution.md +45 -0
  59. package/docs/llm-providers.md +24 -2
  60. package/docs/mcp-activity-retrieval.md +90 -210
  61. package/package.json +1 -1
  62. package/pyproject.toml +1 -1
  63. package/python/catence/providers/garmin/cli.py +43 -9
  64. package/python/catence/providers/garmin/progress.py +85 -0
  65. package/python/catence/providers/garmin/registry.py +2 -0
  66. package/python/catence/providers/garmin/streams.py +38 -0
  67. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  68. package/python/catence/providers/garmin/worker.py +415 -12
  69. package/scripts/discover-opencode-go.mjs +36 -144
  70. package/uv.lock +3 -3
  71. package/docs/deployment.md +0 -338
@@ -63,7 +63,8 @@ function swimSourceLabel(payload) {
63
63
  function datePart(value) {
64
64
  if (!value)
65
65
  return null;
66
- const match = value.match(/^\d{4}-\d{2}-\d{2}/);
66
+ const s = typeof value === 'string' ? value : value.toISOString();
67
+ const match = s.match(/^\d{4}-\d{2}-\d{2}/);
67
68
  return match?.[0] ?? null;
68
69
  }
69
70
  function providerActivityId(provider, remoteId) {
@@ -143,6 +144,8 @@ function snake(value) {
143
144
  return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
144
145
  }
145
146
  function timestamp(value) {
147
+ if (value instanceof Date)
148
+ return value.toISOString();
146
149
  if (typeof value === 'string' && value.length >= 10)
147
150
  return value;
148
151
  if (typeof value !== 'number' || !Number.isFinite(value))
@@ -163,30 +166,69 @@ async function upsertDailyMetric(database, provider, metricDate, metricName, val
163
166
  ON CONFLICT (provider, metric_date, metric_name) DO UPDATE SET value_number = excluded.value_number, value_text = excluded.value_text,
164
167
  unit = excluded.unit, raw_object_hash = excluded.raw_object_hash`, { provider, metricDate, metricName, valueNumber, valueText, unit, rawHash });
165
168
  }
169
+ /**
170
+ * Garmin's nutrition food log keeps its actuals under dailyNutritionContent
171
+ * (a per-day rolled-up object), while meal definitions and hydration endpoints
172
+ * share the same source entity type. Flat keys are kept for providers that
173
+ * return them directly.
174
+ */
166
175
  function nutritionFields(payload) {
176
+ const content = object(payload.dailyNutritionContent);
167
177
  return {
168
- energyKcal: firstNumber(payload, ['kcalConsumed', 'calories', 'totalCalories', 'energyKcal']),
169
- carbohydratesG: firstNumber(payload, ['carbohydrates', 'carbs', 'totalCarbs', 'carbohydrateGrams']),
170
- proteinG: firstNumber(payload, ['protein', 'totalProtein', 'proteinGrams']),
171
- fatG: firstNumber(payload, ['fatTotal', 'fat', 'totalFat', 'fatGrams']),
172
- hydrationMl: firstNumber(payload, ['hydrationVolume', 'hydration', 'waterMl', 'totalWater']),
178
+ energyKcal: firstNumber(payload, ['kcalConsumed', 'calories', 'totalCalories', 'energyKcal']) ?? firstNumber(content, ['calories', 'energyKcal', 'totalCalories']),
179
+ carbohydratesG: firstNumber(payload, ['carbohydrates', 'carbs', 'totalCarbs', 'carbohydrateGrams']) ?? firstNumber(content, ['carbohydrates', 'carbs']),
180
+ proteinG: firstNumber(payload, ['protein', 'totalProtein', 'proteinGrams']) ?? firstNumber(content, ['protein']),
181
+ fatG: firstNumber(payload, ['fatTotal', 'fat', 'totalFat', 'fatGrams']) ?? firstNumber(content, ['fat', 'fatTotal']),
182
+ hydrationMl: firstNumber(payload, ['hydrationVolume', 'hydration', 'waterMl', 'totalWater', 'valueInML', 'dailyAverageinML']),
173
183
  };
174
184
  }
185
+ /**
186
+ * Garmin food-log items live under mealDetails[].foods[]. Each food carries the
187
+ * day's nutrition_date, so a meal label is folded onto the item to avoid losing
188
+ * it. Flat arrays are kept for providers that return items directly.
189
+ */
175
190
  function nutritionItems(payload) {
176
- const candidates = ['foodItems', 'items', 'entries', 'foodLogEntries', 'meals'];
177
- for (const key of candidates) {
191
+ const items = [];
192
+ const mealDetails = payload.mealDetails;
193
+ if (Array.isArray(mealDetails)) {
194
+ for (const detail of mealDetails) {
195
+ const mealDetail = object(detail);
196
+ const mealName = firstString(object(mealDetail.meal), ['mealName']) ?? firstString(mealDetail, ['mealName']);
197
+ const foods = mealDetail.foods;
198
+ if (Array.isArray(foods)) {
199
+ for (const food of foods) {
200
+ const item = object(food);
201
+ if (Object.keys(item).length === 0)
202
+ continue;
203
+ items.push(mealName ? { ...item, mealName } : item);
204
+ }
205
+ }
206
+ else if (Object.keys(mealDetail).length > 0 && firstString(mealDetail, ['foodName', 'name', 'description']) !== null) {
207
+ items.push(mealName ? { ...mealDetail, mealName } : mealDetail);
208
+ }
209
+ }
210
+ if (items.length > 0)
211
+ return items;
212
+ }
213
+ for (const key of ['foodItems', 'items', 'entries', 'foodLogEntries', 'meals']) {
178
214
  const value = payload[key];
179
215
  if (Array.isArray(value))
180
216
  return value.map(object).filter((item) => Object.keys(item).length > 0);
181
217
  }
182
218
  return [];
183
219
  }
220
+ /** True when a payload contributes real nutrition facts rather than meal definitions or hydration-only bookkeeping. */
221
+ function hasNutritionContent(payload) {
222
+ const fields = nutritionFields(payload);
223
+ return fields.energyKcal !== null || fields.carbohydratesG !== null || fields.proteinG !== null || fields.fatG !== null
224
+ || fields.hydrationMl !== null || nutritionItems(payload).length > 0;
225
+ }
184
226
  export async function importSourceEntity(database, entity) {
185
227
  const payload = object(entity.payload);
186
228
  const rawHash = entity.rawObjectHash;
187
229
  await database.run(`INSERT INTO source_entities
188
230
  (provider, entity_type, remote_id, parent_remote_id, occurred_on, source_updated_at, raw_object_hash, payload_json, extension_json)
189
- VALUES ($provider, $entityType, $remoteId, $parentRemoteId, $occurredOn, $sourceUpdatedAt, $rawHash, $payload, $extension)
231
+ VALUES ($provider::VARCHAR, $entityType::VARCHAR, $remoteId::VARCHAR, $parentRemoteId::VARCHAR, $occurredOn::TIMESTAMPTZ, $sourceUpdatedAt::TIMESTAMPTZ, $rawHash::VARCHAR, $payload::JSON, $extension::JSON)
190
232
  ON CONFLICT (provider, entity_type, remote_id) DO UPDATE SET
191
233
  parent_remote_id = excluded.parent_remote_id, occurred_on = excluded.occurred_on,
192
234
  source_updated_at = excluded.source_updated_at, raw_object_hash = excluded.raw_object_hash,
@@ -197,7 +239,7 @@ export async function importSourceEntity(database, entity) {
197
239
  });
198
240
  await database.run(`INSERT INTO domain_entities
199
241
  (provider, entity_type, remote_id, parent_remote_id, occurred_on, payload_json, extension_json, raw_object_hash)
200
- VALUES ($provider, $entityType, $remoteId, $parentRemoteId, $occurredOn, $payload, $extension, $rawHash)
242
+ VALUES ($provider::VARCHAR, $entityType::VARCHAR, $remoteId::VARCHAR, $parentRemoteId::VARCHAR, $occurredOn::TIMESTAMPTZ, $payload::JSON, $extension::JSON, $rawHash::VARCHAR)
201
243
  ON CONFLICT (provider, entity_type, remote_id) DO UPDATE SET
202
244
  parent_remote_id = excluded.parent_remote_id, occurred_on = excluded.occurred_on,
203
245
  payload_json = excluded.payload_json, extension_json = excluded.extension_json, raw_object_hash = excluded.raw_object_hash`, {
@@ -211,6 +253,7 @@ export async function importSourceEntity(database, entity) {
211
253
  }
212
254
  if (entity.entityType === 'activity') {
213
255
  await importActivity(database, entity.provider, entity.remoteId, payload, rawHash);
256
+ await importActivityDecoupling(database, entity.provider, entity.remoteId, payload, rawHash);
214
257
  if (entity.provider === 'intervals')
215
258
  await importIntervalsAutoSwimSets(database, entity.remoteId, payload, rawHash);
216
259
  if (entity.provider === 'garmin') {
@@ -223,6 +266,9 @@ export async function importSourceEntity(database, entity) {
223
266
  if (entity.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set'].includes(entity.entityType)) {
224
267
  await importGarminExplicitSwimLengths(database, entity.parentRemoteId, entity.entityType, payload, rawHash);
225
268
  }
269
+ if (entity.provider === 'garmin' && entity.entityType === 'activity_swim_lengths') {
270
+ await importGarminFitSwimLengths(database, entity.parentRemoteId, payload, rawHash);
271
+ }
226
272
  if (entity.entityType === 'wellness' || entity.entityType === 'daily_health')
227
273
  await importDailyMetrics(database, entity.provider, entity.remoteId, entity.occurredOn, payload, rawHash);
228
274
  if (entity.entityType === 'nutrition_day' || entity.entityType === 'nutrition_log')
@@ -245,6 +291,8 @@ export async function importSourceEntity(database, entity) {
245
291
  await importHealthSession(database, entity.remoteId, entity.occurredOn, 'daily_event', payload, rawHash);
246
292
  if (entity.provider === 'garmin' && entity.entityType === 'activity_power_best')
247
293
  await importActivityPowerBest(database, entity.parentRemoteId, payload, rawHash);
294
+ if (entity.provider === 'garmin' && entity.entityType === 'course')
295
+ await importGarminCourse(database, entity.remoteId, payload, rawHash);
248
296
  }
249
297
  async function importActivity(database, provider, remoteId, payload, rawHash) {
250
298
  const fields = activityFields(provider, payload);
@@ -372,7 +420,7 @@ function nested(payload, key) {
372
420
  return object(payload[key]);
373
421
  }
374
422
  function metricObservedAt(occurredOn, payload) {
375
- return firstTimestamp(payload, ['timestamp', 'timestampLocal', 'calendarDate', 'date']) ?? (occurredOn ? `${occurredOn}T00:00:00Z` : null);
423
+ return firstTimestamp(payload, ['timestamp', 'timestampLocal', 'calendarDate', 'date']) ?? (occurredOn ? `${datePart(occurredOn)}T00:00:00Z` : null);
376
424
  }
377
425
  async function importGarminActivityPerformance(database, remoteId, payload, rawHash) {
378
426
  const fields = activityFields('garmin', payload);
@@ -474,6 +522,99 @@ async function importGarminNumericSeries(database, entityType, remoteId, occurre
474
522
  });
475
523
  }
476
524
  }
525
+ const DECOUPLING_METRICS = [
526
+ { metric: 'aerobic_decoupling_pct', unit: 'pct', keys: ['decoupling', 'aerobicDecoupling', 'icu_decoupling', 'paHrDecoupling', 'pwHrDecoupling', 'aerobic_decoupling'] },
527
+ { metric: 'grade_adjusted_pace_s_per_km', unit: 's_per_km', keys: ['gap', 'gradeAdjustedPace', 'icu_gap', 'grade_adjusted_pace'] },
528
+ ];
529
+ /** Containers searched for provider-supplied decoupling/GAP: the payload itself,
530
+ * its common nested analysis/metrics objects, and the stored metrics_json. */
531
+ function decouplingContainers(payload) {
532
+ const containers = [payload];
533
+ for (const key of ['analysis', 'data', 'metrics', 'icuAnalysis', 'summaries']) {
534
+ const nested = payload[key];
535
+ if (nested !== null && typeof nested === 'object' && !Array.isArray(nested))
536
+ containers.push(nested);
537
+ }
538
+ const metricsJson = payload.metrics_json;
539
+ if (metricsJson !== undefined && metricsJson !== null)
540
+ containers.push(objectFromJson(metricsJson));
541
+ return containers;
542
+ }
543
+ /** A3 (persistence): persist provider-supplied aerobic decoupling (Pa:Hr) and
544
+ * grade-adjusted pace from an activity payload so they are queryable historically
545
+ * instead of only inside one activity's raw metrics_json. Provider values are
546
+ * stored as source_type 'provider' and are authoritative. Locally derived values
547
+ * (deriveDecoding/deriveGap) are a separate, still-derived path. */
548
+ async function importActivityDecoupling(database, provider, remoteId, payload, rawHash) {
549
+ const activitySourceId = providerActivityId(provider, remoteId);
550
+ const fields = activityFields(provider, payload);
551
+ const containers = decouplingContainers(payload);
552
+ for (const spec of DECOUPLING_METRICS) {
553
+ let value = null;
554
+ for (const container of containers) {
555
+ const found = firstNumber(container, spec.keys);
556
+ if (found !== null) {
557
+ value = found;
558
+ break;
559
+ }
560
+ }
561
+ if (value === null)
562
+ continue;
563
+ await database.run(`INSERT INTO activity_decoupling_facts (provider, activity_source_id, sport, started_at_utc, metric, value_number, unit, source_type, caveats_json, raw_object_hash)
564
+ VALUES ($provider, $activitySourceId, $sport, try_cast($startedAtUtc AS TIMESTAMPTZ), $metric, $value, $unit, 'provider', $caveats, $rawHash)
565
+ ON CONFLICT (provider, activity_source_id, metric) DO UPDATE SET
566
+ sport = excluded.sport, started_at_utc = excluded.started_at_utc, value_number = excluded.value_number,
567
+ unit = excluded.unit, source_type = excluded.source_type, caveats_json = excluded.caveats_json, raw_object_hash = excluded.raw_object_hash`, { provider, activitySourceId, sport: fields.sport, startedAtUtc: fields.startAtUtc, metric: spec.metric, value, unit: spec.unit, caveats: json({}), rawHash });
568
+ }
569
+ }
570
+ /** Pull a course's ordered geometry samples, tolerating the several shapes a
571
+ * Garmin course payload may use (a flat ``geoPoints`` array on the live course
572
+ * endpoint, legacy ``coursePoints``, or points nested under a geo/route object).
573
+ * Returns an empty list when no geometry is present. */
574
+ function courseGeometryPoints(payload) {
575
+ const candidates = [payload.geoPoints, payload.coursePoints, payload.geo, payload.points, payload.geometry, payload.locations, payload.geoRoute];
576
+ for (const candidate of candidates) {
577
+ if (Array.isArray(candidate))
578
+ return objectArray(candidate);
579
+ if (candidate !== null && typeof candidate === 'object' && !Array.isArray(candidate)) {
580
+ const obj = candidate;
581
+ for (const key of ['coursePoints', 'points', 'locations', 'coursePoint']) {
582
+ if (Array.isArray(obj[key]))
583
+ return objectArray(obj[key]);
584
+ }
585
+ }
586
+ }
587
+ return [];
588
+ }
589
+ /** A4: normalize Garmin course geometry into course_geometry so a race's
590
+ * elevation/grade profile can be resolved and diffed. The raw payload is also
591
+ * stored by importSourceEntity, so this only fans the points into rows. Live
592
+ * Garmin courses carry a ``geoPoints`` array (one row per sample); inserts are
593
+ * batched because a full course is roughly two thousand points. */
594
+ async function importGarminCourse(database, remoteId, payload, rawHash) {
595
+ const points = courseGeometryPoints(payload);
596
+ const CHUNK = 500;
597
+ for (let offset = 0; offset < points.length; offset += CHUNK) {
598
+ const chunk = points.slice(offset, offset + CHUNK);
599
+ const values = { courseId: remoteId, rawHash };
600
+ const rows = [];
601
+ for (let i = 0; i < chunk.length; i++) {
602
+ const point = chunk[i];
603
+ const seq = offset + i;
604
+ values[`seq${i}`] = seq;
605
+ values[`lat${i}`] = firstNumber(point, ['latitude', 'lat']);
606
+ values[`lon${i}`] = firstNumber(point, ['longitude', 'lon', 'lng']);
607
+ values[`alt${i}`] = firstNumber(point, ['elevation', 'altitude', 'altitudeMeters', 'ele']);
608
+ values[`dist${i}`] = firstNumber(point, ['distance', 'distanceMeters', 'distance_m']);
609
+ rows.push(`($courseId, $seq${i}, $lat${i}, $lon${i}, $alt${i}, $dist${i}, $rawHash)`);
610
+ }
611
+ await database.run(`INSERT INTO course_geometry (course_id, seq, lat, lon, altitude_m, distance_m, raw_object_hash)
612
+ VALUES ${rows.join(', ')}
613
+ ON CONFLICT (course_id, seq) DO UPDATE SET
614
+ lat = excluded.lat, lon = excluded.lon, altitude_m = excluded.altitude_m,
615
+ distance_m = excluded.distance_m, raw_object_hash = excluded.raw_object_hash`, values);
616
+ }
617
+ }
477
618
  async function importGarminTrainingStatus(database, remoteId, occurredOn, payload, rawHash) {
478
619
  const statusMap = nested(nested(payload, 'mostRecentTrainingStatus'), 'latestTrainingStatusData');
479
620
  const balanceMap = nested(nested(payload, 'mostRecentTrainingLoadBalance'), 'metricsTrainingLoadBalanceDTOMap');
@@ -668,6 +809,46 @@ async function importGarminExplicitSwimLengths(database, parentRemoteId, entityT
668
809
  }
669
810
  }
670
811
  }
812
+ async function importGarminFitSwimLengths(database, parentRemoteId, payload, rawHash) {
813
+ if (!parentRemoteId)
814
+ return;
815
+ const activitySourceId = providerActivityId('garmin', parentRemoteId);
816
+ const lengths = objectArray(payload.lengths);
817
+ if (lengths.length === 0)
818
+ return;
819
+ const source = 'garmin_fit_length';
820
+ await database.run('DELETE FROM swim_lengths WHERE activity_source_id = $activitySourceId AND source = $source', { activitySourceId, source });
821
+ const summaryPoolLengthM = await activityPoolLengthM(database, activitySourceId);
822
+ for (const [offset, length] of lengths.entries()) {
823
+ const durationS = firstNumber(length, ['durationS', 'duration', 'durationSeconds', 'elapsedDuration']);
824
+ if (durationS === null)
825
+ continue;
826
+ const sourceLabel = swimSourceLabel(length);
827
+ const isRest = firstBoolean(length, ['isRest', 'rest']) ?? Boolean(sourceLabel && /rest|idle|pause/.test(sourceLabel));
828
+ const lengthIndex = Math.round(firstNumber(length, ['lengthIndex', 'messageIndex', 'lengthNumber', 'index']) ?? offset);
829
+ await database.run(`INSERT INTO swim_lengths
830
+ (activity_source_id, source, length_index, lap_index, pool_length_m, start_time, duration_s, active_duration_s, distance_m, stroke_count, stroke_rate, swolf, avg_hr, max_hr, is_rest, confidence, metrics_json, raw_object_hash)
831
+ VALUES ($activitySourceId, $source, $lengthIndex, $lapIndex, $poolLengthM, $startTime, $durationS, $activeDurationS, $distanceM, $strokeCount, $strokeRate, $swolf, $avgHr, $maxHr, $isRest, 'provider_supplied', $metrics, $rawHash)
832
+ ON CONFLICT (activity_source_id, source, length_index) DO UPDATE SET
833
+ lap_index = excluded.lap_index, pool_length_m = excluded.pool_length_m, start_time = excluded.start_time,
834
+ duration_s = excluded.duration_s, active_duration_s = excluded.active_duration_s, distance_m = excluded.distance_m,
835
+ stroke_count = excluded.stroke_count, stroke_rate = excluded.stroke_rate, swolf = excluded.swolf,
836
+ avg_hr = excluded.avg_hr, max_hr = excluded.max_hr, is_rest = excluded.is_rest,
837
+ confidence = excluded.confidence, metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, {
838
+ activitySourceId, source, lengthIndex,
839
+ lapIndex: firstNumber(length, ['lapIndex', 'lapNumber']), poolLengthM: garminPoolLengthM(length) ?? summaryPoolLengthM,
840
+ startTime: timestamp(length.startTime ?? length.startTimestamp ?? length.timestamp), durationS,
841
+ activeDurationS: firstNumber(length, ['activeDurationS', 'activeDuration', 'movingDuration']),
842
+ distanceM: firstNumber(length, ['distanceM', 'distance']),
843
+ strokeCount: firstNumber(length, ['strokeCount', 'strokes', 'total_strokes']),
844
+ strokeRate: firstNumber(length, ['strokeRate', 'averageStrokeRate', 'avg_swimming_cadence', 'avg_cadence', 'cadence']),
845
+ swolf: firstNumber(length, ['swolf', 'avg_swolf']),
846
+ avgHr: firstNumber(length, ['avgHr', 'averageHR', 'avg_heart_rate']),
847
+ maxHr: firstNumber(length, ['maxHr', 'maxHR', 'max_heart_rate']),
848
+ isRest, metrics: json(length), rawHash,
849
+ });
850
+ }
851
+ }
671
852
  async function addActivityQualityFlag(database, activitySourceId, code, severity, details, rawHash) {
672
853
  await database.run(`INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
673
854
  VALUES ($activitySourceId, $code, $severity, $details, $rawHash)
@@ -791,18 +972,29 @@ async function importNutrition(database, provider, date, remoteId, payload, rawH
791
972
  const nutritionDate = datePart(date) ?? datePart(firstString(payload, ['id', 'date', 'calendarDate'])) ?? null;
792
973
  if (!nutritionDate)
793
974
  return;
975
+ if (!hasNutritionContent(payload))
976
+ return;
794
977
  const fields = nutritionFields(payload);
795
978
  await database.run(`INSERT INTO nutrition_days
796
979
  (provider, nutrition_date, energy_kcal, carbohydrates_g, protein_g, fat_g, hydration_ml, metrics_json, raw_object_hash)
797
- VALUES ($provider, $nutritionDate, $energyKcal, $carbohydratesG, $proteinG, $fatG, $hydrationMl, $metrics, $rawHash)
798
- ON CONFLICT (provider, nutrition_date) DO UPDATE SET energy_kcal = excluded.energy_kcal, carbohydrates_g = excluded.carbohydrates_g,
799
- protein_g = excluded.protein_g, fat_g = excluded.fat_g, hydration_ml = excluded.hydration_ml, metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, { provider, nutritionDate, ...fields, metrics: json(payload), rawHash });
980
+ VALUES ($provider::VARCHAR, $nutritionDate::DATE, $energyKcal::DOUBLE, $carbohydratesG::DOUBLE, $proteinG::DOUBLE, $fatG::DOUBLE, $hydrationMl::DOUBLE, $metrics::JSON, $rawHash::VARCHAR)
981
+ ON CONFLICT (provider, nutrition_date) DO UPDATE SET
982
+ energy_kcal = COALESCE(excluded.energy_kcal, nutrition_days.energy_kcal),
983
+ carbohydrates_g = COALESCE(excluded.carbohydrates_g, nutrition_days.carbohydrates_g),
984
+ protein_g = COALESCE(excluded.protein_g, nutrition_days.protein_g),
985
+ fat_g = COALESCE(excluded.fat_g, nutrition_days.fat_g),
986
+ hydration_ml = COALESCE(excluded.hydration_ml, nutrition_days.hydration_ml),
987
+ metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, { provider, nutritionDate, ...fields, metrics: json(payload), rawHash });
800
988
  for (const [index, item] of nutritionItems(payload).entries()) {
801
989
  const itemFields = nutritionFields(item);
802
990
  const itemId = firstString(item, ['id', 'foodId', 'entryId']) ?? `${remoteId}:${index}`;
803
991
  await database.run(`INSERT INTO nutrition_items
804
992
  (provider, remote_item_id, nutrition_date, meal, consumed_at, food_name, quantity, energy_kcal, carbohydrates_g, protein_g, fat_g, payload_json, raw_object_hash)
805
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
993
+ VALUES (
994
+ CAST(? AS VARCHAR), CAST(? AS VARCHAR), CAST(? AS DATE), CAST(? AS VARCHAR),
995
+ CAST(? AS TIMESTAMP), CAST(? AS VARCHAR), CAST(? AS DOUBLE), CAST(? AS DOUBLE),
996
+ CAST(? AS DOUBLE), CAST(? AS DOUBLE), CAST(? AS DOUBLE), CAST(? AS JSON), CAST(? AS VARCHAR)
997
+ )
806
998
  ON CONFLICT (provider, remote_item_id) DO UPDATE SET nutrition_date = excluded.nutrition_date, meal = excluded.meal,
807
999
  consumed_at = excluded.consumed_at, food_name = excluded.food_name, quantity = excluded.quantity, energy_kcal = excluded.energy_kcal,
808
1000
  carbohydrates_g = excluded.carbohydrates_g, protein_g = excluded.protein_g, fat_g = excluded.fat_g, payload_json = excluded.payload_json,
@@ -2,6 +2,7 @@ import { ReadOnlyRepository } from '../../core/query/repository.js';
2
2
  import type { CatencePaths } from '../../contracts/runtime.js';
3
3
  import type { QueryBindings, WriteDataStore } from '../../contracts/storage.js';
4
4
  import type { Provider } from '../../contracts/staging.js';
5
+ import { type InterruptedRuns, type SyncProgressSnapshot, type SyncProgressState } from '../../contracts/progress.js';
5
6
  export declare class CatenceDatabase implements WriteDataStore {
6
7
  private readonly instance;
7
8
  private readonly connection;
@@ -16,7 +17,19 @@ export declare class CatenceDatabase implements WriteDataStore {
16
17
  interrupt(): void;
17
18
  beginRun(provider: Provider, fromDate: string, runId?: `${string}-${string}-${string}-${string}-${string}`): Promise<string>;
18
19
  finishRun(runId: string): Promise<void>;
20
+ heartbeatRun(runId: string, progress: SyncProgressState): Promise<void>;
21
+ markRunInterrupted(runId: string, stage?: string): Promise<void>;
22
+ interruptStaleSyncRuns(timeoutMs?: number): Promise<InterruptedRuns>;
23
+ syncProgress(timeoutMs?: number): Promise<SyncProgressSnapshot>;
24
+ hasCalendarEventOlderThan(provider: Provider, beforeIsoDate: string, entityType?: string): Promise<boolean>;
25
+ deleteMissingCalendarEntities(provider: Provider, entityTypes: readonly string[], keepRemoteIds: readonly string[], minOccurredOn: string): Promise<void>;
19
26
  addError(runId: string, provider: Provider, endpoint: string, remoteId: string | null, message: string, retryable: boolean): Promise<void>;
27
+ resolveErrors(opts?: {
28
+ runIds?: string[];
29
+ provider?: Provider;
30
+ before?: string;
31
+ all?: boolean;
32
+ }): Promise<number>;
20
33
  status(): Promise<Record<string, unknown>>;
21
34
  getRun(runId: string): Promise<{
22
35
  provider: Provider;
@@ -45,6 +58,12 @@ export declare class CatenceDatabase implements WriteDataStore {
45
58
  activityNeedsDetailSync(provider: Provider, remoteActivityId: string, summaryHash: string): Promise<boolean>;
46
59
  recordActivitySyncState(provider: Provider, remoteActivityId: string, summaryHash: string, runId: string, detailsFetched: boolean): Promise<void>;
47
60
  knownActivitySummaryHashes(provider: Provider): Promise<Record<string, string>>;
61
+ hasGarminLactateThresholdHistory(): Promise<boolean>;
62
+ earliestActivityStartDate(provider: Provider): Promise<string | null>;
63
+ garminSwimActivitySources(): Promise<Array<{
64
+ remote_activity_id: string;
65
+ started_on: string | null;
66
+ }>>;
48
67
  private latestSourceDate;
49
68
  private upsertCursor;
50
69
  }
@@ -66,9 +85,9 @@ export declare class ReadOnlyCatenceDatabase {
66
85
  interrupt(): void;
67
86
  private withConnection;
68
87
  }
69
- /** Compose core read services with the DuckDB-backed, read-only adapter. */
70
- export declare function openReadOnlyRepository(paths: CatencePaths): Promise<ReadOnlyRepository>;
71
88
  export declare class ReadOnlyDatabaseError extends Error {
72
89
  readonly code: 'data_sync_in_progress' | 'data_unavailable';
73
90
  constructor(code: 'data_sync_in_progress' | 'data_unavailable', message: string);
74
91
  }
92
+ /** Compose core read services with the DuckDB-backed, read-only adapter. */
93
+ export declare function openReadOnlyRepository(paths: CatencePaths): Promise<ReadOnlyRepository>;
@@ -2,8 +2,31 @@ import { DuckDBInstance } from '@duckdb/node-api';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { ReadOnlyRepository } from '../../core/query/repository.js';
5
+ import { SYNC_STAGES, STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
5
6
  import { migrations } from './migrations.js';
6
7
  import { json } from './sql.js';
8
+ /**
9
+ * DuckDB's node API throws "Cannot create values of type ANY" when a bound
10
+ * parameter resolves to an untyped value. JS `Date` objects surface as `ANY`
11
+ * (and are not auto-coerced the way strings are), so any date/timestamp
12
+ * parameter read back from the database and re-bound crashes the statement.
13
+ * Serializing dates to ISO strings keeps every parameter concretely typed.
14
+ */
15
+ function serializeValue(value) {
16
+ if (value instanceof Date)
17
+ return value.toISOString();
18
+ return value;
19
+ }
20
+ function serializeBindings(bindings) {
21
+ if (!bindings)
22
+ return bindings;
23
+ if (Array.isArray(bindings))
24
+ return bindings.map(serializeValue);
25
+ const out = {};
26
+ for (const [key, value] of Object.entries(bindings))
27
+ out[key] = serializeValue(value);
28
+ return out;
29
+ }
7
30
  export class CatenceDatabase {
8
31
  instance;
9
32
  connection;
@@ -61,10 +84,10 @@ export class CatenceDatabase {
61
84
  }
62
85
  }
63
86
  async run(sql, values) {
64
- await this.connection.run(sql, values);
87
+ await this.connection.run(sql, serializeBindings(values));
65
88
  }
66
89
  async rows(sql, values) {
67
- const result = await this.connection.runAndReadAll(sql, values);
90
+ const result = await this.connection.runAndReadAll(sql, serializeBindings(values));
68
91
  return result.getRowObjectsJS();
69
92
  }
70
93
  async tableNames(query) {
@@ -81,11 +104,132 @@ export class CatenceDatabase {
81
104
  await this.run(`UPDATE sync_runs SET status = CASE WHEN error_count > 0 THEN 'completed_with_errors' ELSE 'completed' END, completed_at = now() WHERE run_id = $runId`, { runId });
82
105
  await this.run("UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context'");
83
106
  }
107
+ async heartbeatRun(runId, progress) {
108
+ await this.run(`INSERT INTO sync_run_progress (run_id, provider, stage, current_step, completed_units, total_units, percent, elapsed_seconds, estimated_remaining_seconds, heartbeat_at, detail_json)
109
+ VALUES ($runId, $provider, $stage, $currentStep, $completedUnits, $totalUnits, $percent, $elapsedSeconds, $estimatedRemainingSeconds, now(), '{}')
110
+ ON CONFLICT (run_id) DO UPDATE SET
111
+ provider = EXCLUDED.provider,
112
+ stage = EXCLUDED.stage,
113
+ current_step = EXCLUDED.current_step,
114
+ completed_units = EXCLUDED.completed_units,
115
+ total_units = EXCLUDED.total_units,
116
+ percent = EXCLUDED.percent,
117
+ elapsed_seconds = EXCLUDED.elapsed_seconds,
118
+ estimated_remaining_seconds = EXCLUDED.estimated_remaining_seconds,
119
+ heartbeat_at = now(),
120
+ detail_json = '{}'`, {
121
+ runId,
122
+ provider: progress.provider,
123
+ stage: progress.stage,
124
+ currentStep: progress.currentStep,
125
+ completedUnits: progress.completedUnits,
126
+ totalUnits: progress.totalUnits,
127
+ percent: progress.percentComplete,
128
+ elapsedSeconds: progress.elapsedSeconds,
129
+ estimatedRemainingSeconds: progress.estimatedRemainingSeconds,
130
+ });
131
+ }
132
+ async markRunInterrupted(runId, stage = 'interrupted') {
133
+ await this.run("UPDATE sync_runs SET status = 'interrupted', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId });
134
+ await this.run('UPDATE sync_run_progress SET stage = $stage, heartbeat_at = now() WHERE run_id = $runId', { runId, stage });
135
+ }
136
+ async interruptStaleSyncRuns(timeoutMs = STALE_RUN_TIMEOUT_MS) {
137
+ const cutoff = new Date(Date.now() - timeoutMs).toISOString();
138
+ const stale = await this.rows(`SELECT run_id FROM sync_runs WHERE status = 'running' AND started_at < $cutoff`, { cutoff });
139
+ for (const row of stale) {
140
+ await this.run("UPDATE sync_runs SET status = 'timed_out', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId: row.run_id });
141
+ await this.run("UPDATE sync_run_progress SET stage = 'timed_out', heartbeat_at = now() WHERE run_id = $runId", { runId: row.run_id });
142
+ }
143
+ return { runIds: stale.map((row) => row.run_id) };
144
+ }
145
+ async syncProgress(timeoutMs = STALE_RUN_TIMEOUT_MS) {
146
+ // A run killed with its container (docker restart, OOM) keeps
147
+ // status='running' until something cleans it up, and that cleanup only
148
+ // runs inside a starting sync child — which the busy guard refuses to
149
+ // spawn while the row reads as active. Age-filtering here mirrors the
150
+ // sidecar contract: a heartbeat older than the stale timeout can never
151
+ // block a new run or disable the dashboard's Sync button forever.
152
+ const cutoff = new Date(Date.now() - timeoutMs).toISOString();
153
+ const running = await this.rows(`SELECT runs.run_id, runs.provider,
154
+ coalesce(progress.stage, 'starting') AS stage,
155
+ progress.current_step AS current_step,
156
+ coalesce(progress.completed_units, 0) AS completed_units,
157
+ progress.total_units AS total_units,
158
+ coalesce(progress.percent, 0) AS percent,
159
+ coalesce(progress.elapsed_seconds, 0) AS elapsed_seconds,
160
+ progress.estimated_remaining_seconds AS estimated_remaining_seconds,
161
+ cast(coalesce(progress.heartbeat_at, runs.started_at) AS VARCHAR) AS heartbeat_at
162
+ FROM sync_runs AS runs
163
+ LEFT JOIN sync_run_progress AS progress USING (run_id)
164
+ WHERE runs.status = 'running'
165
+ AND coalesce(progress.heartbeat_at, runs.started_at) > $cutoff
166
+ ORDER BY runs.started_at DESC`, { cutoff });
167
+ const recent = await this.rows(`SELECT progress.run_id, progress.provider, progress.stage, progress.current_step,
168
+ progress.completed_units, progress.total_units, progress.percent, progress.elapsed_seconds,
169
+ progress.estimated_remaining_seconds, cast(progress.heartbeat_at AS VARCHAR) AS heartbeat_at
170
+ FROM sync_run_progress AS progress
171
+ ORDER BY progress.heartbeat_at DESC
172
+ LIMIT 10`);
173
+ return { running: running.map(toSyncProgressState), recent: recent.map(toSyncProgressState) };
174
+ }
175
+ async hasCalendarEventOlderThan(provider, beforeIsoDate, entityType = 'event') {
176
+ // Coverage signal for the automatic calendar-events backfill: true once
177
+ // the store already holds an event older than the regular short window,
178
+ // meaning the one-time deep pull has landed and normal syncs can stay
179
+ // narrow. Athletes with genuinely no old events re-run the deep window;
180
+ // it costs one small paginated listing per sync.
181
+ const rows = await this.rows(`SELECT 1 FROM source_entities WHERE provider = $provider AND entity_type = $entityType AND occurred_on < $before LIMIT 1`, { provider, entityType, before: beforeIsoDate });
182
+ return rows.length > 0;
183
+ }
184
+ async deleteMissingCalendarEntities(provider, entityTypes, keepRemoteIds, minOccurredOn) {
185
+ // Calendar entities (joined races, scheduled workouts) are mutable and
186
+ // deletable upstream. Re-fetches upsert survivors via remote_id; this
187
+ // removes in-window rows absent from the latest authoritative listing so
188
+ // upstream deletions propagate instead of leaving ghosts forever. Rows
189
+ // without a date are preserved (they predate strict dating).
190
+ if (!entityTypes.length)
191
+ return;
192
+ const values = { provider, minOccurredOn };
193
+ const typePlaceholders = entityTypes.map((name, index) => {
194
+ values[`type${index}`] = name;
195
+ return `$type${index}`;
196
+ });
197
+ let keepClause = '';
198
+ if (keepRemoteIds.length) {
199
+ keepClause = ` AND remote_id NOT IN (${keepRemoteIds.map((_, index) => `$keep${index}`).join(', ')})`;
200
+ keepRemoteIds.forEach((id, index) => { values[`keep${index}`] = id; });
201
+ }
202
+ for (const table of ['source_entities', 'domain_entities']) {
203
+ await this.run(`DELETE FROM ${table} WHERE provider = $provider AND entity_type IN (${typePlaceholders}) AND occurred_on >= $minOccurredOn${keepClause}`, values);
204
+ }
205
+ }
84
206
  async addError(runId, provider, endpoint, remoteId, message, retryable) {
85
207
  await this.run(`INSERT INTO normalization_errors (error_id, run_id, provider, endpoint, remote_id, message, retryable)
86
208
  VALUES ($errorId, $runId, $provider, $endpoint, $remoteId, $message, $retryable)`, { errorId: randomUUID(), runId, provider, endpoint, remoteId, message, retryable });
87
209
  await this.run('UPDATE sync_runs SET error_count = error_count + 1 WHERE run_id = $runId', { runId });
88
210
  }
211
+ async resolveErrors(opts = {}) {
212
+ const clauses = ['resolved_at IS NULL'];
213
+ const values = {};
214
+ if (opts.runIds && opts.runIds.length > 0) {
215
+ const placeholders = opts.runIds.map((_, index) => `$runId${index}`).join(', ');
216
+ clauses.push(`run_id IN (${placeholders})`);
217
+ opts.runIds.forEach((runId, index) => { values[`runId${index}`] = runId; });
218
+ }
219
+ if (opts.provider) {
220
+ clauses.push('provider = $provider');
221
+ values.provider = opts.provider;
222
+ }
223
+ if (opts.before) {
224
+ clauses.push('created_at < $before');
225
+ values.before = opts.before;
226
+ }
227
+ if (!opts.all && clauses.length === 1) {
228
+ throw new Error('Refusing to resolve all unresolved errors without an explicit --all flag.');
229
+ }
230
+ const resolved = await this.rows(`UPDATE normalization_errors SET resolved_at = now() WHERE ${clauses.join(' AND ')} RETURNING error_id`, values);
231
+ return resolved.length;
232
+ }
89
233
  async status() {
90
234
  // @duckdb/node-api owns native statement state on the connection. Keep
91
235
  // status reads sequential instead of issuing several native calls at once.
@@ -166,6 +310,24 @@ export class CatenceDatabase {
166
310
  const rows = await this.rows('SELECT remote_activity_id, summary_hash FROM activity_sync_state WHERE provider = $provider', { provider });
167
311
  return Object.fromEntries(rows.map((row) => [row.remote_activity_id, row.summary_hash]));
168
312
  }
313
+ async hasGarminLactateThresholdHistory() {
314
+ const rows = await this.rows("SELECT count(*) AS n FROM training_metric_observations WHERE observation_id LIKE 'garmin:lactate_threshold:daily:%'");
315
+ return (rows[0]?.n ?? 0) > 0;
316
+ }
317
+ async earliestActivityStartDate(provider) {
318
+ const rows = await this.rows(`SELECT min(cast(started_at_utc AS DATE)) AS earliest FROM activities activity
319
+ JOIN activity_sources source USING (activity_id) WHERE source.provider = $provider`, { provider });
320
+ return toIsoDate(rows[0]?.earliest ?? null);
321
+ }
322
+ async garminSwimActivitySources() {
323
+ const sports = ['lap_swimming', 'swimming', 'open_water_swimming', 'openwaterswimming'];
324
+ return this.rows(`SELECT source.remote_activity_id, cast(activity.started_at_utc AS VARCHAR) AS started_on
325
+ FROM activity_sources AS source
326
+ JOIN activities AS activity USING (activity_id)
327
+ WHERE source.provider = 'garmin'
328
+ AND lower(coalesce(activity.sport, '')) IN (${sports.map((sport) => `'${sport}'`).join(', ')})
329
+ ORDER BY activity.started_at_utc`);
330
+ }
169
331
  async latestSourceDate(provider, cursorName) {
170
332
  const observations = cursorName === 'daily'
171
333
  ? `SELECT max(metric_date) AS observed_date FROM daily_metrics WHERE provider = $provider
@@ -283,10 +445,6 @@ export class ReadOnlyCatenceDatabase {
283
445
  }
284
446
  }
285
447
  }
286
- /** Compose core read services with the DuckDB-backed, read-only adapter. */
287
- export async function openReadOnlyRepository(paths) {
288
- return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
289
- }
290
448
  export class ReadOnlyDatabaseError extends Error {
291
449
  code;
292
450
  constructor(code, message) {
@@ -295,4 +453,22 @@ export class ReadOnlyDatabaseError extends Error {
295
453
  this.name = 'ReadOnlyDatabaseError';
296
454
  }
297
455
  }
456
+ /** Compose core read services with the DuckDB-backed, read-only adapter. */
457
+ export async function openReadOnlyRepository(paths) {
458
+ return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
459
+ }
460
+ function toSyncProgressState(row) {
461
+ return {
462
+ runId: row.run_id,
463
+ provider: row.provider,
464
+ stage: SYNC_STAGES.includes(row.stage) ? row.stage : 'starting',
465
+ currentStep: row.current_step,
466
+ completedUnits: Math.max(0, Number(row.completed_units) || 0),
467
+ totalUnits: row.total_units === null || row.total_units === undefined ? null : Math.max(0, Number(row.total_units) || 0),
468
+ percentComplete: Math.min(100, Math.max(0, Number(row.percent) || 0)),
469
+ elapsedSeconds: Math.max(0, Number(row.elapsed_seconds) || 0),
470
+ estimatedRemainingSeconds: row.estimated_remaining_seconds === null || row.estimated_remaining_seconds === undefined ? null : Math.max(0, Number(row.estimated_remaining_seconds) || 0),
471
+ heartbeatAt: row.heartbeat_at,
472
+ };
473
+ }
298
474
  //# sourceMappingURL=database.js.map