catence 0.2.0-beta.2 → 0.2.0-beta.21

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 (66) hide show
  1. package/README.md +28 -82
  2. package/config.example.json +25 -0
  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/repository.d.ts +2 -0
  12. package/dist/core/query/repository.js +48 -0
  13. package/dist/core/runtime/catalog.js +17 -7
  14. package/dist/core/runtime/configuration.d.ts +3 -1
  15. package/dist/core/runtime/configuration.js +5 -1
  16. package/dist/core/runtime/opencode-go.d.ts +58 -0
  17. package/dist/core/runtime/opencode-go.js +246 -0
  18. package/dist/core/runtime/secrets.js +51 -13
  19. package/dist/core/runtime/self-update.d.ts +44 -0
  20. package/dist/core/runtime/self-update.js +138 -0
  21. package/dist/elt/application/detached-sync.d.ts +50 -0
  22. package/dist/elt/application/detached-sync.js +99 -0
  23. package/dist/elt/application/management.d.ts +15 -0
  24. package/dist/elt/application/management.js +435 -19
  25. package/dist/elt/ingestion/importer.d.ts +3 -2
  26. package/dist/elt/ingestion/importer.js +5 -4
  27. package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
  28. package/dist/elt/ingestion/providers/intervals.js +37 -2
  29. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  30. package/dist/elt/ingestion/providers/registry.js +18 -1
  31. package/dist/elt/ingestion/providers/strava/service.js +7 -1
  32. package/dist/elt/normalization/normalizers.js +111 -15
  33. package/dist/elt/storage/database.d.ts +21 -2
  34. package/dist/elt/storage/database.js +184 -6
  35. package/dist/elt/storage/migrations.js +25 -1
  36. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  37. package/dist/elt/storage/progress-sidecar.js +200 -0
  38. package/dist/interfaces/cli/commands.js +116 -1
  39. package/dist/interfaces/http/generation-status.d.ts +19 -0
  40. package/dist/interfaces/http/generation-status.js +55 -0
  41. package/dist/interfaces/http/server.d.ts +7 -1
  42. package/dist/interfaces/http/server.js +184 -1
  43. package/dist/interfaces/mcp/server.d.ts +2 -1
  44. package/dist/interfaces/mcp/server.js +50 -13
  45. package/dist/runtime/index.d.ts +8 -1
  46. package/dist/runtime/index.js +4 -1
  47. package/docs/architecture.md +11 -1
  48. package/docs/configuration.md +243 -0
  49. package/docs/deployment/docker.md +528 -0
  50. package/docs/deployment/local-console.md +263 -0
  51. package/docs/deployment/local-mcp.md +329 -0
  52. package/docs/development.md +337 -0
  53. package/docs/distribution.md +58 -0
  54. package/docs/llm-providers.md +249 -6
  55. package/docs/mcp-activity-retrieval.md +37 -30
  56. package/package.json +5 -1
  57. package/pyproject.toml +1 -1
  58. package/python/catence/providers/garmin/cli.py +43 -9
  59. package/python/catence/providers/garmin/progress.py +85 -0
  60. package/python/catence/providers/garmin/registry.py +1 -0
  61. package/python/catence/providers/garmin/streams.py +38 -0
  62. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  63. package/python/catence/providers/garmin/worker.py +372 -11
  64. package/scripts/discover-opencode-go.mjs +102 -0
  65. package/scripts/discover-opencode-zen.mjs +218 -0
  66. package/uv.lock +3 -3
@@ -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,7 @@ 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'],
46
63
  ['training_plans', 'collection', 'training_plan'], ['goals', 'collection', 'goal'], ['personal_records', 'singleton', 'personal_record'],
47
64
  ['earned_badges', 'collection', 'badge'], ['available_badges', 'collection', 'badge'], ['challenges', 'collection', 'challenge'],
