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.
- package/README.md +5 -5
- package/config.example.json +1 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/contracts/runtime.d.ts +4 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +20 -10
- package/dist/core/query/repository.d.ts +1 -1
- package/dist/core/query/repository.js +31 -2
- package/dist/core/query/sql-guard.js +37 -5
- package/dist/core/runtime/athlete-file.d.ts +49 -0
- package/dist/core/runtime/athlete-file.js +177 -0
- package/dist/core/runtime/configuration.js +2 -0
- package/dist/elt/application/management.d.ts +7 -0
- package/dist/elt/application/management.js +94 -5
- package/dist/elt/normalization/normalizers.js +122 -0
- package/dist/elt/storage/migrations.js +45 -1
- package/dist/elt/storage/progress-sidecar.d.ts +9 -0
- package/dist/elt/storage/progress-sidecar.js +31 -16
- package/dist/interfaces/cli/commands.js +5 -1
- package/dist/interfaces/http/server.js +141 -3
- package/dist/interfaces/mcp/server.js +106 -16
- package/dist/runtime/index.d.ts +3 -1
- package/dist/runtime/index.js +2 -1
- package/docs/architecture.md +1 -1
- package/docs/configuration.md +49 -0
- package/docs/deployment/docker.md +87 -8
- package/docs/deployment/local-console.md +124 -21
- package/docs/deployment/local-mcp.md +12 -5
- package/docs/development.md +5 -3
- package/docs/llm-providers.md +4 -4
- package/package.json +7 -5
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +31 -3
- package/python/catence/providers/garmin/progress.py +71 -35
- package/python/catence/providers/garmin/staging.py +11 -4
- package/python/catence/providers/garmin/worker.py +420 -206
- 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) =>
|
|
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
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
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 {
|