catence 0.0.0 → 0.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +201 -29
  3. package/config.example.json +63 -0
  4. package/dist/contracts/index.d.ts +4 -0
  5. package/dist/contracts/index.js +5 -0
  6. package/dist/contracts/release.d.ts +20 -0
  7. package/dist/contracts/release.js +17 -0
  8. package/dist/contracts/runtime.d.ts +11 -0
  9. package/dist/contracts/runtime.js +2 -0
  10. package/dist/contracts/staging.d.ts +183 -0
  11. package/dist/contracts/staging.js +36 -0
  12. package/dist/contracts/storage.d.ts +15 -0
  13. package/dist/contracts/storage.js +2 -0
  14. package/dist/core/dashboard/snapshot.d.ts +16 -0
  15. package/dist/core/dashboard/snapshot.js +65 -0
  16. package/dist/core/query/activity-discovery.d.ts +21 -0
  17. package/dist/core/query/activity-discovery.js +133 -0
  18. package/dist/core/query/analytics.d.ts +59 -0
  19. package/dist/core/query/analytics.js +395 -0
  20. package/dist/core/query/catalog.d.ts +26 -0
  21. package/dist/core/query/catalog.js +141 -0
  22. package/dist/core/query/fitness.d.ts +40 -0
  23. package/dist/core/query/fitness.js +297 -0
  24. package/dist/core/query/repository.d.ts +19 -0
  25. package/dist/core/query/repository.js +182 -0
  26. package/dist/core/query/sql-guard.d.ts +7 -0
  27. package/dist/core/query/sql-guard.js +121 -0
  28. package/dist/core/query/sql.d.ts +2 -0
  29. package/dist/core/query/sql.js +5 -0
  30. package/dist/core/query/swimming.d.ts +19 -0
  31. package/dist/core/query/swimming.js +184 -0
  32. package/dist/core/query/wellness.d.ts +45 -0
  33. package/dist/core/query/wellness.js +292 -0
  34. package/dist/core/retrieval/index.d.ts +13 -0
  35. package/dist/core/retrieval/index.js +248 -0
  36. package/dist/core/runtime/catalog.d.ts +41 -0
  37. package/dist/core/runtime/catalog.js +111 -0
  38. package/dist/core/runtime/configuration.d.ts +71 -0
  39. package/dist/core/runtime/configuration.js +106 -0
  40. package/dist/core/runtime/limiter.d.ts +13 -0
  41. package/dist/core/runtime/limiter.js +17 -0
  42. package/dist/core/runtime/secrets.d.ts +24 -0
  43. package/dist/core/runtime/secrets.js +68 -0
  44. package/dist/elt/application/demo.d.ts +23 -0
  45. package/dist/elt/application/demo.js +165 -0
  46. package/dist/elt/application/management.d.ts +25 -0
  47. package/dist/elt/application/management.js +285 -0
  48. package/dist/elt/ingestion/importer.d.ts +8 -0
  49. package/dist/elt/ingestion/importer.js +87 -0
  50. package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
  51. package/dist/elt/ingestion/providers/intervals.js +224 -0
  52. package/dist/elt/ingestion/providers/registry.d.ts +19 -0
  53. package/dist/elt/ingestion/providers/registry.js +62 -0
  54. package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
  55. package/dist/elt/ingestion/providers/strava/service.js +366 -0
  56. package/dist/elt/normalization/activities/linking.d.ts +5 -0
  57. package/dist/elt/normalization/activities/linking.js +165 -0
  58. package/dist/elt/normalization/normalizers.d.ts +5 -0
  59. package/dist/elt/normalization/normalizers.js +816 -0
  60. package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
  61. package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
  62. package/dist/elt/storage/artifacts.d.ts +13 -0
  63. package/dist/elt/storage/artifacts.js +48 -0
  64. package/dist/elt/storage/database.d.ts +74 -0
  65. package/dist/elt/storage/database.js +296 -0
  66. package/dist/elt/storage/migrations.d.ts +3 -0
  67. package/dist/elt/storage/migrations.js +832 -0
  68. package/dist/elt/storage/migrations.legacy.d.ts +12 -0
  69. package/dist/elt/storage/migrations.legacy.js +332 -0
  70. package/dist/elt/storage/sql.d.ts +2 -0
  71. package/dist/elt/storage/sql.js +7 -0
  72. package/dist/elt/storage/write-lock.d.ts +6 -0
  73. package/dist/elt/storage/write-lock.js +28 -0
  74. package/dist/elt/streams.d.ts +24 -0
  75. package/dist/elt/streams.js +62 -0
  76. package/dist/interfaces/cli/commands.d.ts +2 -0
  77. package/dist/interfaces/cli/commands.js +166 -0
  78. package/dist/interfaces/cli/main.d.ts +2 -0
  79. package/dist/interfaces/cli/main.js +3 -0
  80. package/dist/interfaces/http/cli-options.d.ts +9 -0
  81. package/dist/interfaces/http/cli-options.js +81 -0
  82. package/dist/interfaces/http/server.d.ts +17 -0
  83. package/dist/interfaces/http/server.js +196 -0
  84. package/dist/interfaces/mcp/cli-options.d.ts +6 -0
  85. package/dist/interfaces/mcp/cli-options.js +36 -0
  86. package/dist/interfaces/mcp/main.d.ts +2 -0
  87. package/dist/interfaces/mcp/main.js +66 -0
  88. package/dist/interfaces/mcp/server.d.ts +8 -0
  89. package/dist/interfaces/mcp/server.js +572 -0
  90. package/dist/runtime/index.d.ts +33 -0
  91. package/dist/runtime/index.js +21 -0
  92. package/docs/architecture.md +100 -0
  93. package/docs/assets/image.png +0 -0
  94. package/docs/mcp-activity-retrieval.md +377 -0
  95. package/package.json +81 -6
  96. package/pyproject.toml +28 -0
  97. package/python/__init__.py +1 -0
  98. package/python/catence/__init__.py +0 -0
  99. package/python/catence/providers/__init__.py +0 -0
  100. package/python/catence/providers/garmin/__init__.py +3 -0
  101. package/python/catence/providers/garmin/cli.py +62 -0
  102. package/python/catence/providers/garmin/contracts.py +23 -0
  103. package/python/catence/providers/garmin/registry.py +48 -0
  104. package/python/catence/providers/garmin/staging.py +87 -0
  105. package/python/catence/providers/garmin/streams.py +195 -0
  106. package/python/catence/providers/garmin/worker.py +450 -0
  107. package/python/catence/providers/strava/__init__.py +3 -0
  108. package/python/catence/providers/strava/cli.py +404 -0
  109. package/python/catence/providers/strava/staging.py +73 -0
  110. package/uv.lock +708 -0
