catence 0.2.0-beta.8 → 0.2.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 (76) hide show
  1. package/README.md +38 -166
  2. package/config.example.json +5 -1
  3. package/dist/contracts/index.d.ts +1 -0
  4. package/dist/contracts/index.js +1 -0
  5. package/dist/contracts/progress.d.ts +23 -0
  6. package/dist/contracts/progress.js +49 -0
  7. package/dist/contracts/release.d.ts +1 -1
  8. package/dist/contracts/release.js +1 -1
  9. package/dist/core/logging.d.ts +16 -0
  10. package/dist/core/logging.js +68 -0
  11. package/dist/core/query/activity-discovery.d.ts +2 -0
  12. package/dist/core/query/activity-discovery.js +1 -1
  13. package/dist/core/query/analytics.d.ts +10 -0
  14. package/dist/core/query/analytics.js +494 -0
  15. package/dist/core/query/catalog.d.ts +1 -1
  16. package/dist/core/query/catalog.js +8 -4
  17. package/dist/core/query/events.d.ts +34 -0
  18. package/dist/core/query/events.js +121 -0
  19. package/dist/core/query/fitness.d.ts +4 -0
  20. package/dist/core/query/fitness.js +60 -0
  21. package/dist/core/query/repository.d.ts +2 -0
  22. package/dist/core/query/repository.js +48 -0
  23. package/dist/core/query/swimming.js +2 -2
  24. package/dist/core/runtime/configuration.d.ts +3 -1
  25. package/dist/core/runtime/configuration.js +5 -1
  26. package/dist/core/runtime/opencode-go.d.ts +58 -0
  27. package/dist/core/runtime/opencode-go.js +246 -0
  28. package/dist/core/runtime/secrets.js +51 -13
  29. package/dist/elt/application/detached-sync.d.ts +50 -0
  30. package/dist/elt/application/detached-sync.js +99 -0
  31. package/dist/elt/application/management.d.ts +15 -0
  32. package/dist/elt/application/management.js +435 -19
  33. package/dist/elt/ingestion/importer.d.ts +3 -2
  34. package/dist/elt/ingestion/importer.js +5 -4
  35. package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
  36. package/dist/elt/ingestion/providers/intervals.js +37 -2
  37. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  38. package/dist/elt/ingestion/providers/registry.js +19 -1
  39. package/dist/elt/ingestion/providers/strava/service.js +7 -1
  40. package/dist/elt/normalization/normalizers.js +207 -15
  41. package/dist/elt/storage/database.d.ts +21 -2
  42. package/dist/elt/storage/database.js +184 -6
  43. package/dist/elt/storage/migrations.js +145 -1
  44. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  45. package/dist/elt/storage/progress-sidecar.js +200 -0
  46. package/dist/interfaces/cli/commands.js +94 -1
  47. package/dist/interfaces/http/generation-status.d.ts +19 -0
  48. package/dist/interfaces/http/generation-status.js +55 -0
  49. package/dist/interfaces/http/server.d.ts +7 -1
  50. package/dist/interfaces/http/server.js +184 -1
  51. package/dist/interfaces/mcp/main.js +33 -1
  52. package/dist/interfaces/mcp/server.d.ts +2 -1
  53. package/dist/interfaces/mcp/server.js +168 -45
  54. package/dist/runtime/index.d.ts +9 -2
  55. package/dist/runtime/index.js +6 -2
  56. package/docs/architecture.md +13 -2
  57. package/docs/assets/image.png +0 -0
  58. package/docs/configuration.md +243 -0
  59. package/docs/deployment/docker.md +528 -0
  60. package/docs/deployment/local-console.md +263 -0
  61. package/docs/deployment/local-mcp.md +329 -0
  62. package/docs/development.md +337 -0
  63. package/docs/distribution.md +45 -0
  64. package/docs/llm-providers.md +66 -1
  65. package/docs/mcp-activity-retrieval.md +90 -210
  66. package/package.json +1 -1
  67. package/pyproject.toml +1 -1
  68. package/python/catence/providers/garmin/cli.py +43 -9
  69. package/python/catence/providers/garmin/progress.py +85 -0
  70. package/python/catence/providers/garmin/registry.py +2 -0
  71. package/python/catence/providers/garmin/streams.py +38 -0
  72. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  73. package/python/catence/providers/garmin/worker.py +415 -12
  74. package/scripts/discover-opencode-go.mjs +36 -144
  75. package/uv.lock +3 -3
  76. package/docs/deployment.md +0 -338
@@ -2,7 +2,7 @@ import { IntervalsClient } from 'intervals-icu';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { stableJsonHash, storeArtifact, storeJsonArtifact } from '../../storage/artifacts.js';
4
4
  import { importRecord, queueWorkItem, completeWorkItem, failWorkItem } from '../importer.js';
5
- import { intervalsActivityReadEndpoints, intervalsSecondaryReadRegistry, assertReadOnlyRegistry } from './registry.js';
5
+ import { intervalsActivityReadEndpoints, intervalsSecondaryReadRegistry, intervalsEventSyncWindow, assertReadOnlyRegistry } from './registry.js';
6
6
  import { intervalsStreamsToSamples, writeParquetSamples } from '../../streams.js';
7
7
  const apiBaseUrl = 'https://intervals.icu/api/v1';
