catence 0.2.0-beta.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +38 -180
  2. package/dist/contracts/index.d.ts +1 -0
  3. package/dist/contracts/index.js +1 -0
  4. package/dist/contracts/progress.d.ts +23 -0
  5. package/dist/contracts/progress.js +49 -0
  6. package/dist/contracts/release.d.ts +1 -1
  7. package/dist/contracts/release.js +1 -1
  8. package/dist/core/query/activity-discovery.d.ts +2 -0
  9. package/dist/core/query/activity-discovery.js +1 -1
  10. package/dist/core/query/analytics.d.ts +10 -0
  11. package/dist/core/query/analytics.js +494 -0
  12. package/dist/core/query/catalog.d.ts +1 -1
  13. package/dist/core/query/catalog.js +8 -4
  14. package/dist/core/query/events.d.ts +34 -0
  15. package/dist/core/query/events.js +121 -0
  16. package/dist/core/query/fitness.d.ts +4 -0
  17. package/dist/core/query/fitness.js +60 -0
  18. package/dist/core/query/repository.d.ts +2 -0
  19. package/dist/core/query/repository.js +48 -0
  20. package/dist/core/query/swimming.js +2 -2
  21. package/dist/core/runtime/configuration.d.ts +3 -1
  22. package/dist/core/runtime/configuration.js +5 -1
  23. package/dist/core/runtime/opencode-go.d.ts +58 -0
  24. package/dist/core/runtime/opencode-go.js +246 -0
  25. package/dist/core/runtime/secrets.js +51 -13
  26. package/dist/elt/application/detached-sync.d.ts +50 -0
  27. package/dist/elt/application/detached-sync.js +99 -0
  28. package/dist/elt/application/management.d.ts +15 -0
  29. package/dist/elt/application/management.js +420 -18
  30. package/dist/elt/ingestion/importer.js +1 -1
  31. package/dist/elt/ingestion/providers/intervals.d.ts +9 -0
  32. package/dist/elt/ingestion/providers/intervals.js +33 -1
  33. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  34. package/dist/elt/ingestion/providers/registry.js +19 -1
  35. package/dist/elt/normalization/normalizers.js +207 -15
  36. package/dist/elt/storage/database.d.ts +21 -2
  37. package/dist/elt/storage/database.js +182 -6
  38. package/dist/elt/storage/migrations.js +145 -1
  39. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  40. package/dist/elt/storage/progress-sidecar.js +200 -0
  41. package/dist/interfaces/cli/commands.js +94 -1
  42. package/dist/interfaces/http/generation-status.d.ts +19 -0
  43. package/dist/interfaces/http/generation-status.js +55 -0
  44. package/dist/interfaces/http/server.d.ts +7 -1
  45. package/dist/interfaces/http/server.js +184 -1
  46. package/dist/interfaces/mcp/main.js +33 -1
  47. package/dist/interfaces/mcp/server.d.ts +2 -1
  48. package/dist/interfaces/mcp/server.js +168 -45
  49. package/dist/runtime/index.d.ts +9 -2
  50. package/dist/runtime/index.js +6 -2
  51. package/docs/architecture.md +13 -2
  52. package/docs/assets/image.png +0 -0
  53. package/docs/configuration.md +243 -0
  54. package/docs/deployment/docker.md +528 -0
  55. package/docs/deployment/local-console.md +263 -0
  56. package/docs/deployment/local-mcp.md +329 -0
  57. package/docs/development.md +337 -0
  58. package/docs/distribution.md +45 -0
  59. package/docs/llm-providers.md +24 -2
  60. package/docs/mcp-activity-retrieval.md +90 -210
  61. package/package.json +1 -1
  62. package/pyproject.toml +1 -1
  63. package/python/catence/providers/garmin/cli.py +43 -9
  64. package/python/catence/providers/garmin/progress.py +85 -0
  65. package/python/catence/providers/garmin/registry.py +2 -0
  66. package/python/catence/providers/garmin/streams.py +38 -0
  67. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  68. package/python/catence/providers/garmin/worker.py +415 -12
  69. package/scripts/discover-opencode-go.mjs +36 -144
  70. package/uv.lock +3 -3
  71. package/docs/deployment.md +0 -338
