catence 0.2.0-beta.21 → 0.2.0-beta.23

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.
@@ -0,0 +1,34 @@
1
+ import { ReadOnlyRepository } from './repository.js';
2
+ /** A courseId resolved from an event (or activity) payload, or null when absent. */
3
+ export type ResolvedCourseId = string | null;
4
+ export interface ResolveEventCourseResult {
5
+ eventId: string;
6
+ provider: string | null;
7
+ courseId: ResolvedCourseId;
8
+ courseSynced: boolean;
9
+ geometrySampleCount: number;
10
+ caveats: string[];
11
+ }
12
+ export interface DiffCourseProfilesResult {
13
+ eventCourseId: ResolvedCourseId;
14
+ pastActivityCourseId: ResolvedCourseId;
15
+ sameCourse: boolean | null;
16
+ differentCourse: boolean | null;
17
+ caveats: string[];
18
+ }
19
+ /** Pull a courseId from a Garmin/Intervals event/activity payload, tolerating the
20
+ * common key spellings and a nested `course.id` shape. Returns null when the
21
+ * payload carries no course reference rather than guessing. */
22
+ export declare function extractCourseId(payload: unknown): ResolvedCourseId;
23
+ export declare class EventsService {
24
+ private readonly repository;
25
+ constructor(repository: ReadOnlyRepository);
26
+ /** Resolve an event's referenced Garmin courseId and report whether its
27
+ * geometry has been synced. When geometry is absent, the result carries an
28
+ * explicit caveat so the agent never silently reuses a prior course profile. */
29
+ resolveEventCourse(eventId: string): Promise<ResolveEventCourseResult>;
30
+ /** Compare an event's course against a past activity's course. A different
31
+ * courseId means the prior profile does not apply; an absent reference is
32
+ * reported as unknown rather than assumed equal. */
33
+ diffCourseProfiles(eventCourseId: ResolvedCourseId, pastActivityId?: string): Promise<DiffCourseProfilesResult>;
34
+ }
@@ -0,0 +1,121 @@
1
+ const COURSE_ID_KEYS = ['courseId', 'course_id', 'courseID'];
2
+ /** Pull a courseId from a Garmin/Intervals event/activity payload, tolerating the
3
+ * common key spellings and a nested `course.id` shape. Returns null when the
4
+ * payload carries no course reference rather than guessing. */
5
+ export function extractCourseId(payload) {
6
+ let normalized = payload;
7
+ if (typeof payload === 'string') {
8
+ try {
9
+ normalized = JSON.parse(payload);
10
+ }
11
+ catch {
12
+ normalized = {};
13
+ }
14
+ }
15
+ const record = (typeof normalized === 'object' && normalized !== null && !Array.isArray(normalized)
16
+ ? normalized
17
+ : {});
18
+ for (const key of COURSE_ID_KEYS) {
19
+ const value = record[key];
20
+ if (typeof value === 'string' && value.length > 0)
21
+ return value;
22
+ if (typeof value === 'number' && Number.isFinite(value))
23
+ return String(value);
24
+ }
25
+ const nested = record.course;
26
+ if (nested !== null && typeof nested === 'object' && !Array.isArray(nested)) {
27
+ const nestedId = nested.id;
28
+ if (typeof nestedId === 'string' && nestedId.length > 0)
29
+ return nestedId;
30
+ if (typeof nestedId === 'number' && Number.isFinite(nestedId))
31
+ return String(nestedId);
32
+ }
33
+ return null;
34
+ }
35
+ export class EventsService {
36
+ repository;
37
+ constructor(repository) {
38
+ this.repository = repository;
39
+ }
40
+ /** Resolve an event's referenced Garmin courseId and report whether its
41
+ * geometry has been synced. When geometry is absent, the result carries an
42
+ * explicit caveat so the agent never silently reuses a prior course profile. */
43
+ async resolveEventCourse(eventId) {
44
+ const caveats = [];
45
+ const events = await this.repository.rows(`SELECT provider, payload_json FROM source_entities
46
+ WHERE entity_type = 'event' AND remote_id = $eventId
47
+ ORDER BY provider = 'garmin' DESC, remote_id LIMIT 1`, { eventId });
48
+ if (events.length === 0) {
49
+ return {
50
+ eventId,
51
+ provider: null,
52
+ courseId: null,
53
+ courseSynced: false,
54
+ geometrySampleCount: 0,
55
+ caveats: [`No event source entity found for eventId ${eventId}.`],
56
+ };
57
+ }
58
+ const event = events[0];
59
+ const courseId = extractCourseId(event.payload_json);
60
+ if (courseId === null) {
61
+ return {
62
+ eventId,
63
+ provider: event.provider,
64
+ courseId: null,
65
+ courseSynced: false,
66
+ geometrySampleCount: 0,
67
+ caveats: [`Event ${eventId} does not reference a courseId; no course profile to resolve.`],
68
+ };
69
+ }
70
+ const synced = await this.repository.rows(`SELECT remote_id FROM source_entities
71
+ WHERE entity_type = 'course' AND remote_id = $courseId LIMIT 1`, { courseId });
72
+ const geometry = await this.repository.rows(`SELECT count(*) AS sample_count FROM course_geometry WHERE course_id = $courseId`, { courseId });
73
+ const geometrySampleCount = geometry.length > 0 ? Number(geometry[0].sample_count) : 0;
74
+ const courseSynced = synced.length > 0 && geometrySampleCount > 0;
75
+ if (!courseSynced) {
76
+ caveats.push(`Course geometry for courseId ${courseId} is not yet synced; this year's gradient is unverified until a recon run maps to that course. Do not reuse a prior course profile.`);
77
+ }
78
+ return {
79
+ eventId,
80
+ provider: event.provider,
81
+ courseId,
82
+ courseSynced,
83
+ geometrySampleCount,
84
+ caveats,
85
+ };
86
+ }
87
+ /** Compare an event's course against a past activity's course. A different
88
+ * courseId means the prior profile does not apply; an absent reference is
89
+ * reported as unknown rather than assumed equal. */
90
+ async diffCourseProfiles(eventCourseId, pastActivityId) {
91
+ const caveats = [];
92
+ let pastActivityCourseId = null;
93
+ if (pastActivityId) {
94
+ const activities = await this.repository.rows(`SELECT payload_json FROM source_entities
95
+ WHERE entity_type = 'activity' AND remote_id = $activityId LIMIT 1`, { activityId: pastActivityId });
96
+ if (activities.length > 0) {
97
+ pastActivityCourseId = extractCourseId(activities[0].payload_json);
98
+ }
99
+ else {
100
+ caveats.push(`No activity source entity found for pastActivityId ${pastActivityId}; could not resolve its course.`);
101
+ }
102
+ }
103
+ let sameCourse = null;
104
+ let differentCourse = null;
105
+ if (eventCourseId !== null && pastActivityCourseId !== null) {
106
+ sameCourse = eventCourseId === pastActivityCourseId;
107
+ differentCourse = !sameCourse;
108
+ if (differentCourse) {
109
+ caveats.push(`Event courseId ${eventCourseId} differs from past activity courseId ${pastActivityCourseId}; do not reuse the prior course profile.`);
110
+ }
111
+ }
112
+ else if (eventCourseId === null) {
113
+ caveats.push('Event courseId is unknown; a prior course profile cannot be validated against it.');
114
+ }
115
+ else {
116
+ caveats.push('Past activity courseId is unknown; a prior course profile cannot be confirmed or refuted.');
117
+ }
118
+ return { eventCourseId, pastActivityCourseId, sameCourse, differentCourse, caveats };
119
+ }
120
+ }
121
+ //# sourceMappingURL=events.js.map
@@ -26,6 +26,9 @@ export type LatestCyclingActivitiesRequest = FitnessDateRange & {
26
26
  includeMultisport?: boolean;
27
27
  limit?: number;
28
28
  };
