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,395 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { getColumn, getDataset, QueryValidationError } from './catalog.js';
|
|
3
|
+
import { jsonSafe } from './repository.js';
|
|
4
|
+
const resolutionIntervals = {
|
|
5
|
+
'1m': 'minute', '5m': 'minute', '15m': 'minute', '1h': 'hour', day: 'day', week: 'week',
|
|
6
|
+
};
|
|
7
|
+
function quoteIdentifier(identifier) {
|
|
8
|
+
return `"${identifier.replaceAll('"', '""')}"`;
|
|
9
|
+
}
|
|
10
|
+
function valuesRecord(values, key, value) {
|
|
11
|
+
values[key] = value;
|
|
12
|
+
}
|
|
13
|
+
function compileFilter(dataset, filter, index, values) {
|
|
14
|
+
const column = getColumn(dataset, filter.column);
|
|
15
|
+
if (!column.filterable)
|
|
16
|
+
throw new QueryValidationError(`${filter.column} cannot be used as a filter on ${dataset.name}.`);
|
|
17
|
+
const sqlColumn = quoteIdentifier(column.name);
|
|
18
|
+
const key = `filter${index}`;
|
|
19
|
+
if (filter.op === 'eq' || filter.op === 'neq') {
|
|
20
|
+
if (filter.value === null)
|
|
21
|
+
return `${sqlColumn} IS ${filter.op === 'eq' ? '' : 'NOT '}NULL`;
|
|
22
|
+
valuesRecord(values, key, filter.value);
|
|
23
|
+
return `${sqlColumn} ${filter.op === 'eq' ? '=' : '<>'} $${key}`;
|
|
24
|
+
}
|
|
25
|
+
if (filter.op === 'gt' || filter.op === 'gte' || filter.op === 'lt' || filter.op === 'lte') {
|
|
26
|
+
valuesRecord(values, key, filter.value);
|
|
27
|
+
return `${sqlColumn} ${{ gt: '>', gte: '>=', lt: '<', lte: '<=' }[filter.op]} $${key}`;
|
|
28
|
+
}
|
|
29
|
+
if (filter.op === 'contains') {
|
|
30
|
+
if (typeof filter.value !== 'string')
|
|
31
|
+
throw new QueryValidationError('contains filters require a string value.');
|
|
32
|
+
valuesRecord(values, key, filter.value);
|
|
33
|
+
return `${sqlColumn} ILIKE '%' || $${key} || '%'`;
|
|
34
|
+
}
|
|
35
|
+
if (filter.op === 'in') {
|
|
36
|
+
if (!Array.isArray(filter.value) || filter.value.length === 0 || filter.value.length > 100)
|
|
37
|
+
throw new QueryValidationError('in filters require 1–100 values.');
|
|
38
|
+
const placeholders = filter.value.map((item, itemIndex) => {
|
|
39
|
+
const itemKey = `${key}_${itemIndex}`;
|
|
40
|
+
valuesRecord(values, itemKey, item);
|
|
41
|
+
return `$${itemKey}`;
|
|
42
|
+
});
|
|
43
|
+
return `${sqlColumn} IN (${placeholders.join(', ')})`;
|
|
44
|
+
}
|
|
45
|
+
if (filter.op === 'between') {
|
|
46
|
+
if (!Array.isArray(filter.value) || filter.value.length !== 2)
|
|
47
|
+
throw new QueryValidationError('between filters require exactly two values.');
|
|
48
|
+
valuesRecord(values, `${key}_start`, filter.value[0]);
|
|
49
|
+
valuesRecord(values, `${key}_end`, filter.value[1]);
|
|
50
|
+
return `${sqlColumn} BETWEEN $${key}_start AND $${key}_end`;
|
|
51
|
+
}
|
|
52
|
+
throw new QueryValidationError(`Unsupported filter operator: ${filter.op}`);
|
|
53
|
+
}
|
|
54
|
+
export function compileFilters(dataset, filters = [], startDate, endDate, activityId) {
|
|
55
|
+
const values = {};
|
|
56
|
+
const clauses = filters.map((filter, index) => compileFilter(dataset, filter, index, values));
|
|
57
|
+
if ((startDate || endDate) && !dataset.dateColumn)
|
|
58
|
+
throw new QueryValidationError(`${dataset.name} has no date column; scope it using an ID filter.`);
|
|
59
|
+
if (startDate) {
|
|
60
|
+
values.startDate = startDate;
|
|
61
|
+
clauses.push(`${quoteIdentifier(dataset.dateColumn)} >= cast($startDate AS ${dataset.dateColumn === 'metric_date' || dataset.dateColumn === 'nutrition_date' || dataset.dateColumn === 'occurred_on' ? 'DATE' : 'TIMESTAMPTZ'})`);
|
|
62
|
+
}
|
|
63
|
+
if (endDate) {
|
|
64
|
+
values.endDate = dataset.dateColumn === 'metric_date' || dataset.dateColumn === 'nutrition_date' || dataset.dateColumn === 'occurred_on' ? endDate : `${endDate}T23:59:59.999Z`;
|
|
65
|
+
clauses.push(`${quoteIdentifier(dataset.dateColumn)} <= cast($endDate AS ${dataset.dateColumn === 'metric_date' || dataset.dateColumn === 'nutrition_date' || dataset.dateColumn === 'occurred_on' ? 'DATE' : 'TIMESTAMPTZ'})`);
|
|
66
|
+
}
|
|
67
|
+
if (activityId) {
|
|
68
|
+
const activityColumn = dataset.columns.find((column) => column.name === 'activity_id');
|
|
69
|
+
if (!activityColumn)
|
|
70
|
+
throw new QueryValidationError(`${dataset.name} cannot be scoped by logical activity ID.`);
|
|
71
|
+
values.activityId = activityId;
|
|
72
|
+
clauses.push('"activity_id" = $activityId');
|
|
73
|
+
}
|
|
74
|
+
return { where: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', values };
|
|
75
|
+
}
|
|
76
|
+
function encodeCursor(cursor) {
|
|
77
|
+
return Buffer.from(JSON.stringify(cursor)).toString('base64url');
|
|
78
|
+
}
|
|
79
|
+
function decodeCursor(cursor, expectedHash) {
|
|
80
|
+
try {
|
|
81
|
+
const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
82
|
+
if (!Number.isInteger(decoded.offset) || decoded.offset < 0 || decoded.hash !== expectedHash || !decoded.resolution)
|
|
83
|
+
throw new Error();
|
|
84
|
+
return decoded;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new QueryValidationError('Invalid or mismatched cursor. Start the series query again.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function queryHash(request) {
|
|
91
|
+
return createHash('sha256').update(JSON.stringify(request)).digest('base64url');
|
|
92
|
+
}
|
|
93
|
+
function isNumeric(column) {
|
|
94
|
+
return column.metric === true;
|
|
95
|
+
}
|
|
96
|
+
function isTemporal(column) {
|
|
97
|
+
return /(?:^|\s)(?:DATE|TIME|TIMESTAMP)/i.test(column.type);
|
|
98
|
+
}
|
|
99
|
+
function timestampExpression(dataset) {
|
|
100
|
+
if (!dataset.dateColumn)
|
|
101
|
+
throw new QueryValidationError(`${dataset.name} has no time axis.`);
|
|
102
|
+
return quoteIdentifier(dataset.dateColumn);
|
|
103
|
+
}
|
|
104
|
+
function bucketExpression(timestamp, resolution) {
|
|
105
|
+
if (resolution === 'day' || resolution === 'week' || resolution === '1h')
|
|
106
|
+
return `date_trunc('${resolutionIntervals[resolution]}', ${timestamp})`;
|
|
107
|
+
if (resolution === '1m')
|
|
108
|
+
return `date_trunc('minute', ${timestamp})`;
|
|
109
|
+
if (resolution === '5m' || resolution === '15m') {
|
|
110
|
+
const seconds = resolution === '5m' ? 300 : 900;
|
|
111
|
+
return `to_timestamp(floor(epoch(${timestamp}) / ${seconds}) * ${seconds})`;
|
|
112
|
+
}
|
|
113
|
+
throw new QueryValidationError(`Cannot bucket with ${resolution}.`);
|
|
114
|
+
}
|
|
115
|
+
function sourceRelation(relation) {
|
|
116
|
+
return relation.trimStart().toLowerCase().startsWith('select') ? `(${relation})` : quoteIdentifier(relation);
|
|
117
|
+
}
|
|
118
|
+
export class AnalyticsService {
|
|
119
|
+
repository;
|
|
120
|
+
constructor(repository) {
|
|
121
|
+
this.repository = repository;
|
|
122
|
+
}
|
|
123
|
+
async readSeries(request) {
|
|
124
|
+
const dataset = getDataset(request.dataset);
|
|
125
|
+
const metrics = [...new Set(request.metrics)];
|
|
126
|
+
if (metrics.length === 0 || metrics.length > 12)
|
|
127
|
+
throw new QueryValidationError('read_series requires 1–12 metrics.');
|
|
128
|
+
for (const metric of metrics) {
|
|
129
|
+
const column = getColumn(dataset, metric);
|
|
130
|
+
if (!isNumeric(column))
|
|
131
|
+
throw new QueryValidationError(`${metric} is not a numeric metric in ${dataset.name}.`);
|
|
132
|
+
}
|
|
133
|
+
const requestedResolution = request.resolution ?? 'auto';
|
|
134
|
+
// MCP validates a 1,000-point public page. Internal model fitting can use
|
|
135
|
+
// the documented 5,000-point cap without changing the public tool limit.
|
|
136
|
+
const pageSize = Math.min(Math.max(request.pageSize ?? 1_000, 1), 5_000);
|
|
137
|
+
const { cursor: ignoredCursor, pageSize: ignoredPageSize, ...fingerprintRequest } = request;
|
|
138
|
+
void ignoredCursor;
|
|
139
|
+
void ignoredPageSize;
|
|
140
|
+
const fingerprint = queryHash(fingerprintRequest);
|
|
141
|
+
const cursor = request.cursor ? decodeCursor(request.cursor, fingerprint) : undefined;
|
|
142
|
+
let resolution = cursor?.resolution ?? requestedResolution;
|
|
143
|
+
const relation = await this.repository.datasetRelation(dataset.name);
|
|
144
|
+
const source = sourceRelation(relation);
|
|
145
|
+
const { where, values } = compileFilters(dataset, request.filters, request.startDate, request.endDate, request.activityId);
|
|
146
|
+
if (dataset.name === 'activity_samples' && !request.startDate && !request.endDate && !request.activityId && !request.filters?.some((filter) => filter.column === 'activity_source_id')) {
|
|
147
|
+
throw new QueryValidationError('activity_samples requires a date scope or activity_source_id filter.');
|
|
148
|
+
}
|
|
149
|
+
const date = timestampExpression(dataset);
|
|
150
|
+
const rawSelect = `${date} AS timestamp, ${metrics.map(quoteIdentifier).join(', ')}, ${dataset.provenanceColumns.filter((column) => dataset.columns.some((known) => known.name === column)).map(quoteIdentifier).join(', ')}`;
|
|
151
|
+
const order = `${date} ASC, ${dataset.provenanceColumns[0] ? quoteIdentifier(dataset.provenanceColumns[0]) : '1'} ASC`;
|
|
152
|
+
let rawTotalRows;
|
|
153
|
+
if (resolution === 'auto') {
|
|
154
|
+
const probe = await this.repository.rows(`SELECT count(*)::BIGINT AS row_count FROM ${source} AS source ${where}`, values);
|
|
155
|
+
const rowCount = Number(probe[0]?.row_count ?? 0);
|
|
156
|
+
rawTotalRows = rowCount;
|
|
157
|
+
resolution = rowCount > pageSize
|
|
158
|
+
? dataset.name === 'activity_samples' ? '1m'
|
|
159
|
+
: ['daily_metrics', 'daily_health', 'nutrition_days', 'nutrition_items'].includes(dataset.name) ? 'week'
|
|
160
|
+
: 'day'
|
|
161
|
+
: 'raw';
|
|
162
|
+
}
|
|
163
|
+
const offset = cursor?.offset ?? 0;
|
|
164
|
+
let totalRows;
|
|
165
|
+
if (resolution === 'raw') {
|
|
166
|
+
if (rawTotalRows === undefined) {
|
|
167
|
+
const total = await this.repository.rows(`SELECT count(*) AS total_rows FROM ${source} AS source ${where}`, values);
|
|
168
|
+
rawTotalRows = Number(total[0]?.total_rows ?? 0);
|
|
169
|
+
}
|
|
170
|
+
totalRows = rawTotalRows;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const bucket = bucketExpression(date, resolution);
|
|
174
|
+
const provenance = dataset.provenanceColumns.filter((column) => dataset.columns.some((known) => known.name === column));
|
|
175
|
+
const total = await this.repository.rows(`
|
|
176
|
+
SELECT count(*) AS total_rows FROM (
|
|
177
|
+
SELECT ${bucket} AS timestamp
|
|
178
|
+
FROM ${source} AS source ${where}
|
|
179
|
+
GROUP BY ${bucket}, ${provenance.map(quoteIdentifier).join(', ')}
|
|
180
|
+
) AS series_total
|
|
181
|
+
`, values);
|
|
182
|
+
totalRows = Number(total[0]?.total_rows ?? 0);
|
|
183
|
+
}
|
|
184
|
+
values.limit = pageSize + 1;
|
|
185
|
+
values.offset = offset;
|
|
186
|
+
let rows;
|
|
187
|
+
if (resolution === 'raw') {
|
|
188
|
+
rows = await this.repository.rows(`SELECT ${rawSelect} FROM ${source} AS source ${where} ORDER BY ${order} LIMIT $limit OFFSET $offset`, values);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const bucket = bucketExpression(date, resolution);
|
|
192
|
+
const provenance = dataset.provenanceColumns.filter((column) => dataset.columns.some((known) => known.name === column));
|
|
193
|
+
rows = await this.repository.rows(`
|
|
194
|
+
SELECT ${bucket} AS timestamp, ${metrics.map((metric) => `avg(${quoteIdentifier(metric)}) AS ${quoteIdentifier(metric)}`).join(', ')}, ${provenance.map(quoteIdentifier).join(', ')}
|
|
195
|
+
FROM ${source} AS source ${where}
|
|
196
|
+
GROUP BY ${bucket}, ${provenance.map(quoteIdentifier).join(', ')}
|
|
197
|
+
ORDER BY timestamp ASC${provenance.length ? `, ${provenance.map(quoteIdentifier).join(', ')}` : ''}
|
|
198
|
+
LIMIT $limit OFFSET $offset
|
|
199
|
+
`, values);
|
|
200
|
+
}
|
|
201
|
+
const hasMore = rows.length > pageSize;
|
|
202
|
+
if (hasMore)
|
|
203
|
+
rows.pop();
|
|
204
|
+
const returnedRows = rows.length;
|
|
205
|
+
const truncated = offset + returnedRows < totalRows;
|
|
206
|
+
return jsonSafe({
|
|
207
|
+
returnedRows,
|
|
208
|
+
totalRows,
|
|
209
|
+
truncated,
|
|
210
|
+
nextCursor: truncated ? encodeCursor({ hash: fingerprint, offset: offset + returnedRows, resolution }) : undefined,
|
|
211
|
+
data: rows,
|
|
212
|
+
provenance: { dataset: dataset.name, source: dataset.relation ?? 'registered stream_manifest Parquet only', columns: dataset.provenanceColumns },
|
|
213
|
+
query: { dataset: dataset.name, metrics, filters: request.filters ?? [], startDate: request.startDate, endDate: request.endDate, activityId: request.activityId, requestedResolution, resolution, pageSize },
|
|
214
|
+
caveats: [
|
|
215
|
+
...(resolution !== requestedResolution && requestedResolution === 'auto' ? ['Automatically downsampled to stay within the first response page. Use the returned cursor for later pages.'] : []),
|
|
216
|
+
...(truncated ? ['Results are incomplete. Do not draw exhaustive conclusions; retrieve remaining pages with nextCursor or narrow the query.'] : []),
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
async aggregate(request) {
|
|
221
|
+
const dataset = getDataset(request.dataset);
|
|
222
|
+
if (dataset.samplesOnly && !request.filters?.some((filter) => filter.column === 'activity_source_id'))
|
|
223
|
+
throw new QueryValidationError('activity_samples aggregation requires an activity_source_id filter.');
|
|
224
|
+
if (request.metrics.length === 0 || request.metrics.length > 12)
|
|
225
|
+
throw new QueryValidationError('aggregate_data requires 1–12 aggregations.');
|
|
226
|
+
const relation = await this.repository.datasetRelation(dataset.name);
|
|
227
|
+
const source = sourceRelation(relation);
|
|
228
|
+
const { where, values } = compileFilters(dataset, request.filters, request.startDate, request.endDate);
|
|
229
|
+
const dimensions = [...new Set(request.dimensions ?? [])];
|
|
230
|
+
for (const dimension of dimensions) {
|
|
231
|
+
const column = getColumn(dataset, dimension);
|
|
232
|
+
if (!column.groupable)
|
|
233
|
+
throw new QueryValidationError(`${dimension} is not a permitted grouping.`);
|
|
234
|
+
}
|
|
235
|
+
const groups = dimensions.map(quoteIdentifier);
|
|
236
|
+
const selections = [...groups];
|
|
237
|
+
if (request.timeBucket) {
|
|
238
|
+
if (!dataset.dateColumn)
|
|
239
|
+
throw new QueryValidationError(`${dataset.name} cannot be time-bucketed.`);
|
|
240
|
+
const bucket = `date_trunc('${request.timeBucket}', ${timestampExpression(dataset)})`;
|
|
241
|
+
selections.unshift(`${bucket} AS time_bucket`);
|
|
242
|
+
groups.unshift(bucket);
|
|
243
|
+
}
|
|
244
|
+
for (const [index, metric] of request.metrics.entries()) {
|
|
245
|
+
const column = getColumn(dataset, metric.column);
|
|
246
|
+
// A timestamp has no meaningful sum/mean/percentile, but its extrema are
|
|
247
|
+
// both safe and useful (for example, first/last observation dates).
|
|
248
|
+
if (metric.operation !== 'count' && !isNumeric(column) && !((metric.operation === 'min' || metric.operation === 'max') && isTemporal(column))) {
|
|
249
|
+
throw new QueryValidationError(`${metric.column} is not numeric; only min and max support temporal columns.`);
|
|
250
|
+
}
|
|
251
|
+
const outputName = metric.as ?? `${metric.operation}_${metric.column}`;
|
|
252
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(outputName))
|
|
253
|
+
throw new QueryValidationError('Aggregation aliases must be simple identifiers.');
|
|
254
|
+
if (metric.operation === 'percentile') {
|
|
255
|
+
const percentile = metric.percentile ?? 0.5;
|
|
256
|
+
if (!(percentile > 0 && percentile < 1))
|
|
257
|
+
throw new QueryValidationError('percentile must be between 0 and 1.');
|
|
258
|
+
values[`percentile${index}`] = percentile;
|
|
259
|
+
selections.push(`quantile_cont(${quoteIdentifier(metric.column)}, $percentile${index}) AS ${quoteIdentifier(outputName)}`);
|
|
260
|
+
}
|
|
261
|
+
else if (metric.operation === 'count')
|
|
262
|
+
selections.push(`count(${quoteIdentifier(metric.column)}) AS ${quoteIdentifier(outputName)}`);
|
|
263
|
+
else
|
|
264
|
+
selections.push(`${{ sum: 'sum', mean: 'avg', min: 'min', max: 'max' }[metric.operation]}(${quoteIdentifier(metric.column)}) AS ${quoteIdentifier(outputName)}`);
|
|
265
|
+
}
|
|
266
|
+
// ``timeBucket`` produces the public ``time_bucket`` field. Keep ``bucket``
|
|
267
|
+
// as an ordering-only compatibility alias for calls made before that name
|
|
268
|
+
// was standardized.
|
|
269
|
+
const requestedOrderBy = request.orderBy?.column;
|
|
270
|
+
const orderBy = requestedOrderBy === 'bucket' && request.timeBucket ? 'time_bucket' : requestedOrderBy;
|
|
271
|
+
const aliases = selections.map((selection) => selection.match(/AS "?([A-Za-z_][A-Za-z0-9_]*)"?$/i)?.[1]).filter(Boolean);
|
|
272
|
+
if (orderBy && !aliases.includes(orderBy) && !dimensions.includes(orderBy))
|
|
273
|
+
throw new QueryValidationError('orderBy must name a selected dimension, time_bucket, or aggregation alias.');
|
|
274
|
+
values.limit = Math.min(Math.max(request.limit ?? 100, 1), 500);
|
|
275
|
+
const rows = await this.repository.rows(`SELECT ${selections.join(', ')} FROM ${source} AS source ${where}${groups.length ? ` GROUP BY ${groups.join(', ')}` : ''}${orderBy ? ` ORDER BY ${quoteIdentifier(orderBy)} ${request.orderBy?.direction === 'asc' ? 'ASC' : 'DESC'}` : ''} LIMIT $limit`, values);
|
|
276
|
+
return jsonSafe({ data: rows, provenance: { dataset: dataset.name, columns: dataset.provenanceColumns }, query: request, caveats: [] });
|
|
277
|
+
}
|
|
278
|
+
async analyzeSeries(request) {
|
|
279
|
+
const compareMetric = request.compareMetric ?? request.metrics[1];
|
|
280
|
+
const series = await this.readSeries({ ...request, metrics: [...new Set([request.metrics[0], ...(compareMetric ? [compareMetric] : [])])], resolution: request.resolution ?? 'auto', pageSize: 1_000 });
|
|
281
|
+
const rows = series.data;
|
|
282
|
+
const primary = request.metrics[0];
|
|
283
|
+
const values = rows.map((row) => numberOrNull(row[primary]));
|
|
284
|
+
const finite = values.filter((value) => value !== null);
|
|
285
|
+
if (finite.length < 3)
|
|
286
|
+
throw new QueryValidationError('At least three non-null observations are required for analysis.');
|
|
287
|
+
const analysis = request.analysis;
|
|
288
|
+
let result;
|
|
289
|
+
if (analysis === 'rolling_mean' || analysis === 'rolling_median') {
|
|
290
|
+
const window = Math.min(Math.max(request.window ?? 7, 2), 100);
|
|
291
|
+
result = { window, values: rolling(values, window, analysis === 'rolling_mean' ? mean : median) };
|
|
292
|
+
}
|
|
293
|
+
else if (analysis === 'baseline_change') {
|
|
294
|
+
const baseline = mean(finite.slice(0, Math.max(1, Math.floor(finite.length / 3))));
|
|
295
|
+
const recent = mean(finite.slice(-Math.max(1, Math.floor(finite.length / 3))));
|
|
296
|
+
result = { baseline, recent, absoluteChange: recent - baseline, percentChange: baseline === 0 ? null : ((recent - baseline) / Math.abs(baseline)) * 100 };
|
|
297
|
+
}
|
|
298
|
+
else if (analysis === 'z_score') {
|
|
299
|
+
const center = mean(finite);
|
|
300
|
+
const deviation = standardDeviation(finite);
|
|
301
|
+
result = { mean: center, standardDeviation: deviation, values: values.map((value) => value === null || deviation === 0 ? null : (value - center) / deviation) };
|
|
302
|
+
}
|
|
303
|
+
else if (analysis === 'pearson' || analysis === 'spearman') {
|
|
304
|
+
const compare = compareMetric;
|
|
305
|
+
if (!compare)
|
|
306
|
+
throw new QueryValidationError('Correlation requires compareMetric or a second metric.');
|
|
307
|
+
const pairs = rows.map((row) => [numberOrNull(row[primary]), numberOrNull(row[compare])]).filter((pair) => pair[0] !== null && pair[1] !== null);
|
|
308
|
+
if (pairs.length < 3)
|
|
309
|
+
throw new QueryValidationError('At least three paired values are required for correlation.');
|
|
310
|
+
const [x, y] = pairs.reduce((accumulator, pair) => { accumulator[0].push(pair[0]); accumulator[1].push(pair[1]); return accumulator; }, [[], []]);
|
|
311
|
+
result = { compareMetric: compare, correlation: pearson(analysis === 'spearman' ? ranks(x) : x, analysis === 'spearman' ? ranks(y) : y), pairedCount: pairs.length, method: analysis };
|
|
312
|
+
}
|
|
313
|
+
else if (analysis === 'seasonal_comparison') {
|
|
314
|
+
const byMonth = new Map();
|
|
315
|
+
rows.forEach((row, index) => {
|
|
316
|
+
if (values[index] === null || !row.timestamp)
|
|
317
|
+
return;
|
|
318
|
+
const month = timestampText(row.timestamp).slice(5, 7);
|
|
319
|
+
byMonth.set(month, [...(byMonth.get(month) ?? []), values[index]]);
|
|
320
|
+
});
|
|
321
|
+
result = { monthlyMeans: Object.fromEntries([...byMonth.entries()].map(([month, seriesValues]) => [month, mean(seriesValues)])) };
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
const x = rows.map((_, index) => index).filter((index) => values[index] !== null);
|
|
325
|
+
const y = values.filter((value) => value !== null);
|
|
326
|
+
result = analysis === 'linear_trend' ? linearFit(x, y) : theilSen(x, y);
|
|
327
|
+
}
|
|
328
|
+
return jsonSafe({ data: result, provenance: series.provenance, query: { ...series.query, analysis }, caveats: [rows.length >= 1_000 && series.nextCursor ? 'Analysis used the first 1,000 page points; choose a coarser resolution or narrower scope for a complete analysis.' : 'Null metric values were excluded from calculations.'], input: { returnedRows: rows.length, nonNullValues: finite.length, dateCoverage: coverage(rows) } });
|
|
329
|
+
}
|
|
330
|
+
async fitSeriesModel(request) {
|
|
331
|
+
const yMetric = request.yMetric ?? request.metrics[0];
|
|
332
|
+
if (!yMetric)
|
|
333
|
+
throw new QueryValidationError('A y metric is required.');
|
|
334
|
+
const series = await this.readSeries({ ...request, metrics: [...new Set([yMetric, ...(request.xMetric ? [request.xMetric] : [])])], pageSize: 5_000 });
|
|
335
|
+
const rows = series.data;
|
|
336
|
+
if (rows.length > 5_000)
|
|
337
|
+
throw new QueryValidationError('Model input exceeds the 5,000-point cap. Use a coarser resolution or narrower range.');
|
|
338
|
+
const pairs = rows.map((row, index) => [request.xMetric ? numberOrNull(row[request.xMetric]) : index, numberOrNull(row[yMetric])]).filter((pair) => pair[0] !== null && pair[1] !== null);
|
|
339
|
+
if (pairs.length < (request.model === 'polynomial_3' ? 6 : 3))
|
|
340
|
+
throw new QueryValidationError('Insufficient non-null input for the selected model.');
|
|
341
|
+
const x = pairs.map((pair) => pair[0]);
|
|
342
|
+
const y = pairs.map((pair) => pair[1]);
|
|
343
|
+
if (new Set(x).size < 2 || new Set(y).size < 2)
|
|
344
|
+
throw new QueryValidationError('Model input cannot be constant.');
|
|
345
|
+
const fit = request.model === 'ols_linear' ? linearFit(x, y) : request.model === 'theil_sen_linear' ? theilSen(x, y) : polynomialFit(x, y, request.model === 'polynomial_2' ? 2 : 3);
|
|
346
|
+
const predictions = x.map((item) => predict(fit.coefficients, item));
|
|
347
|
+
const residuals = y.map((item, index) => item - predictions[index]);
|
|
348
|
+
return jsonSafe({ data: { model: request.model, xMetric: request.xMetric ?? 'observation_index', yMetric, coefficients: fit.coefficients, rSquared: rSquared(y, predictions), residuals: { mean: mean(residuals), standardDeviation: standardDeviation(residuals), min: Math.min(...residuals), max: Math.max(...residuals) } }, provenance: series.provenance, query: series.query, caveats: ['Descriptive fit only; it is not a physiological or sport-performance model.', 'Rows with null x or y values were excluded.', ...(series.nextCursor ? ['Input was capped at the first 5,000 points after the declared resolution.'] : [])], input: { selectedRows: rows.length, fittedRows: pairs.length, excludedRows: rows.length - pairs.length, cappedRows: series.nextCursor ? 'at least 1 additional page' : 0, dateCoverage: coverage(rows) } });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function numberOrNull(value) { return typeof value === 'number' && Number.isFinite(value) ? value : null; }
|
|
352
|
+
function timestampText(value) { return value instanceof Date ? value.toISOString() : String(value); }
|
|
353
|
+
function mean(values) { return values.reduce((sum, value) => sum + value, 0) / values.length; }
|
|
354
|
+
function median(values) { const sorted = [...values].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; }
|
|
355
|
+
function standardDeviation(values) { const center = mean(values); return Math.sqrt(values.reduce((sum, value) => sum + (value - center) ** 2, 0) / values.length); }
|
|
356
|
+
function rolling(values, window, fn) { return values.map((_, index) => { const chunk = values.slice(Math.max(0, index - window + 1), index + 1).filter((item) => item !== null); return chunk.length ? fn(chunk) : null; }); }
|
|
357
|
+
function pearson(x, y) { const sx = standardDeviation(x); const sy = standardDeviation(y); if (sx === 0 || sy === 0)
|
|
358
|
+
return null; const mx = mean(x); const my = mean(y); return x.reduce((sum, value, index) => sum + (value - mx) * (y[index] - my), 0) / (x.length * sx * sy); }
|
|
359
|
+
function ranks(values) { const ordered = values.map((value, index) => ({ value, index })).sort((a, b) => a.value - b.value); const output = new Array(values.length); let index = 0; while (index < ordered.length) {
|
|
360
|
+
let end = index;
|
|
361
|
+
while (end + 1 < ordered.length && ordered[end + 1].value === ordered[index].value)
|
|
362
|
+
end++;
|
|
363
|
+
const rank = (index + end + 2) / 2;
|
|
364
|
+
for (let cursor = index; cursor <= end; cursor++)
|
|
365
|
+
output[ordered[cursor].index] = rank;
|
|
366
|
+
index = end + 1;
|
|
367
|
+
} return output; }
|
|
368
|
+
function linearFit(x, y) { const mx = mean(x); const my = mean(y); const denominator = x.reduce((sum, value) => sum + (value - mx) ** 2, 0); if (denominator === 0)
|
|
369
|
+
throw new QueryValidationError('x values are constant.'); const slope = x.reduce((sum, value, index) => sum + (value - mx) * (y[index] - my), 0) / denominator; const intercept = my - slope * mx; return { coefficients: [intercept, slope], slope, intercept }; }
|
|
370
|
+
function theilSen(x, y) { const slopes = []; for (let left = 0; left < x.length; left++)
|
|
371
|
+
for (let right = left + 1; right < x.length; right++)
|
|
372
|
+
if (x[right] !== x[left])
|
|
373
|
+
slopes.push((y[right] - y[left]) / (x[right] - x[left])); const slope = median(slopes); const intercept = median(x.map((value, index) => y[index] - slope * value)); return { coefficients: [intercept, slope], slope, intercept }; }
|
|
374
|
+
function polynomialFit(x, y, degree) { const size = degree + 1; const matrix = Array.from({ length: size }, (_, row) => Array.from({ length: size + 1 }, (_, column) => column === size ? x.reduce((sum, value, index) => sum + y[index] * value ** row, 0) : x.reduce((sum, value) => sum + value ** (row + column), 0))); for (let pivot = 0; pivot < size; pivot++) {
|
|
375
|
+
let best = pivot;
|
|
376
|
+
for (let row = pivot + 1; row < size; row++)
|
|
377
|
+
if (Math.abs(matrix[row][pivot]) > Math.abs(matrix[best][pivot]))
|
|
378
|
+
best = row;
|
|
379
|
+
[matrix[pivot], matrix[best]] = [matrix[best], matrix[pivot]];
|
|
380
|
+
if (Math.abs(matrix[pivot][pivot]) < 1e-12)
|
|
381
|
+
throw new QueryValidationError('Polynomial fit is singular.');
|
|
382
|
+
const scale = matrix[pivot][pivot];
|
|
383
|
+
for (let column = pivot; column <= size; column++)
|
|
384
|
+
matrix[pivot][column] /= scale;
|
|
385
|
+
for (let row = 0; row < size; row++)
|
|
386
|
+
if (row !== pivot) {
|
|
387
|
+
const factor = matrix[row][pivot];
|
|
388
|
+
for (let column = pivot; column <= size; column++)
|
|
389
|
+
matrix[row][column] -= factor * matrix[pivot][column];
|
|
390
|
+
}
|
|
391
|
+
} return { coefficients: matrix.map((row) => row[size]) }; }
|
|
392
|
+
function predict(coefficients, x) { return coefficients.reduce((sum, coefficient, index) => sum + coefficient * x ** index, 0); }
|
|
393
|
+
function rSquared(actual, predicted) { const total = actual.reduce((sum, value) => sum + (value - mean(actual)) ** 2, 0); return total === 0 ? null : 1 - actual.reduce((sum, value, index) => sum + (value - predicted[index]) ** 2, 0) / total; }
|
|
394
|
+
function coverage(rows) { return { start: rows[0]?.timestamp ?? null, end: rows.at(-1)?.timestamp ?? null }; }
|
|
395
|
+
//# sourceMappingURL=analytics.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type DatasetName = 'activities' | 'canonical_activities' | 'canonical_activity_facts' | 'activity_sources' | 'activity_summaries' | 'activity_intervals' | 'swim_lengths' | 'swim_sets' | 'activity_quality_flags' | 'daily_metrics' | 'daily_health' | 'training_metrics' | 'training_metric_observations' | 'wellness_samples' | 'health_sessions' | 'power_bests' | 'nutrition_days' | 'nutrition_items' | 'events' | 'workouts' | 'workout_documents' | 'training_plans' | 'routes' | 'gear' | 'devices' | 'goals' | 'achievements' | 'messages' | 'source_entities' | 'activity_samples' | 'activity_links' | 'activity_segments' | 'segments' | 'segment_effort_history' | 'strava_gear';
|
|
2
|
+
export type CatalogColumn = {
|
|
3
|
+
name: string;
|
|
4
|
+
type: string;
|
|
5
|
+
unit?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
filterable?: boolean;
|
|
8
|
+
groupable?: boolean;
|
|
9
|
+
metric?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type DatasetDefinition = {
|
|
12
|
+
name: DatasetName;
|
|
13
|
+
relation?: string;
|
|
14
|
+
description: string;
|
|
15
|
+
dateColumn?: string;
|
|
16
|
+
provenanceColumns: string[];
|
|
17
|
+
columns: CatalogColumn[];
|
|
18
|
+
permittedGroupings: string[];
|
|
19
|
+
samplesOnly?: boolean;
|
|
20
|
+
};
|
|
21
|
+
export declare const DATASET_CATALOG: Record<Exclude<DatasetName, 'training_metrics'>, DatasetDefinition>;
|
|
22
|
+
export declare function getDataset(name: string): DatasetDefinition;
|
|
23
|
+
export declare function getColumn(dataset: DatasetDefinition, name: string): CatalogColumn;
|
|
24
|
+
export declare class QueryValidationError extends Error {
|
|
25
|
+
constructor(message: string);
|
|
26
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const column = (name, type, options = {}) => ({ name, type, ...options });
|
|
2
|
+
export const DATASET_CATALOG = {
|
|
3
|
+
activities: {
|
|
4
|
+
name: 'activities', relation: 'activities', description: 'Logical activities linked by provider IDs, unique high-confidence fuzzy evidence, or an explicit manual correction.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_id', 'link_state'], permittedGroupings: ['sport', 'link_state'],
|
|
5
|
+
columns: [column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('started_at_local', 'VARCHAR'), column('timezone', 'VARCHAR'), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR', { filterable: true }), column('link_state', 'VARCHAR', { filterable: true, groupable: true })],
|
|
6
|
+
},
|
|
7
|
+
canonical_activities: {
|
|
8
|
+
name: 'canonical_activities', relation: 'canonical_activity_training', description: 'One training interpretation per linked activity; Garmin is canonical when present, while Intervals analysis values remain separately provenance-tagged.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_id', 'activity_source_id', 'provider'], permittedGroupings: ['provider', 'sport'],
|
|
9
|
+
columns: [column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR'), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('elevation_gain_m', 'DOUBLE', { unit: 'm', metric: true }), column('calories', 'DOUBLE', { unit: 'kcal', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('weighted_power', 'DOUBLE', { unit: 'W', metric: true }), column('training_load', 'DOUBLE', { metric: true }), column('rpe', 'DOUBLE', { metric: true }), column('feel', 'DOUBLE', { metric: true })],
|
|
10
|
+
},
|
|
11
|
+
canonical_activity_facts: {
|
|
12
|
+
name: 'canonical_activity_facts', relation: 'canonical_activity_facts', description: 'Field-level canonical activity values. Garmin is preferred when available; the original Garmin and Intervals values, selected source, disagreement, and quality flags remain visible.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_id', 'garmin_activity_source_id', 'intervals_activity_source_id', 'distance_source', 'quality_flags'], permittedGroupings: ['sport', 'link_state', 'distance_source'],
|
|
13
|
+
columns: [column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR'), column('link_state', 'VARCHAR', { filterable: true, groupable: true }), column('garmin_activity_source_id', 'VARCHAR', { filterable: true }), column('intervals_activity_source_id', 'VARCHAR', { filterable: true }), column('garmin_distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('intervals_distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('resolved_distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('distance_source', 'VARCHAR', { filterable: true, groupable: true }), column('garmin_moving_s', 'DOUBLE', { unit: 's', metric: true }), column('intervals_moving_s', 'DOUBLE', { unit: 's', metric: true }), column('resolved_moving_s', 'DOUBLE', { unit: 's', metric: true }), column('moving_s_source', 'VARCHAR', { filterable: true, groupable: true }), column('garmin_elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('intervals_elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('resolved_elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('elapsed_s_source', 'VARCHAR', { filterable: true, groupable: true }), column('garmin_avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('intervals_avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('resolved_avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_hr_source', 'VARCHAR', { filterable: true, groupable: true }), column('provider_distance_difference_m', 'DOUBLE', { unit: 'm', metric: true }), column('quality_flags', 'JSON')],
|
|
14
|
+
},
|
|
15
|
+
activity_sources: {
|
|
16
|
+
name: 'activity_sources', relation: 'activity_sources', description: 'Provider-specific activity identities and source artifact references.', provenanceColumns: ['provider', 'remote_activity_id', 'raw_object_hash'], permittedGroupings: ['provider'],
|
|
17
|
+
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('remote_activity_id', 'VARCHAR', { filterable: true }), column('external_id', 'VARCHAR', { filterable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
18
|
+
},
|
|
19
|
+
activity_summaries: {
|
|
20
|
+
name: 'activity_summaries', relation: 'activity_summary_facts', description: 'Provider activity summary metrics with activity and timestamp provenance.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'activity_id', 'provider', 'raw_object_hash'], permittedGroupings: ['provider', 'sport'],
|
|
21
|
+
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR', { filterable: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('elevation_gain_m', 'DOUBLE', { unit: 'm', metric: true }), column('calories', 'DOUBLE', { unit: 'kcal', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('weighted_power', 'DOUBLE', { unit: 'W', metric: true }), column('avg_cadence', 'DOUBLE', { unit: 'rpm', metric: true }), column('training_load', 'DOUBLE', { metric: true }), column('rpe', 'DOUBLE', { metric: true }), column('feel', 'DOUBLE', { metric: true })],
|
|
22
|
+
},
|
|
23
|
+
activity_intervals: {
|
|
24
|
+
name: 'activity_intervals', relation: 'activity_interval_facts', description: 'Structured intervals attached to an activity source.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'activity_id', 'provider'], permittedGroupings: ['provider', 'sport', 'label'],
|
|
25
|
+
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('interval_key', 'VARCHAR', { filterable: true }), column('label', 'VARCHAR', { filterable: true, groupable: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('start_s', 'DOUBLE', { unit: 's', metric: true }), column('end_s', 'DOUBLE', { unit: 's', metric: true }), column('duration_s', 'DOUBLE', { unit: 's', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_pace', 'DOUBLE', { metric: true }), column('intensity', 'DOUBLE', { metric: true })],
|
|
26
|
+
},
|
|
27
|
+
swim_lengths: {
|
|
28
|
+
name: 'swim_lengths', relation: 'swim_length_facts', description: 'Explicit provider-supplied swim-length facts. An empty result means lengths were not supplied; Catence does not infer them from activity samples.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'source', 'confidence', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'source', 'confidence', 'is_rest'],
|
|
29
|
+
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('source', 'VARCHAR', { filterable: true, groupable: true }), column('confidence', 'VARCHAR', { filterable: true, groupable: true }), column('length_index', 'INTEGER', { filterable: true, groupable: true }), column('lap_index', 'INTEGER', { filterable: true, groupable: true }), column('pool_length_m', 'DOUBLE', { unit: 'm', metric: true }), column('start_time', 'TIMESTAMPTZ', { filterable: true }), column('duration_s', 'DOUBLE', { unit: 's', metric: true }), column('active_duration_s', 'DOUBLE', { unit: 's', metric: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('stroke_count', 'DOUBLE', { unit: 'count', metric: true }), column('stroke_rate', 'DOUBLE', { unit: 'strokes/min', metric: true }), column('swolf', 'DOUBLE', { metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('is_rest', 'BOOLEAN', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
30
|
+
},
|
|
31
|
+
swim_sets: {
|
|
32
|
+
name: 'swim_sets', relation: 'swim_set_facts', description: 'Provider swim groupings. source_type distinguishes Garmin detected/manual groupings from Intervals.icu auto-detected blocks; labels and unprovided durations remain explicit.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'source_type', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'source_type', 'label'],
|
|
33
|
+
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('set_index', 'INTEGER', { filterable: true, groupable: true }), column('label', 'VARCHAR', { filterable: true, groupable: true }), column('reps', 'INTEGER', { metric: true }), column('rep_distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('total_distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('work_s', 'DOUBLE', { unit: 's', metric: true }), column('rest_s', 'DOUBLE', { unit: 's', metric: true }), column('avg_pace', 'DOUBLE', { metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('stroke_rate', 'DOUBLE', { unit: 'strokes/min', metric: true }), column('raw_object_hash', 'VARCHAR')],
|
|
34
|
+
},
|
|
35
|
+
activity_quality_flags: {
|
|
36
|
+
name: 'activity_quality_flags', relation: 'activity_quality_flag_facts', description: 'Ingestion-time quality and completeness flags. They flag caveats without deleting or overwriting provider source values.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'flag_code', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'flag_code', 'severity'],
|
|
37
|
+
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('flag_code', 'VARCHAR', { filterable: true, groupable: true }), column('severity', 'VARCHAR', { filterable: true, groupable: true }), column('details_json', 'JSON'), column('raw_object_hash', 'VARCHAR')],
|
|
38
|
+
},
|
|
39
|
+
daily_metrics: {
|
|
40
|
+
name: 'daily_metrics', relation: 'daily_metrics', description: 'Long-form provider daily facts. Metric names and units are explicit.', dateColumn: 'metric_date', provenanceColumns: ['provider', 'raw_object_hash'], permittedGroupings: ['provider', 'metric_name'],
|
|
41
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('metric_date', 'DATE', { filterable: true, groupable: true }), column('metric_name', 'VARCHAR', { filterable: true, groupable: true }), column('value_number', 'DOUBLE', { metric: true }), column('value_text', 'VARCHAR'), column('unit', 'VARCHAR', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
42
|
+
},
|
|
43
|
+
daily_health: {
|
|
44
|
+
name: 'daily_health', relation: 'daily_health', description: 'One canonical daily-health record per date. Garmin wins whenever it has facts for that date; Intervals or another provider is a whole-day fallback. Use daily_metrics to compare source observations.', dateColumn: 'metric_date', provenanceColumns: ['provider'], permittedGroupings: ['provider'],
|
|
45
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('metric_date', 'DATE', { filterable: true, groupable: true }), column('resting_hr_bpm', 'DOUBLE', { unit: 'bpm', metric: true }), column('hrv_ms', 'DOUBLE', { unit: 'ms', metric: true }), column('sleep_seconds', 'DOUBLE', { unit: 's', metric: true }), column('sleep_score', 'DOUBLE', { metric: true }), column('stress', 'DOUBLE', { metric: true }), column('body_battery', 'DOUBLE', { metric: true }), column('readiness', 'DOUBLE', { metric: true }), column('spo2_pct', 'DOUBLE', { unit: '%', metric: true }), column('weight_kg', 'DOUBLE', { unit: 'kg', metric: true }), column('steps', 'DOUBLE', { unit: 'count', metric: true })],
|
|
46
|
+
},
|
|
47
|
+
training_metric_observations: {
|
|
48
|
+
name: 'training_metric_observations', relation: 'training_metric_observations', description: 'Provider training-metric observations. Garmin retains cycling FTP history/settings/activity values and its latest running lactate-threshold power, pace, and heart-rate settings with provenance. Garmin may label its running VO₂max estimate as generic.', dateColumn: 'observed_at', provenanceColumns: ['provider', 'source_type', 'source_remote_id', 'activity_source_id', 'raw_object_hash'], permittedGroupings: ['provider', 'metric_name', 'sport', 'source_type'],
|
|
49
|
+
columns: [column('observation_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('metric_name', 'VARCHAR', { filterable: true, groupable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('observed_at', 'TIMESTAMPTZ', { filterable: true }), column('value_number', 'DOUBLE', { metric: true }), column('value_text', 'VARCHAR', { filterable: true, groupable: true }), column('unit', 'VARCHAR', { filterable: true, groupable: true }), column('device_id', 'VARCHAR', { filterable: true, groupable: true }), column('dimensions_json', 'JSON'), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('source_remote_id', 'VARCHAR', { filterable: true }), column('activity_source_id', 'VARCHAR', { filterable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
50
|
+
},
|
|
51
|
+
wellness_samples: {
|
|
52
|
+
name: 'wellness_samples', relation: 'wellness_samples', description: 'Timestamped Garmin health-series samples for heart rate, HRV, stress, and Body Battery.', dateColumn: 'observed_at', provenanceColumns: ['provider', 'source_type', 'source_remote_id', 'raw_object_hash'], permittedGroupings: ['provider', 'metric_name', 'source_type'],
|
|
53
|
+
columns: [column('sample_id', 'VARCHAR', { filterable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('metric_name', 'VARCHAR', { filterable: true, groupable: true }), column('observed_at', 'TIMESTAMPTZ', { filterable: true }), column('value_number', 'DOUBLE', { metric: true }), column('unit', 'VARCHAR', { filterable: true, groupable: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('source_remote_id', 'VARCHAR', { filterable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
54
|
+
},
|
|
55
|
+
health_sessions: {
|
|
56
|
+
name: 'health_sessions', relation: 'health_sessions', description: 'Garmin sleep, nap, daily-event, and Body Battery event records with their source payload retained.', dateColumn: 'occurred_on', provenanceColumns: ['provider', 'source_type', 'source_remote_id', 'raw_object_hash'], permittedGroupings: ['provider', 'session_type', 'source_type'],
|
|
57
|
+
columns: [column('session_id', 'VARCHAR', { filterable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('session_type', 'VARCHAR', { filterable: true, groupable: true }), column('occurred_on', 'DATE', { filterable: true, groupable: true }), column('started_at', 'TIMESTAMPTZ', { filterable: true }), column('ended_at', 'TIMESTAMPTZ', { filterable: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('source_remote_id', 'VARCHAR', { filterable: true }), column('payload_json', 'JSON'), column('raw_object_hash', 'VARCHAR')],
|
|
58
|
+
},
|
|
59
|
+
power_bests: {
|
|
60
|
+
name: 'power_bests', relation: 'power_best_facts', description: 'Power-duration bests derived from Garmin FIT streams; they are labelled derived while retaining the Garmin activity provenance.', dateColumn: 'started_at_utc', provenanceColumns: ['provider', 'activity_source_id', 'source_type', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'duration_s', 'source_type'],
|
|
61
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('duration_s', 'INTEGER', { filterable: true, groupable: true }), column('best_power_w', 'DOUBLE', { unit: 'W', metric: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
62
|
+
},
|
|
63
|
+
nutrition_days: {
|
|
64
|
+
name: 'nutrition_days', relation: 'nutrition_days', description: 'Daily nutrition totals, never a replacement for the item-level food log.', dateColumn: 'nutrition_date', provenanceColumns: ['provider', 'raw_object_hash'], permittedGroupings: ['provider'],
|
|
65
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('nutrition_date', 'DATE', { filterable: true, groupable: true }), column('energy_kcal', 'DOUBLE', { unit: 'kcal', metric: true }), column('carbohydrates_g', 'DOUBLE', { unit: 'g', metric: true }), column('protein_g', 'DOUBLE', { unit: 'g', metric: true }), column('fat_g', 'DOUBLE', { unit: 'g', metric: true }), column('hydration_ml', 'DOUBLE', { unit: 'ml', metric: true }), column('raw_object_hash', 'VARCHAR')],
|
|
66
|
+
},
|
|
67
|
+
nutrition_items: {
|
|
68
|
+
name: 'nutrition_items', relation: 'nutrition_items', description: 'Meal and food-item detail retained from the provider.', dateColumn: 'nutrition_date', provenanceColumns: ['provider', 'remote_item_id', 'raw_object_hash'], permittedGroupings: ['provider', 'meal', 'food_name'],
|
|
69
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('remote_item_id', 'VARCHAR', { filterable: true }), column('nutrition_date', 'DATE', { filterable: true, groupable: true }), column('meal', 'VARCHAR', { filterable: true, groupable: true }), column('consumed_at', 'TIMESTAMPTZ', { filterable: true }), column('food_name', 'VARCHAR', { filterable: true, groupable: true }), column('quantity', 'DOUBLE', { metric: true }), column('energy_kcal', 'DOUBLE', { unit: 'kcal', metric: true }), column('carbohydrates_g', 'DOUBLE', { unit: 'g', metric: true }), column('protein_g', 'DOUBLE', { unit: 'g', metric: true }), column('fat_g', 'DOUBLE', { unit: 'g', metric: true }), column('raw_object_hash', 'VARCHAR')],
|
|
70
|
+
},
|
|
71
|
+
events: domain('events', 'event_id', 'Planned and completed calendar events.', 'occurred_on'),
|
|
72
|
+
workouts: domain('workouts', 'workout_id', 'Provider workouts and scheduled workouts.', 'occurred_on'),
|
|
73
|
+
workout_documents: {
|
|
74
|
+
name: 'workout_documents', relation: 'workout_documents', description: 'Structured workout documents.', provenanceColumns: ['provider', 'workout_id'], permittedGroupings: ['provider'],
|
|
75
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('workout_id', 'VARCHAR', { filterable: true, groupable: true }), column('workout_doc', 'JSON'), column('payload_json', 'JSON')],
|
|
76
|
+
},
|
|
77
|
+
training_plans: domain('training_plans', 'training_plan_id', 'Training plans and plan hierarchy.', 'occurred_on'),
|
|
78
|
+
routes: domain('routes', 'route_id', 'Saved routes.'),
|
|
79
|
+
gear: domain('gear', 'gear_id', 'Equipment and gear records.'),
|
|
80
|
+
devices: domain('devices', 'device_id', 'Device metadata and ownership details.'),
|
|
81
|
+
goals: domain('goals', 'goal_id', 'Goals and challenges.'),
|
|
82
|
+
achievements: domain('achievements', 'achievement_id', 'Badges and personal records.'),
|
|
83
|
+
messages: domain('messages', 'message_id', 'Provider chat/messages.', 'occurred_on'),
|
|
84
|
+
source_entities: {
|
|
85
|
+
name: 'source_entities', relation: 'source_entities', description: 'All raw-normalized source entities, including fields not modelled elsewhere.', dateColumn: 'occurred_on', provenanceColumns: ['provider', 'entity_type', 'remote_id', 'raw_object_hash'], permittedGroupings: ['provider', 'entity_type'],
|
|
86
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('entity_type', 'VARCHAR', { filterable: true, groupable: true }), column('remote_id', 'VARCHAR', { filterable: true }), column('parent_remote_id', 'VARCHAR', { filterable: true }), column('occurred_on', 'DATE', { filterable: true, groupable: true }), column('source_updated_at', 'TIMESTAMPTZ', { filterable: true }), column('raw_object_hash', 'VARCHAR'), column('payload_json', 'JSON'), column('extension_json', 'JSON')],
|
|
87
|
+
},
|
|
88
|
+
activity_links: {
|
|
89
|
+
name: 'activity_links', relation: 'activity_links', description: 'Auditable source-to-logical activity links and their evidence.', provenanceColumns: ['activity_source_id', 'method', 'evidence_json'], permittedGroupings: ['method'],
|
|
90
|
+
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('method', 'VARCHAR', { filterable: true, groupable: true }), column('confidence', 'DOUBLE', { metric: true }), column('evidence_json', 'JSON'), column('linked_at', 'TIMESTAMPTZ', { filterable: true })],
|
|
91
|
+
},
|
|
92
|
+
activity_segments: {
|
|
93
|
+
name: 'activity_segments', relation: 'activity_segments', description: 'Strava segment efforts embedded in a hydrated activity; absent values are not inferred.', dateColumn: 'started_at', provenanceColumns: ['activity_source_id', 'effort_id', 'segment_id', 'raw_object_hash'], permittedGroupings: ['segment_id'],
|
|
94
|
+
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('effort_id', 'VARCHAR', { filterable: true, groupable: true }), column('segment_id', 'VARCHAR', { filterable: true, groupable: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('average_watts', 'DOUBLE', { unit: 'W', metric: true }), column('average_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('average_cadence', 'DOUBLE', { unit: 'rpm', metric: true }), column('pr_rank', 'INTEGER', { metric: true }), column('kom_rank', 'INTEGER', { metric: true }), column('started_at', 'TIMESTAMPTZ', { filterable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
95
|
+
},
|
|
96
|
+
segments: {
|
|
97
|
+
name: 'segments', relation: 'strava_segments', description: 'Hydrated Strava segment facts. Verification is unavailable from the public API and is never inferred.', provenanceColumns: ['segment_id', 'raw_object_hash'], permittedGroupings: ['activity_type', 'climb_category', 'starred', 'private'],
|
|
98
|
+
columns: [column('segment_id', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR', { filterable: true, groupable: true }), column('activity_type', 'VARCHAR', { filterable: true, groupable: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('average_grade_pct', 'DOUBLE', { unit: '%', metric: true }), column('maximum_grade_pct', 'DOUBLE', { unit: '%', metric: true }), column('climb_category', 'INTEGER', { filterable: true, groupable: true }), column('total_elevation_gain_m', 'DOUBLE', { unit: 'm', metric: true }), column('private', 'BOOLEAN', { filterable: true, groupable: true }), column('hazardous', 'BOOLEAN', { filterable: true, groupable: true }), column('starred', 'BOOLEAN', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
99
|
+
},
|
|
100
|
+
segment_effort_history: {
|
|
101
|
+
name: 'segment_effort_history', relation: 'segment_effort_history', description: 'Authenticated athlete historic Strava segment efforts, hydrated only on demand.', dateColumn: 'started_at', provenanceColumns: ['effort_id', 'segment_id', 'raw_object_hash'], permittedGroupings: ['segment_id', 'climb_category'],
|
|
102
|
+
columns: [column('effort_id', 'VARCHAR', { filterable: true, groupable: true }), column('segment_id', 'VARCHAR', { filterable: true, groupable: true }), column('strava_activity_id', 'VARCHAR', { filterable: true }), column('segment_name', 'VARCHAR', { filterable: true, groupable: true }), column('average_grade_pct', 'DOUBLE', { unit: '%', metric: true }), column('climb_category', 'INTEGER', { filterable: true, groupable: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('average_speed_mps', 'DOUBLE', { unit: 'm/s', metric: true }), column('average_watts', 'DOUBLE', { unit: 'W', metric: true }), column('average_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('average_cadence', 'DOUBLE', { unit: 'rpm', metric: true }), column('pr_rank', 'INTEGER', { metric: true }), column('kom_rank', 'INTEGER', { metric: true }), column('started_at', 'TIMESTAMPTZ', { filterable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
103
|
+
},
|
|
104
|
+
strava_gear: {
|
|
105
|
+
name: 'strava_gear', relation: 'strava_gear', description: 'Current Strava bikes and shoes, refreshed from the authenticated athlete profile.', provenanceColumns: ['gear_id', 'raw_object_hash'], permittedGroupings: ['provider'],
|
|
106
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('gear_id', 'VARCHAR', { filterable: true, groupable: true }), column('payload_json', 'JSON'), column('extension_json', 'JSON'), column('raw_object_hash', 'VARCHAR')],
|
|
107
|
+
},
|
|
108
|
+
activity_samples: {
|
|
109
|
+
name: 'activity_samples', description: 'Registered normalized activity samples from immutable Parquet; callers cannot supply file paths.', dateColumn: 'timestamp_utc', provenanceColumns: ['provider', 'activity_source_id', 'content_hash'], permittedGroupings: ['provider', 'activity_source_id'], samplesOnly: true,
|
|
110
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('content_hash', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('timestamp_utc', 'TIMESTAMPTZ', { filterable: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('latitude', 'DOUBLE', { unit: 'degrees', metric: true }), column('longitude', 'DOUBLE', { unit: 'degrees', metric: true }), column('altitude_m', 'DOUBLE', { unit: 'm', metric: true }), column('heart_rate_bpm', 'DOUBLE', { unit: 'bpm', metric: true }), column('power_w', 'DOUBLE', { unit: 'W', metric: true }), column('cadence_rpm', 'DOUBLE', { unit: 'rpm', metric: true }), column('speed_mps', 'DOUBLE', { unit: 'm/s', metric: true }), column('temperature_c', 'DOUBLE', { unit: 'C', metric: true }), column('grade_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_right_balance_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_torque_effectiveness_pct', 'DOUBLE', { unit: '%', metric: true }), column('right_torque_effectiveness_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_pedal_smoothness_pct', 'DOUBLE', { unit: '%', metric: true }), column('right_pedal_smoothness_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_power_phase_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_power_phase_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_power_phase_peak_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_power_phase_peak_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_peak_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_peak_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_platform_center_offset_mm', 'DOUBLE', { unit: 'mm', metric: true }), column('right_platform_center_offset_mm', 'DOUBLE', { unit: 'mm', metric: true }), column('extras_json', 'JSON')],
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
function domain(name, id, description, dateColumn) {
|
|
114
|
+
return {
|
|
115
|
+
name, relation: name, description, dateColumn, provenanceColumns: ['provider', id, 'raw_object_hash'], permittedGroupings: ['provider'],
|
|
116
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column(id, 'VARCHAR', { filterable: true, groupable: true }), ...(dateColumn ? [column(dateColumn, 'DATE', { filterable: true, groupable: true })] : []), column('payload_json', 'JSON'), column('extension_json', 'JSON'), column('raw_object_hash', 'VARCHAR')],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export function getDataset(name) {
|
|
120
|
+
// training_metrics was the original public catalog name. Keep it as a
|
|
121
|
+
// backwards-compatible input alias, while presenting the actual queryable
|
|
122
|
+
// relation everywhere so describe_dataset and the SQL allow-list agree.
|
|
123
|
+
const normalizedName = name === 'training_metrics' ? 'training_metric_observations' : name;
|
|
124
|
+
const dataset = DATASET_CATALOG[normalizedName];
|
|
125
|
+
if (!dataset)
|
|
126
|
+
throw new QueryValidationError(`Unknown dataset: ${name}. Use describe_data to see the catalog.`);
|
|
127
|
+
return dataset;
|
|
128
|
+
}
|
|
129
|
+
export function getColumn(dataset, name) {
|
|
130
|
+
const found = dataset.columns.find((columnDefinition) => columnDefinition.name === name);
|
|
131
|
+
if (!found)
|
|
132
|
+
throw new QueryValidationError(`${name} is not a cataloged column of ${dataset.name}.`);
|
|
133
|
+
return found;
|
|
134
|
+
}
|
|
135
|
+
export class QueryValidationError extends Error {
|
|
136
|
+
constructor(message) {
|
|
137
|
+
super(message);
|
|
138
|
+
this.name = 'QueryValidationError';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=catalog.js.map
|