@@ -0,0 +1,40 @@
1
+ import { ReadOnlyRepository } from './repository.js';
2
+ export type FitnessDateRange = {
3
+ startDate?: string;
4
+ endDate?: string;
5
+ };
6
+ export type FtpHistoryRequest = FitnessDateRange & {
7
+ sport?: string;
8
+ sourcePreference?: 'settings' | 'settings_then_activity' | 'all';
9
+ };
10
+ export type Vo2MaxHistoryRequest = FitnessDateRange & {
11
+ sport?: string;
12
+ };
13
+ export type PowerSportFamily = 'cycling' | 'running';
14
+ type PowerSportSelection = {
15
+ sport?: string;
16
+ sportFamily?: PowerSportFamily;
17
+ };
18
+ export type PowerCurveTrendRequest = FitnessDateRange & PowerSportSelection & {
19
+ durations?: number[];
20
+ sourceQuality?: 'all' | 'garmin_fit_derived';
21
+ };
22
+ export type PowerCoverageReportRequest = FitnessDateRange & PowerSportSelection & {
23
+ sourceQuality?: 'all' | 'garmin_fit_derived';
24
+ };
25
+ export type LatestCyclingActivitiesRequest = FitnessDateRange & {
26
+ includeMultisport?: boolean;
27
+ limit?: number;
28
+ };
29
+ /** Read-only, source-aware fitness projections built on normalized facts. */
30
+ export declare class FitnessService {
31
+ private readonly repository;
32
+ constructor(repository: ReadOnlyRepository);
33
+ ftpHistory(request?: FtpHistoryRequest): Promise<Record<string, unknown>>;
34
+ vo2MaxHistory(request?: Vo2MaxHistoryRequest): Promise<Record<string, unknown>>;
35
+ powerCurveTrend(request?: PowerCurveTrendRequest): Promise<Record<string, unknown>>;
36
+ powerCoverageReport(request?: PowerCoverageReportRequest): Promise<Record<string, unknown>>;
37
+ latestCyclingActivities(request?: LatestCyclingActivitiesRequest): Promise<Record<string, unknown>>;
38
+ cyclingProgressReport(request?: FitnessDateRange): Promise<Record<string, unknown>>;
39
+ }
40
+ export {};
@@ -0,0 +1,297 @@
1
+ import { QueryValidationError } from './catalog.js';
2
+ const CYCLING_SPORTS = ['cycling', 'road_biking', 'indoor_cycling', 'virtual_ride'];
3
+ const DEFAULT_POWER_DURATIONS = [5, 60, 300, 1_200, 1_800];
4
+ const RUNNING_VO2MAX_ALIASES = new Set(['run', 'running']);
5
+ function addDateRange(column, request, values) {
6
+ const clauses = [];
7
+ if (request.startDate) {
8
+ values.startDate = request.startDate;
9
+ clauses.push(`${column} >= cast($startDate AS TIMESTAMPTZ)`);
10
+ }
11
+ if (request.endDate) {
12
+ values.endDate = `${request.endDate}T23:59:59.999Z`;
13
+ clauses.push(`${column} <= cast($endDate AS TIMESTAMPTZ)`);
14
+ }
15
+ return clauses;
16
+ }
17
+ function cyclingSportClause(column, sport, values) {
18
+ if (!sport || sport.toLowerCase() === 'cycling')
19
+ return `lower(${column}) IN (${CYCLING_SPORTS.map((item) => `'${item}'`).join(', ')})`;
20
+ values.sport = sport;
21
+ return `lower(${column}) = lower($sport)`;
22
+ }
23
+ function powerSportClause(column, selection, values) {
24
+ if (selection.sport && selection.sportFamily)
25
+ throw new QueryValidationError('Choose either sport or sportFamily, not both.');
26
+ if (selection.sport) {
27
+ values.sport = selection.sport;
28
+ return `lower(${column}) = lower($sport)`;
29
+ }
30
+ if (selection.sportFamily === 'cycling')
31
+ return `lower(${column}) IN (${CYCLING_SPORTS.map((item) => `'${item}'`).join(', ')})`;
32
+ if (selection.sportFamily === 'running')
33
+ return `lower(${column}) LIKE '%run%'`;
34
+ throw new QueryValidationError('power queries require an explicit sport or sportFamily.');
35
+ }
36
+ function durationLabel(duration) {
37
+ if (duration < 60)
38
+ return `${duration} s`;
39
+ if (duration % 60 === 0)
40
+ return `${duration / 60} min`;
41
+ return `${Math.floor(duration / 60)} min ${duration % 60} s`;
42
+ }
43
+ function sourceRank(sourceType) {
44
+ if (sourceType === 'cycling_ftp_history')
45
+ return 0;
46
+ if (sourceType === 'cycling_ftp')
47
+ return 1;
48
+ if (sourceType === 'activity_summary')
49
+ return 2;
50
+ return 3;
51
+ }
52
+ function sourceTypeFilter(preference) {
53
+ if (preference === 'settings')
54
+ return "AND source_type IN ('cycling_ftp_history', 'cycling_ftp')";
55
+ if (preference === 'settings_then_activity')
56
+ return "AND source_type IN ('cycling_ftp_history', 'cycling_ftp', 'activity_summary')";
57
+ return '';
58
+ }
59
+ /** Read-only, source-aware fitness projections built on normalized facts. */
60
+ export class FitnessService {
61
+ repository;
62
+ constructor(repository) {
63
+ this.repository = repository;
64
+ }
65
+ async ftpHistory(request = {}) {
66
+ const sourcePreference = request.sourcePreference ?? 'settings_then_activity';
67
+ const values = {};
68
+ const clauses = ["metric_name = 'cycling_ftp_w'", cyclingSportClause('sport', request.sport ?? 'cycling', values), ...addDateRange('observed_at', request, values)];
69
+ const observations = await this.repository.rows(`
70
+ SELECT observation_id, cast(observed_at AS VARCHAR) AS observed_at, value_number, value_text, unit, sport,
71
+ source_type, source_remote_id, activity_source_id, raw_object_hash
72
+ FROM training_metric_observations
73
+ WHERE ${clauses.join(' AND ')} ${sourceTypeFilter(sourcePreference)}
74
+ ORDER BY observed_at ASC,
75
+ CASE source_type WHEN 'cycling_ftp_history' THEN 0 WHEN 'cycling_ftp' THEN 1 WHEN 'activity_summary' THEN 2 ELSE 3 END,
76
+ observation_id ASC
77
+ `, values);
78
+ const seriesByDate = new Map();
79
+ for (const observation of observations) {
80
+ const date = observation.observed_at.slice(0, 10);
81
+ const current = seriesByDate.get(date);
82
+ if (!current || sourceRank(observation.source_type) < sourceRank(current.source_type))
83
+ seriesByDate.set(date, observation);
84
+ }
85
+ return {
86
+ data: { observations, preferredSeries: [...seriesByDate.values()] },
87
+ provenance: { dataset: 'training_metric_observations', metric: 'cycling_ftp_w', sourcePreference },
88
+ query: { ...request, sport: request.sport ?? 'cycling', sourcePreference },
89
+ caveats: [
90
+ 'FTP settings, the direct current-setting endpoint, and activity-summary FTP are retained as distinct source types.',
91
+ ...(observations.length === 0 ? ['No matching normalized FTP observations are available for this range.'] : []),
92
+ ],
93
+ };
94
+ }
95
+ async vo2MaxHistory(request = {}) {
96
+ if (!request.sport) {
97
+ const values = {};
98
+ const clauses = ["metric_name = 'vo2_max_ml_kg_min'", ...addDateRange('observed_at', request, values)];
99
+ const availableSports = await this.repository.rows(`
100
+ WITH ranked AS (
101
+ SELECT sport, value_number, observed_at,
102
+ count(*) OVER (PARTITION BY sport)::INTEGER AS count,
103
+ row_number() OVER (PARTITION BY sport ORDER BY observed_at DESC, observation_id DESC) AS latest_rank
104
+ FROM training_metric_observations
105
+ WHERE ${clauses.join(' AND ')}
106
+ )
107
+ SELECT sport, max(count)::INTEGER AS count,
108
+ max(value_number) FILTER (WHERE latest_rank = 1) AS latest,
109
+ cast(max(observed_at) FILTER (WHERE latest_rank = 1) AS VARCHAR) AS latest_observed_at
110
+ FROM ranked GROUP BY sport ORDER BY sport ASC
111
+ `, values);
112
+ return {
113
+ data: {
114
+ availableSports,
115
+ sportAliases: [{ requestedSport: 'running', sourceSport: 'generic', reason: 'Garmin labels its running VO₂max estimate as generic.' }],
116
+ actionRequired: 'Choose a sport explicitly. Use running for Garmin running VO₂max.',
117
+ },
118
+ provenance: { dataset: 'training_metric_observations', metric: 'vo2_max_ml_kg_min' },
119
+ query: { ...request, sport: null },
120
+ caveats: [
121
+ 'No VO₂max observations are returned until sport is chosen explicitly; Catence never silently defaults to cycling.',
122
+ 'For a running request, Catence reads Garmin’s generic source-labelled VO₂max series and retains generic on each returned observation.',
123
+ ],
124
+ };
125
+ }
126
+ const requestedSport = request.sport;
127
+ const sourceSport = RUNNING_VO2MAX_ALIASES.has(requestedSport.trim().toLowerCase()) ? 'generic' : requestedSport;
128
+ const values = { sourceSport };
129
+ const clauses = ["metric_name = 'vo2_max_ml_kg_min'", 'lower(sport) = lower($sourceSport)', ...addDateRange('observed_at', request, values)];
130
+ const observations = await this.repository.rows(`
131
+ SELECT observation_id, cast(observed_at AS VARCHAR) AS observed_at, value_number, value_text, unit, sport,
132
+ source_type, source_remote_id, activity_source_id, raw_object_hash
133
+ FROM training_metric_observations
134
+ WHERE ${clauses.join(' AND ')}
135
+ ORDER BY observed_at ASC, observation_id ASC
136
+ `, values);
137
+ return {
138
+ data: observations,
139
+ provenance: { dataset: 'training_metric_observations', metric: 'vo2_max_ml_kg_min', requestedSport, sourceSport },
140
+ query: { ...request, sport: requestedSport, sourceSport },
141
+ caveats: [
142
+ 'Sport values on observations are reported exactly as Garmin supplied them; generic and cycling values are never combined.',
143
+ ...(sourceSport === 'generic' && requestedSport !== 'generic' ? ['Garmin supplies running VO₂max under the generic source label; the returned rows preserve that raw label.'] : []),
144
+ ...(observations.length === 0 ? [`No normalized ${requestedSport} VO₂max history is available for this range.`] : []),
145
+ ],
146
+ };
147
+ }
148
+ async powerCurveTrend(request = {}) {
149
+ const durations = [...new Set(request.durations ?? DEFAULT_POWER_DURATIONS)].sort((left, right) => left - right);
150
+ if (durations.length === 0 || durations.length > 12 || durations.some((duration) => !Number.isInteger(duration) || duration < 1 || duration > 86_400)) {
151
+ throw new QueryValidationError('durations must contain 1–12 whole-second values between 1 and 86,400.');
152
+ }
153
+ const sourceQuality = request.sourceQuality ?? 'all';
154
+ const values = {};
155
+ const durationPlaceholders = durations.map((duration, index) => {
156
+ const key = `duration${index}`;
157
+ values[key] = duration;
158
+ return `$${key}`;
159
+ });
160
+ const clauses = [powerSportClause('sport', request, values), `duration_s IN (${durationPlaceholders.join(', ')})`, ...addDateRange('started_at_utc', request, values)];
161
+ if (sourceQuality !== 'all')
162
+ clauses.push("source_type = 'garmin_fit_derived'");
163
+ const rows = await this.repository.rows(`
164
+ WITH ranked AS (
165
+ SELECT cast(date_trunc('month', started_at_utc) AS VARCHAR) AS month, duration_s, best_power_w,
166
+ activity_id, activity_source_id, cast(started_at_utc AS VARCHAR) AS started_at_utc, sport, source_type, raw_object_hash,
167
+ row_number() OVER (
168
+ PARTITION BY date_trunc('month', started_at_utc), duration_s
169
+ ORDER BY best_power_w DESC, started_at_utc ASC, activity_source_id ASC
170
+ ) AS source_rank
171
+ FROM power_best_facts
172
+ WHERE ${clauses.join(' AND ')}
173
+ )
174
+ SELECT * EXCLUDE (source_rank) FROM ranked WHERE source_rank = 1
175
+ ORDER BY month ASC, duration_s ASC
176
+ `, values);
177
+ return {
178
+ data: rows.map((row) => ({ ...row, durationLabel: durationLabel(row.duration_s) })),
179
+ provenance: { dataset: 'power_best_facts', sourceQuality },
180
+ query: { ...request, durations, sourceQuality, aggregation: 'monthly maximum per duration' },
181
+ caveats: [
182
+ 'Values are duration-labelled Garmin FIT-derived power bests; each month/duration row identifies its supporting activity.',
183
+ ...(rows.length === 0 ? ['No normalized power-best observations matched the requested durations and range.'] : []),
184
+ ],
185
+ };
186
+ }
187
+ async powerCoverageReport(request = {}) {
188
+ const sourceQuality = request.sourceQuality ?? 'garmin_fit_derived';
189
+ const values = {};
190
+ const activityClauses = [powerSportClause('activity.sport', request, values), ...addDateRange('activity.started_at_utc', request, values)];
191
+ const bestClause = sourceQuality === 'garmin_fit_derived' ? "WHERE best.source_type = 'garmin_fit_derived'" : '';
192
+ const scoped = `
193
+ WITH source_activities AS (
194
+ SELECT source.activity_source_id, activity.started_at_utc, activity.sport
195
+ FROM activity_sources AS source
196
+ JOIN activities AS activity USING (activity_id)
197
+ WHERE ${activityClauses.join(' AND ')}
198
+ ), scoped_bests AS (
199
+ SELECT best.activity_source_id, best.duration_s, best.best_power_w, best.source_type
200
+ FROM activity_power_bests AS best
201
+ JOIN source_activities USING (activity_source_id)
202
+ ${bestClause}
203
+ )
204
+ `;
205
+ const [activityCoverage, durationInventory] = await Promise.all([
206
+ this.repository.rows(`${scoped}
207
+ SELECT sport, count(DISTINCT source_activities.activity_source_id)::INTEGER AS activities,
208
+ count(DISTINCT scoped_bests.activity_source_id)::INTEGER AS powered_activities,
209
+ count(scoped_bests.duration_s)::INTEGER AS datapoints,
210
+ cast(min(started_at_utc) AS VARCHAR) AS first_activity_at,
211
+ cast(max(started_at_utc) AS VARCHAR) AS last_activity_at
212
+ FROM source_activities
213
+ LEFT JOIN scoped_bests USING (activity_source_id)
214
+ GROUP BY sport
215
+ ORDER BY sport`, values),
216
+ this.repository.rows(`${scoped}
217
+ SELECT duration_s, count(*)::INTEGER AS datapoints,
218
+ count(DISTINCT activity_source_id)::INTEGER AS activities,
219
+ min(best_power_w) AS min_power_w, max(best_power_w) AS max_power_w
220
+ FROM scoped_bests
221
+ GROUP BY duration_s
222
+ ORDER BY duration_s`, values),
223
+ ]);
224
+ return {
225
+ data: { activityCoverage, durationInventory: durationInventory.map((row) => ({ ...row, durationLabel: durationLabel(row.duration_s) })) },
226
+ provenance: { activityDataset: 'activities', powerDataset: 'power_best_facts', sourceQuality },
227
+ query: { ...request, sourceQuality },
228
+ caveats: [
229
+ 'Coverage is based on FIT-derived per-activity duration bests, never sparse activity-summary average power.',
230
+ 'Short-duration maxima can include sensor spikes; inspect the supporting activity stream before treating an extreme as physiological.',
231
+ 'Raw activity samples remain scoped to one activity source at a time and may paginate.',
232
+ ],
233
+ };
234
+ }
235
+ async latestCyclingActivities(request = {}) {
236
+ const limit = Math.min(Math.max(request.limit ?? 20, 1), 100);
237
+ const values = { includeMultisport: request.includeMultisport ?? false, limit };
238
+ const sportClause = cyclingSportClause('activity.sport', 'cycling', values);
239
+ const clauses = [`(${sportClause} OR ($includeMultisport AND lower(activity.sport) = 'multi_sport'))`, ...addDateRange('activity.started_at_utc', request, values)];
240
+ const rows = await this.repository.rows(`
241
+ SELECT source.activity_id, source.activity_source_id, source.remote_activity_id,
242
+ cast(activity.started_at_utc AS VARCHAR) AS started_at_utc, activity.sport, activity.name, activity.link_state,
243
+ raw.parent_remote_id, summary.distance_m, summary.moving_s, summary.elapsed_s, summary.elevation_gain_m,
244
+ summary.avg_hr, summary.avg_power, summary.training_load, manifest.row_count AS stream_rows, source.raw_object_hash
245
+ FROM activity_sources AS source
246
+ JOIN activities AS activity USING (activity_id)
247
+ LEFT JOIN activity_summaries AS summary USING (activity_source_id)
248
+ LEFT JOIN source_entities AS raw ON raw.provider = source.provider AND raw.entity_type = 'activity' AND raw.remote_id = source.remote_activity_id
249
+ LEFT JOIN stream_manifest AS manifest ON manifest.provider = source.provider AND manifest.activity_source_id = source.activity_source_id
250
+ WHERE source.provider = 'garmin' AND ${clauses.join(' AND ')}
251
+ ORDER BY activity.started_at_utc DESC, source.activity_source_id ASC
252
+ LIMIT $limit
253
+ `, values);
254
+ return {
255
+ data: rows.map((row) => ({
256
+ ...row,
257
+ isMultisportParent: row.sport.toLowerCase() === 'multi_sport',
258
+ parentActivityRemoteId: row.parent_remote_id,
259
+ streamAvailable: Number(row.stream_rows ?? 0) > 0,
260
+ })),
261
+ provenance: { relations: ['activities', 'activity_sources', 'activity_summaries', 'source_entities', 'stream_manifest'], provider: 'garmin' },
262
+ query: { ...request, includeMultisport: request.includeMultisport ?? false, limit },
263
+ caveats: [
264
+ 'The projection uses Garmin source records only, avoiding duplicate Intervals summaries for the same linked activity.',
265
+ ...(request.includeMultisport ? ['Multisport parent summaries are flagged and are not represented as cycling-only component metrics.'] : []),
266
+ ],
267
+ };
268
+ }
269
+ async cyclingProgressReport(request = {}) {
270
+ const values = {};
271
+ const clauses = [cyclingSportClause('sport', 'cycling', values), ...addDateRange('started_at_utc', request, values)];
272
+ const [ftp, vo2max, powerCurve, monthlyVolume] = await Promise.all([
273
+ this.ftpHistory({ ...request, sport: 'cycling' }),
274
+ this.vo2MaxHistory({ ...request, sport: 'cycling' }),
275
+ this.powerCurveTrend({ ...request, sport: 'cycling' }),
276
+ this.repository.rows(`
277
+ SELECT cast(date_trunc('month', started_at_utc) AS VARCHAR) AS month,
278
+ count(*)::INTEGER AS activities, sum(distance_m) AS distance_m, sum(moving_s) AS moving_s,
279
+ sum(training_load) AS training_load
280
+ FROM canonical_activity_training
281
+ WHERE ${clauses.join(' AND ')}
282
+ GROUP BY date_trunc('month', started_at_utc)
283
+ ORDER BY month ASC
284
+ `, values),
285
+ ]);
286
+ return {
287
+ data: { ftp, vo2max, powerCurve, monthlyVolume },
288
+ provenance: { relations: ['training_metric_observations', 'power_best_facts', 'canonical_activity_training'] },
289
+ query: request,
290
+ caveats: [
291
+ 'This is a descriptive, source-aware report—not a physiological performance model.',
292
+ 'FTP settings, VO₂max snapshots, volume/load, and power bests retain their individual provenance in the nested results.',
293
+ ],
294
+ };
295
+ }
296
+ }
297
+ //# sourceMappingURL=fitness.js.map
@@ -0,0 +1,19 @@
1
+ import type { CatencePaths } from '../../contracts/runtime.js';
2
+ import type { QueryValues, ReadDataStore } from '../../contracts/storage.js';
3
+ import { type DatasetDefinition } from './catalog.js';
4
+ export declare class ReadOnlyRepository {
5
+ readonly database: ReadDataStore;
6
+ readonly paths: CatencePaths;
7
+ constructor(database: ReadDataStore, paths: CatencePaths);
8
+ close(): Promise<void>;
9
+ rows<T extends Record<string, unknown>>(sql: string, values?: QueryValues): Promise<T[]>;
10
+ datasetRelation(datasetName: string): Promise<string>;
11
+ activitySampleRelation(): Promise<string>;
12
+ status(): Promise<Record<string, unknown>>;
13
+ coverage(): Promise<Record<string, unknown>>;
14
+ activity(activityId: string): Promise<Record<string, unknown> | null>;
15
+ summary(startDate: string, endDate: string): Promise<Record<string, unknown>>;
16
+ catalog(): DatasetDefinition[];
17
+ }
18
+ /** Converts DuckDB's Date/BigInt values into MCP-safe JSON values. */
19
+ export declare function jsonSafe<T>(value: T): T;
@@ -0,0 +1,182 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { sqlString } from './sql.js';
4
+ import { DATASET_CATALOG, getDataset } from './catalog.js';
5
+ export class ReadOnlyRepository {
6
+ database;
7
+ paths;
8
+ constructor(database, paths) {
9
+ this.database = database;
10
+ this.paths = paths;
11
+ }
12
+ async close() {
13
+ await this.database.close();
14
+ }
15
+ async rows(sql, values) {
16
+ return this.database.rows(sql, values);
17
+ }
18
+ async datasetRelation(datasetName) {
19
+ const dataset = getDataset(datasetName);
20
+ if (!dataset.samplesOnly)
21
+ return dataset.relation;
22
+ return this.activitySampleRelation();
23
+ }
24
+ async activitySampleRelation() {
25
+ const manifests = await this.rows(`
26
+ SELECT relative_path
27
+ FROM stream_manifest
28
+ QUALIFY row_number() OVER (PARTITION BY provider, activity_source_id ORDER BY content_hash DESC) = 1
29
+ `);
30
+ const hasReadableRegisteredStream = manifests.some((manifest) => {
31
+ const candidate = path.resolve(this.paths.root, manifest.relative_path);
32
+ const expectedRoot = `${path.resolve(this.paths.lake)}${path.sep}`;
33
+ return candidate.startsWith(expectedRoot) && candidate.endsWith('.parquet') && existsSync(candidate);
34
+ });
35
+ if (hasReadableRegisteredStream) {
36
+ // A UNION ALL branch for every stream reaches DuckDB's expression-depth
37
+ // limit around 1,000 activities. The glob is internal and rooted at the
38
+ // configured lake; the manifest join remains the authoritative allowlist
39
+ // and supplies stable provider/content-hash provenance.
40
+ const lakeGlob = path.join(this.paths.lake, '**', '*.parquet');
41
+ return `
42
+ SELECT manifest.provider AS provider,
43
+ manifest.content_hash AS content_hash,
44
+ sample.activity_source_id,
45
+ try_cast(sample.timestamp_utc AS TIMESTAMPTZ) AS timestamp_utc,
46
+ try_cast(sample.elapsed_s AS DOUBLE) AS elapsed_s,
47
+ try_cast(sample.distance_m AS DOUBLE) AS distance_m,
48
+ try_cast(sample.latitude AS DOUBLE) AS latitude,
49
+ try_cast(sample.longitude AS DOUBLE) AS longitude,
50
+ try_cast(sample.altitude_m AS DOUBLE) AS altitude_m,
51
+ try_cast(sample.heart_rate_bpm AS DOUBLE) AS heart_rate_bpm,
52
+ try_cast(sample.power_w AS DOUBLE) AS power_w,
53
+ try_cast(sample.cadence_rpm AS DOUBLE) AS cadence_rpm,
54
+ try_cast(sample.speed_mps AS DOUBLE) AS speed_mps,
55
+ try_cast(sample.temperature_c AS DOUBLE) AS temperature_c,
56
+ try_cast(sample.grade_pct AS DOUBLE) AS grade_pct,
57
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftRightBalance'), json_extract_string(sample.extras_json, '$.left_right_balance')) AS DOUBLE) AS left_right_balance_pct,
58
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftTorqueEffectiveness'), json_extract_string(sample.extras_json, '$.left_torque_effectiveness')) AS DOUBLE) AS left_torque_effectiveness_pct,
59
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightTorqueEffectiveness'), json_extract_string(sample.extras_json, '$.right_torque_effectiveness')) AS DOUBLE) AS right_torque_effectiveness_pct,
60
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftPedalSmoothness'), json_extract_string(sample.extras_json, '$.left_pedal_smoothness')) AS DOUBLE) AS left_pedal_smoothness_pct,
61
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightPedalSmoothness'), json_extract_string(sample.extras_json, '$.right_pedal_smoothness')) AS DOUBLE) AS right_pedal_smoothness_pct,
62
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftPowerPhase[0]'), json_extract_string(sample.extras_json, '$.left_power_phase[0]')) AS DOUBLE) AS left_power_phase_start_deg,
63
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftPowerPhase[1]'), json_extract_string(sample.extras_json, '$.left_power_phase[1]')) AS DOUBLE) AS left_power_phase_end_deg,
64
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightPowerPhase[0]'), json_extract_string(sample.extras_json, '$.right_power_phase[0]')) AS DOUBLE) AS right_power_phase_start_deg,
65
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightPowerPhase[1]'), json_extract_string(sample.extras_json, '$.right_power_phase[1]')) AS DOUBLE) AS right_power_phase_end_deg,
66
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftPowerPhasePeak[0]'), json_extract_string(sample.extras_json, '$.left_power_phase_peak[0]')) AS DOUBLE) AS left_power_phase_peak_start_deg,
67
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftPowerPhasePeak[1]'), json_extract_string(sample.extras_json, '$.left_power_phase_peak[1]')) AS DOUBLE) AS left_power_phase_peak_end_deg,
68
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightPowerPhasePeak[0]'), json_extract_string(sample.extras_json, '$.right_power_phase_peak[0]')) AS DOUBLE) AS right_power_phase_peak_start_deg,
69
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightPowerPhasePeak[1]'), json_extract_string(sample.extras_json, '$.right_power_phase_peak[1]')) AS DOUBLE) AS right_power_phase_peak_end_deg,
70
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftPlatformCenterOffset'), json_extract_string(sample.extras_json, '$.left_pco')) AS DOUBLE) AS left_platform_center_offset_mm,
71
+ try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightPlatformCenterOffset'), json_extract_string(sample.extras_json, '$.right_pco')) AS DOUBLE) AS right_platform_center_offset_mm,
72
+ try_cast(sample.extras_json AS JSON) AS extras_json
73
+ FROM read_parquet(${sqlString(lakeGlob)}, union_by_name = true) AS sample
74
+ JOIN (
75
+ SELECT provider, activity_source_id, content_hash
76
+ FROM stream_manifest
77
+ QUALIFY row_number() OVER (PARTITION BY provider, activity_source_id ORDER BY content_hash DESC) = 1
78
+ ) AS manifest USING (activity_source_id)
79
+ `;
80
+ }
81
+ return `SELECT
82
+ CAST(NULL AS VARCHAR) AS provider,
83
+ CAST(NULL AS VARCHAR) AS content_hash,
84
+ CAST(NULL AS VARCHAR) AS activity_source_id,
85
+ CAST(NULL AS TIMESTAMPTZ) AS timestamp_utc,
86
+ CAST(NULL AS DOUBLE) AS elapsed_s,
87
+ CAST(NULL AS DOUBLE) AS distance_m,
88
+ CAST(NULL AS DOUBLE) AS latitude,
89
+ CAST(NULL AS DOUBLE) AS longitude,
90
+ CAST(NULL AS DOUBLE) AS altitude_m,
91
+ CAST(NULL AS DOUBLE) AS heart_rate_bpm,
92
+ CAST(NULL AS DOUBLE) AS power_w,
93
+ CAST(NULL AS DOUBLE) AS cadence_rpm,
94
+ CAST(NULL AS DOUBLE) AS speed_mps,
95
+ CAST(NULL AS DOUBLE) AS temperature_c,
96
+ CAST(NULL AS DOUBLE) AS grade_pct,
97
+ CAST(NULL AS DOUBLE) AS left_right_balance_pct,
98
+ CAST(NULL AS DOUBLE) AS left_torque_effectiveness_pct,
99
+ CAST(NULL AS DOUBLE) AS right_torque_effectiveness_pct,
100
+ CAST(NULL AS DOUBLE) AS left_pedal_smoothness_pct,
101
+ CAST(NULL AS DOUBLE) AS right_pedal_smoothness_pct,
102
+ CAST(NULL AS DOUBLE) AS left_power_phase_start_deg,
103
+ CAST(NULL AS DOUBLE) AS left_power_phase_end_deg,
104
+ CAST(NULL AS DOUBLE) AS right_power_phase_start_deg,
105
+ CAST(NULL AS DOUBLE) AS right_power_phase_end_deg,
106
+ CAST(NULL AS DOUBLE) AS left_power_phase_peak_start_deg,
107
+ CAST(NULL AS DOUBLE) AS left_power_phase_peak_end_deg,
108
+ CAST(NULL AS DOUBLE) AS right_power_phase_peak_start_deg,
109
+ CAST(NULL AS DOUBLE) AS right_power_phase_peak_end_deg,
110
+ CAST(NULL AS DOUBLE) AS left_platform_center_offset_mm,
111
+ CAST(NULL AS DOUBLE) AS right_platform_center_offset_mm,
112
+ CAST(NULL AS JSON) AS extras_json
113
+ WHERE FALSE`;
114
+ }
115
+ async status() {
116
+ // @duckdb/node-api shares native statement state on a connection. These
117
+ // small status reads must remain sequential; concurrent calls can hang a
118
+ // read-only dashboard request before it writes its response.
119
+ const runs = await this.rows(`SELECT run_id, provider, cast(from_date AS VARCHAR) AS from_date, cast(started_at AS VARCHAR) AS started_at, cast(completed_at AS VARCHAR) AS completed_at, status, error_count FROM sync_runs ORDER BY started_at DESC LIMIT 20`);
120
+ const counts = await this.rows(`SELECT
121
+ (SELECT count(*)::INTEGER FROM activities) AS activities,
122
+ (SELECT count(*)::INTEGER FROM activity_sources) AS activity_sources,
123
+ (SELECT count(*)::INTEGER FROM daily_metrics) AS daily_metrics,
124
+ (SELECT count(*)::INTEGER FROM nutrition_items) AS nutrition_items,
125
+ (SELECT count(*)::INTEGER FROM source_entities) AS source_entities,
126
+ (SELECT count(*)::INTEGER FROM retrieval_documents) AS retrieval_documents`);
127
+ const errors = await this.rows(`SELECT count(*)::INTEGER AS unresolved FROM normalization_errors WHERE resolved_at IS NULL`);
128
+ const streamCoverage = await this.rows(`SELECT count(*)::INTEGER AS streams, coalesce(sum(row_count), 0)::BIGINT AS sample_rows, min(start_at) AS starts_at, max(end_at) AS ends_at FROM stream_manifest`);
129
+ const indexState = await this.rows(`SELECT status, mode, source_watermark, cast(built_at AS VARCHAR) AS built_at FROM retrieval_index_state WHERE index_name = 'context'`);
130
+ const cursors = await this.rows(`SELECT provider, cursor_name, cast(covered_through_date AS VARCHAR) AS covered_through_date, cast(latest_source_date AS VARCHAR) AS latest_source_date, lookback_days, cast(last_completed_at AS VARCHAR) AS last_completed_at, status FROM sync_cursors ORDER BY provider, cursor_name`);
131
+ return {
132
+ syncRuns: runs,
133
+ entityCounts: counts[0] ?? {},
134
+ unresolvedExtractionErrors: errors[0]?.unresolved ?? 0,
135
+ streams: streamCoverage[0] ?? { streams: 0, sample_rows: 0 },
136
+ retrievalIndex: indexState[0] ?? { status: 'stale', mode: 'keyword' },
137
+ incrementalCursors: cursors,
138
+ };
139
+ }
140
+ async coverage() {
141
+ const rows = await this.rows(`
142
+ SELECT 'activities' AS dataset, min(cast(started_at_utc AS DATE)) AS start_date, max(cast(started_at_utc AS DATE)) AS end_date, count(*)::INTEGER AS row_count FROM activities
143
+ UNION ALL SELECT 'daily_metrics', min(metric_date), max(metric_date), count(*)::INTEGER FROM daily_metrics
144
+ UNION ALL SELECT 'training_metric_observations', min(cast(observed_at AS DATE)), max(cast(observed_at AS DATE)), count(*)::INTEGER FROM training_metric_observations
145
+ UNION ALL SELECT 'nutrition_days', min(nutrition_date), max(nutrition_date), count(*)::INTEGER FROM nutrition_days
146
+ UNION ALL SELECT 'source_entities', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM source_entities
147
+ `);
148
+ return { coverage: rows, providers: await this.rows(`SELECT provider, count(*)::INTEGER AS activity_sources FROM activity_sources GROUP BY provider ORDER BY provider`) };
149
+ }
150
+ async activity(activityId) {
151
+ const activity = await this.rows(`SELECT * FROM activities WHERE activity_id = $activityId`, { activityId });
152
+ if (!activity[0])
153
+ return null;
154
+ const sources = await this.rows(`SELECT * FROM activity_sources WHERE activity_id = $activityId ORDER BY CASE provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END, provider`, { activityId });
155
+ const summaries = await this.rows(`SELECT * FROM activity_summary_facts WHERE activity_id = $activityId ORDER BY CASE provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END, provider`, { activityId });
156
+ const intervals = await this.rows(`SELECT * FROM activity_interval_facts WHERE activity_id = $activityId ORDER BY CASE provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END, provider, start_s`, { activityId });
157
+ return { activity: activity[0], sources, summaries, intervals };
158
+ }
159
+ async summary(startDate, endDate) {
160
+ const training = await this.rows(`SELECT cast(started_at_utc AS DATE) AS date, count(*)::INTEGER AS activities, sum(distance_m) AS distance_m, sum(moving_s) AS moving_s, sum(training_load) AS training_load FROM canonical_activity_training WHERE cast(started_at_utc AS DATE) BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE) GROUP BY 1 ORDER BY 1`, { startDate, endDate });
161
+ const health = await this.rows(`SELECT * FROM daily_health WHERE metric_date BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE) ORDER BY metric_date, provider`, { startDate, endDate });
162
+ const nutrition = await this.rows(`SELECT * FROM nutrition_days WHERE nutrition_date BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE) ORDER BY nutrition_date, provider`, { startDate, endDate });
163
+ return { training, health, nutrition };
164
+ }
165
+ catalog() {
166
+ return Object.values(DATASET_CATALOG);
167
+ }
168
+ }
169
+ /** Converts DuckDB's Date/BigInt values into MCP-safe JSON values. */
170
+ export function jsonSafe(value) {
171
+ if (typeof value === 'bigint')
172
+ return String(value);
173
+ if (value instanceof Date)
174
+ return value.toISOString();
175
+ if (Array.isArray(value))
176
+ return value.map((item) => jsonSafe(item));
177
+ if (value && typeof value === 'object') {
178
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonSafe(item)]));
179
+ }
180
+ return value;
181
+ }
182
+ //# sourceMappingURL=repository.js.map
@@ -0,0 +1,7 @@
1
+ import { ReadOnlyRepository } from './repository.js';
2
+ export declare function queryReadOnlyData(repository: ReadOnlyRepository, request: {
3
+ sql: string;
4
+ values?: Record<string, string | number | boolean | null>;
5
+ cursor?: string;
6
+ pageSize?: number;
7
+ }): Promise<Record<string, unknown>>;