catence 0.2.0-beta.8 → 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 -166
- package/config.example.json +5 -1
- 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/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 +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 +19 -1
- package/dist/elt/ingestion/providers/strava/service.js +7 -1
- package/dist/elt/normalization/normalizers.js +207 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +184 -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 +66 -1
- 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
|
@@ -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,8 @@ 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'],
|
|
63
|
+
['course', 'child', 'course'],
|
|
46
64
|
['training_plans', 'collection', 'training_plan'], ['goals', 'collection', 'goal'], ['personal_records', 'singleton', 'personal_record'],
|
|
47
65
|
['earned_badges', 'collection', 'badge'], ['available_badges', 'collection', 'badge'], ['challenges', 'collection', 'challenge'],
|
|
48
66
|
['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`, {
|
|
@@ -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>;
|