catence 0.2.0-beta.2 → 0.2.0-beta.21
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 +28 -82
- package/config.example.json +25 -0
- 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/logging.d.ts +16 -0
- package/dist/core/logging.js +68 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +48 -0
- package/dist/core/runtime/catalog.js +17 -7
- 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/core/runtime/self-update.d.ts +44 -0
- package/dist/core/runtime/self-update.js +138 -0
- 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 +435 -19
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +5 -4
- package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
- package/dist/elt/ingestion/providers/intervals.js +37 -2
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +18 -1
- package/dist/elt/ingestion/providers/strava/service.js +7 -1
- package/dist/elt/normalization/normalizers.js +111 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +184 -6
- package/dist/elt/storage/migrations.js +25 -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 +116 -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/server.d.ts +2 -1
- package/dist/interfaces/mcp/server.js +50 -13
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +4 -1
- package/docs/architecture.md +11 -1
- 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 +58 -0
- package/docs/llm-providers.md +249 -6
- package/docs/mcp-activity-retrieval.md +37 -30
- package/package.json +5 -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 +1 -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 +372 -11
- package/scripts/discover-opencode-go.mjs +102 -0
- package/scripts/discover-opencode-zen.mjs +218 -0
- package/uv.lock +3 -3
|
@@ -2,7 +2,7 @@ import { IntervalsClient } from 'intervals-icu';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { stableJsonHash, storeArtifact, storeJsonArtifact } from '../../storage/artifacts.js';
|
|
4
4
|
import { importRecord, queueWorkItem, completeWorkItem, failWorkItem } from '../importer.js';
|
|
5
|
-
import { intervalsActivityReadEndpoints, intervalsSecondaryReadRegistry, assertReadOnlyRegistry } from './registry.js';
|
|
5
|
+
import { intervalsActivityReadEndpoints, intervalsSecondaryReadRegistry, intervalsEventSyncWindow, assertReadOnlyRegistry } from './registry.js';
|
|
6
6
|
import { intervalsStreamsToSamples, writeParquetSamples } from '../../streams.js';
|
|
7
7
|
const apiBaseUrl = 'https://intervals.icu/api/v1';
|
|
8
8
|
function asObject(value) {
|
|
@@ -42,12 +42,14 @@ export class IntervalsExtractor {
|
|
|
42
42
|
paths;
|
|
43
43
|
apiKey;
|
|
44
44
|
athleteId;
|
|
45
|
+
log;
|
|
45
46
|
client;
|
|
46
|
-
constructor(database, paths, apiKey, athleteId) {
|
|
47
|
+
constructor(database, paths, apiKey, athleteId, log) {
|
|
47
48
|
this.database = database;
|
|
48
49
|
this.paths = paths;
|
|
49
50
|
this.apiKey = apiKey;
|
|
50
51
|
this.athleteId = athleteId;
|
|
52
|
+
this.log = log;
|
|
51
53
|
// The typed client is used for provider-compatible authentication and the
|
|
52
54
|
// primary identity request. Manifest-only calls below intentionally use
|
|
53
55
|
// the same read-only credentials to cover endpoint families not wrapped by it.
|
|
@@ -65,6 +67,21 @@ export class IntervalsExtractor {
|
|
|
65
67
|
for (const endpoint of intervalsSecondaryReadRegistry) {
|
|
66
68
|
if (endpoint.name === 'athlete')
|
|
67
69
|
continue;
|
|
70
|
+
// Calendar events straddle today (past for activity association,
|
|
71
|
+
// future for planning); they must not inherit the cursor-bounded
|
|
72
|
+
// activity window, which always ends at today.
|
|
73
|
+
if (endpoint.name === 'events') {
|
|
74
|
+
const window = await this.resolveEventsSyncWindow();
|
|
75
|
+
const payload = await this.capture(runId, 'events', null, window, async () => this.getCollection('events', resolvedAthleteId, window.fromDate, window.toDate));
|
|
76
|
+
if (payload === undefined)
|
|
77
|
+
continue;
|
|
78
|
+
await this.importEntities('event', 'events', payload, null, runId);
|
|
79
|
+
// The listing is authoritative: events deleted upstream must not
|
|
80
|
+
// linger as ghosts in the store once this sync succeeded.
|
|
81
|
+
const seenIds = asItems(payload).map((item) => idOf(item, '')).filter(Boolean);
|
|
82
|
+
await this.database.deleteMissingCalendarEntities('intervals', ['event'], seenIds, window.fromDate);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
68
85
|
if (!activityWindow)
|
|
69
86
|
continue;
|
|
70
87
|
const { fromDate, toDate } = activityWindow;
|
|
@@ -84,6 +101,23 @@ export class IntervalsExtractor {
|
|
|
84
101
|
}
|
|
85
102
|
}
|
|
86
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Regular syncs reach back EVENT_WINDOW_PAST_DAYS — just enough to
|
|
106
|
+
* associate recently finished events with their activities. A store whose
|
|
107
|
+
* own activity history predates that span gets an automatic one-time
|
|
108
|
+
* coverage backfill instead: the window stretches to the earliest
|
|
109
|
+
* activity until an event older than the short window lands (self-
|
|
110
|
+
* clearing), after which deeper history is the backfill command's job.
|
|
111
|
+
*/
|
|
112
|
+
async resolveEventsSyncWindow() {
|
|
113
|
+
const short = intervalsEventSyncWindow();
|
|
114
|
+
if (await this.database.hasCalendarEventOlderThan('intervals', short.fromDate))
|
|
115
|
+
return short;
|
|
116
|
+
const earliest = await this.database.earliestActivityStartDate('intervals');
|
|
117
|
+
if (!earliest || earliest >= short.fromDate)
|
|
118
|
+
return short;
|
|
119
|
+
return { fromDate: earliest, toDate: short.toDate };
|
|
120
|
+
}
|
|
87
121
|
async captureCollectionChildren(runId, collection, payload, athleteId) {
|
|
88
122
|
const children = {
|
|
89
123
|
events: [{ suffix: (id) => `/athlete/${athleteId}/events/${id}`, entityType: 'event' }],
|
|
@@ -189,6 +223,7 @@ export class IntervalsExtractor {
|
|
|
189
223
|
catch (error) {
|
|
190
224
|
const message = error instanceof Error ? error.message : String(error);
|
|
191
225
|
await failWorkItem(this.database, workId, message);
|
|
226
|
+
this.log.warn('Endpoint capture failed', { runId, provider: 'intervals', endpoint, remoteId, error: message });
|
|
192
227
|
await this.database.addError(runId, 'intervals', endpoint, remoteId, message, true);
|
|
193
228
|
return undefined;
|
|
194
229
|
}
|
|
@@ -11,6 +11,19 @@ export declare const intervalsReadRegistry: readonly EndpointSpec[];
|
|
|
11
11
|
* Garmin's activity detail, files, streams, or account data.
|
|
12
12
|
*/
|
|
13
13
|
export declare const intervalsSecondaryReadRegistry: EndpointSpec[];
|
|
14
|
+
/** How far back and ahead of today the planned-events fetch reaches. */
|
|
15
|
+
/** Regular syncs look back a month for activity association; deeper history arrives via the automatic coverage backfill. */
|
|
16
|
+
export declare const EVENT_WINDOW_PAST_DAYS = 30;
|
|
17
|
+
export declare const EVENT_WINDOW_FUTURE_DAYS = 365;
|
|
18
|
+
/**
|
|
19
|
+
* Deliberately cursor-free: activities chase a past-only incremental window,
|
|
20
|
+
* while calendar events must straddle today so upcoming races and recently
|
|
21
|
+
* completed ones (associatable with their finished activities) both arrive.
|
|
22
|
+
*/
|
|
23
|
+
export declare function intervalsEventSyncWindow(now?: Date): {
|
|
24
|
+
fromDate: string;
|
|
25
|
+
toDate: string;
|
|
26
|
+
};
|
|
14
27
|
export declare const intervalsActivityReadEndpoints: readonly ["activity", "streams", "intervals", "map", "weather", "weather_summary", "best_efforts", "power_curve", "pace_curve", "hr_curve", "power_vs_hr", "hr_load_model", "segments", "messages", "original_file", "fit_file", "gpx_file"];
|
|
15
28
|
/** Read-only methods exposed by python-garminconnect, grouped for manifest tests and worker dispatch. */
|
|
16
29
|
export declare const garminReadRegistry: readonly EndpointSpec[];
|
|
@@ -21,7 +21,23 @@ export const intervalsReadRegistry = registry('intervals', [
|
|
|
21
21
|
* supplies the separately named training calculations without duplicating
|
|
22
22
|
* Garmin's activity detail, files, streams, or account data.
|
|
23
23
|
*/
|
|
24
|
-
export const intervalsSecondaryReadRegistry = intervalsReadRegistry.filter((endpoint) => endpoint.name === 'athlete' || endpoint.name === 'activities');
|
|
24
|
+
export const intervalsSecondaryReadRegistry = intervalsReadRegistry.filter((endpoint) => endpoint.name === 'athlete' || endpoint.name === 'activities' || endpoint.name === 'events');
|
|
25
|
+
/** How far back and ahead of today the planned-events fetch reaches. */
|
|
26
|
+
/** Regular syncs look back a month for activity association; deeper history arrives via the automatic coverage backfill. */
|
|
27
|
+
export const EVENT_WINDOW_PAST_DAYS = 30;
|
|
28
|
+
export const EVENT_WINDOW_FUTURE_DAYS = 365;
|
|
29
|
+
/**
|
|
30
|
+
* Deliberately cursor-free: activities chase a past-only incremental window,
|
|
31
|
+
* while calendar events must straddle today so upcoming races and recently
|
|
32
|
+
* completed ones (associatable with their finished activities) both arrive.
|
|
33
|
+
*/
|
|
34
|
+
export function intervalsEventSyncWindow(now = new Date()) {
|
|
35
|
+
const dayMs = 86_400_000;
|
|
36
|
+
return {
|
|
37
|
+
fromDate: new Date(now.getTime() - EVENT_WINDOW_PAST_DAYS * dayMs).toISOString().slice(0, 10),
|
|
38
|
+
toDate: new Date(now.getTime() + EVENT_WINDOW_FUTURE_DAYS * dayMs).toISOString().slice(0, 10),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
25
41
|
export const intervalsActivityReadEndpoints = [
|
|
26
42
|
'activity', 'streams', 'intervals', 'map', 'weather', 'weather_summary', 'best_efforts', 'power_curve', 'pace_curve', 'hr_curve',
|
|
27
43
|
'power_vs_hr', 'hr_load_model', 'segments', 'messages', 'original_file', 'fit_file', 'gpx_file',
|
|
@@ -43,6 +59,7 @@ export const garminReadRegistry = registry('garmin', [
|
|
|
43
59
|
['activity_power_zones', 'activity', 'activity_zone'], ['activity_exercise_sets', 'activity', 'activity_exercise_set'], ['activity_gear', 'activity', 'activity_gear'],
|
|
44
60
|
['activity_original', 'activity', 'activity_file'], ['activity_tcx', 'activity', 'activity_file'], ['activity_gpx', 'activity', 'activity_file'], ['activity_kml', 'activity', 'activity_file'],
|
|
45
61
|
['activity_csv', 'activity', 'activity_file'], ['workouts', 'collection', 'workout'], ['scheduled_workouts', 'date_range', 'scheduled_workout'],
|
|
62
|
+
['events', 'collection', 'event'],
|
|
46
63
|
['training_plans', 'collection', 'training_plan'], ['goals', 'collection', 'goal'], ['personal_records', 'singleton', 'personal_record'],
|
|
47
64
|
['earned_badges', 'collection', 'badge'], ['available_badges', 'collection', 'badge'], ['challenges', 'collection', 'challenge'],
|
|
48
65
|
['gear', 'collection', 'gear'], ['golf_summary', 'collection', 'golf_scorecard'], ['golf_user_stats', 'singleton', 'golf_user_stat'],
|
|
@@ -8,6 +8,7 @@ import { ensurePaths, loadCatenceConfig } from '../../../../core/runtime/configu
|
|
|
8
8
|
import { athleteProviderEnvironment } from '../../../../core/runtime/secrets.js';
|
|
9
9
|
import { CatenceDatabase, openReadOnlyRepository } from '../../../storage/database.js';
|
|
10
10
|
import { importJsonl } from '../../importer.js';
|
|
11
|
+
import { createSyncLogger } from '../../../../core/logging.js';
|
|
11
12
|
import { withDataWriteLock } from '../../../storage/write-lock.js';
|
|
12
13
|
const execFileAsync = promisify(execFile);
|
|
13
14
|
export class StravaRateLimitError extends Error {
|
|
@@ -108,6 +109,7 @@ async function cachedState(database, resourceType, remoteId) {
|
|
|
108
109
|
async function runReadOperation(paths, operation, extraArgs, resource, refresh = false) {
|
|
109
110
|
return withDataWriteLock(paths, async () => {
|
|
110
111
|
await ensurePaths(paths);
|
|
112
|
+
const log = createSyncLogger(paths);
|
|
111
113
|
const database = await CatenceDatabase.open(paths);
|
|
112
114
|
try {
|
|
113
115
|
if (!refresh) {
|
|
@@ -117,6 +119,7 @@ async function runReadOperation(paths, operation, extraArgs, resource, refresh =
|
|
|
117
119
|
}
|
|
118
120
|
await checkBudget(database, paths);
|
|
119
121
|
const runId = await database.beginRun('strava', new Date().toISOString().slice(0, 10));
|
|
122
|
+
log.info('Starting Strava read operation', { runId, operation, resource: resource.id });
|
|
120
123
|
const directory = path.join(paths.staging, 'strava');
|
|
121
124
|
const stagingPath = path.join(directory, `${runId}.jsonl`);
|
|
122
125
|
const resultPath = path.join(directory, `${runId}.result.json`);
|
|
@@ -124,17 +127,20 @@ async function runReadOperation(paths, operation, extraArgs, resource, refresh =
|
|
|
124
127
|
try {
|
|
125
128
|
result = await invokeWorker(paths, ['--mode', operation, '--run-id', runId, '--output', stagingPath, ...extraArgs], resultPath);
|
|
126
129
|
if (existsSync(stagingPath))
|
|
127
|
-
await importJsonl(database, runId, stagingPath);
|
|
130
|
+
await importJsonl(database, runId, stagingPath, log);
|
|
128
131
|
await storeRateState(database, result);
|
|
129
132
|
await writeState(database, resource.type, resource.id, result);
|
|
130
133
|
}
|
|
131
134
|
catch (error) {
|
|
132
135
|
const message = error instanceof Error ? error.message : String(error);
|
|
136
|
+
log.error('Strava read operation failed', { runId, operation, resource: resource.id, error: message, stack: error instanceof Error ? error.stack : undefined });
|
|
133
137
|
await database.addError(runId, 'strava', operation, resource.id, message, true);
|
|
134
138
|
throw error;
|
|
135
139
|
}
|
|
136
140
|
finally {
|
|
137
141
|
await database.finishRun(runId);
|
|
142
|
+
const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
|
|
143
|
+
log.info('Strava read operation finished', { runId, operation, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
|
|
138
144
|
}
|
|
139
145
|
if (result.status === 'error')
|
|
140
146
|
throw new StravaEnrichmentError(result.message ?? 'Strava did not complete the requested enrichment.');
|
|
@@ -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`, {
|
|
@@ -223,6 +265,9 @@ export async function importSourceEntity(database, entity) {
|
|
|
223
265
|
if (entity.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set'].includes(entity.entityType)) {
|
|
224
266
|
await importGarminExplicitSwimLengths(database, entity.parentRemoteId, entity.entityType, payload, rawHash);
|
|
225
267
|
}
|
|
268
|
+
if (entity.provider === 'garmin' && entity.entityType === 'activity_swim_lengths') {
|
|
269
|
+
await importGarminFitSwimLengths(database, entity.parentRemoteId, payload, rawHash);
|
|
270
|
+
}
|
|
226
271
|
if (entity.entityType === 'wellness' || entity.entityType === 'daily_health')
|
|
227
272
|
await importDailyMetrics(database, entity.provider, entity.remoteId, entity.occurredOn, payload, rawHash);
|
|
228
273
|
if (entity.entityType === 'nutrition_day' || entity.entityType === 'nutrition_log')
|
|
@@ -372,7 +417,7 @@ function nested(payload, key) {
|
|
|
372
417
|
return object(payload[key]);
|
|
373
418
|
}
|
|
374
419
|
function metricObservedAt(occurredOn, payload) {
|
|
375
|
-
return firstTimestamp(payload, ['timestamp', 'timestampLocal', 'calendarDate', 'date']) ?? (occurredOn ? `${occurredOn}T00:00:00Z` : null);
|
|
420
|
+
return firstTimestamp(payload, ['timestamp', 'timestampLocal', 'calendarDate', 'date']) ?? (occurredOn ? `${datePart(occurredOn)}T00:00:00Z` : null);
|
|
376
421
|
}
|
|
377
422
|
async function importGarminActivityPerformance(database, remoteId, payload, rawHash) {
|
|
378
423
|
const fields = activityFields('garmin', payload);
|
|
@@ -668,6 +713,46 @@ async function importGarminExplicitSwimLengths(database, parentRemoteId, entityT
|
|
|
668
713
|
}
|
|
669
714
|
}
|
|
670
715
|
}
|
|
716
|
+
async function importGarminFitSwimLengths(database, parentRemoteId, payload, rawHash) {
|
|
717
|
+
if (!parentRemoteId)
|
|
718
|
+
return;
|
|
719
|
+
const activitySourceId = providerActivityId('garmin', parentRemoteId);
|
|
720
|
+
const lengths = objectArray(payload.lengths);
|
|
721
|
+
if (lengths.length === 0)
|
|
722
|
+
return;
|
|
723
|
+
const source = 'garmin_fit_length';
|
|
724
|
+
await database.run('DELETE FROM swim_lengths WHERE activity_source_id = $activitySourceId AND source = $source', { activitySourceId, source });
|
|
725
|
+
const summaryPoolLengthM = await activityPoolLengthM(database, activitySourceId);
|
|
726
|
+
for (const [offset, length] of lengths.entries()) {
|
|
727
|
+
const durationS = firstNumber(length, ['durationS', 'duration', 'durationSeconds', 'elapsedDuration']);
|
|
728
|
+
if (durationS === null)
|
|
729
|
+
continue;
|
|
730
|
+
const sourceLabel = swimSourceLabel(length);
|
|
731
|
+
const isRest = firstBoolean(length, ['isRest', 'rest']) ?? Boolean(sourceLabel && /rest|idle|pause/.test(sourceLabel));
|
|
732
|
+
const lengthIndex = Math.round(firstNumber(length, ['lengthIndex', 'messageIndex', 'lengthNumber', 'index']) ?? offset);
|
|
733
|
+
await database.run(`INSERT INTO swim_lengths
|
|
734
|
+
(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)
|
|
735
|
+
VALUES ($activitySourceId, $source, $lengthIndex, $lapIndex, $poolLengthM, $startTime, $durationS, $activeDurationS, $distanceM, $strokeCount, $strokeRate, $swolf, $avgHr, $maxHr, $isRest, 'provider_supplied', $metrics, $rawHash)
|
|
736
|
+
ON CONFLICT (activity_source_id, source, length_index) DO UPDATE SET
|
|
737
|
+
lap_index = excluded.lap_index, pool_length_m = excluded.pool_length_m, start_time = excluded.start_time,
|
|
738
|
+
duration_s = excluded.duration_s, active_duration_s = excluded.active_duration_s, distance_m = excluded.distance_m,
|
|
739
|
+
stroke_count = excluded.stroke_count, stroke_rate = excluded.stroke_rate, swolf = excluded.swolf,
|
|
740
|
+
avg_hr = excluded.avg_hr, max_hr = excluded.max_hr, is_rest = excluded.is_rest,
|
|
741
|
+
confidence = excluded.confidence, metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, {
|
|
742
|
+
activitySourceId, source, lengthIndex,
|
|
743
|
+
lapIndex: firstNumber(length, ['lapIndex', 'lapNumber']), poolLengthM: garminPoolLengthM(length) ?? summaryPoolLengthM,
|
|
744
|
+
startTime: timestamp(length.startTime ?? length.startTimestamp ?? length.timestamp), durationS,
|
|
745
|
+
activeDurationS: firstNumber(length, ['activeDurationS', 'activeDuration', 'movingDuration']),
|
|
746
|
+
distanceM: firstNumber(length, ['distanceM', 'distance']),
|
|
747
|
+
strokeCount: firstNumber(length, ['strokeCount', 'strokes', 'total_strokes']),
|
|
748
|
+
strokeRate: firstNumber(length, ['strokeRate', 'averageStrokeRate', 'avg_swimming_cadence', 'avg_cadence', 'cadence']),
|
|
749
|
+
swolf: firstNumber(length, ['swolf', 'avg_swolf']),
|
|
750
|
+
avgHr: firstNumber(length, ['avgHr', 'averageHR', 'avg_heart_rate']),
|
|
751
|
+
maxHr: firstNumber(length, ['maxHr', 'maxHR', 'max_heart_rate']),
|
|
752
|
+
isRest, metrics: json(length), rawHash,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
671
756
|
async function addActivityQualityFlag(database, activitySourceId, code, severity, details, rawHash) {
|
|
672
757
|
await database.run(`INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
|
|
673
758
|
VALUES ($activitySourceId, $code, $severity, $details, $rawHash)
|
|
@@ -791,18 +876,29 @@ async function importNutrition(database, provider, date, remoteId, payload, rawH
|
|
|
791
876
|
const nutritionDate = datePart(date) ?? datePart(firstString(payload, ['id', 'date', 'calendarDate'])) ?? null;
|
|
792
877
|
if (!nutritionDate)
|
|
793
878
|
return;
|
|
879
|
+
if (!hasNutritionContent(payload))
|
|
880
|
+
return;
|
|
794
881
|
const fields = nutritionFields(payload);
|
|
795
882
|
await database.run(`INSERT INTO nutrition_days
|
|
796
883
|
(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
|
-
|
|
884
|
+
VALUES ($provider::VARCHAR, $nutritionDate::DATE, $energyKcal::DOUBLE, $carbohydratesG::DOUBLE, $proteinG::DOUBLE, $fatG::DOUBLE, $hydrationMl::DOUBLE, $metrics::JSON, $rawHash::VARCHAR)
|
|
885
|
+
ON CONFLICT (provider, nutrition_date) DO UPDATE SET
|
|
886
|
+
energy_kcal = COALESCE(excluded.energy_kcal, nutrition_days.energy_kcal),
|
|
887
|
+
carbohydrates_g = COALESCE(excluded.carbohydrates_g, nutrition_days.carbohydrates_g),
|
|
888
|
+
protein_g = COALESCE(excluded.protein_g, nutrition_days.protein_g),
|
|
889
|
+
fat_g = COALESCE(excluded.fat_g, nutrition_days.fat_g),
|
|
890
|
+
hydration_ml = COALESCE(excluded.hydration_ml, nutrition_days.hydration_ml),
|
|
891
|
+
metrics_json = excluded.metrics_json, raw_object_hash = excluded.raw_object_hash`, { provider, nutritionDate, ...fields, metrics: json(payload), rawHash });
|
|
800
892
|
for (const [index, item] of nutritionItems(payload).entries()) {
|
|
801
893
|
const itemFields = nutritionFields(item);
|
|
802
894
|
const itemId = firstString(item, ['id', 'foodId', 'entryId']) ?? `${remoteId}:${index}`;
|
|
803
895
|
await database.run(`INSERT INTO nutrition_items
|
|
804
896
|
(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 (
|
|
897
|
+
VALUES (
|
|
898
|
+
CAST(? AS VARCHAR), CAST(? AS VARCHAR), CAST(? AS DATE), CAST(? AS VARCHAR),
|
|
899
|
+
CAST(? AS TIMESTAMP), CAST(? AS VARCHAR), CAST(? AS DOUBLE), CAST(? AS DOUBLE),
|
|
900
|
+
CAST(? AS DOUBLE), CAST(? AS DOUBLE), CAST(? AS DOUBLE), CAST(? AS JSON), CAST(? AS VARCHAR)
|
|
901
|
+
)
|
|
806
902
|
ON CONFLICT (provider, remote_item_id) DO UPDATE SET nutrition_date = excluded.nutrition_date, meal = excluded.meal,
|
|
807
903
|
consumed_at = excluded.consumed_at, food_name = excluded.food_name, quantity = excluded.quantity, energy_kcal = excluded.energy_kcal,
|
|
808
904
|
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>;
|