catence 0.2.1-beta.2 → 0.2.1-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 (67) hide show
  1. package/README.md +4 -4
  2. package/config.example.json +1 -0
  3. package/dist/contracts/release.d.ts +1 -1
  4. package/dist/contracts/release.js +1 -1
  5. package/dist/contracts/runtime.d.ts +4 -0
  6. package/dist/core/query/catalog.d.ts +1 -1
  7. package/dist/core/query/catalog.js +16 -10
  8. package/dist/core/query/repository.d.ts +1 -1
  9. package/dist/core/query/repository.js +31 -2
  10. package/dist/core/retrieval/index.js +23 -5
  11. package/dist/core/runtime/athlete-file.d.ts +49 -0
  12. package/dist/core/runtime/athlete-file.js +177 -0
  13. package/dist/core/runtime/configuration.js +2 -0
  14. package/dist/core/runtime/console-accounts.d.ts +52 -0
  15. package/dist/core/runtime/console-accounts.js +103 -0
  16. package/dist/core/runtime/mcp-keys.d.ts +65 -0
  17. package/dist/core/runtime/mcp-keys.js +171 -0
  18. package/dist/core/runtime/oauth/model.d.ts +44 -0
  19. package/dist/core/runtime/oauth/model.js +345 -0
  20. package/dist/core/runtime/oauth/public-client.d.ts +28 -0
  21. package/dist/core/runtime/oauth/public-client.js +35 -0
  22. package/dist/core/runtime/oauth/settings.d.ts +33 -0
  23. package/dist/core/runtime/oauth/settings.js +96 -0
  24. package/dist/core/runtime/secrets.d.ts +15 -0
  25. package/dist/core/runtime/secrets.js +48 -7
  26. package/dist/elt/application/autosync.d.ts +71 -0
  27. package/dist/elt/application/autosync.js +173 -0
  28. package/dist/elt/application/detached-sync.d.ts +7 -2
  29. package/dist/elt/application/detached-sync.js +10 -4
  30. package/dist/elt/application/management.d.ts +7 -0
  31. package/dist/elt/application/management.js +103 -7
  32. package/dist/elt/ingestion/importer.d.ts +11 -1
  33. package/dist/elt/ingestion/importer.js +63 -14
  34. package/dist/elt/ingestion/providers/intervals.d.ts +7 -0
  35. package/dist/elt/ingestion/providers/intervals.js +21 -2
  36. package/dist/elt/normalization/normalizers.js +122 -0
  37. package/dist/elt/storage/database.d.ts +6 -1
  38. package/dist/elt/storage/database.js +7 -1
  39. package/dist/elt/storage/migrations.js +45 -1
  40. package/dist/elt/storage/progress-sidecar.d.ts +9 -0
  41. package/dist/elt/storage/progress-sidecar.js +31 -16
  42. package/dist/interfaces/cli/commands.js +117 -2
  43. package/dist/interfaces/http/cli-options.d.ts +64 -1
  44. package/dist/interfaces/http/cli-options.js +214 -2
  45. package/dist/interfaces/http/oauth.d.ts +16 -0
  46. package/dist/interfaces/http/oauth.js +228 -0
  47. package/dist/interfaces/http/server.d.ts +60 -1
  48. package/dist/interfaces/http/server.js +810 -16
  49. package/dist/interfaces/mcp/main.js +35 -3
  50. package/dist/interfaces/mcp/server.d.ts +21 -1
  51. package/dist/interfaces/mcp/server.js +107 -21
  52. package/dist/runtime/index.d.ts +15 -3
  53. package/dist/runtime/index.js +9 -2
  54. package/docs/architecture.md +1 -1
  55. package/docs/configuration.md +76 -6
  56. package/docs/deployment/docker.md +173 -15
  57. package/docs/deployment/local-console.md +132 -21
  58. package/docs/deployment/local-mcp.md +190 -11
  59. package/docs/development.md +5 -3
  60. package/docs/llm-providers.md +4 -4
  61. package/package.json +11 -4
  62. package/pyproject.toml +1 -1
  63. package/python/catence/providers/garmin/cli.py +55 -7
  64. package/python/catence/providers/garmin/progress.py +71 -35
  65. package/python/catence/providers/garmin/staging.py +11 -4
  66. package/python/catence/providers/garmin/worker.py +420 -206
  67. package/uv.lock +3 -3
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod';
2
+ import { type CatalogPaths } from '../../core/runtime/catalog.js';
3
+ import type { CatencePaths } from '../../contracts/runtime.js';
4
+ import { startDetachedSync, type DetachedSyncHandle } from './detached-sync.js';
5
+ /** The sync cadences the scheduler accepts, in minutes. */
6
+ export declare const AUTO_SYNC_INTERVAL_MINUTES: readonly [15, 30, 60, 120, 360, 720, 1440];
7
+ export type AutoSyncIntervalMinutes = (typeof AUTO_SYNC_INTERVAL_MINUTES)[number];
8
+ /** Auto-sync is on by default at 2h: fresh data several times a day, far inside provider limits. */
9
+ export declare const DEFAULT_AUTO_SYNC_INTERVAL_MINUTES: AutoSyncIntervalMinutes;
10
+ declare const autoSyncFileSchema: z.ZodObject<{
11
+ enabled: z.ZodBoolean;
12
+ intervalMinutes: z.ZodNumber & z.ZodType<15 | 60 | 30 | 360 | 120 | 720 | 1440, number, z.core.$ZodTypeInternals<15 | 60 | 30 | 360 | 120 | 720 | 1440, number>>;
13
+ lastTriggeredAt: z.ZodDefault<z.ZodNullable<z.ZodString>>;
14
+ lastLogFile: z.ZodDefault<z.ZodNullable<z.ZodString>>;
15
+ }, z.core.$strict>;
16
+ export type AutoSyncConfig = z.infer<typeof autoSyncFileSchema>;
17
+ export type AutoSyncSchedule = {
18
+ athleteId: string;
19
+ enabled: boolean;
20
+ intervalMinutes: AutoSyncIntervalMinutes;
21
+ intervalOptions: ReadonlyArray<{
22
+ minutes: AutoSyncIntervalMinutes;
23
+ label: string;
24
+ }>;
25
+ lastTriggeredAt: string | null;
26
+ lastLogFile: string | null;
27
+ nextRunAt: string | null;
28
+ };
29
+ export declare function autoSyncFilePath(paths: CatencePaths): string;
30
+ /**
31
+ * Parse one of the interval labels the UI and CLI accept (`15m`, `2h`, `24h`)
32
+ * or a bare minute count into a valid cadence. Anything else is rejected at
33
+ * the boundary so stored state can never hold an unsupported interval.
34
+ */
35
+ export declare function parseAutoSyncInterval(value: string): AutoSyncIntervalMinutes;
36
+ /**
37
+ * Read the athlete's schedule. A missing file means "on by default": the
38
+ * effective schedule is reported without writing anything, so a fresh store
39
+ * stays pristine until the first explicit change (the scheduler does write
40
+ * trigger bookkeeping once it actually fires).
41
+ */
42
+ export declare function readAutoSyncSchedule(paths: CatencePaths, athleteId: string): Promise<AutoSyncSchedule>;
43
+ /**
44
+ * Apply an explicit schedule change (UI or CLI). Only the requested fields
45
+ * change; trigger bookkeeping survives an interval switch or a pause.
46
+ */
47
+ export declare function updateAutoSyncSchedule(paths: CatencePaths, athleteId: string, change: {
48
+ enabled?: boolean;
49
+ intervalMinutes?: AutoSyncIntervalMinutes;
50
+ }): Promise<AutoSyncSchedule>;
51
+ export type AutoSyncSchedulerOptions = {
52
+ catalogPaths: CatalogPaths;
53
+ /** Internal compatibility hook for tests. */
54
+ startSync?: (request: Parameters<typeof startDetachedSync>[0]) => Promise<DetachedSyncHandle>;
55
+ now?: () => Date;
56
+ /** How often due schedules are checked; default every 30 seconds. */
57
+ tickMs?: number;
58
+ };
59
+ export type AutoSyncScheduler = {
60
+ stop(): void;
61
+ };
62
+ /**
63
+ * The auto-sync scheduler: a periodic timer that triggers a detached sync for
64
+ * every athlete whose schedule is due. It opens no database connection and
65
+ * takes no locks — the detached child and the single-writer DuckDB lock stay
66
+ * the only sync executors, so an already-running sync simply makes the tick a
67
+ * no-op (DetachedSyncBusyError is skipped quietly and retried on a later
68
+ * tick, once the active run has finished).
69
+ */
70
+ export declare function startAutoSyncScheduler(options: AutoSyncSchedulerOptions): AutoSyncScheduler;
71
+ export {};
@@ -0,0 +1,173 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { athleteStorePaths, loadCatalog } from '../../core/runtime/catalog.js';
5
+ import { createSyncLogger } from '../../core/logging.js';
6
+ import { DetachedSyncBusyError, startDetachedSync } from './detached-sync.js';
7
+ /** The sync cadences the scheduler accepts, in minutes. */
8
+ export const AUTO_SYNC_INTERVAL_MINUTES = [15, 30, 60, 120, 360, 720, 1440];
9
+ /** Auto-sync is on by default at 2h: fresh data several times a day, far inside provider limits. */
10
+ export const DEFAULT_AUTO_SYNC_INTERVAL_MINUTES = 120;
11
+ const INTERVAL_CHOICE_LABELS = new Map([
12
+ [15, '15m'], [30, '30m'], [60, '1h'], [120, '2h'], [360, '6h'], [720, '12h'], [1440, '24h'],
13
+ ]);
14
+ const autoSyncFileSchema = z.object({
15
+ enabled: z.boolean(),
16
+ intervalMinutes: z.number().int().refine((value) => AUTO_SYNC_INTERVAL_MINUTES.includes(value), 'intervalMinutes must be one of 15, 30, 60, 120, 360, 720, 1440.'),
17
+ lastTriggeredAt: z.string().datetime().nullable().default(null),
18
+ lastLogFile: z.string().nullable().default(null),
19
+ }).strict();
20
+ export function autoSyncFilePath(paths) {
21
+ return path.join(paths.root, 'autosync.json');
22
+ }
23
+ /**
24
+ * Parse one of the interval labels the UI and CLI accept (`15m`, `2h`, `24h`)
25
+ * or a bare minute count into a valid cadence. Anything else is rejected at
26
+ * the boundary so stored state can never hold an unsupported interval.
27
+ */
28
+ export function parseAutoSyncInterval(value) {
29
+ const trimmed = value.trim().toLowerCase();
30
+ const match = /^(\d+)(m|h)$/.exec(trimmed);
31
+ const minutes = match ? (match[2] === 'h' ? Number(match[1]) * 60 : Number(match[1])) : Number(trimmed);
32
+ if (Number.isInteger(minutes) && AUTO_SYNC_INTERVAL_MINUTES.includes(minutes)) {
33
+ return minutes;
34
+ }
35
+ throw new Error(`interval must be one of: ${[...INTERVAL_CHOICE_LABELS.values()].join(', ')}.`);
36
+ }
37
+ function defaultConfig() {
38
+ return { enabled: true, intervalMinutes: DEFAULT_AUTO_SYNC_INTERVAL_MINUTES, lastTriggeredAt: null, lastLogFile: null };
39
+ }
40
+ function nextRunAt(config) {
41
+ if (!config.enabled || !config.lastTriggeredAt)
42
+ return null;
43
+ return new Date(Date.parse(config.lastTriggeredAt) + config.intervalMinutes * 60_000).toISOString();
44
+ }
45
+ function toSchedule(athleteId, config) {
46
+ return {
47
+ athleteId,
48
+ enabled: config.enabled,
49
+ intervalMinutes: config.intervalMinutes,
50
+ intervalOptions: AUTO_SYNC_INTERVAL_MINUTES.map((minutes) => ({ minutes, label: INTERVAL_CHOICE_LABELS.get(minutes) ?? `${minutes}m` })),
51
+ lastTriggeredAt: config.lastTriggeredAt,
52
+ lastLogFile: config.lastLogFile,
53
+ nextRunAt: nextRunAt(config),
54
+ };
55
+ }
56
+ function isMissingFile(error) {
57
+ return typeof error === 'object' && error !== null && error.code === 'ENOENT';
58
+ }
59
+ async function readEffectiveConfig(paths) {
60
+ let raw;
61
+ try {
62
+ raw = await readFile(autoSyncFilePath(paths), 'utf8');
63
+ }
64
+ catch (error) {
65
+ if (!isMissingFile(error))
66
+ throw error;
67
+ return defaultConfig();
68
+ }
69
+ const parsed = autoSyncFileSchema.safeParse(JSON.parse(raw));
70
+ if (!parsed.success) {
71
+ throw new Error(`autosync.json is not a valid auto-sync schedule (${parsed.error.issues[0]?.message ?? 'unknown issue'}).`);
72
+ }
73
+ return parsed.data;
74
+ }
75
+ /**
76
+ * Read the athlete's schedule. A missing file means "on by default": the
77
+ * effective schedule is reported without writing anything, so a fresh store
78
+ * stays pristine until the first explicit change (the scheduler does write
79
+ * trigger bookkeeping once it actually fires).
80
+ */
81
+ export async function readAutoSyncSchedule(paths, athleteId) {
82
+ return toSchedule(athleteId, await readEffectiveConfig(paths));
83
+ }
84
+ async function writeAutoSyncConfig(paths, config) {
85
+ const file = autoSyncFilePath(paths);
86
+ // A freshly added athlete has no store directory yet; the schedule file is
87
+ // often its first artifact.
88
+ await mkdir(path.dirname(file), { recursive: true });
89
+ const temporary = `${file}.${process.pid}.tmp`;
90
+ await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
91
+ await rename(temporary, file);
92
+ }
93
+ /**
94
+ * Apply an explicit schedule change (UI or CLI). Only the requested fields
95
+ * change; trigger bookkeeping survives an interval switch or a pause.
96
+ */
97
+ export async function updateAutoSyncSchedule(paths, athleteId, change) {
98
+ const current = await readEffectiveConfig(paths);
99
+ const updated = {
100
+ ...current,
101
+ ...(change.enabled === undefined ? {} : { enabled: change.enabled }),
102
+ ...(change.intervalMinutes === undefined ? {} : { intervalMinutes: change.intervalMinutes }),
103
+ };
104
+ await writeAutoSyncConfig(paths, updated);
105
+ return toSchedule(athleteId, updated);
106
+ }
107
+ /** Record that the scheduler fired (or attempted to fire) for this athlete. */
108
+ async function recordTrigger(paths, logFile, at) {
109
+ const config = await readEffectiveConfig(paths);
110
+ await writeAutoSyncConfig(paths, { ...config, lastTriggeredAt: at.toISOString(), lastLogFile: logFile });
111
+ }
112
+ /**
113
+ * The auto-sync scheduler: a periodic timer that triggers a detached sync for
114
+ * every athlete whose schedule is due. It opens no database connection and
115
+ * takes no locks — the detached child and the single-writer DuckDB lock stay
116
+ * the only sync executors, so an already-running sync simply makes the tick a
117
+ * no-op (DetachedSyncBusyError is skipped quietly and retried on a later
118
+ * tick, once the active run has finished).
119
+ */
120
+ export function startAutoSyncScheduler(options) {
121
+ const startSync = options.startSync ?? startDetachedSync;
122
+ const now = options.now ?? (() => new Date());
123
+ const tickMs = options.tickMs ?? 30_000;
124
+ let ticking = false;
125
+ const tick = async () => {
126
+ if (ticking)
127
+ return; // Never overlap ticks; the in-flight one wins.
128
+ ticking = true;
129
+ try {
130
+ const catalog = await loadCatalog(options.catalogPaths);
131
+ await Promise.allSettled(catalog.athletes.map(async (athlete) => {
132
+ const paths = athleteStorePaths(options.catalogPaths, athlete.id);
133
+ const log = createSyncLogger(paths);
134
+ try {
135
+ const config = await readEffectiveConfig(paths);
136
+ if (!config.enabled)
137
+ return;
138
+ const dueAt = config.lastTriggeredAt ? Date.parse(config.lastTriggeredAt) + config.intervalMinutes * 60_000 : 0;
139
+ if (now().getTime() < dueAt)
140
+ return;
141
+ const handle = await startSync({ paths, athleteId: athlete.id, provider: 'all', cliHome: options.catalogPaths.root });
142
+ await recordTrigger(paths, handle.logFile, now());
143
+ log.info('Auto-sync triggered', { athleteId: athlete.id, intervalMinutes: config.intervalMinutes, logFile: handle.logFile });
144
+ }
145
+ catch (error) {
146
+ if (error instanceof DetachedSyncBusyError)
147
+ return; // A run is active; retry on a later tick.
148
+ // Any other failure is recorded as a trigger attempt so a broken
149
+ // store cannot be hammered every tick; the next cadence retries.
150
+ await recordTrigger(paths, null, now()).catch(() => undefined);
151
+ log.error('Auto-sync trigger failed', { athleteId: athlete.id, error: error instanceof Error ? error.message : String(error) });
152
+ }
153
+ }));
154
+ }
155
+ catch (error) {
156
+ process.stderr.write(`[catence] Auto-sync tick failed: ${error instanceof Error ? error.message : String(error)}\n`);
157
+ }
158
+ finally {
159
+ ticking = false;
160
+ }
161
+ };
162
+ const timer = setInterval(() => { void tick(); }, tickMs);
163
+ timer.unref?.();
164
+ // The first tick is immediate: a restart should heal stale data within a
165
+ // minute instead of waiting a full interval.
166
+ void tick();
167
+ return {
168
+ stop() {
169
+ clearInterval(timer);
170
+ },
171
+ };
172
+ }
173
+ //# sourceMappingURL=autosync.js.map
@@ -8,6 +8,11 @@ export type DetachedSyncRequest = {
8
8
  provider?: DetachedSyncProvider;
9
9
  from?: string;
10
10
  refresh?: boolean;
11
+ /**
12
+ * Run `catence-data backfill` instead of `sync`: extend history backwards
13
+ * from `from` without advancing the incremental cursors. Requires `from`.
14
+ */
15
+ backfill?: boolean;
11
16
  /**
12
17
  * The `--home` value the detached `catence-data` child must receive.
13
18
  *
@@ -35,8 +40,8 @@ export type DetachedSyncSpawner = (command: string, arguments_: readonly string[
35
40
  unref(): void;
36
41
  };
37
42
  /**
38
- * Spawn one detached `catence-data sync` process for an athlete store and
39
- * return immediately.
43
+ * Spawn one detached `catence-data sync` (or `backfill`, when requested)
44
+ * process for an athlete store and return immediately.
40
45
  *
41
46
  * The child outlives this process (SSH deaths, Console restarts), logs to a
42
47
  * file under the catalog home, and is guarded by the same live-progress
@@ -46,10 +46,15 @@ function assertValidRequest(request) {
46
46
  if (request.from !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(request.from)) {
47
47
  throw new Error('from must use YYYY-MM-DD.');
48
48
  }
49
+ // The CLI's backfill subcommand requires --from; enforce it here so the
50
+ // child never spawns into a usage error.
51
+ if (request.backfill && !request.from) {
52
+ throw new Error('from is required for a backfill.');
53
+ }
49
54
  }
50
55
  /**
51
- * Spawn one detached `catence-data sync` process for an athlete store and
52
- * return immediately.
56
+ * Spawn one detached `catence-data sync` (or `backfill`, when requested)
57
+ * process for an athlete store and return immediately.
53
58
  *
54
59
  * The child outlives this process (SSH deaths, Console restarts), logs to a
55
60
  * file under the catalog home, and is guarded by the same live-progress
@@ -73,8 +78,9 @@ export async function startDetachedSync(request, dependencies = {}) {
73
78
  const stamp = (dependencies.now ?? (() => new Date()))().toISOString().replace(/[:.]/g, '-');
74
79
  const logFile = path.join(request.paths.root, 'logs', `sync-${request.athleteId}-${stamp}.log`);
75
80
  await mkdir(path.dirname(logFile), { recursive: true });
76
- await writeFile(logFile, `[${new Date().toISOString()}] catence-data --athlete ${request.athleteId} sync --provider ${provider}${request.from ? ` --from ${request.from}` : ''}${request.refresh ? ' --refresh' : ''}\n`, 'utf8');
77
- const arguments_ = [cliInvocation().entry, '--home', request.cliHome ?? request.paths.root, '--athlete', request.athleteId, 'sync', '--provider', provider];
81
+ const subcommand = request.backfill ? 'backfill' : 'sync';
82
+ await writeFile(logFile, `[${new Date().toISOString()}] catence-data --athlete ${request.athleteId} ${subcommand} --provider ${provider}${request.from ? ` --from ${request.from}` : ''}${request.refresh ? ' --refresh' : ''}\n`, 'utf8');
83
+ const arguments_ = [cliInvocation().entry, '--home', request.cliHome ?? request.paths.root, '--athlete', request.athleteId, subcommand, '--provider', provider];
78
84
  if (request.from)
79
85
  arguments_.push('--from', request.from);
80
86
  if (request.refresh)
@@ -37,4 +37,11 @@ export declare function resolveExtractionErrors(paths: CatencePaths, opts?: {
37
37
  * previously mis-parsed nutrition_days/nutrition_items from stored payloads.
38
38
  */
39
39
  export declare function reimportNutrition(paths: CatencePaths): Promise<Record<string, unknown>>;
40
+ /**
41
+ * Re-run normalization over already-captured Garmin weigh-in entities without
42
+ * contacting any provider. Stores synced before beta.5 staged weigh_ins
43
+ * payloads but never projected them to daily_metrics, so this backfills
44
+ * weight_kg from stored payloads. Idempotent upserts make it safe to re-run.
45
+ */
46
+ export declare function reimportWeight(paths: CatencePaths): Promise<Record<string, unknown>>;
40
47
  export {};
@@ -19,7 +19,7 @@ import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUr
19
19
  import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
20
20
  import { importSourceEntity } from '../normalization/normalizers.js';
21
21
  import { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../storage/database.js';
22
- import { mergeProgress, ProgressPump, readRunningProgress, removeProgressSidecar } from '../storage/progress-sidecar.js';
22
+ import { mergeProgress, ProgressPump, readRunningProgress, removeProgressSidecar, writeProgressSidecar, PROGRESS_KEEPALIVE_MS } from '../storage/progress-sidecar.js';
23
23
  import { withDataWriteLock } from '../storage/write-lock.js';
24
24
  function packageRoot() {
25
25
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
@@ -114,9 +114,16 @@ async function backfillGarminSwimLengths(database, paths, parentRunId, log) {
114
114
  }
115
115
  // The swim list is queried here and handed to the Python worker as a file:
116
116
  // the child must never open DuckDB itself because the parent holds the
117
- // write lock for the whole run.
117
+ // write lock for the whole run. Only activities without per-length rows are
118
+ // handed over: re-parsing every archived FIT on every sync cost ~23s/run
119
+ // with nothing new to stage.
118
120
  const activitiesFile = path.join(paths.staging, 'garmin', `${parentRunId}.swim_activities.json`);
119
- await writeFile(activitiesFile, JSON.stringify(await database.garminSwimActivitySources()));
121
+ const pendingSwims = await database.garminSwimActivitySourcesWithoutLengths();
122
+ if (pendingSwims.length === 0) {
123
+ log.info('Garmin swim backfill skipped: every swim activity already has lengths', { parentRunId });
124
+ return;
125
+ }
126
+ await writeFile(activitiesFile, JSON.stringify(pendingSwims));
120
127
  const arguments_ = [
121
128
  'run',
122
129
  'python',
@@ -207,7 +214,21 @@ function runGarminWorker(runId, arguments_, environment, pump, log, onInterruptR
207
214
  }
208
215
  log.info(`[garmin] ${line}`);
209
216
  });
210
- buffer(child.stderr, (line) => log.error(`[garmin] ${line}`));
217
+ buffer(child.stderr, (line) => {
218
+ let parsed;
219
+ try {
220
+ parsed = JSON.parse(line);
221
+ }
222
+ catch {
223
+ log.error(`[garmin] ${line}`);
224
+ return;
225
+ }
226
+ if (typeof parsed === 'object' && parsed !== null && parsed.kind === 'stage_timings') {
227
+ log.info('Garmin staging worker stage timings', parsed);
228
+ return;
229
+ }
230
+ log.error(`[garmin] ${line}`);
231
+ });
211
232
  child.on('error', (error) => {
212
233
  if (killTimer)
213
234
  clearTimeout(killTimer);
@@ -310,7 +331,9 @@ async function syncProvider(database, paths, provider, options, log) {
310
331
  // streams even when the list-summary hash is unchanged.
311
332
  await writeFile(knownActivities, JSON.stringify(options.refreshActivities ? {} : await database.knownActivitySummaryHashes('garmin')));
312
333
  extractionStarted = true;
313
- const arguments_ = ['run', 'python', '-m', 'python.catence.providers.garmin.cli', '--from', fromDate, '--to', options.toDate, '--data-dir', paths.root, '--output', output, '--run-id', runId, '--known-activities', knownActivities];
334
+ const requestedConcurrency = Number.parseInt(process.env.CATENCE_GARMIN_CONCURRENCY ?? '5', 10);
335
+ const workerConcurrency = Number.isFinite(requestedConcurrency) && requestedConcurrency > 0 ? Math.trunc(requestedConcurrency) : 5;
336
+ const arguments_ = ['run', 'python', '-m', 'python.catence.providers.garmin.cli', '--from', fromDate, '--to', options.toDate, '--data-dir', paths.root, '--output', output, '--run-id', runId, '--known-activities', knownActivities, '--concurrency', String(workerConcurrency)];
314
337
  if (dailyWindow)
315
338
  arguments_.push('--daily-from', dailyWindow.fromDate, '--daily-to', dailyWindow.toDate);
316
339
  else
@@ -340,6 +363,7 @@ async function syncProvider(database, paths, provider, options, log) {
340
363
  if (needsEventBackfill && earliestActivity && earliestActivity < eventsShortFrom) {
341
364
  arguments_.push('--events-from', earliestActivity);
342
365
  }
366
+ const workerStartedAt = Date.now();
343
367
  let interruptChild = null;
344
368
  await runWithInterruptGuard(runId, database, () => interruptChild?.(), guard, async () => {
345
369
  const outcome = await runGarminWorker(runId, arguments_, {
@@ -350,14 +374,21 @@ async function syncProvider(database, paths, provider, options, log) {
350
374
  throw new Error(`Garmin staging worker exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
351
375
  }
352
376
  });
377
+ log.info('Garmin staging worker finished', { runId, durationMs: Date.now() - workerStartedAt, concurrency: workerConcurrency });
353
378
  if (!guard.interrupted) {
354
379
  if (!existsSync(output))
355
380
  throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
381
+ let phaseStartedAt = Date.now();
356
382
  await importJsonl(database, runId, output, log);
383
+ log.info('Garmin staging import finished', { runId, durationMs: Date.now() - phaseStartedAt });
384
+ phaseStartedAt = Date.now();
357
385
  await reconcileGarminCalendarEntities(database, output);
386
+ log.info('Garmin calendar reconciliation finished', { runId, durationMs: Date.now() - phaseStartedAt });
358
387
  if (provider === 'garmin') {
359
388
  try {
389
+ phaseStartedAt = Date.now();
360
390
  await backfillGarminSwimLengths(database, paths, runId, log);
391
+ log.info('Garmin swim-length backfill finished', { runId, durationMs: Date.now() - phaseStartedAt });
361
392
  }
362
393
  catch (error) {
363
394
  log.warn('Garmin swim-length backfill after sync failed', {
@@ -430,8 +461,43 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
430
461
  runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }, log));
431
462
  const executedRuns = runs.filter((run) => !run.skipped);
432
463
  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 });
464
+ // The per-provider pumps already stopped (sidecars removed, runs marked
465
+ // completed), but buildRetrievalIndex still holds the single-writer DB
466
+ // lock, so /dashboard reads as unavailable. Hold a non-terminal sidecar
467
+ // for the last executed run so /sync/status keeps reporting running and
468
+ // the Status Sync button stays disabled through local post-processing.
469
+ const last = [...executedRuns].reverse().find((run) => run.runId);
470
+ let indexingKeepalive = null;
471
+ const holdIndexingProgress = () => last?.runId
472
+ ? writeProgressSidecar(paths, last.runId, {
473
+ ...baseProgress(last.runId, last.provider, 'importing'),
474
+ currentStep: 'building retrieval index',
475
+ percentComplete: 99,
476
+ }).catch((error) => {
477
+ log.debug('Indexing progress sidecar write failed', {
478
+ runId: last.runId,
479
+ error: error instanceof Error ? error.message : String(error),
480
+ });
481
+ })
482
+ : Promise.resolve();
483
+ if (last?.runId) {
484
+ await holdIndexingProgress();
485
+ indexingKeepalive = setInterval(() => {
486
+ void holdIndexingProgress();
487
+ }, PROGRESS_KEEPALIVE_MS);
488
+ indexingKeepalive.unref?.();
489
+ }
490
+ try {
491
+ const indexStartedAt = Date.now();
492
+ const index = await buildRetrievalIndex(database);
493
+ log.info('Rebuilt retrieval index after sync', { documents: index.documents, mode: index.mode, watermark: index.watermark, durationMs: Date.now() - indexStartedAt });
494
+ }
495
+ finally {
496
+ if (indexingKeepalive)
497
+ clearInterval(indexingKeepalive);
498
+ if (last?.runId)
499
+ await removeProgressSidecar(paths, last.runId).catch(() => undefined);
500
+ }
435
501
  }
436
502
  return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs, timedOutRuns: timedOutRuns.runIds };
437
503
  }
@@ -688,6 +754,36 @@ export async function reimportNutrition(paths) {
688
754
  await database.close();
689
755
  }
690
756
  }
757
+ /**
758
+ * Re-run normalization over already-captured Garmin weigh-in entities without
759
+ * contacting any provider. Stores synced before beta.5 staged weigh_ins
760
+ * payloads but never projected them to daily_metrics, so this backfills
761
+ * weight_kg from stored payloads. Idempotent upserts make it safe to re-run.
762
+ */
763
+ export async function reimportWeight(paths) {
764
+ if (!existsSync(paths.database))
765
+ throw new Error('No Catence database exists yet. Complete a sync before re-importing weight.');
766
+ const database = await CatenceDatabase.open(paths);
767
+ try {
768
+ const rows = await database.rows(`SELECT provider, entity_type, remote_id, parent_remote_id, occurred_on, source_updated_at, raw_object_hash,
769
+ cast(payload_json AS VARCHAR) AS payload_json, cast(extension_json AS VARCHAR) AS extension_json
770
+ FROM source_entities
771
+ WHERE provider = 'garmin' AND entity_type = 'body_composition'
772
+ ORDER BY occurred_on, remote_id`);
773
+ for (const row of rows) {
774
+ await importSourceEntity(database, {
775
+ kind: 'source_entity', schemaVersion: 1, provider: row.provider, entityType: row.entity_type,
776
+ remoteId: row.remote_id, parentRemoteId: row.parent_remote_id, occurredOn: row.occurred_on, sourceUpdatedAt: row.source_updated_at,
777
+ rawObjectHash: row.raw_object_hash, payload: parseJsonObject(row.payload_json), extension: parseJsonObject(row.extension_json),
778
+ });
779
+ }
780
+ const weights = (await database.rows(`SELECT count(*) AS count FROM daily_metrics WHERE metric_name = 'weight_kg'`))[0]?.count ?? 0;
781
+ return { reimportedEntities: rows.length, weightObservations: Number(weights) };
782
+ }
783
+ finally {
784
+ await database.close();
785
+ }
786
+ }
691
787
  function parseJsonObject(value) {
692
788
  if (!value)
693
789
  return {};
@@ -1,7 +1,17 @@
1
1
  import type { CatenceDatabase } from '../storage/database.js';
2
2
  import { type StagingRecord } from '../../contracts/staging.js';
3
3
  import type { SyncLogger } from '../../core/logging.js';
4
- export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord, log?: SyncLogger): Promise<void>;
4
+ /**
5
+ * Per-pass import state. When present, activity-quality refreshes are
6
+ * deferred to the end of the pass: a staging file lists an activity before
7
+ * its detail records, so refreshing inline would compute flags from partial
8
+ * data, and one refresh per activity is enough regardless of how many
9
+ * detail entity types follow it.
10
+ */
11
+ export type ImportPassContext = {
12
+ deferredQualityActivityIds: Set<string>;
13
+ };
14
+ export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord, log?: SyncLogger, pass?: ImportPassContext): Promise<void>;
5
15
  export declare function importJsonl(database: CatenceDatabase, runId: string, filePath: string, log?: SyncLogger): Promise<number>;
6
16
  export declare function importSingleStagingFile(database: CatenceDatabase, runId: string, filePath: string): Promise<number>;
7
17
  export declare function queueWorkItem(database: CatenceDatabase, runId: string, provider: 'intervals' | 'garmin' | 'strava', endpoint: string, remoteId: string | null, scope: unknown): Promise<string>;
@@ -7,7 +7,12 @@ 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, log) {
10
+ const GARMIN_ACTIVITY_DETAIL_ENTITY_TYPES = ['activity_detail', 'activity_interval', 'activity_exercise_set', 'activity_swim_lengths'];
11
+ async function activityIdForSource(database, activitySourceId) {
12
+ const activity = await database.rows('SELECT activity_id FROM activity_sources WHERE activity_source_id = $activitySourceId', { activitySourceId });
13
+ return activity[0]?.activity_id ?? null;
14
+ }
15
+ export async function importRecord(database, runId, record, log, pass) {
11
16
  if (record.kind === 'run_manifest') {
12
17
  if (record.runId !== runId)
13
18
  throw new Error(`Staging manifest run ${record.runId} does not match importer run ${runId}.`);
@@ -29,14 +34,14 @@ export async function importRecord(database, runId, record, log) {
29
34
  await importSourceEntity(database, record);
30
35
  if (record.entityType === 'activity') {
31
36
  await reconcileActivityLink(database, record.provider + ':' + record.remoteId);
32
- const activity = await database.rows('SELECT activity_id FROM activity_sources WHERE activity_source_id = $activitySourceId', { activitySourceId: record.provider + ':' + record.remoteId });
33
- if (activity[0]?.activity_id)
34
- await refreshActivityQuality(database, activity[0].activity_id);
37
+ const activityId = await activityIdForSource(database, record.provider + ':' + record.remoteId);
38
+ if (activityId)
39
+ await queueActivityQualityRefresh(database, activityId, pass);
35
40
  }
36
- if (record.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set', 'activity_swim_lengths'].includes(record.entityType) && record.parentRemoteId) {
37
- const activity = await database.rows('SELECT activity_id FROM activity_sources WHERE activity_source_id = $activitySourceId', { activitySourceId: `garmin:${record.parentRemoteId}` });
38
- if (activity[0]?.activity_id)
39
- await refreshActivityQuality(database, activity[0].activity_id);
41
+ if (record.provider === 'garmin' && GARMIN_ACTIVITY_DETAIL_ENTITY_TYPES.includes(record.entityType) && record.parentRemoteId) {
42
+ const activityId = await activityIdForSource(database, `garmin:${record.parentRemoteId}`);
43
+ if (activityId)
44
+ await queueActivityQualityRefresh(database, activityId, pass);
40
45
  }
41
46
  return;
42
47
  }
@@ -56,17 +61,61 @@ export async function importRecord(database, runId, record, log) {
56
61
  await database.addError(runId, record.provider, record.endpoint, record.remoteId, record.message, record.retryable);
57
62
  log?.warn('Staging record reported an extraction error', { runId, provider: record.provider, endpoint: record.endpoint, remoteId: record.remoteId, error: record.message });
58
63
  }
64
+ /**
65
+ * Refresh now (single-record callers) or defer to the end of the pass
66
+ * (bulk importJsonl callers) — see ImportPassContext.
67
+ */
68
+ async function queueActivityQualityRefresh(database, activityId, pass) {
69
+ if (pass) {
70
+ pass.deferredQualityActivityIds.add(activityId);
71
+ return;
72
+ }
73
+ await refreshActivityQuality(database, activityId);
74
+ }
75
+ /** Records per transaction: large enough to amortize DuckDB commits, small enough to bound a mid-file failure. */
76
+ const IMPORT_CHUNK_SIZE = 200;
59
77
  export async function importJsonl(database, runId, filePath, log) {
60
78
  let count = 0;
79
+ const pass = { deferredQualityActivityIds: new Set() };
61
80
  const file = createReadStream(filePath, { encoding: 'utf8' });
62
81
  const lines = createInterface({ input: file, crlfDelay: Infinity });
63
- for await (const line of lines) {
64
- if (!line.trim())
65
- continue;
66
- const record = stagingRecordSchema.parse(JSON.parse(line));
67
- await importRecord(database, runId, record, log);
68
- count += 1;
82
+ let inTransaction = false;
83
+ const startedAt = Date.now();
84
+ try {
85
+ for await (const line of lines) {
86
+ if (!line.trim())
87
+ continue;
88
+ if (count % IMPORT_CHUNK_SIZE === 0) {
89
+ await database.run('BEGIN TRANSACTION');
90
+ inTransaction = true;
91
+ }
92
+ const record = stagingRecordSchema.parse(JSON.parse(line));
93
+ await importRecord(database, runId, record, log, pass);
94
+ count += 1;
95
+ if (count % IMPORT_CHUNK_SIZE === 0) {
96
+ await database.run('COMMIT');
97
+ inTransaction = false;
98
+ }
99
+ }
100
+ }
101
+ catch (error) {
102
+ if (inTransaction)
103
+ await database.run('ROLLBACK');
104
+ throw error;
105
+ }
106
+ if (inTransaction) {
107
+ await database.run('COMMIT');
108
+ inTransaction = false;
109
+ }
110
+ const qualityStartedAt = Date.now();
111
+ for (const activityId of pass.deferredQualityActivityIds) {
112
+ await refreshActivityQuality(database, activityId);
69
113
  }
114
+ log?.info('Staging import finished with stage timings', {
115
+ runId, records: count, durationMs: Date.now() - startedAt,
116
+ qualityRefreshes: pass.deferredQualityActivityIds.size,
117
+ qualityRefreshMs: Date.now() - qualityStartedAt,
118
+ });
70
119
  return count;
71
120
  }
72
121
  export async function importSingleStagingFile(database, runId, filePath) {
@@ -27,6 +27,13 @@ export declare class IntervalsExtractor {
27
27
  private captureActivity;
28
28
  private capture;
29
29
  private importEntities;
30
+ /**
31
+ * Intervals.icu enforces 2500 requests per rolling 15 minutes and 5000 per
32
+ * day; over-limit calls answer 429 with a Retry-After header. Back off
33
+ * cooperatively and retry a bounded number of times so one auto-sync run
34
+ * degrades instead of recording a wall of retryable errors.
35
+ */
36
+ private request;
30
37
  private getJson;
31
38
  private getBinary;
32
39
  }