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.
Files changed (106) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +373 -29
  3. package/config.example.json +63 -0
  4. package/dist/contracts/index.d.ts +4 -0
  5. package/dist/contracts/index.js +5 -0
  6. package/dist/contracts/release.d.ts +20 -0
  7. package/dist/contracts/release.js +17 -0
  8. package/dist/contracts/runtime.d.ts +11 -0
  9. package/dist/contracts/runtime.js +2 -0
  10. package/dist/contracts/staging.d.ts +183 -0
  11. package/dist/contracts/staging.js +36 -0
  12. package/dist/contracts/storage.d.ts +15 -0
  13. package/dist/contracts/storage.js +2 -0
  14. package/dist/core/dashboard/snapshot.d.ts +16 -0
  15. package/dist/core/dashboard/snapshot.js +65 -0
  16. package/dist/core/query/activity-discovery.d.ts +21 -0
  17. package/dist/core/query/activity-discovery.js +133 -0
  18. package/dist/core/query/analytics.d.ts +59 -0
  19. package/dist/core/query/analytics.js +395 -0
  20. package/dist/core/query/catalog.d.ts +26 -0
  21. package/dist/core/query/catalog.js +141 -0
  22. package/dist/core/query/fitness.d.ts +40 -0
  23. package/dist/core/query/fitness.js +297 -0
  24. package/dist/core/query/repository.d.ts +19 -0
  25. package/dist/core/query/repository.js +182 -0
  26. package/dist/core/query/sql-guard.d.ts +7 -0
  27. package/dist/core/query/sql-guard.js +121 -0
  28. package/dist/core/query/sql.d.ts +2 -0
  29. package/dist/core/query/sql.js +5 -0
  30. package/dist/core/query/swimming.d.ts +19 -0
  31. package/dist/core/query/swimming.js +184 -0
  32. package/dist/core/query/wellness.d.ts +45 -0
  33. package/dist/core/query/wellness.js +292 -0
  34. package/dist/core/retrieval/index.d.ts +13 -0
  35. package/dist/core/retrieval/index.js +248 -0
  36. package/dist/core/runtime/configuration.d.ts +71 -0
  37. package/dist/core/runtime/configuration.js +106 -0
  38. package/dist/core/runtime/limiter.d.ts +13 -0
  39. package/dist/core/runtime/limiter.js +17 -0
  40. package/dist/elt/application/demo.d.ts +23 -0
  41. package/dist/elt/application/demo.js +165 -0
  42. package/dist/elt/application/management.d.ts +25 -0
  43. package/dist/elt/application/management.js +283 -0
  44. package/dist/elt/ingestion/importer.d.ts +8 -0
  45. package/dist/elt/ingestion/importer.js +87 -0
  46. package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
  47. package/dist/elt/ingestion/providers/intervals.js +224 -0
  48. package/dist/elt/ingestion/providers/registry.d.ts +19 -0
  49. package/dist/elt/ingestion/providers/registry.js +62 -0
  50. package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
  51. package/dist/elt/ingestion/providers/strava/service.js +365 -0
  52. package/dist/elt/normalization/activities/linking.d.ts +5 -0
  53. package/dist/elt/normalization/activities/linking.js +165 -0
  54. package/dist/elt/normalization/normalizers.d.ts +5 -0
  55. package/dist/elt/normalization/normalizers.js +816 -0
  56. package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
  57. package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
  58. package/dist/elt/storage/artifacts.d.ts +13 -0
  59. package/dist/elt/storage/artifacts.js +48 -0
  60. package/dist/elt/storage/database.d.ts +74 -0
  61. package/dist/elt/storage/database.js +296 -0
  62. package/dist/elt/storage/migrations.d.ts +3 -0
  63. package/dist/elt/storage/migrations.js +832 -0
  64. package/dist/elt/storage/migrations.legacy.d.ts +12 -0
  65. package/dist/elt/storage/migrations.legacy.js +332 -0
  66. package/dist/elt/storage/sql.d.ts +2 -0
  67. package/dist/elt/storage/sql.js +7 -0
  68. package/dist/elt/storage/write-lock.d.ts +6 -0
  69. package/dist/elt/storage/write-lock.js +28 -0
  70. package/dist/elt/streams.d.ts +24 -0
  71. package/dist/elt/streams.js +62 -0
  72. package/dist/interfaces/cli/commands.d.ts +2 -0
  73. package/dist/interfaces/cli/commands.js +101 -0
  74. package/dist/interfaces/cli/main.d.ts +2 -0
  75. package/dist/interfaces/cli/main.js +3 -0
  76. package/dist/interfaces/http/cli-options.d.ts +9 -0
  77. package/dist/interfaces/http/cli-options.js +81 -0
  78. package/dist/interfaces/http/server.d.ts +14 -0
  79. package/dist/interfaces/http/server.js +169 -0
  80. package/dist/interfaces/mcp/cli-options.d.ts +6 -0
  81. package/dist/interfaces/mcp/cli-options.js +36 -0
  82. package/dist/interfaces/mcp/main.d.ts +2 -0
  83. package/dist/interfaces/mcp/main.js +59 -0
  84. package/dist/interfaces/mcp/server.d.ts +8 -0
  85. package/dist/interfaces/mcp/server.js +521 -0
  86. package/dist/runtime/index.d.ts +29 -0
  87. package/dist/runtime/index.js +19 -0
  88. package/docs/architecture.md +100 -0
  89. package/docs/assets/image.png +0 -0
  90. package/docs/mcp-activity-retrieval.md +377 -0
  91. package/package.json +81 -6
  92. package/pyproject.toml +28 -0
  93. package/python/__init__.py +1 -0
  94. package/python/catence/__init__.py +0 -0
  95. package/python/catence/providers/__init__.py +0 -0
  96. package/python/catence/providers/garmin/__init__.py +3 -0
  97. package/python/catence/providers/garmin/cli.py +62 -0
  98. package/python/catence/providers/garmin/contracts.py +23 -0
  99. package/python/catence/providers/garmin/registry.py +48 -0
  100. package/python/catence/providers/garmin/staging.py +87 -0
  101. package/python/catence/providers/garmin/streams.py +195 -0
  102. package/python/catence/providers/garmin/worker.py +450 -0
  103. package/python/catence/providers/strava/__init__.py +3 -0
  104. package/python/catence/providers/strava/cli.py +404 -0
  105. package/python/catence/providers/strava/staging.py +73 -0
  106. package/uv.lock +708 -0
@@ -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;
@@ -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 {};