@@ -1,11 +1,14 @@
1
- import { execFile } from 'node:child_process';
1
+ import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync } from 'node:fs';
4
- import { mkdir, writeFile } from 'node:fs/promises';
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { createReadStream } from 'node:fs';
6
+ import { createInterface } from 'node:readline';
7
+ import { EVENT_WINDOW_PAST_DAYS } from '../ingestion/providers/registry.js';
5
8
  import { createServer } from 'node:http';
6
9
  import path from 'node:path';
7
- import { promisify } from 'node:util';
8
10
  import { fileURLToPath } from 'node:url';
11
+ import { normalizeProgress, STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
9
12
  import { buildRetrievalIndex } from '../../core/retrieval/index.js';
10
13
  import { defaultFromDate, ensurePaths, requireIntervalsConfig } from '../../core/runtime/configuration.js';
11
14
  import { createSyncLogger } from '../../core/logging.js';
@@ -14,12 +17,245 @@ import { importJsonl } from '../ingestion/importer.js';
14
17
  import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
15
18
  import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUrl, syncStravaGear } from '../ingestion/providers/strava/service.js';
16
19
  import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
17
- import { CatenceDatabase } from '../storage/database.js';
20
+ import { importSourceEntity } from '../normalization/normalizers.js';
21
+ import { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../storage/database.js';
22
+ import { mergeProgress, ProgressPump, readRunningProgress, removeProgressSidecar } from '../storage/progress-sidecar.js';
18
23
  import { withDataWriteLock } from '../storage/write-lock.js';
19
- const execFileAsync = promisify(execFile);
20
24
  function packageRoot() {
21
25
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
22
26
  }
27
+ // Mirrors python/catence/providers/garmin/worker.py fetch windows: the events
28
+ // listing looks back a year and scheduled workouts start three months ago, so
29
+ // only rows inside those windows are authoritative for deletion sweeps.
30
+ const GARMIN_EVENTS_LOOKBACK_DAYS = 365;
31
+ const GARMIN_SCHEDULED_MONTHS_BACK = 3;
32
+ /**
33
+ * Delete calendar entities that the latest Garmin listing no longer contains.
34
+ * The staging manifest is the authority on which ids were just fetched; any
35
+ * in-window row of the same type missing from it was deleted (or un-enrolled)
36
+ * upstream and would otherwise linger as a ghost.
37
+ */
38
+ async function reconcileGarminCalendarEntities(database, stagedManifestPath) {
39
+ const events = [];
40
+ const scheduled = [];
41
+ const file = createReadStream(stagedManifestPath, { encoding: 'utf8' });
42
+ const lines = createInterface({ input: file, crlfDelay: Infinity });
43
+ for await (const line of lines) {
44
+ if (!line.trim())
45
+ continue;
46
+ let record;
47
+ try {
48
+ record = JSON.parse(line);
49
+ }
50
+ catch {
51
+ continue; // The importer owns malformed-line reporting.
52
+ }
53
+ const candidate = record;
54
+ if (candidate?.kind !== 'source_entity' || candidate.provider !== 'garmin')
55
+ continue;
56
+ if (candidate.entityType === 'event' && typeof candidate.remoteId === 'string')
57
+ events.push(candidate.remoteId);
58
+ if (candidate.entityType === 'scheduled_workout' && typeof candidate.remoteId === 'string')
59
+ scheduled.push(candidate.remoteId);
60
+ }
61
+ const now = new Date();
62
+ const eventCutoff = new Date(now.getTime() - GARMIN_EVENTS_LOOKBACK_DAYS * 86_400_000).toISOString().slice(0, 10);
63
+ const scheduledCutoff = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - GARMIN_SCHEDULED_MONTHS_BACK, 1)).toISOString().slice(0, 10);
64
+ await database.deleteMissingCalendarEntities('garmin', ['event'], events, eventCutoff);
65
+ await database.deleteMissingCalendarEntities('garmin', ['scheduled_workout'], scheduled, scheduledCutoff);
66
+ }
67
+ function baseProgress(runId, provider, stage) {
68
+ return {
69
+ runId, provider, stage, currentStep: null,
70
+ completedUnits: 0, totalUnits: null, percentComplete: 0, elapsedSeconds: 0,
71
+ estimatedRemainingSeconds: null, heartbeatAt: new Date().toISOString(),
72
+ };
73
+ }
74
+ /**
75
+ * Run the standalone swim-length backfill without progress streaming.
76
+ * The backfill reads every archived Garmin FIT (already on disk) and emits
77
+ * source_entity rows for any swim that actually contains length messages;
78
+ * importing them is idempotent, so re-parsing already-imported swims is
79
+ * harmless.
80
+ */
81
+ function runSwimBackfillProcess(arguments_, environment, log) {
82
+ return new Promise((resolve) => {
83
+ const child = spawn('uv', arguments_, { ...environment, stdio: ['ignore', 'pipe', 'pipe'] });
84
+ const stdout = [];
85
+ const stderr = [];
86
+ child.stdout.on('data', (chunk) => stdout.push(chunk.toString('utf8')));
87
+ child.stderr.on('data', (chunk) => stderr.push(chunk.toString('utf8')));
88
+ child.on('error', (error) => {
89
+ log.error('Garmin swim-backfill process could not be started', { error: error.message });
90
+ resolve({ code: 1, signal: null });
91
+ });
92
+ child.on('close', (code, signal) => {
93
+ const out = stdout.join('').trim();
94
+ const err = stderr.join('').trim();
95
+ if (out)
96
+ log.info(`[garmin swim_backfill] ${out}`);
97
+ if (err)
98
+ log.error(`[garmin swim_backfill] ${err}`);
99
+ resolve({ code, signal });
100
+ });
101
+ });
102
+ }
103
+ async function backfillGarminSwimLengths(database, paths, parentRunId, log) {
104
+ const output = path.join(paths.staging, 'garmin', `${parentRunId}.swim_lengths.jsonl`);
105
+ await mkdir(path.dirname(output), { recursive: true });
106
+ // Fresh file for deterministic counting; a stale file would make
107
+ // line counting ambiguous.
108
+ try {
109
+ const { rm } = await import('node:fs/promises');
110
+ await rm(output, { force: true });
111
+ }
112
+ catch {
113
+ // Best-effort cleanup.
114
+ }
115
+ // The swim list is queried here and handed to the Python worker as a file:
116
+ // the child must never open DuckDB itself because the parent holds the
117
+ // write lock for the whole run.
118
+ const activitiesFile = path.join(paths.staging, 'garmin', `${parentRunId}.swim_activities.json`);
119
+ await writeFile(activitiesFile, JSON.stringify(await database.garminSwimActivitySources()));
120
+ const arguments_ = [
121
+ 'run',
122
+ 'python',
123
+ '-m',
124
+ 'python.catence.providers.garmin.swim_backfill',
125
+ '--data-dir',
126
+ paths.root,
127
+ '--output',
128
+ output,
129
+ '--activities',
130
+ activitiesFile,
131
+ ];
132
+ const environment = {
133
+ cwd: packageRoot(),
134
+ env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
135
+ };
136
+ const outcome = await runSwimBackfillProcess(arguments_, environment, log);
137
+ if (outcome.code !== 0) {
138
+ throw new Error(`Garmin swim_backfill exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
139
+ }
140
+ if (!existsSync(output)) {
141
+ log.info('Garmin swim backfill produced no output file', { parentRunId });
142
+ return;
143
+ }
144
+ const content = await readFile(output, 'utf8');
145
+ const lines = content.split('\n').filter((line) => line.trim().length > 0);
146
+ if (lines.length === 0) {
147
+ log.info('Garmin swim backfill produced no staged lengths', { parentRunId });
148
+ return;
149
+ }
150
+ const backfillRunId = randomUUID();
151
+ const imported = await importJsonl(database, backfillRunId, output, log);
152
+ log.info('Imported swim lengths from automatic backfill', { parentRunId, backfillRunId, staged: lines.length, imported });
153
+ }
154
+ /**
155
+ * Run the Garmin staging worker with streamed stdout/stderr so progress and
156
+ * errors surface in real time instead of buffering until the child exits.
157
+ * Progress NDJSON lines (kind 'progress') are normalized and persisted as
158
+ * throttled heartbeats; other output is forwarded to the sync logger.
159
+ */
160
+ function runGarminWorker(runId, arguments_, environment, pump, log, onInterruptReady) {
161
+ return new Promise((resolve) => {
162
+ const child = spawn('uv', arguments_, { ...environment, stdio: ['ignore', 'pipe', 'pipe'] });
163
+ let killTimer;
164
+ onInterruptReady(() => {
165
+ if (child.exitCode !== null || child.signalCode !== null)
166
+ return;
167
+ child.kill('SIGTERM');
168
+ killTimer = setTimeout(() => {
169
+ if (child.exitCode !== null || child.signalCode !== null)
170
+ return;
171
+ child.kill('SIGKILL');
172
+ }, 10_000);
173
+ killTimer.unref();
174
+ });
175
+ const buffer = (stream, sink) => {
176
+ let pending = '';
177
+ stream.on('data', (chunk) => {
178
+ pending += chunk.toString('utf8');
179
+ let newline;
180
+ while ((newline = pending.indexOf('\n')) >= 0) {
181
+ const line = pending.slice(0, newline).trim();
182
+ pending = pending.slice(newline + 1);
183
+ if (line)
184
+ sink(line);
185
+ }
186
+ });
187
+ stream.on('end', () => {
188
+ const line = pending.trim();
189
+ if (line)
190
+ sink(line);
191
+ });
192
+ };
193
+ buffer(child.stdout, (line) => {
194
+ let parsed;
195
+ try {
196
+ parsed = JSON.parse(line);
197
+ }
198
+ catch {
199
+ log.info(`[garmin] ${line}`);
200
+ return;
201
+ }
202
+ if (typeof parsed === 'object' && parsed !== null && parsed.kind === 'progress') {
203
+ const state = normalizeProgress(parsed);
204
+ if (state)
205
+ pump.publish(state);
206
+ return;
207
+ }
208
+ log.info(`[garmin] ${line}`);
209
+ });
210
+ buffer(child.stderr, (line) => log.error(`[garmin] ${line}`));
211
+ child.on('error', (error) => {
212
+ if (killTimer)
213
+ clearTimeout(killTimer);
214
+ resolve({ code: 1, signal: null });
215
+ log.error('Garmin staging worker could not be started', { runId, error: error.message });
216
+ });
217
+ child.on('close', (code, signal) => {
218
+ if (killTimer)
219
+ clearTimeout(killTimer);
220
+ if (code === 0)
221
+ pump.setStageImporting();
222
+ resolve({ code, signal });
223
+ });
224
+ });
225
+ }
226
+ /**
227
+ * Trap termination signals for the duration of a sync run so an interrupted
228
+ * run is marked 'interrupted' in sync_runs instead of being left as a zombie
229
+ * 'running' row. The process exit code mirrors the received signal.
230
+ */ async function runWithInterruptGuard(runId, database, onInterrupt, state, work) {
231
+ let exitCode = 0;
232
+ const handler = (signal) => {
233
+ if (state.interrupted)
234
+ return;
235
+ state.interrupted = true;
236
+ exitCode = signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 129;
237
+ void database.markRunInterrupted(runId).catch(() => undefined);
238
+ try {
239
+ onInterrupt();
240
+ }
241
+ catch {
242
+ // The child may not exist yet; the run is still marked interrupted.
243
+ }
244
+ };
245
+ process.once('SIGINT', handler);
246
+ process.once('SIGTERM', handler);
247
+ process.once('SIGHUP', handler);
248
+ try {
249
+ await work();
250
+ }
251
+ finally {
252
+ process.removeListener('SIGINT', handler);
253
+ process.removeListener('SIGTERM', handler);
254
+ process.removeListener('SIGHUP', handler);
255
+ if (state.interrupted)
256
+ process.exitCode = exitCode;
257
+ }
258
+ }
23
259
  async function syncProvider(database, paths, provider, options, log) {
24
260
  const explicitWindow = options.explicitFrom ? { fromDate: options.explicitFrom, toDate: options.toDate, source: 'explicit' } : null;
25
261
  const uncoveredExplicitWindow = async (cursorName) => {
@@ -52,12 +288,18 @@ async function syncProvider(database, paths, provider, options, log) {
52
288
  backfill: options.backfill, refreshActivities: options.refreshActivities,
53
289
  });
54
290
  let extractionStarted = false;
291
+ const guard = { interrupted: false };
292
+ const pump = new ProgressPump(runId, provider, { database, paths, log });
293
+ pump.start();
55
294
  try {
56
295
  if (provider === 'intervals') {
57
296
  const environment = await athleteProviderEnvironment(paths);
58
297
  const config = requireIntervalsConfig(environment);
59
298
  extractionStarted = true;
60
- await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId, log).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
299
+ pump.setStage('intervals');
300
+ await runWithInterruptGuard(runId, database, () => undefined, guard, async () => {
301
+ await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId, log).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
302
+ });
61
303
  }
62
304
  else {
63
305
  const output = path.join(paths.staging, 'garmin', `${runId}.jsonl`);
@@ -79,26 +321,77 @@ async function syncProvider(database, paths, provider, options, log) {
79
321
  arguments_.push('--skip-activities');
80
322
  if (options.backfill && !options.refreshActivities)
81
323
  arguments_.push('--historical-only');
82
- await execFileAsync('uv', arguments_, {
83
- cwd: packageRoot(),
84
- env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
324
+ // Data-completeness catch-up: stores synced before lactate-threshold
325
+ // history capture only hold the latest singleton point. When no
326
+ // per-day LT observations exist yet, widen the worker's LT window to
327
+ // the athlete's first activity so the history backfills once; the
328
+ // condition self-clears after the first successful import.
329
+ if (!(await database.hasGarminLactateThresholdHistory())) {
330
+ const earliest = await database.earliestActivityStartDate('garmin');
331
+ if (earliest)
332
+ arguments_.push('--lt-history-from', earliest);
333
+ }
334
+ // Calendar-events coverage backfill: a store whose activities predate
335
+ // the short lookback gets exactly one deep pull (to its earliest
336
+ // activity); once an older event lands, normal syncs stay narrow.
337
+ const eventsShortFrom = new Date(Date.now() - EVENT_WINDOW_PAST_DAYS * 86_400_000).toISOString().slice(0, 10);
338
+ const needsEventBackfill = !(await database.hasCalendarEventOlderThan('garmin', eventsShortFrom));
339
+ const earliestActivity = needsEventBackfill ? await database.earliestActivityStartDate('garmin') : null;
340
+ if (needsEventBackfill && earliestActivity && earliestActivity < eventsShortFrom) {
341
+ arguments_.push('--events-from', earliestActivity);
342
+ }
343
+ let interruptChild = null;
344
+ await runWithInterruptGuard(runId, database, () => interruptChild?.(), guard, async () => {
345
+ const outcome = await runGarminWorker(runId, arguments_, {
346
+ cwd: packageRoot(),
347
+ env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
348
+ }, pump, log, (kill) => { interruptChild = kill; });
349
+ if (!guard.interrupted && outcome.code !== 0) {
350
+ throw new Error(`Garmin staging worker exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
351
+ }
85
352
  });
