catence 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +373 -29
- package/config.example.json +63 -0
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.js +5 -0
- package/dist/contracts/release.d.ts +20 -0
- package/dist/contracts/release.js +17 -0
- package/dist/contracts/runtime.d.ts +11 -0
- package/dist/contracts/runtime.js +2 -0
- package/dist/contracts/staging.d.ts +183 -0
- package/dist/contracts/staging.js +36 -0
- package/dist/contracts/storage.d.ts +15 -0
- package/dist/contracts/storage.js +2 -0
- package/dist/core/dashboard/snapshot.d.ts +16 -0
- package/dist/core/dashboard/snapshot.js +65 -0
- package/dist/core/query/activity-discovery.d.ts +21 -0
- package/dist/core/query/activity-discovery.js +133 -0
- package/dist/core/query/analytics.d.ts +59 -0
- package/dist/core/query/analytics.js +395 -0
- package/dist/core/query/catalog.d.ts +26 -0
- package/dist/core/query/catalog.js +141 -0
- package/dist/core/query/fitness.d.ts +40 -0
- package/dist/core/query/fitness.js +297 -0
- package/dist/core/query/repository.d.ts +19 -0
- package/dist/core/query/repository.js +182 -0
- package/dist/core/query/sql-guard.d.ts +7 -0
- package/dist/core/query/sql-guard.js +121 -0
- package/dist/core/query/sql.d.ts +2 -0
- package/dist/core/query/sql.js +5 -0
- package/dist/core/query/swimming.d.ts +19 -0
- package/dist/core/query/swimming.js +184 -0
- package/dist/core/query/wellness.d.ts +45 -0
- package/dist/core/query/wellness.js +292 -0
- package/dist/core/retrieval/index.d.ts +13 -0
- package/dist/core/retrieval/index.js +248 -0
- package/dist/core/runtime/configuration.d.ts +71 -0
- package/dist/core/runtime/configuration.js +106 -0
- package/dist/core/runtime/limiter.d.ts +13 -0
- package/dist/core/runtime/limiter.js +17 -0
- package/dist/elt/application/demo.d.ts +23 -0
- package/dist/elt/application/demo.js +165 -0
- package/dist/elt/application/management.d.ts +25 -0
- package/dist/elt/application/management.js +283 -0
- package/dist/elt/ingestion/importer.d.ts +8 -0
- package/dist/elt/ingestion/importer.js +87 -0
- package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
- package/dist/elt/ingestion/providers/intervals.js +224 -0
- package/dist/elt/ingestion/providers/registry.d.ts +19 -0
- package/dist/elt/ingestion/providers/registry.js +62 -0
- package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
- package/dist/elt/ingestion/providers/strava/service.js +365 -0
- package/dist/elt/normalization/activities/linking.d.ts +5 -0
- package/dist/elt/normalization/activities/linking.js +165 -0
- package/dist/elt/normalization/normalizers.d.ts +5 -0
- package/dist/elt/normalization/normalizers.js +816 -0
- package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
- package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
- package/dist/elt/storage/artifacts.d.ts +13 -0
- package/dist/elt/storage/artifacts.js +48 -0
- package/dist/elt/storage/database.d.ts +74 -0
- package/dist/elt/storage/database.js +296 -0
- package/dist/elt/storage/migrations.d.ts +3 -0
- package/dist/elt/storage/migrations.js +832 -0
- package/dist/elt/storage/migrations.legacy.d.ts +12 -0
- package/dist/elt/storage/migrations.legacy.js +332 -0
- package/dist/elt/storage/sql.d.ts +2 -0
- package/dist/elt/storage/sql.js +7 -0
- package/dist/elt/storage/write-lock.d.ts +6 -0
- package/dist/elt/storage/write-lock.js +28 -0
- package/dist/elt/streams.d.ts +24 -0
- package/dist/elt/streams.js +62 -0
- package/dist/interfaces/cli/commands.d.ts +2 -0
- package/dist/interfaces/cli/commands.js +101 -0
- package/dist/interfaces/cli/main.d.ts +2 -0
- package/dist/interfaces/cli/main.js +3 -0
- package/dist/interfaces/http/cli-options.d.ts +9 -0
- package/dist/interfaces/http/cli-options.js +81 -0
- package/dist/interfaces/http/server.d.ts +14 -0
- package/dist/interfaces/http/server.js +169 -0
- package/dist/interfaces/mcp/cli-options.d.ts +6 -0
- package/dist/interfaces/mcp/cli-options.js +36 -0
- package/dist/interfaces/mcp/main.d.ts +2 -0
- package/dist/interfaces/mcp/main.js +59 -0
- package/dist/interfaces/mcp/server.d.ts +8 -0
- package/dist/interfaces/mcp/server.js +521 -0
- package/dist/runtime/index.d.ts +29 -0
- package/dist/runtime/index.js +19 -0
- package/docs/architecture.md +100 -0
- package/docs/assets/image.png +0 -0
- package/docs/mcp-activity-retrieval.md +377 -0
- package/package.json +81 -6
- package/pyproject.toml +28 -0
- package/python/__init__.py +1 -0
- package/python/catence/__init__.py +0 -0
- package/python/catence/providers/__init__.py +0 -0
- package/python/catence/providers/garmin/__init__.py +3 -0
- package/python/catence/providers/garmin/cli.py +62 -0
- package/python/catence/providers/garmin/contracts.py +23 -0
- package/python/catence/providers/garmin/registry.py +48 -0
- package/python/catence/providers/garmin/staging.py +87 -0
- package/python/catence/providers/garmin/streams.py +195 -0
- package/python/catence/providers/garmin/worker.py +450 -0
- package/python/catence/providers/strava/__init__.py +3 -0
- package/python/catence/providers/strava/cli.py +404 -0
- package/python/catence/providers/strava/staging.py +73 -0
- package/uv.lock +708 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { DATASET_CATALOG, QueryValidationError } from './catalog.js';
|
|
3
|
+
import { jsonSafe } from './repository.js';
|
|
4
|
+
const forbidden = /\b(attach|detach|copy|export|import|install|load|pragma|set|reset|create|alter|drop|delete|insert|update|merge|vacuum|checkpoint|call|execute|prepare|transaction|begin|commit|rollback|read_parquet|read_csv|read_json|glob|parquet_scan|csv_scan|httpfs|sqlite_scan)\b/i;
|
|
5
|
+
const comments = /(--|\/\*|\*\/)/;
|
|
6
|
+
const MAX_PAGE_SIZE = 500;
|
|
7
|
+
// Leave room for metadata and MCP's envelope instead of constructing a data
|
|
8
|
+
// payload which is just below the database-side limit but still gets cut off
|
|
9
|
+
// by the client transport.
|
|
10
|
+
const MAX_RESPONSE_BYTES = 400_000;
|
|
11
|
+
function queryHash(sql, values) {
|
|
12
|
+
return createHash('sha256').update(JSON.stringify({ sql, values: Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))) })).digest('base64url');
|
|
13
|
+
}
|
|
14
|
+
function encodeCursor(cursor) {
|
|
15
|
+
return Buffer.from(JSON.stringify(cursor)).toString('base64url');
|
|
16
|
+
}
|
|
17
|
+
function decodeCursor(cursor, expectedHash) {
|
|
18
|
+
try {
|
|
19
|
+
const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
20
|
+
if (!Number.isInteger(decoded.offset) || decoded.offset < 0 || decoded.hash !== expectedHash)
|
|
21
|
+
throw new Error();
|
|
22
|
+
return decoded;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new QueryValidationError('Invalid or mismatched cursor. Start the read-only query again.');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function safeSql(sql) {
|
|
29
|
+
const normalized = sql.trim();
|
|
30
|
+
if (!normalized)
|
|
31
|
+
throw new QueryValidationError('SQL is required.');
|
|
32
|
+
if (!/^(select\b|with\b)/i.test(normalized))
|
|
33
|
+
throw new QueryValidationError('Only a single SELECT or WITH … SELECT statement is allowed.');
|
|
34
|
+
if (normalized.includes(';') || comments.test(normalized))
|
|
35
|
+
throw new QueryValidationError('SQL comments and multiple statements are not allowed.');
|
|
36
|
+
if (forbidden.test(normalized))
|
|
37
|
+
throw new QueryValidationError('SQL contains a prohibited keyword or filesystem table function.');
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
function normalizeRelation(name) {
|
|
41
|
+
// DuckDB's getTableNames() preserves aliases (for example, "activities AS
|
|
42
|
+
// a"), while the catalog intentionally contains only relation names.
|
|
43
|
+
// Discard that syntactic alias before doing the allow-list comparison.
|
|
44
|
+
const relation = name.trim().replace(/\s+(?:as\s+)?(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s*$/i, '');
|
|
45
|
+
return relation.replaceAll('"', '').split('.').at(-1).toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
export async function queryReadOnlyData(repository, request) {
|
|
48
|
+
const sql = safeSql(request.sql);
|
|
49
|
+
let relations;
|
|
50
|
+
try {
|
|
51
|
+
relations = await repository.database.tableNames(sql);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new QueryValidationError(`SQL could not be inspected: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
const allowed = new Set(Object.values(DATASET_CATALOG).filter((dataset) => !dataset.samplesOnly && dataset.relation).map((dataset) => dataset.relation.toLowerCase()));
|
|
57
|
+
for (const relation of relations) {
|
|
58
|
+
if (!allowed.has(normalizeRelation(relation)))
|
|
59
|
+
throw new QueryValidationError(`Relation ${relation} is not in the read-only catalog.`);
|
|
60
|
+
}
|
|
61
|
+
const ctes = new Set([...sql.matchAll(/(?:\bwith|,)\s*([A-Za-z_][A-Za-z0-9_]*)\s+as\s*\(/gi)].map((match) => match[1].toLowerCase()));
|
|
62
|
+
for (const match of sql.matchAll(/\b(?:from|join)\s+([A-Za-z_][A-Za-z0-9_.]*)/gi)) {
|
|
63
|
+
const relation = normalizeRelation(match[1]);
|
|
64
|
+
if (!allowed.has(relation) && !ctes.has(relation))
|
|
65
|
+
throw new QueryValidationError(`Relation ${match[1]} is not in the read-only catalog.`);
|
|
66
|
+
}
|
|
67
|
+
const values = request.values ?? {};
|
|
68
|
+
if ('__catence_page_size' in values || '__catence_offset' in values)
|
|
69
|
+
throw new QueryValidationError('Bind names prefixed with __catence_ are reserved for pagination.');
|
|
70
|
+
const pageSize = Math.min(Math.max(request.pageSize ?? 100, 1), MAX_PAGE_SIZE);
|
|
71
|
+
const fingerprint = queryHash(sql, values);
|
|
72
|
+
const offset = request.cursor ? decodeCursor(request.cursor, fingerprint).offset : 0;
|
|
73
|
+
const timeout = setTimeout(() => repository.database.interrupt(), 4_000);
|
|
74
|
+
let totalRows;
|
|
75
|
+
let rows;
|
|
76
|
+
try {
|
|
77
|
+
const total = await repository.rows(`SELECT count(*) AS total_rows FROM (${sql}) AS guarded_query`, values);
|
|
78
|
+
totalRows = Number(total[0]?.total_rows ?? 0);
|
|
79
|
+
// ORDER BY ALL creates one stable ordering from every selected column,
|
|
80
|
+
// including query results without their own ORDER BY. Identical rows are
|
|
81
|
+
// interchangeable, so paging them cannot omit a distinguishable result.
|
|
82
|
+
rows = await repository.rows(`SELECT * FROM (${sql}) AS guarded_query ORDER BY ALL LIMIT $__catence_page_size OFFSET $__catence_offset`, { ...values, __catence_page_size: pageSize, __catence_offset: offset });
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
throw new QueryValidationError(`Read-only query failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
clearTimeout(timeout);
|
|
89
|
+
}
|
|
90
|
+
const withinByteLimit = [];
|
|
91
|
+
let bytes = 0;
|
|
92
|
+
let oversizedFirstRow = false;
|
|
93
|
+
for (const row of rows) {
|
|
94
|
+
const serialized = JSON.stringify(jsonSafe(row));
|
|
95
|
+
if (bytes + Buffer.byteLength(serialized) > MAX_RESPONSE_BYTES) {
|
|
96
|
+
oversizedFirstRow = withinByteLimit.length === 0;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
bytes += Buffer.byteLength(serialized);
|
|
100
|
+
withinByteLimit.push(row);
|
|
101
|
+
}
|
|
102
|
+
const returnedRows = withinByteLimit.length;
|
|
103
|
+
const truncated = offset + returnedRows < totalRows;
|
|
104
|
+
const nextCursor = truncated && returnedRows > 0 ? encodeCursor({ hash: fingerprint, offset: offset + returnedRows }) : undefined;
|
|
105
|
+
return jsonSafe({
|
|
106
|
+
returnedRows,
|
|
107
|
+
totalRows,
|
|
108
|
+
truncated,
|
|
109
|
+
nextCursor,
|
|
110
|
+
data: withinByteLimit,
|
|
111
|
+
provenance: { resolvedViews: relations.map(normalizeRelation), readOnly: true },
|
|
112
|
+
query: { sql, bindNames: Object.keys(values), pageSize, timeoutMs: 4_000, ordering: 'all selected columns ascending' },
|
|
113
|
+
caveats: [
|
|
114
|
+
...(withinByteLimit.length < rows.length && !oversizedFirstRow ? ['Response reached Catence’s safe response-size limit; continue with nextCursor.'] : []),
|
|
115
|
+
...(oversizedFirstRow ? ['The next row alone exceeds Catence’s safe response-size limit. Narrow the selected columns before continuing; no cursor was advanced.'] : []),
|
|
116
|
+
...(truncated ? ['Results are incomplete. Do not draw exhaustive conclusions; retrieve remaining pages with nextCursor or narrow the query.'] : []),
|
|
117
|
+
],
|
|
118
|
+
columnTypes: withinByteLimit[0] ? Object.fromEntries(Object.entries(withinByteLimit[0]).map(([key, value]) => [key, value === null ? 'NULL' : typeof value])) : {},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=sql-guard.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ReadOnlyRepository } from './repository.js';
|
|
2
|
+
export type SwimLapsRequest = {
|
|
3
|
+
/** A source id (for example garmin:123) or a linked logical activity id. */
|
|
4
|
+
activityId: string;
|
|
5
|
+
provider?: 'garmin' | 'intervals';
|
|
6
|
+
};
|
|
7
|
+
export type SwimProgressRequest = {
|
|
8
|
+
startDate?: string;
|
|
9
|
+
endDate?: string;
|
|
10
|
+
/** Restrict the comparable pool cohort. Values are metres, not Garmin centimetres. */
|
|
11
|
+
poolLengthM?: number;
|
|
12
|
+
};
|
|
13
|
+
/** Read-only source-aware swim facts and a deliberately non-physiological progress report. */
|
|
14
|
+
export declare class SwimmingService {
|
|
15
|
+
private readonly repository;
|
|
16
|
+
constructor(repository: ReadOnlyRepository);
|
|
17
|
+
swimLaps(request: SwimLapsRequest): Promise<Record<string, unknown>>;
|
|
18
|
+
swimProgressReport(request?: SwimProgressRequest): Promise<Record<string, unknown>>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { QueryValidationError } from './catalog.js';
|
|
2
|
+
function parseJson(value) {
|
|
3
|
+
if (typeof value !== 'string')
|
|
4
|
+
return value;
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(value);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function addDateRange(column, request, values) {
|
|
13
|
+
const clauses = [];
|
|
14
|
+
if (request.startDate) {
|
|
15
|
+
values.startDate = request.startDate;
|
|
16
|
+
clauses.push(`${column} >= cast($startDate AS TIMESTAMPTZ)`);
|
|
17
|
+
}
|
|
18
|
+
if (request.endDate) {
|
|
19
|
+
values.endDate = `${request.endDate}T23:59:59.999Z`;
|
|
20
|
+
clauses.push(`${column} <= cast($endDate AS TIMESTAMPTZ)`);
|
|
21
|
+
}
|
|
22
|
+
return clauses;
|
|
23
|
+
}
|
|
24
|
+
/** Read-only source-aware swim facts and a deliberately non-physiological progress report. */
|
|
25
|
+
export class SwimmingService {
|
|
26
|
+
repository;
|
|
27
|
+
constructor(repository) {
|
|
28
|
+
this.repository = repository;
|
|
29
|
+
}
|
|
30
|
+
async swimLaps(request) {
|
|
31
|
+
const values = { activityId: request.activityId };
|
|
32
|
+
const providerClause = request.provider ? 'AND source.provider = $provider' : '';
|
|
33
|
+
if (request.provider)
|
|
34
|
+
values.provider = request.provider;
|
|
35
|
+
// A source id can happen to equal its unlinked logical id (for example
|
|
36
|
+
// garmin:123). Prefer the explicit source interpretation whenever it
|
|
37
|
+
// exists, otherwise expand a logical id to all of its provider records.
|
|
38
|
+
const exactSource = await this.repository.rows(`
|
|
39
|
+
SELECT activity_source_id FROM activity_sources
|
|
40
|
+
WHERE activity_source_id = $activityId
|
|
41
|
+
${request.provider ? 'AND provider = $provider' : ''}
|
|
42
|
+
LIMIT 1
|
|
43
|
+
`, values);
|
|
44
|
+
const identityClause = exactSource.length ? 'source.activity_source_id = $activityId' : 'source.activity_id = $activityId';
|
|
45
|
+
const sources = await this.repository.rows(`
|
|
46
|
+
SELECT source.activity_id, source.activity_source_id, source.provider, source.remote_activity_id,
|
|
47
|
+
cast(activity.started_at_utc AS VARCHAR) AS started_at_utc, activity.sport, activity.name
|
|
48
|
+
FROM activity_sources AS source
|
|
49
|
+
JOIN activities AS activity USING (activity_id)
|
|
50
|
+
WHERE ${identityClause}
|
|
51
|
+
AND lower(coalesce(activity.sport, '')) LIKE '%swim%'
|
|
52
|
+
${providerClause}
|
|
53
|
+
ORDER BY CASE source.provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END, source.activity_source_id
|
|
54
|
+
`, values);
|
|
55
|
+
if (sources.length === 0)
|
|
56
|
+
throw new QueryValidationError(`No swimming activity source matched ${request.activityId}.`);
|
|
57
|
+
const data = await Promise.all(sources.map(async (source) => {
|
|
58
|
+
const [lengths, sets, flags] = await Promise.all([
|
|
59
|
+
this.repository.rows(`
|
|
60
|
+
SELECT length_index, lap_index, pool_length_m, cast(start_time AS VARCHAR) AS start_time,
|
|
61
|
+
duration_s, active_duration_s, distance_m, stroke_count, stroke_rate, swolf,
|
|
62
|
+
avg_hr, max_hr, is_rest, source, confidence, raw_object_hash
|
|
63
|
+
FROM swim_length_facts
|
|
64
|
+
WHERE activity_source_id = $activitySourceId
|
|
65
|
+
ORDER BY source, length_index
|
|
66
|
+
`, { activitySourceId: source.activity_source_id }),
|
|
67
|
+
this.repository.rows(`
|
|
68
|
+
SELECT source_type, set_index, label, reps, rep_distance_m, total_distance_m,
|
|
69
|
+
work_s, rest_s, avg_pace, avg_hr, max_hr, stroke_rate, raw_object_hash
|
|
70
|
+
FROM swim_set_facts
|
|
71
|
+
WHERE activity_source_id = $activitySourceId
|
|
72
|
+
ORDER BY source_type, set_index
|
|
73
|
+
`, { activitySourceId: source.activity_source_id }),
|
|
74
|
+
this.repository.rows(`
|
|
75
|
+
SELECT flag_code, severity, details_json, raw_object_hash
|
|
76
|
+
FROM activity_quality_flag_facts
|
|
77
|
+
WHERE activity_source_id = $activitySourceId
|
|
78
|
+
ORDER BY severity DESC, flag_code
|
|
79
|
+
`, { activitySourceId: source.activity_source_id }),
|
|
80
|
+
]);
|
|
81
|
+
return {
|
|
82
|
+
...source,
|
|
83
|
+
lengths,
|
|
84
|
+
sets,
|
|
85
|
+
qualityFlags: flags.map((flag) => ({ ...flag, details_json: parseJson(flag.details_json) })),
|
|
86
|
+
availability: {
|
|
87
|
+
explicitLengthFacts: lengths.length > 0,
|
|
88
|
+
setFacts: sets.length > 0,
|
|
89
|
+
perLengthPaceAvailable: lengths.some((length) => Number(length.duration_s) > 0 && Number(length.distance_m) > 0),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}));
|
|
93
|
+
const noLengthSources = data.filter((source) => !source.availability.explicitLengthFacts).map((source) => source.activity_source_id);
|
|
94
|
+
return {
|
|
95
|
+
data,
|
|
96
|
+
provenance: { relations: ['activities', 'activity_sources', 'swim_length_facts', 'swim_set_facts', 'activity_quality_flag_facts'], idInput: request.activityId },
|
|
97
|
+
query: request,
|
|
98
|
+
caveats: [
|
|
99
|
+
...(noLengthSources.length ? [`No explicit provider-supplied per-length records are available for: ${noLengthSources.join(', ')}. Catence does not manufacture laps or pace from sample points.`] : []),
|
|
100
|
+
'Intervals.icu auto blocks and Garmin detected split summaries are returned as sets; neither is silently labelled as a manually pressed lap.',
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
async swimProgressReport(request = {}) {
|
|
105
|
+
if (request.poolLengthM !== undefined && (!Number.isFinite(request.poolLengthM) || request.poolLengthM <= 0 || request.poolLengthM > 200)) {
|
|
106
|
+
throw new QueryValidationError('poolLengthM must be a positive value no greater than 200 metres.');
|
|
107
|
+
}
|
|
108
|
+
const values = {};
|
|
109
|
+
const poolLengthSql = `CASE
|
|
110
|
+
WHEN try_cast(json_extract_string(summary.metrics_json, '$.poolLength') AS DOUBLE) >= 100
|
|
111
|
+
THEN try_cast(json_extract_string(summary.metrics_json, '$.poolLength') AS DOUBLE) / 100
|
|
112
|
+
ELSE try_cast(json_extract_string(summary.metrics_json, '$.poolLength') AS DOUBLE)
|
|
113
|
+
END`;
|
|
114
|
+
const clauses = ["source.provider = 'garmin'", "lower(coalesce(activity.sport, '')) LIKE '%swim%'", ...addDateRange('activity.started_at_utc', request, values)];
|
|
115
|
+
if (request.poolLengthM !== undefined) {
|
|
116
|
+
values.poolLengthM = request.poolLengthM;
|
|
117
|
+
clauses.push(`abs((${poolLengthSql}) - $poolLengthM) <= 0.01`);
|
|
118
|
+
}
|
|
119
|
+
const rows = await this.repository.rows(`
|
|
120
|
+
WITH quality AS (
|
|
121
|
+
SELECT activity_source_id,
|
|
122
|
+
json_group_array(json_object('code', flag_code, 'severity', severity, 'details', details_json)) AS quality_flags
|
|
123
|
+
FROM activity_quality_flags GROUP BY activity_source_id
|
|
124
|
+
), lengths AS (
|
|
125
|
+
SELECT activity_source_id, count(*) AS length_fact_count,
|
|
126
|
+
count(*) FILTER (WHERE duration_s > 0 AND distance_m > 0) AS pace_length_fact_count
|
|
127
|
+
FROM swim_lengths GROUP BY activity_source_id
|
|
128
|
+
), sets AS (
|
|
129
|
+
SELECT activity_source_id, count(*) AS set_fact_count FROM swim_sets GROUP BY activity_source_id
|
|
130
|
+
)
|
|
131
|
+
SELECT source.activity_id, source.activity_source_id, cast(activity.started_at_utc AS VARCHAR) AS started_at_utc,
|
|
132
|
+
activity.sport, activity.name, summary.distance_m, summary.moving_s, summary.elapsed_s, summary.avg_hr, summary.max_hr,
|
|
133
|
+
${poolLengthSql} AS pool_length_m,
|
|
134
|
+
try_cast(json_extract_string(summary.metrics_json, '$.activeLengths') AS DOUBLE) AS active_lengths,
|
|
135
|
+
try_cast(json_extract_string(summary.metrics_json, '$.averageSwolf') AS DOUBLE) AS average_swolf,
|
|
136
|
+
try_cast(json_extract_string(summary.metrics_json, '$.fastestSplit_100') AS DOUBLE) AS fastest_100_s,
|
|
137
|
+
try_cast(json_extract_string(summary.metrics_json, '$.averageSwimCadenceInStrokesPerMinute') AS DOUBLE) AS stroke_cadence_spm,
|
|
138
|
+
coalesce(lengths.length_fact_count, 0) AS length_fact_count,
|
|
139
|
+
coalesce(lengths.pace_length_fact_count, 0) AS pace_length_fact_count,
|
|
140
|
+
coalesce(sets.set_fact_count, 0) AS set_fact_count,
|
|
141
|
+
coalesce(quality.quality_flags, cast('[]' AS JSON)) AS quality_flags
|
|
142
|
+
FROM activity_sources AS source
|
|
143
|
+
JOIN activities AS activity USING (activity_id)
|
|
144
|
+
JOIN activity_summaries AS summary USING (activity_source_id)
|
|
145
|
+
LEFT JOIN lengths USING (activity_source_id)
|
|
146
|
+
LEFT JOIN sets USING (activity_source_id)
|
|
147
|
+
LEFT JOIN quality USING (activity_source_id)
|
|
148
|
+
WHERE ${clauses.join(' AND ')}
|
|
149
|
+
ORDER BY activity.started_at_utc ASC, source.activity_source_id ASC
|
|
150
|
+
`, values);
|
|
151
|
+
const sessions = rows.map((row) => ({
|
|
152
|
+
...row,
|
|
153
|
+
length_fact_count: Number(row.length_fact_count),
|
|
154
|
+
pace_length_fact_count: Number(row.pace_length_fact_count),
|
|
155
|
+
set_fact_count: Number(row.set_fact_count),
|
|
156
|
+
quality_flags: parseJson(row.quality_flags),
|
|
157
|
+
dataCompleteness: {
|
|
158
|
+
explicitLengthFactsAvailable: Number(row.length_fact_count) > 0,
|
|
159
|
+
setFactsAvailable: Number(row.set_fact_count) > 0,
|
|
160
|
+
perLengthPaceAvailable: Number(row.pace_length_fact_count) > 0,
|
|
161
|
+
},
|
|
162
|
+
}));
|
|
163
|
+
return {
|
|
164
|
+
data: {
|
|
165
|
+
sessions,
|
|
166
|
+
coverage: {
|
|
167
|
+
sessionCount: sessions.length,
|
|
168
|
+
poolSessionCount: sessions.filter((session) => session.pool_length_m !== null).length,
|
|
169
|
+
explicitLengthFactSessions: sessions.filter((session) => session.length_fact_count > 0).length,
|
|
170
|
+
setFactSessions: sessions.filter((session) => session.set_fact_count > 0).length,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
provenance: { relations: ['activities', 'activity_sources', 'activity_summaries', 'swim_lengths', 'swim_sets', 'activity_quality_flags'], summaryProvider: 'garmin' },
|
|
174
|
+
query: request,
|
|
175
|
+
caveats: [
|
|
176
|
+
'This report compares Garmin-native summary metrics. It does not infer a pool pace curve from moving time divided by distance.',
|
|
177
|
+
'Per-length pace, rest, and HR drift are reported only when explicit swim-length records exist; activity samples never create synthetic lengths.',
|
|
178
|
+
'Intervals.icu auto blocks are separately available through get_swim_laps and retain source_type: intervals_auto.',
|
|
179
|
+
...(sessions.length === 0 ? ['No Garmin swimming sessions matched the selected range and pool length.'] : []),
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=swimming.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ReadOnlyRepository } from './repository.js';
|
|
2
|
+
/**
|
|
3
|
+
* A small, intentionally opinionated layer over Catence's general query
|
|
4
|
+
* surface. It keeps the common wellness/recovery questions compact while
|
|
5
|
+
* retaining the same normalized daily-health and canonical-training facts.
|
|
6
|
+
*/
|
|
7
|
+
export declare const WELLNESS_METRICS: readonly ["resting_hr_bpm", "hrv_ms", "sleep_seconds", "sleep_score", "stress", "body_battery", "readiness", "spo2_pct", "steps", "training_load", "activity_count", "distance_m", "moving_s"];
|
|
8
|
+
export type WellnessMetric = (typeof WELLNESS_METRICS)[number];
|
|
9
|
+
export type WellnessCorrelationRequest = {
|
|
10
|
+
metricA: WellnessMetric;
|
|
11
|
+
metricB: WellnessMetric;
|
|
12
|
+
startDate?: string;
|
|
13
|
+
endDate?: string;
|
|
14
|
+
method?: 'pearson' | 'spearman';
|
|
15
|
+
lagDays?: number;
|
|
16
|
+
scanLags?: boolean;
|
|
17
|
+
};
|
|
18
|
+
export type WellnessBaselineRequest = {
|
|
19
|
+
metrics?: WellnessMetric[];
|
|
20
|
+
endDate?: string;
|
|
21
|
+
windowDays?: number;
|
|
22
|
+
};
|
|
23
|
+
export type WellnessAnomalyRequest = {
|
|
24
|
+
metrics?: WellnessMetric[];
|
|
25
|
+
startDate?: string;
|
|
26
|
+
endDate?: string;
|
|
27
|
+
zThreshold?: number;
|
|
28
|
+
};
|
|
29
|
+
export type WellnessCoverageRequest = {
|
|
30
|
+
metrics?: WellnessMetric[];
|
|
31
|
+
startDate?: string;
|
|
32
|
+
endDate?: string;
|
|
33
|
+
};
|
|
34
|
+
export declare class WellnessService {
|
|
35
|
+
private readonly repository;
|
|
36
|
+
constructor(repository: ReadOnlyRepository);
|
|
37
|
+
correlate(request: WellnessCorrelationRequest): Promise<Record<string, unknown>>;
|
|
38
|
+
baselines(request: WellnessBaselineRequest): Promise<Record<string, unknown>>;
|
|
39
|
+
anomalies(request: WellnessAnomalyRequest): Promise<Record<string, unknown>>;
|
|
40
|
+
coverage(request: WellnessCoverageRequest): Promise<Record<string, unknown>>;
|
|
41
|
+
private assertMetric;
|
|
42
|
+
private resolveRange;
|
|
43
|
+
private dailyRows;
|
|
44
|
+
private correlation;
|
|
45
|
+
}
|