catence 0.0.0 → 0.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +201 -29
  3. package/config.example.json +63 -0
  4. package/dist/contracts/index.d.ts +4 -0
  5. package/dist/contracts/index.js +5 -0
  6. package/dist/contracts/release.d.ts +20 -0
  7. package/dist/contracts/release.js +17 -0
  8. package/dist/contracts/runtime.d.ts +11 -0
  9. package/dist/contracts/runtime.js +2 -0
  10. package/dist/contracts/staging.d.ts +183 -0
  11. package/dist/contracts/staging.js +36 -0
  12. package/dist/contracts/storage.d.ts +15 -0
  13. package/dist/contracts/storage.js +2 -0
  14. package/dist/core/dashboard/snapshot.d.ts +16 -0
  15. package/dist/core/dashboard/snapshot.js +65 -0
  16. package/dist/core/query/activity-discovery.d.ts +21 -0
  17. package/dist/core/query/activity-discovery.js +133 -0
  18. package/dist/core/query/analytics.d.ts +59 -0
  19. package/dist/core/query/analytics.js +395 -0
  20. package/dist/core/query/catalog.d.ts +26 -0
  21. package/dist/core/query/catalog.js +141 -0
  22. package/dist/core/query/fitness.d.ts +40 -0
  23. package/dist/core/query/fitness.js +297 -0
  24. package/dist/core/query/repository.d.ts +19 -0
  25. package/dist/core/query/repository.js +182 -0
  26. package/dist/core/query/sql-guard.d.ts +7 -0
  27. package/dist/core/query/sql-guard.js +121 -0
  28. package/dist/core/query/sql.d.ts +2 -0
  29. package/dist/core/query/sql.js +5 -0
  30. package/dist/core/query/swimming.d.ts +19 -0
  31. package/dist/core/query/swimming.js +184 -0
  32. package/dist/core/query/wellness.d.ts +45 -0
  33. package/dist/core/query/wellness.js +292 -0
  34. package/dist/core/retrieval/index.d.ts +13 -0
  35. package/dist/core/retrieval/index.js +248 -0
  36. package/dist/core/runtime/catalog.d.ts +41 -0
  37. package/dist/core/runtime/catalog.js +111 -0
  38. package/dist/core/runtime/configuration.d.ts +71 -0
  39. package/dist/core/runtime/configuration.js +106 -0
  40. package/dist/core/runtime/limiter.d.ts +13 -0
  41. package/dist/core/runtime/limiter.js +17 -0
  42. package/dist/core/runtime/secrets.d.ts +24 -0
  43. package/dist/core/runtime/secrets.js +68 -0
  44. package/dist/elt/application/demo.d.ts +23 -0
  45. package/dist/elt/application/demo.js +165 -0
  46. package/dist/elt/application/management.d.ts +25 -0
  47. package/dist/elt/application/management.js +285 -0
  48. package/dist/elt/ingestion/importer.d.ts +8 -0
  49. package/dist/elt/ingestion/importer.js +87 -0
  50. package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
  51. package/dist/elt/ingestion/providers/intervals.js +224 -0
  52. package/dist/elt/ingestion/providers/registry.d.ts +19 -0
  53. package/dist/elt/ingestion/providers/registry.js +62 -0
  54. package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
  55. package/dist/elt/ingestion/providers/strava/service.js +366 -0
  56. package/dist/elt/normalization/activities/linking.d.ts +5 -0
  57. package/dist/elt/normalization/activities/linking.js +165 -0
  58. package/dist/elt/normalization/normalizers.d.ts +5 -0
  59. package/dist/elt/normalization/normalizers.js +816 -0
  60. package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
  61. package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
  62. package/dist/elt/storage/artifacts.d.ts +13 -0
  63. package/dist/elt/storage/artifacts.js +48 -0
  64. package/dist/elt/storage/database.d.ts +74 -0
  65. package/dist/elt/storage/database.js +296 -0
  66. package/dist/elt/storage/migrations.d.ts +3 -0
  67. package/dist/elt/storage/migrations.js +832 -0
  68. package/dist/elt/storage/migrations.legacy.d.ts +12 -0
  69. package/dist/elt/storage/migrations.legacy.js +332 -0
  70. package/dist/elt/storage/sql.d.ts +2 -0
  71. package/dist/elt/storage/sql.js +7 -0
  72. package/dist/elt/storage/write-lock.d.ts +6 -0
  73. package/dist/elt/storage/write-lock.js +28 -0
  74. package/dist/elt/streams.d.ts +24 -0
  75. package/dist/elt/streams.js +62 -0
  76. package/dist/interfaces/cli/commands.d.ts +2 -0
  77. package/dist/interfaces/cli/commands.js +166 -0
  78. package/dist/interfaces/cli/main.d.ts +2 -0
  79. package/dist/interfaces/cli/main.js +3 -0
  80. package/dist/interfaces/http/cli-options.d.ts +9 -0
  81. package/dist/interfaces/http/cli-options.js +81 -0
  82. package/dist/interfaces/http/server.d.ts +17 -0
  83. package/dist/interfaces/http/server.js +196 -0
  84. package/dist/interfaces/mcp/cli-options.d.ts +6 -0
  85. package/dist/interfaces/mcp/cli-options.js +36 -0
  86. package/dist/interfaces/mcp/main.d.ts +2 -0
  87. package/dist/interfaces/mcp/main.js +66 -0
  88. package/dist/interfaces/mcp/server.d.ts +8 -0
  89. package/dist/interfaces/mcp/server.js +572 -0
  90. package/dist/runtime/index.d.ts +33 -0
  91. package/dist/runtime/index.js +21 -0
  92. package/docs/architecture.md +100 -0
  93. package/docs/assets/image.png +0 -0
  94. package/docs/mcp-activity-retrieval.md +377 -0
  95. package/package.json +81 -6
  96. package/pyproject.toml +28 -0
  97. package/python/__init__.py +1 -0
  98. package/python/catence/__init__.py +0 -0
  99. package/python/catence/providers/__init__.py +0 -0
  100. package/python/catence/providers/garmin/__init__.py +3 -0
  101. package/python/catence/providers/garmin/cli.py +62 -0
  102. package/python/catence/providers/garmin/contracts.py +23 -0
  103. package/python/catence/providers/garmin/registry.py +48 -0
  104. package/python/catence/providers/garmin/staging.py +87 -0
  105. package/python/catence/providers/garmin/streams.py +195 -0
  106. package/python/catence/providers/garmin/worker.py +450 -0
  107. package/python/catence/providers/strava/__init__.py +3 -0
  108. package/python/catence/providers/strava/cli.py +404 -0
  109. package/python/catence/providers/strava/staging.py +73 -0
  110. package/uv.lock +708 -0
