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.
- package/LICENSE +661 -0
- package/README.md +201 -29
- package/config.example.json +63 -0
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.js +5 -0
- package/dist/contracts/release.d.ts +20 -0
- package/dist/contracts/release.js +17 -0
- package/dist/contracts/runtime.d.ts +11 -0
- package/dist/contracts/runtime.js +2 -0
- package/dist/contracts/staging.d.ts +183 -0
- package/dist/contracts/staging.js +36 -0
- package/dist/contracts/storage.d.ts +15 -0
- package/dist/contracts/storage.js +2 -0
- package/dist/core/dashboard/snapshot.d.ts +16 -0
- package/dist/core/dashboard/snapshot.js +65 -0
- package/dist/core/query/activity-discovery.d.ts +21 -0
- package/dist/core/query/activity-discovery.js +133 -0
- package/dist/core/query/analytics.d.ts +59 -0
- package/dist/core/query/analytics.js +395 -0
- package/dist/core/query/catalog.d.ts +26 -0
- package/dist/core/query/catalog.js +141 -0
- package/dist/core/query/fitness.d.ts +40 -0
- package/dist/core/query/fitness.js +297 -0
- package/dist/core/query/repository.d.ts +19 -0
- package/dist/core/query/repository.js +182 -0
- package/dist/core/query/sql-guard.d.ts +7 -0
- package/dist/core/query/sql-guard.js +121 -0
- package/dist/core/query/sql.d.ts +2 -0
- package/dist/core/query/sql.js +5 -0
- package/dist/core/query/swimming.d.ts +19 -0
- package/dist/core/query/swimming.js +184 -0
- package/dist/core/query/wellness.d.ts +45 -0
- package/dist/core/query/wellness.js +292 -0
- package/dist/core/retrieval/index.d.ts +13 -0
- package/dist/core/retrieval/index.js +248 -0
- package/dist/core/runtime/catalog.d.ts +41 -0
- package/dist/core/runtime/catalog.js +111 -0
- package/dist/core/runtime/configuration.d.ts +71 -0
- package/dist/core/runtime/configuration.js +106 -0
- package/dist/core/runtime/limiter.d.ts +13 -0
- package/dist/core/runtime/limiter.js +17 -0
- package/dist/core/runtime/secrets.d.ts +24 -0
- package/dist/core/runtime/secrets.js +68 -0
- package/dist/elt/application/demo.d.ts +23 -0
- package/dist/elt/application/demo.js +165 -0
- package/dist/elt/application/management.d.ts +25 -0
- package/dist/elt/application/management.js +285 -0
- package/dist/elt/ingestion/importer.d.ts +8 -0
- package/dist/elt/ingestion/importer.js +87 -0
- package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
- package/dist/elt/ingestion/providers/intervals.js +224 -0
- package/dist/elt/ingestion/providers/registry.d.ts +19 -0
- package/dist/elt/ingestion/providers/registry.js +62 -0
- package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
- package/dist/elt/ingestion/providers/strava/service.js +366 -0
- package/dist/elt/normalization/activities/linking.d.ts +5 -0
- package/dist/elt/normalization/activities/linking.js +165 -0
- package/dist/elt/normalization/normalizers.d.ts +5 -0
- package/dist/elt/normalization/normalizers.js +816 -0
- package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
- package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
- package/dist/elt/storage/artifacts.d.ts +13 -0
- package/dist/elt/storage/artifacts.js +48 -0
- package/dist/elt/storage/database.d.ts +74 -0
- package/dist/elt/storage/database.js +296 -0
- package/dist/elt/storage/migrations.d.ts +3 -0
- package/dist/elt/storage/migrations.js +832 -0
- package/dist/elt/storage/migrations.legacy.d.ts +12 -0
- package/dist/elt/storage/migrations.legacy.js +332 -0
- package/dist/elt/storage/sql.d.ts +2 -0
- package/dist/elt/storage/sql.js +7 -0
- package/dist/elt/storage/write-lock.d.ts +6 -0
- package/dist/elt/storage/write-lock.js +28 -0
- package/dist/elt/streams.d.ts +24 -0
- package/dist/elt/streams.js +62 -0
- package/dist/interfaces/cli/commands.d.ts +2 -0
- package/dist/interfaces/cli/commands.js +166 -0
- package/dist/interfaces/cli/main.d.ts +2 -0
- package/dist/interfaces/cli/main.js +3 -0
- package/dist/interfaces/http/cli-options.d.ts +9 -0
- package/dist/interfaces/http/cli-options.js +81 -0
- package/dist/interfaces/http/server.d.ts +17 -0
- package/dist/interfaces/http/server.js +196 -0
- package/dist/interfaces/mcp/cli-options.d.ts +6 -0
- package/dist/interfaces/mcp/cli-options.js +36 -0
- package/dist/interfaces/mcp/main.d.ts +2 -0
- package/dist/interfaces/mcp/main.js +66 -0
- package/dist/interfaces/mcp/server.d.ts +8 -0
- package/dist/interfaces/mcp/server.js +572 -0
- package/dist/runtime/index.d.ts +33 -0
- package/dist/runtime/index.js +21 -0
- package/docs/architecture.md +100 -0
- package/docs/assets/image.png +0 -0
- package/docs/mcp-activity-retrieval.md +377 -0
- package/package.json +81 -6
- package/pyproject.toml +28 -0
- package/python/__init__.py +1 -0
- package/python/catence/__init__.py +0 -0
- package/python/catence/providers/__init__.py +0 -0
- package/python/catence/providers/garmin/__init__.py +3 -0
- package/python/catence/providers/garmin/cli.py +62 -0
- package/python/catence/providers/garmin/contracts.py +23 -0
- package/python/catence/providers/garmin/registry.py +48 -0
- package/python/catence/providers/garmin/staging.py +87 -0
- package/python/catence/providers/garmin/streams.py +195 -0
- package/python/catence/providers/garmin/worker.py +450 -0
- package/python/catence/providers/strava/__init__.py +3 -0
- package/python/catence/providers/strava/cli.py +404 -0
- package/python/catence/providers/strava/staging.py +73 -0
- package/uv.lock +708 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
function registry(provider, entries) {
|
|
2
|
+
return entries.map(([name, scope, entityType]) => ({ provider, name, scope, entityType, readOnly: true, pagination: scope === 'date_range' ? 'provider' : 'none' }));
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* The only provider operations an extractor may execute. Entries intentionally
|
|
6
|
+
* describe data families, not mutations: extraction code rejects every route
|
|
7
|
+
* outside this registry.
|
|
8
|
+
*/
|
|
9
|
+
export const intervalsReadRegistry = registry('intervals', [
|
|
10
|
+
['athlete', 'singleton', 'athlete'], ['profile', 'singleton', 'profile'], ['connections', 'singleton', 'connection'],
|
|
11
|
+
['sport_settings', 'collection', 'sport_setting'], ['activities', 'date_range', 'activity'], ['wellness', 'date_range', 'wellness'],
|
|
12
|
+
['events', 'date_range', 'event'], ['workouts', 'collection', 'workout'], ['folders', 'collection', 'folder'], ['gear', 'collection', 'gear'],
|
|
13
|
+
['routes', 'collection', 'route'], ['custom_items', 'collection', 'custom_item'], ['fitness', 'date_range', 'fitness_summary'],
|
|
14
|
+
['activity_summary', 'date_range', 'fitness_summary'], ['power_curves', 'date_range', 'power_curve'], ['pace_curves', 'date_range', 'pace_curve'],
|
|
15
|
+
['hr_curves', 'date_range', 'hr_curve'], ['activity_power_curves', 'date_range', 'activity_power_curve'],
|
|
16
|
+
['activity_hr_curves', 'date_range', 'activity_hr_curve'], ['weather_config', 'singleton', 'weather_config'], ['weather_forecast', 'singleton', 'weather_forecast'],
|
|
17
|
+
['chats', 'collection', 'chat'], ['activity_tags', 'collection', 'activity_tag'], ['event_tags', 'collection', 'event_tag'], ['workout_tags', 'collection', 'workout_tag'],
|
|
18
|
+
]);
|
|
19
|
+
/**
|
|
20
|
+
* Intervals is an analysis companion to Garmin: its activity-list payload
|
|
21
|
+
* supplies the separately named training calculations without duplicating
|
|
22
|
+
* Garmin's activity detail, files, streams, or account data.
|
|
23
|
+
*/
|
|
24
|
+
export const intervalsSecondaryReadRegistry = intervalsReadRegistry.filter((endpoint) => endpoint.name === 'athlete' || endpoint.name === 'activities');
|
|
25
|
+
export const intervalsActivityReadEndpoints = [
|
|
26
|
+
'activity', 'streams', 'intervals', 'map', 'weather', 'weather_summary', 'best_efforts', 'power_curve', 'pace_curve', 'hr_curve',
|
|
27
|
+
'power_vs_hr', 'hr_load_model', 'segments', 'messages', 'original_file', 'fit_file', 'gpx_file',
|
|
28
|
+
];
|
|
29
|
+
/** Read-only methods exposed by python-garminconnect, grouped for manifest tests and worker dispatch. */
|
|
30
|
+
export const garminReadRegistry = registry('garmin', [
|
|
31
|
+
['user_profile', 'singleton', 'profile'], ['userprofile_settings', 'singleton', 'profile_setting'], ['devices', 'collection', 'device'],
|
|
32
|
+
['device_settings', 'child', 'device_setting'], ['device_solar', 'date_range', 'device_solar'], ['device_alarms', 'collection', 'device_alarm'],
|
|
33
|
+
['stats', 'date_range', 'daily_stats'], ['user_summary', 'date_range', 'daily_health'], ['steps', 'date_range', 'steps'],
|
|
34
|
+
['heart_rates', 'date_range', 'heart_rate_day'], ['sleep', 'date_range', 'sleep'], ['stress', 'date_range', 'stress'], ['body_battery', 'date_range', 'body_battery'],
|
|
35
|
+
['respiration', 'date_range', 'respiration'], ['spo2', 'date_range', 'spo2'], ['hrv', 'date_range', 'hrv'], ['training_readiness', 'date_range', 'training_readiness'],
|
|
36
|
+
['training_status', 'date_range', 'training_status'], ['max_metrics', 'date_range', 'max_metric'], ['lactate_threshold', 'singleton', 'lactate_threshold'],
|
|
37
|
+
['endurance_score', 'date_range', 'endurance_score'], ['running_tolerance', 'date_range', 'running_tolerance'], ['race_predictions', 'date_range', 'race_prediction'],
|
|
38
|
+
['body_composition', 'date_range', 'body_composition'], ['weigh_ins', 'date_range', 'weigh_in'], ['blood_pressure', 'date_range', 'blood_pressure'],
|
|
39
|
+
['hydration', 'date_range', 'hydration'], ['nutrition_food_log', 'date_range', 'nutrition_log'], ['nutrition_meals', 'date_range', 'nutrition_log'], ['nutrition_settings', 'date_range', 'nutrition_setting'],
|
|
40
|
+
['menstrual', 'date_range', 'menstrual'], ['pregnancy_summary', 'singleton', 'pregnancy_summary'], ['activities', 'date_range', 'activity'],
|
|
41
|
+
['activity_details', 'activity', 'activity_detail'], ['activity_splits', 'activity', 'activity_interval'], ['activity_typed_splits', 'activity', 'activity_interval'],
|
|
42
|
+
['activity_split_summaries', 'activity', 'activity_interval'], ['activity_weather', 'activity', 'activity_weather'], ['activity_hr_zones', 'activity', 'activity_zone'],
|
|
43
|
+
['activity_power_zones', 'activity', 'activity_zone'], ['activity_exercise_sets', 'activity', 'activity_exercise_set'], ['activity_gear', 'activity', 'activity_gear'],
|
|
44
|
+
['activity_original', 'activity', 'activity_file'], ['activity_tcx', 'activity', 'activity_file'], ['activity_gpx', 'activity', 'activity_file'], ['activity_kml', 'activity', 'activity_file'],
|
|
45
|
+
['activity_csv', 'activity', 'activity_file'], ['workouts', 'collection', 'workout'], ['scheduled_workouts', 'date_range', 'scheduled_workout'],
|
|
46
|
+
['training_plans', 'collection', 'training_plan'], ['goals', 'collection', 'goal'], ['personal_records', 'singleton', 'personal_record'],
|
|
47
|
+
['earned_badges', 'collection', 'badge'], ['available_badges', 'collection', 'badge'], ['challenges', 'collection', 'challenge'],
|
|
48
|
+
['gear', 'collection', 'gear'], ['golf_summary', 'collection', 'golf_scorecard'], ['golf_user_stats', 'singleton', 'golf_user_stat'],
|
|
49
|
+
]);
|
|
50
|
+
export const stravaReadRegistry = registry('strava', [
|
|
51
|
+
['athlete', 'singleton', 'athlete'], ['gear', 'child', 'gear'],
|
|
52
|
+
['activity_candidates', 'date_range', 'activity'], ['activity_detail', 'activity', 'activity'],
|
|
53
|
+
['segment', 'child', 'segment'], ['segment_efforts', 'child', 'segment_effort'],
|
|
54
|
+
]);
|
|
55
|
+
export const allReadOnlyEndpoints = [...intervalsReadRegistry, ...garminReadRegistry, ...stravaReadRegistry];
|
|
56
|
+
export function assertReadOnlyRegistry() {
|
|
57
|
+
for (const endpoint of allReadOnlyEndpoints) {
|
|
58
|
+
if (endpoint.readOnly !== true)
|
|
59
|
+
throw new Error(`Unsafe endpoint registered: ${endpoint.provider}/${endpoint.name}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CatencePaths } from '../../../../contracts/runtime.js';
|
|
2
|
+
type WorkerStatus = 'completed' | 'partial' | 'not_found' | 'ambiguous' | 'connected' | 'authorization_required' | 'error';
|
|
3
|
+
type WorkerResult = {
|
|
4
|
+
status: WorkerStatus;
|
|
5
|
+
message?: string;
|
|
6
|
+
authorizationUrl?: string;
|
|
7
|
+
stravaActivityId?: string;
|
|
8
|
+
segmentId?: string;
|
|
9
|
+
gearCount?: number;
|
|
10
|
+
effortCount?: number;
|
|
11
|
+
continuationPage?: number;
|
|
12
|
+
lastCompletedPage?: number;
|
|
13
|
+
candidateCount?: number;
|
|
14
|
+
rawHash?: string;
|
|
15
|
+
matchEvidence?: Record<string, unknown>;
|
|
16
|
+
matchDiagnostics?: Record<string, unknown>;
|
|
17
|
+
rateHeaders?: Record<string, string>;
|
|
18
|
+
};
|
|
19
|
+
export declare class StravaRateLimitError extends Error {
|
|
20
|
+
readonly retryAfterSeconds?: number | undefined;
|
|
21
|
+
constructor(message: string, retryAfterSeconds?: number | undefined);
|
|
22
|
+
}
|
|
23
|
+
export declare class StravaEnrichmentError extends Error {
|
|
24
|
+
constructor(message: string);
|
|
25
|
+
}
|
|
26
|
+
export declare function syncStravaGear(paths: CatencePaths, refresh?: boolean): Promise<WorkerResult | Record<string, unknown>>;
|
|
27
|
+
/** Returns a Strava activity ID only for formats that identify one directly. */
|
|
28
|
+
export declare function stravaActivityIdFromExternalId(value: string | null | undefined): string | null;
|
|
29
|
+
export declare function stravaActivityIdFromSourcePayload(value: string | Record<string, unknown> | null): string | null;
|
|
30
|
+
/** Prefer a persisted Strava source over a generic provider external ID. */
|
|
31
|
+
export declare function resolveStravaActivityId(activityId: string, matches: Array<{
|
|
32
|
+
provider: string;
|
|
33
|
+
remote_activity_id: string;
|
|
34
|
+
external_id: string | null;
|
|
35
|
+
}>): string | null;
|
|
36
|
+
export declare function multisportChildActivitySourceIds(paths: CatencePaths, activityId: string): Promise<string[] | null>;
|
|
37
|
+
export declare function hydrateStravaActivity(paths: CatencePaths, activityId: string, refresh?: boolean): Promise<WorkerResult | Record<string, unknown>>;
|
|
38
|
+
export declare function hydrateStravaSegmentHistory(paths: CatencePaths, segmentId: string, refresh?: boolean): Promise<WorkerResult | Record<string, unknown>>;
|
|
39
|
+
export declare function getStravaAuthorizationUrl(paths: CatencePaths, redirectUri: string): Promise<WorkerResult>;
|
|
40
|
+
export declare function completeStravaAuthorization(paths: CatencePaths, code: string, redirectUri: string): Promise<WorkerResult>;
|
|
41
|
+
export declare function disconnectStrava(paths: CatencePaths): Promise<void>;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { ensurePaths, loadCatenceConfig } from '../../../../core/runtime/configuration.js';
|
|
8
|
+
import { athleteProviderEnvironment } from '../../../../core/runtime/secrets.js';
|
|
9
|
+
import { CatenceDatabase, openReadOnlyRepository } from '../../../storage/database.js';
|
|
10
|
+
import { importJsonl } from '../../importer.js';
|
|
11
|
+
import { withDataWriteLock } from '../../../storage/write-lock.js';
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
export class StravaRateLimitError extends Error {
|
|
14
|
+
retryAfterSeconds;
|
|
15
|
+
constructor(message, retryAfterSeconds) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
18
|
+
this.name = 'StravaRateLimitError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class StravaEnrichmentError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'StravaEnrichmentError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function packageRoot() {
|
|
28
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..');
|
|
29
|
+
}
|
|
30
|
+
function toFiniteInteger(value) {
|
|
31
|
+
if (!value)
|
|
32
|
+
return null;
|
|
33
|
+
const parsed = Number(value);
|
|
34
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
35
|
+
}
|
|
36
|
+
function parsePair(value) {
|
|
37
|
+
if (!value)
|
|
38
|
+
return [null, null];
|
|
39
|
+
const [first, second] = value.split(',').map((part) => toFiniteInteger(part.trim()));
|
|
40
|
+
return [first ?? null, second ?? null];
|
|
41
|
+
}
|
|
42
|
+
async function readWorkerResult(filePath) {
|
|
43
|
+
if (!existsSync(filePath))
|
|
44
|
+
throw new StravaEnrichmentError('Strava worker did not write its result manifest.');
|
|
45
|
+
const parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
|
46
|
+
if (!parsed || typeof parsed.status !== 'string')
|
|
47
|
+
throw new StravaEnrichmentError('Strava worker wrote an invalid result manifest.');
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
async function invokeWorker(paths, args, resultPath) {
|
|
51
|
+
try {
|
|
52
|
+
await execFileAsync('uv', ['run', 'python', '-m', 'python.catence.providers.strava.cli', ...args, '--data-dir', paths.root, '--result', resultPath], {
|
|
53
|
+
cwd: packageRoot(),
|
|
54
|
+
env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (!existsSync(resultPath)) {
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
throw new StravaEnrichmentError(`Strava worker failed before it could report a result: ${message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return readWorkerResult(resultPath);
|
|
64
|
+
}
|
|
65
|
+
async function storeRateState(database, result) {
|
|
66
|
+
const headers = result.rateHeaders ?? {};
|
|
67
|
+
const [limit15, limitDay] = parsePair(headers['x-ratelimit-limit']);
|
|
68
|
+
const [usage15, usageDay] = parsePair(headers['x-ratelimit-usage']);
|
|
69
|
+
const isThrottled = result.status === 'partial' && /rate limit|429/i.test(result.message ?? '');
|
|
70
|
+
await database.run(`INSERT INTO strava_rate_state (account_id, read_limit_15m, read_usage_15m, read_limit_day, read_usage_day, blocked_until, observed_at)
|
|
71
|
+
VALUES ('authenticated_athlete', $limit15, $usage15, $limitDay, $usageDay, CASE WHEN $throttled THEN now() + INTERVAL 15 MINUTE ELSE NULL END, now())
|
|
72
|
+
ON CONFLICT (account_id) DO UPDATE SET
|
|
73
|
+
read_limit_15m = COALESCE(excluded.read_limit_15m, strava_rate_state.read_limit_15m),
|
|
74
|
+
read_usage_15m = COALESCE(excluded.read_usage_15m, strava_rate_state.read_usage_15m),
|
|
75
|
+
read_limit_day = COALESCE(excluded.read_limit_day, strava_rate_state.read_limit_day),
|
|
76
|
+
read_usage_day = COALESCE(excluded.read_usage_day, strava_rate_state.read_usage_day),
|
|
77
|
+
blocked_until = CASE WHEN excluded.blocked_until IS NOT NULL THEN excluded.blocked_until ELSE strava_rate_state.blocked_until END,
|
|
78
|
+
observed_at = now()`, { limit15, usage15, limitDay, usageDay, throttled: isThrottled });
|
|
79
|
+
}
|
|
80
|
+
async function checkBudget(database, paths) {
|
|
81
|
+
const config = await loadCatenceConfig(paths);
|
|
82
|
+
const budget = config.providers?.strava?.budget;
|
|
83
|
+
const rate = (await database.rows(`SELECT blocked_until, read_usage_15m, read_usage_day FROM strava_rate_state WHERE account_id = 'authenticated_athlete'`))[0];
|
|
84
|
+
const blockedUntil = rate?.blocked_until ? new Date(rate.blocked_until).getTime() : 0;
|
|
85
|
+
if (blockedUntil > Date.now())
|
|
86
|
+
throw new StravaRateLimitError('Strava has recently throttled Catence; retry after its remote rate-limit window.', Math.ceil((blockedUntil - Date.now()) / 1_000));
|
|
87
|
+
if (budget?.readRequestsPer15Minutes && (rate?.read_usage_15m ?? 0) >= budget.readRequestsPer15Minutes)
|
|
88
|
+
throw new StravaRateLimitError('Catence local Strava 15-minute request budget has been reached.');
|
|
89
|
+
if (budget?.readRequestsPerDay && (rate?.read_usage_day ?? 0) >= budget.readRequestsPerDay)
|
|
90
|
+
throw new StravaRateLimitError('Catence local Strava daily request budget has been reached.');
|
|
91
|
+
}
|
|
92
|
+
async function writeState(database, resourceType, remoteId, result) {
|
|
93
|
+
const status = result.status === 'completed' ? 'completed' : result.status;
|
|
94
|
+
await database.run(`INSERT INTO strava_enrichment_state (resource_type, remote_id, status, fetched_at, completed_at, continuation_page, detail_json)
|
|
95
|
+
VALUES ($resourceType, $remoteId, $status, now(), CASE WHEN $status = 'completed' THEN now() ELSE NULL END, $continuationPage, $detail)
|
|
96
|
+
ON CONFLICT (resource_type, remote_id) DO UPDATE SET
|
|
97
|
+
status = excluded.status, fetched_at = now(), completed_at = CASE WHEN excluded.status = 'completed' THEN now() ELSE strava_enrichment_state.completed_at END,
|
|
98
|
+
continuation_page = excluded.continuation_page, detail_json = excluded.detail_json`, { resourceType, remoteId, status, continuationPage: result.continuationPage ?? null, detail: JSON.stringify(result) });
|
|
99
|
+
}
|
|
100
|
+
async function cachedState(database, resourceType, remoteId) {
|
|
101
|
+
const state = await database.rows(`SELECT status, detail_json, fetched_at, completed_at FROM strava_enrichment_state WHERE resource_type = $resourceType AND remote_id = $remoteId`, { resourceType, remoteId });
|
|
102
|
+
const row = state[0];
|
|
103
|
+
if (!row || row.status !== 'completed')
|
|
104
|
+
return null;
|
|
105
|
+
const detail = typeof row.detail_json === 'string' ? JSON.parse(row.detail_json) : row.detail_json;
|
|
106
|
+
return { ...detail, cache: { reused: true, fetchedAt: row.fetched_at, completedAt: row.completed_at } };
|
|
107
|
+
}
|
|
108
|
+
async function runReadOperation(paths, operation, extraArgs, resource, refresh = false) {
|
|
109
|
+
return withDataWriteLock(paths, async () => {
|
|
110
|
+
await ensurePaths(paths);
|
|
111
|
+
const database = await CatenceDatabase.open(paths);
|
|
112
|
+
try {
|
|
113
|
+
if (!refresh) {
|
|
114
|
+
const cached = await cachedState(database, resource.type, resource.id);
|
|
115
|
+
if (cached)
|
|
116
|
+
return cached;
|
|
117
|
+
}
|
|
118
|
+
await checkBudget(database, paths);
|
|
119
|
+
const runId = await database.beginRun('strava', new Date().toISOString().slice(0, 10));
|
|
120
|
+
const directory = path.join(paths.staging, 'strava');
|
|
121
|
+
const stagingPath = path.join(directory, `${runId}.jsonl`);
|
|
122
|
+
const resultPath = path.join(directory, `${runId}.result.json`);
|
|
123
|
+
let result;
|
|
124
|
+
try {
|
|
125
|
+
result = await invokeWorker(paths, ['--mode', operation, '--run-id', runId, '--output', stagingPath, ...extraArgs], resultPath);
|
|
126
|
+
if (existsSync(stagingPath))
|
|
127
|
+
await importJsonl(database, runId, stagingPath);
|
|
128
|
+
await storeRateState(database, result);
|
|
129
|
+
await writeState(database, resource.type, resource.id, result);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
133
|
+
await database.addError(runId, 'strava', operation, resource.id, message, true);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
await database.finishRun(runId);
|
|
138
|
+
}
|
|
139
|
+
if (result.status === 'error')
|
|
140
|
+
throw new StravaEnrichmentError(result.message ?? 'Strava did not complete the requested enrichment.');
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
await database.close();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
export async function syncStravaGear(paths, refresh = true) {
|
|
149
|
+
return runReadOperation(paths, 'gear', [], { type: 'gear', id: 'authenticated_athlete' }, refresh);
|
|
150
|
+
}
|
|
151
|
+
/** Returns a Strava activity ID only for formats that identify one directly. */
|
|
152
|
+
export function stravaActivityIdFromExternalId(value) {
|
|
153
|
+
const externalId = value?.trim();
|
|
154
|
+
if (!externalId)
|
|
155
|
+
return null;
|
|
156
|
+
const match = externalId.match(/^strava:(\d+)$/i)
|
|
157
|
+
?? externalId.match(/^https?:\/\/(?:www\.)?strava\.com\/activities\/(\d+)\/?$/i);
|
|
158
|
+
return match?.[1] ?? null;
|
|
159
|
+
}
|
|
160
|
+
function object(value) {
|
|
161
|
+
if (value && typeof value === 'object')
|
|
162
|
+
return value;
|
|
163
|
+
if (typeof value !== 'string')
|
|
164
|
+
return {};
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(value);
|
|
167
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export function stravaActivityIdFromSourcePayload(value) {
|
|
174
|
+
const stravaId = object(value).strava_id;
|
|
175
|
+
const candidate = typeof stravaId === 'number' ? String(stravaId) : typeof stravaId === 'string' ? stravaId.trim() : '';
|
|
176
|
+
return /^\d+$/.test(candidate) ? candidate : null;
|
|
177
|
+
}
|
|
178
|
+
/** Prefer a persisted Strava source over a generic provider external ID. */
|
|
179
|
+
export function resolveStravaActivityId(activityId, matches) {
|
|
180
|
+
return stravaActivityIdFromExternalId(activityId)
|
|
181
|
+
?? matches.find((match) => match.provider === 'strava')?.remote_activity_id
|
|
182
|
+
?? null;
|
|
183
|
+
}
|
|
184
|
+
export async function multisportChildActivitySourceIds(paths, activityId) {
|
|
185
|
+
const repository = await openReadOnlyRepository(paths);
|
|
186
|
+
try {
|
|
187
|
+
const parent = (await repository.rows(`
|
|
188
|
+
SELECT coalesce(
|
|
189
|
+
try_cast(json_extract_string(raw.payload_json, '$.isMultiSportParent') AS BOOLEAN),
|
|
190
|
+
try_cast(json_extract_string(raw.payload_json, '$.isParent') AS BOOLEAN),
|
|
191
|
+
false
|
|
192
|
+
) AS is_multisport_parent
|
|
193
|
+
FROM activity_sources AS source
|
|
194
|
+
JOIN source_entities AS raw ON raw.provider = source.provider AND raw.entity_type = 'activity' AND raw.remote_id = source.remote_activity_id
|
|
195
|
+
WHERE source.activity_source_id = $activityId AND source.provider = 'garmin'
|
|
196
|
+
`, { activityId }))[0];
|
|
197
|
+
if (!parent?.is_multisport_parent)
|
|
198
|
+
return null;
|
|
199
|
+
const children = await repository.rows(`
|
|
200
|
+
SELECT child_source.activity_source_id
|
|
201
|
+
FROM activity_sources AS parent_source
|
|
202
|
+
JOIN source_entities AS child_raw
|
|
203
|
+
ON child_raw.provider = 'garmin' AND child_raw.entity_type = 'activity' AND child_raw.parent_remote_id = parent_source.remote_activity_id
|
|
204
|
+
JOIN activity_sources AS child_source
|
|
205
|
+
ON child_source.provider = 'garmin' AND child_source.remote_activity_id = child_raw.remote_id
|
|
206
|
+
JOIN activities AS child_activity ON child_activity.activity_id = child_source.activity_id
|
|
207
|
+
WHERE parent_source.activity_source_id = $activityId
|
|
208
|
+
AND parent_source.provider = 'garmin'
|
|
209
|
+
AND lower(coalesce(child_activity.sport, '')) NOT LIKE '%transition%'
|
|
210
|
+
ORDER BY child_activity.started_at_utc, child_source.activity_source_id
|
|
211
|
+
`, { activityId });
|
|
212
|
+
return children.map((child) => child.activity_source_id);
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
await repository.close();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
export async function hydrateStravaActivity(paths, activityId, refresh = false) {
|
|
219
|
+
const multisportChildren = await multisportChildActivitySourceIds(paths, activityId);
|
|
220
|
+
if (multisportChildren) {
|
|
221
|
+
if (!multisportChildren.length) {
|
|
222
|
+
throw new StravaEnrichmentError('This Garmin multisport parent has no staged child activities. Refresh the Garmin activity before hydrating Strava.');
|
|
223
|
+
}
|
|
224
|
+
const childOutcomes = [];
|
|
225
|
+
for (const childActivityId of multisportChildren) {
|
|
226
|
+
const result = await hydrateStravaActivity(paths, childActivityId, refresh);
|
|
227
|
+
const status = typeof result.status === 'string' ? result.status : 'completed';
|
|
228
|
+
childOutcomes.push({ activityId: childActivityId, status, result });
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
status: childOutcomes.every((outcome) => outcome.status === 'completed') ? 'completed' : 'partial',
|
|
232
|
+
hydrationStrategy: 'garmin_multisport_children',
|
|
233
|
+
childOutcomes,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return withDataWriteLock(paths, async () => {
|
|
237
|
+
await ensurePaths(paths);
|
|
238
|
+
const database = await CatenceDatabase.open(paths);
|
|
239
|
+
try {
|
|
240
|
+
const matches = await database.rows(`
|
|
241
|
+
SELECT source.activity_source_id, source.activity_id, source.provider, source.remote_activity_id, source.external_id, raw.payload_json AS source_payload, activity.started_at_utc, activity.sport, summary.distance_m, summary.elapsed_s
|
|
242
|
+
FROM activity_sources source JOIN activities activity USING (activity_id)
|
|
243
|
+
LEFT JOIN activity_summaries summary USING (activity_source_id)
|
|
244
|
+
LEFT JOIN source_entities raw ON raw.provider = source.provider AND raw.entity_type = 'activity' AND raw.remote_id = source.remote_activity_id
|
|
245
|
+
LEFT JOIN activity_links link USING (activity_source_id)
|
|
246
|
+
WHERE source.activity_source_id = $activityId
|
|
247
|
+
OR source.activity_id = (SELECT activity_id FROM activity_sources WHERE activity_source_id = $activityId)
|
|
248
|
+
OR link.activity_id = (SELECT activity_id FROM activity_sources WHERE activity_source_id = $activityId)
|
|
249
|
+
ORDER BY CASE source.provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END
|
|
250
|
+
`, { activityId });
|
|
251
|
+
const activity = matches[0];
|
|
252
|
+
if (!activity)
|
|
253
|
+
throw new StravaEnrichmentError(`No Catence activity or activity source named ${activityId} exists.`);
|
|
254
|
+
const requestedSource = matches.find((match) => match.activity_source_id === activityId);
|
|
255
|
+
const sourceStravaActivityId = requestedSource ? stravaActivityIdFromSourcePayload(requestedSource.source_payload) : null;
|
|
256
|
+
const directStravaActivityId = sourceStravaActivityId ?? resolveStravaActivityId(activityId, matches);
|
|
257
|
+
const matchMethod = sourceStravaActivityId ? 'source_strava_id' : directStravaActivityId ? 'linked_strava_source' : null;
|
|
258
|
+
const resource = { type: 'activity', id: activity.activity_source_id };
|
|
259
|
+
if (!refresh) {
|
|
260
|
+
const cached = await cachedState(database, resource.type, resource.id);
|
|
261
|
+
if (cached)
|
|
262
|
+
return cached;
|
|
263
|
+
}
|
|
264
|
+
if (!directStravaActivityId && (!activity.started_at_utc || !activity.sport || activity.distance_m === null || activity.elapsed_s === null)) {
|
|
265
|
+
throw new StravaEnrichmentError('This activity lacks the timestamp, sport, distance, or elapsed duration required for safe Strava matching.');
|
|
266
|
+
}
|
|
267
|
+
await checkBudget(database, paths);
|
|
268
|
+
const runId = await database.beginRun('strava', new Date().toISOString().slice(0, 10));
|
|
269
|
+
const directory = path.join(paths.staging, 'strava');
|
|
270
|
+
const stagingPath = path.join(directory, `${runId}.jsonl`);
|
|
271
|
+
const resultPath = path.join(directory, `${runId}.result.json`);
|
|
272
|
+
let result;
|
|
273
|
+
try {
|
|
274
|
+
const matchingArguments = [
|
|
275
|
+
...(directStravaActivityId ? ['--strava-activity-id', directStravaActivityId] : []),
|
|
276
|
+
...(matchMethod ? ['--strava-match-method', matchMethod] : []),
|
|
277
|
+
...(activity.started_at_utc && activity.sport && activity.distance_m !== null && activity.elapsed_s !== null
|
|
278
|
+
? ['--started-at', new Date(activity.started_at_utc).toISOString(), '--sport', activity.sport, '--distance-m', String(activity.distance_m), '--elapsed-s', String(activity.elapsed_s)]
|
|
279
|
+
: []),
|
|
280
|
+
];
|
|
281
|
+
result = await invokeWorker(paths, [
|
|
282
|
+
'--mode', 'activity', '--run-id', runId, '--output', stagingPath,
|
|
283
|
+
'--activity-id', activity.activity_source_id, ...matchingArguments,
|
|
284
|
+
...(refresh ? ['--refresh'] : []),
|
|
285
|
+
], resultPath);
|
|
286
|
+
if (existsSync(stagingPath))
|
|
287
|
+
await importJsonl(database, runId, stagingPath);
|
|
288
|
+
await storeRateState(database, result);
|
|
289
|
+
await writeState(database, resource.type, resource.id, result);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
await database.addError(runId, 'strava', 'activity', activity.activity_source_id, error instanceof Error ? error.message : String(error), true);
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
await database.finishRun(runId);
|
|
297
|
+
}
|
|
298
|
+
if (result.status === 'error')
|
|
299
|
+
throw new StravaEnrichmentError(result.message ?? 'Strava activity hydration failed.');
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
await database.close();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
export async function hydrateStravaSegmentHistory(paths, segmentId, refresh = false) {
|
|
308
|
+
return withDataWriteLock(paths, async () => {
|
|
309
|
+
await ensurePaths(paths);
|
|
310
|
+
const database = await CatenceDatabase.open(paths);
|
|
311
|
+
try {
|
|
312
|
+
const found = await database.rows('SELECT segment_id FROM strava_segments WHERE segment_id = $segmentId', { segmentId });
|
|
313
|
+
if (!found[0])
|
|
314
|
+
throw new StravaEnrichmentError(`Strava segment ${segmentId} is not persisted. Hydrate an activity containing it first.`);
|
|
315
|
+
const resource = { type: 'segment_history', id: segmentId };
|
|
316
|
+
if (!refresh) {
|
|
317
|
+
const cached = await cachedState(database, resource.type, resource.id);
|
|
318
|
+
if (cached)
|
|
319
|
+
return cached;
|
|
320
|
+
}
|
|
321
|
+
const continuation = (await database.rows('SELECT continuation_page FROM strava_enrichment_state WHERE resource_type = $type AND remote_id = $segmentId', { type: resource.type, segmentId }))[0]?.continuation_page;
|
|
322
|
+
await checkBudget(database, paths);
|
|
323
|
+
const runId = await database.beginRun('strava', new Date().toISOString().slice(0, 10));
|
|
324
|
+
const directory = path.join(paths.staging, 'strava');
|
|
325
|
+
const stagingPath = path.join(directory, `${runId}.jsonl`);
|
|
326
|
+
const resultPath = path.join(directory, `${runId}.result.json`);
|
|
327
|
+
let result;
|
|
328
|
+
try {
|
|
329
|
+
result = await invokeWorker(paths, ['--mode', 'segment-history', '--run-id', runId, '--output', stagingPath, '--segment-id', segmentId, '--start-page', String(continuation ?? 1), ...(refresh ? ['--refresh'] : [])], resultPath);
|
|
330
|
+
if (existsSync(stagingPath))
|
|
331
|
+
await importJsonl(database, runId, stagingPath);
|
|
332
|
+
await storeRateState(database, result);
|
|
333
|
+
await writeState(database, resource.type, resource.id, result);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
await database.addError(runId, 'strava', 'segment-history', segmentId, error instanceof Error ? error.message : String(error), true);
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
await database.finishRun(runId);
|
|
341
|
+
}
|
|
342
|
+
if (result.status === 'error')
|
|
343
|
+
throw new StravaEnrichmentError(result.message ?? 'Strava segment history hydration failed.');
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
await database.close();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
export async function getStravaAuthorizationUrl(paths, redirectUri) {
|
|
352
|
+
await ensurePaths(paths);
|
|
353
|
+
const resultPath = path.join(paths.staging, 'strava', `authorization-${Date.now()}.json`);
|
|
354
|
+
return invokeWorker(paths, ['--mode', 'authorization-url', '--redirect-uri', redirectUri], resultPath);
|
|
355
|
+
}
|
|
356
|
+
export async function completeStravaAuthorization(paths, code, redirectUri) {
|
|
357
|
+
await ensurePaths(paths);
|
|
358
|
+
const resultPath = path.join(paths.staging, 'strava', `authorization-${Date.now()}.json`);
|
|
359
|
+
return invokeWorker(paths, ['--mode', 'auth', '--code', code, '--redirect-uri', redirectUri], resultPath);
|
|
360
|
+
}
|
|
361
|
+
export async function disconnectStrava(paths) {
|
|
362
|
+
await withDataWriteLock(paths, async () => {
|
|
363
|
+
await rm(path.join(paths.secrets, 'strava.json'), { force: true });
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
//# sourceMappingURL=service.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CatenceDatabase } from '../../storage/database.js';
|
|
2
|
+
/** Attach a source to a logical activity only when its link is provable or uniquely high-confidence. */
|
|
3
|
+
export declare function reconcileActivityLink(database: CatenceDatabase, activitySourceId: string): Promise<void>;
|
|
4
|
+
export declare function setManualActivityLink(database: CatenceDatabase, activitySourceId: string, activityId: string): Promise<void>;
|
|
5
|
+
export declare function unlinkActivitySource(database: CatenceDatabase, activitySourceId: string): Promise<void>;
|