86
- if (!existsSync(output))
87
- throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
88
- await importJsonl(database, runId, output, log);
353
+ if (!guard.interrupted) {
354
+ if (!existsSync(output))
355
+ throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
356
+ await importJsonl(database, runId, output, log);
357
+ await reconcileGarminCalendarEntities(database, output);
358
+ if (provider === 'garmin') {
359
+ try {
360
+ await backfillGarminSwimLengths(database, paths, runId, log);
361
+ }
362
+ catch (error) {
363
+ log.warn('Garmin swim-length backfill after sync failed', {
364
+ runId,
365
+ error: error instanceof Error ? error.message : String(error),
366
+ });
367
+ }
368
+ }
369
+ }
89
370
  }
90
371
  }
91
372
  catch (error) {
92
- const message = error instanceof Error ? error.message : String(error);
93
- log.error('Sync run failed', { runId, provider, error: message, stack: error instanceof Error ? error.stack : undefined });
94
- await database.addError(runId, provider, 'sync', null, message, true);
373
+ if (!guard.interrupted) {
374
+ const message = error instanceof Error ? error.message : String(error);
375
+ log.error('Sync run failed', { runId, provider, error: message, stack: error instanceof Error ? error.stack : undefined });
376
+ await database.addError(runId, provider, 'sync', null, message, true);
377
+ await database.heartbeatRun(runId, baseProgress(runId, provider, 'failed')).catch(() => undefined);
378
+ }
95
379
  }
