catence 0.2.0 → 0.2.1-beta.10

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 (38) hide show
  1. package/README.md +5 -5
  2. package/config.example.json +1 -0
  3. package/dist/contracts/release.d.ts +1 -1
  4. package/dist/contracts/release.js +1 -1
  5. package/dist/contracts/runtime.d.ts +4 -0
  6. package/dist/core/query/catalog.d.ts +1 -1
  7. package/dist/core/query/catalog.js +20 -10
  8. package/dist/core/query/repository.d.ts +1 -1
  9. package/dist/core/query/repository.js +31 -2
  10. package/dist/core/query/sql-guard.js +37 -5
  11. package/dist/core/runtime/athlete-file.d.ts +49 -0
  12. package/dist/core/runtime/athlete-file.js +177 -0
  13. package/dist/core/runtime/configuration.js +2 -0
  14. package/dist/elt/application/management.d.ts +7 -0
  15. package/dist/elt/application/management.js +94 -5
  16. package/dist/elt/normalization/normalizers.js +122 -0
  17. package/dist/elt/storage/migrations.js +45 -1
  18. package/dist/elt/storage/progress-sidecar.d.ts +9 -0
  19. package/dist/elt/storage/progress-sidecar.js +31 -16
  20. package/dist/interfaces/cli/commands.js +5 -1
  21. package/dist/interfaces/http/server.js +141 -3
  22. package/dist/interfaces/mcp/server.js +106 -16
  23. package/dist/runtime/index.d.ts +3 -1
  24. package/dist/runtime/index.js +2 -1
  25. package/docs/architecture.md +1 -1
  26. package/docs/configuration.md +49 -0
  27. package/docs/deployment/docker.md +87 -8
  28. package/docs/deployment/local-console.md +124 -21
  29. package/docs/deployment/local-mcp.md +12 -5
  30. package/docs/development.md +5 -3
  31. package/docs/llm-providers.md +4 -4
  32. package/package.json +7 -5
  33. package/pyproject.toml +1 -1
  34. package/python/catence/providers/garmin/cli.py +31 -3
  35. package/python/catence/providers/garmin/progress.py +71 -35
  36. package/python/catence/providers/garmin/staging.py +11 -4
  37. package/python/catence/providers/garmin/worker.py +420 -206
  38. package/uv.lock +3 -3
@@ -19,7 +19,7 @@ import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUr
19
19
  import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
20
20
  import { importSourceEntity } from '../normalization/normalizers.js';
21
21
  import { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../storage/database.js';
22
- import { mergeProgress, ProgressPump, readRunningProgress, removeProgressSidecar } from '../storage/progress-sidecar.js';
22
+ import { mergeProgress, ProgressPump, readRunningProgress, removeProgressSidecar, writeProgressSidecar, PROGRESS_KEEPALIVE_MS } from '../storage/progress-sidecar.js';
23
23
  import { withDataWriteLock } from '../storage/write-lock.js';
24
24
  function packageRoot() {
25
25
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
@@ -207,7 +207,21 @@ function runGarminWorker(runId, arguments_, environment, pump, log, onInterruptR
207
207
  }
208
208
  log.info(`[garmin] ${line}`);
209
209
  });