8
8
  function asObject(value) {
@@ -42,12 +42,14 @@ export class IntervalsExtractor {
42
42
  paths;
43
43
  apiKey;
44
44
  athleteId;
45
+ log;
45
46
  client;
46
- constructor(database, paths, apiKey, athleteId) {
47
+ constructor(database, paths, apiKey, athleteId, log) {
47
48
  this.database = database;
48
49
  this.paths = paths;
49
50
  this.apiKey = apiKey;
50
51
  this.athleteId = athleteId;
52
+ this.log = log;
51
53
  // The typed client is used for provider-compatible authentication and the
52
54
  // primary identity request. Manifest-only calls below intentionally use
53
55
  // the same read-only credentials to cover endpoint families not wrapped by it.
@@ -65,6 +67,21 @@ export class IntervalsExtractor {
65
67
  for (const endpoint of intervalsSecondaryReadRegistry) {
66
68
  if (endpoint.name === 'athlete')
67
69
  continue;
70
+ // Calendar events straddle today (past for activity association,
71
+ // future for planning); they must not inherit the cursor-bounded
72
+ // activity window, which always ends at today.
73
+ if (endpoint.name === 'events') {
74
+ const window = await this.resolveEventsSyncWindow();
75
+ const payload = await this.capture(runId, 'events', null, window, async () => this.getCollection('events', resolvedAthleteId, window.fromDate, window.toDate));
76
+ if (payload === undefined)
77
+ continue;
78
+ await this.importEntities('event', 'events', payload, null, runId);
79
+ // The listing is authoritative: events deleted upstream must not
80
+ // linger as ghosts in the store once this sync succeeded.
81
+ const seenIds = asItems(payload).map((item) => idOf(item, '')).filter(Boolean);
82
+ await this.database.deleteMissingCalendarEntities('intervals', ['event'], seenIds, window.fromDate);
83
+ continue;
84
+ }
68
85
  if (!activityWindow)
69
86
  continue;
70
87
  const { fromDate, toDate } = activityWindow;
@@ -84,6 +101,23 @@ export class IntervalsExtractor {
84
101
  }
85
102
  }
86
103
  }
104
+ /**
105
+ * Regular syncs reach back EVENT_WINDOW_PAST_DAYS — just enough to
106
+ * associate recently finished events with their activities. A store whose
107
+ * own activity history predates that span gets an automatic one-time
108
+ * coverage backfill instead: the window stretches to the earliest
109
+ * activity until an event older than the short window lands (self-
110
+ * clearing), after which deeper history is the backfill command's job.
111
+ */
112
+ async resolveEventsSyncWindow() {
113
+ const short = intervalsEventSyncWindow();
114
+ if (await this.database.hasCalendarEventOlderThan('intervals', short.fromDate))
115
+ return short;
116
+ const earliest = await this.database.earliestActivityStartDate('intervals');
117
+ if (!earliest || earliest >= short.fromDate)
118
+ return short;
119
+ return { fromDate: earliest, toDate: short.toDate };
120
+ }
87
121
  async captureCollectionChildren(runId, collection, payload, athleteId) {
88
122
  const children = {
89
123
  events: [{ suffix: (id) => `/athlete/${athleteId}/events/${id}`, entityType: 'event' }],
@@ -189,6 +223,7 @@ export class IntervalsExtractor {
189
223
  catch (error) {
190
224
  const message = error instanceof Error ? error.message : String(error);
191
225
  await failWorkItem(this.database, workId, message);
226
+ this.log.warn('Endpoint capture failed', { runId, provider: 'intervals', endpoint, remoteId, error: message });
192
227
  await this.database.addError(runId, 'intervals', endpoint, remoteId, message, true);
193
228
  return undefined;
194
229
  }
@@ -11,6 +11,19 @@ export declare const intervalsReadRegistry: readonly EndpointSpec[];
11
11
  * Garmin's activity detail, files, streams, or account data.
12
12
  */
13
13
  export declare const intervalsSecondaryReadRegistry: EndpointSpec[];
14
+ /** How far back and ahead of today the planned-events fetch reaches. */
15
+ /** Regular syncs look back a month for activity association; deeper history arrives via the automatic coverage backfill. */
16
+ export declare const EVENT_WINDOW_PAST_DAYS = 30;
17
+ export declare const EVENT_WINDOW_FUTURE_DAYS = 365;
18
+ /**
19
+ * Deliberately cursor-free: activities chase a past-only incremental window,
20
+ * while calendar events must straddle today so upcoming races and recently
21
+ * completed ones (associatable with their finished activities) both arrive.
22
+ */
23
+ export declare function intervalsEventSyncWindow(now?: Date): {
24
+ fromDate: string;
25
+ toDate: string;
26
+ };
14
27
  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
28
  /** Read-only methods exposed by python-garminconnect, grouped for manifest tests and worker dispatch. */
16
29
  export declare const garminReadRegistry: readonly EndpointSpec[];
@@ -21,7 +21,23 @@ export const intervalsReadRegistry = registry('intervals', [
21
21
  * supplies the separately named training calculations without duplicating
22
22
  * Garmin's activity detail, files, streams, or account data.
23
23
  */
24
- export const intervalsSecondaryReadRegistry = intervalsReadRegistry.filter((endpoint) => endpoint.name === 'athlete' || endpoint.name === 'activities');
24
+ export const intervalsSecondaryReadRegistry = intervalsReadRegistry.filter((endpoint) => endpoint.name === 'athlete' || endpoint.name === 'activities' || endpoint.name === 'events');
25
+ /** How far back and ahead of today the planned-events fetch reaches. */
26
+ /** Regular syncs look back a month for activity association; deeper history arrives via the automatic coverage backfill. */
27
+ export const EVENT_WINDOW_PAST_DAYS = 30;
28
+ export const EVENT_WINDOW_FUTURE_DAYS = 365;
29
+ /**
30
+ * Deliberately cursor-free: activities chase a past-only incremental window,
31
+ * while calendar events must straddle today so upcoming races and recently
32
+ * completed ones (associatable with their finished activities) both arrive.
33
+ */
34
+ export function intervalsEventSyncWindow(now = new Date()) {
35
+ const dayMs = 86_400_000;
36
+ return {
37
+ fromDate: new Date(now.getTime() - EVENT_WINDOW_PAST_DAYS * dayMs).toISOString().slice(0, 10),
38
+ toDate: new Date(now.getTime() + EVENT_WINDOW_FUTURE_DAYS * dayMs).toISOString().slice(0, 10),
39
+ };
40
+ }
25
41
  export const intervalsActivityReadEndpoints = [
26
42
  'activity', 'streams', 'intervals', 'map', 'weather', 'weather_summary', 'best_efforts', 'power_curve', 'pace_curve', 'hr_curve',
27
43
  'power_vs_hr', 'hr_load_model', 'segments', 'messages', 'original_file', 'fit_file', 'gpx_file',
@@ -43,6 +59,8 @@ export const garminReadRegistry = registry('garmin', [
43
59
  ['activity_power_zones', 'activity', 'activity_zone'], ['activity_exercise_sets', 'activity', 'activity_exercise_set'], ['activity_gear', 'activity', 'activity_gear'],
44
60
  ['activity_original', 'activity', 'activity_file'], ['activity_tcx', 'activity', 'activity_file'], ['activity_gpx', 'activity', 'activity_file'], ['activity_kml', 'activity', 'activity_file'],
45
61
  ['activity_csv', 'activity', 'activity_file'], ['workouts', 'collection', 'workout'], ['scheduled_workouts', 'date_range', 'scheduled_workout'],
62
+ ['events', 'collection', 'event'],
63
+ ['course', 'child', 'course'],
46
64
  ['training_plans', 'collection', 'training_plan'], ['goals', 'collection', 'goal'], ['personal_records', 'singleton', 'personal_record'],
47
65
  ['earned_badges', 'collection', 'badge'], ['available_badges', 'collection', 'badge'], ['challenges', 'collection', 'challenge'],
48
66
  ['gear', 'collection', 'gear'], ['golf_summary', 'collection', 'golf_scorecard'], ['golf_user_stats', 'singleton', 'golf_user_stat'],
@@ -8,6 +8,7 @@ import { ensurePaths, loadCatenceConfig } from '../../../../core/runtime/configu
8
8
  import { athleteProviderEnvironment } from '../../../../core/runtime/secrets.js';
9
9
  import { CatenceDatabase, openReadOnlyRepository } from '../../../storage/database.js';
10
10
  import { importJsonl } from '../../importer.js';
11
+ import { createSyncLogger } from '../../../../core/logging.js';
11
12
  import { withDataWriteLock } from '../../../storage/write-lock.js';
12
13
  const execFileAsync = promisify(execFile);
13
14
  export class StravaRateLimitError extends Error {
@@ -108,6 +109,7 @@ async function cachedState(database, resourceType, remoteId) {
108
109
  async function runReadOperation(paths, operation, extraArgs, resource, refresh = false) {
109
110
  return withDataWriteLock(paths, async () => {
110
111
  await ensurePaths(paths);
112
+ const log = createSyncLogger(paths);
111
113
  const database = await CatenceDatabase.open(paths);
112
114
  try {
113
115
  if (!refresh) {
@@ -117,6 +119,7 @@ async function runReadOperation(paths, operation, extraArgs, resource, refresh =
117
119
  }
118
120
  await checkBudget(database, paths);
119
121
  const runId = await database.beginRun('strava', new Date().toISOString().slice(0, 10));
122
+ log.info('Starting Strava read operation', { runId, operation, resource: resource.id });
120
123
  const directory = path.join(paths.staging, 'strava');
121
124
  const stagingPath = path.join(directory, `${runId}.jsonl`);
122
125
  const resultPath = path.join(directory, `${runId}.result.json`);
@@ -124,17 +127,20 @@ async function runReadOperation(paths, operation, extraArgs, resource, refresh =
124
127
  try {
125
128
  result = await invokeWorker(paths, ['--mode', operation, '--run-id', runId, '--output', stagingPath, ...extraArgs], resultPath);
126
129
  if (existsSync(stagingPath))
127
- await importJsonl(database, runId, stagingPath);
130
+ await importJsonl(database, runId, stagingPath, log);
128
131
  await storeRateState(database, result);
129
132
  await writeState(database, resource.type, resource.id, result);
130
133
  }
131
134
  catch (error) {
132
135
  const message = error instanceof Error ? error.message : String(error);
136
+ log.error('Strava read operation failed', { runId, operation, resource: resource.id, error: message, stack: error instanceof Error ? error.stack : undefined });
133
137
  await database.addError(runId, 'strava', operation, resource.id, message, true);
134
138
  throw error;
135
139
  }
136
140
  finally {
137
141
  await database.finishRun(runId);
142
+ const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
143
+ log.info('Strava read operation finished', { runId, operation, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
138
144
  }
139
145
  if (result.status === 'error')
140
146
  throw new StravaEnrichmentError(result.message ?? 'Strava did not complete the requested enrichment.');
@@ -63,7 +63,8 @@ function swimSourceLabel(payload) {
63
63
  function datePart(value) {
64
64
  if (!value)
65
65
  return null;
66
- const match = value.match(/^\d{4}-\d{2}-\d{2}/);
66
+ const s = typeof value === 'string' ? value : value.toISOString();
67
+ const match = s.match(/^\d{4}-\d{2}-\d{2}/);
67
68
  return match?.[0] ?? null;
68
69
  }
69
70
  function providerActivityId(provider, remoteId) {
@@ -143,6 +144,8 @@ function snake(value) {
143
144
  return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
144
145
  }
145
146
  function timestamp(value) {
147
+ if (value instanceof Date)
148
+ return value.toISOString();
146
149
  if (typeof value === 'string' && value.length >= 10)
147
150
  return value;
148
151
  if (typeof value !== 'number' || !Number.isFinite(value))
@@ -163,30 +166,69 @@ async function upsertDailyMetric(database, provider, metricDate, metricName, val
163
166
  ON CONFLICT (provider, metric_date, metric_name) DO UPDATE SET value_number = excluded.value_number, value_text = excluded.value_text,
164
167
  unit = excluded.unit, raw_object_hash = excluded.raw_object_hash`, { provider, metricDate, metricName, valueNumber, valueText, unit, rawHash });
165
168
  }
169
+ /**
170
+ * Garmin's nutrition food log keeps its actuals under dailyNutritionContent
171
+ * (a per-day rolled-up object), while meal definitions and hydration endpoints
172
+ * share the same source entity type. Flat keys are kept for providers that
173
+ * return them directly.
174
+ */
166
175
  function nutritionFields(payload) {
176
+ const content = object(payload.dailyNutritionContent);
167
177
  return {
168
- energyKcal: firstNumber(payload, ['kcalConsumed', 'calories', 'totalCalories', 'energyKcal']),
169
- carbohydratesG: firstNumber(payload, ['carbohydrates', 'carbs', 'totalCarbs', 'carbohydrateGrams']),
170
- proteinG: firstNumber(payload, ['protein', 'totalProtein', 'proteinGrams']),
171
- fatG: firstNumber(payload, ['fatTotal', 'fat', 'totalFat', 'fatGrams']),
172
- hydrationMl: firstNumber(payload, ['hydrationVolume', 'hydration', 'waterMl', 'totalWater']),
178
+ energyKcal: firstNumber(payload, ['kcalConsumed', 'calories', 'totalCalories', 'energyKcal']) ?? firstNumber(content, ['calories', 'energyKcal', 'totalCalories']),
179
+ carbohydratesG: firstNumber(payload, ['carbohydrates', 'carbs', 'totalCarbs', 'carbohydrateGrams']) ?? firstNumber(content, ['carbohydrates', 'carbs']),
180
+ proteinG: firstNumber(payload, ['protein', 'totalProtein', 'proteinGrams']) ?? firstNumber(content, ['protein']),
181
+ fatG: firstNumber(payload, ['fatTotal', 'fat', 'totalFat', 'fatGrams']) ?? firstNumber(content, ['fat', 'fatTotal']),
182
+ hydrationMl: firstNumber(payload, ['hydrationVolume', 'hydration', 'waterMl', 'totalWater', 'valueInML', 'dailyAverageinML']),
173
183
  };
174
184
  }
185
+ /**
186
+ * Garmin food-log items live under mealDetails[].foods[]. Each food carries the
187
+ * day's nutrition_date, so a meal label is folded onto the item to avoid losing
188
+ * it. Flat arrays are kept for providers that return items directly.
189
+ */
175
190
  function nutritionItems(payload) {
176
- const candidates = ['foodItems', 'items', 'entries', 'foodLogEntries', 'meals'];
177
- for (const key of candidates) {
191
+ const items = [];
192
+ const mealDetails = payload.mealDetails;
193
+ if (Array.isArray(mealDetails)) {
194
+ for (const detail of mealDetails) {
195
+ const mealDetail = object(detail);
196
+ const mealName = firstString(object(mealDetail.meal), ['mealName']) ?? firstString(mealDetail, ['mealName']);
197
+ const foods = mealDetail.foods;
198
+ if (Array.isArray(foods)) {
199
+ for (const food of foods) {
200
+ const item = object(food);
201
+ if (Object.keys(item).length === 0)
202
+ continue;
203
+ items.push(mealName ? { ...item, mealName } : item);
204
+ }
205
+ }
206
+ else if (Object.keys(mealDetail).length > 0 && firstString(mealDetail, ['foodName', 'name', 'description']) !== null) {
207
+ items.push(mealName ? { ...mealDetail, mealName } : mealDetail);
208
+ }
209
+ }
210
+ if (items.length > 0)
211
+ return items;
212
+ }
213
+ for (const key of ['foodItems', 'items', 'entries', 'foodLogEntries', 'meals']) {
178
214
  const value = payload[key];
179
215
  if (Array.isArray(value))
180
216
  return value.map(object).filter((item) => Object.keys(item).length > 0);
181
217
  }
182
218
  return [];
183
219
  }
220
+ /** True when a payload contributes real nutrition facts rather than meal definitions or hydration-only bookkeeping. */
221
+ function hasNutritionContent(payload) {
222
+ const fields = nutritionFields(payload);
223
+ return fields.energyKcal !== null || fields.carbohydratesG !== null || fields.proteinG !== null || fields.fatG !== null
224
+ || fields.hydrationMl !== null || nutritionItems(payload).length > 0;
225
+ }
184
226
  export async function importSourceEntity(database, entity) {
185
227
  const payload = object(entity.payload);
186
228
  const rawHash = entity.rawObjectHash;
187
229
  await database.run(`INSERT INTO source_entities
188
230
  (provider, entity_type, remote_id, parent_remote_id, occurred_on, source_updated_at, raw_object_hash, payload_json, extension_json)
189
- VALUES ($provider, $entityType, $remoteId, $parentRemoteId, $occurredOn, $sourceUpdatedAt, $rawHash, $payload, $extension)
231
+ VALUES ($provider::VARCHAR, $entityType::VARCHAR, $remoteId::VARCHAR, $parentRemoteId::VARCHAR, $occurredOn::TIMESTAMPTZ, $sourceUpdatedAt::TIMESTAMPTZ, $rawHash::VARCHAR, $payload::JSON, $extension::JSON)
190
232
  ON CONFLICT (provider, entity_type, remote_id) DO UPDATE SET
191
233
  parent_remote_id = excluded.parent_remote_id, occurred_on = excluded.occurred_on,
192
234
  source_updated_at = excluded.source_updated_at, raw_object_hash = excluded.raw_object_hash,
@@ -197,7 +239,7 @@ export async function importSourceEntity(database, entity) {
197
239
  });
198
240
  await database.run(`INSERT INTO domain_entities
199
241
  (provider, entity_type, remote_id, parent_remote_id, occurred_on, payload_json, extension_json, raw_object_hash)
200
- VALUES ($provider, $entityType, $remoteId, $parentRemoteId, $occurredOn, $payload, $extension, $rawHash)
242
+ VALUES ($provider::VARCHAR, $entityType::VARCHAR, $remoteId::VARCHAR, $parentRemoteId::VARCHAR, $occurredOn::TIMESTAMPTZ, $payload::JSON, $extension::JSON, $rawHash::VARCHAR)
201
243
  ON CONFLICT (provider, entity_type, remote_id) DO UPDATE SET
202
244
  parent_remote_id = excluded.parent_remote_id, occurred_on = excluded.occurred_on,
203
245
  payload_json = excluded.payload_json, extension_json = excluded.extension_json, raw_object_hash = excluded.raw_object_hash`, {
@@ -211,6 +253,7 @@ export async function importSourceEntity(database, entity) {
211
253
  }
212
254
  if (entity.entityType === 'activity') {
213
255
  await importActivity(database, entity.provider, entity.remoteId, payload, rawHash);
256
+ await importActivityDecoupling(database, entity.provider, entity.remoteId, payload, rawHash);
214
257
  if (entity.provider === 'intervals')
215
258
  await importIntervalsAutoSwimSets(database, entity.remoteId, payload, rawHash);
216
259
  if (entity.provider === 'garmin') {
@@ -223,6 +266,9 @@ export async function importSourceEntity(database, entity) {
223
266
  if (entity.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set'].includes(entity.entityType)) {
224
267
  await importGarminExplicitSwimLengths(database, entity.parentRemoteId, entity.entityType, payload, rawHash);
225
268
  }
269
+ if (entity.provider === 'garmin' && entity.entityType === 'activity_swim_lengths') {
270
+ await importGarminFitSwimLengths(database, entity.parentRemoteId, payload, rawHash);
271
+ }
226
272
  if (entity.entityType === 'wellness' || entity.entityType === 'daily_health')
227
273
  await importDailyMetrics(database, entity.provider, entity.remoteId, entity.occurredOn, payload, rawHash);
228
274
  if (entity.entityType === 'nutrition_day' || entity.entityType === 'nutrition_log')
@@ -245,6 +291,8 @@ export async function importSourceEntity(database, entity) {
245
291
  await importHealthSession(database, entity.remoteId, entity.occurredOn, 'daily_event', payload, rawHash);
246
292
  if (entity.provider === 'garmin' && entity.entityType === 'activity_power_best')
247
293
  await importActivityPowerBest(database, entity.parentRemoteId, payload, rawHash);
294
+ if (entity.provider === 'garmin' && entity.entityType === 'course')
295
+ await importGarminCourse(database, entity.remoteId, payload, rawHash);
248
296
  }
249
297
  async function importActivity(database, provider, remoteId, payload, rawHash) {
250
298
  const fields = activityFields(provider, payload);
@@ -372,7 +420,7 @@ function nested(payload, key) {
372
420
  return object(payload[key]);
373
421
  }
374
422
  function metricObservedAt(occurredOn, payload) {
375
- return firstTimestamp(payload, ['timestamp', 'timestampLocal', 'calendarDate', 'date']) ?? (occurredOn ? `${occurredOn}T00:00:00Z` : null);
423
+ return firstTimestamp(payload, ['timestamp', 'timestampLocal', 'calendarDate', 'date']) ?? (occurredOn ? `${datePart(occurredOn)}T00:00:00Z` : null);
376
424
  }
377
425
  async function importGarminActivityPerformance(database, remoteId, payload, rawHash) {
378
426
  const fields = activityFields('garmin', payload);
@@ -474,6 +522,99 @@ async function importGarminNumericSeries(database, entityType, remoteId, occurre
474
522
  });
475
523
  }
476
524
  }
525
+ const DECOUPLING_METRICS = [
526
+ { metric: 'aerobic_decoupling_pct', unit: 'pct', keys: ['decoupling', 'aerobicDecoupling', 'icu_decoupling', 'paHrDecoupling', 'pwHrDecoupling', 'aerobic_decoupling'] },
527
+ { metric: 'grade_adjusted_pace_s_per_km', unit: 's_per_km', keys: ['gap', 'gradeAdjustedPace', 'icu_gap', 'grade_adjusted_pace'] },
528
+ ];
529
+ /** Containers searched for provider-supplied decoupling/GAP: the payload itself,
530
+ * its common nested analysis/metrics objects, and the stored metrics_json. */
531
+ function decouplingContainers(payload) {
532
+ const containers = [payload];
533
+ for (const key of ['analysis', 'data', 'metrics', 'icuAnalysis', 'summaries']) {
534
+ const nested = payload[key];
535
+ if (nested !== null && typeof nested === 'object' && !Array.isArray(nested))
536
+ containers.push(nested);
537
+ }
538
+ const metricsJson = payload.metrics_json;
539
+ if (metricsJson !== undefined && metricsJson !== null)
540
+ containers.push(objectFromJson(metricsJson));
541
+ return containers;
542
+ }
543
+ /** A3 (persistence): persist provider-supplied aerobic decoupling (Pa:Hr) and
544
+ * grade-adjusted pace from an activity payload so they are queryable historically
545
+ * instead of only inside one activity's raw metrics_json. Provider values are
546
+ * stored as source_type 'provider' and are authoritative. Locally derived values
547
+ * (deriveDecoding/deriveGap) are a separate, still-derived path. */
548
+ async function importActivityDecoupling(database, provider, remoteId, payload, rawHash) {
549
+ const activitySourceId = providerActivityId(provider, remoteId);
550
+ const fields = activityFields(provider, payload);
551
+ const containers = decouplingContainers(payload);
552
+ for (const spec of DECOUPLING_METRICS) {
553
+ let value = null;
554
+ for (const container of containers) {
555
+ const found = firstNumber(container, spec.keys);
556
+ if (found !== null) {
557
+ value = found;
558
+ break;
559
+ }
560
+ }
561
+ if (value === null)
562
+ continue;
563
+ await database.run(`INSERT INTO activity_decoupling_facts (provider, activity_source_id, sport, started_at_utc, metric, value_number, unit, source_type, caveats_json, raw_object_hash)
564
+ VALUES ($provider, $activitySourceId, $sport, try_cast($startedAtUtc AS TIMESTAMPTZ), $metric, $value, $unit, 'provider', $caveats, $rawHash)
565
+ ON CONFLICT (provider, activity_source_id, metric) DO UPDATE SET
566
+ sport = excluded.sport, started_at_utc = excluded.started_at_utc, value_number = excluded.value_number,
567
+ unit = excluded.unit, source_type = excluded.source_type, caveats_json = excluded.caveats_json, raw_object_hash = excluded.raw_object_hash`, { provider, activitySourceId, sport: fields.sport, startedAtUtc: fields.startAtUtc, metric: spec.metric, value, unit: spec.unit, caveats: json({}), rawHash });
568
+ }
569
+ }
570
+ /** Pull a course's ordered geometry samples, tolerating the several shapes a
571
+ * Garmin course payload may use (a flat ``geoPoints`` array on the live course
572
+ * endpoint, legacy ``coursePoints``, or points nested under a geo/route object).
573
+ * Returns an empty list when no geometry is present. */
574
+ function courseGeometryPoints(payload) {
575
+ const candidates = [payload.geoPoints, payload.coursePoints, payload.geo, payload.points, payload.geometry, payload.locations, payload.geoRoute];
576
+ for (const candidate of candidates) {
577
+ if (Array.isArray(candidate))
578
+ return objectArray(candidate);
579
+ if (candidate !== null && typeof candidate === 'object' && !Array.isArray(candidate)) {
580
+ const obj = candidate;
581
+ for (const key of ['coursePoints', 'points', 'locations', 'coursePoint']) {
582
+ if (Array.isArray(obj[key]))
583
+ return objectArray(obj[key]);
584
+ }
585
+ }
586
+ }
587
+ return [];
588
+ }
589
+ /** A4: normalize Garmin course geometry into course_geometry so a race's
590
+ * elevation/grade profile can be resolved and diffed. The raw payload is also
591
+ * stored by importSourceEntity, so this only fans the points into rows. Live
592
+ * Garmin courses carry a ``geoPoints`` array (one row per sample); inserts are
593
+ * batched because a full course is roughly two thousand points. */
594
+ async function importGarminCourse(database, remoteId, payload, rawHash) {
595
+ const points = courseGeometryPoints(payload);
596
+ const CHUNK = 500;
597
+ for (let offset = 0; offset < points.length; offset += CHUNK) {
598
+ const chunk = points.slice(offset, offset + CHUNK);
599
+ const values = { courseId: remoteId, rawHash };
600
+ const rows = [];
601
+ for (let i = 0; i < chunk.length; i++) {
602
+ const point = chunk[i];
603
+ const seq = offset + i;
604
+ values[`seq${i}`] = seq;
605
+ values[`lat${i}`] = firstNumber(point, ['latitude', 'lat']);
606
+ values[`lon${i}`] = firstNumber(point, ['longitude', 'lon', 'lng']);
607
+ values[`alt${i}`] = firstNumber(point, ['elevation', 'altitude', 'altitudeMeters', 'ele']);
608
+ values[`dist${i}`] = firstNumber(point, ['distance', 'distanceMeters', 'distance_m']);
609
+ rows.push(`($courseId, $seq${i}, $lat${i}, $lon${i}, $alt${i}, $dist${i}, $rawHash)`);
610
+ }
611
+ await database.run(`INSERT INTO course_geometry (course_id, seq, lat, lon, altitude_m, distance_m, raw_object_hash)
612
+ VALUES ${rows.join(', ')}
613
+ ON CONFLICT (course_id, seq) DO UPDATE SET
614
+ lat = excluded.lat, lon = excluded.lon, altitude_m = excluded.altitude_m,
615
+ distance_m = excluded.distance_m, raw_object_hash = excluded.raw_object_hash`, values);
616
+ }
617
+ }
477
618
  async function importGarminTrainingStatus(database, remoteId, occurredOn, payload, rawHash) {
478
619
  const statusMap = nested(nested(payload, 'mostRecentTrainingStatus'), 'latestTrainingStatusData');
479
620
  const balanceMap = nested(nested(payload, 'mostRecentTrainingLoadBalance'), 'metricsTrainingLoadBalanceDTOMap');
@@ -668,6 +809,46 @@ async function importGarminExplicitSwimLengths(database, parentRemoteId, entityT
668
809
  }
669
810
  }
670
811
  }
812
+ async function importGarminFitSwimLengths(database, parentRemoteId, payload, rawHash) {
813
+ if (!parentRemoteId)
814
+ return;
815
+ const activitySourceId = providerActivityId('garmin', parentRemoteId);
816
+ const lengths = objectArray(payload.lengths);
817
+ if (lengths.length === 0)
818
+ return;
819
+ const source = 'garmin_fit_length';
820
+ await database.run('DELETE FROM swim_lengths WHERE activity_source_id = $activitySourceId AND source = $source', { activitySourceId, source });
821
+ const summaryPoolLengthM = await activityPoolLengthM(database, activitySourceId);
822
+ for (const [offset, length] of lengths.entries()) {
823
+ const durationS = firstNumber(length, ['durationS', 'duration', 'durationSeconds', 'elapsedDuration']);
824
+ if (durationS === null)
825
+ continue;
826
+ const sourceLabel = swimSourceLabel(length);
827
+ const isRest = firstBoolean(length, ['isRest', 'rest']) ?? Boolean(sourceLabel && /rest|idle|pause/.test(sourceLabel));
828
+ const lengthIndex = Math.round(firstNumber(length, ['lengthIndex', 'messageIndex', 'lengthNumber', 'index']) ?? offset);
829
+ await database.run(`INSERT INTO swim_lengths
830
+ (activity_source_id, source, length_index, lap_index, pool_length_m, start_time, duration_s, active_duration_s, distance_m, stroke_count, stroke_rate, swolf, avg_hr, max_hr, is_rest, confidence, metrics_json, raw_object_hash)
831
+ VALUES ($activitySourceId, $source, $lengthIndex, $lapIndex, $poolLengthM, $startTime, $durationS, $activeDurationS, $distanceM, $strokeCount, $strokeRate, $swolf, $avgHr, $maxHr, $isRest, 'provider_supplied', $metrics, $rawHash)
832
+ ON CONFLICT (activity_source_id, source, length_index) DO UPDATE SET
833
+ lap_index = excluded.lap_index, pool_length_m = excluded.pool_length_m, start_time = excluded.start_time,
834
+ duration_s = excluded.duration_s, active_duration_s = excluded.active_duration_s, distance_m = excluded.distance_m,
835
+ stroke_count = excluded.stroke_count, stroke_rate = excluded.stroke_rate, swolf = excluded.swolf,
836
+ avg_hr = excluded.avg_hr, max_hr = excluded.max_hr, is_rest = excluded.is_rest,
837
+ confidence = excluded.confidence, metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, {
838
+ activitySourceId, source, lengthIndex,
839
+ lapIndex: firstNumber(length, ['lapIndex', 'lapNumber']), poolLengthM: garminPoolLengthM(length) ?? summaryPoolLengthM,
840
+ startTime: timestamp(length.startTime ?? length.startTimestamp ?? length.timestamp), durationS,
841
+ activeDurationS: firstNumber(length, ['activeDurationS', 'activeDuration', 'movingDuration']),
842
+ distanceM: firstNumber(length, ['distanceM', 'distance']),
843
+ strokeCount: firstNumber(length, ['strokeCount', 'strokes', 'total_strokes']),
844
+ strokeRate: firstNumber(length, ['strokeRate', 'averageStrokeRate', 'avg_swimming_cadence', 'avg_cadence', 'cadence']),
845
+ swolf: firstNumber(length, ['swolf', 'avg_swolf']),
846
+ avgHr: firstNumber(length, ['avgHr', 'averageHR', 'avg_heart_rate']),
847
+ maxHr: firstNumber(length, ['maxHr', 'maxHR', 'max_heart_rate']),
848
+ isRest, metrics: json(length), rawHash,
849
+ });
850
+ }
851
+ }
671
852
  async function addActivityQualityFlag(database, activitySourceId, code, severity, details, rawHash) {
672
853
  await database.run(`INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
673
854
  VALUES ($activitySourceId, $code, $severity, $details, $rawHash)
@@ -791,18 +972,29 @@ async function importNutrition(database, provider, date, remoteId, payload, rawH
791
972
  const nutritionDate = datePart(date) ?? datePart(firstString(payload, ['id', 'date', 'calendarDate'])) ?? null;
792
973
  if (!nutritionDate)
793
974
  return;
975
+ if (!hasNutritionContent(payload))
976
+ return;
794
977
  const fields = nutritionFields(payload);
795
978
  await database.run(`INSERT INTO nutrition_days
796
979
  (provider, nutrition_date, energy_kcal, carbohydrates_g, protein_g, fat_g, hydration_ml, metrics_json, raw_object_hash)
797
- VALUES ($provider, $nutritionDate, $energyKcal, $carbohydratesG, $proteinG, $fatG, $hydrationMl, $metrics, $rawHash)
798
- ON CONFLICT (provider, nutrition_date) DO UPDATE SET energy_kcal = excluded.energy_kcal, carbohydrates_g = excluded.carbohydrates_g,
799
- protein_g = excluded.protein_g, fat_g = excluded.fat_g, hydration_ml = excluded.hydration_ml, metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, { provider, nutritionDate, ...fields, metrics: json(payload), rawHash });
980
+ VALUES ($provider::VARCHAR, $nutritionDate::DATE, $energyKcal::DOUBLE, $carbohydratesG::DOUBLE, $proteinG::DOUBLE, $fatG::DOUBLE, $hydrationMl::DOUBLE, $metrics::JSON, $rawHash::VARCHAR)
981
+ ON CONFLICT (provider, nutrition_date) DO UPDATE SET
982
+ energy_kcal = COALESCE(excluded.energy_kcal, nutrition_days.energy_kcal),
983
+ carbohydrates_g = COALESCE(excluded.carbohydrates_g, nutrition_days.carbohydrates_g),
984
+ protein_g = COALESCE(excluded.protein_g, nutrition_days.protein_g),
985
+ fat_g = COALESCE(excluded.fat_g, nutrition_days.fat_g),
986
+ hydration_ml = COALESCE(excluded.hydration_ml, nutrition_days.hydration_ml),
987
+ metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, { provider, nutritionDate, ...fields, metrics: json(payload), rawHash });
800
988
  for (const [index, item] of nutritionItems(payload).entries()) {
801
989
  const itemFields = nutritionFields(item);
802
990
  const itemId = firstString(item, ['id', 'foodId', 'entryId']) ?? `${remoteId}:${index}`;
803
991
  await database.run(`INSERT INTO nutrition_items
804
992
  (provider, remote_item_id, nutrition_date, meal, consumed_at, food_name, quantity, energy_kcal, carbohydrates_g, protein_g, fat_g, payload_json, raw_object_hash)
805
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
993
+ VALUES (
994
+ CAST(? AS VARCHAR), CAST(? AS VARCHAR), CAST(? AS DATE), CAST(? AS VARCHAR),
995
+ CAST(? AS TIMESTAMP), CAST(? AS VARCHAR), CAST(? AS DOUBLE), CAST(? AS DOUBLE),
996
+ CAST(? AS DOUBLE), CAST(? AS DOUBLE), CAST(? AS DOUBLE), CAST(? AS JSON), CAST(? AS VARCHAR)
997
+ )
806
998
  ON CONFLICT (provider, remote_item_id) DO UPDATE SET nutrition_date = excluded.nutrition_date, meal = excluded.meal,
807
999
  consumed_at = excluded.consumed_at, food_name = excluded.food_name, quantity = excluded.quantity, energy_kcal = excluded.energy_kcal,
808
1000
  carbohydrates_g = excluded.carbohydrates_g, protein_g = excluded.protein_g, fat_g = excluded.fat_g, payload_json = excluded.payload_json,
@@ -2,6 +2,7 @@ import { ReadOnlyRepository } from '../../core/query/repository.js';
2
2
  import type { CatencePaths } from '../../contracts/runtime.js';
3
3
  import type { QueryBindings, WriteDataStore } from '../../contracts/storage.js';
4
4
  import type { Provider } from '../../contracts/staging.js';
5
+ import { type InterruptedRuns, type SyncProgressSnapshot, type SyncProgressState } from '../../contracts/progress.js';
5
6
  export declare class CatenceDatabase implements WriteDataStore {
6
7
  private readonly instance;
7
8
  private readonly connection;
@@ -16,7 +17,19 @@ export declare class CatenceDatabase implements WriteDataStore {
16
17
  interrupt(): void;
17
18
  beginRun(provider: Provider, fromDate: string, runId?: `${string}-${string}-${string}-${string}-${string}`): Promise<string>;
18
19
  finishRun(runId: string): Promise<void>;
20
+ heartbeatRun(runId: string, progress: SyncProgressState): Promise<void>;
21
+ markRunInterrupted(runId: string, stage?: string): Promise<void>;
22
+ interruptStaleSyncRuns(timeoutMs?: number): Promise<InterruptedRuns>;
23
+ syncProgress(timeoutMs?: number): Promise<SyncProgressSnapshot>;
24
+ hasCalendarEventOlderThan(provider: Provider, beforeIsoDate: string, entityType?: string): Promise<boolean>;
25
+ deleteMissingCalendarEntities(provider: Provider, entityTypes: readonly string[], keepRemoteIds: readonly string[], minOccurredOn: string): Promise<void>;
19
26
  addError(runId: string, provider: Provider, endpoint: string, remoteId: string | null, message: string, retryable: boolean): Promise<void>;
27
+ resolveErrors(opts?: {
28
+ runIds?: string[];
29
+ provider?: Provider;
30
+ before?: string;
31
+ all?: boolean;
32
+ }): Promise<number>;
20
33
  status(): Promise<Record<string, unknown>>;
21
34
  getRun(runId: string): Promise<{
22
35
  provider: Provider;
@@ -45,6 +58,12 @@ export declare class CatenceDatabase implements WriteDataStore {
45
58
  activityNeedsDetailSync(provider: Provider, remoteActivityId: string, summaryHash: string): Promise<boolean>;
46
59
  recordActivitySyncState(provider: Provider, remoteActivityId: string, summaryHash: string, runId: string, detailsFetched: boolean): Promise<void>;
47
60
  knownActivitySummaryHashes(provider: Provider): Promise<Record<string, string>>;
61
+ hasGarminLactateThresholdHistory(): Promise<boolean>;
62
+ earliestActivityStartDate(provider: Provider): Promise<string | null>;
63
+ garminSwimActivitySources(): Promise<Array<{
64
+ remote_activity_id: string;
65
+ started_on: string | null;
66
+ }>>;
48
67
  private latestSourceDate;
49
68
  private upsertCursor;
50
69
  }
@@ -66,9 +85,9 @@ export declare class ReadOnlyCatenceDatabase {
66
85
  interrupt(): void;
67
86
  private withConnection;
68
87
  }
69
- /** Compose core read services with the DuckDB-backed, read-only adapter. */
70
- export declare function openReadOnlyRepository(paths: CatencePaths): Promise<ReadOnlyRepository>;
71
88
  export declare class ReadOnlyDatabaseError extends Error {
72
89
  readonly code: 'data_sync_in_progress' | 'data_unavailable';
73
90
  constructor(code: 'data_sync_in_progress' | 'data_unavailable', message: string);
74
91
  }
92
+ /** Compose core read services with the DuckDB-backed, read-only adapter. */
93
+ export declare function openReadOnlyRepository(paths: CatencePaths): Promise<ReadOnlyRepository>;