catence 0.2.0-beta.2 → 0.2.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +28 -82
  2. package/config.example.json +25 -0
  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/repository.d.ts +2 -0
  12. package/dist/core/query/repository.js +48 -0
  13. package/dist/core/runtime/catalog.js +17 -7
  14. package/dist/core/runtime/configuration.d.ts +3 -1
  15. package/dist/core/runtime/configuration.js +5 -1
  16. package/dist/core/runtime/opencode-go.d.ts +58 -0
  17. package/dist/core/runtime/opencode-go.js +246 -0
  18. package/dist/core/runtime/secrets.js +51 -13
  19. package/dist/core/runtime/self-update.d.ts +44 -0
  20. package/dist/core/runtime/self-update.js +138 -0
  21. package/dist/elt/application/detached-sync.d.ts +50 -0
  22. package/dist/elt/application/detached-sync.js +99 -0
  23. package/dist/elt/application/management.d.ts +15 -0
  24. package/dist/elt/application/management.js +435 -19
  25. package/dist/elt/ingestion/importer.d.ts +3 -2
  26. package/dist/elt/ingestion/importer.js +5 -4
  27. package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
  28. package/dist/elt/ingestion/providers/intervals.js +37 -2
  29. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  30. package/dist/elt/ingestion/providers/registry.js +18 -1
  31. package/dist/elt/ingestion/providers/strava/service.js +7 -1
  32. package/dist/elt/normalization/normalizers.js +111 -15
  33. package/dist/elt/storage/database.d.ts +21 -2
  34. package/dist/elt/storage/database.js +184 -6
  35. package/dist/elt/storage/migrations.js +25 -1
  36. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  37. package/dist/elt/storage/progress-sidecar.js +200 -0
  38. package/dist/interfaces/cli/commands.js +116 -1
  39. package/dist/interfaces/http/generation-status.d.ts +19 -0
  40. package/dist/interfaces/http/generation-status.js +55 -0
  41. package/dist/interfaces/http/server.d.ts +7 -1
  42. package/dist/interfaces/http/server.js +184 -1
  43. package/dist/interfaces/mcp/server.d.ts +2 -1
  44. package/dist/interfaces/mcp/server.js +50 -13
  45. package/dist/runtime/index.d.ts +8 -1
  46. package/dist/runtime/index.js +4 -1
  47. package/docs/architecture.md +11 -1
  48. package/docs/configuration.md +243 -0
  49. package/docs/deployment/docker.md +528 -0
  50. package/docs/deployment/local-console.md +263 -0
  51. package/docs/deployment/local-mcp.md +329 -0
  52. package/docs/development.md +337 -0
  53. package/docs/distribution.md +58 -0
  54. package/docs/llm-providers.md +249 -6
  55. package/docs/mcp-activity-retrieval.md +37 -30
  56. package/package.json +5 -1
  57. package/pyproject.toml +1 -1
  58. package/python/catence/providers/garmin/cli.py +43 -9
  59. package/python/catence/providers/garmin/progress.py +85 -0
  60. package/python/catence/providers/garmin/registry.py +1 -0
  61. package/python/catence/providers/garmin/streams.py +38 -0
  62. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  63. package/python/catence/providers/garmin/worker.py +372 -11
  64. package/scripts/discover-opencode-go.mjs +102 -0
  65. package/scripts/discover-opencode-zen.mjs +218 -0
  66. package/uv.lock +3 -3
