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.
Files changed (76) hide show
  1. package/README.md +38 -166
  2. package/config.example.json +5 -1
  3. package/dist/contracts/index.d.ts +1 -0
  4. package/dist/contracts/index.js +1 -0
  5. package/dist/contracts/progress.d.ts +23 -0
  6. package/dist/contracts/progress.js +49 -0
  7. package/dist/contracts/release.d.ts +1 -1
  8. package/dist/contracts/release.js +1 -1
  9. package/dist/core/logging.d.ts +16 -0
  10. package/dist/core/logging.js +68 -0
  11. package/dist/core/query/activity-discovery.d.ts +2 -0
  12. package/dist/core/query/activity-discovery.js +1 -1
  13. package/dist/core/query/analytics.d.ts +10 -0
  14. package/dist/core/query/analytics.js +494 -0
  15. package/dist/core/query/catalog.d.ts +1 -1
  16. package/dist/core/query/catalog.js +8 -4
  17. package/dist/core/query/events.d.ts +34 -0
  18. package/dist/core/query/events.js +121 -0
  19. package/dist/core/query/fitness.d.ts +4 -0
  20. package/dist/core/query/fitness.js +60 -0
  21. package/dist/core/query/repository.d.ts +2 -0
  22. package/dist/core/query/repository.js +48 -0
  23. package/dist/core/query/swimming.js +2 -2
  24. package/dist/core/runtime/configuration.d.ts +3 -1
  25. package/dist/core/runtime/configuration.js +5 -1
  26. package/dist/core/runtime/opencode-go.d.ts +58 -0
  27. package/dist/core/runtime/opencode-go.js +246 -0
  28. package/dist/core/runtime/secrets.js +51 -13
  29. package/dist/elt/application/detached-sync.d.ts +50 -0
  30. package/dist/elt/application/detached-sync.js +99 -0
  31. package/dist/elt/application/management.d.ts +15 -0
  32. package/dist/elt/application/management.js +435 -19
  33. package/dist/elt/ingestion/importer.d.ts +3 -2
  34. package/dist/elt/ingestion/importer.js +5 -4
  35. package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
  36. package/dist/elt/ingestion/providers/intervals.js +37 -2
  37. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  38. package/dist/elt/ingestion/providers/registry.js +19 -1
  39. package/dist/elt/ingestion/providers/strava/service.js +7 -1
  40. package/dist/elt/normalization/normalizers.js +207 -15
  41. package/dist/elt/storage/database.d.ts +21 -2
  42. package/dist/elt/storage/database.js +184 -6
  43. package/dist/elt/storage/migrations.js +145 -1
  44. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  45. package/dist/elt/storage/progress-sidecar.js +200 -0
  46. package/dist/interfaces/cli/commands.js +94 -1
  47. package/dist/interfaces/http/generation-status.d.ts +19 -0
  48. package/dist/interfaces/http/generation-status.js +55 -0
  49. package/dist/interfaces/http/server.d.ts +7 -1
  50. package/dist/interfaces/http/server.js +184 -1
  51. package/dist/interfaces/mcp/main.js +33 -1
  52. package/dist/interfaces/mcp/server.d.ts +2 -1
  53. package/dist/interfaces/mcp/server.js +168 -45
  54. package/dist/runtime/index.d.ts +9 -2
  55. package/dist/runtime/index.js +6 -2
  56. package/docs/architecture.md +13 -2
  57. package/docs/assets/image.png +0 -0
  58. package/docs/configuration.md +243 -0
  59. package/docs/deployment/docker.md +528 -0
  60. package/docs/deployment/local-console.md +263 -0
  61. package/docs/deployment/local-mcp.md +329 -0
  62. package/docs/development.md +337 -0
  63. package/docs/distribution.md +45 -0
  64. package/docs/llm-providers.md +66 -1
  65. package/docs/mcp-activity-retrieval.md +90 -210
  66. package/package.json +1 -1
  67. package/pyproject.toml +1 -1
  68. package/python/catence/providers/garmin/cli.py +43 -9
  69. package/python/catence/providers/garmin/progress.py +85 -0
  70. package/python/catence/providers/garmin/registry.py +2 -0
  71. package/python/catence/providers/garmin/streams.py +38 -0
  72. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  73. package/python/catence/providers/garmin/worker.py +415 -12
  74. package/scripts/discover-opencode-go.mjs +36 -144
  75. package/uv.lock +3 -3
  76. package/docs/deployment.md +0 -338
@@ -1,25 +1,262 @@
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';
14
+ import { createSyncLogger } from '../../core/logging.js';
11
15
  import { athleteProviderEnvironment } from '../../core/runtime/secrets.js';
12
16
  import { importJsonl } from '../ingestion/importer.js';
13
17
  import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
14
18
  import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUrl, syncStravaGear } from '../ingestion/providers/strava/service.js';
15
19
  import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
16
- 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';
17
23
  import { withDataWriteLock } from '../storage/write-lock.js';
18
- const execFileAsync = promisify(execFile);
19
24
  function packageRoot() {
20
25
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
21
26
  }
