catence 0.2.0-beta.11 → 0.2.0-beta.13

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.
@@ -1,5 +1,5 @@
1
1
  export declare const STALE_RUN_TIMEOUT_MS: number;
2
- export declare const SYNC_STAGES: readonly ["starting", "login", "singletons", "daily", "range", "ftp_history", "max_metrics", "hrv_history", "scores", "activities", "collections", "importing", "completed", "interrupted", "failed", "timed_out"];
2
+ export declare const SYNC_STAGES: readonly ["starting", "login", "singletons", "daily", "range", "ftp_history", "max_metrics", "hrv_history", "scores", "activities", "collections", "intervals", "importing", "completed", "interrupted", "failed", "timed_out"];
3
3
  export type SyncStage = (typeof SYNC_STAGES)[number];
4
4
  export interface SyncProgressState {
5
5
  runId: string;
@@ -11,6 +11,7 @@ export const SYNC_STAGES = [
11
11
  'scores',
12
12
  'activities',
13
13
  'collections',
14
+ 'intervals',
14
15
  'importing',
15
16
  'completed',
16
17
  'interrupted',
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.11";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.13";
7
7
  export declare const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export type CatenceRuntimeCapabilities = {
9
9
  mcp: true;
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.11';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.13';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -24,4 +24,17 @@ export declare function unlinkActivity(paths: CatencePaths, source: string): Pro
24
24
  export declare function dataStatus(paths: CatencePaths): Promise<Record<string, unknown>>;
25
25
  export declare function syncProgress(paths: CatencePaths): Promise<SyncProgressSnapshot>;
26
26
  export declare function rebuildRetrievalIndex(paths: CatencePaths): Promise<Record<string, unknown>>;
27
+ export declare function importStagedFile(paths: CatencePaths, provider: ProviderChoice, filePath: string): Promise<Record<string, unknown>>;
28
+ export declare function resolveExtractionErrors(paths: CatencePaths, opts?: {
29
+ runIds?: string[];
30
+ provider?: string;
31
+ before?: string;
32
+ all?: boolean;
33
+ }): Promise<Record<string, unknown>>;
34
+ /**
35
+ * Re-run normalization over already-captured nutrition entities without
36
+ * contacting any provider. Idempotent upserts let a fixed normalizer backfill
37
+ * previously mis-parsed nutrition_days/nutrition_items from stored payloads.
38
+ */
39
+ export declare function reimportNutrition(paths: CatencePaths): Promise<Record<string, unknown>>;
27
40
  export {};
@@ -14,8 +14,9 @@ import { importJsonl } from '../ingestion/importer.js';
14
14
  import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
15
15
  import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUrl, syncStravaGear } from '../ingestion/providers/strava/service.js';
16
16
  import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
17
+ import { importSourceEntity } from '../normalization/normalizers.js';
17
18
  import { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../storage/database.js';
18
- import { mergeProgress, readRunningProgress, removeProgressSidecar, writeProgressSidecar } from '../storage/progress-sidecar.js';
19
+ import { mergeProgress, ProgressPump, readRunningProgress, removeProgressSidecar } from '../storage/progress-sidecar.js';
19
20
  import { withDataWriteLock } from '../storage/write-lock.js';
20
21
  function packageRoot() {
21
22
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
@@ -33,7 +34,7 @@ function baseProgress(runId, provider, stage) {
33
34
  * Progress NDJSON lines (kind 'progress') are normalized and persisted as
34
35
  * throttled heartbeats; other output is forwarded to the sync logger.
35
36
  */
36
- function runGarminWorker(runId, arguments_, environment, database, paths, log, onInterruptReady) {
37
+ function runGarminWorker(runId, arguments_, environment, pump, log, onInterruptReady) {
37
38
  return new Promise((resolve) => {
38
39
  const child = spawn('uv', arguments_, { ...environment, stdio: ['ignore', 'pipe', 'pipe'] });
39
40
  let killTimer;
@@ -48,24 +49,6 @@ function runGarminWorker(runId, arguments_, environment, database, paths, log, o
48
49
  }, 10_000);
49
50
  killTimer.unref();
50
51
  });
51
- let lastProgress = null;
52
- let lastHeartbeatAt = 0;
53
- const heartbeat = (state) => {
54
- const now = Date.now();
55
- if (now - lastHeartbeatAt < 2_000)
56
- return;
57
- lastHeartbeatAt = now;
58
- void database.heartbeatRun(runId, state).catch((error) => {
59
- log.debug('Progress heartbeat failed', { runId, error: error instanceof Error ? error.message : String(error) });
60
- });
61
- void writeProgressSidecar(paths, runId, state).catch((error) => {
62
- log.debug('Progress sidecar write failed', { runId, error: error instanceof Error ? error.message : String(error) });
63
- });
64
- };
65
- const fallback = setInterval(() => {
66
- heartbeat(lastProgress ?? baseProgress(runId, 'garmin', 'starting'));
67
- }, 30_000);
68
- fallback.unref();
69
52
  const buffer = (stream, sink) => {
70
53
  let pending = '';
71
54
  stream.on('data', (chunk) => {
@@ -95,29 +78,24 @@ function runGarminWorker(runId, arguments_, environment, database, paths, log, o
95
78
  }
96
79
  if (typeof parsed === 'object' && parsed !== null && parsed.kind === 'progress') {
97
80
  const state = normalizeProgress(parsed);
98
- if (state) {
99
- lastProgress = state;
100
- heartbeat(state);
101
- }
81
+ if (state)
82
+ pump.publish(state);
102
83
  return;
103
84
  }
104
85
  log.info(`[garmin] ${line}`);
105
86
  });
106
87
  buffer(child.stderr, (line) => log.error(`[garmin] ${line}`));
107
88
  child.on('error', (error) => {
108
- clearInterval(fallback);
109
89
  if (killTimer)
110
90
  clearTimeout(killTimer);
111
91
  resolve({ code: 1, signal: null });
112
92
  log.error('Garmin staging worker could not be started', { runId, error: error.message });
113
93
  });
114
94
  child.on('close', (code, signal) => {
115
- clearInterval(fallback);
116
95
  if (killTimer)
117
96
  clearTimeout(killTimer);
118
- if (code === 0 && lastProgress) {
119
- void writeProgressSidecar(paths, runId, { ...lastProgress, stage: 'importing' }).catch(() => undefined);
120
- }
97
+ if (code === 0)
98
+ pump.setStageImporting();
121
99
  resolve({ code, signal });
122
100
  });
123
101
  });
@@ -188,11 +166,14 @@ async function syncProvider(database, paths, provider, options, log) {
188
166
  });
189
167
  let extractionStarted = false;
190
168
  const guard = { interrupted: false };
169
+ const pump = new ProgressPump(runId, provider, { database, paths, log });
170
+ pump.start();
191
171
  try {
192
172
  if (provider === 'intervals') {
193
173
  const environment = await athleteProviderEnvironment(paths);
194
174
  const config = requireIntervalsConfig(environment);
195
175
  extractionStarted = true;
176
+ pump.setStage('intervals');
196
177
  await runWithInterruptGuard(runId, database, () => undefined, guard, async () => {
197
178
  await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId, log).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
198
179
  });
@@ -222,7 +203,7 @@ async function syncProvider(database, paths, provider, options, log) {
222
203
  const outcome = await runGarminWorker(runId, arguments_, {
223
204
  cwd: packageRoot(),
224
205
  env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
225
- }, database, paths, log, (kill) => { interruptChild = kill; });
206
+ }, pump, log, (kill) => { interruptChild = kill; });
226
207
  if (!guard.interrupted && outcome.code !== 0) {
227
208
  throw new Error(`Garmin staging worker exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
228
209
  }
@@ -243,12 +224,14 @@ async function syncProvider(database, paths, provider, options, log) {
243
224
  }
244
225
  }
245
226
  finally {
227
+ pump.stop();
246
228
  if (guard.interrupted) {
247
229
  await database.markRunInterrupted(runId);
248
230
  log.warn('Sync run interrupted', { runId, provider });
249
231
  }
250
232
  else {
251
233
  await database.finishRun(runId);
234
+ pump.publishFinal(baseProgress(runId, provider, 'completed'));
252
235
  }
253
236
  await removeProgressSidecar(paths, runId).catch(() => undefined);
254
237
  const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
@@ -291,6 +274,11 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
291
274
  const runs = [];
292
275
  for (const source of providers)
293
276
  runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }, log));
277
+ const executedRuns = runs.filter((run) => !run.skipped);
278
+ if (executedRuns.length > 0) {
279
+ const index = await buildRetrievalIndex(database);
280
+ log.info('Rebuilt retrieval index after sync', { documents: index.documents, mode: index.mode, watermark: index.watermark });
281
+ }
294
282
  return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs, timedOutRuns: timedOutRuns.runIds };
295
283
  }
296
284
  finally {
@@ -484,4 +472,76 @@ export async function rebuildRetrievalIndex(paths) {
484
472
  await database.close();
485
473
  }
486
474
  }
475
+ export async function importStagedFile(paths, provider, filePath) {
476
+ if (!existsSync(paths.database))
477
+ throw new Error('No Catence database exists yet. Complete a sync before importing staged records.');
478
+ if (!existsSync(filePath))
479
+ throw new Error(`Staging file not found: ${filePath}`);
480
+ const database = await CatenceDatabase.open(paths);
481
+ try {
482
+ const runId = randomUUID();
483
+ const importedRecords = await importJsonl(database, runId, filePath);
484
+ return { runId, provider, importedRecords };
485
+ }
486
+ finally {
487
+ await database.close();
488
+ }
489
+ }
490
+ export async function resolveExtractionErrors(paths, opts = {}) {
491
+ if (!existsSync(paths.database))
492
+ throw new Error('No Catence database exists yet.');
493
+ const database = await CatenceDatabase.open(paths);
494
+ try {
495
+ const resolved = await database.resolveErrors({
496
+ runIds: opts.runIds,
497
+ provider: opts.provider,
498
+ before: opts.before,
499
+ all: opts.all,
500
+ });
501
+ return { resolvedErrors: resolved };
502
+ }
503
+ finally {
504
+ await database.close();
505
+ }
506
+ }
507
+ /**
508
+ * Re-run normalization over already-captured nutrition entities without
509
+ * contacting any provider. Idempotent upserts let a fixed normalizer backfill
510
+ * previously mis-parsed nutrition_days/nutrition_items from stored payloads.
511
+ */
512
+ export async function reimportNutrition(paths) {
513
+ if (!existsSync(paths.database))
514
+ throw new Error('No Catence database exists yet. Complete a sync before re-importing nutrition.');
515
+ const database = await CatenceDatabase.open(paths);
516
+ try {
517
+ const rows = await database.rows(`SELECT provider, entity_type, remote_id, parent_remote_id, occurred_on, source_updated_at, raw_object_hash,
518
+ cast(payload_json AS VARCHAR) AS payload_json, cast(extension_json AS VARCHAR) AS extension_json
519
+ FROM source_entities
520
+ WHERE entity_type IN ('nutrition_log', 'nutrition_day')
521
+ ORDER BY provider, occurred_on, remote_id`);
522
+ for (const row of rows) {
523
+ await importSourceEntity(database, {
524
+ kind: 'source_entity', schemaVersion: 1, provider: row.provider, entityType: row.entity_type,
525
+ remoteId: row.remote_id, parentRemoteId: row.parent_remote_id, occurredOn: row.occurred_on, sourceUpdatedAt: row.source_updated_at,
526
+ rawObjectHash: row.raw_object_hash, payload: parseJsonObject(row.payload_json), extension: parseJsonObject(row.extension_json),
527
+ });
528
+ }
529
+ const days = (await database.rows('SELECT count(*) AS count FROM nutrition_days'))[0]?.count ?? 0;
530
+ const items = (await database.rows('SELECT count(*) AS count FROM nutrition_items'))[0]?.count ?? 0;
531
+ return { reimportedEntities: rows.length, nutritionDays: days, nutritionItems: items };
532
+ }
533
+ finally {
534
+ await database.close();
535
+ }
536
+ }
537
+ function parseJsonObject(value) {
538
+ if (!value)
539
+ return {};
540
+ try {
541
+ return JSON.parse(value);
542
+ }
543
+ catch {
544
+ return {};
545
+ }
546
+ }
487
547
  //# sourceMappingURL=management.js.map
@@ -33,7 +33,7 @@ export async function importRecord(database, runId, record, log) {
33
33
  if (activity[0]?.activity_id)
34
34
  await refreshActivityQuality(database, activity[0].activity_id);
35
35
  }
36
- if (record.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set'].includes(record.entityType) && record.parentRemoteId) {
36
+ if (record.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set', 'activity_swim_lengths'].includes(record.entityType) && record.parentRemoteId) {
37
37
  const activity = await database.rows('SELECT activity_id FROM activity_sources WHERE activity_source_id = $activitySourceId', { activitySourceId: `garmin:${record.parentRemoteId}` });
38
38
  if (activity[0]?.activity_id)
39
39
  await refreshActivityQuality(database, activity[0].activity_id);
@@ -163,24 +163,63 @@ async function upsertDailyMetric(database, provider, metricDate, metricName, val
163
163
  ON CONFLICT (provider, metric_date, metric_name) DO UPDATE SET value_number = excluded.value_number, value_text = excluded.value_text,
164
164
  unit = excluded.unit, raw_object_hash = excluded.raw_object_hash`, { provider, metricDate, metricName, valueNumber, valueText, unit, rawHash });
165
165
  }
166
+ /**
167
+ * Garmin's nutrition food log keeps its actuals under dailyNutritionContent
168
+ * (a per-day rolled-up object), while meal definitions and hydration endpoints
169
+ * share the same source entity type. Flat keys are kept for providers that
170
+ * return them directly.
171
+ */
166
172
  function nutritionFields(payload) {
173
+ const content = object(payload.dailyNutritionContent);
167
174
  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']),
175
+ energyKcal: firstNumber(payload, ['kcalConsumed', 'calories', 'totalCalories', 'energyKcal']) ?? firstNumber(content, ['calories', 'energyKcal', 'totalCalories']),
176
+ carbohydratesG: firstNumber(payload, ['carbohydrates', 'carbs', 'totalCarbs', 'carbohydrateGrams']) ?? firstNumber(content, ['carbohydrates', 'carbs']),
177
+ proteinG: firstNumber(payload, ['protein', 'totalProtein', 'proteinGrams']) ?? firstNumber(content, ['protein']),
178
+ fatG: firstNumber(payload, ['fatTotal', 'fat', 'totalFat', 'fatGrams']) ?? firstNumber(content, ['fat', 'fatTotal']),
179
+ hydrationMl: firstNumber(payload, ['hydrationVolume', 'hydration', 'waterMl', 'totalWater', 'valueInML', 'dailyAverageinML']),
173
180
  };
174
181
  }
182
+ /**
183
+ * Garmin food-log items live under mealDetails[].foods[]. Each food carries the
184
+ * day's nutrition_date, so a meal label is folded onto the item to avoid losing
185
+ * it. Flat arrays are kept for providers that return items directly.
186
+ */
175
187
  function nutritionItems(payload) {
176
- const candidates = ['foodItems', 'items', 'entries', 'foodLogEntries', 'meals'];
177
- for (const key of candidates) {
188
+ const items = [];
189
+ const mealDetails = payload.mealDetails;
190
+ if (Array.isArray(mealDetails)) {
191
+ for (const detail of mealDetails) {
192
+ const mealDetail = object(detail);
193
+ const mealName = firstString(object(mealDetail.meal), ['mealName']) ?? firstString(mealDetail, ['mealName']);
194
+ const foods = mealDetail.foods;
195
+ if (Array.isArray(foods)) {
196
+ for (const food of foods) {
197
+ const item = object(food);
198
+ if (Object.keys(item).length === 0)
199
+ continue;
200
+ items.push(mealName ? { ...item, mealName } : item);
201
+ }
202
+ }
203
+ else if (Object.keys(mealDetail).length > 0 && firstString(mealDetail, ['foodName', 'name', 'description']) !== null) {
204
+ items.push(mealName ? { ...mealDetail, mealName } : mealDetail);
205
+ }
206
+ }
207
+ if (items.length > 0)
208
+ return items;
209
+ }
210
+ for (const key of ['foodItems', 'items', 'entries', 'foodLogEntries', 'meals']) {
178
211
  const value = payload[key];
179
212
  if (Array.isArray(value))
180
213
  return value.map(object).filter((item) => Object.keys(item).length > 0);
181
214
  }
182
215
  return [];
183
216
  }
217
+ /** True when a payload contributes real nutrition facts rather than meal definitions or hydration-only bookkeeping. */
218
+ function hasNutritionContent(payload) {
219
+ const fields = nutritionFields(payload);
220
+ return fields.energyKcal !== null || fields.carbohydratesG !== null || fields.proteinG !== null || fields.fatG !== null
221
+ || fields.hydrationMl !== null || nutritionItems(payload).length > 0;
222
+ }
184
223
  export async function importSourceEntity(database, entity) {
185
224
  const payload = object(entity.payload);
186
225
  const rawHash = entity.rawObjectHash;
@@ -223,6 +262,9 @@ export async function importSourceEntity(database, entity) {
223
262
  if (entity.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set'].includes(entity.entityType)) {
224
263
  await importGarminExplicitSwimLengths(database, entity.parentRemoteId, entity.entityType, payload, rawHash);
225
264
  }
265
+ if (entity.provider === 'garmin' && entity.entityType === 'activity_swim_lengths') {
266
+ await importGarminFitSwimLengths(database, entity.parentRemoteId, payload, rawHash);
267
+ }
226
268
  if (entity.entityType === 'wellness' || entity.entityType === 'daily_health')
227
269
  await importDailyMetrics(database, entity.provider, entity.remoteId, entity.occurredOn, payload, rawHash);
228
270
  if (entity.entityType === 'nutrition_day' || entity.entityType === 'nutrition_log')
@@ -668,6 +710,46 @@ async function importGarminExplicitSwimLengths(database, parentRemoteId, entityT
668
710
  }
669
711
  }
670
712
  }
713
+ async function importGarminFitSwimLengths(database, parentRemoteId, payload, rawHash) {
714
+ if (!parentRemoteId)
715
+ return;
716
+ const activitySourceId = providerActivityId('garmin', parentRemoteId);
717
+ const lengths = objectArray(payload.lengths);
718
+ if (lengths.length === 0)
719
+ return;
720
+ const source = 'garmin_fit_length';
721
+ await database.run('DELETE FROM swim_lengths WHERE activity_source_id = $activitySourceId AND source = $source', { activitySourceId, source });
722
+ const summaryPoolLengthM = await activityPoolLengthM(database, activitySourceId);
723
+ for (const [offset, length] of lengths.entries()) {
724
+ const durationS = firstNumber(length, ['durationS', 'duration', 'durationSeconds', 'elapsedDuration']);
725
+ if (durationS === null)
726
+ continue;
727
+ const sourceLabel = swimSourceLabel(length);
728
+ const isRest = firstBoolean(length, ['isRest', 'rest']) ?? Boolean(sourceLabel && /rest|idle|pause/.test(sourceLabel));
729
+ const lengthIndex = Math.round(firstNumber(length, ['lengthIndex', 'messageIndex', 'lengthNumber', 'index']) ?? offset);
730
+ await database.run(`INSERT INTO swim_lengths
731
+ (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)
732
+ VALUES ($activitySourceId, $source, $lengthIndex, $lapIndex, $poolLengthM, $startTime, $durationS, $activeDurationS, $distanceM, $strokeCount, $strokeRate, $swolf, $avgHr, $maxHr, $isRest, 'provider_supplied', $metrics, $rawHash)
733
+ ON CONFLICT (activity_source_id, source, length_index) DO UPDATE SET
734
+ lap_index = excluded.lap_index, pool_length_m = excluded.pool_length_m, start_time = excluded.start_time,
735
+ duration_s = excluded.duration_s, active_duration_s = excluded.active_duration_s, distance_m = excluded.distance_m,
736
+ stroke_count = excluded.stroke_count, stroke_rate = excluded.stroke_rate, swolf = excluded.swolf,
737
+ avg_hr = excluded.avg_hr, max_hr = excluded.max_hr, is_rest = excluded.is_rest,
738
+ confidence = excluded.confidence, metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, {
739
+ activitySourceId, source, lengthIndex,
740
+ lapIndex: firstNumber(length, ['lapIndex', 'lapNumber']), poolLengthM: garminPoolLengthM(length) ?? summaryPoolLengthM,
741
+ startTime: timestamp(length.startTime ?? length.startTimestamp ?? length.timestamp), durationS,
742
+ activeDurationS: firstNumber(length, ['activeDurationS', 'activeDuration', 'movingDuration']),
743
+ distanceM: firstNumber(length, ['distanceM', 'distance']),
744
+ strokeCount: firstNumber(length, ['strokeCount', 'strokes', 'total_strokes']),
745
+ strokeRate: firstNumber(length, ['strokeRate', 'averageStrokeRate', 'avg_swimming_cadence', 'avg_cadence', 'cadence']),
746
+ swolf: firstNumber(length, ['swolf', 'avg_swolf']),
747
+ avgHr: firstNumber(length, ['avgHr', 'averageHR', 'avg_heart_rate']),
748
+ maxHr: firstNumber(length, ['maxHr', 'maxHR', 'max_heart_rate']),
749
+ isRest, metrics: json(length), rawHash,
750
+ });
751
+ }
752
+ }
671
753
  async function addActivityQualityFlag(database, activitySourceId, code, severity, details, rawHash) {
672
754
  await database.run(`INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
673
755
  VALUES ($activitySourceId, $code, $severity, $details, $rawHash)
@@ -791,12 +873,19 @@ async function importNutrition(database, provider, date, remoteId, payload, rawH
791
873
  const nutritionDate = datePart(date) ?? datePart(firstString(payload, ['id', 'date', 'calendarDate'])) ?? null;
792
874
  if (!nutritionDate)
793
875
  return;
876
+ if (!hasNutritionContent(payload))
877
+ return;
794
878
  const fields = nutritionFields(payload);
795
879
  await database.run(`INSERT INTO nutrition_days
796
880
  (provider, nutrition_date, energy_kcal, carbohydrates_g, protein_g, fat_g, hydration_ml, metrics_json, raw_object_hash)
797
881
  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 });
882
+ ON CONFLICT (provider, nutrition_date) DO UPDATE SET
883
+ energy_kcal = COALESCE(excluded.energy_kcal, nutrition_days.energy_kcal),
884
+ carbohydrates_g = COALESCE(excluded.carbohydrates_g, nutrition_days.carbohydrates_g),
885
+ protein_g = COALESCE(excluded.protein_g, nutrition_days.protein_g),
886
+ fat_g = COALESCE(excluded.fat_g, nutrition_days.fat_g),
887
+ hydration_ml = COALESCE(excluded.hydration_ml, nutrition_days.hydration_ml),
888
+ metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, { provider, nutritionDate, ...fields, metrics: json(payload), rawHash });
800
889
  for (const [index, item] of nutritionItems(payload).entries()) {
801
890
  const itemFields = nutritionFields(item);
802
891
  const itemId = firstString(item, ['id', 'foodId', 'entryId']) ?? `${remoteId}:${index}`;
@@ -22,6 +22,12 @@ export declare class CatenceDatabase implements WriteDataStore {
22
22
  interruptStaleSyncRuns(timeoutMs?: number): Promise<InterruptedRuns>;
23
23
  syncProgress(): Promise<SyncProgressSnapshot>;
24
24
  addError(runId: string, provider: Provider, endpoint: string, remoteId: string | null, message: string, retryable: boolean): Promise<void>;
25
+ resolveErrors(opts?: {
26
+ runIds?: string[];
27
+ provider?: Provider;
28
+ before?: string;
29
+ all?: boolean;
30
+ }): Promise<number>;
25
31
  status(): Promise<Record<string, unknown>>;
26
32
  getRun(runId: string): Promise<{
27
33
  provider: Provider;
@@ -147,6 +147,28 @@ export class CatenceDatabase {
147
147
  VALUES ($errorId, $runId, $provider, $endpoint, $remoteId, $message, $retryable)`, { errorId: randomUUID(), runId, provider, endpoint, remoteId, message, retryable });
148
148
  await this.run('UPDATE sync_runs SET error_count = error_count + 1 WHERE run_id = $runId', { runId });
149
149
  }
150
+ async resolveErrors(opts = {}) {
151
+ const clauses = ['resolved_at IS NULL'];
152
+ const values = {};
153
+ if (opts.runIds && opts.runIds.length > 0) {
154
+ const placeholders = opts.runIds.map((_, index) => `$runId${index}`).join(', ');
155
+ clauses.push(`run_id IN (${placeholders})`);
156
+ opts.runIds.forEach((runId, index) => { values[`runId${index}`] = runId; });
157
+ }
158
+ if (opts.provider) {
159
+ clauses.push('provider = $provider');
160
+ values.provider = opts.provider;
161
+ }
162
+ if (opts.before) {
163
+ clauses.push('created_at < $before');
164
+ values.before = opts.before;
165
+ }
166
+ if (!opts.all && clauses.length === 1) {
167
+ throw new Error('Refusing to resolve all unresolved errors without an explicit --all flag.');
168
+ }
169
+ const resolved = await this.rows(`UPDATE normalization_errors SET resolved_at = now() WHERE ${clauses.join(' AND ')} RETURNING error_id`, values);
170
+ return resolved.length;
171
+ }
150
172
  async status() {
151
173
  // @duckdb/node-api owns native statement state on the connection. Keep
152
174
  // status reads sequential instead of issuing several native calls at once.
@@ -1,7 +1,11 @@
1
- import { type SyncProgressSnapshot, type SyncProgressState } from '../../contracts/progress.js';
1
+ import { type SyncProgressSnapshot, type SyncProgressState, type SyncStage } from '../../contracts/progress.js';
2
2
  import type { CatencePaths } from '../../contracts/runtime.js';
3
+ import type { SyncLogger } from '../../core/logging.js';
4
+ import type { CatenceDatabase } from './database.js';
3
5
  export declare const PROGRESS_SIDECAR_SUFFIX = ".progress.json";
4
6
  export declare const PROGRESS_SIDECAR_STALE_MS: number;
7
+ export declare const PROGRESS_KEEPALIVE_MS: number;
8
+ export declare const PROGRESS_PUBLISH_THROTTLE_MS: number;
5
9
  export declare function progressSidecarPath(paths: CatencePaths, runId: string): string;
6
10
  /**
7
11
  * Persist one progress heartbeat as a sidecar file next to the Garmin staging
@@ -24,3 +28,34 @@ export declare function readRunningProgress(paths: CatencePaths, staleAfterMs?:
24
28
  * both sources and sorted by heartbeat recency.
25
29
  */
26
30
  export declare function mergeProgress(dbRunning: SyncProgressState[], dbRecent: SyncProgressState[], sidecars: SyncProgressState[]): SyncProgressSnapshot;
31
+ export interface ProgressPumpOptions {
32
+ database: CatenceDatabase;
33
+ paths: CatencePaths;
34
+ log: SyncLogger;
35
+ }
36
+ /**
37
+ * Drives progress heartbeats for the whole lifetime of a sync run - including
38
+ * phases that emit no worker output (the Intervals extractor and the Garmin
39
+ * JSONL import). `publish` throttles writes to the database and sidecar; a
40
+ * 30-second keep-alive interval refreshes the last known state so a run never
41
+ * looks stale while it is still active, even during the lock-holding import.
42
+ */
43
+ export declare class ProgressPump {
44
+ private readonly runId;
45
+ private readonly provider;
46
+ private readonly options;
47
+ private lastProgress;
48
+ private lastHeartbeatAt;
49
+ private interval;
50
+ private stageOverride;
51
+ constructor(runId: string, provider: string, options: ProgressPumpOptions);
52
+ start(): void;
53
+ publish(state: SyncProgressState): void;
54
+ /** Force subsequent keep-alive heartbeats to report the given stage. */
55
+ setStage(stage: SyncStage): void;
56
+ /** Transition the run to the JSONL import stage; keep-alives persist it. */
57
+ setStageImporting(): void;
58
+ /** Write a terminal heartbeat immediately, bypassing the publish throttle. */
59
+ publishFinal(state: SyncProgressState): void;
60
+ stop(): void;
61
+ }
@@ -1,9 +1,11 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { normalizeProgress } from '../../contracts/progress.js';
4
+ import { normalizeProgress, } from '../../contracts/progress.js';
5
5
  export const PROGRESS_SIDECAR_SUFFIX = '.progress.json';
6
6
  export const PROGRESS_SIDECAR_STALE_MS = 5 * 60 * 1000;
7
+ export const PROGRESS_KEEPALIVE_MS = 30 * 1000;
8
+ export const PROGRESS_PUBLISH_THROTTLE_MS = 2 * 1000;
7
9
  const TERMINAL_STAGES = new Set(['completed', 'failed', 'interrupted', 'timed_out']);
8
10
  export function progressSidecarPath(paths, runId) {
9
11
  return path.join(paths.staging, 'garmin', `${runId}${PROGRESS_SIDECAR_SUFFIX}`);
@@ -16,7 +18,7 @@ export function progressSidecarPath(paths, runId) {
16
18
  export async function writeProgressSidecar(paths, runId, state) {
17
19
  const target = progressSidecarPath(paths, runId);
18
20
  await mkdir(path.dirname(target), { recursive: true });
19
- const temporary = `${target}.tmp`;
21
+ const temporary = `${target}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}.tmp`;
20
22
  await writeFile(temporary, JSON.stringify(state));
21
23
  await rename(temporary, target);
22
24
  }
@@ -84,4 +86,95 @@ export function mergeProgress(dbRunning, dbRecent, sidecars) {
84
86
  .slice(0, 10),
85
87
  };
86
88
  }
89
+ function baselineState(runId, provider, stage) {
90
+ return {
91
+ runId,
92
+ provider,
93
+ stage,
94
+ currentStep: null,
95
+ completedUnits: 0,
96
+ totalUnits: null,
97
+ percentComplete: 0,
98
+ elapsedSeconds: 0,
99
+ estimatedRemainingSeconds: null,
100
+ heartbeatAt: new Date().toISOString(),
101
+ };
102
+ }
103
+ /**
104
+ * Drives progress heartbeats for the whole lifetime of a sync run - including
105
+ * phases that emit no worker output (the Intervals extractor and the Garmin
106
+ * JSONL import). `publish` throttles writes to the database and sidecar; a
107
+ * 30-second keep-alive interval refreshes the last known state so a run never
108
+ * looks stale while it is still active, even during the lock-holding import.
109
+ */
110
+ export class ProgressPump {
111
+ runId;
112
+ provider;
113
+ options;
114
+ lastProgress = null;
115
+ lastHeartbeatAt = 0;
116
+ interval = null;
117
+ stageOverride = null;
118
+ constructor(runId, provider, options) {
119
+ this.runId = runId;
120
+ this.provider = provider;
121
+ this.options = options;
122
+ }
123
+ start() {
124
+ if (this.interval)
125
+ return;
126
+ this.interval = setInterval(() => {
127
+ this.publish(this.lastProgress
128
+ ? { ...this.lastProgress, heartbeatAt: new Date().toISOString() }
129
+ : baselineState(this.runId, this.provider, this.stageOverride ?? 'starting'));
130
+ }, PROGRESS_KEEPALIVE_MS);
131
+ this.interval.unref();
132
+ }
133
+ publish(state) {
134
+ const now = Date.now();
135
+ if (this.lastHeartbeatAt !== 0 && now - this.lastHeartbeatAt < PROGRESS_PUBLISH_THROTTLE_MS) {
136
+ this.lastProgress = state;
137
+ return;
138
+ }
139
+ this.lastHeartbeatAt = now;
140
+ this.lastProgress = state;
141
+ const { database, paths, log } = this.options;
142
+ void database.heartbeatRun(this.runId, state).catch((error) => {
143
+ log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
144
+ });
145
+ void writeProgressSidecar(paths, this.runId, state).catch((error) => {
146
+ log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
147
+ });
148
+ }
149
+ /** Force subsequent keep-alive heartbeats to report the given stage. */
150
+ setStage(stage) {
151
+ this.stageOverride = stage;
152
+ if (this.lastProgress) {
153
+ this.lastProgress = { ...this.lastProgress, stage };
154
+ this.publish(this.lastProgress);
155
+ }
156
+ }
157
+ /** Transition the run to the JSONL import stage; keep-alives persist it. */
158
+ setStageImporting() {
159
+ this.setStage('importing');
160
+ }
161
+ /** Write a terminal heartbeat immediately, bypassing the publish throttle. */
162
+ publishFinal(state) {
163
+ this.lastHeartbeatAt = Date.now();
164
+ this.lastProgress = state;
165
+ const { database, paths, log } = this.options;
166
+ void database.heartbeatRun(this.runId, state).catch((error) => {
167
+ log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
168
+ });
169
+ void writeProgressSidecar(paths, this.runId, state).catch((error) => {
170
+ log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
171
+ });
172
+ }
173
+ stop() {
174
+ if (this.interval) {
175
+ clearInterval(this.interval);
176
+ this.interval = null;
177
+ }
178
+ }
179
+ }
87
180
  //# sourceMappingURL=progress-sidecar.js.map
@@ -2,7 +2,7 @@
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
4
  import { Command } from 'commander';
5
- import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
5
+ import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
6
6
  const program = new Command()
7
7
  .name('catence-data')
8
8
  .description('Athlete-scoped Catence data setup, extraction, and normalization.')
@@ -252,5 +252,30 @@ program.command('build-retrieval-index').description('Build derived local retrie
252
252
  const { paths } = await currentAthlete();
253
253
  process.stdout.write(`${JSON.stringify(await rebuildRetrievalIndex(paths), null, 2)}\n`);
254
254
  });
255
+ program.command('reimport-nutrition').description('Re-run normalization over captured nutrition entities without contacting providers.').action(async () => {
256
+ const { paths } = await currentAthlete();
257
+ process.stdout.write(`${JSON.stringify(await reimportNutrition(paths), null, 2)}\n`);
258
+ });
259
+ program
260
+ .command('import')
261
+ .description('Import a staged JSONL file (for example a swim-length backfill) into one athlete’s data store.')
262
+ .requiredOption('--provider <provider>', 'garmin, intervals, or strava')
263
+ .argument('<file>', 'path to the staged JSONL file')
264
+ .action(async (file, options) => {
265
+ const { paths } = await currentAthlete();
266
+ process.stdout.write(`${JSON.stringify(await importStagedFile(paths, options.provider, file), null, 2)}\n`);
267
+ });
268
+ program
269
+ .command('resolve-errors')
270
+ .description('Mark unresolved extraction errors as resolved (for example stale bootstrap failures from an environment that has since recovered).')
271
+ .option('--run <run-id>', 'resolve errors from a specific run only (repeatable)')
272
+ .option('--provider <provider>', 'resolve errors for garmin, intervals, or strava only')
273
+ .option('--before <timestamp>', 'resolve errors created before this ISO timestamp')
274
+ .option('--all', 'resolve every unresolved error')
275
+ .action(async (options) => {
276
+ const { paths } = await currentAthlete();
277
+ const runIds = Array.isArray(options.run) ? options.run : options.run ? [options.run] : undefined;
278
+ process.stdout.write(`${JSON.stringify(await resolveExtractionErrors(paths, { runIds, provider: options.provider, before: options.before, all: options.all }), null, 2)}\n`);
279
+ });
255
280
  await program.parseAsync();
256
281
  //# sourceMappingURL=commands.js.map
@@ -28,7 +28,7 @@ export { WELLNESS_METRICS, WellnessService } from '../core/query/wellness.js';
28
28
  export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
29
29
  export { queryReadOnlyData } from '../core/query/sql-guard.js';
30
30
  export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
31
- export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
31
+ export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
32
32
  export type { ProviderChoice } from '../elt/application/management.js';
33
33
  export type { InterruptedRuns, SyncProgressSnapshot, SyncProgressState } from '../contracts/progress.js';
34
34
  export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
@@ -15,7 +15,7 @@ export { WELLNESS_METRICS, WellnessService } from '../core/query/wellness.js';
15
15
  export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
16
16
  export { queryReadOnlyData } from '../core/query/sql-guard.js';
17
17
  export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
18
- export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
18
+ export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
19
19
  export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
20
20
  export { DataWriteBusyError } from '../elt/storage/write-lock.js';
21
21
  export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
@@ -209,6 +209,10 @@ for live runs and only touch the `sync_run_progress` table for recent-run
209
209
  history once the lock is free — so they keep working while a backfill holds the
210
210
  lock, and never crash with a lock error.
211
211
 
212
+ Progress also stays live through the entire run, not just worker extraction: a
213
+ 30-second keep-alive covers the JSONL import phase (which emits no worker
214
+ output) and the `intervals` phase of a provider-all sync or backfill.
215
+
212
216
  Other read-only tools (`status`, `query_read_only_data`, and friends) still
213
217
  need the database and therefore return a clean `data_sync_in_progress` error
214
218
  while a sync run is active. That is expected behavior: the store is locked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.0-beta.11",
3
+ "version": "0.2.0-beta.13",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -183,6 +183,44 @@ def fit_archive_to_samples(activity_source_id: str, archive: Path) -> list[dict[
183
183
  return []
184
184
 
185
185
 
186
+ def fit_archive_to_swim_lengths(activity_source_id: str, archive: Path) -> list[dict[str, Any]]:
187
+ """Best-effort per-length parsing from the FIT 'length' messages; malformed/unavailable archives yield no rows."""
188
+ try:
189
+ import fitdecode
190
+ with zipfile.ZipFile(archive) as zip_file:
191
+ fit_name = next((name for name in zip_file.namelist() if name.lower().endswith(".fit")), None)
192
+ if not fit_name:
193
+ return []
194
+ with zip_file.open(fit_name) as stream, fitdecode.FitReader(stream) as reader:
195
+ rows: list[dict[str, Any]] = []
196
+ for frame in reader:
197
+ if not isinstance(frame, fitdecode.records.FitDataMessage) or frame.name != "length":
198
+ continue
199
+ fields = {field.name: field.value for field in frame.fields}
200
+ length_type = str(fields.get("length_type") or "").lower()
201
+ start_time = fields.get("start_time") or fields.get("timestamp")
202
+ row = {
203
+ "lengthIndex": _number(fields.get("message_index")),
204
+ "lapIndex": None,
205
+ "startTime": start_time.isoformat().replace("+00:00", "Z") if hasattr(start_time, "isoformat") else None,
206
+ "durationS": _number(fields.get("total_elapsed_time") or fields.get("total_timer_time")),
207
+ "activeDurationS": _number(fields.get("total_timer_time")),
208
+ "distanceM": _number(fields.get("distance")),
209
+ "strokeCount": _number(fields.get("total_strokes")),
210
+ "strokeRate": _number(fields.get("avg_swimming_cadence") or fields.get("avg_cadence")),
211
+ "swolf": _number(fields.get("avg_swolf")),
212
+ "avgHr": _number(fields.get("avg_heart_rate") or fields.get("average_heart_rate")),
213
+ "maxHr": _number(fields.get("max_heart_rate")),
214
+ "isRest": length_type in {"rest", "idle", "pause"},
215
+ "label": str(fields.get("swim_stroke") or "").lower() or None,
216
+ "raw": {name: (value.isoformat().replace("+00:00", "Z") if hasattr(value, "isoformat") else value) for name, value in fields.items()},
217
+ }
218
+ rows.append(row)
219
+ return rows
220
+ except (ImportError, OSError, ValueError, zipfile.BadZipFile):
221
+ return []
222
+
223
+
186
224
  def write_parquet(data_dir: Path, activity_id: str, start_date: str, samples: Iterable[dict[str, Any]]) -> tuple[Path, str, int]:
187
225
  records = list(samples)
188
226
  year, month = (start_date[:4] or "unknown"), (start_date[5:7] or "unknown")
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from .staging import StagingWriter
7
+ from .streams import fit_archive_to_swim_lengths
8
+
9
+ SWIM_SPORTS = ("lap_swimming", "swimming", "open_water_swimming", "openwaterswimming")
10
+
11
+
12
+ def parse_args() -> argparse.Namespace:
13
+ parser = argparse.ArgumentParser(description="Backfill swim lengths from already-archived Garmin activity FIT files.")
14
+ parser.add_argument("--data-dir", required=True, type=Path)
15
+ parser.add_argument("--output", required=True, type=Path)
16
+ return parser.parse_args()
17
+
18
+
19
+ def main() -> None:
20
+ args = parse_args()
21
+ database_path = args.data_dir / "catence.duckdb"
22
+ if not database_path.exists():
23
+ raise SystemExit(f"Catalog database not found: {database_path}")
24
+ import duckdb
25
+
26
+ con = duckdb.connect(str(database_path), read_only=True)
27
+ rows = con.execute(
28
+ """
29
+ SELECT source.remote_activity_id, cast(activity.started_at_utc AS VARCHAR) AS started_at_utc
30
+ FROM activity_sources AS source
31
+ JOIN activities AS activity USING (activity_id)
32
+ WHERE source.provider = 'garmin'
33
+ AND lower(coalesce(activity.sport, '')) IN ({placeholders})
34
+ ORDER BY activity.started_at_utc
35
+ """.format(placeholders=", ".join(f"'{sport}'" for sport in SWIM_SPORTS)),
36
+ ).fetchall()
37
+ con.close()
38
+
39
+ writer = StagingWriter(args.data_dir, args.output)
40
+ staged = 0
41
+ for remote_id, started_at_utc in rows:
42
+ activity_id = str(remote_id)
43
+ zip_dir = args.data_dir / "raw" / "garmin" / "activity_original" / activity_id
44
+ if not zip_dir.is_dir():
45
+ continue
46
+ zips = sorted(zip_dir.glob("*.zip"))
47
+ if not zips:
48
+ continue
49
+ archive = zips[-1]
50
+ lengths = fit_archive_to_swim_lengths(f"garmin:{activity_id}", archive)
51
+ if not lengths:
52
+ continue
53
+ writer.source_entity(
54
+ "activity_swim_lengths",
55
+ f"{activity_id}:fit",
56
+ {"lengths": lengths},
57
+ archive.stem,
58
+ parent_remote_id=activity_id,
59
+ occurred_on=str(started_at_utc)[:10] if started_at_utc else None,
60
+ )
61
+ staged += 1
62
+ print(f"Staged swim lengths for {staged} activities; output: {args.output}")
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
@@ -9,7 +9,7 @@ from typing import Any, Callable, Mapping
9
9
  from .progress import ProgressReporter
10
10
  from .registry import GARMIN_READ_METHODS, assert_read_only_registry
11
11
  from .staging import StagingWriter
12
- from .streams import SAMPLE_COLUMNS, activity_details_to_samples, activity_power_bests, fit_archive_to_samples, write_parquet
12
+ from .streams import SAMPLE_COLUMNS, activity_details_to_samples, activity_power_bests, fit_archive_to_samples, fit_archive_to_swim_lengths, write_parquet
13
13
 
14
14
 
15
15
  class WorkerInterrupted(Exception):
@@ -556,6 +556,20 @@ class GarminStagingWorker:
556
556
  parent_remote_id=activity_id,
557
557
  occurred_on=start_date[:10],
558
558
  )
559
+ if original_path and original_path.exists():
560
+ sport = str(summary.get("sportType") or summary.get("sport") or "").lower()
561
+ if sport == "lap_swimming" or "swim" in sport:
562
+ lengths = fit_archive_to_swim_lengths(f"garmin:{activity_id}", original_path)
563
+ if lengths:
564
+ start_date = str(summary.get("startTimeLocal") or summary.get("startTimeGMT") or date.today().isoformat())
565
+ self.writer.source_entity(
566
+ "activity_swim_lengths",
567
+ f"{activity_id}:fit",
568
+ {"lengths": lengths},
569
+ original_hash,
570
+ parent_remote_id=activity_id,
571
+ occurred_on=start_date[:10],
572
+ )
559
573
 
560
574
  def _collections(self) -> None:
561
575
  today = date.today()