@@ -2,8 +2,31 @@ import { DuckDBInstance } from '@duckdb/node-api';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { ReadOnlyRepository } from '../../core/query/repository.js';
5
+ import { SYNC_STAGES, STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
5
6
  import { migrations } from './migrations.js';
6
7
  import { json } from './sql.js';
8
+ /**
9
+ * DuckDB's node API throws "Cannot create values of type ANY" when a bound
10
+ * parameter resolves to an untyped value. JS `Date` objects surface as `ANY`
11
+ * (and are not auto-coerced the way strings are), so any date/timestamp
12
+ * parameter read back from the database and re-bound crashes the statement.
13
+ * Serializing dates to ISO strings keeps every parameter concretely typed.
14
+ */
15
+ function serializeValue(value) {
16
+ if (value instanceof Date)
17
+ return value.toISOString();
18
+ return value;
19
+ }
20
+ function serializeBindings(bindings) {
21
+ if (!bindings)
22
+ return bindings;
23
+ if (Array.isArray(bindings))
24
+ return bindings.map(serializeValue);
25
+ const out = {};
26
+ for (const [key, value] of Object.entries(bindings))
27
+ out[key] = serializeValue(value);
28
+ return out;
29
+ }
7
30
  export class CatenceDatabase {
8
31
  instance;
9
32
  connection;
@@ -61,10 +84,10 @@ export class CatenceDatabase {
61
84
  }
62
85
  }
63
86
  async run(sql, values) {
64
- await this.connection.run(sql, values);
87
+ await this.connection.run(sql, serializeBindings(values));
65
88
  }
66
89
  async rows(sql, values) {
67
- const result = await this.connection.runAndReadAll(sql, values);
90
+ const result = await this.connection.runAndReadAll(sql, serializeBindings(values));
68
91
  return result.getRowObjectsJS();
69
92
  }
70
93
  async tableNames(query) {
@@ -81,11 +104,132 @@ export class CatenceDatabase {
81
104
  await this.run(`UPDATE sync_runs SET status = CASE WHEN error_count > 0 THEN 'completed_with_errors' ELSE 'completed' END, completed_at = now() WHERE run_id = $runId`, { runId });
82
105
  await this.run("UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context'");
83
106
  }
107
+ async heartbeatRun(runId, progress) {
108
+ await this.run(`INSERT INTO sync_run_progress (run_id, provider, stage, current_step, completed_units, total_units, percent, elapsed_seconds, estimated_remaining_seconds, heartbeat_at, detail_json)
109
+ VALUES ($runId, $provider, $stage, $currentStep, $completedUnits, $totalUnits, $percent, $elapsedSeconds, $estimatedRemainingSeconds, now(), '{}')
110
+ ON CONFLICT (run_id) DO UPDATE SET
111
+ provider = EXCLUDED.provider,
112
+ stage = EXCLUDED.stage,
113
+ current_step = EXCLUDED.current_step,
114
+ completed_units = EXCLUDED.completed_units,
115
+ total_units = EXCLUDED.total_units,
116
+ percent = EXCLUDED.percent,
117
+ elapsed_seconds = EXCLUDED.elapsed_seconds,
118
+ estimated_remaining_seconds = EXCLUDED.estimated_remaining_seconds,
119
+ heartbeat_at = now(),
120
+ detail_json = '{}'`, {
121
+ runId,
122
+ provider: progress.provider,
123
+ stage: progress.stage,
124
+ currentStep: progress.currentStep,
125
+ completedUnits: progress.completedUnits,
126
+ totalUnits: progress.totalUnits,
127
+ percent: progress.percentComplete,
128
+ elapsedSeconds: progress.elapsedSeconds,
129
+ estimatedRemainingSeconds: progress.estimatedRemainingSeconds,
130
+ });
131
+ }
132
+ async markRunInterrupted(runId, stage = 'interrupted') {
133
+ await this.run("UPDATE sync_runs SET status = 'interrupted', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId });
134
+ await this.run('UPDATE sync_run_progress SET stage = $stage, heartbeat_at = now() WHERE run_id = $runId', { runId, stage });
135
+ }
136
+ async interruptStaleSyncRuns(timeoutMs = STALE_RUN_TIMEOUT_MS) {
137
+ const cutoff = new Date(Date.now() - timeoutMs).toISOString();
138
+ const stale = await this.rows(`SELECT run_id FROM sync_runs WHERE status = 'running' AND started_at < $cutoff`, { cutoff });
139
+ for (const row of stale) {
140
+ await this.run("UPDATE sync_runs SET status = 'timed_out', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId: row.run_id });
141
+ await this.run("UPDATE sync_run_progress SET stage = 'timed_out', heartbeat_at = now() WHERE run_id = $runId", { runId: row.run_id });
142
+ }
143
+ return { runIds: stale.map((row) => row.run_id) };
144
+ }
145
+ async syncProgress(timeoutMs = STALE_RUN_TIMEOUT_MS) {
146
+ // A run killed with its container (docker restart, OOM) keeps
147
+ // status='running' until something cleans it up, and that cleanup only
148
+ // runs inside a starting sync child — which the busy guard refuses to
149
+ // spawn while the row reads as active. Age-filtering here mirrors the
150
+ // sidecar contract: a heartbeat older than the stale timeout can never
151
+ // block a new run or disable the dashboard's Sync button forever.
152
+ const cutoff = new Date(Date.now() - timeoutMs).toISOString();
153
+ const running = await this.rows(`SELECT runs.run_id, runs.provider,
154
+ coalesce(progress.stage, 'starting') AS stage,
155
+ progress.current_step AS current_step,
156
+ coalesce(progress.completed_units, 0) AS completed_units,
157
+ progress.total_units AS total_units,
158
+ coalesce(progress.percent, 0) AS percent,
159
+ coalesce(progress.elapsed_seconds, 0) AS elapsed_seconds,
160
+ progress.estimated_remaining_seconds AS estimated_remaining_seconds,
161
+ cast(coalesce(progress.heartbeat_at, runs.started_at) AS VARCHAR) AS heartbeat_at
162
+ FROM sync_runs AS runs
163
+ LEFT JOIN sync_run_progress AS progress USING (run_id)
164
+ WHERE runs.status = 'running'
165
+ AND coalesce(progress.heartbeat_at, runs.started_at) > $cutoff
166
+ ORDER BY runs.started_at DESC`, { cutoff });
167
+ const recent = await this.rows(`SELECT progress.run_id, progress.provider, progress.stage, progress.current_step,
168
+ progress.completed_units, progress.total_units, progress.percent, progress.elapsed_seconds,
169
+ progress.estimated_remaining_seconds, cast(progress.heartbeat_at AS VARCHAR) AS heartbeat_at
170
+ FROM sync_run_progress AS progress
171
+ ORDER BY progress.heartbeat_at DESC
172
+ LIMIT 10`);
173
+ return { running: running.map(toSyncProgressState), recent: recent.map(toSyncProgressState) };
174
+ }
175
+ async hasCalendarEventOlderThan(provider, beforeIsoDate, entityType = 'event') {
176
+ // Coverage signal for the automatic calendar-events backfill: true once
177
+ // the store already holds an event older than the regular short window,
178
+ // meaning the one-time deep pull has landed and normal syncs can stay
179
+ // narrow. Athletes with genuinely no old events re-run the deep window;
180
+ // it costs one small paginated listing per sync.
181
+ const rows = await this.rows(`SELECT 1 FROM source_entities WHERE provider = $provider AND entity_type = $entityType AND occurred_on < $before LIMIT 1`, { provider, entityType, before: beforeIsoDate });
182
+ return rows.length > 0;
183
+ }
184
+ async deleteMissingCalendarEntities(provider, entityTypes, keepRemoteIds, minOccurredOn) {
185
+ // Calendar entities (joined races, scheduled workouts) are mutable and
186
+ // deletable upstream. Re-fetches upsert survivors via remote_id; this
187
+ // removes in-window rows absent from the latest authoritative listing so
188
+ // upstream deletions propagate instead of leaving ghosts forever. Rows
189
+ // without a date are preserved (they predate strict dating).
190
+ if (!entityTypes.length)
191
+ return;
192
+ const values = { provider, minOccurredOn };
193
+ const typePlaceholders = entityTypes.map((name, index) => {
194
+ values[`type${index}`] = name;
195
+ return `$type${index}`;
196
+ });
197
+ let keepClause = '';
198
+ if (keepRemoteIds.length) {
199
+ keepClause = ` AND remote_id NOT IN (${keepRemoteIds.map((_, index) => `$keep${index}`).join(', ')})`;
200
+ keepRemoteIds.forEach((id, index) => { values[`keep${index}`] = id; });
201
+ }
202
+ for (const table of ['source_entities', 'domain_entities']) {
203
+ await this.run(`DELETE FROM ${table} WHERE provider = $provider AND entity_type IN (${typePlaceholders}) AND occurred_on >= $minOccurredOn${keepClause}`, values);
204
+ }
205
+ }
84
206
  async addError(runId, provider, endpoint, remoteId, message, retryable) {
85
207
  await this.run(`INSERT INTO normalization_errors (error_id, run_id, provider, endpoint, remote_id, message, retryable)
86
208
  VALUES ($errorId, $runId, $provider, $endpoint, $remoteId, $message, $retryable)`, { errorId: randomUUID(), runId, provider, endpoint, remoteId, message, retryable });
87
209
  await this.run('UPDATE sync_runs SET error_count = error_count + 1 WHERE run_id = $runId', { runId });
88
210
  }
211
+ async resolveErrors(opts = {}) {
212
+ const clauses = ['resolved_at IS NULL'];
213
+ const values = {};
214
+ if (opts.runIds && opts.runIds.length > 0) {
215
+ const placeholders = opts.runIds.map((_, index) => `$runId${index}`).join(', ');
216
+ clauses.push(`run_id IN (${placeholders})`);
217
+ opts.runIds.forEach((runId, index) => { values[`runId${index}`] = runId; });
218
+ }
219
+ if (opts.provider) {
220
+ clauses.push('provider = $provider');
221
+ values.provider = opts.provider;
222
+ }
223
+ if (opts.before) {
224
+ clauses.push('created_at < $before');
225
+ values.before = opts.before;
226
+ }
227
+ if (!opts.all && clauses.length === 1) {
228
+ throw new Error('Refusing to resolve all unresolved errors without an explicit --all flag.');
229
+ }
230
+ const resolved = await this.rows(`UPDATE normalization_errors SET resolved_at = now() WHERE ${clauses.join(' AND ')} RETURNING error_id`, values);
231
+ return resolved.length;
232
+ }
89
233
  async status() {
90
234
  // @duckdb/node-api owns native statement state on the connection. Keep
91
235
  // status reads sequential instead of issuing several native calls at once.
@@ -94,9 +238,11 @@ export class CatenceDatabase {
94
238
  const rawObjects = await this.rows('SELECT count(*)::INTEGER AS count FROM raw_objects');
95
239
  const activities = await this.rows('SELECT count(*)::INTEGER AS count FROM activities');
96
240
  const streams = await this.rows('SELECT count(*)::INTEGER AS count FROM stream_manifest');
241
+ const errorDetails = await this.rows('SELECT run_id, provider, endpoint, remote_id, message, cast(created_at AS VARCHAR) AS created_at FROM normalization_errors WHERE resolved_at IS NULL ORDER BY created_at DESC LIMIT 100');
97
242
  const cursors = await this.rows('SELECT provider, cursor_name, cast(covered_through_date AS VARCHAR) AS covered_through_date, cast(latest_source_date AS VARCHAR) AS latest_source_date, lookback_days, last_successful_run_id, cast(last_completed_at AS VARCHAR) AS last_completed_at, status FROM sync_cursors ORDER BY provider, cursor_name');
98
243
  return {
99
244
  runs, unresolvedErrors: errors[0]?.count ?? 0, rawObjects: rawObjects[0]?.count ?? 0, activities: activities[0]?.count ?? 0, streams: streams[0]?.count ?? 0,
245
+ errors: errorDetails,
100
246
  cursors: cursors.map((cursor) => ({ ...cursor, next_from_date: subtractDays(cursor.covered_through_date, Number(cursor.lookback_days)) })),
101
247
  };
102
248
  }
@@ -164,6 +310,24 @@ export class CatenceDatabase {
164
310
  const rows = await this.rows('SELECT remote_activity_id, summary_hash FROM activity_sync_state WHERE provider = $provider', { provider });
165
311
  return Object.fromEntries(rows.map((row) => [row.remote_activity_id, row.summary_hash]));
166
312
  }
313
+ async hasGarminLactateThresholdHistory() {
314
+ const rows = await this.rows("SELECT count(*) AS n FROM training_metric_observations WHERE observation_id LIKE 'garmin:lactate_threshold:daily:%'");
315
+ return (rows[0]?.n ?? 0) > 0;
316
+ }
317
+ async earliestActivityStartDate(provider) {
318
+ const rows = await this.rows(`SELECT min(cast(started_at_utc AS DATE)) AS earliest FROM activities activity
319
+ JOIN activity_sources source USING (activity_id) WHERE source.provider = $provider`, { provider });
320
+ return toIsoDate(rows[0]?.earliest ?? null);
321
+ }
322
+ async garminSwimActivitySources() {
323
+ const sports = ['lap_swimming', 'swimming', 'open_water_swimming', 'openwaterswimming'];
324
+ return this.rows(`SELECT source.remote_activity_id, cast(activity.started_at_utc AS VARCHAR) AS started_on
325
+ FROM activity_sources AS source
326
+ JOIN activities AS activity USING (activity_id)
327
+ WHERE source.provider = 'garmin'
328
+ AND lower(coalesce(activity.sport, '')) IN (${sports.map((sport) => `'${sport}'`).join(', ')})
329
+ ORDER BY activity.started_at_utc`);
330
+ }
167
331
  async latestSourceDate(provider, cursorName) {
168
332
  const observations = cursorName === 'daily'
169
333
  ? `SELECT max(metric_date) AS observed_date FROM daily_metrics WHERE provider = $provider
@@ -281,10 +445,6 @@ export class ReadOnlyCatenceDatabase {
281
445
  }
282
446
  }
283
447
  }
284
- /** Compose core read services with the DuckDB-backed, read-only adapter. */
285
- export async function openReadOnlyRepository(paths) {
286
- return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
287
- }
288
448
  export class ReadOnlyDatabaseError extends Error {
289
449
  code;
290
450
  constructor(code, message) {
@@ -293,4 +453,22 @@ export class ReadOnlyDatabaseError extends Error {
293
453
  this.name = 'ReadOnlyDatabaseError';
294
454
  }
295
455
  }
456
+ /** Compose core read services with the DuckDB-backed, read-only adapter. */
457
+ export async function openReadOnlyRepository(paths) {
458
+ return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
459
+ }
460
+ function toSyncProgressState(row) {
461
+ return {
462
+ runId: row.run_id,
463
+ provider: row.provider,
464
+ stage: SYNC_STAGES.includes(row.stage) ? row.stage : 'starting',
465
+ currentStep: row.current_step,
466
+ completedUnits: Math.max(0, Number(row.completed_units) || 0),
467
+ totalUnits: row.total_units === null || row.total_units === undefined ? null : Math.max(0, Number(row.total_units) || 0),
468
+ percentComplete: Math.min(100, Math.max(0, Number(row.percent) || 0)),
469
+ elapsedSeconds: Math.max(0, Number(row.elapsed_seconds) || 0),
470
+ estimatedRemainingSeconds: row.estimated_remaining_seconds === null || row.estimated_remaining_seconds === undefined ? null : Math.max(0, Number(row.estimated_remaining_seconds) || 0),
471
+ heartbeatAt: row.heartbeat_at,
472
+ };
473
+ }
296
474
  //# sourceMappingURL=database.js.map
@@ -828,5 +828,29 @@ const swimFactsAndQualityMigration = {
828
828
  UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
829
829
  `,
830
830
  };
831
- export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration];
831
+ const syncRunProgressMigration = {
832
+ version: 14,
833
+ name: 'sync_run_progress_heartbeats',
834
+ sql: `
835
+ -- Live progress heartbeats for in-flight sync runs. Written by the Node
836
+ -- side from progress records streamed by provider workers; read-only
837
+ -- surfaces (status, progress CLI, MCP) join against sync_runs.
838
+ CREATE TABLE IF NOT EXISTS sync_run_progress (
839
+ run_id VARCHAR PRIMARY KEY,
840
+ provider VARCHAR NOT NULL,
841
+ stage VARCHAR NOT NULL,
842
+ current_step VARCHAR,
843
+ completed_units INTEGER NOT NULL DEFAULT 0,
844
+ total_units INTEGER,
845
+ percent DOUBLE NOT NULL DEFAULT 0,
846
+ elapsed_seconds DOUBLE NOT NULL DEFAULT 0,
847
+ estimated_remaining_seconds DOUBLE,
848
+ heartbeat_at TIMESTAMPTZ NOT NULL,
849
+ detail_json JSON NOT NULL DEFAULT '{}'
850
+ );
851
+
852
+ UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
853
+ `,
854
+ };
855
+ export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration, syncRunProgressMigration];
832
856
  //# sourceMappingURL=migrations.js.map
@@ -0,0 +1,61 @@
1
+ import { type SyncProgressSnapshot, type SyncProgressState, type SyncStage } from '../../contracts/progress.js';
2
+ import type { CatencePaths } from '../../contracts/runtime.js';
3
+ import type { SyncLogger } from '../../core/logging.js';
4
+ import type { CatenceDatabase } from './database.js';
5
+ export declare const PROGRESS_SIDECAR_SUFFIX = ".progress.json";
6
+ export declare const PROGRESS_SIDECAR_STALE_MS: number;
7
+ export declare const PROGRESS_KEEPALIVE_MS: number;
8
+ export declare const PROGRESS_PUBLISH_THROTTLE_MS: number;
9
+ export declare function progressSidecarPath(paths: CatencePaths, runId: string): string;
10
+ /**
11
+ * Persist one progress heartbeat as a sidecar file next to the Garmin staging
12
+ * manifest. Reading this file needs no DuckDB lock, so `progress` stays live
13
+ * while a sync run holds the single-writer database lock.
14
+ */
15
+ export declare function writeProgressSidecar(paths: CatencePaths, runId: string, state: SyncProgressState): Promise<void>;
16
+ export declare function removeProgressSidecar(paths: CatencePaths, runId: string): Promise<void>;
17
+ /**
18
+ * Scan every persisted sidecar heartbeat for an athlete store. Corrupt or
19
+ * unreadable files are skipped so a torn write can never break progress reads.
20
+ */
21
+ export declare function readProgressSidecars(paths: CatencePaths): Promise<SyncProgressState[]>;
22
+ /** Active runs from sidecars: non-terminal stage and a fresh heartbeat. */
23
+ export declare function readRunningProgress(paths: CatencePaths, staleAfterMs?: number): Promise<SyncProgressState[]>;
24
+ /**
25
+ * Merge database-backed progress with sidecar heartbeats. Sidecars win for a
26
+ * given run (they are freshest); database rows fill runs that never emitted a
27
+ * sidecar (for example Intervals runs). Recent history is deduplicated across
28
+ * both sources and sorted by heartbeat recency.
29
+ */
30
+ export declare function mergeProgress(dbRunning: SyncProgressState[], dbRecent: SyncProgressState[], sidecars: SyncProgressState[]): SyncProgressSnapshot;
31
+ export interface ProgressPumpOptions {
32
+ database: CatenceDatabase;
33
+ paths: CatencePaths;
34
+ log: SyncLogger;
35
+ }
36
+ /**
37
+ * Drives progress heartbeats for the whole lifetime of a sync run - including
38
+ * phases that emit no worker output (the Intervals extractor and the Garmin
39
+ * JSONL import). `publish` throttles writes to the database and sidecar; a
40
+ * 30-second keep-alive interval refreshes the last known state so a run never
41
+ * looks stale while it is still active, even during the lock-holding import.
42
+ */
43
+ export declare class ProgressPump {
44
+ private readonly runId;
45
+ private readonly provider;
46
+ private readonly options;
47
+ private lastProgress;
48
+ private lastHeartbeatAt;
49
+ private interval;
50
+ private stageOverride;
51
+ constructor(runId: string, provider: string, options: ProgressPumpOptions);
52
+ start(): void;
53
+ publish(state: SyncProgressState): void;
54
+ /** Force subsequent keep-alive heartbeats to report the given stage. */
55
+ setStage(stage: SyncStage): void;
56
+ /** Transition the run to the JSONL import stage; keep-alives persist it. */
57
+ setStageImporting(): void;
58
+ /** Write a terminal heartbeat immediately, bypassing the publish throttle. */
59
+ publishFinal(state: SyncProgressState): Promise<void>;
60
+ stop(): void;
61
+ }
@@ -0,0 +1,200 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { normalizeProgress, } from '../../contracts/progress.js';
5
+ export const PROGRESS_SIDECAR_SUFFIX = '.progress.json';
6
+ export const PROGRESS_SIDECAR_STALE_MS = 5 * 60 * 1000;
7
+ export const PROGRESS_KEEPALIVE_MS = 30 * 1000;
8
+ export const PROGRESS_PUBLISH_THROTTLE_MS = 2 * 1000;
9
+ const TERMINAL_STAGES = new Set(['completed', 'failed', 'interrupted', 'timed_out']);
10
+ export function progressSidecarPath(paths, runId) {
11
+ return path.join(paths.staging, 'garmin', `${runId}${PROGRESS_SIDECAR_SUFFIX}`);
12
+ }
13
+ /**
14
+ * Persist one progress heartbeat as a sidecar file next to the Garmin staging
15
+ * manifest. Reading this file needs no DuckDB lock, so `progress` stays live
16
+ * while a sync run holds the single-writer database lock.
17
+ */
18
+ export async function writeProgressSidecar(paths, runId, state) {
19
+ const target = progressSidecarPath(paths, runId);
20
+ await mkdir(path.dirname(target), { recursive: true });
21
+ const temporary = `${target}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}.tmp`;
22
+ await writeFile(temporary, JSON.stringify(state));
23
+ await rename(temporary, target);
24
+ }
25
+ export async function removeProgressSidecar(paths, runId) {
26
+ await rm(progressSidecarPath(paths, runId), { force: true });
27
+ }
28
+ /**
29
+ * Scan every persisted sidecar heartbeat for an athlete store. Corrupt or
30
+ * unreadable files are skipped so a torn write can never break progress reads.
31
+ */
32
+ export async function readProgressSidecars(paths) {
33
+ const directory = path.join(paths.staging, 'garmin');
34
+ if (!existsSync(directory))
35
+ return [];
36
+ const files = (await readdir(directory)).filter((file) => file.endsWith(PROGRESS_SIDECAR_SUFFIX));
37
+ const states = [];
38
+ for (const file of files) {
39
+ try {
40
+ const state = normalizeProgress(JSON.parse(await readFile(path.join(directory, file), 'utf8')));
41
+ if (state)
42
+ states.push(state);
43
+ }
44
+ catch {
45
+ // Torn or partial sidecar write; it is not authoritative.
46
+ }
47
+ }
48
+ return states;
49
+ }
50
+ /** Active runs from sidecars: non-terminal stage and a fresh heartbeat. */
51
+ export async function readRunningProgress(paths, staleAfterMs = PROGRESS_SIDECAR_STALE_MS) {
52
+ const now = Date.now();
53
+ const directory = path.join(paths.staging, 'garmin');
54
+ if (!existsSync(directory))
55
+ return [];
56
+ const files = (await readdir(directory)).filter((file) => file.endsWith(PROGRESS_SIDECAR_SUFFIX));
57
+ const running = [];
58
+ const prunable = [];
59
+ for (const file of files) {
60
+ let state = null;
61
+ try {
62
+ state = normalizeProgress(JSON.parse(await readFile(path.join(directory, file), 'utf8')));
63
+ }
64
+ catch {
65
+ // Torn or partial sidecar write; it is not authoritative.
66
+ }
67
+ if (!state)
68
+ continue;
69
+ const ageMs = now - Date.parse(state.heartbeatAt);
70
+ if (TERMINAL_STAGES.has(state.stage) || !Number.isFinite(ageMs) || ageMs > staleAfterMs) {
71
+ // A terminal heartbeat that survived its own cleanup (a crash between
72
+ // the final publish and the sidecar removal) must never read as an
73
+ // active run again, so sweep it instead of leaving it behind forever.
74
+ prunable.push(file);
75
+ continue;
76
+ }
77
+ running.push(state);
78
+ }
79
+ await Promise.all(prunable.map((file) => rm(path.join(directory, file), { force: true }).catch(() => undefined)));
80
+ return running;
81
+ }
82
+ /**
83
+ * Merge database-backed progress with sidecar heartbeats. Sidecars win for a
84
+ * given run (they are freshest); database rows fill runs that never emitted a
85
+ * sidecar (for example Intervals runs). Recent history is deduplicated across
86
+ * both sources and sorted by heartbeat recency.
87
+ */
88
+ export function mergeProgress(dbRunning, dbRecent, sidecars) {
89
+ const running = new Map();
90
+ for (const state of sidecars)
91
+ running.set(state.runId, state);
92
+ for (const state of dbRunning)
93
+ if (!running.has(state.runId))
94
+ running.set(state.runId, state);
95
+ const recent = new Map();
96
+ for (const state of dbRecent)
97
+ recent.set(state.runId, state);
98
+ for (const state of sidecars)
99
+ recent.set(state.runId, state);
100
+ return {
101
+ running: [...running.values()],
102
+ recent: [...recent.values()]
103
+ .sort((a, b) => Date.parse(b.heartbeatAt) - Date.parse(a.heartbeatAt))
104
+ .slice(0, 10),
105
+ };
106
+ }
107
+ function baselineState(runId, provider, stage) {
108
+ return {
109
+ runId,
110
+ provider,
111
+ stage,
112
+ currentStep: null,
113
+ completedUnits: 0,
114
+ totalUnits: null,
115
+ percentComplete: 0,
116
+ elapsedSeconds: 0,
117
+ estimatedRemainingSeconds: null,
118
+ heartbeatAt: new Date().toISOString(),
119
+ };
120
+ }
121
+ /**
122
+ * Drives progress heartbeats for the whole lifetime of a sync run - including
123
+ * phases that emit no worker output (the Intervals extractor and the Garmin
124
+ * JSONL import). `publish` throttles writes to the database and sidecar; a
125
+ * 30-second keep-alive interval refreshes the last known state so a run never
126
+ * looks stale while it is still active, even during the lock-holding import.
127
+ */
128
+ export class ProgressPump {
129
+ runId;
130
+ provider;
131
+ options;
132
+ lastProgress = null;
133
+ lastHeartbeatAt = 0;
134
+ interval = null;
135
+ stageOverride = null;
136
+ constructor(runId, provider, options) {
137
+ this.runId = runId;
138
+ this.provider = provider;
139
+ this.options = options;
140
+ }
141
+ start() {
142
+ if (this.interval)
143
+ return;
144
+ this.interval = setInterval(() => {
145
+ this.publish(this.lastProgress
146
+ ? { ...this.lastProgress, heartbeatAt: new Date().toISOString() }
147
+ : baselineState(this.runId, this.provider, this.stageOverride ?? 'starting'));
148
+ }, PROGRESS_KEEPALIVE_MS);
149
+ this.interval.unref();
150
+ }
151
+ publish(state) {
152
+ const now = Date.now();
153
+ if (this.lastHeartbeatAt !== 0 && now - this.lastHeartbeatAt < PROGRESS_PUBLISH_THROTTLE_MS) {
154
+ this.lastProgress = state;
155
+ return;
156
+ }
157
+ this.lastHeartbeatAt = now;
158
+ this.lastProgress = state;
159
+ const { database, paths, log } = this.options;
160
+ void database.heartbeatRun(this.runId, state).catch((error) => {
161
+ log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
162
+ });
163
+ void writeProgressSidecar(paths, this.runId, state).catch((error) => {
164
+ log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
165
+ });
166
+ }
167
+ /** Force subsequent keep-alive heartbeats to report the given stage. */
168
+ setStage(stage) {
169
+ this.stageOverride = stage;
170
+ if (this.lastProgress) {
171
+ this.lastProgress = { ...this.lastProgress, stage };
172
+ this.publish(this.lastProgress);
173
+ }
174
+ }
175
+ /** Transition the run to the JSONL import stage; keep-alives persist it. */
176
+ setStageImporting() {
177
+ this.setStage('importing');
178
+ }
179
+ /** Write a terminal heartbeat immediately, bypassing the publish throttle. */
180
+ async publishFinal(state) {
181
+ this.lastHeartbeatAt = Date.now();
182
+ this.lastProgress = state;
183
+ const { database, paths, log } = this.options;
184
+ // Both terminal writes complete before the caller removes the sidecar, so
185
+ // a finished run can never resurrect its own progress file after cleanup.
186
+ await database.heartbeatRun(this.runId, state).catch((error) => {
187
+ log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
188
+ });
189
+ await writeProgressSidecar(paths, this.runId, state).catch((error) => {
190
+ log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
191
+ });
192
+ }
193
+ stop() {
194
+ if (this.interval) {
195
+ clearInterval(this.interval);
196
+ this.interval = null;
197
+ }
198
+ }
199
+ }
200
+ //# sourceMappingURL=progress-sidecar.js.map