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,285 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { buildRetrievalIndex } from '../../core/retrieval/index.js';
|
|
10
|
+
import { defaultFromDate, ensurePaths, requireIntervalsConfig } from '../../core/runtime/configuration.js';
|
|
11
|
+
import { athleteProviderEnvironment } from '../../core/runtime/secrets.js';
|
|
12
|
+
import { importJsonl } from '../ingestion/importer.js';
|
|
13
|
+
import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
|
|
14
|
+
import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUrl, syncStravaGear } from '../ingestion/providers/strava/service.js';
|
|
15
|
+
import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
|
|
16
|
+
import { CatenceDatabase } from '../storage/database.js';
|
|
17
|
+
import { withDataWriteLock } from '../storage/write-lock.js';
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
function packageRoot() {
|
|
20
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
21
|
+
}
|
|
22
|
+
async function syncProvider(database, paths, provider, options) {
|
|
23
|
+
const explicitWindow = options.explicitFrom ? { fromDate: options.explicitFrom, toDate: options.toDate, source: 'explicit' } : null;
|
|
24
|
+
const uncoveredExplicitWindow = async (cursorName) => {
|
|
25
|
+
if (!explicitWindow)
|
|
26
|
+
return null;
|
|
27
|
+
if (!options.backfill)
|
|
28
|
+
return explicitWindow;
|
|
29
|
+
const window = await database.resolveBackfillWindow(provider, cursorName, explicitWindow.fromDate, explicitWindow.toDate, options.refreshActivities);
|
|
30
|
+
return window ? { ...window, source: 'explicit' } : null;
|
|
31
|
+
};
|
|
32
|
+
const dailyWindow = explicitWindow ? await uncoveredExplicitWindow('daily') : await database.resolveIncrementalWindow(provider, 'daily', options.toDate, defaultFromDate(), 3);
|
|
33
|
+
const activityWindow = explicitWindow ? await uncoveredExplicitWindow('activities') : await database.resolveIncrementalWindow(provider, 'activities', options.toDate, defaultFromDate(), 14);
|
|
34
|
+
const hasProviderWork = provider === 'intervals' ? activityWindow !== null : dailyWindow !== null || activityWindow !== null;
|
|
35
|
+
if (!hasProviderWork) {
|
|
36
|
+
return {
|
|
37
|
+
runId: null, provider, fromDate: null,
|
|
38
|
+
dailyFromDate: dailyWindow?.fromDate ?? null, dailyToDate: dailyWindow?.toDate ?? null,
|
|
39
|
+
activityFromDate: activityWindow?.fromDate ?? null, activityToDate: activityWindow?.toDate ?? null,
|
|
40
|
+
toDate: options.toDate, dailyCursorSource: dailyWindow?.source ?? null, activityCursorSource: activityWindow?.source ?? null,
|
|
41
|
+
refreshedActivities: provider === 'garmin' && options.refreshActivities, skipped: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const fromDate = [dailyWindow?.fromDate, activityWindow?.fromDate].filter((date) => Boolean(date)).sort()[0];
|
|
45
|
+
const runId = await database.beginRun(provider, fromDate);
|
|
46
|
+
let extractionStarted = false;
|
|
47
|
+
try {
|
|
48
|
+
if (provider === 'intervals') {
|
|
49
|
+
const environment = await athleteProviderEnvironment(paths);
|
|
50
|
+
const config = requireIntervalsConfig(environment);
|
|
51
|
+
extractionStarted = true;
|
|
52
|
+
await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const output = path.join(paths.staging, 'garmin', `${runId}.jsonl`);
|
|
56
|
+
const knownActivities = path.join(paths.staging, 'garmin', `${runId}.known-activities.json`);
|
|
57
|
+
await mkdir(path.dirname(knownActivities), { recursive: true });
|
|
58
|
+
// An explicit refresh intentionally provides no known hashes, causing
|
|
59
|
+
// the staging worker to re-fetch Garmin activity details, files, and
|
|
60
|
+
// streams even when the list-summary hash is unchanged.
|
|
61
|
+
await writeFile(knownActivities, JSON.stringify(options.refreshActivities ? {} : await database.knownActivitySummaryHashes('garmin')));
|
|
62
|
+
extractionStarted = true;
|
|
63
|
+
const arguments_ = ['run', 'python', '-m', 'python.catence.providers.garmin.cli', '--from', fromDate, '--to', options.toDate, '--data-dir', paths.root, '--output', output, '--run-id', runId, '--known-activities', knownActivities];
|
|
64
|
+
if (dailyWindow)
|
|
65
|
+
arguments_.push('--daily-from', dailyWindow.fromDate, '--daily-to', dailyWindow.toDate);
|
|
66
|
+
else
|
|
67
|
+
arguments_.push('--skip-daily');
|
|
68
|
+
if (activityWindow)
|
|
69
|
+
arguments_.push('--activity-from', activityWindow.fromDate, '--activity-to', activityWindow.toDate);
|
|
70
|
+
else
|
|
71
|
+
arguments_.push('--skip-activities');
|
|
72
|
+
if (options.backfill && !options.refreshActivities)
|
|
73
|
+
arguments_.push('--historical-only');
|
|
74
|
+
await execFileAsync('uv', arguments_, {
|
|
75
|
+
cwd: packageRoot(),
|
|
76
|
+
env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
|
|
77
|
+
});
|
|
78
|
+
if (!existsSync(output))
|
|
79
|
+
throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
|
|
80
|
+
await importJsonl(database, runId, output);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
await database.addError(runId, provider, 'sync', null, error instanceof Error ? error.message : String(error), true);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await database.finishRun(runId);
|
|
88
|
+
}
|
|
89
|
+
if (options.advanceCursor && !options.explicitFrom && extractionStarted) {
|
|
90
|
+
await database.advanceIncrementalCursor(provider, 'daily', runId, options.toDate, 3);
|
|
91
|
+
await database.advanceIncrementalCursor(provider, 'activities', runId, options.toDate, 14);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
runId, provider, fromDate,
|
|
95
|
+
dailyFromDate: dailyWindow?.fromDate ?? null, dailyToDate: dailyWindow?.toDate ?? null,
|
|
96
|
+
activityFromDate: activityWindow?.fromDate ?? null, activityToDate: activityWindow?.toDate ?? null,
|
|
97
|
+
toDate: options.toDate, dailyCursorSource: dailyWindow?.source ?? null, activityCursorSource: activityWindow?.source ?? null,
|
|
98
|
+
refreshedActivities: provider === 'garmin' && options.refreshActivities, skipped: false,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export async function initializeDataStore(paths) {
|
|
102
|
+
await ensurePaths(paths);
|
|
103
|
+
const database = await CatenceDatabase.open(paths);
|
|
104
|
+
await database.close();
|
|
105
|
+
return { dataDir: paths.root, database: paths.database, message: 'Catence data store is ready. Run sync to import provider data.' };
|
|
106
|
+
}
|
|
107
|
+
export async function syncData(paths, provider, explicitFrom, advanceCursor = true, refreshActivities = false, backfill = false) {
|
|
108
|
+
await ensurePaths(paths);
|
|
109
|
+
if (provider === 'strava')
|
|
110
|
+
return { provider, result: await syncStravaGear(paths) };
|
|
111
|
+
const database = await CatenceDatabase.open(paths);
|
|
112
|
+
try {
|
|
113
|
+
const providers = provider === 'all' ? ['intervals', 'garmin'] : [provider];
|
|
114
|
+
const toDate = new Date().toISOString().slice(0, 10);
|
|
115
|
+
const runs = [];
|
|
116
|
+
for (const source of providers)
|
|
117
|
+
runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }));
|
|
118
|
+
return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs };
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
await database.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export async function retryDataSync(paths, previousRunId) {
|
|
125
|
+
await ensurePaths(paths);
|
|
126
|
+
const database = await CatenceDatabase.open(paths);
|
|
127
|
+
try {
|
|
128
|
+
const previous = await database.getRun(previousRunId);
|
|
129
|
+
if (!previous)
|
|
130
|
+
throw new Error(`No sync run found for ${previousRunId}.`);
|
|
131
|
+
if (previous.provider === 'strava')
|
|
132
|
+
throw new Error('Retry Strava gear sync with `catence-data sync --provider strava`.');
|
|
133
|
+
const run = await syncProvider(database, paths, previous.provider, { explicitFrom: previous.from_date, toDate: new Date().toISOString().slice(0, 10), advanceCursor: false, refreshActivities: false, backfill: false });
|
|
134
|
+
return { retriedRun: previousRunId, runId: run.runId, run };
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
await database.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export async function connectStrava(paths, code, redirectUri) {
|
|
141
|
+
return code ? completeStravaAuthorization(paths, code, redirectUri) : getStravaAuthorizationUrl(paths, redirectUri);
|
|
142
|
+
}
|
|
143
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
144
|
+
/** Validate a local OAuth return address before opening a callback listener. */
|
|
145
|
+
export function loopbackStravaRedirect(redirectUri) {
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = new URL(redirectUri);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new Error('--redirect-uri must be an absolute URL.');
|
|
152
|
+
}
|
|
153
|
+
if (parsed.protocol !== 'http:' || !LOOPBACK_HOSTS.has(parsed.hostname) || !parsed.port) {
|
|
154
|
+
throw new Error('--callback requires an http loopback --redirect-uri with an explicit port.');
|
|
155
|
+
}
|
|
156
|
+
return parsed;
|
|
157
|
+
}
|
|
158
|
+
function readStravaCallback(requestUrl, callback) {
|
|
159
|
+
const received = new URL(requestUrl ?? '/', callback);
|
|
160
|
+
if (received.pathname !== callback.pathname)
|
|
161
|
+
throw new Error('Strava returned to an unexpected callback path.');
|
|
162
|
+
const error = received.searchParams.get('error');
|
|
163
|
+
if (error)
|
|
164
|
+
throw new Error(`Strava authorization was declined or failed: ${error}.`);
|
|
165
|
+
const code = received.searchParams.get('code');
|
|
166
|
+
if (!code)
|
|
167
|
+
throw new Error('Strava callback did not include an authorization code.');
|
|
168
|
+
return { code, state: received.searchParams.get('state') };
|
|
169
|
+
}
|
|
170
|
+
function callbackPage(response, success) {
|
|
171
|
+
response.writeHead(success ? 200 : 400, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
172
|
+
response.end(success
|
|
173
|
+
? '<!doctype html><title>Catence connected</title><p>Strava is connected to Catence. You can close this tab.</p>'
|
|
174
|
+
: '<!doctype html><title>Catence authorization failed</title><p>Strava authorization did not complete. Return to the terminal for details.</p>');
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Complete Strava OAuth through a short-lived loopback callback listener.
|
|
178
|
+
*
|
|
179
|
+
* The authorization URL is emitted through the caller so the CLI can keep
|
|
180
|
+
* stdout machine-readable for the final connection result.
|
|
181
|
+
*/
|
|
182
|
+
export async function connectStravaWithCallback(paths, redirectUri, onAuthorizationUrl, timeoutMs = 300_000) {
|
|
183
|
+
const callback = loopbackStravaRedirect(redirectUri);
|
|
184
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000)
|
|
185
|
+
throw new Error('OAuth callback timeout must be at least one second.');
|
|
186
|
+
const expectedState = randomUUID();
|
|
187
|
+
const listener = createServer();
|
|
188
|
+
const callbackResult = new Promise((resolve, reject) => {
|
|
189
|
+
listener.once('error', reject);
|
|
190
|
+
listener.on('request', (request, response) => {
|
|
191
|
+
if (request.method !== 'GET') {
|
|
192
|
+
response.writeHead(405, { allow: 'GET' }).end();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const result = readStravaCallback(request.url, callback);
|
|
197
|
+
if (result.state !== expectedState)
|
|
198
|
+
throw new Error('Strava callback state did not match this authorization request.');
|
|
199
|
+
callbackPage(response, true);
|
|
200
|
+
resolve(result);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
callbackPage(response, false);
|
|
204
|
+
reject(error);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
try {
|
|
209
|
+
await new Promise((resolve, reject) => {
|
|
210
|
+
listener.once('error', reject);
|
|
211
|
+
listener.listen(Number(callback.port), callback.hostname.replace(/^\[|\]$/g, ''), () => {
|
|
212
|
+
listener.off('error', reject);
|
|
213
|
+
resolve();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
const authorization = await getStravaAuthorizationUrl(paths, callback.toString());
|
|
217
|
+
if (!authorization.authorizationUrl)
|
|
218
|
+
throw new Error('Strava did not return an authorization URL.');
|
|
219
|
+
const authorizationUrl = new URL(authorization.authorizationUrl);
|
|
220
|
+
authorizationUrl.searchParams.set('state', expectedState);
|
|
221
|
+
onAuthorizationUrl(authorizationUrl.toString());
|
|
222
|
+
const timed = Promise.race([
|
|
223
|
+
callbackResult,
|
|
224
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Timed out waiting for the Strava callback.')), timeoutMs)),
|
|
225
|
+
]);
|
|
226
|
+
const { code } = await timed;
|
|
227
|
+
return completeStravaAuthorization(paths, code, callback.toString());
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
if (listener.listening)
|
|
231
|
+
await new Promise((resolve) => listener.close(() => resolve()));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
export async function disconnectStravaAccount(paths) {
|
|
235
|
+
await disconnectStrava(paths);
|
|
236
|
+
return { provider: 'strava', disconnected: true };
|
|
237
|
+
}
|
|
238
|
+
export async function linkActivity(paths, source, activity) {
|
|
239
|
+
await ensurePaths(paths);
|
|
240
|
+
await withDataWriteLock(paths, async () => {
|
|
241
|
+
const database = await CatenceDatabase.open(paths);
|
|
242
|
+
try {
|
|
243
|
+
await setManualActivityLink(database, source, activity);
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
await database.close();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
return { source, activity, method: 'manual' };
|
|
250
|
+
}
|
|
251
|
+
export async function unlinkActivity(paths, source) {
|
|
252
|
+
await ensurePaths(paths);
|
|
253
|
+
await withDataWriteLock(paths, async () => {
|
|
254
|
+
const database = await CatenceDatabase.open(paths);
|
|
255
|
+
try {
|
|
256
|
+
await unlinkActivitySource(database, source);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await database.close();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
return { source, method: 'source' };
|
|
263
|
+
}
|
|
264
|
+
export async function dataStatus(paths) {
|
|
265
|
+
await ensurePaths(paths);
|
|
266
|
+
const database = await CatenceDatabase.open(paths);
|
|
267
|
+
try {
|
|
268
|
+
return await database.status();
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
await database.close();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
export async function rebuildRetrievalIndex(paths) {
|
|
275
|
+
if (!existsSync(paths.database))
|
|
276
|
+
throw new Error('No Catence database exists yet. Complete a sync before building the retrieval index.');
|
|
277
|
+
const database = await CatenceDatabase.open(paths);
|
|
278
|
+
try {
|
|
279
|
+
return await buildRetrievalIndex(database);
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
await database.close();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
//# sourceMappingURL=management.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CatenceDatabase } from '../storage/database.js';
|
|
2
|
+
import { type StagingRecord } from '../../contracts/staging.js';
|
|
3
|
+
export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord): Promise<void>;
|
|
4
|
+
export declare function importJsonl(database: CatenceDatabase, runId: string, filePath: string): Promise<number>;
|
|
5
|
+
export declare function importSingleStagingFile(database: CatenceDatabase, runId: string, filePath: string): Promise<number>;
|
|
6
|
+
export declare function queueWorkItem(database: CatenceDatabase, runId: string, provider: 'intervals' | 'garmin' | 'strava', endpoint: string, remoteId: string | null, scope: unknown): Promise<string>;
|
|
7
|
+
export declare function completeWorkItem(database: CatenceDatabase, itemId: string): Promise<void>;
|
|
8
|
+
export declare function failWorkItem(database: CatenceDatabase, itemId: string, message: string): Promise<void>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { stagingRecordSchema } from '../../contracts/staging.js';
|
|
6
|
+
import { importSourceEntity, refreshActivityQuality } from '../normalization/normalizers.js';
|
|
7
|
+
import { normalizeStravaEntity } from '../normalization/segments/strava-normalizer.js';
|
|
8
|
+
import { reconcileActivityLink } from '../normalization/activities/linking.js';
|
|
9
|
+
import { json } from '../storage/sql.js';
|
|
10
|
+
export async function importRecord(database, runId, record) {
|
|
11
|
+
if (record.kind === 'run_manifest') {
|
|
12
|
+
if (record.runId !== runId)
|
|
13
|
+
throw new Error(`Staging manifest run ${record.runId} does not match importer run ${runId}.`);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (record.kind === 'raw_object') {
|
|
17
|
+
await database.run(`INSERT OR IGNORE INTO raw_objects VALUES
|
|
18
|
+
($contentHash, $provider, $endpoint, $remoteId, $fetchedAt, $contentType, $relativePath, $scope, $parserVersion)`, {
|
|
19
|
+
contentHash: record.contentHash, provider: record.provider, endpoint: record.endpoint, remoteId: record.remoteId,
|
|
20
|
+
fetchedAt: record.fetchedAt, contentType: record.contentType, relativePath: record.relativePath,
|
|
21
|
+
scope: json(record.scope), parserVersion: record.schemaVersion,
|
|
22
|
+
});
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (record.kind === 'source_entity') {
|
|
26
|
+
if (record.provider === 'strava')
|
|
27
|
+
await normalizeStravaEntity(database, record);
|
|
28
|
+
else
|
|
29
|
+
await importSourceEntity(database, record);
|
|
30
|
+
if (record.entityType === 'activity') {
|
|
31
|
+
await reconcileActivityLink(database, record.provider + ':' + record.remoteId);
|
|
32
|
+
const activity = await database.rows('SELECT activity_id FROM activity_sources WHERE activity_source_id = $activitySourceId', { activitySourceId: record.provider + ':' + record.remoteId });
|
|
33
|
+
if (activity[0]?.activity_id)
|
|
34
|
+
await refreshActivityQuality(database, activity[0].activity_id);
|
|
35
|
+
}
|
|
36
|
+
if (record.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set'].includes(record.entityType) && record.parentRemoteId) {
|
|
37
|
+
const activity = await database.rows('SELECT activity_id FROM activity_sources WHERE activity_source_id = $activitySourceId', { activitySourceId: `garmin:${record.parentRemoteId}` });
|
|
38
|
+
if (activity[0]?.activity_id)
|
|
39
|
+
await refreshActivityQuality(database, activity[0].activity_id);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (record.kind === 'stream_manifest') {
|
|
44
|
+
const activitySourceId = `${record.provider}:${record.activityRemoteId}`;
|
|
45
|
+
await database.run(`INSERT OR IGNORE INTO stream_manifest VALUES
|
|
46
|
+
($provider, $activitySourceId, $contentHash, $relativePath, $rowCount, $startAt, $endAt, $columns, $rawHash)`, {
|
|
47
|
+
provider: record.provider, activitySourceId, contentHash: record.contentHash, relativePath: record.relativePath,
|
|
48
|
+
rowCount: record.rowCount, startAt: record.startAt, endAt: record.endAt, columns: json(record.columns), rawHash: record.rawObjectHash,
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (record.kind === 'activity_sync_state') {
|
|
53
|
+
await database.recordActivitySyncState(record.provider, record.activityRemoteId, record.summaryHash, runId, record.detailsFetched);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
await database.addError(runId, record.provider, record.endpoint, record.remoteId, record.message, record.retryable);
|
|
57
|
+
}
|
|
58
|
+
export async function importJsonl(database, runId, filePath) {
|
|
59
|
+
let count = 0;
|
|
60
|
+
const file = createReadStream(filePath, { encoding: 'utf8' });
|
|
61
|
+
const lines = createInterface({ input: file, crlfDelay: Infinity });
|
|
62
|
+
for await (const line of lines) {
|
|
63
|
+
if (!line.trim())
|
|
64
|
+
continue;
|
|
65
|
+
const record = stagingRecordSchema.parse(JSON.parse(line));
|
|
66
|
+
await importRecord(database, runId, record);
|
|
67
|
+
count += 1;
|
|
68
|
+
}
|
|
69
|
+
return count;
|
|
70
|
+
}
|
|
71
|
+
export async function importSingleStagingFile(database, runId, filePath) {
|
|
72
|
+
await readFile(filePath, 'utf8');
|
|
73
|
+
return importJsonl(database, runId, filePath);
|
|
74
|
+
}
|
|
75
|
+
export async function queueWorkItem(database, runId, provider, endpoint, remoteId, scope) {
|
|
76
|
+
const itemId = randomUUID();
|
|
77
|
+
await database.run(`INSERT INTO sync_items (item_id, run_id, provider, endpoint, remote_id, scope_json, status)
|
|
78
|
+
VALUES ($itemId, $runId, $provider, $endpoint, $remoteId, $scope, 'pending')`, { itemId, runId, provider, endpoint, remoteId, scope: json(scope) });
|
|
79
|
+
return itemId;
|
|
80
|
+
}
|
|
81
|
+
export async function completeWorkItem(database, itemId) {
|
|
82
|
+
await database.run(`UPDATE sync_items SET status = 'completed', attempts = attempts + 1, completed_at = now() WHERE item_id = $itemId`, { itemId });
|
|
83
|
+
}
|
|
84
|
+
export async function failWorkItem(database, itemId, message) {
|
|
85
|
+
await database.run(`UPDATE sync_items SET status = 'failed', attempts = attempts + 1, error_message = $message, completed_at = now() WHERE item_id = $itemId`, { itemId, message });
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=importer.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CatenceDatabase } from '../../storage/database.js';
|
|
2
|
+
import type { CatencePaths } from '../../../contracts/runtime.js';
|
|
3
|
+
export declare class IntervalsExtractor {
|
|
4
|
+
private readonly database;
|
|
5
|
+
private readonly paths;
|
|
6
|
+
private readonly apiKey;
|
|
7
|
+
private readonly athleteId;
|
|
8
|
+
private readonly client;
|
|
9
|
+
constructor(database: CatenceDatabase, paths: CatencePaths, apiKey: string, athleteId: string);
|
|
10
|
+
sync(runId: string, activityWindow: {
|
|
11
|
+
fromDate: string;
|
|
12
|
+
toDate: string;
|
|
13
|
+
} | null, includeAccount?: boolean): Promise<void>;
|
|
14
|
+
private captureCollectionChildren;
|
|
15
|
+
private getCollection;
|
|
16
|
+
private captureActivity;
|
|
17
|
+
private capture;
|
|
18
|
+
private importEntities;
|
|
19
|
+
private getJson;
|
|
20
|
+
private getBinary;
|
|
21
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { IntervalsClient } from 'intervals-icu';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { stableJsonHash, storeArtifact, storeJsonArtifact } from '../../storage/artifacts.js';
|
|
4
|
+
import { importRecord, queueWorkItem, completeWorkItem, failWorkItem } from '../importer.js';
|
|
5
|
+
import { intervalsActivityReadEndpoints, intervalsSecondaryReadRegistry, assertReadOnlyRegistry } from './registry.js';
|
|
6
|
+
import { intervalsStreamsToSamples, writeParquetSamples } from '../../streams.js';
|
|
7
|
+
const apiBaseUrl = 'https://intervals.icu/api/v1';
|
|
8
|
+
function asObject(value) {
|
|
9
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
10
|
+
}
|
|
11
|
+
function asItems(value) {
|
|
12
|
+
if (Array.isArray(value))
|
|
13
|
+
return value.map(asObject);
|
|
14
|
+
const object = asObject(value);
|
|
15
|
+
for (const key of ['items', 'data', 'activities', 'events', 'workouts', 'wellness']) {
|
|
16
|
+
if (Array.isArray(object[key]))
|
|
17
|
+
return object[key].map(asObject);
|
|
18
|
+
}
|
|
19
|
+
return Object.keys(object).length > 0 ? [object] : [];
|
|
20
|
+
}
|
|
21
|
+
function idOf(payload, fallback) {
|
|
22
|
+
for (const key of ['id', 'activity_id', 'activityId', 'date', 'name']) {
|
|
23
|
+
const value = payload[key];
|
|
24
|
+
if (typeof value === 'string' || typeof value === 'number')
|
|
25
|
+
return String(value);
|
|
26
|
+
}
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
function entityDate(payload) {
|
|
30
|
+
for (const key of ['id', 'date', 'start_date_local', 'start_date', 'updated']) {
|
|
31
|
+
const value = payload[key];
|
|
32
|
+
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function basicAuth(apiKey) {
|
|
38
|
+
return `Basic ${Buffer.from(`API_KEY:${apiKey}`).toString('base64')}`;
|
|
39
|
+
}
|
|
40
|
+
export class IntervalsExtractor {
|
|
41
|
+
database;
|
|
42
|
+
paths;
|
|
43
|
+
apiKey;
|
|
44
|
+
athleteId;
|
|
45
|
+
client;
|
|
46
|
+
constructor(database, paths, apiKey, athleteId) {
|
|
47
|
+
this.database = database;
|
|
48
|
+
this.paths = paths;
|
|
49
|
+
this.apiKey = apiKey;
|
|
50
|
+
this.athleteId = athleteId;
|
|
51
|
+
// The typed client is used for provider-compatible authentication and the
|
|
52
|
+
// primary identity request. Manifest-only calls below intentionally use
|
|
53
|
+
// the same read-only credentials to cover endpoint families not wrapped by it.
|
|
54
|
+
this.client = new IntervalsClient({ apiKey, athleteId, maxRetries: 3 });
|
|
55
|
+
}
|
|
56
|
+
async sync(runId, activityWindow, includeAccount = true) {
|
|
57
|
+
assertReadOnlyRegistry();
|
|
58
|
+
let resolvedAthleteId = this.athleteId;
|
|
59
|
+
if (includeAccount) {
|
|
60
|
+
const athlete = await this.capture(runId, 'athlete', null, {}, async () => this.client.athletes.getAthlete());
|
|
61
|
+
const athletePayload = asObject(athlete);
|
|
62
|
+
resolvedAthleteId = typeof athletePayload.id === 'string' ? athletePayload.id : this.athleteId;
|
|
63
|
+
await this.database.insertSourceAccount('intervals', resolvedAthleteId, typeof athletePayload.name === 'string' ? athletePayload.name : null, athletePayload);
|
|
64
|
+
}
|
|
65
|
+
for (const endpoint of intervalsSecondaryReadRegistry) {
|
|
66
|
+
if (endpoint.name === 'athlete')
|
|
67
|
+
continue;
|
|
68
|
+
if (!activityWindow)
|
|
69
|
+
continue;
|
|
70
|
+
const { fromDate, toDate } = activityWindow;
|
|
71
|
+
const payload = await this.capture(runId, endpoint.name, null, { fromDate, toDate }, async () => this.getCollection(endpoint.name, resolvedAthleteId, fromDate, toDate));
|
|
72
|
+
if (payload === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
await this.importEntities(endpoint.entityType, endpoint.name, payload, null, runId);
|
|
75
|
+
if (endpoint.name === 'activities') {
|
|
76
|
+
for (const activity of asItems(payload)) {
|
|
77
|
+
const activityId = idOf(activity, randomUUID());
|
|
78
|
+
const summaryHash = stableJsonHash(activity);
|
|
79
|
+
// The activity-list payload already contains the Intervals-only
|
|
80
|
+
// training calculations. Garmin remains the source of record for
|
|
81
|
+
// detail, files, streams, and structured activity data.
|
|
82
|
+
await this.database.recordActivitySyncState('intervals', activityId, summaryHash, runId, false);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async captureCollectionChildren(runId, collection, payload, athleteId) {
|
|
88
|
+
const children = {
|
|
89
|
+
events: [{ suffix: (id) => `/athlete/${athleteId}/events/${id}`, entityType: 'event' }],
|
|
90
|
+
workouts: [{ suffix: (id) => `/athlete/${athleteId}/workouts/${id}`, entityType: 'workout' }],
|
|
91
|
+
folders: [
|
|
92
|
+
{ suffix: (id) => `/athlete/${athleteId}/folders/${id}`, entityType: 'folder' },
|
|
93
|
+
{ suffix: (id) => `/athlete/${athleteId}/folders/${id}/workouts`, entityType: 'workout' },
|
|
94
|
+
{ suffix: (id) => `/athlete/${athleteId}/folders/${id}/shared-with`, entityType: 'folder_share' },
|
|
95
|
+
],
|
|
96
|
+
gear: [
|
|
97
|
+
{ suffix: (id) => `/athlete/${athleteId}/gear/${id}`, entityType: 'gear' },
|
|
98
|
+
{ suffix: (id) => `/athlete/${athleteId}/gear/${id}/reminder`, entityType: 'gear_reminder' },
|
|
99
|
+
],
|
|
100
|
+
routes: [{ suffix: (id) => `/athlete/${athleteId}/routes/${id}`, entityType: 'route' }],
|
|
101
|
+
sport_settings: [{ suffix: (id) => `/athlete/${athleteId}/sport-settings/${id}`, entityType: 'sport_setting' }],
|
|
102
|
+
custom_items: [
|
|
103
|
+
{ suffix: (id) => `/athlete/${athleteId}/custom-item/${id}`, entityType: 'custom_item' },
|
|
104
|
+
{ suffix: (id) => `/athlete/${athleteId}/custom-item/${id}/image`, entityType: 'custom_item_image', binary: true },
|
|
105
|
+
],
|
|
106
|
+
chats: [
|
|
107
|
+
{ suffix: (id) => `/chats/${id}`, entityType: 'chat' },
|
|
108
|
+
{ suffix: (id) => `/chats/${id}/messages`, entityType: 'message' },
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
for (const item of asItems(payload)) {
|
|
112
|
+
const remoteId = idOf(item, 'unknown');
|
|
113
|
+
for (const child of children[collection] ?? []) {
|
|
114
|
+
const endpoint = `${collection}_${child.entityType}`;
|
|
115
|
+
const childPayload = await this.capture(runId, endpoint, remoteId, { parent: collection }, async () => child.binary ? this.getBinary(child.suffix(remoteId)) : this.getJson(child.suffix(remoteId)));
|
|
116
|
+
if (childPayload === undefined || childPayload instanceof Uint8Array)
|
|
117
|
+
continue;
|
|
118
|
+
await this.importEntities(child.entityType, endpoint, childPayload, remoteId, runId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async getCollection(name, athleteId, fromDate, toDate) {
|
|
123
|
+
const query = new URLSearchParams({ oldest: fromDate, newest: toDate, resolve: 'true' });
|
|
124
|
+
const routes = {
|
|
125
|
+
profile: `/athlete/${athleteId}/profile`, connections: `/athlete/${athleteId}/connections`, sport_settings: `/athlete/${athleteId}/sport-settings`,
|
|
126
|
+
activities: `/athlete/${athleteId}/activities`, wellness: `/athlete/${athleteId}/wellness`, events: `/athlete/${athleteId}/events`,
|
|
127
|
+
workouts: `/athlete/${athleteId}/workouts`, folders: `/athlete/${athleteId}/folders`, gear: `/athlete/${athleteId}/gear`, routes: `/athlete/${athleteId}/routes`,
|
|
128
|
+
custom_items: `/athlete/${athleteId}/custom-item`, fitness: `/athlete/${athleteId}/fitness`, activity_summary: `/athlete/${athleteId}/activity-summary`,
|
|
129
|
+
power_curves: `/athlete/${athleteId}/power-curves`, pace_curves: `/athlete/${athleteId}/pace-curves`, hr_curves: `/athlete/${athleteId}/hr-curves`,
|
|
130
|
+
activity_power_curves: `/athlete/${athleteId}/activity-power-curves`, activity_hr_curves: `/athlete/${athleteId}/activity-hr-curves`,
|
|
131
|
+
weather_config: `/athlete/${athleteId}/weather-config`, weather_forecast: `/athlete/${athleteId}/weather`, chats: `/athlete/${athleteId}/chats`,
|
|
132
|
+
activity_tags: `/athlete/${athleteId}/activity-tags`, event_tags: `/athlete/${athleteId}/event-tags`, workout_tags: `/athlete/${athleteId}/workout-tags`,
|
|
133
|
+
};
|
|
134
|
+
const route = routes[name];
|
|
135
|
+
if (!route)
|
|
136
|
+
throw new Error(`Endpoint ${name} is not on the Intervals read-only route map.`);
|
|
137
|
+
return this.getJson(`${route}?${query.toString()}`);
|
|
138
|
+
}
|
|
139
|
+
async captureActivity(runId, activityId, summary) {
|
|
140
|
+
for (const endpoint of intervalsActivityReadEndpoints) {
|
|
141
|
+
const payload = await this.capture(runId, `activity_${endpoint}`, activityId, {}, async () => {
|
|
142
|
+
if (endpoint === 'original_file' || endpoint === 'fit_file' || endpoint === 'gpx_file') {
|
|
143
|
+
const route = endpoint === 'original_file' ? `/activity/${activityId}/file` : endpoint === 'fit_file' ? `/activity/${activityId}/fit-file` : `/activity/${activityId}/gpx-file`;
|
|
144
|
+
return this.getBinary(route);
|
|
145
|
+
}
|
|
146
|
+
const route = endpoint === 'activity' ? `/activity/${activityId}` : `/activity/${activityId}/${endpoint.replaceAll('_', '-')}`;
|
|
147
|
+
return this.getJson(route);
|
|
148
|
+
});
|
|
149
|
+
if (payload === undefined)
|
|
150
|
+
continue;
|
|
151
|
+
if (payload instanceof Uint8Array)
|
|
152
|
+
continue;
|
|
153
|
+
if (endpoint === 'streams') {
|
|
154
|
+
const detail = asObject(summary);
|
|
155
|
+
const samples = intervalsStreamsToSamples(`intervals:${activityId}`, Array.isArray(payload) ? payload.map(asObject) : [], typeof detail.start_date === 'string' ? detail.start_date : null);
|
|
156
|
+
if (samples.length > 0) {
|
|
157
|
+
const parquet = await writeParquetSamples(this.database, this.paths, 'intervals', activityId, typeof detail.start_date_local === 'string' ? detail.start_date_local : new Date().toISOString().slice(0, 10), samples);
|
|
158
|
+
await importRecord(this.database, runId, {
|
|
159
|
+
kind: 'stream_manifest', schemaVersion: 1, provider: 'intervals', activityRemoteId: activityId,
|
|
160
|
+
relativePath: parquet.relativePath, contentHash: parquet.contentHash, rowCount: samples.length,
|
|
161
|
+
startAt: samples[0]?.timestamp_utc ?? null, endAt: samples.at(-1)?.timestamp_utc ?? null, columns: parquet.columns, rawObjectHash: null,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (endpoint === 'intervals') {
|
|
166
|
+
const intervals = Array.isArray(payload) ? payload : asObject(payload).icu_intervals;
|
|
167
|
+
if (Array.isArray(intervals))
|
|
168
|
+
await this.importEntities('activity_interval', 'activity_intervals', intervals, activityId, runId);
|
|
169
|
+
}
|
|
170
|
+
else if (endpoint !== 'streams') {
|
|
171
|
+
await this.importEntities(`activity_${endpoint}`, `activity_${endpoint}`, payload, activityId, runId);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async capture(runId, endpoint, remoteId, scope, action) {
|
|
176
|
+
const workId = await queueWorkItem(this.database, runId, 'intervals', endpoint, remoteId, scope);
|
|
177
|
+
try {
|
|
178
|
+
const value = await action();
|
|
179
|
+
const artifact = value instanceof Uint8Array
|
|
180
|
+
? await storeArtifact(this.paths, 'intervals', endpoint, remoteId, value, endpoint.includes('file') ? 'bin' : 'dat', 'application/octet-stream')
|
|
181
|
+
: await storeJsonArtifact(this.paths, 'intervals', endpoint, remoteId, value);
|
|
182
|
+
await importRecord(this.database, runId, {
|
|
183
|
+
kind: 'raw_object', schemaVersion: 1, provider: 'intervals', endpoint, remoteId, fetchedAt: new Date().toISOString(),
|
|
184
|
+
contentHash: artifact.contentHash, contentType: artifact.contentType, relativePath: artifact.relativePath, scope,
|
|
185
|
+
});
|
|
186
|
+
await completeWorkItem(this.database, workId);
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
191
|
+
await failWorkItem(this.database, workId, message);
|
|
192
|
+
await this.database.addError(runId, 'intervals', endpoint, remoteId, message, true);
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async importEntities(entityType, endpoint, payload, parentRemoteId, runId) {
|
|
197
|
+
const artifactHash = (await this.database.rows('SELECT content_hash FROM raw_objects WHERE provider = \'intervals\' AND endpoint = $endpoint AND remote_id IS NOT DISTINCT FROM $remoteId ORDER BY fetched_at DESC LIMIT 1', { endpoint: endpoint.startsWith('activity_') ? endpoint : endpoint, remoteId: parentRemoteId }))[0]?.content_hash ?? null;
|
|
198
|
+
for (const [index, item] of asItems(payload).entries()) {
|
|
199
|
+
const remoteId = idOf(item, `${endpoint}:${index}`);
|
|
200
|
+
const entity = {
|
|
201
|
+
kind: 'source_entity', schemaVersion: 1, provider: 'intervals', entityType, remoteId, parentRemoteId,
|
|
202
|
+
occurredOn: entityDate(item), sourceUpdatedAt: typeof item.updated === 'string' ? item.updated : null, rawObjectHash: artifactHash,
|
|
203
|
+
payload: item, extension: {},
|
|
204
|
+
};
|
|
205
|
+
await importRecord(this.database, runId, entity);
|
|
206
|
+
if (entityType === 'wellness') {
|
|
207
|
+
await importRecord(this.database, runId, { ...entity, entityType: 'nutrition_day' });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async getJson(route) {
|
|
212
|
+
const response = await fetch(`${apiBaseUrl}${route}`, { headers: { Authorization: basicAuth(this.apiKey), Accept: 'application/json' } });
|
|
213
|
+
if (!response.ok)
|
|
214
|
+
throw new Error(`Intervals ${route} failed with HTTP ${response.status}`);
|
|
215
|
+
return response.json();
|
|
216
|
+
}
|
|
217
|
+
async getBinary(route) {
|
|
218
|
+
const response = await fetch(`${apiBaseUrl}${route}`, { headers: { Authorization: basicAuth(this.apiKey) } });
|
|
219
|
+
if (!response.ok)
|
|
220
|
+
throw new Error(`Intervals ${route} failed with HTTP ${response.status}`);
|
|
221
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=intervals.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { EndpointSpec } from '../../../contracts/staging.js';
|
|
2
|
+
/**
|
|
3
|
+
* The only provider operations an extractor may execute. Entries intentionally
|
|
4
|
+
* describe data families, not mutations: extraction code rejects every route
|
|
5
|
+
* outside this registry.
|
|
6
|
+
*/
|
|
7
|
+
export declare const intervalsReadRegistry: readonly EndpointSpec[];
|
|
8
|
+
/**
|
|
9
|
+
* Intervals is an analysis companion to Garmin: its activity-list payload
|
|
10
|
+
* supplies the separately named training calculations without duplicating
|
|
11
|
+
* Garmin's activity detail, files, streams, or account data.
|
|
12
|
+
*/
|
|
13
|
+
export declare const intervalsSecondaryReadRegistry: EndpointSpec[];
|
|
14
|
+
export declare const intervalsActivityReadEndpoints: readonly ["activity", "streams", "intervals", "map", "weather", "weather_summary", "best_efforts", "power_curve", "pace_curve", "hr_curve", "power_vs_hr", "hr_load_model", "segments", "messages", "original_file", "fit_file", "gpx_file"];
|
|
15
|
+
/** Read-only methods exposed by python-garminconnect, grouped for manifest tests and worker dispatch. */
|
|
16
|
+
export declare const garminReadRegistry: readonly EndpointSpec[];
|
|
17
|
+
export declare const stravaReadRegistry: readonly EndpointSpec[];
|
|
18
|
+
export declare const allReadOnlyEndpoints: readonly EndpointSpec[];
|
|
19
|
+
export declare function assertReadOnlyRegistry(): void;
|