@@ -0,0 +1,12 @@
1
+ export type Migration = {
2
+ version: number;
3
+ name: string;
4
+ sql: string;
5
+ /**
6
+ * Idempotent statements that must run after the migration transaction
7
+ * commits. This is reserved for engine operations that cannot safely be
8
+ * combined with a populated-table rewrite on all supported DuckDB builds.
9
+ */
10
+ postCommitSql?: string;
11
+ };
12
+ export declare const migrations: Migration[];
@@ -0,0 +1,332 @@
1
+ export const migrations = [
2
+ {
3
+ version: 1,
4
+ name: 'initial_ingestion_schema',
5
+ sql: `
6
+ CREATE TABLE IF NOT EXISTS schema_migrations (
7
+ version INTEGER PRIMARY KEY,
8
+ name VARCHAR NOT NULL,
9
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
10
+ );
11
+ CREATE TABLE IF NOT EXISTS source_accounts (
12
+ provider VARCHAR NOT NULL,
13
+ remote_account_id VARCHAR NOT NULL,
14
+ display_name VARCHAR,
15
+ fetched_at TIMESTAMPTZ NOT NULL,
16
+ payload_json JSON NOT NULL,
17
+ PRIMARY KEY (provider, remote_account_id)
18
+ );
19
+ CREATE TABLE IF NOT EXISTS sync_runs (
20
+ run_id VARCHAR PRIMARY KEY,
21
+ provider VARCHAR NOT NULL,
22
+ from_date DATE NOT NULL,
23
+ started_at TIMESTAMPTZ NOT NULL,
24
+ completed_at TIMESTAMPTZ,
25
+ status VARCHAR NOT NULL,
26
+ parser_version INTEGER NOT NULL,
27
+ error_count INTEGER NOT NULL DEFAULT 0
28
+ );
29
+ CREATE TABLE IF NOT EXISTS sync_items (
30
+ item_id VARCHAR PRIMARY KEY,
31
+ run_id VARCHAR NOT NULL,
32
+ provider VARCHAR NOT NULL,
33
+ endpoint VARCHAR NOT NULL,
34
+ remote_id VARCHAR,
35
+ scope_json JSON NOT NULL,
36
+ status VARCHAR NOT NULL,
37
+ attempts INTEGER NOT NULL DEFAULT 0,
38
+ error_message VARCHAR,
39
+ started_at TIMESTAMPTZ,
40
+ completed_at TIMESTAMPTZ
41
+ );
42
+ CREATE TABLE IF NOT EXISTS raw_objects (
43
+ content_hash VARCHAR PRIMARY KEY,
44
+ provider VARCHAR NOT NULL,
45
+ endpoint VARCHAR NOT NULL,
46
+ remote_id VARCHAR,
47
+ fetched_at TIMESTAMPTZ NOT NULL,
48
+ content_type VARCHAR NOT NULL,
49
+ relative_path VARCHAR NOT NULL,
50
+ scope_json JSON NOT NULL,
51
+ parser_version INTEGER NOT NULL
52
+ );
53
+ CREATE TABLE IF NOT EXISTS source_entities (
54
+ provider VARCHAR NOT NULL,
55
+ entity_type VARCHAR NOT NULL,
56
+ remote_id VARCHAR NOT NULL,
57
+ parent_remote_id VARCHAR,
58
+ occurred_on DATE,
59
+ source_updated_at TIMESTAMPTZ,
60
+ raw_object_hash VARCHAR,
61
+ payload_json JSON NOT NULL,
62
+ extension_json JSON NOT NULL,
63
+ normalized_at TIMESTAMPTZ NOT NULL DEFAULT now(),
64
+ PRIMARY KEY (provider, entity_type, remote_id)
65
+ );
66
+ CREATE TABLE IF NOT EXISTS normalization_errors (
67
+ error_id VARCHAR PRIMARY KEY,
68
+ run_id VARCHAR NOT NULL,
69
+ provider VARCHAR NOT NULL,
70
+ endpoint VARCHAR NOT NULL,
71
+ remote_id VARCHAR,
72
+ message VARCHAR NOT NULL,
73
+ retryable BOOLEAN NOT NULL,
74
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
75
+ resolved_at TIMESTAMPTZ
76
+ );
77
+ CREATE TABLE IF NOT EXISTS activities (
78
+ activity_id VARCHAR PRIMARY KEY,
79
+ started_at_utc TIMESTAMPTZ,
80
+ started_at_local VARCHAR,
81
+ timezone VARCHAR,
82
+ sport VARCHAR,
83
+ name VARCHAR,
84
+ link_state VARCHAR NOT NULL
85
+ );
86
+ CREATE TABLE IF NOT EXISTS activity_sources (
87
+ activity_source_id VARCHAR PRIMARY KEY,
88
+ activity_id VARCHAR NOT NULL,
89
+ provider VARCHAR NOT NULL,
90
+ remote_activity_id VARCHAR NOT NULL,
91
+ external_id VARCHAR,
92
+ raw_object_hash VARCHAR,
93
+ UNIQUE (provider, remote_activity_id)
94
+ );
95
+ CREATE TABLE IF NOT EXISTS activity_summaries (
96
+ activity_source_id VARCHAR PRIMARY KEY,
97
+ distance_m DOUBLE,
98
+ moving_s DOUBLE,
99
+ elapsed_s DOUBLE,
100
+ elevation_gain_m DOUBLE,
101
+ calories DOUBLE,
102
+ avg_hr DOUBLE,
103
+ max_hr DOUBLE,
104
+ avg_power DOUBLE,
105
+ weighted_power DOUBLE,
106
+ avg_cadence DOUBLE,
107
+ training_load DOUBLE,
108
+ rpe DOUBLE,
109
+ feel DOUBLE,
110
+ metrics_json JSON NOT NULL
111
+ );
112
+ CREATE TABLE IF NOT EXISTS activity_intervals (
113
+ activity_source_id VARCHAR NOT NULL,
114
+ interval_key VARCHAR NOT NULL,
115
+ label VARCHAR,
116
+ start_s DOUBLE,
117
+ end_s DOUBLE,
118
+ distance_m DOUBLE,
119
+ avg_power DOUBLE,
120
+ avg_hr DOUBLE,
121
+ avg_pace DOUBLE,
122
+ intensity DOUBLE,
123
+ metrics_json JSON NOT NULL,
124
+ PRIMARY KEY (activity_source_id, interval_key)
125
+ );
126
+ CREATE TABLE IF NOT EXISTS daily_metrics (
127
+ provider VARCHAR NOT NULL,
128
+ metric_date DATE NOT NULL,
129
+ metric_name VARCHAR NOT NULL,
130
+ value_number DOUBLE,
131
+ value_text VARCHAR,
132
+ unit VARCHAR,
133
+ raw_object_hash VARCHAR,
134
+ PRIMARY KEY (provider, metric_date, metric_name)
135
+ );
136
+ CREATE TABLE IF NOT EXISTS nutrition_days (
137
+ provider VARCHAR NOT NULL,
138
+ nutrition_date DATE NOT NULL,
139
+ energy_kcal DOUBLE,
140
+ carbohydrates_g DOUBLE,
141
+ protein_g DOUBLE,
142
+ fat_g DOUBLE,
143
+ hydration_ml DOUBLE,
144
+ metrics_json JSON NOT NULL,
145
+ raw_object_hash VARCHAR,
146
+ PRIMARY KEY (provider, nutrition_date)
147
+ );
148
+ CREATE TABLE IF NOT EXISTS nutrition_items (
149
+ provider VARCHAR NOT NULL,
150
+ remote_item_id VARCHAR NOT NULL,
151
+ nutrition_date DATE NOT NULL,
152
+ meal VARCHAR,
153
+ consumed_at TIMESTAMPTZ,
154
+ food_name VARCHAR,
155
+ quantity DOUBLE,
156
+ energy_kcal DOUBLE,
157
+ carbohydrates_g DOUBLE,
158
+ protein_g DOUBLE,
159
+ fat_g DOUBLE,
160
+ payload_json JSON NOT NULL,
161
+ raw_object_hash VARCHAR,
162
+ PRIMARY KEY (provider, remote_item_id)
163
+ );
164
+ CREATE TABLE IF NOT EXISTS domain_entities (
165
+ provider VARCHAR NOT NULL,
166
+ entity_type VARCHAR NOT NULL,
167
+ remote_id VARCHAR NOT NULL,
168
+ parent_remote_id VARCHAR,
169
+ occurred_on DATE,
170
+ payload_json JSON NOT NULL,
171
+ extension_json JSON NOT NULL,
172
+ raw_object_hash VARCHAR,
173
+ PRIMARY KEY (provider, entity_type, remote_id)
174
+ );
175
+ CREATE TABLE IF NOT EXISTS stream_manifest (
176
+ provider VARCHAR NOT NULL,
177
+ activity_source_id VARCHAR NOT NULL,
178
+ content_hash VARCHAR NOT NULL,
179
+ relative_path VARCHAR NOT NULL,
180
+ row_count BIGINT NOT NULL,
181
+ start_at TIMESTAMPTZ,
182
+ end_at TIMESTAMPTZ,
183
+ columns_json JSON NOT NULL,
184
+ raw_object_hash VARCHAR,
185
+ PRIMARY KEY (provider, activity_source_id, content_hash)
186
+ );
187
+ CREATE OR REPLACE VIEW daily_health AS
188
+ SELECT provider, metric_date,
189
+ max(value_number) FILTER (WHERE metric_name = 'resting_hr_bpm') AS resting_hr_bpm,
190
+ max(value_number) FILTER (WHERE metric_name = 'hrv_ms') AS hrv_ms,
191
+ max(value_number) FILTER (WHERE metric_name = 'sleep_seconds') AS sleep_seconds,
192
+ max(value_number) FILTER (WHERE metric_name = 'sleep_score') AS sleep_score,
193
+ max(value_number) FILTER (WHERE metric_name = 'stress') AS stress,
194
+ max(value_number) FILTER (WHERE metric_name = 'body_battery') AS body_battery,
195
+ max(value_number) FILTER (WHERE metric_name = 'readiness') AS readiness,
196
+ max(value_number) FILTER (WHERE metric_name = 'spo2_pct') AS spo2_pct,
197
+ max(value_number) FILTER (WHERE metric_name = 'weight_kg') AS weight_kg,
198
+ max(value_number) FILTER (WHERE metric_name = 'steps') AS steps
199
+ FROM daily_metrics
200
+ GROUP BY provider, metric_date;
201
+ CREATE OR REPLACE VIEW canonical_activity_training AS
202
+ SELECT * EXCLUDE (priority, row_rank)
203
+ FROM (
204
+ SELECT a.*, s.provider, summary.*, CASE WHEN s.provider = 'intervals' THEN 0 ELSE 1 END AS priority,
205
+ row_number() OVER (PARTITION BY a.activity_id ORDER BY CASE WHEN s.provider = 'intervals' THEN 0 ELSE 1 END) AS row_rank
206
+ FROM activities a
207
+ JOIN activity_sources s USING (activity_id)
208
+ LEFT JOIN activity_summaries summary USING (activity_source_id)
209
+ )
210
+ WHERE row_rank = 1;
211
+ `,
212
+ },
213
+ {
214
+ version: 2,
215
+ name: 'domain_projection_views',
216
+ sql: `
217
+ CREATE OR REPLACE VIEW events AS
218
+ SELECT provider, remote_id AS event_id, occurred_on, parent_remote_id, payload_json, extension_json, raw_object_hash
219
+ FROM domain_entities WHERE entity_type = 'event';
220
+ CREATE OR REPLACE VIEW workouts AS
221
+ SELECT provider, remote_id AS workout_id, occurred_on, parent_remote_id, payload_json, extension_json, raw_object_hash
222
+ FROM domain_entities WHERE entity_type IN ('workout', 'scheduled_workout');
223
+ CREATE OR REPLACE VIEW workout_documents AS
224
+ SELECT provider, remote_id AS workout_id, payload_json->'workout_doc' AS workout_doc, payload_json
225
+ FROM domain_entities WHERE entity_type IN ('workout', 'scheduled_workout');
226
+ CREATE OR REPLACE VIEW training_plans AS
227
+ SELECT provider, remote_id AS training_plan_id, occurred_on, payload_json, extension_json, raw_object_hash
228
+ FROM domain_entities WHERE entity_type = 'training_plan';
229
+ CREATE OR REPLACE VIEW routes AS
230
+ SELECT provider, remote_id AS route_id, payload_json, extension_json, raw_object_hash
231
+ FROM domain_entities WHERE entity_type = 'route';
232
+ CREATE OR REPLACE VIEW gear AS
233
+ SELECT provider, remote_id AS gear_id, payload_json, extension_json, raw_object_hash
234
+ FROM domain_entities WHERE entity_type = 'gear';
235
+ CREATE OR REPLACE VIEW devices AS
236
+ SELECT provider, remote_id AS device_id, payload_json, extension_json, raw_object_hash
237
+ FROM domain_entities WHERE entity_type = 'device';
238
+ CREATE OR REPLACE VIEW goals AS
239
+ SELECT provider, remote_id AS goal_id, payload_json, extension_json, raw_object_hash
240
+ FROM domain_entities WHERE entity_type IN ('goal', 'challenge');
241
+ CREATE OR REPLACE VIEW achievements AS
242
+ SELECT provider, remote_id AS achievement_id, payload_json, extension_json, raw_object_hash
243
+ FROM domain_entities WHERE entity_type IN ('badge', 'personal_record');
244
+ CREATE OR REPLACE VIEW messages AS
245
+ SELECT provider, remote_id AS message_id, parent_remote_id, occurred_on, payload_json, extension_json, raw_object_hash
246
+ FROM domain_entities WHERE entity_type = 'message';
247
+ `,
248
+ },
249
+ {
250
+ version: 3,
251
+ name: 'retrieval_documents',
252
+ sql: `
253
+ CREATE TABLE IF NOT EXISTS retrieval_documents (
254
+ document_id VARCHAR PRIMARY KEY,
255
+ entity_type VARCHAR NOT NULL,
256
+ entity_id VARCHAR NOT NULL,
257
+ activity_source_id VARCHAR,
258
+ provider VARCHAR,
259
+ occurred_on DATE,
260
+ document_text VARCHAR NOT NULL,
261
+ metadata_json JSON NOT NULL,
262
+ content_hash VARCHAR NOT NULL UNIQUE,
263
+ generated_at TIMESTAMPTZ NOT NULL DEFAULT now()
264
+ );
265
+ CREATE INDEX IF NOT EXISTS retrieval_documents_entity_idx
266
+ ON retrieval_documents (entity_type, entity_id);
267
+ CREATE INDEX IF NOT EXISTS retrieval_documents_date_idx
268
+ ON retrieval_documents (occurred_on);
269
+ CREATE TABLE IF NOT EXISTS retrieval_index_state (
270
+ index_name VARCHAR PRIMARY KEY,
271
+ status VARCHAR NOT NULL,
272
+ mode VARCHAR NOT NULL,
273
+ source_watermark VARCHAR,
274
+ built_at TIMESTAMPTZ,
275
+ detail_json JSON NOT NULL DEFAULT '{}'
276
+ );
277
+ INSERT INTO retrieval_index_state (index_name, status, mode, detail_json)
278
+ VALUES ('context', 'stale', 'keyword', '{}')
279
+ ON CONFLICT (index_name) DO NOTHING;
280
+ `,
281
+ },
282
+ {
283
+ version: 4,
284
+ name: 'query_projection_views',
285
+ sql: `
286
+ CREATE OR REPLACE VIEW activity_summary_facts AS
287
+ SELECT summary.*, source.activity_id, source.provider, source.remote_activity_id,
288
+ source.raw_object_hash, activity.started_at_utc, activity.started_at_local,
289
+ activity.timezone, activity.sport, activity.name
290
+ FROM activity_summaries summary
291
+ JOIN activity_sources source USING (activity_source_id)
292
+ JOIN activities activity USING (activity_id);
293
+ CREATE OR REPLACE VIEW activity_interval_facts AS
294
+ SELECT intervals.*, source.activity_id, source.provider,
295
+ activity.started_at_utc, activity.sport, activity.name
296
+ FROM activity_intervals intervals
297
+ JOIN activity_sources source USING (activity_source_id)
298
+ JOIN activities activity USING (activity_id);
299
+ `,
300
+ },
301
+ {
302
+ version: 5,
303
+ name: 'incremental_sync_cursors',
304
+ sql: `
305
+ CREATE TABLE IF NOT EXISTS sync_cursors (
306
+ provider VARCHAR NOT NULL,
307
+ cursor_name VARCHAR NOT NULL,
308
+ covered_through_date DATE NOT NULL,
309
+ latest_source_date DATE,
310
+ lookback_days INTEGER NOT NULL,
311
+ last_successful_run_id VARCHAR,
312
+ last_completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
313
+ status VARCHAR NOT NULL,
314
+ detail_json JSON NOT NULL DEFAULT '{}',
315
+ PRIMARY KEY (provider, cursor_name)
316
+ );
317
+ CREATE TABLE IF NOT EXISTS activity_sync_state (
318
+ provider VARCHAR NOT NULL,
319
+ remote_activity_id VARCHAR NOT NULL,
320
+ summary_hash VARCHAR NOT NULL,
321
+ last_seen_run_id VARCHAR NOT NULL,
322
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
323
+ last_detail_sync_at TIMESTAMPTZ,
324
+ details_status VARCHAR NOT NULL,
325
+ PRIMARY KEY (provider, remote_activity_id)
326
+ );
327
+ CREATE INDEX IF NOT EXISTS activity_sync_state_hash_idx
328
+ ON activity_sync_state (provider, summary_hash);
329
+ `,
330
+ },
331
+ ];
332
+ //# sourceMappingURL=migrations.legacy.js.map
@@ -0,0 +1,2 @@
1
+ export declare function sqlString(value: string): string;
2
+ export declare function json(value: unknown): string;
@@ -0,0 +1,7 @@
1
+ export function sqlString(value) {
2
+ return `'${value.replaceAll("'", "''")}'`;
3
+ }
4
+ export function json(value) {
5
+ return JSON.stringify(value ?? {});
6
+ }
7
+ //# sourceMappingURL=sql.js.map
@@ -0,0 +1,6 @@
1
+ import type { CatencePaths } from '../../contracts/runtime.js';
2
+ export declare class DataWriteBusyError extends Error {
3
+ constructor();
4
+ }
5
+ /** Serializes short writer transactions across local CLI and stdio MCP processes. */
6
+ export declare function withDataWriteLock<T>(paths: CatencePaths, work: () => Promise<T>): Promise<T>;
@@ -0,0 +1,28 @@
1
+ import { open, unlink } from 'node:fs/promises';
2
+ export class DataWriteBusyError extends Error {
3
+ constructor() {
4
+ super('Another Catence sync or enrichment request currently owns the data directory. Retry after it completes.');
5
+ this.name = 'DataWriteBusyError';
6
+ }
7
+ }
8
+ /** Serializes short writer transactions across local CLI and stdio MCP processes. */
9
+ export async function withDataWriteLock(paths, work) {
10
+ let handle;
11
+ try {
12
+ handle = await open(paths.lock, 'wx', 0o600);
13
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`);
14
+ }
15
+ catch (error) {
16
+ if (error.code === 'EEXIST')
17
+ throw new DataWriteBusyError();
18
+ throw error;
19
+ }
20
+ try {
21
+ return await work();
22
+ }
23
+ finally {
24
+ await handle.close().catch(() => undefined);
25
+ await unlink(paths.lock).catch(() => undefined);
26
+ }
27
+ }
28
+ //# sourceMappingURL=write-lock.js.map
@@ -0,0 +1,24 @@
1
+ import type { CatencePaths } from '../contracts/runtime.js';
2
+ import type { CatenceDatabase } from './storage/database.js';
3
+ export type ActivitySample = {
4
+ activity_source_id: string;
5
+ timestamp_utc: string | null;
6
+ elapsed_s: number | null;
7
+ distance_m: number | null;
8
+ latitude: number | null;
9
+ longitude: number | null;
10
+ altitude_m: number | null;
11
+ heart_rate_bpm: number | null;
12
+ power_w: number | null;
13
+ cadence_rpm: number | null;
14
+ speed_mps: number | null;
15
+ temperature_c: number | null;
16
+ grade_pct: number | null;
17
+ extras_json: string;
18
+ };
19
+ export declare function intervalsStreamsToSamples(activitySourceId: string, streams: Array<Record<string, unknown>>, startAt: string | null): ActivitySample[];
20
+ export declare function writeParquetSamples(database: CatenceDatabase, paths: CatencePaths, provider: 'intervals' | 'garmin', activityRemoteId: string, startDate: string, samples: ActivitySample[]): Promise<{
21
+ relativePath: string;
22
+ contentHash: string;
23
+ columns: string[];
24
+ }>;
@@ -0,0 +1,62 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, rename, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { sqlString } from './storage/sql.js';
5
+ const knownColumns = {
6
+ time: 'elapsed_s', elapsed_time: 'elapsed_s', elapsed_s: 'elapsed_s', distance: 'distance_m', lat: 'latitude', latitude: 'latitude',
7
+ lng: 'longitude', lon: 'longitude', longitude: 'longitude', altitude: 'altitude_m', elevation: 'altitude_m', heartrate: 'heart_rate_bpm',
8
+ heart_rate: 'heart_rate_bpm', hr: 'heart_rate_bpm', watts: 'power_w', power: 'power_w', cadence: 'cadence_rpm', velocity_smooth: 'speed_mps',
9
+ speed: 'speed_mps', temp: 'temperature_c', temperature: 'temperature_c', grade_smooth: 'grade_pct', grade: 'grade_pct',
10
+ };
11
+ function numeric(value) {
12
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
13
+ }
14
+ export function intervalsStreamsToSamples(activitySourceId, streams, startAt) {
15
+ const arrays = new Map();
16
+ for (const stream of streams) {
17
+ const name = typeof stream.type === 'string' ? stream.type : typeof stream.name === 'string' ? stream.name : null;
18
+ const values = Array.isArray(stream.data) ? stream.data : null;
19
+ if (name && values)
20
+ arrays.set(name, values);
21
+ }
22
+ const count = Math.max(0, ...[...arrays.values()].map((values) => values.length));
23
+ const origin = startAt ? Date.parse(startAt) : Number.NaN;
24
+ return Array.from({ length: count }, (_, index) => {
25
+ const extras = {};
26
+ const sample = {
27
+ activity_source_id: activitySourceId, timestamp_utc: Number.isFinite(origin) ? new Date(origin + (numeric(arrays.get('time')?.[index]) ?? index) * 1000).toISOString() : null,
28
+ elapsed_s: null, distance_m: null, latitude: null, longitude: null, altitude_m: null, heart_rate_bpm: null, power_w: null,
29
+ cadence_rpm: null, speed_mps: null, temperature_c: null, grade_pct: null, extras_json: '{}',
30
+ };
31
+ for (const [name, values] of arrays) {
32
+ const value = values[index];
33
+ const column = knownColumns[name];
34
+ if (column)
35
+ sample[column] = numeric(value);
36
+ else
37
+ extras[name] = value;
38
+ }
39
+ sample.extras_json = JSON.stringify(extras);
40
+ return sample;
41
+ });
42
+ }
43
+ export async function writeParquetSamples(database, paths, provider, activityRemoteId, startDate, samples) {
44
+ const activitySourceId = `${provider}:${activityRemoteId}`;
45
+ const year = startDate.slice(0, 4) || 'unknown';
46
+ const month = startDate.slice(5, 7) || 'unknown';
47
+ const directory = path.join(paths.lake, `provider=${provider}`, `year=${year}`, `month=${month}`);
48
+ await mkdir(directory, { recursive: true });
49
+ const jsonPath = path.join(paths.staging, `${provider}-${activityRemoteId}-${process.pid}.ndjson`);
50
+ await writeFile(jsonPath, samples.map((sample) => JSON.stringify(sample)).join('\n'));
51
+ const target = path.join(directory, `activity=${activityRemoteId}.parquet`);
52
+ const temporary = `${target}.${process.pid}.tmp`;
53
+ await database.run(`COPY (SELECT * FROM read_ndjson_auto(${sqlString(jsonPath)})) TO ${sqlString(temporary)} (FORMAT PARQUET, COMPRESSION ZSTD)`);
54
+ await rename(temporary, target);
55
+ const contentHash = createHash('sha256').update(await (await import('node:fs/promises')).readFile(target)).digest('hex');
56
+ return {
57
+ relativePath: path.relative(paths.root, target),
58
+ contentHash,
59
+ columns: ['activity_source_id', 'timestamp_utc', 'elapsed_s', 'distance_m', 'latitude', 'longitude', 'altitude_m', 'heart_rate_bpm', 'power_w', 'cadence_rpm', 'speed_mps', 'temperature_c', 'grade_pct', 'extras_json'],
60
+ };
61
+ }
62
+ //# sourceMappingURL=streams.js.map
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { stdin as input, stdout as output } from 'node:process';
4
+ import { Command } from 'commander';
5
+ import { addAthlete, athleteStorePaths, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, initializeCatalog, linkActivity, loadCatalog, rebuildRetrievalIndex, resolveAthlete, resolveCatalogPaths, retryDataSync, setAthleteSecret, syncData, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
6
+ const program = new Command()
7
+ .name('catence-data')
8
+ .description('Athlete-scoped Catence data setup, extraction, and normalization.')
9
+ .option('--home <directory>', 'Catence catalog home directory', defaultCatalogHome())
10
+ .option('--athlete <id>', 'athlete ID for a data operation');
11
+ function currentCatalog() {
12
+ return resolveCatalogPaths(program.opts().home);
13
+ }
14
+ async function currentAthlete() {
15
+ const athleteId = program.opts().athlete;
16
+ if (!athleteId)
17
+ throw new Error('--athlete is required for this command. Run catence-data athlete list to inspect the catalog.');
18
+ return resolveAthlete(currentCatalog(), athleteId);
19
+ }
20
+ async function prompt(question) {
21
+ if (!input.isTTY)
22
+ throw new Error(`Missing required setup value. Re-run with the matching option (${question}).`);
23
+ const terminal = createInterface({ input, output });
24
+ try {
25
+ return (await terminal.question(question)).trim();
26
+ }
27
+ finally {
28
+ terminal.close();
29
+ }
30
+ }
31
+ async function stdinSecret() {
32
+ const chunks = [];
33
+ for await (const chunk of input)
34
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
35
+ const value = Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '');
36
+ if (!value)
37
+ throw new Error('--value-stdin received an empty secret.');
38
+ return value;
39
+ }
40
+ program.command('setup')
41
+ .description('Create a home catalog and its first athlete store. Existing 0.1 stores must be re-synced.')
42
+ .option('--athlete <id>', 'first athlete ID')
43
+ .option('--label <label>', 'display label for the first athlete')
44
+ .action(async (options) => {
45
+ // Commander resolves the global --athlete option first when it appears
46
+ // after this subcommand, so accept either scope without making the setup
47
+ // command order-sensitive.
48
+ const athlete = options.athlete ?? program.opts().athlete ?? await prompt('First athlete ID (for example, alex): ');
49
+ const label = options.label ?? await prompt('First athlete display name: ');
50
+ const catalog = await initializeCatalog(currentCatalog(), { id: athlete, label });
51
+ process.stdout.write(`${JSON.stringify({ home: currentCatalog().root, catalog, message: 'Catence catalog is ready. Add provider values with catence-data secret set --athlete <id> --value-stdin, then run sync.' }, null, 2)}\n`);
52
+ });
53
+ const athleteCommand = program.command('athlete').description('Manage isolated athlete stores in this Catence catalog.');
54
+ athleteCommand.command('list').action(async () => {
55
+ process.stdout.write(`${JSON.stringify(await loadCatalog(currentCatalog()), null, 2)}\n`);
56
+ });
57
+ athleteCommand.command('add')
58
+ .requiredOption('--id <id>', 'stable athlete ID')
59
+ .requiredOption('--label <label>', 'display label')
60
+ .option('--default', 'make this the default athlete for Console setup')
61
+ .action(async (options) => {
62
+ process.stdout.write(`${JSON.stringify(await addAthlete(currentCatalog(), { id: options.id, label: options.label, setDefault: options.default }), null, 2)}\n`);
63
+ });
64
+ const secretCommand = program.command('secret').description('Write a provider value into one athlete’s owner-only secret store.');
65
+ secretCommand.command('set')
66
+ .requiredOption('--provider <provider>', 'garmin, intervals, or strava')
67
+ .requiredOption('--field <field>', 'provider secret field')
68
+ .requiredOption('--value-stdin', 'read the secret from stdin without putting it in shell history')
69
+ .action(async (options) => {
70
+ if (!['garmin', 'intervals', 'strava'].includes(options.provider))
71
+ throw new Error('--provider must be garmin, intervals, or strava.');
72
+ const { paths } = await currentAthlete();
73
+ await setAthleteSecret(paths, options.provider, options.field, await stdinSecret());
74
+ process.stdout.write(`${JSON.stringify({ athleteId: program.opts().athlete, provider: options.provider, field: options.field, stored: true }, null, 2)}\n`);
75
+ });
76
+ program.command('demo')
77
+ .description('Create a generated demo athlete in an isolated demo catalog without contacting providers.')
78
+ .option('--days <days>', 'generated days of wellness and training data', '90')
79
+ .option('--seed <seed>', 'deterministic generator seed', '17')
80
+ .action(async (options) => {
81
+ const explicitHome = process.argv.some((argument) => argument === '--home' || argument.startsWith('--home='));
82
+ const demoCatalog = resolveCatalogPaths(explicitHome ? currentCatalog().root : `${defaultCatalogHome()}-demo`);
83
+ let catalog;
84
+ try {
85
+ catalog = await loadCatalog(demoCatalog);
86
+ }
87
+ catch {
88
+ catalog = await initializeCatalog(demoCatalog, { id: 'demo', label: 'Generated demo athlete' });
89
+ }
90
+ if (!catalog.athletes.some((athlete) => athlete.id === 'demo')) {
91
+ catalog = await addAthlete(demoCatalog, { id: 'demo', label: 'Generated demo athlete' });
92
+ }
93
+ const paths = athleteStorePaths(demoCatalog, 'demo');
94
+ const result = await createDemoStore(paths, { days: Number(options.days), seed: Number(options.seed) });
95
+ process.stdout.write(`${JSON.stringify({ ...result, home: demoCatalog.root, athleteId: 'demo' }, null, 2)}\n`);
96
+ });
97
+ program.command('sync')
98
+ .requiredOption('--provider <provider>', 'intervals, garmin, strava, or all')
99
+ .option('--from <date>', 'explicit ISO date range; otherwise use incremental cursors')
100
+ .option('--refresh', 're-fetch Garmin activity details, files, and streams even when summaries are unchanged')
101
+ .action(async (options) => {
102
+ if (!['intervals', 'garmin', 'strava', 'all'].includes(options.provider))
103
+ throw new Error('--provider must be intervals, garmin, strava, or all.');
104
+ const { paths } = await currentAthlete();
105
+ process.stdout.write(`${JSON.stringify(await syncData(paths, options.provider, options.from, true, options.refresh ?? false), null, 2)}\n`);
106
+ });
107
+ program.command('backfill')
108
+ .requiredOption('--from <date>', 'ISO date at which to begin backfill')
109
+ .option('--provider <provider>', 'intervals, garmin, strava, or all', 'all')
110
+ .option('--refresh', 're-fetch and upsert data already covered by the requested range')
111
+ .action(async (options) => {
112
+ if (!['intervals', 'garmin', 'strava', 'all'].includes(options.provider))
113
+ throw new Error('--provider must be intervals, garmin, strava, or all.');
114
+ const { paths } = await currentAthlete();
115
+ process.stdout.write(`${JSON.stringify(await syncData(paths, options.provider, options.from, false, options.refresh ?? false, true), null, 2)}\n`);
116
+ });
117
+ program.command('retry')
118
+ .requiredOption('--run <run-id>', 'failed run to retry')
119
+ .action(async (options) => {
120
+ const { paths } = await currentAthlete();
121
+ process.stdout.write(`${JSON.stringify(await retryDataSync(paths, options.run), null, 2)}\n`);
122
+ });
123
+ program.command('auth').description('Connect a source account using one athlete’s local credentials.')
124
+ .command('strava')
125
+ .option('--code <authorization-code>', 'OAuth authorization code returned by Strava')
126
+ .option('--redirect-uri <uri>', 'OAuth redirect URI; defaults depend on the selected flow')
127
+ .option('--callback', 'wait for a browser OAuth callback on a loopback redirect URI')
128
+ .option('--timeout-seconds <seconds>', 'maximum callback wait time', '300')
129
+ .action(async (options) => {
130
+ if (options.callback && options.code)
131
+ throw new Error('--code and --callback cannot be used together.');
132
+ const timeoutSeconds = Number(options.timeoutSeconds);
133
+ if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 900)
134
+ throw new Error('--timeout-seconds must be an integer between 1 and 900.');
135
+ const { paths } = await currentAthlete();
136
+ const redirectUri = options.redirectUri ?? (options.callback ? 'http://127.0.0.1:8765/strava/callback' : 'http://localhost');
137
+ const result = options.callback
138
+ ? await connectStravaWithCallback(paths, redirectUri, (url) => process.stderr.write(`Open this URL in your browser to connect Strava:\n${url}\nWaiting for the local callback…\n`), timeoutSeconds * 1_000)
139
+ : await connectStrava(paths, options.code, redirectUri);
140
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
141
+ });
142
+ program.command('disconnect').description('Remove one athlete’s stored provider connection.')
143
+ .command('strava')
144
+ .action(async () => {
145
+ const { paths } = await currentAthlete();
146
+ process.stdout.write(`${JSON.stringify(await disconnectStravaAccount(paths), null, 2)}\n`);
147
+ });
148
+ const activityCommand = program.command('activity').description('Audit and correct logical activity links for one athlete.');
149
+ activityCommand.command('link').requiredOption('--source <activity-source-id>').requiredOption('--activity <activity-id>').action(async (options) => {
150
+ const { paths } = await currentAthlete();
151
+ process.stdout.write(`${JSON.stringify(await linkActivity(paths, options.source, options.activity), null, 2)}\n`);
152
+ });
153
+ activityCommand.command('unlink').requiredOption('--source <activity-source-id>').action(async (options) => {
154
+ const { paths } = await currentAthlete();
155
+ process.stdout.write(`${JSON.stringify(await unlinkActivity(paths, options.source), null, 2)}\n`);
156
+ });
157
+ program.command('status').action(async () => {
158
+ const { paths } = await currentAthlete();
159
+ process.stdout.write(`${JSON.stringify(await dataStatus(paths), null, 2)}\n`);
160
+ });
161
+ program.command('build-retrieval-index').description('Build derived local retrieval context for one athlete.').action(async () => {
162
+ const { paths } = await currentAthlete();
163
+ process.stdout.write(`${JSON.stringify(await rebuildRetrievalIndex(paths), null, 2)}\n`);
164
+ });
165
+ await program.parseAsync();
166
+ //# sourceMappingURL=commands.js.map
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import './commands.js';
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import './commands.js';
3
+ //# sourceMappingURL=main.js.map