96
380
  finally {
97
- await database.finishRun(runId);
381
+ pump.stop();
382
+ if (guard.interrupted) {
383
+ await database.markRunInterrupted(runId);
384
+ log.warn('Sync run interrupted', { runId, provider });
385
+ }
386
+ else {
387
+ await database.finishRun(runId);
388
+ await pump.publishFinal(baseProgress(runId, provider, 'completed'));
389
+ }
390
+ await removeProgressSidecar(paths, runId).catch(() => undefined);
98
391
  const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
99
392
  log.info('Sync run finished', { runId, provider, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
100
393
  }
101
- if (options.advanceCursor && !options.explicitFrom && extractionStarted) {
394
+ if (!guard.interrupted && options.advanceCursor && !options.explicitFrom && extractionStarted) {
102
395
  await database.advanceIncrementalCursor(provider, 'daily', runId, options.toDate, 3);
103
396
  await database.advanceIncrementalCursor(provider, 'activities', runId, options.toDate, 14);
104
397
  }
@@ -123,12 +416,24 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
123
416
  const database = await CatenceDatabase.open(paths);
124
417
  const log = createSyncLogger(paths);
125
418
  try {
419
+ const timedOutRuns = await database.interruptStaleSyncRuns(STALE_RUN_TIMEOUT_MS);
420
+ if (timedOutRuns.runIds.length > 0) {
421
+ log.warn('Marked stale sync runs as timed out', { runIds: timedOutRuns.runIds, timeoutMs: STALE_RUN_TIMEOUT_MS });
422
+ }
423
+ for (const runId of timedOutRuns.runIds) {
424
+ await removeProgressSidecar(paths, runId).catch(() => undefined);
425
+ }
126
426
  const providers = provider === 'all' ? ['intervals', 'garmin'] : [provider];
127
427
  const toDate = new Date().toISOString().slice(0, 10);
128
428
  const runs = [];
129
429
  for (const source of providers)
130
430
  runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }, log));
131
- return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs };
431
+ const executedRuns = runs.filter((run) => !run.skipped);
432
+ if (executedRuns.length > 0) {
433
+ const index = await buildRetrievalIndex(database);
434
+ log.info('Rebuilt retrieval index after sync', { documents: index.documents, mode: index.mode, watermark: index.watermark });
435
+ }
436
+ return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs, timedOutRuns: timedOutRuns.runIds };
132
437
  }