29
+ export type ReadinessBaselineRequest = FitnessDateRange & {
30
+ sport?: string;
31
+ };
29
32
  /** Read-only, source-aware fitness projections built on normalized facts. */
30
33
  export declare class FitnessService {
31
34
  private readonly repository;
@@ -36,5 +39,6 @@ export declare class FitnessService {
36
39
  powerCoverageReport(request?: PowerCoverageReportRequest): Promise<Record<string, unknown>>;
37
40
  latestCyclingActivities(request?: LatestCyclingActivitiesRequest): Promise<Record<string, unknown>>;
38
41
  cyclingProgressReport(request?: FitnessDateRange): Promise<Record<string, unknown>>;
42
+ readinessBaseline(request?: ReadinessBaselineRequest): Promise<Record<string, unknown>>;
39
43
  }
40
44
  export {};
@@ -293,5 +293,65 @@ export class FitnessService {
293
293
  ],
294
294
  };
295
295
  }
296
+ async readinessBaseline(request = {}) {
297
+ const sport = request.sport ?? 'running';
298
+ const isRunning = /run/i.test(sport);
299
+ const values = { sport };
300
+ const metricClauses = [
301
+ `metric_name IN ('running_lactate_threshold_pace_s_per_km','running_lactate_threshold_power_w','running_lactate_threshold_hr_bpm','running_lactate_threshold_power_w_kg','race_prediction_time5_k','race_prediction_time10_k','race_prediction_time_half_marathon','race_prediction_time_marathon','training_status_fitness_trend','endurance_score_overall_score')`,
302
+ `(lower(sport) = lower($sport) OR lower(sport) = 'generic')`,
303
+ ...addDateRange('observed_at', request, values),
304
+ ];
305
+ const observations = await this.repository.rows(`
306
+ SELECT observation_id, cast(observed_at AS VARCHAR) AS observed_at, value_number, value_text, unit, sport,
307
+ source_type, source_remote_id, activity_source_id, raw_object_hash, metric_name
308
+ FROM training_metric_observations
309
+ WHERE ${metricClauses.join(' AND ')}
310
+ ORDER BY observed_at DESC, observation_id DESC
311
+ `, values);
312
+ const [vo2max, powerCurve, powerCoverage] = await Promise.all([
313
+ this.vo2MaxHistory({ ...request, sport }),
314
+ this.powerCurveTrend({ ...request, sportFamily: isRunning ? 'running' : 'cycling' }),
315
+ this.powerCoverageReport({ ...request, sportFamily: isRunning ? 'running' : 'cycling' }),
316
+ ]);
317
+ const byMetric = new Map();
318
+ for (const row of observations) {
319
+ const arr = byMetric.get(row.metric_name) ?? [];
320
+ arr.push(row);
321
+ byMetric.set(row.metric_name, arr);
322
+ }
323
+ const latest = (name) => byMetric.get(name)?.[0] ?? null;
324
+ const series = (name) => byMetric.get(name) ?? [];
325
+ return {
326
+ data: {
327
+ sport,
328
+ lactateThreshold: {
329
+ pace_s_per_km: latest('running_lactate_threshold_pace_s_per_km'),
330
+ power_w: latest('running_lactate_threshold_power_w'),
331
+ hr_bpm: latest('running_lactate_threshold_hr_bpm'),
332
+ power_w_kg: latest('running_lactate_threshold_power_w_kg'),
333
+ powerSeries: series('running_lactate_threshold_power_w'),
334
+ },
335
+ vo2max,
336
+ racePrediction: {
337
+ time5_k: latest('race_prediction_time5_k'),
338
+ time10_k: latest('race_prediction_time10_k'),
339
+ timeHalfMarathon: latest('race_prediction_time_half_marathon'),
340
+ timeMarathon: latest('race_prediction_time_marathon'),
341
+ },
342
+ fitnessTrend: latest('training_status_fitness_trend'),
343
+ enduranceScore: latest('endurance_score_overall_score'),
344
+ powerCurve,
345
+ powerCoverage,
346
+ },
347
+ provenance: { dataset: 'training_metric_observations', powerDataset: 'power_best_facts' },
348
+ query: request,
349
+ caveats: [
350
+ 'Readiness baseline is descriptive, not a physiological performance model; it combines lactate threshold, VO₂max, race prediction, fitness trend, and FIT-derived power.',
351
+ 'Running power uses Garmin FIT-derived power bests (power_best_facts), not sparse activity-summary average power; a NULL summary avg_power does not mean no power.',
352
+ 'Course/elevation profile for a specific race is not included here; call resolve_event_course(eventId) before reusing any prior course profile.',
353
+ ],
354
+ };
355
+ }
296
356
  }
