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.
- package/README.md +4 -4
- package/config.example.json +1 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/contracts/runtime.d.ts +4 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +16 -10
- package/dist/core/query/repository.d.ts +1 -1
- package/dist/core/query/repository.js +31 -2
- package/dist/core/retrieval/index.js +23 -5
- package/dist/core/runtime/athlete-file.d.ts +49 -0
- package/dist/core/runtime/athlete-file.js +177 -0
- package/dist/core/runtime/configuration.js +2 -0
- package/dist/core/runtime/console-accounts.d.ts +52 -0
- package/dist/core/runtime/console-accounts.js +103 -0
- package/dist/core/runtime/mcp-keys.d.ts +65 -0
- package/dist/core/runtime/mcp-keys.js +171 -0
- package/dist/core/runtime/oauth/model.d.ts +44 -0
- package/dist/core/runtime/oauth/model.js +345 -0
- package/dist/core/runtime/oauth/public-client.d.ts +28 -0
- package/dist/core/runtime/oauth/public-client.js +35 -0
- package/dist/core/runtime/oauth/settings.d.ts +33 -0
- package/dist/core/runtime/oauth/settings.js +96 -0
- package/dist/core/runtime/secrets.d.ts +15 -0
- package/dist/core/runtime/secrets.js +48 -7
- package/dist/elt/application/autosync.d.ts +71 -0
- package/dist/elt/application/autosync.js +173 -0
- package/dist/elt/application/detached-sync.d.ts +7 -2
- package/dist/elt/application/detached-sync.js +10 -4
- package/dist/elt/application/management.d.ts +7 -0
- package/dist/elt/application/management.js +103 -7
- package/dist/elt/ingestion/importer.d.ts +11 -1
- package/dist/elt/ingestion/importer.js +63 -14
- package/dist/elt/ingestion/providers/intervals.d.ts +7 -0
- package/dist/elt/ingestion/providers/intervals.js +21 -2
- package/dist/elt/normalization/normalizers.js +122 -0
- package/dist/elt/storage/database.d.ts +6 -1
- package/dist/elt/storage/database.js +7 -1
- package/dist/elt/storage/migrations.js +45 -1
- package/dist/elt/storage/progress-sidecar.d.ts +9 -0
- package/dist/elt/storage/progress-sidecar.js +31 -16
- package/dist/interfaces/cli/commands.js +117 -2
- package/dist/interfaces/http/cli-options.d.ts +64 -1
- package/dist/interfaces/http/cli-options.js +214 -2
- package/dist/interfaces/http/oauth.d.ts +16 -0
- package/dist/interfaces/http/oauth.js +228 -0
- package/dist/interfaces/http/server.d.ts +60 -1
- package/dist/interfaces/http/server.js +810 -16
- package/dist/interfaces/mcp/main.js +35 -3
- package/dist/interfaces/mcp/server.d.ts +21 -1
- package/dist/interfaces/mcp/server.js +107 -21
- package/dist/runtime/index.d.ts +15 -3
- package/dist/runtime/index.js +9 -2
- package/docs/architecture.md +1 -1
- package/docs/configuration.md +76 -6
- package/docs/deployment/docker.md +173 -15
- package/docs/deployment/local-console.md +132 -21
- package/docs/deployment/local-mcp.md +190 -11
- package/docs/development.md +5 -3
- package/docs/llm-providers.md +4 -4
- package/package.json +11 -4
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +55 -7
- package/python/catence/providers/garmin/progress.py +71 -35
- package/python/catence/providers/garmin/staging.py +11 -4
- package/python/catence/providers/garmin/worker.py +420 -206
- 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`
|
|
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`
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
if (
|
|
34
|
-
await
|
|
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' &&
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
await
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
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
|
}
|