catence 0.2.0-beta.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -180
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/progress.d.ts +23 -0
- package/dist/contracts/progress.js +49 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/query/activity-discovery.d.ts +2 -0
- package/dist/core/query/activity-discovery.js +1 -1
- package/dist/core/query/analytics.d.ts +10 -0
- package/dist/core/query/analytics.js +494 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +8 -4
- package/dist/core/query/events.d.ts +34 -0
- package/dist/core/query/events.js +121 -0
- package/dist/core/query/fitness.d.ts +4 -0
- package/dist/core/query/fitness.js +60 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +48 -0
- package/dist/core/query/swimming.js +2 -2
- package/dist/core/runtime/configuration.d.ts +3 -1
- package/dist/core/runtime/configuration.js +5 -1
- package/dist/core/runtime/opencode-go.d.ts +58 -0
- package/dist/core/runtime/opencode-go.js +246 -0
- package/dist/core/runtime/secrets.js +51 -13
- package/dist/elt/application/detached-sync.d.ts +50 -0
- package/dist/elt/application/detached-sync.js +99 -0
- package/dist/elt/application/management.d.ts +15 -0
- package/dist/elt/application/management.js +420 -18
- package/dist/elt/ingestion/importer.js +1 -1
- package/dist/elt/ingestion/providers/intervals.d.ts +9 -0
- package/dist/elt/ingestion/providers/intervals.js +33 -1
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +19 -1
- package/dist/elt/normalization/normalizers.js +207 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +182 -6
- package/dist/elt/storage/migrations.js +145 -1
- package/dist/elt/storage/progress-sidecar.d.ts +61 -0
- package/dist/elt/storage/progress-sidecar.js +200 -0
- package/dist/interfaces/cli/commands.js +94 -1
- package/dist/interfaces/http/generation-status.d.ts +19 -0
- package/dist/interfaces/http/generation-status.js +55 -0
- package/dist/interfaces/http/server.d.ts +7 -1
- package/dist/interfaces/http/server.js +184 -1
- package/dist/interfaces/mcp/main.js +33 -1
- package/dist/interfaces/mcp/server.d.ts +2 -1
- package/dist/interfaces/mcp/server.js +168 -45
- package/dist/runtime/index.d.ts +9 -2
- package/dist/runtime/index.js +6 -2
- package/docs/architecture.md +13 -2
- package/docs/assets/image.png +0 -0
- package/docs/configuration.md +243 -0
- package/docs/deployment/docker.md +528 -0
- package/docs/deployment/local-console.md +263 -0
- package/docs/deployment/local-mcp.md +329 -0
- package/docs/development.md +337 -0
- package/docs/distribution.md +45 -0
- package/docs/llm-providers.md +24 -2
- package/docs/mcp-activity-retrieval.md +90 -210
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +43 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/registry.py +2 -0
- package/python/catence/providers/garmin/streams.py +38 -0
- package/python/catence/providers/garmin/swim_backfill.py +83 -0
- package/python/catence/providers/garmin/worker.py +415 -12
- package/scripts/discover-opencode-go.mjs +36 -144
- package/uv.lock +3 -3
- package/docs/deployment.md +0 -338
|
@@ -63,7 +63,8 @@ function swimSourceLabel(payload) {
|
|
|
63
63
|
function datePart(value) {
|
|
64
64
|
if (!value)
|
|
65
65
|
return null;
|
|
66
|
-
const
|
|
66
|
+
const s = typeof value === 'string' ? value : value.toISOString();
|
|
67
|
+
const match = s.match(/^\d{4}-\d{2}-\d{2}/);
|
|
67
68
|
return match?.[0] ?? null;
|
|
68
69
|
}
|
|
69
70
|
function providerActivityId(provider, remoteId) {
|
|
@@ -143,6 +144,8 @@ function snake(value) {
|
|
|
143
144
|
return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
|
|
144
145
|
}
|
|
145
146
|
function timestamp(value) {
|
|
147
|
+
if (value instanceof Date)
|
|
148
|
+
return value.toISOString();
|
|
146
149
|
if (typeof value === 'string' && value.length >= 10)
|
|
147
150
|
return value;
|
|
148
151
|
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
@@ -163,30 +166,69 @@ async function upsertDailyMetric(database, provider, metricDate, metricName, val
|
|
|
163
166
|
ON CONFLICT (provider, metric_date, metric_name) DO UPDATE SET value_number = excluded.value_number, value_text = excluded.value_text,
|
|
164
167
|
unit = excluded.unit, raw_object_hash = excluded.raw_object_hash`, { provider, metricDate, metricName, valueNumber, valueText, unit, rawHash });
|
|
165
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Garmin's nutrition food log keeps its actuals under dailyNutritionContent
|
|
171
|
+
* (a per-day rolled-up object), while meal definitions and hydration endpoints
|
|
172
|
+
* share the same source entity type. Flat keys are kept for providers that
|
|
173
|
+
* return them directly.
|
|
174
|
+
*/
|
|
166
175
|
function nutritionFields(payload) {
|
|
176
|
+
const content = object(payload.dailyNutritionContent);
|
|
167
177
|
return {
|
|
168
|
-
energyKcal: firstNumber(payload, ['kcalConsumed', 'calories', 'totalCalories', 'energyKcal']),
|
|
169
|
-
carbohydratesG: firstNumber(payload, ['carbohydrates', 'carbs', 'totalCarbs', 'carbohydrateGrams']),
|
|
170
|
-
proteinG: firstNumber(payload, ['protein', 'totalProtein', 'proteinGrams']),
|
|
171
|
-
fatG: firstNumber(payload, ['fatTotal', 'fat', 'totalFat', 'fatGrams']),
|
|
172
|
-
hydrationMl: firstNumber(payload, ['hydrationVolume', 'hydration', 'waterMl', 'totalWater']),
|
|
178
|
+
energyKcal: firstNumber(payload, ['kcalConsumed', 'calories', 'totalCalories', 'energyKcal']) ?? firstNumber(content, ['calories', 'energyKcal', 'totalCalories']),
|
|
179
|
+
carbohydratesG: firstNumber(payload, ['carbohydrates', 'carbs', 'totalCarbs', 'carbohydrateGrams']) ?? firstNumber(content, ['carbohydrates', 'carbs']),
|
|
180
|
+
proteinG: firstNumber(payload, ['protein', 'totalProtein', 'proteinGrams']) ?? firstNumber(content, ['protein']),
|
|
181
|
+
fatG: firstNumber(payload, ['fatTotal', 'fat', 'totalFat', 'fatGrams']) ?? firstNumber(content, ['fat', 'fatTotal']),
|
|
182
|
+
hydrationMl: firstNumber(payload, ['hydrationVolume', 'hydration', 'waterMl', 'totalWater', 'valueInML', 'dailyAverageinML']),
|
|
173
183
|
};
|
|
174
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Garmin food-log items live under mealDetails[].foods[]. Each food carries the
|
|
187
|
+
* day's nutrition_date, so a meal label is folded onto the item to avoid losing
|
|
188
|
+
* it. Flat arrays are kept for providers that return items directly.
|
|
189
|
+
*/
|
|
175
190
|
function nutritionItems(payload) {
|
|
176
|
-
const
|
|
177
|
-
|
|
191
|
+
const items = [];
|
|
192
|
+
const mealDetails = payload.mealDetails;
|
|
193
|
+
if (Array.isArray(mealDetails)) {
|
|
194
|
+
for (const detail of mealDetails) {
|
|
195
|
+
const mealDetail = object(detail);
|
|
196
|
+
const mealName = firstString(object(mealDetail.meal), ['mealName']) ?? firstString(mealDetail, ['mealName']);
|
|
197
|
+
const foods = mealDetail.foods;
|
|
198
|
+
if (Array.isArray(foods)) {
|
|
199
|
+
for (const food of foods) {
|
|
200
|
+
const item = object(food);
|
|
201
|
+
if (Object.keys(item).length === 0)
|
|
202
|
+
continue;
|
|
203
|
+
items.push(mealName ? { ...item, mealName } : item);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else if (Object.keys(mealDetail).length > 0 && firstString(mealDetail, ['foodName', 'name', 'description']) !== null) {
|
|
207
|
+
items.push(mealName ? { ...mealDetail, mealName } : mealDetail);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (items.length > 0)
|
|
211
|
+
return items;
|
|
212
|
+
}
|
|
213
|
+
for (const key of ['foodItems', 'items', 'entries', 'foodLogEntries', 'meals']) {
|
|
178
214
|
const value = payload[key];
|
|
179
215
|
if (Array.isArray(value))
|
|
180
216
|
return value.map(object).filter((item) => Object.keys(item).length > 0);
|
|
181
217
|
}
|
|
182
218
|
return [];
|
|
183
219
|
}
|
|
220
|
+
/** True when a payload contributes real nutrition facts rather than meal definitions or hydration-only bookkeeping. */
|
|
221
|
+
function hasNutritionContent(payload) {
|
|
222
|
+
const fields = nutritionFields(payload);
|
|
223
|
+
return fields.energyKcal !== null || fields.carbohydratesG !== null || fields.proteinG !== null || fields.fatG !== null
|
|
224
|
+
|| fields.hydrationMl !== null || nutritionItems(payload).length > 0;
|
|
225
|
+
}
|
|
184
226
|
export async function importSourceEntity(database, entity) {
|
|
185
227
|
const payload = object(entity.payload);
|
|
186
228
|
const rawHash = entity.rawObjectHash;
|
|
187
229
|
await database.run(`INSERT INTO source_entities
|
|
188
230
|
(provider, entity_type, remote_id, parent_remote_id, occurred_on, source_updated_at, raw_object_hash, payload_json, extension_json)
|
|
189
|
-
VALUES ($provider, $entityType, $remoteId, $parentRemoteId, $occurredOn, $sourceUpdatedAt, $rawHash, $payload, $extension)
|
|
231
|
+
VALUES ($provider::VARCHAR, $entityType::VARCHAR, $remoteId::VARCHAR, $parentRemoteId::VARCHAR, $occurredOn::TIMESTAMPTZ, $sourceUpdatedAt::TIMESTAMPTZ, $rawHash::VARCHAR, $payload::JSON, $extension::JSON)
|
|
190
232
|
ON CONFLICT (provider, entity_type, remote_id) DO UPDATE SET
|
|
191
233
|
parent_remote_id = excluded.parent_remote_id, occurred_on = excluded.occurred_on,
|
|
192
234
|
source_updated_at = excluded.source_updated_at, raw_object_hash = excluded.raw_object_hash,
|
|
@@ -197,7 +239,7 @@ export async function importSourceEntity(database, entity) {
|
|
|
197
239
|
});
|
|
198
240
|
await database.run(`INSERT INTO domain_entities
|
|
199
241
|
(provider, entity_type, remote_id, parent_remote_id, occurred_on, payload_json, extension_json, raw_object_hash)
|
|
200
|
-
VALUES ($provider, $entityType, $remoteId, $parentRemoteId, $occurredOn, $payload, $extension, $rawHash)
|
|
242
|
+
VALUES ($provider::VARCHAR, $entityType::VARCHAR, $remoteId::VARCHAR, $parentRemoteId::VARCHAR, $occurredOn::TIMESTAMPTZ, $payload::JSON, $extension::JSON, $rawHash::VARCHAR)
|
|
201
243
|
ON CONFLICT (provider, entity_type, remote_id) DO UPDATE SET
|
|
202
244
|
parent_remote_id = excluded.parent_remote_id, occurred_on = excluded.occurred_on,
|
|
203
245
|
payload_json = excluded.payload_json, extension_json = excluded.extension_json, raw_object_hash = excluded.raw_object_hash`, {
|
|
@@ -211,6 +253,7 @@ export async function importSourceEntity(database, entity) {
|
|
|
211
253
|
}
|
|
212
254
|
if (entity.entityType === 'activity') {
|
|
213
255
|
await importActivity(database, entity.provider, entity.remoteId, payload, rawHash);
|
|
256
|
+
await importActivityDecoupling(database, entity.provider, entity.remoteId, payload, rawHash);
|
|
214
257
|
if (entity.provider === 'intervals')
|
|
215
258
|
await importIntervalsAutoSwimSets(database, entity.remoteId, payload, rawHash);
|
|
216
259
|
if (entity.provider === 'garmin') {
|
|
@@ -223,6 +266,9 @@ export async function importSourceEntity(database, entity) {
|
|
|
223
266
|
if (entity.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set'].includes(entity.entityType)) {
|
|
224
267
|
await importGarminExplicitSwimLengths(database, entity.parentRemoteId, entity.entityType, payload, rawHash);
|
|
225
268
|
}
|
|
269
|
+
if (entity.provider === 'garmin' && entity.entityType === 'activity_swim_lengths') {
|
|
270
|
+
await importGarminFitSwimLengths(database, entity.parentRemoteId, payload, rawHash);
|
|
271
|
+
}
|
|
226
272
|
if (entity.entityType === 'wellness' || entity.entityType === 'daily_health')
|
|
227
273
|
await importDailyMetrics(database, entity.provider, entity.remoteId, entity.occurredOn, payload, rawHash);
|
|
228
274
|
if (entity.entityType === 'nutrition_day' || entity.entityType === 'nutrition_log')
|
|
@@ -245,6 +291,8 @@ export async function importSourceEntity(database, entity) {
|
|
|
245
291
|
await importHealthSession(database, entity.remoteId, entity.occurredOn, 'daily_event', payload, rawHash);
|
|
246
292
|
if (entity.provider === 'garmin' && entity.entityType === 'activity_power_best')
|
|
247
293
|
await importActivityPowerBest(database, entity.parentRemoteId, payload, rawHash);
|
|
294
|
+
if (entity.provider === 'garmin' && entity.entityType === 'course')
|
|
295
|
+
await importGarminCourse(database, entity.remoteId, payload, rawHash);
|
|
248
296
|
}
|
|
249
297
|
async function importActivity(database, provider, remoteId, payload, rawHash) {
|
|
250
298
|
const fields = activityFields(provider, payload);
|
|
@@ -372,7 +420,7 @@ function nested(payload, key) {
|
|
|
372
420
|
return object(payload[key]);
|
|
373
421
|
}
|
|
374
422
|
function metricObservedAt(occurredOn, payload) {
|
|
375
|
-
return firstTimestamp(payload, ['timestamp', 'timestampLocal', 'calendarDate', 'date']) ?? (occurredOn ? `${occurredOn}T00:00:00Z` : null);
|
|
423
|
+
return firstTimestamp(payload, ['timestamp', 'timestampLocal', 'calendarDate', 'date']) ?? (occurredOn ? `${datePart(occurredOn)}T00:00:00Z` : null);
|
|
376
424
|
}
|
|
377
425
|
async function importGarminActivityPerformance(database, remoteId, payload, rawHash) {
|
|
378
426
|
const fields = activityFields('garmin', payload);
|
|
@@ -474,6 +522,99 @@ async function importGarminNumericSeries(database, entityType, remoteId, occurre
|
|
|
474
522
|
});
|
|
475
523
|
}
|
|
476
524
|
}
|
|
525
|
+
const DECOUPLING_METRICS = [
|
|
526
|
+
{ metric: 'aerobic_decoupling_pct', unit: 'pct', keys: ['decoupling', 'aerobicDecoupling', 'icu_decoupling', 'paHrDecoupling', 'pwHrDecoupling', 'aerobic_decoupling'] },
|
|
527
|
+
{ metric: 'grade_adjusted_pace_s_per_km', unit: 's_per_km', keys: ['gap', 'gradeAdjustedPace', 'icu_gap', 'grade_adjusted_pace'] },
|
|
528
|
+
];
|
|
529
|
+
/** Containers searched for provider-supplied decoupling/GAP: the payload itself,
|
|
530
|
+
* its common nested analysis/metrics objects, and the stored metrics_json. */
|
|
531
|
+
function decouplingContainers(payload) {
|
|
532
|
+
const containers = [payload];
|
|
533
|
+
for (const key of ['analysis', 'data', 'metrics', 'icuAnalysis', 'summaries']) {
|
|
534
|
+
const nested = payload[key];
|
|
535
|
+
if (nested !== null && typeof nested === 'object' && !Array.isArray(nested))
|
|
536
|
+
containers.push(nested);
|
|
537
|
+
}
|
|
538
|
+
const metricsJson = payload.metrics_json;
|
|
539
|
+
if (metricsJson !== undefined && metricsJson !== null)
|
|
540
|
+
containers.push(objectFromJson(metricsJson));
|
|
541
|
+
return containers;
|
|
542
|
+
}
|
|
543
|
+
/** A3 (persistence): persist provider-supplied aerobic decoupling (Pa:Hr) and
|
|
544
|
+
* grade-adjusted pace from an activity payload so they are queryable historically
|
|
545
|
+
* instead of only inside one activity's raw metrics_json. Provider values are
|
|
546
|
+
* stored as source_type 'provider' and are authoritative. Locally derived values
|
|
547
|
+
* (deriveDecoding/deriveGap) are a separate, still-derived path. */
|
|
548
|
+
async function importActivityDecoupling(database, provider, remoteId, payload, rawHash) {
|
|
549
|
+
const activitySourceId = providerActivityId(provider, remoteId);
|
|
550
|
+
const fields = activityFields(provider, payload);
|
|
551
|
+
const containers = decouplingContainers(payload);
|
|
552
|
+
for (const spec of DECOUPLING_METRICS) {
|
|
553
|
+
let value = null;
|
|
554
|
+
for (const container of containers) {
|
|
555
|
+
const found = firstNumber(container, spec.keys);
|
|
556
|
+
if (found !== null) {
|
|
557
|
+
value = found;
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (value === null)
|
|
562
|
+
continue;
|
|
563
|
+
await database.run(`INSERT INTO activity_decoupling_facts (provider, activity_source_id, sport, started_at_utc, metric, value_number, unit, source_type, caveats_json, raw_object_hash)
|
|
564
|
+
VALUES ($provider, $activitySourceId, $sport, try_cast($startedAtUtc AS TIMESTAMPTZ), $metric, $value, $unit, 'provider', $caveats, $rawHash)
|
|
565
|
+
ON CONFLICT (provider, activity_source_id, metric) DO UPDATE SET
|
|
566
|
+
sport = excluded.sport, started_at_utc = excluded.started_at_utc, value_number = excluded.value_number,
|
|
567
|
+
unit = excluded.unit, source_type = excluded.source_type, caveats_json = excluded.caveats_json, raw_object_hash = excluded.raw_object_hash`, { provider, activitySourceId, sport: fields.sport, startedAtUtc: fields.startAtUtc, metric: spec.metric, value, unit: spec.unit, caveats: json({}), rawHash });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** Pull a course's ordered geometry samples, tolerating the several shapes a
|
|
571
|
+
* Garmin course payload may use (a flat ``geoPoints`` array on the live course
|
|
572
|
+
* endpoint, legacy ``coursePoints``, or points nested under a geo/route object).
|
|
573
|
+
* Returns an empty list when no geometry is present. */
|
|
574
|
+
function courseGeometryPoints(payload) {
|
|
575
|
+
const candidates = [payload.geoPoints, payload.coursePoints, payload.geo, payload.points, payload.geometry, payload.locations, payload.geoRoute];
|
|
576
|
+
for (const candidate of candidates) {
|
|
577
|
+
if (Array.isArray(candidate))
|
|
578
|
+
return objectArray(candidate);
|
|
579
|
+
if (candidate !== null && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
|
580
|
+
const obj = candidate;
|
|
581
|
+
for (const key of ['coursePoints', 'points', 'locations', 'coursePoint']) {
|
|
582
|
+
if (Array.isArray(obj[key]))
|
|
583
|
+
return objectArray(obj[key]);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return [];
|
|
588
|
+
}
|
|
589
|
+
/** A4: normalize Garmin course geometry into course_geometry so a race's
|
|
590
|
+
* elevation/grade profile can be resolved and diffed. The raw payload is also
|
|
591
|
+
* stored by importSourceEntity, so this only fans the points into rows. Live
|
|
592
|
+
* Garmin courses carry a ``geoPoints`` array (one row per sample); inserts are
|
|
593
|
+
* batched because a full course is roughly two thousand points. */
|
|
594
|
+
async function importGarminCourse(database, remoteId, payload, rawHash) {
|
|
595
|
+
const points = courseGeometryPoints(payload);
|
|
596
|
+
const CHUNK = 500;
|
|
597
|
+
for (let offset = 0; offset < points.length; offset += CHUNK) {
|
|
598
|
+
const chunk = points.slice(offset, offset + CHUNK);
|
|
599
|
+
const values = { courseId: remoteId, rawHash };
|
|
600
|
+
const rows = [];
|
|
601
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
602
|
+
const point = chunk[i];
|
|
603
|
+
const seq = offset + i;
|
|
604
|
+
values[`seq${i}`] = seq;
|
|
605
|
+
values[`lat${i}`] = firstNumber(point, ['latitude', 'lat']);
|
|
606
|
+
values[`lon${i}`] = firstNumber(point, ['longitude', 'lon', 'lng']);
|
|
607
|
+
values[`alt${i}`] = firstNumber(point, ['elevation', 'altitude', 'altitudeMeters', 'ele']);
|
|
608
|
+
values[`dist${i}`] = firstNumber(point, ['distance', 'distanceMeters', 'distance_m']);
|
|
609
|
+
rows.push(`($courseId, $seq${i}, $lat${i}, $lon${i}, $alt${i}, $dist${i}, $rawHash)`);
|
|
610
|
+
}
|
|
611
|
+
await database.run(`INSERT INTO course_geometry (course_id, seq, lat, lon, altitude_m, distance_m, raw_object_hash)
|
|
612
|
+
VALUES ${rows.join(', ')}
|
|
613
|
+
ON CONFLICT (course_id, seq) DO UPDATE SET
|
|
614
|
+
lat = excluded.lat, lon = excluded.lon, altitude_m = excluded.altitude_m,
|
|
615
|
+
distance_m = excluded.distance_m, raw_object_hash = excluded.raw_object_hash`, values);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
477
618
|
async function importGarminTrainingStatus(database, remoteId, occurredOn, payload, rawHash) {
|
|
478
619
|
const statusMap = nested(nested(payload, 'mostRecentTrainingStatus'), 'latestTrainingStatusData');
|
|
479
620
|
const balanceMap = nested(nested(payload, 'mostRecentTrainingLoadBalance'), 'metricsTrainingLoadBalanceDTOMap');
|
|
@@ -668,6 +809,46 @@ async function importGarminExplicitSwimLengths(database, parentRemoteId, entityT
|
|
|
668
809
|
}
|
|
669
810
|
}
|
|
670
811
|
}
|
|
812
|
+
async function importGarminFitSwimLengths(database, parentRemoteId, payload, rawHash) {
|
|
813
|
+
if (!parentRemoteId)
|
|
814
|
+
return;
|
|
815
|
+
const activitySourceId = providerActivityId('garmin', parentRemoteId);
|
|
816
|
+
const lengths = objectArray(payload.lengths);
|
|
817
|
+
if (lengths.length === 0)
|
|
818
|
+
return;
|
|
819
|
+
const source = 'garmin_fit_length';
|
|
820
|
+
await database.run('DELETE FROM swim_lengths WHERE activity_source_id = $activitySourceId AND source = $source', { activitySourceId, source });
|
|
821
|
+
const summaryPoolLengthM = await activityPoolLengthM(database, activitySourceId);
|
|
822
|
+
for (const [offset, length] of lengths.entries()) {
|
|
823
|
+
const durationS = firstNumber(length, ['durationS', 'duration', 'durationSeconds', 'elapsedDuration']);
|
|
824
|
+
if (durationS === null)
|
|
825
|
+
continue;
|
|
826
|
+
const sourceLabel = swimSourceLabel(length);
|
|
827
|
+
const isRest = firstBoolean(length, ['isRest', 'rest']) ?? Boolean(sourceLabel && /rest|idle|pause/.test(sourceLabel));
|
|
828
|
+
const lengthIndex = Math.round(firstNumber(length, ['lengthIndex', 'messageIndex', 'lengthNumber', 'index']) ?? offset);
|
|
829
|
+
await database.run(`INSERT INTO swim_lengths
|
|
830
|
+
(activity_source_id, source, length_index, lap_index, pool_length_m, start_time, duration_s, active_duration_s, distance_m, stroke_count, stroke_rate, swolf, avg_hr, max_hr, is_rest, confidence, metrics_json, raw_object_hash)
|
|
831
|
+
VALUES ($activitySourceId, $source, $lengthIndex, $lapIndex, $poolLengthM, $startTime, $durationS, $activeDurationS, $distanceM, $strokeCount, $strokeRate, $swolf, $avgHr, $maxHr, $isRest, 'provider_supplied', $metrics, $rawHash)
|
|
832
|
+
ON CONFLICT (activity_source_id, source, length_index) DO UPDATE SET
|
|
833
|
+
lap_index = excluded.lap_index, pool_length_m = excluded.pool_length_m, start_time = excluded.start_time,
|
|
834
|
+
duration_s = excluded.duration_s, active_duration_s = excluded.active_duration_s, distance_m = excluded.distance_m,
|
|
835
|
+
stroke_count = excluded.stroke_count, stroke_rate = excluded.stroke_rate, swolf = excluded.swolf,
|
|
836
|
+
avg_hr = excluded.avg_hr, max_hr = excluded.max_hr, is_rest = excluded.is_rest,
|
|
837
|
+
confidence = excluded.confidence, metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, {
|
|
838
|
+
activitySourceId, source, lengthIndex,
|
|
839
|
+
lapIndex: firstNumber(length, ['lapIndex', 'lapNumber']), poolLengthM: garminPoolLengthM(length) ?? summaryPoolLengthM,
|
|
840
|
+
startTime: timestamp(length.startTime ?? length.startTimestamp ?? length.timestamp), durationS,
|
|
841
|
+
activeDurationS: firstNumber(length, ['activeDurationS', 'activeDuration', 'movingDuration']),
|
|
842
|
+
distanceM: firstNumber(length, ['distanceM', 'distance']),
|
|
843
|
+
strokeCount: firstNumber(length, ['strokeCount', 'strokes', 'total_strokes']),
|
|
844
|
+
strokeRate: firstNumber(length, ['strokeRate', 'averageStrokeRate', 'avg_swimming_cadence', 'avg_cadence', 'cadence']),
|
|
845
|
+
swolf: firstNumber(length, ['swolf', 'avg_swolf']),
|
|
846
|
+
avgHr: firstNumber(length, ['avgHr', 'averageHR', 'avg_heart_rate']),
|
|
847
|
+
maxHr: firstNumber(length, ['maxHr', 'maxHR', 'max_heart_rate']),
|
|
848
|
+
isRest, metrics: json(length), rawHash,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
}
|
|
671
852
|
async function addActivityQualityFlag(database, activitySourceId, code, severity, details, rawHash) {
|
|
672
853
|
await database.run(`INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
|
|
673
854
|
VALUES ($activitySourceId, $code, $severity, $details, $rawHash)
|
|
@@ -791,18 +972,29 @@ async function importNutrition(database, provider, date, remoteId, payload, rawH
|
|
|
791
972
|
const nutritionDate = datePart(date) ?? datePart(firstString(payload, ['id', 'date', 'calendarDate'])) ?? null;
|
|
792
973
|
if (!nutritionDate)
|
|
793
974
|
return;
|
|
975
|
+
if (!hasNutritionContent(payload))
|
|
976
|
+
return;
|
|
794
977
|
const fields = nutritionFields(payload);
|
|
795
978
|
await database.run(`INSERT INTO nutrition_days
|
|
796
979
|
(provider, nutrition_date, energy_kcal, carbohydrates_g, protein_g, fat_g, hydration_ml, metrics_json, raw_object_hash)
|
|
797
|
-
VALUES ($provider, $nutritionDate, $energyKcal, $carbohydratesG, $proteinG, $fatG, $hydrationMl, $metrics, $rawHash)
|
|
798
|
-
ON CONFLICT (provider, nutrition_date) DO UPDATE SET
|
|
799
|
-
|
|
980
|
+
VALUES ($provider::VARCHAR, $nutritionDate::DATE, $energyKcal::DOUBLE, $carbohydratesG::DOUBLE, $proteinG::DOUBLE, $fatG::DOUBLE, $hydrationMl::DOUBLE, $metrics::JSON, $rawHash::VARCHAR)
|
|
981
|
+
ON CONFLICT (provider, nutrition_date) DO UPDATE SET
|
|
982
|
+
energy_kcal = COALESCE(excluded.energy_kcal, nutrition_days.energy_kcal),
|
|
983
|
+
carbohydrates_g = COALESCE(excluded.carbohydrates_g, nutrition_days.carbohydrates_g),
|
|
984
|
+
protein_g = COALESCE(excluded.protein_g, nutrition_days.protein_g),
|
|
985
|
+
fat_g = COALESCE(excluded.fat_g, nutrition_days.fat_g),
|
|
986
|
+
hydration_ml = COALESCE(excluded.hydration_ml, nutrition_days.hydration_ml),
|
|
987
|
+
metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, { provider, nutritionDate, ...fields, metrics: json(payload), rawHash });
|
|
800
988
|
for (const [index, item] of nutritionItems(payload).entries()) {
|
|
801
989
|
const itemFields = nutritionFields(item);
|
|
802
990
|
const itemId = firstString(item, ['id', 'foodId', 'entryId']) ?? `${remoteId}:${index}`;
|
|
803
991
|
await database.run(`INSERT INTO nutrition_items
|
|
804
992
|
(provider, remote_item_id, nutrition_date, meal, consumed_at, food_name, quantity, energy_kcal, carbohydrates_g, protein_g, fat_g, payload_json, raw_object_hash)
|
|
805
|
-
VALUES (
|
|
993
|
+
VALUES (
|
|
994
|
+
CAST(? AS VARCHAR), CAST(? AS VARCHAR), CAST(? AS DATE), CAST(? AS VARCHAR),
|
|
995
|
+
CAST(? AS TIMESTAMP), CAST(? AS VARCHAR), CAST(? AS DOUBLE), CAST(? AS DOUBLE),
|
|
996
|
+
CAST(? AS DOUBLE), CAST(? AS DOUBLE), CAST(? AS DOUBLE), CAST(? AS JSON), CAST(? AS VARCHAR)
|
|
997
|
+
)
|
|
806
998
|
ON CONFLICT (provider, remote_item_id) DO UPDATE SET nutrition_date = excluded.nutrition_date, meal = excluded.meal,
|
|
807
999
|
consumed_at = excluded.consumed_at, food_name = excluded.food_name, quantity = excluded.quantity, energy_kcal = excluded.energy_kcal,
|
|
808
1000
|
carbohydrates_g = excluded.carbohydrates_g, protein_g = excluded.protein_g, fat_g = excluded.fat_g, payload_json = excluded.payload_json,
|
|
@@ -2,6 +2,7 @@ import { ReadOnlyRepository } from '../../core/query/repository.js';
|
|
|
2
2
|
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
3
3
|
import type { QueryBindings, WriteDataStore } from '../../contracts/storage.js';
|
|
4
4
|
import type { Provider } from '../../contracts/staging.js';
|
|
5
|
+
import { type InterruptedRuns, type SyncProgressSnapshot, type SyncProgressState } from '../../contracts/progress.js';
|
|
5
6
|
export declare class CatenceDatabase implements WriteDataStore {
|
|
6
7
|
private readonly instance;
|
|
7
8
|
private readonly connection;
|
|
@@ -16,7 +17,19 @@ export declare class CatenceDatabase implements WriteDataStore {
|
|
|
16
17
|
interrupt(): void;
|
|
17
18
|
beginRun(provider: Provider, fromDate: string, runId?: `${string}-${string}-${string}-${string}-${string}`): Promise<string>;
|
|
18
19
|
finishRun(runId: string): Promise<void>;
|
|
20
|
+
heartbeatRun(runId: string, progress: SyncProgressState): Promise<void>;
|
|
21
|
+
markRunInterrupted(runId: string, stage?: string): Promise<void>;
|
|
22
|
+
interruptStaleSyncRuns(timeoutMs?: number): Promise<InterruptedRuns>;
|
|
23
|
+
syncProgress(timeoutMs?: number): Promise<SyncProgressSnapshot>;
|
|
24
|
+
hasCalendarEventOlderThan(provider: Provider, beforeIsoDate: string, entityType?: string): Promise<boolean>;
|
|
25
|
+
deleteMissingCalendarEntities(provider: Provider, entityTypes: readonly string[], keepRemoteIds: readonly string[], minOccurredOn: string): Promise<void>;
|
|
19
26
|
addError(runId: string, provider: Provider, endpoint: string, remoteId: string | null, message: string, retryable: boolean): Promise<void>;
|
|
27
|
+
resolveErrors(opts?: {
|
|
28
|
+
runIds?: string[];
|
|
29
|
+
provider?: Provider;
|
|
30
|
+
before?: string;
|
|
31
|
+
all?: boolean;
|
|
32
|
+
}): Promise<number>;
|
|
20
33
|
status(): Promise<Record<string, unknown>>;
|
|
21
34
|
getRun(runId: string): Promise<{
|
|
22
35
|
provider: Provider;
|
|
@@ -45,6 +58,12 @@ export declare class CatenceDatabase implements WriteDataStore {
|
|
|
45
58
|
activityNeedsDetailSync(provider: Provider, remoteActivityId: string, summaryHash: string): Promise<boolean>;
|
|
46
59
|
recordActivitySyncState(provider: Provider, remoteActivityId: string, summaryHash: string, runId: string, detailsFetched: boolean): Promise<void>;
|
|
47
60
|
knownActivitySummaryHashes(provider: Provider): Promise<Record<string, string>>;
|
|
61
|
+
hasGarminLactateThresholdHistory(): Promise<boolean>;
|
|
62
|
+
earliestActivityStartDate(provider: Provider): Promise<string | null>;
|
|
63
|
+
garminSwimActivitySources(): Promise<Array<{
|
|
64
|
+
remote_activity_id: string;
|
|
65
|
+
started_on: string | null;
|
|
66
|
+
}>>;
|
|
48
67
|
private latestSourceDate;
|
|
49
68
|
private upsertCursor;
|
|
50
69
|
}
|
|
@@ -66,9 +85,9 @@ export declare class ReadOnlyCatenceDatabase {
|
|
|
66
85
|
interrupt(): void;
|
|
67
86
|
private withConnection;
|
|
68
87
|
}
|
|
69
|
-
/** Compose core read services with the DuckDB-backed, read-only adapter. */
|
|
70
|
-
export declare function openReadOnlyRepository(paths: CatencePaths): Promise<ReadOnlyRepository>;
|
|
71
88
|
export declare class ReadOnlyDatabaseError extends Error {
|
|
72
89
|
readonly code: 'data_sync_in_progress' | 'data_unavailable';
|
|
73
90
|
constructor(code: 'data_sync_in_progress' | 'data_unavailable', message: string);
|
|
74
91
|
}
|
|
92
|
+
/** Compose core read services with the DuckDB-backed, read-only adapter. */
|
|
93
|
+
export declare function openReadOnlyRepository(paths: CatencePaths): Promise<ReadOnlyRepository>;
|
|
@@ -2,8 +2,31 @@ import { DuckDBInstance } from '@duckdb/node-api';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { ReadOnlyRepository } from '../../core/query/repository.js';
|
|
5
|
+
import { SYNC_STAGES, STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
|
|
5
6
|
import { migrations } from './migrations.js';
|
|
6
7
|
import { json } from './sql.js';
|
|
8
|
+
/**
|
|
9
|
+
* DuckDB's node API throws "Cannot create values of type ANY" when a bound
|
|
10
|
+
* parameter resolves to an untyped value. JS `Date` objects surface as `ANY`
|
|
11
|
+
* (and are not auto-coerced the way strings are), so any date/timestamp
|
|
12
|
+
* parameter read back from the database and re-bound crashes the statement.
|
|
13
|
+
* Serializing dates to ISO strings keeps every parameter concretely typed.
|
|
14
|
+
*/
|
|
15
|
+
function serializeValue(value) {
|
|
16
|
+
if (value instanceof Date)
|
|
17
|
+
return value.toISOString();
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function serializeBindings(bindings) {
|
|
21
|
+
if (!bindings)
|
|
22
|
+
return bindings;
|
|
23
|
+
if (Array.isArray(bindings))
|
|
24
|
+
return bindings.map(serializeValue);
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const [key, value] of Object.entries(bindings))
|
|
27
|
+
out[key] = serializeValue(value);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
7
30
|
export class CatenceDatabase {
|
|
8
31
|
instance;
|
|
9
32
|
connection;
|
|
@@ -61,10 +84,10 @@ export class CatenceDatabase {
|
|
|
61
84
|
}
|
|
62
85
|
}
|
|
63
86
|
async run(sql, values) {
|
|
64
|
-
await this.connection.run(sql, values);
|
|
87
|
+
await this.connection.run(sql, serializeBindings(values));
|
|
65
88
|
}
|
|
66
89
|
async rows(sql, values) {
|
|
67
|
-
const result = await this.connection.runAndReadAll(sql, values);
|
|
90
|
+
const result = await this.connection.runAndReadAll(sql, serializeBindings(values));
|
|
68
91
|
return result.getRowObjectsJS();
|
|
69
92
|
}
|
|
70
93
|
async tableNames(query) {
|
|
@@ -81,11 +104,132 @@ export class CatenceDatabase {
|
|
|
81
104
|
await this.run(`UPDATE sync_runs SET status = CASE WHEN error_count > 0 THEN 'completed_with_errors' ELSE 'completed' END, completed_at = now() WHERE run_id = $runId`, { runId });
|
|
82
105
|
await this.run("UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context'");
|
|
83
106
|
}
|
|
107
|
+
async heartbeatRun(runId, progress) {
|
|
108
|
+
await this.run(`INSERT INTO sync_run_progress (run_id, provider, stage, current_step, completed_units, total_units, percent, elapsed_seconds, estimated_remaining_seconds, heartbeat_at, detail_json)
|
|
109
|
+
VALUES ($runId, $provider, $stage, $currentStep, $completedUnits, $totalUnits, $percent, $elapsedSeconds, $estimatedRemainingSeconds, now(), '{}')
|
|
110
|
+
ON CONFLICT (run_id) DO UPDATE SET
|
|
111
|
+
provider = EXCLUDED.provider,
|
|
112
|
+
stage = EXCLUDED.stage,
|
|
113
|
+
current_step = EXCLUDED.current_step,
|
|
114
|
+
completed_units = EXCLUDED.completed_units,
|
|
115
|
+
total_units = EXCLUDED.total_units,
|
|
116
|
+
percent = EXCLUDED.percent,
|
|
117
|
+
elapsed_seconds = EXCLUDED.elapsed_seconds,
|
|
118
|
+
estimated_remaining_seconds = EXCLUDED.estimated_remaining_seconds,
|
|
119
|
+
heartbeat_at = now(),
|
|
120
|
+
detail_json = '{}'`, {
|
|
121
|
+
runId,
|
|
122
|
+
provider: progress.provider,
|
|
123
|
+
stage: progress.stage,
|
|
124
|
+
currentStep: progress.currentStep,
|
|
125
|
+
completedUnits: progress.completedUnits,
|
|
126
|
+
totalUnits: progress.totalUnits,
|
|
127
|
+
percent: progress.percentComplete,
|
|
128
|
+
elapsedSeconds: progress.elapsedSeconds,
|
|
129
|
+
estimatedRemainingSeconds: progress.estimatedRemainingSeconds,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async markRunInterrupted(runId, stage = 'interrupted') {
|
|
133
|
+
await this.run("UPDATE sync_runs SET status = 'interrupted', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId });
|
|
134
|
+
await this.run('UPDATE sync_run_progress SET stage = $stage, heartbeat_at = now() WHERE run_id = $runId', { runId, stage });
|
|
135
|
+
}
|
|
136
|
+
async interruptStaleSyncRuns(timeoutMs = STALE_RUN_TIMEOUT_MS) {
|
|
137
|
+
const cutoff = new Date(Date.now() - timeoutMs).toISOString();
|
|
138
|
+
const stale = await this.rows(`SELECT run_id FROM sync_runs WHERE status = 'running' AND started_at < $cutoff`, { cutoff });
|
|
139
|
+
for (const row of stale) {
|
|
140
|
+
await this.run("UPDATE sync_runs SET status = 'timed_out', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId: row.run_id });
|
|
141
|
+
await this.run("UPDATE sync_run_progress SET stage = 'timed_out', heartbeat_at = now() WHERE run_id = $runId", { runId: row.run_id });
|
|
142
|
+
}
|
|
143
|
+
return { runIds: stale.map((row) => row.run_id) };
|
|
144
|
+
}
|
|
145
|
+
async syncProgress(timeoutMs = STALE_RUN_TIMEOUT_MS) {
|
|
146
|
+
// A run killed with its container (docker restart, OOM) keeps
|
|
147
|
+
// status='running' until something cleans it up, and that cleanup only
|
|
148
|
+
// runs inside a starting sync child — which the busy guard refuses to
|
|
149
|
+
// spawn while the row reads as active. Age-filtering here mirrors the
|
|
150
|
+
// sidecar contract: a heartbeat older than the stale timeout can never
|
|
151
|
+
// block a new run or disable the dashboard's Sync button forever.
|
|
152
|
+
const cutoff = new Date(Date.now() - timeoutMs).toISOString();
|
|
153
|
+
const running = await this.rows(`SELECT runs.run_id, runs.provider,
|
|
154
|
+
coalesce(progress.stage, 'starting') AS stage,
|
|
155
|
+
progress.current_step AS current_step,
|
|
156
|
+
coalesce(progress.completed_units, 0) AS completed_units,
|
|
157
|
+
progress.total_units AS total_units,
|
|
158
|
+
coalesce(progress.percent, 0) AS percent,
|
|
159
|
+
coalesce(progress.elapsed_seconds, 0) AS elapsed_seconds,
|
|
160
|
+
progress.estimated_remaining_seconds AS estimated_remaining_seconds,
|
|
161
|
+
cast(coalesce(progress.heartbeat_at, runs.started_at) AS VARCHAR) AS heartbeat_at
|
|
162
|
+
FROM sync_runs AS runs
|
|
163
|
+
LEFT JOIN sync_run_progress AS progress USING (run_id)
|
|
164
|
+
WHERE runs.status = 'running'
|
|
165
|
+
AND coalesce(progress.heartbeat_at, runs.started_at) > $cutoff
|
|
166
|
+
ORDER BY runs.started_at DESC`, { cutoff });
|
|
167
|
+
const recent = await this.rows(`SELECT progress.run_id, progress.provider, progress.stage, progress.current_step,
|
|
168
|
+
progress.completed_units, progress.total_units, progress.percent, progress.elapsed_seconds,
|
|
169
|
+
progress.estimated_remaining_seconds, cast(progress.heartbeat_at AS VARCHAR) AS heartbeat_at
|
|
170
|
+
FROM sync_run_progress AS progress
|
|
171
|
+
ORDER BY progress.heartbeat_at DESC
|
|
172
|
+
LIMIT 10`);
|
|
173
|
+
return { running: running.map(toSyncProgressState), recent: recent.map(toSyncProgressState) };
|
|
174
|
+
}
|
|
175
|
+
async hasCalendarEventOlderThan(provider, beforeIsoDate, entityType = 'event') {
|
|
176
|
+
// Coverage signal for the automatic calendar-events backfill: true once
|
|
177
|
+
// the store already holds an event older than the regular short window,
|
|
178
|
+
// meaning the one-time deep pull has landed and normal syncs can stay
|
|
179
|
+
// narrow. Athletes with genuinely no old events re-run the deep window;
|
|
180
|
+
// it costs one small paginated listing per sync.
|
|
181
|
+
const rows = await this.rows(`SELECT 1 FROM source_entities WHERE provider = $provider AND entity_type = $entityType AND occurred_on < $before LIMIT 1`, { provider, entityType, before: beforeIsoDate });
|
|
182
|
+
return rows.length > 0;
|
|
183
|
+
}
|
|
184
|
+
async deleteMissingCalendarEntities(provider, entityTypes, keepRemoteIds, minOccurredOn) {
|
|
185
|
+
// Calendar entities (joined races, scheduled workouts) are mutable and
|
|
186
|
+
// deletable upstream. Re-fetches upsert survivors via remote_id; this
|
|
187
|
+
// removes in-window rows absent from the latest authoritative listing so
|
|
188
|
+
// upstream deletions propagate instead of leaving ghosts forever. Rows
|
|
189
|
+
// without a date are preserved (they predate strict dating).
|
|
190
|
+
if (!entityTypes.length)
|
|
191
|
+
return;
|
|
192
|
+
const values = { provider, minOccurredOn };
|
|
193
|
+
const typePlaceholders = entityTypes.map((name, index) => {
|
|
194
|
+
values[`type${index}`] = name;
|
|
195
|
+
return `$type${index}`;
|
|
196
|
+
});
|
|
197
|
+
let keepClause = '';
|
|
198
|
+
if (keepRemoteIds.length) {
|
|
199
|
+
keepClause = ` AND remote_id NOT IN (${keepRemoteIds.map((_, index) => `$keep${index}`).join(', ')})`;
|
|
200
|
+
keepRemoteIds.forEach((id, index) => { values[`keep${index}`] = id; });
|
|
201
|
+
}
|
|
202
|
+
for (const table of ['source_entities', 'domain_entities']) {
|
|
203
|
+
await this.run(`DELETE FROM ${table} WHERE provider = $provider AND entity_type IN (${typePlaceholders}) AND occurred_on >= $minOccurredOn${keepClause}`, values);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
84
206
|
async addError(runId, provider, endpoint, remoteId, message, retryable) {
|
|
85
207
|
await this.run(`INSERT INTO normalization_errors (error_id, run_id, provider, endpoint, remote_id, message, retryable)
|
|
86
208
|
VALUES ($errorId, $runId, $provider, $endpoint, $remoteId, $message, $retryable)`, { errorId: randomUUID(), runId, provider, endpoint, remoteId, message, retryable });
|
|
87
209
|
await this.run('UPDATE sync_runs SET error_count = error_count + 1 WHERE run_id = $runId', { runId });
|
|
88
210
|
}
|
|
211
|
+
async resolveErrors(opts = {}) {
|
|
212
|
+
const clauses = ['resolved_at IS NULL'];
|
|
213
|
+
const values = {};
|
|
214
|
+
if (opts.runIds && opts.runIds.length > 0) {
|
|
215
|
+
const placeholders = opts.runIds.map((_, index) => `$runId${index}`).join(', ');
|
|
216
|
+
clauses.push(`run_id IN (${placeholders})`);
|
|
217
|
+
opts.runIds.forEach((runId, index) => { values[`runId${index}`] = runId; });
|
|
218
|
+
}
|
|
219
|
+
if (opts.provider) {
|
|
220
|
+
clauses.push('provider = $provider');
|
|
221
|
+
values.provider = opts.provider;
|
|
222
|
+
}
|
|
223
|
+
if (opts.before) {
|
|
224
|
+
clauses.push('created_at < $before');
|
|
225
|
+
values.before = opts.before;
|
|
226
|
+
}
|
|
227
|
+
if (!opts.all && clauses.length === 1) {
|
|
228
|
+
throw new Error('Refusing to resolve all unresolved errors without an explicit --all flag.');
|
|
229
|
+
}
|
|
230
|
+
const resolved = await this.rows(`UPDATE normalization_errors SET resolved_at = now() WHERE ${clauses.join(' AND ')} RETURNING error_id`, values);
|
|
231
|
+
return resolved.length;
|
|
232
|
+
}
|
|
89
233
|
async status() {
|
|
90
234
|
// @duckdb/node-api owns native statement state on the connection. Keep
|
|
91
235
|
// status reads sequential instead of issuing several native calls at once.
|
|
@@ -166,6 +310,24 @@ export class CatenceDatabase {
|
|
|
166
310
|
const rows = await this.rows('SELECT remote_activity_id, summary_hash FROM activity_sync_state WHERE provider = $provider', { provider });
|
|
167
311
|
return Object.fromEntries(rows.map((row) => [row.remote_activity_id, row.summary_hash]));
|
|
168
312
|
}
|
|
313
|
+
async hasGarminLactateThresholdHistory() {
|
|
314
|
+
const rows = await this.rows("SELECT count(*) AS n FROM training_metric_observations WHERE observation_id LIKE 'garmin:lactate_threshold:daily:%'");
|
|
315
|
+
return (rows[0]?.n ?? 0) > 0;
|
|
316
|
+
}
|
|
317
|
+
async earliestActivityStartDate(provider) {
|
|
318
|
+
const rows = await this.rows(`SELECT min(cast(started_at_utc AS DATE)) AS earliest FROM activities activity
|
|
319
|
+
JOIN activity_sources source USING (activity_id) WHERE source.provider = $provider`, { provider });
|
|
320
|
+
return toIsoDate(rows[0]?.earliest ?? null);
|
|
321
|
+
}
|
|
322
|
+
async garminSwimActivitySources() {
|
|
323
|
+
const sports = ['lap_swimming', 'swimming', 'open_water_swimming', 'openwaterswimming'];
|
|
324
|
+
return this.rows(`SELECT source.remote_activity_id, cast(activity.started_at_utc AS VARCHAR) AS started_on
|
|
325
|
+
FROM activity_sources AS source
|
|
326
|
+
JOIN activities AS activity USING (activity_id)
|
|
327
|
+
WHERE source.provider = 'garmin'
|
|
328
|
+
AND lower(coalesce(activity.sport, '')) IN (${sports.map((sport) => `'${sport}'`).join(', ')})
|
|
329
|
+
ORDER BY activity.started_at_utc`);
|
|
330
|
+
}
|
|
169
331
|
async latestSourceDate(provider, cursorName) {
|
|
170
332
|
const observations = cursorName === 'daily'
|
|
171
333
|
? `SELECT max(metric_date) AS observed_date FROM daily_metrics WHERE provider = $provider
|
|
@@ -283,10 +445,6 @@ export class ReadOnlyCatenceDatabase {
|
|
|
283
445
|
}
|
|
284
446
|
}
|
|
285
447
|
}
|
|
286
|
-
/** Compose core read services with the DuckDB-backed, read-only adapter. */
|
|
287
|
-
export async function openReadOnlyRepository(paths) {
|
|
288
|
-
return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
|
|
289
|
-
}
|
|
290
448
|
export class ReadOnlyDatabaseError extends Error {
|
|
291
449
|
code;
|
|
292
450
|
constructor(code, message) {
|
|
@@ -295,4 +453,22 @@ export class ReadOnlyDatabaseError extends Error {
|
|
|
295
453
|
this.name = 'ReadOnlyDatabaseError';
|
|
296
454
|
}
|
|
297
455
|
}
|
|
456
|
+
/** Compose core read services with the DuckDB-backed, read-only adapter. */
|
|
457
|
+
export async function openReadOnlyRepository(paths) {
|
|
458
|
+
return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
|
|
459
|
+
}
|
|
460
|
+
function toSyncProgressState(row) {
|
|
461
|
+
return {
|
|
462
|
+
runId: row.run_id,
|
|
463
|
+
provider: row.provider,
|
|
464
|
+
stage: SYNC_STAGES.includes(row.stage) ? row.stage : 'starting',
|
|
465
|
+
currentStep: row.current_step,
|
|
466
|
+
completedUnits: Math.max(0, Number(row.completed_units) || 0),
|
|
467
|
+
totalUnits: row.total_units === null || row.total_units === undefined ? null : Math.max(0, Number(row.total_units) || 0),
|
|
468
|
+
percentComplete: Math.min(100, Math.max(0, Number(row.percent) || 0)),
|
|
469
|
+
elapsedSeconds: Math.max(0, Number(row.elapsed_seconds) || 0),
|
|
470
|
+
estimatedRemainingSeconds: row.estimated_remaining_seconds === null || row.estimated_remaining_seconds === undefined ? null : Math.max(0, Number(row.estimated_remaining_seconds) || 0),
|
|
471
|
+
heartbeatAt: row.heartbeat_at,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
298
474
|
//# sourceMappingURL=database.js.map
|