297
357
  //# sourceMappingURL=fitness.js.map
@@ -60,6 +60,7 @@ export const garminReadRegistry = registry('garmin', [
60
60
  ['activity_original', 'activity', 'activity_file'], ['activity_tcx', 'activity', 'activity_file'], ['activity_gpx', 'activity', 'activity_file'], ['activity_kml', 'activity', 'activity_file'],
61
61
  ['activity_csv', 'activity', 'activity_file'], ['workouts', 'collection', 'workout'], ['scheduled_workouts', 'date_range', 'scheduled_workout'],
62
62
  ['events', 'collection', 'event'],
63
+ ['course', 'child', 'course'],
63
64
  ['training_plans', 'collection', 'training_plan'], ['goals', 'collection', 'goal'], ['personal_records', 'singleton', 'personal_record'],
64
65
  ['earned_badges', 'collection', 'badge'], ['available_badges', 'collection', 'badge'], ['challenges', 'collection', 'challenge'],
65
66
  ['gear', 'collection', 'gear'], ['golf_summary', 'collection', 'golf_scorecard'], ['golf_user_stats', 'singleton', 'golf_user_stat'],
@@ -253,6 +253,7 @@ export async function importSourceEntity(database, entity) {
253
253
  }
254
254
  if (entity.entityType === 'activity') {
255
255
  await importActivity(database, entity.provider, entity.remoteId, payload, rawHash);
256
+ await importActivityDecoupling(database, entity.provider, entity.remoteId, payload, rawHash);
256
257
  if (entity.provider === 'intervals')
257
258
  await importIntervalsAutoSwimSets(database, entity.remoteId, payload, rawHash);
258
259
  if (entity.provider === 'garmin') {
@@ -290,6 +291,8 @@ export async function importSourceEntity(database, entity) {
290
291
  await importHealthSession(database, entity.remoteId, entity.occurredOn, 'daily_event', payload, rawHash);
291
292
  if (entity.provider === 'garmin' && entity.entityType === 'activity_power_best')
292
293
  await importActivityPowerBest(database, entity.parentRemoteId, payload, rawHash);
294
+ if (entity.provider === 'garmin' && entity.entityType === 'course')
295
+ await importGarminCourse(database, entity.remoteId, payload, rawHash);
293
296
  }
294
297
  async function importActivity(database, provider, remoteId, payload, rawHash) {
295
298
  const fields = activityFields(provider, payload);
@@ -519,6 +522,99 @@ async function importGarminNumericSeries(database, entityType, remoteId, occurre
519
522
  });
520
523
  }
521
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
+ }
522
618
  async function importGarminTrainingStatus(database, remoteId, occurredOn, payload, rawHash) {
523
619
  const statusMap = nested(nested(payload, 'mostRecentTrainingStatus'), 'latestTrainingStatusData');
524
620
  const balanceMap = nested(nested(payload, 'mostRecentTrainingLoadBalance'), 'metricsTrainingLoadBalanceDTOMap');
@@ -852,5 +852,125 @@ const syncRunProgressMigration = {
852
852
  UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
853
853
  `,
854
854
  };
855
- export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration, syncRunProgressMigration];
855
+ const canonicalTrainingDerivedAvgPowerMigration = {
856
+ version: 15,
857
+ name: 'canonical_training_derived_avg_power_w',
858
+ sql: `
859
+ -- A2: Stop "NULL avg_power => no power" inference without rewriting provenance.
860
+ -- activity_summaries.avg_power is sparse (NULL for many 2024 running
861
+ -- activities) while authoritative Garmin FIT-derived power is present as
862
+ -- per-duration rows in activity_power_bests (exposed as power_best_facts).
863
+ -- Overwriting the summary value would conflate two layers and break the
864
+ -- provenance contract (summary vs FIT-derived). Instead, extend the
865
+ -- canonical view with a derived column that aggregates the FIT signal:
866
+ -- derived_avg_power_w is the per-activity mean of best_power_w across
867
+ -- all stored durations. Callers should prefer
868
+ -- coalesce(avg_power, derived_avg_power_w) or use power_coverage_report /
869
+ -- read_series on activity_samples for the full signal. NULL remains NULL
870
+ -- when no FIT bests exist for that activity, making absence explicit.
871
+ -- Idempotent via CREATE OR REPLACE VIEW; no table mutation.
872
+ -- Older test fixtures (v9) created activity_summaries with only a subset
873
+ -- of columns; ensure the full canonical set exists so this view's
874
+ -- intervals_analysis (which unconditionally references training_load etc.)
875
+ -- does not throw Binder Error on upgrade.
876
+ ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS elevation_gain_m DOUBLE;
877
+ ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS calories DOUBLE;
878
+ ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS max_hr DOUBLE;
879
+ ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS avg_power DOUBLE;
880
+ ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS weighted_power DOUBLE;
881
+ ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS avg_cadence DOUBLE;
882
+ ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS training_load DOUBLE;
883
+ ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS rpe DOUBLE;
884
+ ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS feel DOUBLE;
885
+ CREATE OR REPLACE VIEW canonical_activity_training AS
886
+ WITH ranked AS (
887
+ SELECT a.*, source.activity_source_id, source.provider, source.remote_activity_id, source.raw_object_hash,
888
+ summary.*, row_number() OVER (
889
+ PARTITION BY a.activity_id
890
+ ORDER BY CASE source.provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END
891
+ ) AS source_rank
892
+ FROM activities a
893
+ JOIN activity_sources source USING (activity_id)
894
+ LEFT JOIN activity_summaries summary USING (activity_source_id)
895
+ ), intervals_analysis AS (
896
+ SELECT source.activity_id,
897
+ max(summary.training_load) AS intervals_training_load,
898
+ max(summary.rpe) AS intervals_rpe,
899
+ max(summary.feel) AS intervals_feel,
900
+ max(summary.weighted_power) AS intervals_weighted_power
901
+ FROM activity_sources source
902
+ JOIN activity_summaries summary USING (activity_source_id)
903
+ WHERE source.provider = 'intervals'
904
+ GROUP BY source.activity_id
905
+ ), derived_power AS (
906
+ SELECT activity_source_id, avg(best_power_w) AS derived_avg_power_w
907
+ FROM activity_power_bests
908
+ GROUP BY activity_source_id
909
+ )
910
+ SELECT ranked.* EXCLUDE (source_rank),
911
+ intervals_analysis.intervals_training_load,
912
+ intervals_analysis.intervals_rpe,
913
+ intervals_analysis.intervals_feel,
914
+ intervals_analysis.intervals_weighted_power,
915
+ derived_power.derived_avg_power_w
916
+ FROM ranked
917
+ LEFT JOIN intervals_analysis USING (activity_id)
918
+ LEFT JOIN derived_power USING (activity_source_id)
919
+ WHERE source_rank = 1;
920
+
921
+ UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
922
+ `,
923
+ };
924
+ const courseGeometryMigration = {
925
+ version: 16,
926
+ name: 'course_geometry',
927
+ sql: `
928
+ -- A4: Garmin course/route geometry so a race's elevation/grade profile can be
929
+ -- resolved and compared against a prior activity's course. garminconnect
930
+ -- 0.3.x has no dedicated course method, so courses are staged from the raw
931
+ -- Connect API (connectapi("course-service/course/{id}"), validated live:
932
+ -- returns metadata + a geoPoints geometry array) and normalized here.
933
+ -- courseId is the Garmin course identifier referenced by an event's courseId.
934
+ CREATE TABLE IF NOT EXISTS course_geometry (
935
+ course_id VARCHAR NOT NULL,
936
+ seq INTEGER NOT NULL,
937
+ lat DOUBLE,
938
+ lon DOUBLE,
939
+ altitude_m DOUBLE,
940
+ distance_m DOUBLE,
941
+ raw_object_hash VARCHAR,
942
+ PRIMARY KEY (course_id, seq)
943
+ );
944
+
945
+ UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
946
+ `,
947
+ };
948
+ const activityDecouplingFactsMigration = {
949
+ version: 17,
950
+ name: 'activity_decoupling_facts',
951
+ sql: `
952
+ -- A3 (persistence): make aerobic decoupling (Pa:Hr) and grade-adjusted pace
953
+ -- (GAP) queryable historically instead of only inside one activity's raw
954
+ -- metrics_json. Rows are provider-supplied when the provider payload carries
955
+ -- them (source_type 'provider'); locally derived values are tagged 'derived'
956
+ -- with caveats_json. Provider values remain authoritative and are never
957
+ -- overwritten by derivation.
958
+ CREATE TABLE IF NOT EXISTS activity_decoupling_facts (
959
+ provider VARCHAR NOT NULL,
960
+ activity_source_id VARCHAR NOT NULL,
961
+ sport VARCHAR,
962
+ started_at_utc TIMESTAMPTZ,
963
+ metric VARCHAR NOT NULL,
964
+ value_number DOUBLE,
965
+ unit VARCHAR,
966
+ source_type VARCHAR NOT NULL,
967
+ caveats_json JSON,
968
+ raw_object_hash VARCHAR,
969
+ PRIMARY KEY (provider, activity_source_id, metric)
970
+ );
971
+
972
+ UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
973
+ `,
974
+ };
975
+ export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration, syncRunProgressMigration, canonicalTrainingDerivedAvgPowerMigration, courseGeometryMigration, activityDecouplingFactsMigration];
856
976
  //# sourceMappingURL=migrations.js.map
@@ -2,7 +2,7 @@
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
4
  import { Command } from 'commander';
5
- import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
5
+ import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, CatenceDatabase, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
6
6
  const program = new Command()
7
7
  .name('catence-data')
8
8
  .description('Athlete-scoped Catence data setup, extraction, and normalization.')
@@ -277,5 +277,41 @@ program
277
277
  const runIds = Array.isArray(options.run) ? options.run : options.run ? [options.run] : undefined;
278
278
  process.stdout.write(`${JSON.stringify(await resolveExtractionErrors(paths, { runIds, provider: options.provider, before: options.before, all: options.all }), null, 2)}\n`);
279
279
  });
280
+ program
281
+ .command('migrate')
282
+ .description('Apply pending DuckDB schema migrations. Without --all it migrates the selected --athlete store; with --all it migrates every athlete in the catalog.')
283
+ .option('--all', 'migrate every athlete in the catalog')
284
+ .option('--home <directory>', 'Catence catalog home directory (also accepts global --home)')
285
+ .action(async (options) => {
286
+ const effectiveHome = options.home ?? program.opts().home;
287
+ const effectiveCatalog = effectiveHome ? resolveCatalogPaths(effectiveHome) : currentCatalog();
288
+ const migratePaths = async (paths) => {
289
+ const database = await CatenceDatabase.open(paths);
290
+ const applied = await database.rows('SELECT version FROM schema_migrations ORDER BY version');
291
+ await database.close();
292
+ return { migrated: true, appliedVersions: applied.map((row) => row.version) };
293
+ };
294
+ if (options.all) {
295
+ const catalog = await loadCatalog(effectiveCatalog);
296
+ const results = {};
297
+ for (const athlete of catalog.athletes) {
298
+ const paths = athleteStorePaths(effectiveCatalog, athlete.id);
299
+ try {
300
+ results[athlete.id] = await migratePaths(paths);
301
+ }
302
+ catch (error) {
303
+ results[athlete.id] = { migrated: false, error: error instanceof Error ? error.message : String(error) };
304
+ }
305
+ }
306
+ process.stdout.write(`${JSON.stringify({ catalog: effectiveCatalog.root, results }, null, 2)}\n`);
307
+ return;
308
+ }
309
+ const athleteId = program.opts().athlete;
310
+ if (!athleteId)
311
+ throw new Error('--athlete is required for this command. Run catence-data athlete list to inspect the catalog or use --all.');
312
+ const { athlete, paths } = await resolveAthlete(effectiveCatalog, athleteId);
313
+ const result = await migratePaths(paths);
314
+ process.stdout.write(`${JSON.stringify({ athleteId: athlete.id, ...result }, null, 2)}\n`);
315
+ });
280
316
  await program.parseAsync();
281
317
  //# sourceMappingURL=commands.js.map
@@ -1,11 +1,42 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { existsSync } from 'node:fs';
4
- import { addAthlete, athleteStorePaths, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
4
+ import { addAthlete, athleteStorePaths, CatenceDatabase, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
5
5
  import { parseServeCliOptions, SERVE_USAGE } from '../http/cli-options.js';
6
6
  import { createCatenceHttpServer } from '../http/server.js';
7
7
  import { MCP_USAGE, parseMcpCliOptions } from './cli-options.js';
8
8
  import { createCatenceMcpServer } from './server.js';
9
+ async function migrateCatalogStores(home) {
10
+ // On Docker image bumps the DuckDB schema version increases. The
11
+ // read-only MCP health probe would otherwise report "version X required"
12
+ // until the next manual sync. Proactively opening each athlete store in
13
+ // writable mode runs the idempotent migrations (database.ts:migrate)
14
+ // before the HTTP server starts listening, so a plain `docker compose up`
15
+ // after `deploy-console.sh beta` heals existing installs without an extra
16
+ // manual `catence-data migrate --all` step. Failures are best-effort:
17
+ // a missing catalog or a store still locked by another process is retried
18
+ // on the next sync or health check.
19
+ try {
20
+ const catalogPaths = resolveCatalogPaths(home);
21
+ const catalog = await loadCatalog(catalogPaths).catch(() => null);
22
+ if (!catalog || catalog.athletes.length === 0)
23
+ return;
24
+ for (const athlete of catalog.athletes) {
25
+ const paths = athleteStorePaths(catalogPaths, athlete.id);
26
+ try {
27
+ const database = await CatenceDatabase.open(paths);
28
+ await database.close();
29
+ }
30
+ catch {
31
+ // Best-effort: a lock or a corrupted store must not prevent the
32
+ // server from starting; the next sync will retry the migration.
33
+ }
34
+ }
35
+ }
36
+ catch {
37
+ // Catalog read failures are non-fatal for startup.
38
+ }
39
+ }
9
40
  async function run() {
10
41
  const arguments_ = process.argv.slice(2);
11
42
  if (arguments_[0] === 'serve') {
@@ -14,6 +45,7 @@ async function run() {
14
45
  process.stderr.write(`${SERVE_USAGE}\n`);
15
46
  return;
16
47
  }
48
+ await migrateCatalogStores(options.home);
17
49
  const httpServer = createCatenceHttpServer({
18
50
  catalogPaths: resolveCatalogPaths(options.home),
19
51
  allowedOrigins: options.allowedOrigins,