210
- buffer(child.stderr, (line) => log.error(`[garmin] ${line}`));
210
+ buffer(child.stderr, (line) => {
211
+ let parsed;
212
+ try {
213
+ parsed = JSON.parse(line);
214
+ }
215
+ catch {
216
+ log.error(`[garmin] ${line}`);
217
+ return;
218
+ }
219
+ if (typeof parsed === 'object' && parsed !== null && parsed.kind === 'stage_timings') {
220
+ log.info('Garmin staging worker stage timings', parsed);
221
+ return;
222
+ }
223
+ log.error(`[garmin] ${line}`);
224
+ });
211
225
  child.on('error', (error) => {
212
226
  if (killTimer)
213
227
  clearTimeout(killTimer);
@@ -310,7 +324,9 @@ async function syncProvider(database, paths, provider, options, log) {
310
324
  // streams even when the list-summary hash is unchanged.
311
325
  await writeFile(knownActivities, JSON.stringify(options.refreshActivities ? {} : await database.knownActivitySummaryHashes('garmin')));
312
326
  extractionStarted = true;
313
- const arguments_ = ['run', 'python', '-m', 'python.catence.providers.garmin.cli', '--from', fromDate, '--to', options.toDate, '--data-dir', paths.root, '--output', output, '--run-id', runId, '--known-activities', knownActivities];
327
+ const requestedConcurrency = Number.parseInt(process.env.CATENCE_GARMIN_CONCURRENCY ?? '5', 10);
328
+ const workerConcurrency = Number.isFinite(requestedConcurrency) && requestedConcurrency > 0 ? Math.trunc(requestedConcurrency) : 5;
329
+ const arguments_ = ['run', 'python', '-m', 'python.catence.providers.garmin.cli', '--from', fromDate, '--to', options.toDate, '--data-dir', paths.root, '--output', output, '--run-id', runId, '--known-activities', knownActivities, '--concurrency', String(workerConcurrency)];
314
330
  if (dailyWindow)
315
331
  arguments_.push('--daily-from', dailyWindow.fromDate, '--daily-to', dailyWindow.toDate);
316
332
  else
@@ -340,6 +356,7 @@ async function syncProvider(database, paths, provider, options, log) {
340
356
  if (needsEventBackfill && earliestActivity && earliestActivity < eventsShortFrom) {
341
357
  arguments_.push('--events-from', earliestActivity);
342
358
  }
359
+ const workerStartedAt = Date.now();
343
360
  let interruptChild = null;
344
361
  await runWithInterruptGuard(runId, database, () => interruptChild?.(), guard, async () => {
345
362
  const outcome = await runGarminWorker(runId, arguments_, {
@@ -350,14 +367,21 @@ async function syncProvider(database, paths, provider, options, log) {
350
367
  throw new Error(`Garmin staging worker exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
351
368
  }
352
369
  });
370
+ log.info('Garmin staging worker finished', { runId, durationMs: Date.now() - workerStartedAt, concurrency: workerConcurrency });
353
371
  if (!guard.interrupted) {
354
372
  if (!existsSync(output))
355
373
  throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
374
+ let phaseStartedAt = Date.now();
356
375
  await importJsonl(database, runId, output, log);
376
+ log.info('Garmin staging import finished', { runId, durationMs: Date.now() - phaseStartedAt });
377
+ phaseStartedAt = Date.now();
357
378
  await reconcileGarminCalendarEntities(database, output);
379
+ log.info('Garmin calendar reconciliation finished', { runId, durationMs: Date.now() - phaseStartedAt });
358
380
  if (provider === 'garmin') {
359
381
  try {
382
+ phaseStartedAt = Date.now();
360
383
  await backfillGarminSwimLengths(database, paths, runId, log);
384
+ log.info('Garmin swim-length backfill finished', { runId, durationMs: Date.now() - phaseStartedAt });
361
385
  }
362
386
  catch (error) {
363
387
  log.warn('Garmin swim-length backfill after sync failed', {
@@ -430,8 +454,43 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
430
454
  runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }, log));
431
455
  const executedRuns = runs.filter((run) => !run.skipped);
432
456
  if (executedRuns.length > 0) {
433
- const index = await buildRetrievalIndex(database);
434
- log.info('Rebuilt retrieval index after sync', { documents: index.documents, mode: index.mode, watermark: index.watermark });
457
+ // The per-provider pumps already stopped (sidecars removed, runs marked
458
+ // completed), but buildRetrievalIndex still holds the single-writer DB
459
+ // lock, so /dashboard reads as unavailable. Hold a non-terminal sidecar
460
+ // for the last executed run so /sync/status keeps reporting running and
461
+ // the Status Sync button stays disabled through local post-processing.
462
+ const last = [...executedRuns].reverse().find((run) => run.runId);
463
+ let indexingKeepalive = null;
464
+ const holdIndexingProgress = () => last?.runId
465
+ ? writeProgressSidecar(paths, last.runId, {
466
+ ...baseProgress(last.runId, last.provider, 'importing'),
467
+ currentStep: 'building retrieval index',
468
+ percentComplete: 99,
469
+ }).catch((error) => {
470
+ log.debug('Indexing progress sidecar write failed', {
471
+ runId: last.runId,
472
+ error: error instanceof Error ? error.message : String(error),
473
+ });
474
+ })
475
+ : Promise.resolve();
476
+ if (last?.runId) {
477
+ await holdIndexingProgress();
478
+ indexingKeepalive = setInterval(() => {
479
+ void holdIndexingProgress();
480
+ }, PROGRESS_KEEPALIVE_MS);
481
+ indexingKeepalive.unref?.();
482
+ }
483
+ try {
484
+ const indexStartedAt = Date.now();
485
+ const index = await buildRetrievalIndex(database);
486
+ log.info('Rebuilt retrieval index after sync', { documents: index.documents, mode: index.mode, watermark: index.watermark, durationMs: Date.now() - indexStartedAt });
487
+ }
488
+ finally {
489
+ if (indexingKeepalive)
490
+ clearInterval(indexingKeepalive);
491
+ if (last?.runId)
492
+ await removeProgressSidecar(paths, last.runId).catch(() => undefined);
493
+ }
435
494
  }
436
495
  return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs, timedOutRuns: timedOutRuns.runIds };
437
496
  }
@@ -688,6 +747,36 @@ export async function reimportNutrition(paths) {
688
747
  await database.close();
689
748
  }
690
749
  }
750
+ /**
751
+ * Re-run normalization over already-captured Garmin weigh-in entities without
752
+ * contacting any provider. Stores synced before beta.5 staged weigh_ins
753
+ * payloads but never projected them to daily_metrics, so this backfills
754
+ * weight_kg from stored payloads. Idempotent upserts make it safe to re-run.
755
+ */
756
+ export async function reimportWeight(paths) {
757
+ if (!existsSync(paths.database))
758
+ throw new Error('No Catence database exists yet. Complete a sync before re-importing weight.');
759
+ const database = await CatenceDatabase.open(paths);
760
+ try {
761
+ const rows = await database.rows(`SELECT provider, entity_type, remote_id, parent_remote_id, occurred_on, source_updated_at, raw_object_hash,
762
+ cast(payload_json AS VARCHAR) AS payload_json, cast(extension_json AS VARCHAR) AS extension_json
763
+ FROM source_entities
764
+ WHERE provider = 'garmin' AND entity_type = 'body_composition'
765
+ ORDER BY occurred_on, remote_id`);
766
+ for (const row of rows) {
767
+ await importSourceEntity(database, {
768
+ kind: 'source_entity', schemaVersion: 1, provider: row.provider, entityType: row.entity_type,
769
+ remoteId: row.remote_id, parentRemoteId: row.parent_remote_id, occurredOn: row.occurred_on, sourceUpdatedAt: row.source_updated_at,
770
+ rawObjectHash: row.raw_object_hash, payload: parseJsonObject(row.payload_json), extension: parseJsonObject(row.extension_json),
771
+ });
772
+ }
773
+ const weights = (await database.rows(`SELECT count(*) AS count FROM daily_metrics WHERE metric_name = 'weight_kg'`))[0]?.count ?? 0;
774
+ return { reimportedEntities: rows.length, weightObservations: Number(weights) };
775
+ }
776
+ finally {
777
+ await database.close();
778
+ }
779
+ }
691
780
  function parseJsonObject(value) {
692
781
  if (!value)
693
782
  return {};
@@ -254,6 +254,7 @@ export async function importSourceEntity(database, entity) {
254
254
  if (entity.entityType === 'activity') {
255
255
  await importActivity(database, entity.provider, entity.remoteId, payload, rawHash);
256
256
  await importActivityDecoupling(database, entity.provider, entity.remoteId, payload, rawHash);
257
+ await importActivityRunDynamics(database, entity.provider, entity.remoteId, payload, rawHash);
257
258
  if (entity.provider === 'intervals')
258
259
  await importIntervalsAutoSwimSets(database, entity.remoteId, payload, rawHash);
259
260
  if (entity.provider === 'garmin') {
@@ -271,6 +272,8 @@ export async function importSourceEntity(database, entity) {
271
272
  }
272
273
  if (entity.entityType === 'wellness' || entity.entityType === 'daily_health')
273
274
  await importDailyMetrics(database, entity.provider, entity.remoteId, entity.occurredOn, payload, rawHash);
275
+ if (entity.provider === 'garmin' && entity.entityType === 'body_composition')
276
+ await importGarminWeight(database, entity.occurredOn, payload, rawHash);
274
277
  if (entity.entityType === 'nutrition_day' || entity.entityType === 'nutrition_log')
275
278
  await importNutrition(database, entity.provider, entity.occurredOn, entity.remoteId, payload, rawHash);
276
279
  if (entity.provider === 'garmin' && entity.entityType === 'training_metric')
@@ -567,6 +570,67 @@ async function importActivityDecoupling(database, provider, remoteId, payload, r
567
570
  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
571
  }
569
572
  }
573
+ /** Containers searched for provider-supplied run dynamics: the payload itself,
574
+ * the multisport summary object, common nested analysis/metrics objects, and
575
+ * the stored metrics_json. Single-activity Garmin payloads carry flat keys;
576
+ * multisport legs carry the same fields under summaryDTO. */
577
+ function runDynamicsContainers(payload) {
578
+ const containers = [payload];
579
+ for (const key of ['summaryDTO', 'analysis', 'data', 'metrics', 'icuAnalysis', 'summaries']) {
580
+ const nested = payload[key];
581
+ if (nested !== null && typeof nested === 'object' && !Array.isArray(nested))
582
+ containers.push(nested);
583
+ }
584
+ const metricsJson = payload.metrics_json;
585
+ if (metricsJson !== undefined && metricsJson !== null)
586
+ containers.push(objectFromJson(metricsJson));
587
+ return containers;
588
+ }
589
+ const RUN_DYNAMICS_METRICS = [
590
+ { column: 'vertical_oscillation_cm', keys: ['avgVerticalOscillation', 'verticalOscillation'] },
591
+ { column: 'ground_contact_time_ms', keys: ['avgGctTime', 'groundContactTime', 'avgGroundContactTime'] },
592
+ { column: 'flight_time_ms', keys: ['avgFlightTime', 'flightTime'] },
593
+ { column: 'stride_length_cm', keys: ['avgStrideLength', 'strideLength'] },
594
+ { column: 'vertical_ratio_pct', keys: ['avgVerticalRatio', 'verticalRatio'] },
595
+ { column: 'cadence_spm', keys: ['averageRunningCadenceInStepsPerMinute', 'avgRunCadence', 'averageCadence'] },
596
+ ];
597
+ function firstReportedNumber(containers, keys) {
598
+ for (const container of containers) {
599
+ const found = firstNumber(container, keys);
600
+ if (found !== null)
601
+ return found;
602
+ }
603
+ return null;
604
+ }
605
+ /** A5 (persistence): persist provider-supplied running form metrics (vertical
606
+ * oscillation, ground contact time, flight time, stride length, vertical ratio,
607
+ * running cadence) so they are queryable across activities instead of only
608
+ * inside one activity's raw metrics_json. A row is stored only when the payload
609
+ * reported at least one dynamics value; null columns in a stored row mean the
610
+ * provider did not report that metric. */
611
+ async function importActivityRunDynamics(database, provider, remoteId, payload, rawHash) {
612
+ const containers = runDynamicsContainers(payload);
613
+ const reported = RUN_DYNAMICS_METRICS.map((spec) => firstReportedNumber(containers, spec.keys));
614
+ if (reported.every((value) => value === null))
615
+ return;
616
+ const values = Object.fromEntries(RUN_DYNAMICS_METRICS.map((spec, index) => [spec.column, reported[index]]));
617
+ const fields = activityFields(provider, payload);
618
+ await database.run(`INSERT INTO run_dynamics_facts
619
+ (provider, activity_source_id, sport, started_at_utc, vertical_oscillation_cm, ground_contact_time_ms, flight_time_ms, stride_length_cm, vertical_ratio_pct, cadence_spm, source_type, caveats_json, raw_object_hash)
620
+ VALUES ($provider, $activitySourceId, $sport, try_cast($startedAtUtc AS TIMESTAMPTZ), $verticalOscillation, $groundContactTime, $flightTime, $strideLength, $verticalRatio, $cadence, 'provider', $caveats, $rawHash)
621
+ ON CONFLICT (provider, activity_source_id) DO UPDATE SET
622
+ sport = excluded.sport, started_at_utc = excluded.started_at_utc,
623
+ vertical_oscillation_cm = excluded.vertical_oscillation_cm, ground_contact_time_ms = excluded.ground_contact_time_ms,
624
+ flight_time_ms = excluded.flight_time_ms, stride_length_cm = excluded.stride_length_cm,
625
+ vertical_ratio_pct = excluded.vertical_ratio_pct, cadence_spm = excluded.cadence_spm,
626
+ source_type = excluded.source_type, caveats_json = excluded.caveats_json, raw_object_hash = excluded.raw_object_hash`, {
627
+ provider, activitySourceId: providerActivityId(provider, remoteId), sport: fields.sport, startedAtUtc: fields.startAtUtc,
628
+ verticalOscillation: values.vertical_oscillation_cm, groundContactTime: values.ground_contact_time_ms,
629
+ flightTime: values.flight_time_ms, strideLength: values.stride_length_cm,
630
+ verticalRatio: values.vertical_ratio_pct, cadence: values.cadence_spm,
631
+ caveats: json({}), rawHash,
632
+ });
633
+ }
570
634
  /** Pull a course's ordered geometry samples, tolerating the several shapes a
571
635
  * Garmin course payload may use (a flat ``geoPoints`` array on the live course
572
636
  * endpoint, legacy ``coursePoints``, or points nested under a geo/route object).
@@ -968,6 +1032,64 @@ async function importDailyMetrics(database, provider, remoteId, date, payload, r
968
1032
  if (provider === 'garmin')
969
1033
  await importGarminDailyDetails(database, remoteId, metricDate, payload, rawHash);
970
1034
  }
1035
+ /**
1036
+ * Weight-only projection for Garmin weigh-ins. The range endpoint returns
1037
+ * `{dailyWeightSummaries: [{summaryDate, allWeightMetrics: [{weight, ...}]}]}`
1038
+ * with weight in grams, while dayview returns `{dateWeightList: [...]}`.
1039
+ * The worker stages a whole range window as one `body_composition` entity,
1040
+ * so dates are derived per entry here. Multiple weigh-ins on one day resolve
1041
+ * to the latest timestamp. Body-composition fields (fat, muscle, ...) are
1042
+ * deliberately ignored.
1043
+ */
1044
+ function weightKgFromValue(value) {
1045
+ if (value === null)
1046
+ return null;
1047
+ return value > 1000 ? value / 1000 : value;
1048
+ }
1049
+ function weightEntryDate(entry, fallbackDay) {
1050
+ // Prefer the parent summaryDate (Garmin's local day) over the entry's UTC
1051
+ // timestampGMT, which can fall on a different UTC date.
1052
+ return fallbackDay
1053
+ ?? datePart(firstString(entry, ['summaryDate', 'calendarDate', 'date']))
1054
+ ?? datePart(timestamp(entry.timestampGMT));
1055
+ }
1056
+ function collectGarminWeightEntries(payload, fallbackDay) {
1057
+ const entries = [];
1058
+ const pushEntry = (entry, fallback) => {
1059
+ const kg = weightKgFromValue(firstNumber(entry, ['weight', 'weightValue', 'weightInKg']));
1060
+ if (kg === null)
1061
+ return;
1062
+ const day = weightEntryDate(entry, fallback ?? fallbackDay);
1063
+ if (!day)
1064
+ return;
1065
+ const ts = typeof entry.timestampGMT === 'number' && Number.isFinite(entry.timestampGMT) ? entry.timestampGMT : 0;
1066
+ entries.push({ date: day, kg, ts });
1067
+ };
1068
+ for (const summary of objectArray(payload.dailyWeightSummaries)) {
1069
+ const day = datePart(firstString(summary, ['summaryDate', 'calendarDate', 'date'])) ?? fallbackDay;
1070
+ for (const metric of objectArray(summary.allWeightMetrics))
1071
+ pushEntry(metric, day);
1072
+ }
1073
+ for (const key of ['dateWeightList', 'allWeightMetrics']) {
1074
+ for (const entry of objectArray(payload[key]))
1075
+ pushEntry(entry, fallbackDay);
1076
+ }
1077
+ if (firstNumber(payload, ['weight', 'weightValue', 'weightInKg']) !== null)
1078
+ pushEntry(payload, fallbackDay);
1079
+ return entries;
1080
+ }
1081
+ async function importGarminWeight(database, date, payload, rawHash) {
1082
+ const fallbackDay = datePart(date);
1083
+ const latestByDay = new Map();
1084
+ for (const entry of collectGarminWeightEntries(payload, fallbackDay)) {
1085
+ const current = latestByDay.get(entry.date);
1086
+ if (!current || entry.ts >= current.ts)
1087
+ latestByDay.set(entry.date, { kg: entry.kg, ts: entry.ts });
1088
+ }
1089
+ for (const [metricDate, { kg }] of latestByDay) {
1090
+ await upsertDailyMetric(database, 'garmin', metricDate, 'weight_kg', kg, null, 'kg', rawHash);
1091
+ }
1092
+ }
971
1093
  async function importNutrition(database, provider, date, remoteId, payload, rawHash) {
972
1094
  const nutritionDate = datePart(date) ?? datePart(firstString(payload, ['id', 'date', 'calendarDate'])) ?? null;
973
1095
  if (!nutritionDate)
@@ -972,5 +972,49 @@ const activityDecouplingFactsMigration = {
972
972
  UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
973
973
  `,
974
974
  };
975
- export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration, syncRunProgressMigration, canonicalTrainingDerivedAvgPowerMigration, courseGeometryMigration, activityDecouplingFactsMigration];
975
+ const runDynamicsFactsMigration = {
976
+ version: 18,
977
+ name: 'run_dynamics_facts',
978
+ sql: `
979
+ -- A5 (persistence): make provider-supplied running dynamics queryable
980
+ -- historically instead of only inside one activity's raw metrics_json.
981
+ -- Values are provider-normalized running form metrics; a null column means
982
+ -- the provider did not report that metric for the activity. Only activities
983
+ -- with at least one reported value get a row. Single activities carry the
984
+ -- fields flat; multisport legs carry them nested under summaryDTO, and the
985
+ -- normalizer searches both shapes. Rows are written by the activity
986
+ -- normalizer on import (matching activity_decoupling, with no SQL backfill),
987
+ -- so previously imported activities gain rows when their source entity is
988
+ -- refreshed or re-imported; the upsert is idempotent.
989
+ CREATE TABLE IF NOT EXISTS run_dynamics_facts (
990
+ provider VARCHAR NOT NULL,
991
+ activity_source_id VARCHAR NOT NULL,
992
+ sport VARCHAR,
993
+ started_at_utc TIMESTAMPTZ,
994
+ vertical_oscillation_cm DOUBLE,
995
+ ground_contact_time_ms DOUBLE,
996
+ flight_time_ms DOUBLE,
997
+ stride_length_cm DOUBLE,
998
+ vertical_ratio_pct DOUBLE,
999
+ cadence_spm DOUBLE,
1000
+ source_type VARCHAR NOT NULL,
1001
+ caveats_json JSON,
1002
+ raw_object_hash VARCHAR,
1003
+ PRIMARY KEY (provider, activity_source_id)
1004
+ );
1005
+
1006
+ -- Join view so callers can filter or group by logical activity without
1007
+ -- hand-joining activity_sources/activities. Every facts row is current by
1008
+ -- construction (the primary key is the source row), so no ranking filter is
1009
+ -- applied and provider rows are never hidden.
1010
+ CREATE OR REPLACE VIEW run_dynamics AS
1011
+ SELECT facts.*, source.activity_id, source.remote_activity_id, activity.name
1012
+ FROM run_dynamics_facts AS facts
1013
+ JOIN activity_sources AS source USING (activity_source_id)
1014
+ JOIN activities AS activity USING (activity_id);
1015
+
1016
+ UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
1017
+ `,
1018
+ };
1019
+ export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration, syncRunProgressMigration, canonicalTrainingDerivedAvgPowerMigration, courseGeometryMigration, activityDecouplingFactsMigration, runDynamicsFactsMigration];
976
1020
  //# sourceMappingURL=migrations.js.map
@@ -48,7 +48,16 @@ export declare class ProgressPump {
48
48
  private lastHeartbeatAt;
49
49
  private interval;
50
50
  private stageOverride;
51
+ /**
52
+ * Serializes heartbeat writes in call order. publish() used to fire its
53
+ * database and sidecar writes without awaiting them, so a throttled write
54
+ * could win the sidecar rename race against a later publishFinal() and
55
+ * leave a stale non-terminal stage behind.
56
+ */
57
+ private writeTail;
51
58
  constructor(runId: string, provider: string, options: ProgressPumpOptions);
59
+ private enqueueHeartbeat;
60
+ private writeHeartbeat;
52
61
  start(): void;
53
62
  publish(state: SyncProgressState): void;
54
63
  /** Force subsequent keep-alive heartbeats to report the given stage. */
@@ -133,11 +133,36 @@ export class ProgressPump {
133
133
  lastHeartbeatAt = 0;
134
134
  interval = null;
135
135
  stageOverride = null;
136
+ /**
137
+ * Serializes heartbeat writes in call order. publish() used to fire its
138
+ * database and sidecar writes without awaiting them, so a throttled write
139
+ * could win the sidecar rename race against a later publishFinal() and
140
+ * leave a stale non-terminal stage behind.
141
+ */
142
+ writeTail = Promise.resolve();
136
143
  constructor(runId, provider, options) {
137
144
  this.runId = runId;
138
145
  this.provider = provider;
139
146
  this.options = options;
140
147
  }
148
+ enqueueHeartbeat(state) {
149
+ const run = this.writeTail.then(() => this.writeHeartbeat(state));
150
+ // A write failure is already logged inside writeHeartbeat; never let it
151
+ // break the chain (or surface as an unhandled rejection) for later beats.
152
+ this.writeTail = run.then(() => undefined, () => undefined);
153
+ return run;
154
+ }
155
+ async writeHeartbeat(state) {
156
+ const { database, paths, log } = this.options;
157
+ await Promise.all([
158
+ database.heartbeatRun(this.runId, state).catch((error) => {
159
+ log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
160
+ }),
161
+ writeProgressSidecar(paths, this.runId, state).catch((error) => {
162
+ log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
163
+ }),
164
+ ]);
165
+ }
141
166
  start() {
142
167
  if (this.interval)
143
168
  return;
@@ -156,13 +181,7 @@ export class ProgressPump {
156
181
  }
157
182
  this.lastHeartbeatAt = now;
158
183
  this.lastProgress = state;
159
- const { database, paths, log } = this.options;
160
- void database.heartbeatRun(this.runId, state).catch((error) => {
161
- log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
162
- });
163
- void writeProgressSidecar(paths, this.runId, state).catch((error) => {
164
- log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
165
- });
184
+ void this.enqueueHeartbeat(state).catch(() => undefined);
166
185
  }
167
186
  /** Force subsequent keep-alive heartbeats to report the given stage. */
168
187
  setStage(stage) {
@@ -180,15 +199,11 @@ export class ProgressPump {
180
199
  async publishFinal(state) {
181
200
  this.lastHeartbeatAt = Date.now();
182
201
  this.lastProgress = state;
183
- const { database, paths, log } = this.options;
184
- // Both terminal writes complete before the caller removes the sidecar, so
185
- // a finished run can never resurrect its own progress file after cleanup.
186
- await database.heartbeatRun(this.runId, state).catch((error) => {
187
- log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
188
- });
189
- await writeProgressSidecar(paths, this.runId, state).catch((error) => {
190
- log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
191
- });
202
+ // Awaiting the chained write guarantees every earlier heartbeat (in
203
+ // particular a throttled publish still in flight) lands first, so the
204
+ // terminal stage is always the last one persisted before the caller
205
+ // removes the sidecar.
206
+ await this.enqueueHeartbeat(state);
192
207
  }
193
208
  stop() {
194
209
  if (this.interval) {
@@ -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, CatenceDatabase, 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';
5
+ import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, CatenceDatabase, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, reimportWeight, 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.')
@@ -256,6 +256,10 @@ program.command('reimport-nutrition').description('Re-run normalization over cap
256
256
  const { paths } = await currentAthlete();
257
257
  process.stdout.write(`${JSON.stringify(await reimportNutrition(paths), null, 2)}\n`);
258
258
  });
259
+ program.command('reimport-weight').description('Backfill weight_kg from captured Garmin weigh-in entities without contacting providers.').action(async () => {
260
+ const { paths } = await currentAthlete();
261
+ process.stdout.write(`${JSON.stringify(await reimportWeight(paths), null, 2)}\n`);
262
+ });
259
263
  program
260
264
  .command('import')
261
265
  .description('Import a staged JSONL file (for example a swim-length backfill) into one athlete’s data store.')
@@ -1,7 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import path from 'node:path';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
- import { catenceRuntimeHealth, DashboardSnapshotService, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, } from '../../runtime/index.js';
4
+ import { addAthlete, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, catenceRuntimeHealth, DashboardSnapshotService, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, listAthleteFileRevisions, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, readAthleteFile, readAthleteFileRevision, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, updateAthleteFile, } from '../../runtime/index.js';
5
5
  import { createCatenceMcpServer } from '../mcp/server.js';
6
6
  import { generationStatus } from './generation-status.js';
7
7
  const MAX_REQUEST_BYTES = 1_000_000;
@@ -18,8 +18,8 @@ function addCorsHeaders(request, response, allowedOrigins) {
18
18
  return false;
19
19
  response.setHeader('access-control-allow-origin', origin);
20
20
  response.setHeader('vary', 'Origin');
21
- response.setHeader('access-control-allow-methods', 'GET, POST, DELETE, OPTIONS');
22
- response.setHeader('access-control-allow-headers', 'content-type, mcp-session-id');
21
+ response.setHeader('access-control-allow-methods', 'GET, POST, PUT, DELETE, OPTIONS');
22
+ response.setHeader('access-control-allow-headers', 'content-type, if-match, mcp-session-id');
23
23
  return true;
24
24
  }
25
25
  async function readJsonBody(request) {
@@ -112,6 +112,49 @@ function parseSyncBody(body) {
112
112
  }
113
113
  return raw;
114
114
  }
115
+ function parseAthleteCreateBody(body) {
116
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
117
+ throw new Error('Request body must be a JSON object.');
118
+ const raw = body;
119
+ for (const key of Object.keys(raw)) {
120
+ if (!['id', 'label', 'setDefault'].includes(key))
121
+ throw new Error(`Unknown athlete field: ${key}.`);
122
+ }
123
+ if (typeof raw.id !== 'string' || !raw.id.trim())
124
+ throw new Error('id is required.');
125
+ if (typeof raw.label !== 'string' || !raw.label.trim())
126
+ throw new Error('label is required.');
127
+ if (raw.setDefault !== undefined && typeof raw.setDefault !== 'boolean')
128
+ throw new Error('setDefault must be a boolean.');
129
+ return { id: raw.id.trim(), label: raw.label.trim(), setDefault: raw.setDefault === true };
130
+ }
131
+ function parseAthleteFileBody(body) {
132
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
133
+ throw new Error('Request body must be a JSON object.');
134
+ const raw = body;
135
+ for (const key of Object.keys(raw)) {
136
+ if (!['operation', 'section', 'content', 'expectedHash'].includes(key))
137
+ throw new Error(`Unknown athlete-file field: ${key}.`);
138
+ }
139
+ if (typeof raw.content !== 'string')
140
+ throw new Error('content must be a string.');
141
+ const operation = raw.operation ?? 'replace';
142
+ if (operation !== 'replace' && operation !== 'append' && operation !== 'replace_section') {
143
+ throw new Error('operation must be replace, append, or replace_section.');
144
+ }
145
+ if (raw.section !== undefined && typeof raw.section !== 'string')
146
+ throw new Error('section must be a string.');
147
+ if (raw.expectedHash !== undefined && raw.expectedHash !== null && typeof raw.expectedHash !== 'string') {
148
+ throw new Error('expectedHash must be a string or null.');
149
+ }
150
+ return {
151
+ operation,
152
+ section: raw.section,
153
+ content: raw.content,
154
+ hasExpectedHash: Object.prototype.hasOwnProperty.call(raw, 'expectedHash'),
155
+ expectedHash: (raw.expectedHash ?? null),
156
+ };
157
+ }
115
158
  async function resolveSyncPaths(catalogPaths, staticPaths, body) {
116
159
  if (catalogPaths) {
117
160
  if (!body.athleteId)
@@ -258,6 +301,33 @@ export function createCatenceHttpServer(options = {}) {
258
301
  }
259
302
  return;
260
303
  }
304
+ if (pathname === '/api/v1/athletes' && request.method === 'POST') {
305
+ if (!catalogPaths) {
306
+ json(response, 400, {
307
+ error: {
308
+ code: 'catalog_required',
309
+ message: 'Adding athletes requires a Catence catalog; this server uses a single legacy data store.',
310
+ },
311
+ });
312
+ return;
313
+ }
314
+ try {
315
+ const body = parseAthleteCreateBody(await readJsonBody(request));
316
+ const catalog = await loadCatalog(catalogPaths);
317
+ if (catalog.athletes.some((athlete) => athlete.id === body.id)) {
318
+ json(response, 409, { error: { code: 'athlete_exists', message: `Athlete ${body.id} already exists.` } });
319
+ return;
320
+ }
321
+ const updated = await addAthlete(catalogPaths, body);
322
+ json(response, 201, { defaultAthleteId: updated.defaultAthleteId, athletes: updated.athletes });
323
+ }
324
+ catch (error) {
325
+ json(response, 400, {
326
+ error: { code: 'athlete_create_failed', message: error instanceof Error ? error.message : String(error) },
327
+ });
328
+ }
329
+ return;
330
+ }
261
331
  if (pathname === '/api/v1/dashboard' && request.method === 'GET') {
262
332
  try {
263
333
  const url = new URL(request.url ?? '/', 'http://localhost');
@@ -268,6 +338,74 @@ export function createCatenceHttpServer(options = {}) {
268
338
  }
269
339
  return;
270
340
  }
341
+ if (pathname === '/api/v1/athlete-file' && request.method === 'GET') {
342
+ try {
343
+ const url = new URL(request.url ?? '/', 'http://localhost');
344
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url);
345
+ const revisionId = url.searchParams.get('revision');
346
+ if (revisionId) {
347
+ let content;
348
+ try {
349
+ content = await readAthleteFileRevision(paths, revisionId);
350
+ }
351
+ catch (error) {
352
+ if (!(error instanceof AthleteFileValidationError))
353
+ throw error;
354
+ content = null;
355
+ }
356
+ if (content === null) {
357
+ json(response, 404, { error: { code: 'athlete_file_revision_not_found', message: 'No athlete-file revision matches this identifier.' } });
358
+ return;
359
+ }
360
+ json(response, 200, { revisionId, content });
361
+ return;
362
+ }
363
+ const snapshot = await readAthleteFile(paths);
364
+ json(response, 200, { ...snapshot, revisions: await listAthleteFileRevisions(paths) });
365
+ }
366
+ catch (error) {
367
+ json(response, 400, { error: { code: 'athlete_file_unavailable', message: error instanceof Error ? error.message : String(error) } });
368
+ }
369
+ return;
370
+ }
371
+ if (pathname === '/api/v1/athlete-file' && request.method === 'PUT') {
372
+ try {
373
+ const url = new URL(request.url ?? '/', 'http://localhost');
374
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url);
375
+ const parsed = parseAthleteFileBody(await readJsonBody(request));
376
+ const header = request.headers['if-match'];
377
+ const headerHash = typeof header === 'string' && header.trim() ? header.trim() : undefined;
378
+ if (!headerHash && !parsed.hasExpectedHash) {
379
+ throw new AthleteFileValidationError('expectedHash is required: send the If-Match header or the expectedHash body field (null to create the file).');
380
+ }
381
+ const snapshot = await updateAthleteFile(paths, {
382
+ operation: parsed.operation,
383
+ section: parsed.section,
384
+ content: parsed.content,
385
+ expectedHash: headerHash ?? parsed.expectedHash,
386
+ });
387
+ json(response, 200, snapshot);
388
+ }
389
+ catch (error) {
390
+ if (error instanceof AthleteFileConflictError) {
391
+ json(response, 409, {
392
+ error: {
393
+ code: 'athlete_file_conflict',
394
+ message: error.message,
395
+ currentHash: error.currentHash,
396
+ currentContent: error.currentContent,
397
+ },
398
+ });
399
+ }
400
+ else if (error instanceof AthleteFileTooLargeError || error instanceof AthleteFileValidationError) {
401
+ json(response, 400, { error: { code: 'invalid_request', message: error.message } });
402
+ }
403
+ else {
404
+ json(response, 400, { error: { code: 'athlete_file_write_failed', message: error instanceof Error ? error.message : String(error) } });
405
+ }
406
+ }
407
+ return;
408
+ }
271
409
  if (pathname === '/api/v1/sync' && request.method === 'POST') {
272
410
  let body;
273
411
  try {