48
65
  ['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`, {
@@ -223,6 +265,9 @@ export async function importSourceEntity(database, entity) {
223
265
  if (entity.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set'].includes(entity.entityType)) {
224
266
  await importGarminExplicitSwimLengths(database, entity.parentRemoteId, entity.entityType, payload, rawHash);
225
267
  }
268
+ if (entity.provider === 'garmin' && entity.entityType === 'activity_swim_lengths') {
269
+ await importGarminFitSwimLengths(database, entity.parentRemoteId, payload, rawHash);
270
+ }
226
271
  if (entity.entityType === 'wellness' || entity.entityType === 'daily_health')
227
272
  await importDailyMetrics(database, entity.provider, entity.remoteId, entity.occurredOn, payload, rawHash);
228
273
  if (entity.entityType === 'nutrition_day' || entity.entityType === 'nutrition_log')
@@ -372,7 +417,7 @@ function nested(payload, key) {
372
417
  return object(payload[key]);
373
418
  }
374
419
  function metricObservedAt(occurredOn, payload) {
375
- return firstTimestamp(payload, ['timestamp', 'timestampLocal', 'calendarDate', 'date']) ?? (occurredOn ? `${occurredOn}T00:00:00Z` : null);
420
+ return firstTimestamp(payload, ['timestamp', 'timestampLocal', 'calendarDate', 'date']) ?? (occurredOn ? `${datePart(occurredOn)}T00:00:00Z` : null);
376
421
  }
377
422
  async function importGarminActivityPerformance(database, remoteId, payload, rawHash) {
378
423
  const fields = activityFields('garmin', payload);
@@ -668,6 +713,46 @@ async function importGarminExplicitSwimLengths(database, parentRemoteId, entityT
668
713
  }
669
714
  }
670
715
  }
716
+ async function importGarminFitSwimLengths(database, parentRemoteId, payload, rawHash) {
717
+ if (!parentRemoteId)
718
+ return;
719
+ const activitySourceId = providerActivityId('garmin', parentRemoteId);
720
+ const lengths = objectArray(payload.lengths);
721
+ if (lengths.length === 0)
722
+ return;
723
+ const source = 'garmin_fit_length';
724
+ await database.run('DELETE FROM swim_lengths WHERE activity_source_id = $activitySourceId AND source = $source', { activitySourceId, source });
725
+ const summaryPoolLengthM = await activityPoolLengthM(database, activitySourceId);
726
+ for (const [offset, length] of lengths.entries()) {
727
+ const durationS = firstNumber(length, ['durationS', 'duration', 'durationSeconds', 'elapsedDuration']);
728
+ if (durationS === null)
729
+ continue;
730
+ const sourceLabel = swimSourceLabel(length);
731
+ const isRest = firstBoolean(length, ['isRest', 'rest']) ?? Boolean(sourceLabel && /rest|idle|pause/.test(sourceLabel));
732
+ const lengthIndex = Math.round(firstNumber(length, ['lengthIndex', 'messageIndex', 'lengthNumber', 'index']) ?? offset);
733
+ await database.run(`INSERT INTO swim_lengths
734
+ (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)
735
+ VALUES ($activitySourceId, $source, $lengthIndex, $lapIndex, $poolLengthM, $startTime, $durationS, $activeDurationS, $distanceM, $strokeCount, $strokeRate, $swolf, $avgHr, $maxHr, $isRest, 'provider_supplied', $metrics, $rawHash)
736
+ ON CONFLICT (activity_source_id, source, length_index) DO UPDATE SET
737
+ lap_index = excluded.lap_index, pool_length_m = excluded.pool_length_m, start_time = excluded.start_time,
738
+ duration_s = excluded.duration_s, active_duration_s = excluded.active_duration_s, distance_m = excluded.distance_m,
739
+ stroke_count = excluded.stroke_count, stroke_rate = excluded.stroke_rate, swolf = excluded.swolf,
740
+ avg_hr = excluded.avg_hr, max_hr = excluded.max_hr, is_rest = excluded.is_rest,
741
+ confidence = excluded.confidence, metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, {
742
+ activitySourceId, source, lengthIndex,
743
+ lapIndex: firstNumber(length, ['lapIndex', 'lapNumber']), poolLengthM: garminPoolLengthM(length) ?? summaryPoolLengthM,
744
+ startTime: timestamp(length.startTime ?? length.startTimestamp ?? length.timestamp), durationS,
745
+ activeDurationS: firstNumber(length, ['activeDurationS', 'activeDuration', 'movingDuration']),
746
+ distanceM: firstNumber(length, ['distanceM', 'distance']),
747
+ strokeCount: firstNumber(length, ['strokeCount', 'strokes', 'total_strokes']),
748
+ strokeRate: firstNumber(length, ['strokeRate', 'averageStrokeRate', 'avg_swimming_cadence', 'avg_cadence', 'cadence']),
749
+ swolf: firstNumber(length, ['swolf', 'avg_swolf']),
750
+ avgHr: firstNumber(length, ['avgHr', 'averageHR', 'avg_heart_rate']),
751
+ maxHr: firstNumber(length, ['maxHr', 'maxHR', 'max_heart_rate']),
752
+ isRest, metrics: json(length), rawHash,
753
+ });
754
+ }
755
+ }
671
756
  async function addActivityQualityFlag(database, activitySourceId, code, severity, details, rawHash) {
672
757
  await database.run(`INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
673
758
  VALUES ($activitySourceId, $code, $severity, $details, $rawHash)
@@ -791,18 +876,29 @@ async function importNutrition(database, provider, date, remoteId, payload, rawH
791
876
  const nutritionDate = datePart(date) ?? datePart(firstString(payload, ['id', 'date', 'calendarDate'])) ?? null;
792
877
  if (!nutritionDate)
793
878
  return;
879
+ if (!hasNutritionContent(payload))
880
+ return;
794
881
  const fields = nutritionFields(payload);
795
882
  await database.run(`INSERT INTO nutrition_days
796
883
  (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 });
884
+ VALUES ($provider::VARCHAR, $nutritionDate::DATE, $energyKcal::DOUBLE, $carbohydratesG::DOUBLE, $proteinG::DOUBLE, $fatG::DOUBLE, $hydrationMl::DOUBLE, $metrics::JSON, $rawHash::VARCHAR)
885
+ ON CONFLICT (provider, nutrition_date) DO UPDATE SET
886
+ energy_kcal = COALESCE(excluded.energy_kcal, nutrition_days.energy_kcal),
887
+ carbohydrates_g = COALESCE(excluded.carbohydrates_g, nutrition_days.carbohydrates_g),
888
+ protein_g = COALESCE(excluded.protein_g, nutrition_days.protein_g),
889
+ fat_g = COALESCE(excluded.fat_g, nutrition_days.fat_g),
890
+ hydration_ml = COALESCE(excluded.hydration_ml, nutrition_days.hydration_ml),
891
+ metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, { provider, nutritionDate, ...fields, metrics: json(payload), rawHash });
800
892
  for (const [index, item] of nutritionItems(payload).entries()) {
801
893
  const itemFields = nutritionFields(item);
802
894
  const itemId = firstString(item, ['id', 'foodId', 'entryId']) ?? `${remoteId}:${index}`;
803
895
  await database.run(`INSERT INTO nutrition_items
804
896
  (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
897
+ VALUES (
898
+ CAST(? AS VARCHAR), CAST(? AS VARCHAR), CAST(? AS DATE), CAST(? AS VARCHAR),
899
+ CAST(? AS TIMESTAMP), CAST(? AS VARCHAR), CAST(? AS DOUBLE), CAST(? AS DOUBLE),
900
+ CAST(? AS DOUBLE), CAST(? AS DOUBLE), CAST(? AS DOUBLE), CAST(? AS JSON), CAST(? AS VARCHAR)
901
+ )
806
902
  ON CONFLICT (provider, remote_item_id) DO UPDATE SET nutrition_date = excluded.nutrition_date, meal = excluded.meal,
807
903
  consumed_at = excluded.consumed_at, food_name = excluded.food_name, quantity = excluded.quantity, energy_kcal = excluded.energy_kcal,
808
904
  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>;