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