133
438
  finally {
134
439
  await database.close();
@@ -138,6 +443,7 @@ export async function retryDataSync(paths, previousRunId) {
138
443
  await ensurePaths(paths);
139
444
  const database = await CatenceDatabase.open(paths);
140
445
  try {
446
+ await database.interruptStaleSyncRuns(STALE_RUN_TIMEOUT_MS);
141
447
  const previous = await database.getRun(previousRunId);
142
448
  if (!previous)
143
449
  throw new Error(`No sync run found for ${previousRunId}.`);
@@ -285,6 +591,30 @@ export async function dataStatus(paths) {
285
591
  await database.close();
286
592
  }
287
593
  }
594
+ export async function syncProgress(paths) {
595
+ await ensurePaths(paths);
596
+ // Live progress must stay readable while a sync run holds the single-writer
597
+ // DuckDB lock, so the running set comes from sidecar heartbeat files first.
598
+ const sidecars = await readRunningProgress(paths);
599
+ let dbRunning = [];
600
+ let dbRecent = [];
601
+ try {
602
+ const repository = await openReadOnlyRepository(paths);
603
+ try {
604
+ const snapshot = await repository.progress();
605
+ dbRunning = snapshot.running;
606
+ dbRecent = snapshot.recent;
607
+ }
608
+ finally {
609
+ await repository.close();
610
+ }
611
+ }
612
+ catch (error) {
613
+ if (!(error instanceof ReadOnlyDatabaseError))
614
+ throw error;
615
+ }
616
+ return mergeProgress(dbRunning, dbRecent, sidecars);
617
+ }
288
618
  export async function rebuildRetrievalIndex(paths) {
289
619
  if (!existsSync(paths.database))
290
620
  throw new Error('No Catence database exists yet. Complete a sync before building the retrieval index.');
@@ -296,4 +626,76 @@ export async function rebuildRetrievalIndex(paths) {
296
626
  await database.close();
297
627
  }
298
628
  }
629
+ export async function importStagedFile(paths, provider, filePath) {
630
+ if (!existsSync(paths.database))
631
+ throw new Error('No Catence database exists yet. Complete a sync before importing staged records.');
632
+ if (!existsSync(filePath))
633
+ throw new Error(`Staging file not found: ${filePath}`);
634
+ const database = await CatenceDatabase.open(paths);
635
+ try {
636
+ const runId = randomUUID();
637
+ const importedRecords = await importJsonl(database, runId, filePath);
638
+ return { runId, provider, importedRecords };
639
+ }
640
+ finally {
641
+ await database.close();
642
+ }
643
+ }
644
+ export async function resolveExtractionErrors(paths, opts = {}) {
645
+ if (!existsSync(paths.database))
646
+ throw new Error('No Catence database exists yet.');
647
+ const database = await CatenceDatabase.open(paths);
648
+ try {
649
+ const resolved = await database.resolveErrors({
650
+ runIds: opts.runIds,
651
+ provider: opts.provider,
652
+ before: opts.before,
653
+ all: opts.all,
654
+ });
655
+ return { resolvedErrors: resolved };
656
+ }
657
+ finally {
658
+ await database.close();
659
+ }
660
+ }
661
+ /**
662
+ * Re-run normalization over already-captured nutrition entities without
663
+ * contacting any provider. Idempotent upserts let a fixed normalizer backfill
664
+ * previously mis-parsed nutrition_days/nutrition_items from stored payloads.
665
+ */
666
+ export async function reimportNutrition(paths) {
667
+ if (!existsSync(paths.database))
668
+ throw new Error('No Catence database exists yet. Complete a sync before re-importing nutrition.');
669
+ const database = await CatenceDatabase.open(paths);
670
+ try {
671
+ const rows = await database.rows(`SELECT provider, entity_type, remote_id, parent_remote_id, occurred_on, source_updated_at, raw_object_hash,
672
+ cast(payload_json AS VARCHAR) AS payload_json, cast(extension_json AS VARCHAR) AS extension_json
673
+ FROM source_entities
674
+ WHERE entity_type IN ('nutrition_log', 'nutrition_day')
675
+ ORDER BY provider, occurred_on, remote_id`);
676
+ for (const row of rows) {
677
+ await importSourceEntity(database, {
678
+ kind: 'source_entity', schemaVersion: 1, provider: row.provider, entityType: row.entity_type,
679
+ remoteId: row.remote_id, parentRemoteId: row.parent_remote_id, occurredOn: row.occurred_on, sourceUpdatedAt: row.source_updated_at,
680
+ rawObjectHash: row.raw_object_hash, payload: parseJsonObject(row.payload_json), extension: parseJsonObject(row.extension_json),
681
+ });
682
+ }
683
+ const days = (await database.rows('SELECT count(*) AS count FROM nutrition_days'))[0]?.count ?? 0;
684
+ const items = (await database.rows('SELECT count(*) AS count FROM nutrition_items'))[0]?.count ?? 0;
685
+ return { reimportedEntities: rows.length, nutritionDays: Number(days), nutritionItems: Number(items) };
686
+ }
687
+ finally {
688
+ await database.close();
689
+ }
690
+ }
691
+ function parseJsonObject(value) {
692
+ if (!value)
693
+ return {};
694
+ try {
695
+ return JSON.parse(value);
696
+ }
697
+ catch {
698
+ return {};
699
+ }
700
+ }
299
701
  //# 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);
@@ -13,6 +13,15 @@ export declare class IntervalsExtractor {
13
13
  fromDate: string;
14
14
  toDate: string;
15
15
  } | null, includeAccount?: boolean): Promise<void>;
16
+ /**
17
+ * Regular syncs reach back EVENT_WINDOW_PAST_DAYS — just enough to
18
+ * associate recently finished events with their activities. A store whose
19
+ * own activity history predates that span gets an automatic one-time
20
+ * coverage backfill instead: the window stretches to the earliest
21
+ * activity until an event older than the short window lands (self-
22
+ * clearing), after which deeper history is the backfill command's job.
23
+ */
24
+ private resolveEventsSyncWindow;
16
25
  private captureCollectionChildren;
17
26
  private getCollection;
18
27
  private captureActivity;
@@ -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) {
@@ -67,6 +67,21 @@ export class IntervalsExtractor {
67
67
  for (const endpoint of intervalsSecondaryReadRegistry) {
68
68
  if (endpoint.name === 'athlete')
69
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
+ }
70
85
  if (!activityWindow)
71
86
  continue;
72
87
  const { fromDate, toDate } = activityWindow;
@@ -86,6 +101,23 @@ export class IntervalsExtractor {
86
101
  }
87
102
  }
88
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
+ }
89
121
  async captureCollectionChildren(runId, collection, payload, athleteId) {
90
122
  const children = {
91
123
  events: [{ suffix: (id) => `/athlete/${athleteId}/events/${id}`, entityType: 'event' }],
@@ -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'],