catence 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +373 -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/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/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 +283 -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 +365 -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 +101 -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 +14 -0
- package/dist/interfaces/http/server.js +169 -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 +59 -0
- package/dist/interfaces/mcp/server.d.ts +8 -0
- package/dist/interfaces/mcp/server.js +521 -0
- package/dist/runtime/index.d.ts +29 -0
- package/dist/runtime/index.js +19 -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,365 @@
|
|
|
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 { CatenceDatabase, openReadOnlyRepository } from '../../../storage/database.js';
|
|
9
|
+
import { importJsonl } from '../../importer.js';
|
|
10
|
+
import { withDataWriteLock } from '../../../storage/write-lock.js';
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
export class StravaRateLimitError extends Error {
|
|
13
|
+
retryAfterSeconds;
|
|
14
|
+
constructor(message, retryAfterSeconds) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
17
|
+
this.name = 'StravaRateLimitError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class StravaEnrichmentError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'StravaEnrichmentError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function packageRoot() {
|
|
27
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..');
|
|
28
|
+
}
|
|
29
|
+
function toFiniteInteger(value) {
|
|
30
|
+
if (!value)
|
|
31
|
+
return null;
|
|
32
|
+
const parsed = Number(value);
|
|
33
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
34
|
+
}
|
|
35
|
+
function parsePair(value) {
|
|
36
|
+
if (!value)
|
|
37
|
+
return [null, null];
|
|
38
|
+
const [first, second] = value.split(',').map((part) => toFiniteInteger(part.trim()));
|
|
39
|
+
return [first ?? null, second ?? null];
|
|
40
|
+
}
|
|
41
|
+
async function readWorkerResult(filePath) {
|
|
42
|
+
if (!existsSync(filePath))
|
|
43
|
+
throw new StravaEnrichmentError('Strava worker did not write its result manifest.');
|
|
44
|
+
const parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
|
45
|
+
if (!parsed || typeof parsed.status !== 'string')
|
|
46
|
+
throw new StravaEnrichmentError('Strava worker wrote an invalid result manifest.');
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
async function invokeWorker(paths, args, resultPath) {
|
|
50
|
+
try {
|
|
51
|
+
await execFileAsync('uv', ['run', 'python', '-m', 'python.catence.providers.strava.cli', ...args, '--data-dir', paths.root, '--result', resultPath], {
|
|
52
|
+
cwd: packageRoot(),
|
|
53
|
+
env: { ...process.env, UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (!existsSync(resultPath)) {
|
|
58
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
59
|
+
throw new StravaEnrichmentError(`Strava worker failed before it could report a result: ${message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return readWorkerResult(resultPath);
|
|
63
|
+
}
|
|
64
|
+
async function storeRateState(database, result) {
|
|
65
|
+
const headers = result.rateHeaders ?? {};
|
|
66
|
+
const [limit15, limitDay] = parsePair(headers['x-ratelimit-limit']);
|
|
67
|
+
const [usage15, usageDay] = parsePair(headers['x-ratelimit-usage']);
|
|
68
|
+
const isThrottled = result.status === 'partial' && /rate limit|429/i.test(result.message ?? '');
|
|
69
|
+
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)
|
|
70
|
+
VALUES ('authenticated_athlete', $limit15, $usage15, $limitDay, $usageDay, CASE WHEN $throttled THEN now() + INTERVAL 15 MINUTE ELSE NULL END, now())
|
|
71
|
+
ON CONFLICT (account_id) DO UPDATE SET
|
|
72
|
+
read_limit_15m = COALESCE(excluded.read_limit_15m, strava_rate_state.read_limit_15m),
|
|
73
|
+
read_usage_15m = COALESCE(excluded.read_usage_15m, strava_rate_state.read_usage_15m),
|
|
74
|
+
read_limit_day = COALESCE(excluded.read_limit_day, strava_rate_state.read_limit_day),
|
|
75
|
+
read_usage_day = COALESCE(excluded.read_usage_day, strava_rate_state.read_usage_day),
|
|
76
|
+
blocked_until = CASE WHEN excluded.blocked_until IS NOT NULL THEN excluded.blocked_until ELSE strava_rate_state.blocked_until END,
|
|
77
|
+
observed_at = now()`, { limit15, usage15, limitDay, usageDay, throttled: isThrottled });
|
|
78
|
+
}
|
|
79
|
+
async function checkBudget(database, paths) {
|
|
80
|
+
const config = await loadCatenceConfig(paths);
|
|
81
|
+
const budget = config.providers?.strava?.budget;
|
|
82
|
+
const rate = (await database.rows(`SELECT blocked_until, read_usage_15m, read_usage_day FROM strava_rate_state WHERE account_id = 'authenticated_athlete'`))[0];
|
|
83
|
+
const blockedUntil = rate?.blocked_until ? new Date(rate.blocked_until).getTime() : 0;
|
|
84
|
+
if (blockedUntil > Date.now())
|
|
85
|
+
throw new StravaRateLimitError('Strava has recently throttled Catence; retry after its remote rate-limit window.', Math.ceil((blockedUntil - Date.now()) / 1_000));
|
|
86
|
+
if (budget?.readRequestsPer15Minutes && (rate?.read_usage_15m ?? 0) >= budget.readRequestsPer15Minutes)
|
|
87
|
+
throw new StravaRateLimitError('Catence local Strava 15-minute request budget has been reached.');
|
|
88
|
+
if (budget?.readRequestsPerDay && (rate?.read_usage_day ?? 0) >= budget.readRequestsPerDay)
|
|
89
|
+
throw new StravaRateLimitError('Catence local Strava daily request budget has been reached.');
|
|
90
|
+
}
|
|
91
|
+
async function writeState(database, resourceType, remoteId, result) {
|
|
92
|
+
const status = result.status === 'completed' ? 'completed' : result.status;
|
|
93
|
+
await database.run(`INSERT INTO strava_enrichment_state (resource_type, remote_id, status, fetched_at, completed_at, continuation_page, detail_json)
|
|
94
|
+
VALUES ($resourceType, $remoteId, $status, now(), CASE WHEN $status = 'completed' THEN now() ELSE NULL END, $continuationPage, $detail)
|
|
95
|
+
ON CONFLICT (resource_type, remote_id) DO UPDATE SET
|
|
96
|
+
status = excluded.status, fetched_at = now(), completed_at = CASE WHEN excluded.status = 'completed' THEN now() ELSE strava_enrichment_state.completed_at END,
|
|
97
|
+
continuation_page = excluded.continuation_page, detail_json = excluded.detail_json`, { resourceType, remoteId, status, continuationPage: result.continuationPage ?? null, detail: JSON.stringify(result) });
|
|
98
|
+
}
|
|
99
|
+
async function cachedState(database, resourceType, remoteId) {
|
|
100
|
+
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 });
|
|
101
|
+
const row = state[0];
|
|
102
|
+
if (!row || row.status !== 'completed')
|
|
103
|
+
return null;
|
|
104
|
+
const detail = typeof row.detail_json === 'string' ? JSON.parse(row.detail_json) : row.detail_json;
|
|
105
|
+
return { ...detail, cache: { reused: true, fetchedAt: row.fetched_at, completedAt: row.completed_at } };
|
|
106
|
+
}
|
|
107
|
+
async function runReadOperation(paths, operation, extraArgs, resource, refresh = false) {
|
|
108
|
+
return withDataWriteLock(paths, async () => {
|
|
109
|
+
await ensurePaths(paths);
|
|
110
|
+
const database = await CatenceDatabase.open(paths);
|
|
111
|
+
try {
|
|
112
|
+
if (!refresh) {
|
|
113
|
+
const cached = await cachedState(database, resource.type, resource.id);
|
|
114
|
+
if (cached)
|
|
115
|
+
return cached;
|
|
116
|
+
}
|
|
117
|
+
await checkBudget(database, paths);
|
|
118
|
+
const runId = await database.beginRun('strava', new Date().toISOString().slice(0, 10));
|
|
119
|
+
const directory = path.join(paths.staging, 'strava');
|
|
120
|
+
const stagingPath = path.join(directory, `${runId}.jsonl`);
|
|
121
|
+
const resultPath = path.join(directory, `${runId}.result.json`);
|
|
122
|
+
let result;
|
|
123
|
+
try {
|
|
124
|
+
result = await invokeWorker(paths, ['--mode', operation, '--run-id', runId, '--output', stagingPath, ...extraArgs], resultPath);
|
|
125
|
+
if (existsSync(stagingPath))
|
|
126
|
+
await importJsonl(database, runId, stagingPath);
|
|
127
|
+
await storeRateState(database, result);
|
|
128
|
+
await writeState(database, resource.type, resource.id, result);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
132
|
+
await database.addError(runId, 'strava', operation, resource.id, message, true);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await database.finishRun(runId);
|
|
137
|
+
}
|
|
138
|
+
if (result.status === 'error')
|
|
139
|
+
throw new StravaEnrichmentError(result.message ?? 'Strava did not complete the requested enrichment.');
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await database.close();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
export async function syncStravaGear(paths, refresh = true) {
|
|
148
|
+
return runReadOperation(paths, 'gear', [], { type: 'gear', id: 'authenticated_athlete' }, refresh);
|
|
149
|
+
}
|
|
150
|
+
/** Returns a Strava activity ID only for formats that identify one directly. */
|
|
151
|
+
export function stravaActivityIdFromExternalId(value) {
|
|
152
|
+
const externalId = value?.trim();
|
|
153
|
+
if (!externalId)
|
|
154
|
+
return null;
|
|
155
|
+
const match = externalId.match(/^strava:(\d+)$/i)
|
|
156
|
+
?? externalId.match(/^https?:\/\/(?:www\.)?strava\.com\/activities\/(\d+)\/?$/i);
|
|
157
|
+
return match?.[1] ?? null;
|
|
158
|
+
}
|
|
159
|
+
function object(value) {
|
|
160
|
+
if (value && typeof value === 'object')
|
|
161
|
+
return value;
|
|
162
|
+
if (typeof value !== 'string')
|
|
163
|
+
return {};
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(value);
|
|
166
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return {};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
export function stravaActivityIdFromSourcePayload(value) {
|
|
173
|
+
const stravaId = object(value).strava_id;
|
|
174
|
+
const candidate = typeof stravaId === 'number' ? String(stravaId) : typeof stravaId === 'string' ? stravaId.trim() : '';
|
|
175
|
+
return /^\d+$/.test(candidate) ? candidate : null;
|
|
176
|
+
}
|
|
177
|
+
/** Prefer a persisted Strava source over a generic provider external ID. */
|
|
178
|
+
export function resolveStravaActivityId(activityId, matches) {
|
|
179
|
+
return stravaActivityIdFromExternalId(activityId)
|
|
180
|
+
?? matches.find((match) => match.provider === 'strava')?.remote_activity_id
|
|
181
|
+
?? null;
|
|
182
|
+
}
|
|
183
|
+
export async function multisportChildActivitySourceIds(paths, activityId) {
|
|
184
|
+
const repository = await openReadOnlyRepository(paths);
|
|
185
|
+
try {
|
|
186
|
+
const parent = (await repository.rows(`
|
|
187
|
+
SELECT coalesce(
|
|
188
|
+
try_cast(json_extract_string(raw.payload_json, '$.isMultiSportParent') AS BOOLEAN),
|
|
189
|
+
try_cast(json_extract_string(raw.payload_json, '$.isParent') AS BOOLEAN),
|
|
190
|
+
false
|
|
191
|
+
) AS is_multisport_parent
|
|
192
|
+
FROM activity_sources AS source
|
|
193
|
+
JOIN source_entities AS raw ON raw.provider = source.provider AND raw.entity_type = 'activity' AND raw.remote_id = source.remote_activity_id
|
|
194
|
+
WHERE source.activity_source_id = $activityId AND source.provider = 'garmin'
|
|
195
|
+
`, { activityId }))[0];
|
|
196
|
+
if (!parent?.is_multisport_parent)
|
|
197
|
+
return null;
|
|
198
|
+
const children = await repository.rows(`
|
|
199
|
+
SELECT child_source.activity_source_id
|
|
200
|
+
FROM activity_sources AS parent_source
|
|
201
|
+
JOIN source_entities AS child_raw
|
|
202
|
+
ON child_raw.provider = 'garmin' AND child_raw.entity_type = 'activity' AND child_raw.parent_remote_id = parent_source.remote_activity_id
|
|
203
|
+
JOIN activity_sources AS child_source
|
|
204
|
+
ON child_source.provider = 'garmin' AND child_source.remote_activity_id = child_raw.remote_id
|
|
205
|
+
JOIN activities AS child_activity ON child_activity.activity_id = child_source.activity_id
|
|
206
|
+
WHERE parent_source.activity_source_id = $activityId
|
|
207
|
+
AND parent_source.provider = 'garmin'
|
|
208
|
+
AND lower(coalesce(child_activity.sport, '')) NOT LIKE '%transition%'
|
|
209
|
+
ORDER BY child_activity.started_at_utc, child_source.activity_source_id
|
|
210
|
+
`, { activityId });
|
|
211
|
+
return children.map((child) => child.activity_source_id);
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
await repository.close();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export async function hydrateStravaActivity(paths, activityId, refresh = false) {
|
|
218
|
+
const multisportChildren = await multisportChildActivitySourceIds(paths, activityId);
|
|
219
|
+
if (multisportChildren) {
|
|
220
|
+
if (!multisportChildren.length) {
|
|
221
|
+
throw new StravaEnrichmentError('This Garmin multisport parent has no staged child activities. Refresh the Garmin activity before hydrating Strava.');
|
|
222
|
+
}
|
|
223
|
+
const childOutcomes = [];
|
|
224
|
+
for (const childActivityId of multisportChildren) {
|
|
225
|
+
const result = await hydrateStravaActivity(paths, childActivityId, refresh);
|
|
226
|
+
const status = typeof result.status === 'string' ? result.status : 'completed';
|
|
227
|
+
childOutcomes.push({ activityId: childActivityId, status, result });
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
status: childOutcomes.every((outcome) => outcome.status === 'completed') ? 'completed' : 'partial',
|
|
231
|
+
hydrationStrategy: 'garmin_multisport_children',
|
|
232
|
+
childOutcomes,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return withDataWriteLock(paths, async () => {
|
|
236
|
+
await ensurePaths(paths);
|
|
237
|
+
const database = await CatenceDatabase.open(paths);
|
|
238
|
+
try {
|
|
239
|
+
const matches = await database.rows(`
|
|
240
|
+
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
|
|
241
|
+
FROM activity_sources source JOIN activities activity USING (activity_id)
|
|
242
|
+
LEFT JOIN activity_summaries summary USING (activity_source_id)
|
|
243
|
+
LEFT JOIN source_entities raw ON raw.provider = source.provider AND raw.entity_type = 'activity' AND raw.remote_id = source.remote_activity_id
|
|
244
|
+
LEFT JOIN activity_links link USING (activity_source_id)
|
|
245
|
+
WHERE source.activity_source_id = $activityId
|
|
246
|
+
OR source.activity_id = (SELECT activity_id FROM activity_sources WHERE activity_source_id = $activityId)
|
|
247
|
+
OR link.activity_id = (SELECT activity_id FROM activity_sources WHERE activity_source_id = $activityId)
|
|
248
|
+
ORDER BY CASE source.provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END
|
|
249
|
+
`, { activityId });
|
|
250
|
+
const activity = matches[0];
|
|
251
|
+
if (!activity)
|
|
252
|
+
throw new StravaEnrichmentError(`No Catence activity or activity source named ${activityId} exists.`);
|
|
253
|
+
const requestedSource = matches.find((match) => match.activity_source_id === activityId);
|
|
254
|
+
const sourceStravaActivityId = requestedSource ? stravaActivityIdFromSourcePayload(requestedSource.source_payload) : null;
|
|
255
|
+
const directStravaActivityId = sourceStravaActivityId ?? resolveStravaActivityId(activityId, matches);
|
|
256
|
+
const matchMethod = sourceStravaActivityId ? 'source_strava_id' : directStravaActivityId ? 'linked_strava_source' : null;
|
|
257
|
+
const resource = { type: 'activity', id: activity.activity_source_id };
|
|
258
|
+
if (!refresh) {
|
|
259
|
+
const cached = await cachedState(database, resource.type, resource.id);
|
|
260
|
+
if (cached)
|
|
261
|
+
return cached;
|
|
262
|
+
}
|
|
263
|
+
if (!directStravaActivityId && (!activity.started_at_utc || !activity.sport || activity.distance_m === null || activity.elapsed_s === null)) {
|
|
264
|
+
throw new StravaEnrichmentError('This activity lacks the timestamp, sport, distance, or elapsed duration required for safe Strava matching.');
|
|
265
|
+
}
|
|
266
|
+
await checkBudget(database, paths);
|
|
267
|
+
const runId = await database.beginRun('strava', new Date().toISOString().slice(0, 10));
|
|
268
|
+
const directory = path.join(paths.staging, 'strava');
|
|
269
|
+
const stagingPath = path.join(directory, `${runId}.jsonl`);
|
|
270
|
+
const resultPath = path.join(directory, `${runId}.result.json`);
|
|
271
|
+
let result;
|
|
272
|
+
try {
|
|
273
|
+
const matchingArguments = [
|
|
274
|
+
...(directStravaActivityId ? ['--strava-activity-id', directStravaActivityId] : []),
|
|
275
|
+
...(matchMethod ? ['--strava-match-method', matchMethod] : []),
|
|
276
|
+
...(activity.started_at_utc && activity.sport && activity.distance_m !== null && activity.elapsed_s !== null
|
|
277
|
+
? ['--started-at', new Date(activity.started_at_utc).toISOString(), '--sport', activity.sport, '--distance-m', String(activity.distance_m), '--elapsed-s', String(activity.elapsed_s)]
|
|
278
|
+
: []),
|
|
279
|
+
];
|
|
280
|
+
result = await invokeWorker(paths, [
|
|
281
|
+
'--mode', 'activity', '--run-id', runId, '--output', stagingPath,
|
|
282
|
+
'--activity-id', activity.activity_source_id, ...matchingArguments,
|
|
283
|
+
...(refresh ? ['--refresh'] : []),
|
|
284
|
+
], resultPath);
|
|
285
|
+
if (existsSync(stagingPath))
|
|
286
|
+
await importJsonl(database, runId, stagingPath);
|
|
287
|
+
await storeRateState(database, result);
|
|
288
|
+
await writeState(database, resource.type, resource.id, result);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
await database.addError(runId, 'strava', 'activity', activity.activity_source_id, error instanceof Error ? error.message : String(error), true);
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
await database.finishRun(runId);
|
|
296
|
+
}
|
|
297
|
+
if (result.status === 'error')
|
|
298
|
+
throw new StravaEnrichmentError(result.message ?? 'Strava activity hydration failed.');
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
await database.close();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
export async function hydrateStravaSegmentHistory(paths, segmentId, refresh = false) {
|
|
307
|
+
return withDataWriteLock(paths, async () => {
|
|
308
|
+
await ensurePaths(paths);
|
|
309
|
+
const database = await CatenceDatabase.open(paths);
|
|
310
|
+
try {
|
|
311
|
+
const found = await database.rows('SELECT segment_id FROM strava_segments WHERE segment_id = $segmentId', { segmentId });
|
|
312
|
+
if (!found[0])
|
|
313
|
+
throw new StravaEnrichmentError(`Strava segment ${segmentId} is not persisted. Hydrate an activity containing it first.`);
|
|
314
|
+
const resource = { type: 'segment_history', id: segmentId };
|
|
315
|
+
if (!refresh) {
|
|
316
|
+
const cached = await cachedState(database, resource.type, resource.id);
|
|
317
|
+
if (cached)
|
|
318
|
+
return cached;
|
|
319
|
+
}
|
|
320
|
+
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;
|
|
321
|
+
await checkBudget(database, paths);
|
|
322
|
+
const runId = await database.beginRun('strava', new Date().toISOString().slice(0, 10));
|
|
323
|
+
const directory = path.join(paths.staging, 'strava');
|
|
324
|
+
const stagingPath = path.join(directory, `${runId}.jsonl`);
|
|
325
|
+
const resultPath = path.join(directory, `${runId}.result.json`);
|
|
326
|
+
let result;
|
|
327
|
+
try {
|
|
328
|
+
result = await invokeWorker(paths, ['--mode', 'segment-history', '--run-id', runId, '--output', stagingPath, '--segment-id', segmentId, '--start-page', String(continuation ?? 1), ...(refresh ? ['--refresh'] : [])], resultPath);
|
|
329
|
+
if (existsSync(stagingPath))
|
|
330
|
+
await importJsonl(database, runId, stagingPath);
|
|
331
|
+
await storeRateState(database, result);
|
|
332
|
+
await writeState(database, resource.type, resource.id, result);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
await database.addError(runId, 'strava', 'segment-history', segmentId, error instanceof Error ? error.message : String(error), true);
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
finally {
|
|
339
|
+
await database.finishRun(runId);
|
|
340
|
+
}
|
|
341
|
+
if (result.status === 'error')
|
|
342
|
+
throw new StravaEnrichmentError(result.message ?? 'Strava segment history hydration failed.');
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
await database.close();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
export async function getStravaAuthorizationUrl(paths, redirectUri) {
|
|
351
|
+
await ensurePaths(paths);
|
|
352
|
+
const resultPath = path.join(paths.staging, 'strava', `authorization-${Date.now()}.json`);
|
|
353
|
+
return invokeWorker(paths, ['--mode', 'authorization-url', '--redirect-uri', redirectUri], resultPath);
|
|
354
|
+
}
|
|
355
|
+
export async function completeStravaAuthorization(paths, code, redirectUri) {
|
|
356
|
+
await ensurePaths(paths);
|
|
357
|
+
const resultPath = path.join(paths.staging, 'strava', `authorization-${Date.now()}.json`);
|
|
358
|
+
return invokeWorker(paths, ['--mode', 'auth', '--code', code, '--redirect-uri', redirectUri], resultPath);
|
|
359
|
+
}
|
|
360
|
+
export async function disconnectStrava(paths) {
|
|
361
|
+
await withDataWriteLock(paths, async () => {
|
|
362
|
+
await rm(path.join(paths.secrets, 'strava.json'), { force: true });
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
//# 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>;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { json } from '../../storage/sql.js';
|
|
2
|
+
function sportFamily(value) {
|
|
3
|
+
if (!value)
|
|
4
|
+
return null;
|
|
5
|
+
const normalized = value.toLowerCase().replaceAll(/[^a-z]/g, '');
|
|
6
|
+
if (normalized.includes('ride') || normalized.includes('cycling') || normalized.includes('bik'))
|
|
7
|
+
return 'ride';
|
|
8
|
+
if (normalized.includes('run'))
|
|
9
|
+
return 'run';
|
|
10
|
+
if (normalized.includes('swim'))
|
|
11
|
+
return 'swim';
|
|
12
|
+
return normalized || null;
|
|
13
|
+
}
|
|
14
|
+
function isVirtualOrIndoor(value) {
|
|
15
|
+
return Boolean(value && /virtual|indoor/i.test(value));
|
|
16
|
+
}
|
|
17
|
+
function timestamp(value) {
|
|
18
|
+
if (!value)
|
|
19
|
+
return null;
|
|
20
|
+
const parsed = Date.parse(value instanceof Date ? value.toISOString() : value);
|
|
21
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
22
|
+
}
|
|
23
|
+
function qualifies(source, candidate) {
|
|
24
|
+
if (source.provider === candidate.provider || !source.started_at_utc || !candidate.started_at_utc)
|
|
25
|
+
return null;
|
|
26
|
+
if (!source.distance_m || !candidate.distance_m || !source.moving_s || !candidate.moving_s)
|
|
27
|
+
return null;
|
|
28
|
+
if (sportFamily(source.sport) !== sportFamily(candidate.sport))
|
|
29
|
+
return null;
|
|
30
|
+
if (isVirtualOrIndoor(source.sport) !== isVirtualOrIndoor(candidate.sport))
|
|
31
|
+
return null;
|
|
32
|
+
const startDelta = Math.abs(timestamp(source.started_at_utc) - timestamp(candidate.started_at_utc)) / 1_000;
|
|
33
|
+
const durationDelta = Math.abs(source.moving_s - candidate.moving_s);
|
|
34
|
+
const distanceDelta = Math.abs(source.distance_m - candidate.distance_m);
|
|
35
|
+
const maxDuration = Math.max(120, source.moving_s * 0.05);
|
|
36
|
+
const maxDistance = Math.max(200, source.distance_m * 0.025);
|
|
37
|
+
if (startDelta > 90 || durationDelta > maxDuration || distanceDelta > maxDistance)
|
|
38
|
+
return null;
|
|
39
|
+
const score = 1 - ((startDelta / 90) + (durationDelta / maxDuration) + (distanceDelta / maxDistance)) / 3;
|
|
40
|
+
return { score, evidence: { sportFamily: sportFamily(source.sport), startDeltaSeconds: startDelta, durationDeltaSeconds: durationDelta, distanceDeltaMeters: distanceDelta, thresholds: { startSeconds: 90, durationSeconds: maxDuration, distanceMeters: maxDistance } } };
|
|
41
|
+
}
|
|
42
|
+
/** Attach a source to a logical activity only when its link is provable or uniquely high-confidence. */
|
|
43
|
+
export async function reconcileActivityLink(database, activitySourceId) {
|
|
44
|
+
const sources = await database.rows(`
|
|
45
|
+
SELECT source.activity_source_id, source.activity_id, source.provider, source.remote_activity_id, source.external_id,
|
|
46
|
+
activity.started_at_utc, activity.sport, summary.distance_m, summary.moving_s
|
|
47
|
+
FROM activity_sources source
|
|
48
|
+
JOIN activities activity USING (activity_id)
|
|
49
|
+
LEFT JOIN activity_summaries summary USING (activity_source_id)
|
|
50
|
+
WHERE source.activity_source_id = $activitySourceId
|
|
51
|
+
`, { activitySourceId });
|
|
52
|
+
const source = sources[0];
|
|
53
|
+
if (!source)
|
|
54
|
+
return;
|
|
55
|
+
const current = await database.rows('SELECT method, activity_id FROM activity_links WHERE activity_source_id = $activitySourceId', { activitySourceId });
|
|
56
|
+
if (current[0]?.method === 'manual' || current[0]?.method === 'fuzzy_high_confidence') {
|
|
57
|
+
if (source.activity_id !== current[0].activity_id) {
|
|
58
|
+
await database.run('UPDATE activity_sources SET activity_id = $activityId WHERE activity_source_id = $activitySourceId', { activityId: current[0].activity_id, activitySourceId });
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const externalCandidates = source.provider === 'intervals' && source.external_id
|
|
63
|
+
? await database.rows(`
|
|
64
|
+
SELECT source.activity_source_id, source.activity_id, source.provider, source.remote_activity_id, source.external_id,
|
|
65
|
+
activity.started_at_utc, activity.sport, summary.distance_m, summary.moving_s
|
|
66
|
+
FROM activity_sources source
|
|
67
|
+
JOIN activities activity USING (activity_id)
|
|
68
|
+
LEFT JOIN activity_summaries summary USING (activity_source_id)
|
|
69
|
+
WHERE source.provider = 'garmin' AND source.remote_activity_id = $externalId
|
|
70
|
+
`, { externalId: source.external_id })
|
|
71
|
+
: source.provider === 'garmin'
|
|
72
|
+
? await database.rows(`
|
|
73
|
+
SELECT source.activity_source_id, source.activity_id, source.provider, source.remote_activity_id, source.external_id,
|
|
74
|
+
activity.started_at_utc, activity.sport, summary.distance_m, summary.moving_s
|
|
75
|
+
FROM activity_sources source
|
|
76
|
+
JOIN activities activity USING (activity_id)
|
|
77
|
+
LEFT JOIN activity_summaries summary USING (activity_source_id)
|
|
78
|
+
WHERE source.provider = 'intervals' AND source.external_id = $remoteActivityId
|
|
79
|
+
`, { remoteActivityId: source.remote_activity_id })
|
|
80
|
+
: [];
|
|
81
|
+
const externalCandidate = externalCandidates.length === 1 ? externalCandidates[0] : null;
|
|
82
|
+
if (externalCandidate) {
|
|
83
|
+
const linkMethods = await database.rows(`SELECT activity_source_id, method FROM activity_links WHERE activity_source_id = $sourceId OR activity_source_id = $candidateId`, { sourceId: source.activity_source_id, candidateId: externalCandidate.activity_source_id });
|
|
84
|
+
if (!linkMethods.some((link) => link.method === 'manual')) {
|
|
85
|
+
const garmin = source.provider === 'garmin' ? source : externalCandidate;
|
|
86
|
+
const intervals = source.provider === 'intervals' ? source : externalCandidate;
|
|
87
|
+
const targetActivityId = garmin.activity_id;
|
|
88
|
+
const previousActivityIds = new Set([source.activity_id, externalCandidate.activity_id]);
|
|
89
|
+
for (const linkedSource of [source, externalCandidate]) {
|
|
90
|
+
await database.run('UPDATE activity_sources SET activity_id = $activityId WHERE activity_source_id = $activitySourceId', {
|
|
91
|
+
activityId: targetActivityId, activitySourceId: linkedSource.activity_source_id,
|
|
92
|
+
});
|
|
93
|
+
await database.run(`INSERT INTO activity_links VALUES ($activitySourceId, $activityId, 'strong_external_id', 1.0, $evidence, now())
|
|
94
|
+
ON CONFLICT (activity_source_id) DO UPDATE SET activity_id = excluded.activity_id, method = excluded.method,
|
|
95
|
+
confidence = excluded.confidence, evidence_json = excluded.evidence_json, linked_at = now()`, {
|
|
96
|
+
activitySourceId: linkedSource.activity_source_id, activityId: targetActivityId,
|
|
97
|
+
evidence: json({ matchedActivitySourceId: linkedSource.provider === 'garmin' ? intervals.activity_source_id : garmin.activity_source_id, externalId: intervals.external_id }),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
await database.run(`UPDATE activities SET link_state = 'strong_external_id' WHERE activity_id = $activityId`, { activityId: targetActivityId });
|
|
101
|
+
for (const previousActivityId of previousActivityIds) {
|
|
102
|
+
await database.run('DELETE FROM activities WHERE activity_id = $activityId AND NOT EXISTS (SELECT 1 FROM activity_sources WHERE activity_id = $activityId)', { activityId: previousActivityId });
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (source.external_id) {
|
|
108
|
+
await database.run(`INSERT INTO activity_links VALUES ($activitySourceId, $activityId, 'strong_external_id', 1.0, '{}', now())
|
|
109
|
+
ON CONFLICT (activity_source_id) DO UPDATE SET activity_id = excluded.activity_id, method = excluded.method, confidence = excluded.confidence, evidence_json = excluded.evidence_json, linked_at = now()`, { activitySourceId, activityId: source.activity_id });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const candidates = await database.rows(`
|
|
113
|
+
SELECT source.activity_source_id, source.activity_id, source.provider, source.remote_activity_id, source.external_id,
|
|
114
|
+
activity.started_at_utc, activity.sport, summary.distance_m, summary.moving_s
|
|
115
|
+
FROM activity_sources source
|
|
116
|
+
JOIN activities activity USING (activity_id)
|
|
117
|
+
LEFT JOIN activity_summaries summary USING (activity_source_id)
|
|
118
|
+
WHERE source.activity_source_id <> $activitySourceId
|
|
119
|
+
AND source.provider <> $provider
|
|
120
|
+
AND activity.started_at_utc BETWEEN $startFloor AND $startCeiling
|
|
121
|
+
`, {
|
|
122
|
+
activitySourceId, provider: source.provider,
|
|
123
|
+
startFloor: new Date(timestamp(source.started_at_utc) - 90_000).toISOString(),
|
|
124
|
+
startCeiling: new Date(timestamp(source.started_at_utc) + 90_000).toISOString(),
|
|
125
|
+
});
|
|
126
|
+
const matched = candidates.map((candidate) => ({ candidate, result: qualifies(source, candidate) })).filter((item) => item.result !== null);
|
|
127
|
+
// A provider may already be strongly linked alongside a second source (for
|
|
128
|
+
// example Garmin plus Intervals). Those are one logical candidate, not an
|
|
129
|
+
// ambiguity that should prevent a matching Strava source from joining it.
|
|
130
|
+
const logicalMatches = new Map();
|
|
131
|
+
for (const match of matched) {
|
|
132
|
+
const previous = logicalMatches.get(match.candidate.activity_id);
|
|
133
|
+
if (!previous || match.result.score > previous.result.score)
|
|
134
|
+
logicalMatches.set(match.candidate.activity_id, match);
|
|
135
|
+
}
|
|
136
|
+
const uniqueMatches = [...logicalMatches.values()];
|
|
137
|
+
if (uniqueMatches.length !== 1) {
|
|
138
|
+
await database.run(`INSERT INTO activity_links VALUES ($activitySourceId, $activityId, 'source', NULL, $evidence, now())
|
|
139
|
+
ON CONFLICT (activity_source_id) DO UPDATE SET activity_id = excluded.activity_id, method = excluded.method, confidence = excluded.confidence, evidence_json = excluded.evidence_json, linked_at = now()`, { activitySourceId, activityId: source.activity_id, evidence: json({ candidates: uniqueMatches.length, matchingSources: matched.length, reason: uniqueMatches.length ? 'ambiguous' : 'no_qualifying_candidate' }) });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const { candidate, result } = uniqueMatches[0];
|
|
143
|
+
const oldActivityId = source.activity_id;
|
|
144
|
+
await database.run('UPDATE activity_sources SET activity_id = $activityId WHERE activity_source_id = $activitySourceId', { activityId: candidate.activity_id, activitySourceId });
|
|
145
|
+
await database.run(`UPDATE activities SET link_state = 'fuzzy_high_confidence' WHERE activity_id = $activityId`, { activityId: candidate.activity_id });
|
|
146
|
+
await database.run(`INSERT INTO activity_links VALUES ($activitySourceId, $activityId, 'fuzzy_high_confidence', $confidence, $evidence, now())
|
|
147
|
+
ON CONFLICT (activity_source_id) DO UPDATE SET activity_id = excluded.activity_id, method = excluded.method, confidence = excluded.confidence, evidence_json = excluded.evidence_json, linked_at = now()`, { activitySourceId, activityId: candidate.activity_id, confidence: result.score, evidence: json({ ...result.evidence, matchedActivitySourceId: candidate.activity_source_id }) });
|
|
148
|
+
await database.run('DELETE FROM activities WHERE activity_id = $activityId AND NOT EXISTS (SELECT 1 FROM activity_sources WHERE activity_id = $activityId)', { activityId: oldActivityId });
|
|
149
|
+
}
|
|
150
|
+
export async function setManualActivityLink(database, activitySourceId, activityId) {
|
|
151
|
+
await database.run('UPDATE activity_sources SET activity_id = $activityId WHERE activity_source_id = $activitySourceId', { activityId, activitySourceId });
|
|
152
|
+
await database.run(`UPDATE activities SET link_state = 'manual' WHERE activity_id = $activityId`, { activityId });
|
|
153
|
+
await database.run(`INSERT INTO activity_links VALUES ($activitySourceId, $activityId, 'manual', 1.0, '{"command":"link"}', now())
|
|
154
|
+
ON CONFLICT (activity_source_id) DO UPDATE SET activity_id = excluded.activity_id, method = excluded.method, confidence = excluded.confidence, evidence_json = excluded.evidence_json, linked_at = now()`, { activitySourceId, activityId });
|
|
155
|
+
}
|
|
156
|
+
export async function unlinkActivitySource(database, activitySourceId) {
|
|
157
|
+
const source = (await database.rows('SELECT provider, remote_activity_id FROM activity_sources WHERE activity_source_id = $activitySourceId', { activitySourceId }))[0];
|
|
158
|
+
if (!source)
|
|
159
|
+
throw new Error(`No activity source exists for ${activitySourceId}.`);
|
|
160
|
+
const activityId = `${source.provider}:${source.remote_activity_id}`;
|
|
161
|
+
await database.run(`INSERT INTO activities (activity_id, link_state) VALUES ($activityId, 'unlinked') ON CONFLICT (activity_id) DO NOTHING`, { activityId });
|
|
162
|
+
await database.run('UPDATE activity_sources SET activity_id = $activityId WHERE activity_source_id = $activitySourceId', { activityId, activitySourceId });
|
|
163
|
+
await database.run(`UPDATE activity_links SET activity_id = $activityId, method = 'source', confidence = NULL, evidence_json = '{"command":"unlink"}', linked_at = now() WHERE activity_source_id = $activitySourceId`, { activityId, activitySourceId });
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=linking.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CatenceDatabase } from '../storage/database.js';
|
|
2
|
+
import type { SourceEntity } from '../../contracts/staging.js';
|
|
3
|
+
export declare function importSourceEntity(database: CatenceDatabase, entity: SourceEntity): Promise<void>;
|
|
4
|
+
/** Re-evaluate source quality whenever an activity is imported or linked. */
|
|
5
|
+
export declare function refreshActivityQuality(database: CatenceDatabase, activityId: string): Promise<void>;
|