22
- async function syncProvider(database, paths, provider, options) {
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
+ }
259
+ async function syncProvider(database, paths, provider, options, log) {
23
260
  const explicitWindow = options.explicitFrom ? { fromDate: options.explicitFrom, toDate: options.toDate, source: 'explicit' } : null;
24
261
  const uncoveredExplicitWindow = async (cursorName) => {
25
262
  if (!explicitWindow)
@@ -33,6 +270,7 @@ async function syncProvider(database, paths, provider, options) {
33
270
  const activityWindow = explicitWindow ? await uncoveredExplicitWindow('activities') : await database.resolveIncrementalWindow(provider, 'activities', options.toDate, defaultFromDate(), 14);
34
271
  const hasProviderWork = provider === 'intervals' ? activityWindow !== null : dailyWindow !== null || activityWindow !== null;
35
272
  if (!hasProviderWork) {
273
+ log.debug('No uncovered work; skipping provider', { provider, daily: dailyWindow ?? null, activities: activityWindow ?? null });
36
274
  return {
37
275
  runId: null, provider, fromDate: null,
38
276
  dailyFromDate: dailyWindow?.fromDate ?? null, dailyToDate: dailyWindow?.toDate ?? null,
@@ -43,13 +281,25 @@ async function syncProvider(database, paths, provider, options) {
43
281
  }
44
282
  const fromDate = [dailyWindow?.fromDate, activityWindow?.fromDate].filter((date) => Boolean(date)).sort()[0];
45
283
  const runId = await database.beginRun(provider, fromDate);
284
+ log.info('Starting sync run', {
285
+ runId, provider, fromDate, toDate: options.toDate,
286
+ dailyFrom: dailyWindow?.fromDate ?? null, dailyTo: dailyWindow?.toDate ?? null,
287
+ activityFrom: activityWindow?.fromDate ?? null, activityTo: activityWindow?.toDate ?? null,
288
+ backfill: options.backfill, refreshActivities: options.refreshActivities,
289
+ });
46
290
  let extractionStarted = false;
291
+ const guard = { interrupted: false };
292
+ const pump = new ProgressPump(runId, provider, { database, paths, log });
293
+ pump.start();
47
294
  try {
48
295
  if (provider === 'intervals') {
49
296
  const environment = await athleteProviderEnvironment(paths);
50
297
  const config = requireIntervalsConfig(environment);
51
298
  extractionStarted = true;
52
- await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId).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
+ });
53
303
  }
54
304
  else {
55
305
  const output = path.join(paths.staging, 'garmin', `${runId}.jsonl`);
@@ -71,22 +321,77 @@ async function syncProvider(database, paths, provider, options) {
71
321
  arguments_.push('--skip-activities');
72
322
  if (options.backfill && !options.refreshActivities)
73
323
  arguments_.push('--historical-only');
74
- await execFileAsync('uv', arguments_, {
75
- cwd: packageRoot(),
76
- 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
+ }
77
352
  });
78
- if (!existsSync(output))
79
- throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
80
- await importJsonl(database, runId, output);
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
+ }
81
370
  }
82
371
  }
83
372
  catch (error) {
84
- await database.addError(runId, provider, 'sync', null, error instanceof Error ? error.message : String(error), 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
+ }
85
379
  }
86
380
  finally {
87
- 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);
391
+ const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
392
+ log.info('Sync run finished', { runId, provider, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
88
393
  }
89
- if (options.advanceCursor && !options.explicitFrom && extractionStarted) {
394
+ if (!guard.interrupted && options.advanceCursor && !options.explicitFrom && extractionStarted) {
90
395
  await database.advanceIncrementalCursor(provider, 'daily', runId, options.toDate, 3);
91
396
  await database.advanceIncrementalCursor(provider, 'activities', runId, options.toDate, 14);
92
397
  }
@@ -109,13 +414,26 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
109
414
  if (provider === 'strava')
110
415
  return { provider, result: await syncStravaGear(paths) };
111
416
  const database = await CatenceDatabase.open(paths);
417
+ const log = createSyncLogger(paths);
112
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
+ }
113
426
  const providers = provider === 'all' ? ['intervals', 'garmin'] : [provider];
114
427
  const toDate = new Date().toISOString().slice(0, 10);
115
428
  const runs = [];
116
429
  for (const source of providers)
117
- runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }));
118
- return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs };
430
+ runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }, log));
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 };
119
437
  }
120
438
  finally {
121
439
  await database.close();
@@ -125,12 +443,14 @@ export async function retryDataSync(paths, previousRunId) {
125
443
  await ensurePaths(paths);
126
444
  const database = await CatenceDatabase.open(paths);
127
445
  try {
446
+ await database.interruptStaleSyncRuns(STALE_RUN_TIMEOUT_MS);
128
447
  const previous = await database.getRun(previousRunId);
129
448
  if (!previous)
130
449
  throw new Error(`No sync run found for ${previousRunId}.`);
131
450
  if (previous.provider === 'strava')
132
451
  throw new Error('Retry Strava gear sync with `catence-data sync --provider strava`.');
133
- const run = await syncProvider(database, paths, previous.provider, { explicitFrom: previous.from_date, toDate: new Date().toISOString().slice(0, 10), advanceCursor: false, refreshActivities: false, backfill: false });
452
+ const log = createSyncLogger(paths);
453
+ const run = await syncProvider(database, paths, previous.provider, { explicitFrom: previous.from_date, toDate: new Date().toISOString().slice(0, 10), advanceCursor: false, refreshActivities: false, backfill: false }, log);
134
454
  return { retriedRun: previousRunId, runId: run.runId, run };
135
455
  }
136
456
  finally {
@@ -271,6 +591,30 @@ export async function dataStatus(paths) {
271
591
  await database.close();
272
592
  }
273
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
+ }
274
618
  export async function rebuildRetrievalIndex(paths) {
275
619
  if (!existsSync(paths.database))
276
620
  throw new Error('No Catence database exists yet. Complete a sync before building the retrieval index.');
@@ -282,4 +626,76 @@ export async function rebuildRetrievalIndex(paths) {
282
626
  await database.close();
283
627
  }
284
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
+ }
285
701
  //# sourceMappingURL=management.js.map
@@ -1,7 +1,8 @@
1
1
  import type { CatenceDatabase } from '../storage/database.js';
2
2
  import { type StagingRecord } from '../../contracts/staging.js';
3
- export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord): Promise<void>;
4
- export declare function importJsonl(database: CatenceDatabase, runId: string, filePath: string): Promise<number>;
3
+ import type { SyncLogger } from '../../core/logging.js';
4
+ export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord, log?: SyncLogger): Promise<void>;
5
+ export declare function importJsonl(database: CatenceDatabase, runId: string, filePath: string, log?: SyncLogger): Promise<number>;
5
6
  export declare function importSingleStagingFile(database: CatenceDatabase, runId: string, filePath: string): Promise<number>;
6
7
  export declare function queueWorkItem(database: CatenceDatabase, runId: string, provider: 'intervals' | 'garmin' | 'strava', endpoint: string, remoteId: string | null, scope: unknown): Promise<string>;
7
8
  export declare function completeWorkItem(database: CatenceDatabase, itemId: string): Promise<void>;
@@ -7,7 +7,7 @@ import { importSourceEntity, refreshActivityQuality } from '../normalization/nor
7
7
  import { normalizeStravaEntity } from '../normalization/segments/strava-normalizer.js';
8
8
  import { reconcileActivityLink } from '../normalization/activities/linking.js';
9
9
  import { json } from '../storage/sql.js';
10
- export async function importRecord(database, runId, record) {
10
+ export async function importRecord(database, runId, record, log) {
11
11
  if (record.kind === 'run_manifest') {
12
12
  if (record.runId !== runId)
13
13
  throw new Error(`Staging manifest run ${record.runId} does not match importer run ${runId}.`);
@@ -33,7 +33,7 @@ export async function importRecord(database, runId, record) {
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);
@@ -54,8 +54,9 @@ export async function importRecord(database, runId, record) {
54
54
  return;
55
55
  }
56
56
  await database.addError(runId, record.provider, record.endpoint, record.remoteId, record.message, record.retryable);
57
+ log?.warn('Staging record reported an extraction error', { runId, provider: record.provider, endpoint: record.endpoint, remoteId: record.remoteId, error: record.message });
57
58
  }
58
- export async function importJsonl(database, runId, filePath) {
59
+ export async function importJsonl(database, runId, filePath, log) {
59
60
  let count = 0;
60
61
  const file = createReadStream(filePath, { encoding: 'utf8' });
61
62
  const lines = createInterface({ input: file, crlfDelay: Infinity });
@@ -63,7 +64,7 @@ export async function importJsonl(database, runId, filePath) {
63
64
  if (!line.trim())
64
65
  continue;
65
66
  const record = stagingRecordSchema.parse(JSON.parse(line));
66
- await importRecord(database, runId, record);
67
+ await importRecord(database, runId, record, log);
67
68
  count += 1;
68
69
  }
69
70
  return count;
@@ -1,16 +1,27 @@
1
1
  import type { CatenceDatabase } from '../../storage/database.js';
2
2
  import type { CatencePaths } from '../../../contracts/runtime.js';
3
+ import type { SyncLogger } from '../../../core/logging.js';
3
4
  export declare class IntervalsExtractor {
4
5
  private readonly database;
5
6
  private readonly paths;
6
7
  private readonly apiKey;
7
8
  private readonly athleteId;
9
+ private readonly log;
8
10
  private readonly client;
9
- constructor(database: CatenceDatabase, paths: CatencePaths, apiKey: string, athleteId: string);
11
+ constructor(database: CatenceDatabase, paths: CatencePaths, apiKey: string, athleteId: string, log: SyncLogger);
10
12
  sync(runId: string, activityWindow: {
11
13
  fromDate: string;
12
14
  toDate: string;
13
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;
14
25
  private captureCollectionChildren;
15
26
  private getCollection;
16
27
  private captureActivity;