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.
Files changed (106) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +373 -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/configuration.d.ts +71 -0
  37. package/dist/core/runtime/configuration.js +106 -0
  38. package/dist/core/runtime/limiter.d.ts +13 -0
  39. package/dist/core/runtime/limiter.js +17 -0
  40. package/dist/elt/application/demo.d.ts +23 -0
  41. package/dist/elt/application/demo.js +165 -0
  42. package/dist/elt/application/management.d.ts +25 -0
  43. package/dist/elt/application/management.js +283 -0
  44. package/dist/elt/ingestion/importer.d.ts +8 -0
  45. package/dist/elt/ingestion/importer.js +87 -0
  46. package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
  47. package/dist/elt/ingestion/providers/intervals.js +224 -0
  48. package/dist/elt/ingestion/providers/registry.d.ts +19 -0
  49. package/dist/elt/ingestion/providers/registry.js +62 -0
  50. package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
  51. package/dist/elt/ingestion/providers/strava/service.js +365 -0
  52. package/dist/elt/normalization/activities/linking.d.ts +5 -0
  53. package/dist/elt/normalization/activities/linking.js +165 -0
  54. package/dist/elt/normalization/normalizers.d.ts +5 -0
  55. package/dist/elt/normalization/normalizers.js +816 -0
  56. package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
  57. package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
  58. package/dist/elt/storage/artifacts.d.ts +13 -0
  59. package/dist/elt/storage/artifacts.js +48 -0
  60. package/dist/elt/storage/database.d.ts +74 -0
  61. package/dist/elt/storage/database.js +296 -0
  62. package/dist/elt/storage/migrations.d.ts +3 -0
  63. package/dist/elt/storage/migrations.js +832 -0
  64. package/dist/elt/storage/migrations.legacy.d.ts +12 -0
  65. package/dist/elt/storage/migrations.legacy.js +332 -0
  66. package/dist/elt/storage/sql.d.ts +2 -0
  67. package/dist/elt/storage/sql.js +7 -0
  68. package/dist/elt/storage/write-lock.d.ts +6 -0
  69. package/dist/elt/storage/write-lock.js +28 -0
  70. package/dist/elt/streams.d.ts +24 -0
  71. package/dist/elt/streams.js +62 -0
  72. package/dist/interfaces/cli/commands.d.ts +2 -0
  73. package/dist/interfaces/cli/commands.js +101 -0
  74. package/dist/interfaces/cli/main.d.ts +2 -0
  75. package/dist/interfaces/cli/main.js +3 -0
  76. package/dist/interfaces/http/cli-options.d.ts +9 -0
  77. package/dist/interfaces/http/cli-options.js +81 -0
  78. package/dist/interfaces/http/server.d.ts +14 -0
  79. package/dist/interfaces/http/server.js +169 -0
  80. package/dist/interfaces/mcp/cli-options.d.ts +6 -0
  81. package/dist/interfaces/mcp/cli-options.js +36 -0
  82. package/dist/interfaces/mcp/main.d.ts +2 -0
  83. package/dist/interfaces/mcp/main.js +59 -0
  84. package/dist/interfaces/mcp/server.d.ts +8 -0
  85. package/dist/interfaces/mcp/server.js +521 -0
  86. package/dist/runtime/index.d.ts +29 -0
  87. package/dist/runtime/index.js +19 -0
  88. package/docs/architecture.md +100 -0
  89. package/docs/assets/image.png +0 -0
  90. package/docs/mcp-activity-retrieval.md +377 -0
  91. package/package.json +81 -6
  92. package/pyproject.toml +28 -0
  93. package/python/__init__.py +1 -0
  94. package/python/catence/__init__.py +0 -0
  95. package/python/catence/providers/__init__.py +0 -0
  96. package/python/catence/providers/garmin/__init__.py +3 -0
  97. package/python/catence/providers/garmin/cli.py +62 -0
  98. package/python/catence/providers/garmin/contracts.py +23 -0
  99. package/python/catence/providers/garmin/registry.py +48 -0
  100. package/python/catence/providers/garmin/staging.py +87 -0
  101. package/python/catence/providers/garmin/streams.py +195 -0
  102. package/python/catence/providers/garmin/worker.py +450 -0
  103. package/python/catence/providers/strava/__init__.py +3 -0
  104. package/python/catence/providers/strava/cli.py +404 -0
  105. package/python/catence/providers/strava/staging.py +73 -0
  106. package/uv.lock +708 -0
@@ -0,0 +1,832 @@
1
+ import { migrations as baseMigrations } from './migrations.legacy.js';
2
+ const stravaAndIdentityMigration = {
3
+ version: 6,
4
+ name: 'strava_enrichment_and_activity_links',
5
+ sql: `
6
+ CREATE TABLE IF NOT EXISTS activity_links (
7
+ activity_source_id VARCHAR PRIMARY KEY,
8
+ activity_id VARCHAR NOT NULL,
9
+ method VARCHAR NOT NULL,
10
+ confidence DOUBLE,
11
+ evidence_json JSON NOT NULL DEFAULT '{}',
12
+ linked_at TIMESTAMPTZ NOT NULL DEFAULT now()
13
+ );
14
+ INSERT INTO activity_links (activity_source_id, activity_id, method, confidence, evidence_json)
15
+ SELECT activity_source_id, activity_id,
16
+ CASE WHEN external_id IS NOT NULL THEN 'strong_external_id' ELSE 'source' END,
17
+ CASE WHEN external_id IS NOT NULL THEN 1.0 ELSE NULL END,
18
+ '{}'
19
+ FROM activity_sources
20
+ ON CONFLICT (activity_source_id) DO NOTHING;
21
+
22
+ CREATE TABLE IF NOT EXISTS strava_enrichment_state (
23
+ resource_type VARCHAR NOT NULL,
24
+ remote_id VARCHAR NOT NULL,
25
+ status VARCHAR NOT NULL,
26
+ fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
27
+ completed_at TIMESTAMPTZ,
28
+ continuation_page INTEGER,
29
+ detail_json JSON NOT NULL DEFAULT '{}',
30
+ PRIMARY KEY (resource_type, remote_id)
31
+ );
32
+ CREATE TABLE IF NOT EXISTS strava_rate_state (
33
+ account_id VARCHAR PRIMARY KEY,
34
+ read_limit_15m INTEGER,
35
+ read_usage_15m INTEGER,
36
+ read_limit_day INTEGER,
37
+ read_usage_day INTEGER,
38
+ blocked_until TIMESTAMPTZ,
39
+ observed_at TIMESTAMPTZ NOT NULL DEFAULT now()
40
+ );
41
+ CREATE TABLE IF NOT EXISTS strava_segments (
42
+ segment_id VARCHAR PRIMARY KEY,
43
+ name VARCHAR,
44
+ activity_type VARCHAR,
45
+ distance_m DOUBLE,
46
+ average_grade_pct DOUBLE,
47
+ maximum_grade_pct DOUBLE,
48
+ climb_category INTEGER,
49
+ elevation_high_m DOUBLE,
50
+ elevation_low_m DOUBLE,
51
+ total_elevation_gain_m DOUBLE,
52
+ private BOOLEAN,
53
+ hazardous BOOLEAN,
54
+ starred BOOLEAN,
55
+ raw_object_hash VARCHAR,
56
+ payload_json JSON NOT NULL,
57
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
58
+ );
59
+ CREATE TABLE IF NOT EXISTS activity_segments (
60
+ activity_source_id VARCHAR NOT NULL,
61
+ effort_id VARCHAR NOT NULL,
62
+ segment_id VARCHAR NOT NULL,
63
+ elapsed_s DOUBLE,
64
+ moving_s DOUBLE,
65
+ distance_m DOUBLE,
66
+ average_watts DOUBLE,
67
+ average_hr DOUBLE,
68
+ max_hr DOUBLE,
69
+ average_cadence DOUBLE,
70
+ device_watts BOOLEAN,
71
+ pr_rank INTEGER,
72
+ kom_rank INTEGER,
73
+ started_at TIMESTAMPTZ,
74
+ raw_object_hash VARCHAR,
75
+ payload_json JSON NOT NULL,
76
+ PRIMARY KEY (activity_source_id, effort_id)
77
+ );
78
+ CREATE INDEX IF NOT EXISTS activity_segments_segment_idx ON activity_segments (segment_id);
79
+ CREATE TABLE IF NOT EXISTS strava_segment_efforts (
80
+ effort_id VARCHAR PRIMARY KEY,
81
+ segment_id VARCHAR NOT NULL,
82
+ strava_activity_id VARCHAR,
83
+ elapsed_s DOUBLE,
84
+ moving_s DOUBLE,
85
+ distance_m DOUBLE,
86
+ average_watts DOUBLE,
87
+ average_hr DOUBLE,
88
+ max_hr DOUBLE,
89
+ average_cadence DOUBLE,
90
+ device_watts BOOLEAN,
91
+ pr_rank INTEGER,
92
+ kom_rank INTEGER,
93
+ started_at TIMESTAMPTZ,
94
+ raw_object_hash VARCHAR,
95
+ payload_json JSON NOT NULL,
96
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
97
+ );
98
+ CREATE INDEX IF NOT EXISTS strava_segment_efforts_segment_idx ON strava_segment_efforts (segment_id, started_at);
99
+
100
+ CREATE OR REPLACE VIEW canonical_activity_training AS
101
+ WITH ranked AS (
102
+ SELECT a.*, source.activity_source_id, source.provider, source.remote_activity_id, source.raw_object_hash,
103
+ summary.*, row_number() OVER (
104
+ PARTITION BY a.activity_id
105
+ ORDER BY CASE source.provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END
106
+ ) AS source_rank
107
+ FROM activities a
108
+ JOIN activity_sources source USING (activity_id)
109
+ LEFT JOIN activity_summaries summary USING (activity_source_id)
110
+ ), intervals_analysis AS (
111
+ SELECT source.activity_id,
112
+ max(summary.training_load) AS intervals_training_load,
113
+ max(summary.rpe) AS intervals_rpe,
114
+ max(summary.feel) AS intervals_feel,
115
+ max(summary.weighted_power) AS intervals_weighted_power
116
+ FROM activity_sources source
117
+ JOIN activity_summaries summary USING (activity_source_id)
118
+ WHERE source.provider = 'intervals'
119
+ GROUP BY source.activity_id
120
+ )
121
+ SELECT ranked.* EXCLUDE (source_rank),
122
+ intervals_analysis.intervals_training_load,
123
+ intervals_analysis.intervals_rpe,
124
+ intervals_analysis.intervals_feel,
125
+ intervals_analysis.intervals_weighted_power
126
+ FROM ranked
127
+ LEFT JOIN intervals_analysis USING (activity_id)
128
+ WHERE source_rank = 1;
129
+
130
+ CREATE OR REPLACE VIEW segment_effort_history AS
131
+ SELECT effort.*, segment.name AS segment_name, segment.average_grade_pct, segment.climb_category,
132
+ CASE WHEN effort.elapsed_s > 0 AND effort.distance_m IS NOT NULL THEN effort.distance_m / effort.elapsed_s ELSE NULL END AS average_speed_mps,
133
+ segment.raw_object_hash AS segment_raw_object_hash
134
+ FROM strava_segment_efforts effort
135
+ LEFT JOIN strava_segments segment USING (segment_id);
136
+ CREATE OR REPLACE VIEW strava_gear AS
137
+ SELECT provider, remote_id AS gear_id, payload_json, extension_json, raw_object_hash
138
+ FROM domain_entities WHERE provider = 'strava' AND entity_type = 'gear';
139
+ `,
140
+ };
141
+ const garminPrimaryAndTrainingMetricsMigration = {
142
+ version: 7,
143
+ name: 'garmin_primary_activity_links_and_training_metric_history',
144
+ sql: `
145
+ CREATE TABLE IF NOT EXISTS training_metric_observations (
146
+ observation_id VARCHAR PRIMARY KEY,
147
+ provider VARCHAR NOT NULL,
148
+ metric_name VARCHAR NOT NULL,
149
+ sport VARCHAR NOT NULL,
150
+ observed_at TIMESTAMPTZ NOT NULL,
151
+ value_number DOUBLE NOT NULL,
152
+ source_type VARCHAR NOT NULL,
153
+ source_remote_id VARCHAR NOT NULL,
154
+ activity_source_id VARCHAR,
155
+ raw_object_hash VARCHAR
156
+ );
157
+ CREATE INDEX IF NOT EXISTS training_metric_observations_metric_idx
158
+ ON training_metric_observations (provider, metric_name, sport, observed_at);
159
+
160
+ -- Intervals preserves the originating Garmin activity ID as external_id.
161
+ -- Attach it to the actual Garmin source so the canonical view selects Garmin
162
+ -- while retaining Intervals' separately named analysis values.
163
+ UPDATE activity_sources AS intervals
164
+ SET activity_id = garmin.activity_id
165
+ FROM activity_sources AS garmin
166
+ WHERE intervals.provider = 'intervals'
167
+ AND garmin.provider = 'garmin'
168
+ AND intervals.external_id = garmin.remote_activity_id
169
+ AND NOT EXISTS (
170
+ SELECT 1 FROM activity_links link
171
+ WHERE link.activity_source_id = intervals.activity_source_id
172
+ AND link.method = 'manual'
173
+ );
174
+
175
+ INSERT INTO activity_links (activity_source_id, activity_id, method, confidence, evidence_json)
176
+ SELECT intervals.activity_source_id, garmin.activity_id, 'strong_external_id', 1.0,
177
+ json_object('matchedActivitySourceId', garmin.activity_source_id, 'externalId', intervals.external_id)
178
+ FROM activity_sources AS intervals
179
+ JOIN activity_sources AS garmin
180
+ ON garmin.provider = 'garmin'
181
+ AND intervals.external_id = garmin.remote_activity_id
182
+ WHERE intervals.provider = 'intervals'
183
+ ON CONFLICT (activity_source_id) DO UPDATE SET
184
+ activity_id = CASE WHEN activity_links.method = 'manual' THEN activity_links.activity_id ELSE excluded.activity_id END,
185
+ method = CASE WHEN activity_links.method = 'manual' THEN activity_links.method ELSE excluded.method END,
186
+ confidence = CASE WHEN activity_links.method = 'manual' THEN activity_links.confidence ELSE excluded.confidence END,
187
+ evidence_json = CASE WHEN activity_links.method = 'manual' THEN activity_links.evidence_json ELSE excluded.evidence_json END,
188
+ linked_at = CASE WHEN activity_links.method = 'manual' THEN activity_links.linked_at ELSE now() END;
189
+
190
+ DELETE FROM activities AS activity
191
+ WHERE NOT EXISTS (SELECT 1 FROM activity_sources AS source WHERE source.activity_id = activity.activity_id);
192
+
193
+ -- Garmin's cycling activity summary retains the FTP active during that
194
+ -- activity. These observations reconstruct history already present in an
195
+ -- imported store without relying on Intervals' derived configuration.
196
+ INSERT INTO training_metric_observations
197
+ (observation_id, provider, metric_name, sport, observed_at, value_number, source_type, source_remote_id, activity_source_id, raw_object_hash)
198
+ SELECT
199
+ 'garmin:cycling_ftp:activity:' || source.remote_activity_id,
200
+ 'garmin', 'cycling_ftp_w', coalesce(activity.sport, json_extract_string(summary.metrics_json, '$.activityType.typeKey'), 'cycling'), activity.started_at_utc,
201
+ try_cast(json_extract_string(summary.metrics_json, '$.maxFtp') AS DOUBLE),
202
+ 'activity_summary', source.remote_activity_id, source.activity_source_id, source.raw_object_hash
203
+ FROM activity_sources AS source
204
+ JOIN activities AS activity USING (activity_id)
205
+ JOIN activity_summaries AS summary USING (activity_source_id)
206
+ WHERE source.provider = 'garmin'
207
+ AND activity.started_at_utc IS NOT NULL
208
+ AND (lower(coalesce(activity.sport, json_extract_string(summary.metrics_json, '$.activityType.typeKey'), '')) LIKE '%cycl%' OR lower(coalesce(activity.sport, json_extract_string(summary.metrics_json, '$.activityType.typeKey'), '')) LIKE '%ride%')
209
+ AND try_cast(json_extract_string(summary.metrics_json, '$.maxFtp') AS DOUBLE) IS NOT NULL
210
+ ON CONFLICT (observation_id) DO UPDATE SET
211
+ observed_at = excluded.observed_at,
212
+ value_number = excluded.value_number,
213
+ sport = excluded.sport,
214
+ raw_object_hash = excluded.raw_object_hash;
215
+
216
+ INSERT INTO training_metric_observations
217
+ (observation_id, provider, metric_name, sport, observed_at, value_number, source_type, source_remote_id, activity_source_id, raw_object_hash)
218
+ SELECT
219
+ 'garmin:cycling_ftp:source:' || entity.remote_id,
220
+ 'garmin', 'cycling_ftp_w', 'cycling',
221
+ cast(json_extract_string(entity.payload_json, '$.calendarDate') AS TIMESTAMPTZ),
222
+ try_cast(json_extract_string(entity.payload_json, '$.functionalThresholdPower') AS DOUBLE),
223
+ 'cycling_ftp', entity.remote_id, NULL, entity.raw_object_hash
224
+ FROM source_entities AS entity
225
+ WHERE entity.provider = 'garmin'
226
+ AND entity.entity_type = 'training_metric'
227
+ AND upper(coalesce(json_extract_string(entity.payload_json, '$.sport'), '')) = 'CYCLING'
228
+ AND json_extract_string(entity.payload_json, '$.calendarDate') IS NOT NULL
229
+ AND try_cast(json_extract_string(entity.payload_json, '$.functionalThresholdPower') AS DOUBLE) IS NOT NULL
230
+ ON CONFLICT (observation_id) DO UPDATE SET
231
+ observed_at = excluded.observed_at,
232
+ value_number = excluded.value_number,
233
+ raw_object_hash = excluded.raw_object_hash;
234
+ `,
235
+ };
236
+ const garminActivitySportAndFtpBackfillMigration = {
237
+ version: 8,
238
+ name: 'garmin_activity_sport_and_ftp_history_backfill',
239
+ sql: `
240
+ UPDATE activities AS activity
241
+ SET sport = coalesce(
242
+ json_extract_string(summary.metrics_json, '$.activityType.typeKey'),
243
+ json_extract_string(summary.metrics_json, '$.activityTypeDTO.typeKey')
244
+ )
245
+ FROM activity_sources AS source
246
+ JOIN activity_summaries AS summary USING (activity_source_id)
247
+ WHERE source.provider = 'garmin'
248
+ AND source.activity_id = activity.activity_id
249
+ AND activity.sport IS NULL;
250
+
251
+ INSERT INTO training_metric_observations
252
+ (observation_id, provider, metric_name, sport, observed_at, value_number, source_type, source_remote_id, activity_source_id, raw_object_hash)
253
+ SELECT
254
+ 'garmin:cycling_ftp:activity:' || source.remote_activity_id,
255
+ 'garmin', 'cycling_ftp_w', coalesce(activity.sport, 'cycling'), activity.started_at_utc,
256
+ try_cast(json_extract_string(summary.metrics_json, '$.maxFtp') AS DOUBLE),
257
+ 'activity_summary', source.remote_activity_id, source.activity_source_id, source.raw_object_hash
258
+ FROM activity_sources AS source
259
+ JOIN activities AS activity USING (activity_id)
260
+ JOIN activity_summaries AS summary USING (activity_source_id)
261
+ WHERE source.provider = 'garmin'
262
+ AND activity.started_at_utc IS NOT NULL
263
+ AND (lower(coalesce(activity.sport, '')) LIKE '%cycl%' OR lower(coalesce(activity.sport, '')) LIKE '%ride%')
264
+ AND try_cast(json_extract_string(summary.metrics_json, '$.maxFtp') AS DOUBLE) IS NOT NULL
265
+ ON CONFLICT (observation_id) DO UPDATE SET
266
+ observed_at = excluded.observed_at,
267
+ value_number = excluded.value_number,
268
+ sport = excluded.sport,
269
+ raw_object_hash = excluded.raw_object_hash;
270
+
271
+ UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
272
+ `,
273
+ };
274
+ const garminPrimaryDailyHealthMigration = {
275
+ version: 9,
276
+ name: 'garmin_primary_daily_health',
277
+ sql: `
278
+ -- Keep all provider observations in daily_metrics. The user-facing health
279
+ -- projection selects one coherent source per date so Garmin does not get
280
+ -- silently mixed with values that Intervals derived from the same device.
281
+ CREATE OR REPLACE VIEW daily_health AS
282
+ WITH provider_health AS (
283
+ SELECT provider, metric_date,
284
+ max(value_number) FILTER (WHERE metric_name = 'resting_hr_bpm') AS resting_hr_bpm,
285
+ max(value_number) FILTER (WHERE metric_name = 'hrv_ms') AS hrv_ms,
286
+ max(value_number) FILTER (WHERE metric_name = 'sleep_seconds') AS sleep_seconds,
287
+ max(value_number) FILTER (WHERE metric_name = 'sleep_score') AS sleep_score,
288
+ max(value_number) FILTER (WHERE metric_name = 'stress') AS stress,
289
+ max(value_number) FILTER (WHERE metric_name = 'body_battery') AS body_battery,
290
+ max(value_number) FILTER (WHERE metric_name = 'readiness') AS readiness,
291
+ max(value_number) FILTER (WHERE metric_name = 'spo2_pct') AS spo2_pct,
292
+ max(value_number) FILTER (WHERE metric_name = 'weight_kg') AS weight_kg,
293
+ max(value_number) FILTER (WHERE metric_name = 'steps') AS steps
294
+ FROM daily_metrics
295
+ GROUP BY provider, metric_date
296
+ ), ranked AS (
297
+ SELECT provider_health.*,
298
+ row_number() OVER (
299
+ PARTITION BY metric_date
300
+ ORDER BY CASE provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END, provider
301
+ ) AS source_rank
302
+ FROM provider_health
303
+ )
304
+ SELECT * EXCLUDE (source_rank)
305
+ FROM ranked
306
+ WHERE source_rank = 1;
307
+ `,
308
+ };
309
+ const garminHistoricalMetricsAndStreamsMigration = {
310
+ version: 10,
311
+ name: 'garmin_historical_metrics_and_detailed_streams',
312
+ sql: `
313
+ DROP INDEX IF EXISTS training_metric_observations_metric_idx;
314
+ ALTER TABLE training_metric_observations ALTER COLUMN value_number DROP NOT NULL;
315
+ ALTER TABLE training_metric_observations ADD COLUMN IF NOT EXISTS value_text VARCHAR;
316
+ ALTER TABLE training_metric_observations ADD COLUMN IF NOT EXISTS unit VARCHAR;
317
+ ALTER TABLE training_metric_observations ADD COLUMN IF NOT EXISTS device_id VARCHAR;
318
+ ALTER TABLE training_metric_observations ADD COLUMN IF NOT EXISTS dimensions_json JSON;
319
+
320
+ CREATE TABLE IF NOT EXISTS wellness_samples (
321
+ sample_id VARCHAR PRIMARY KEY,
322
+ provider VARCHAR NOT NULL,
323
+ metric_name VARCHAR NOT NULL,
324
+ observed_at TIMESTAMPTZ NOT NULL,
325
+ value_number DOUBLE NOT NULL,
326
+ unit VARCHAR,
327
+ source_type VARCHAR NOT NULL,
328
+ source_remote_id VARCHAR NOT NULL,
329
+ raw_object_hash VARCHAR
330
+ );
331
+ CREATE INDEX IF NOT EXISTS wellness_samples_metric_idx
332
+ ON wellness_samples (provider, metric_name, observed_at);
333
+
334
+ CREATE TABLE IF NOT EXISTS health_sessions (
335
+ session_id VARCHAR PRIMARY KEY,
336
+ provider VARCHAR NOT NULL,
337
+ session_type VARCHAR NOT NULL,
338
+ occurred_on DATE,
339
+ started_at TIMESTAMPTZ,
340
+ ended_at TIMESTAMPTZ,
341
+ source_type VARCHAR NOT NULL,
342
+ source_remote_id VARCHAR NOT NULL,
343
+ payload_json JSON NOT NULL,
344
+ raw_object_hash VARCHAR
345
+ );
346
+ CREATE INDEX IF NOT EXISTS health_sessions_date_idx
347
+ ON health_sessions (provider, session_type, occurred_on);
348
+
349
+ CREATE TABLE IF NOT EXISTS activity_power_bests (
350
+ provider VARCHAR NOT NULL,
351
+ activity_source_id VARCHAR NOT NULL,
352
+ duration_s INTEGER NOT NULL,
353
+ best_power_w DOUBLE NOT NULL,
354
+ source_type VARCHAR NOT NULL,
355
+ raw_object_hash VARCHAR,
356
+ PRIMARY KEY (provider, activity_source_id, duration_s)
357
+ );
358
+ CREATE OR REPLACE VIEW power_best_facts AS
359
+ SELECT best.*, source.activity_id, activity.started_at_utc, activity.sport
360
+ FROM activity_power_bests AS best
361
+ JOIN activity_sources AS source USING (activity_source_id)
362
+ JOIN activities AS activity USING (activity_id);
363
+
364
+ UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
365
+ `,
366
+ // DuckDB 1.4.4's Node binding can abort when this index rebuild shares the
367
+ // commit transaction with the populated-table ALTERs above. Run it after
368
+ // that durable migration transaction instead; IF NOT EXISTS makes retries
369
+ // safe across subsequent opens.
370
+ postCommitSql: `
371
+ CREATE INDEX IF NOT EXISTS training_metric_observations_metric_idx
372
+ ON training_metric_observations (provider, metric_name, sport, observed_at);
373
+ `,
374
+ };
375
+ const garminUtcActivityTimestampMigration = {
376
+ version: 11,
377
+ name: 'garmin_gmt_activity_timestamps_are_utc',
378
+ sql: `
379
+ -- Garmin labels these fields GMT but commonly omits the offset. Without an
380
+ -- explicit Z DuckDB treats them as the host's local time when binding them
381
+ -- to TIMESTAMPTZ, shifting activity matching by the local UTC offset.
382
+ UPDATE activities AS activity
383
+ SET started_at_utc = try_cast(replace(gmt_value, ' ', 'T') || 'Z' AS TIMESTAMPTZ)
384
+ FROM activity_sources AS source
385
+ JOIN source_entities AS entity
386
+ ON entity.provider = source.provider
387
+ AND entity.entity_type = 'activity'
388
+ AND entity.remote_id = source.remote_activity_id
389
+ CROSS JOIN LATERAL (
390
+ SELECT coalesce(
391
+ json_extract_string(entity.payload_json, '$.startTimeGMT'),
392
+ json_extract_string(entity.payload_json, '$.summaryDTO.startTimeGMT')
393
+ ) AS gmt_value
394
+ ) AS timestamp_source
395
+ WHERE source.provider = 'garmin'
396
+ AND source.activity_id = activity.activity_id
397
+ AND gmt_value IS NOT NULL
398
+ AND NOT regexp_matches(gmt_value, '(Z|[+-][0-9]{2}:?[0-9]{2})$');
399
+
400
+ UPDATE training_metric_observations AS observation
401
+ SET observed_at = try_cast(replace(gmt_value, ' ', 'T') || 'Z' AS TIMESTAMPTZ)
402
+ FROM source_entities AS entity
403
+ CROSS JOIN LATERAL (
404
+ SELECT coalesce(
405
+ json_extract_string(entity.payload_json, '$.startTimeGMT'),
406
+ json_extract_string(entity.payload_json, '$.summaryDTO.startTimeGMT')
407
+ ) AS gmt_value
408
+ ) AS timestamp_source
409
+ WHERE observation.provider = 'garmin'
410
+ AND observation.source_type = 'activity_summary'
411
+ AND observation.source_remote_id = entity.remote_id
412
+ AND entity.provider = 'garmin'
413
+ AND entity.entity_type = 'activity'
414
+ AND gmt_value IS NOT NULL
415
+ AND NOT regexp_matches(gmt_value, '(Z|[+-][0-9]{2}:?[0-9]{2})$');
416
+
417
+ UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
418
+ `,
419
+ };
420
+ const garminLactateThresholdMetricsMigration = {
421
+ version: 12,
422
+ name: 'garmin_lactate_threshold_training_metrics',
423
+ sql: `
424
+ WITH source AS (
425
+ SELECT remote_id, raw_object_hash,
426
+ try_cast(
427
+ CASE WHEN regexp_matches(coalesce(json_extract_string(payload_json, '$.power.calendarDate'), ''), '(Z|[+-][0-9]{2}:?[0-9]{2})$')
428
+ THEN replace(json_extract_string(payload_json, '$.power.calendarDate'), ' ', 'T')
429
+ ELSE replace(json_extract_string(payload_json, '$.power.calendarDate'), ' ', 'T') || 'Z'
430
+ END AS TIMESTAMPTZ
431
+ ) AS power_observed_at,
432
+ try_cast(
433
+ CASE WHEN regexp_matches(coalesce(json_extract_string(payload_json, '$.speed_and_heart_rate.calendarDate'), ''), '(Z|[+-][0-9]{2}:?[0-9]{2})$')
434
+ THEN replace(json_extract_string(payload_json, '$.speed_and_heart_rate.calendarDate'), ' ', 'T')
435
+ ELSE replace(json_extract_string(payload_json, '$.speed_and_heart_rate.calendarDate'), ' ', 'T') || 'Z'
436
+ END AS TIMESTAMPTZ
437
+ ) AS heart_rate_observed_at,
438
+ try_cast(json_extract_string(payload_json, '$.power.functionalThresholdPower') AS DOUBLE) AS running_power_w,
439
+ try_cast(json_extract_string(payload_json, '$.power.powerToWeight') AS DOUBLE) AS running_power_w_kg,
440
+ try_cast(json_extract_string(payload_json, '$.speed_and_heart_rate.speed') AS DOUBLE) AS running_pace_encoding,
441
+ try_cast(json_extract_string(payload_json, '$.speed_and_heart_rate.heartRate') AS DOUBLE) AS running_heart_rate_bpm,
442
+ try_cast(json_extract_string(payload_json, '$.speed_and_heart_rate.heartRateCycling') AS DOUBLE) AS cycling_heart_rate_bpm,
443
+ json_extract_string(payload_json, '$.power.origin') AS origin,
444
+ try_cast(json_extract_string(payload_json, '$.power.isStale') AS BOOLEAN) AS is_stale,
445
+ try_cast(json_extract_string(payload_json, '$.power.weight') AS DOUBLE) AS weight_kg,
446
+ json_extract_string(payload_json, '$.power.ftpCreateTime') AS ftp_create_time,
447
+ json_extract_string(payload_json, '$.power.weightCreateTime') AS weight_create_time
448
+ FROM source_entities
449
+ WHERE provider = 'garmin' AND entity_type = 'lactate_threshold'
450
+ ), observations AS (
451
+ SELECT 'garmin:lactate_threshold:' || remote_id || ':running_power' AS observation_id,
452
+ 'running_lactate_threshold_power_w' AS metric_name, 'running' AS sport,
453
+ coalesce(power_observed_at, heart_rate_observed_at) AS observed_at, running_power_w AS value_number,
454
+ 'W' AS unit, json_object('origin', origin, 'isStale', is_stale, 'weightKg', weight_kg,
455
+ 'powerToWeight', running_power_w_kg, 'ftpCreateTime', ftp_create_time, 'weightCreateTime', weight_create_time) AS dimensions_json,
456
+ remote_id, raw_object_hash
457
+ FROM source
458
+ UNION ALL
459
+ SELECT 'garmin:lactate_threshold:' || remote_id || ':running_power_to_weight',
460
+ 'running_lactate_threshold_power_w_kg', 'running', coalesce(power_observed_at, heart_rate_observed_at), running_power_w_kg,
461
+ 'W/kg', json_object('origin', origin, 'isStale', is_stale, 'weightKg', weight_kg,
462
+ 'powerToWeight', running_power_w_kg, 'ftpCreateTime', ftp_create_time, 'weightCreateTime', weight_create_time),
463
+ remote_id, raw_object_hash
464
+ FROM source
465
+ UNION ALL
466
+ SELECT 'garmin:lactate_threshold:' || remote_id || ':running_pace',
467
+ 'running_lactate_threshold_pace_s_per_km', 'running', coalesce(heart_rate_observed_at, power_observed_at), running_pace_encoding * 600,
468
+ 's/km', json_object('rawPaceEncoding', running_pace_encoding), remote_id, raw_object_hash
469
+ FROM source
470
+ UNION ALL
471
+ SELECT 'garmin:lactate_threshold:' || remote_id || ':running_heart_rate',
472
+ 'running_lactate_threshold_hr_bpm', 'running', coalesce(heart_rate_observed_at, power_observed_at), running_heart_rate_bpm,
473
+ 'bpm', json('{}'), remote_id, raw_object_hash
474
+ FROM source
475
+ UNION ALL
476
+ SELECT 'garmin:lactate_threshold:' || remote_id || ':cycling_heart_rate',
477
+ 'cycling_lactate_threshold_hr_bpm', 'cycling', coalesce(heart_rate_observed_at, power_observed_at), cycling_heart_rate_bpm,
478
+ 'bpm', json('{}'), remote_id, raw_object_hash
479
+ FROM source
480
+ )
481
+ INSERT INTO training_metric_observations
482
+ (observation_id, provider, metric_name, sport, observed_at, value_number, value_text, unit, device_id, dimensions_json, source_type, source_remote_id, activity_source_id, raw_object_hash)
483
+ SELECT observation_id, 'garmin', metric_name, sport, observed_at, value_number, NULL, unit, NULL, dimensions_json,
484
+ 'lactate_threshold', remote_id, NULL, raw_object_hash
485
+ FROM observations
486
+ WHERE observed_at IS NOT NULL AND value_number IS NOT NULL
487
+ ON CONFLICT (observation_id) DO UPDATE SET
488
+ sport = excluded.sport, observed_at = excluded.observed_at, value_number = excluded.value_number,
489
+ value_text = excluded.value_text, unit = excluded.unit, device_id = excluded.device_id,
490
+ dimensions_json = excluded.dimensions_json, source_type = excluded.source_type,
491
+ source_remote_id = excluded.source_remote_id, activity_source_id = excluded.activity_source_id,
492
+ raw_object_hash = excluded.raw_object_hash;
493
+
494
+ UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
495
+ `,
496
+ };
497
+ const swimFactsAndQualityMigration = {
498
+ version: 13,
499
+ name: 'swim_facts_quality_and_canonical_provenance',
500
+ sql: `
501
+ -- Explicit length records are intentionally separate from generic samples.
502
+ -- A sample stream without a provider-supplied length boundary must never be
503
+ -- expanded into artificial 25 m (or similar) laps.
504
+ CREATE TABLE IF NOT EXISTS swim_lengths (
505
+ activity_source_id VARCHAR NOT NULL,
506
+ source VARCHAR NOT NULL,
507
+ length_index INTEGER NOT NULL,
508
+ lap_index INTEGER,
509
+ pool_length_m DOUBLE,
510
+ start_time TIMESTAMPTZ,
511
+ duration_s DOUBLE,
512
+ active_duration_s DOUBLE,
513
+ distance_m DOUBLE,
514
+ stroke_count DOUBLE,
515
+ stroke_rate DOUBLE,
516
+ swolf DOUBLE,
517
+ avg_hr DOUBLE,
518
+ max_hr DOUBLE,
519
+ is_rest BOOLEAN,
520
+ confidence VARCHAR NOT NULL,
521
+ metrics_json JSON NOT NULL DEFAULT '{}',
522
+ raw_object_hash VARCHAR,
523
+ PRIMARY KEY (activity_source_id, source, length_index)
524
+ );
525
+ CREATE INDEX IF NOT EXISTS swim_lengths_source_idx ON swim_lengths (activity_source_id, source, length_index);
526
+
527
+ -- Sets preserve the provider's grouping rather than turning a detected
528
+ -- "12 x 52 m" block into a user-authored 12 x 50 m workout.
529
+ CREATE TABLE IF NOT EXISTS swim_sets (
530
+ activity_source_id VARCHAR NOT NULL,
531
+ source_type VARCHAR NOT NULL,
532
+ set_index INTEGER NOT NULL,
533
+ label VARCHAR,
534
+ reps INTEGER,
535
+ rep_distance_m DOUBLE,
536
+ total_distance_m DOUBLE,
537
+ work_s DOUBLE,
538
+ rest_s DOUBLE,
539
+ avg_pace DOUBLE,
540
+ avg_hr DOUBLE,
541
+ max_hr DOUBLE,
542
+ stroke_rate DOUBLE,
543
+ metrics_json JSON NOT NULL DEFAULT '{}',
544
+ raw_object_hash VARCHAR,
545
+ PRIMARY KEY (activity_source_id, source_type, set_index)
546
+ );
547
+ CREATE INDEX IF NOT EXISTS swim_sets_source_idx ON swim_sets (activity_source_id, source_type, set_index);
548
+
549
+ CREATE TABLE IF NOT EXISTS activity_quality_flags (
550
+ activity_source_id VARCHAR NOT NULL,
551
+ flag_code VARCHAR NOT NULL,
552
+ severity VARCHAR NOT NULL,
553
+ details_json JSON NOT NULL DEFAULT '{}',
554
+ raw_object_hash VARCHAR,
555
+ PRIMARY KEY (activity_source_id, flag_code)
556
+ );
557
+ CREATE INDEX IF NOT EXISTS activity_quality_flags_source_idx ON activity_quality_flags (activity_source_id, severity);
558
+
559
+ ALTER TABLE activity_intervals ADD COLUMN IF NOT EXISTS duration_s DOUBLE;
560
+ ALTER TABLE activity_intervals ADD COLUMN IF NOT EXISTS moving_s DOUBLE;
561
+ ALTER TABLE activity_intervals ADD COLUMN IF NOT EXISTS source_type VARCHAR;
562
+ -- DuckDB binds SELECT intervals.* at view-creation time, so refresh this
563
+ -- projection after extending the underlying interval table.
564
+ CREATE OR REPLACE VIEW activity_interval_facts AS
565
+ SELECT intervals.*, source.activity_id, source.provider,
566
+ activity.started_at_utc, activity.sport, activity.name
567
+ FROM activity_intervals AS intervals
568
+ JOIN activity_sources AS source USING (activity_source_id)
569
+ JOIN activities AS activity USING (activity_id);
570
+
571
+ -- Older Garmin imports retained the useful split summaries in one raw
572
+ -- payload, then tried to read interval fields from its wrapper object. A
573
+ -- row per provider summary restores the supplied duration/HR/distance
574
+ -- without manufacturing chronology or per-length splits.
575
+ DELETE FROM activity_intervals AS interval
576
+ USING activity_sources AS source, source_entities AS entity
577
+ WHERE interval.activity_source_id = source.activity_source_id
578
+ AND source.provider = 'garmin'
579
+ AND entity.provider = 'garmin'
580
+ AND entity.entity_type = 'activity_interval'
581
+ AND entity.parent_remote_id = source.remote_activity_id
582
+ AND interval.interval_key = entity.remote_id
583
+ AND json_type(entity.payload_json, '$.splitSummaries') = 'ARRAY';
584
+ -- Some Garmin detail endpoints supplied a wrapper with no interval fields
585
+ -- at all. Those rows are neither a split nor a useful no-distance segment;
586
+ -- remove only that entirely-null legacy shape before adding the supplied
587
+ -- split summaries below.
588
+ DELETE FROM activity_intervals AS interval
589
+ USING activity_sources AS source
590
+ WHERE interval.activity_source_id = source.activity_source_id
591
+ AND source.provider = 'garmin'
592
+ AND interval.source_type IS NULL
593
+ AND interval.start_s IS NULL AND interval.end_s IS NULL AND interval.duration_s IS NULL AND interval.moving_s IS NULL
594
+ AND interval.distance_m IS NULL AND interval.avg_power IS NULL AND interval.avg_hr IS NULL
595
+ AND interval.avg_pace IS NULL AND interval.intensity IS NULL;
596
+
597
+ INSERT INTO activity_intervals
598
+ (activity_source_id, interval_key, label, start_s, end_s, distance_m, avg_power, avg_hr, avg_pace, intensity, metrics_json, duration_s, moving_s, source_type)
599
+ SELECT source.activity_source_id,
600
+ 'garmin:split_summary:' || entry.key,
601
+ json_extract_string(entry.value, '$.splitType'),
602
+ NULL, NULL,
603
+ try_cast(json_extract_string(entry.value, '$.distance') AS DOUBLE),
604
+ NULL,
605
+ try_cast(json_extract_string(entry.value, '$.averageHR') AS DOUBLE),
606
+ NULL, NULL,
607
+ json_object('splitSummary', entry.value),
608
+ try_cast(json_extract_string(entry.value, '$.duration') AS DOUBLE),
609
+ try_cast(json_extract_string(entry.value, '$.movingDuration') AS DOUBLE),
610
+ 'garmin_detected'
611
+ FROM source_entities AS entity
612
+ JOIN activity_sources AS source
613
+ ON source.provider = 'garmin' AND source.remote_activity_id = entity.parent_remote_id
614
+ CROSS JOIN json_each(entity.payload_json, '$.splitSummaries') AS entry
615
+ WHERE entity.provider = 'garmin' AND entity.entity_type = 'activity_interval'
616
+ ON CONFLICT (activity_source_id, interval_key) DO UPDATE SET
617
+ label = excluded.label, start_s = excluded.start_s, end_s = excluded.end_s,
618
+ distance_m = excluded.distance_m, avg_power = excluded.avg_power, avg_hr = excluded.avg_hr,
619
+ avg_pace = excluded.avg_pace, intensity = excluded.intensity, metrics_json = excluded.metrics_json,
620
+ duration_s = excluded.duration_s, moving_s = excluded.moving_s, source_type = excluded.source_type;
621
+
622
+ INSERT INTO swim_sets
623
+ (activity_source_id, source_type, set_index, label, reps, rep_distance_m, total_distance_m, work_s, rest_s, avg_pace, avg_hr, max_hr, stroke_rate, metrics_json, raw_object_hash)
624
+ SELECT source.activity_source_id, 'garmin_detected', try_cast(entry.key AS INTEGER),
625
+ json_extract_string(entry.value, '$.splitType'),
626
+ try_cast(json_extract_string(entry.value, '$.noOfSplits') AS INTEGER),
627
+ NULL,
628
+ try_cast(json_extract_string(entry.value, '$.distance') AS DOUBLE),
629
+ try_cast(json_extract_string(entry.value, '$.movingDuration') AS DOUBLE),
630
+ NULL, NULL,
631
+ try_cast(json_extract_string(entry.value, '$.averageHR') AS DOUBLE),
632
+ try_cast(json_extract_string(entry.value, '$.maxHR') AS DOUBLE),
633
+ NULL,
634
+ json_object('splitSummary', entry.value), entity.raw_object_hash
635
+ FROM source_entities AS entity
636
+ JOIN activity_sources AS source
637
+ ON source.provider = 'garmin' AND source.remote_activity_id = entity.parent_remote_id
638
+ CROSS JOIN json_each(entity.payload_json, '$.splitSummaries') AS entry
639
+ WHERE entity.provider = 'garmin' AND entity.entity_type = 'activity_interval'
640
+ ON CONFLICT (activity_source_id, source_type, set_index) DO UPDATE SET
641
+ label = excluded.label, reps = excluded.reps, rep_distance_m = excluded.rep_distance_m,
642
+ total_distance_m = excluded.total_distance_m, work_s = excluded.work_s, rest_s = excluded.rest_s,
643
+ avg_pace = excluded.avg_pace, avg_hr = excluded.avg_hr, max_hr = excluded.max_hr,
644
+ stroke_rate = excluded.stroke_rate, metrics_json = excluded.metrics_json,
645
+ raw_object_hash = excluded.raw_object_hash;
646
+
647
+ -- Intervals.icu exposes its swim blocks on the activity summary. Parse
648
+ -- only its documented compact labels; duration and pace stay null because
649
+ -- that payload does not supply them reliably.
650
+ WITH blocks AS (
651
+ SELECT source.activity_source_id, source.raw_object_hash, try_cast(entry.key AS INTEGER) AS set_index,
652
+ json_extract_string(entry.value, '$') AS raw_label,
653
+ regexp_extract(json_extract_string(entry.value, '$'), '^\\s*(\\d+)\\s*x\\s*(\\d+(?:\\.\\d+)?)\\s*m(?:\\s+(\\d+(?:\\.\\d+)?)\\s*bpm)?\\s*$', 1) AS reps_text,
654
+ regexp_extract(json_extract_string(entry.value, '$'), '^\\s*(\\d+)\\s*x\\s*(\\d+(?:\\.\\d+)?)\\s*m(?:\\s+(\\d+(?:\\.\\d+)?)\\s*bpm)?\\s*$', 2) AS distance_text,
655
+ regexp_extract(json_extract_string(entry.value, '$'), '^\\s*(\\d+)\\s*x\\s*(\\d+(?:\\.\\d+)?)\\s*m(?:\\s+(\\d+(?:\\.\\d+)?)\\s*bpm)?\\s*$', 3) AS hr_text
656
+ FROM source_entities AS entity
657
+ JOIN activity_sources AS source
658
+ ON source.provider = 'intervals' AND source.remote_activity_id = entity.remote_id
659
+ CROSS JOIN json_each(entity.payload_json, '$.interval_summary') AS entry
660
+ WHERE entity.provider = 'intervals' AND entity.entity_type = 'activity'
661
+ )
662
+ INSERT INTO swim_sets
663
+ (activity_source_id, source_type, set_index, label, reps, rep_distance_m, total_distance_m, work_s, rest_s, avg_pace, avg_hr, max_hr, stroke_rate, metrics_json, raw_object_hash)
664
+ SELECT activity_source_id, 'intervals_auto', set_index, raw_label,
665
+ try_cast(reps_text AS INTEGER), try_cast(distance_text AS DOUBLE),
666
+ try_cast(reps_text AS DOUBLE) * try_cast(distance_text AS DOUBLE),
667
+ NULL, NULL, NULL, try_cast(nullif(hr_text, '') AS DOUBLE), NULL, NULL,
668
+ json_object('rawLabel', raw_label, 'providerGrouping', 'intervals_auto'), raw_object_hash
669
+ FROM blocks
670
+ WHERE reps_text <> '' AND distance_text <> ''
671
+ ON CONFLICT (activity_source_id, source_type, set_index) DO UPDATE SET
672
+ label = excluded.label, reps = excluded.reps, rep_distance_m = excluded.rep_distance_m,
673
+ total_distance_m = excluded.total_distance_m, work_s = excluded.work_s, rest_s = excluded.rest_s,
674
+ avg_pace = excluded.avg_pace, avg_hr = excluded.avg_hr, max_hr = excluded.max_hr,
675
+ stroke_rate = excluded.stroke_rate, metrics_json = excluded.metrics_json,
676
+ raw_object_hash = excluded.raw_object_hash;
677
+
678
+ INSERT INTO activity_intervals
679
+ (activity_source_id, interval_key, label, start_s, end_s, distance_m, avg_power, avg_hr, avg_pace, intensity, metrics_json, duration_s, moving_s, source_type)
680
+ SELECT activity_source_id, 'intervals:auto:' || set_index, label,
681
+ NULL, NULL, total_distance_m, NULL, avg_hr, NULL, NULL,
682
+ metrics_json, NULL, NULL, 'intervals_auto'
683
+ FROM swim_sets
684
+ WHERE source_type = 'intervals_auto'
685
+ ON CONFLICT (activity_source_id, interval_key) DO UPDATE SET
686
+ label = excluded.label, start_s = excluded.start_s, end_s = excluded.end_s,
687
+ distance_m = excluded.distance_m, avg_power = excluded.avg_power, avg_hr = excluded.avg_hr,
688
+ avg_pace = excluded.avg_pace, intensity = excluded.intensity, metrics_json = excluded.metrics_json,
689
+ duration_s = excluded.duration_s, moving_s = excluded.moving_s, source_type = excluded.source_type;
690
+
691
+ -- Quality flags are source facts. They make the data limitation visible
692
+ -- without suppressing the original provider summary.
693
+ INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
694
+ SELECT source.activity_source_id, 'pool_length_implausible', 'warning',
695
+ json_object('poolLengthM', CASE WHEN pool_length >= 100 THEN pool_length / 100 ELSE pool_length END), source.raw_object_hash
696
+ FROM activity_sources AS source
697
+ JOIN activities AS activity USING (activity_id)
698
+ JOIN activity_summaries AS summary USING (activity_source_id)
699
+ CROSS JOIN LATERAL (SELECT try_cast(json_extract_string(summary.metrics_json, '$.poolLength') AS DOUBLE) AS pool_length) AS values
700
+ WHERE source.provider = 'garmin' AND lower(activity.sport) = 'lap_swimming'
701
+ AND pool_length IS NOT NULL
702
+ AND (CASE WHEN pool_length >= 100 THEN pool_length / 100 ELSE pool_length END) NOT BETWEEN 15 AND 100
703
+ ON CONFLICT (activity_source_id, flag_code) DO UPDATE SET severity = excluded.severity, details_json = excluded.details_json, raw_object_hash = excluded.raw_object_hash;
704
+
705
+ INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
706
+ SELECT source.activity_source_id, 'zero_swim_speed_and_cadence', 'warning',
707
+ json_object('averageSpeedMps', average_speed, 'strokeCadenceSpm', cadence, 'durationS', greatest(coalesce(summary.moving_s, 0), coalesce(summary.elapsed_s, 0))), source.raw_object_hash
708
+ FROM activity_sources AS source
709
+ JOIN activities AS activity USING (activity_id)
710
+ JOIN activity_summaries AS summary USING (activity_source_id)
711
+ CROSS JOIN LATERAL (
712
+ SELECT try_cast(json_extract_string(summary.metrics_json, '$.averageSpeed') AS DOUBLE) AS average_speed,
713
+ try_cast(json_extract_string(summary.metrics_json, '$.averageSwimCadenceInStrokesPerMinute') AS DOUBLE) AS cadence
714
+ ) AS values
715
+ WHERE source.provider = 'garmin' AND lower(activity.sport) = 'lap_swimming'
716
+ AND greatest(coalesce(summary.moving_s, 0), coalesce(summary.elapsed_s, 0)) >= 900
717
+ AND average_speed = 0 AND cadence = 0
718
+ ON CONFLICT (activity_source_id, flag_code) DO UPDATE SET severity = excluded.severity, details_json = excluded.details_json, raw_object_hash = excluded.raw_object_hash;
719
+
720
+ INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
721
+ SELECT source.activity_source_id, 'active_lengths_distance_mismatch', 'warning',
722
+ json_object('activeLengths', active_lengths, 'poolLengthM', pool_length_m, 'expectedDistanceM', active_lengths * pool_length_m, 'summaryDistanceM', summary.distance_m), source.raw_object_hash
723
+ FROM activity_sources AS source
724
+ JOIN activities AS activity USING (activity_id)
725
+ JOIN activity_summaries AS summary USING (activity_source_id)
726
+ CROSS JOIN LATERAL (
727
+ SELECT try_cast(json_extract_string(summary.metrics_json, '$.activeLengths') AS DOUBLE) AS active_lengths,
728
+ try_cast(json_extract_string(summary.metrics_json, '$.poolLength') AS DOUBLE) AS raw_pool_length
729
+ ) AS raw_values
730
+ CROSS JOIN LATERAL (SELECT CASE WHEN raw_pool_length >= 100 THEN raw_pool_length / 100 ELSE raw_pool_length END AS pool_length_m) AS values
731
+ WHERE source.provider = 'garmin' AND lower(activity.sport) = 'lap_swimming'
732
+ AND active_lengths > 0 AND pool_length_m > 0 AND summary.distance_m > 0
733
+ AND abs(active_lengths * pool_length_m - summary.distance_m) > greatest(pool_length_m, active_lengths * pool_length_m * 0.02)
734
+ ON CONFLICT (activity_source_id, flag_code) DO UPDATE SET severity = excluded.severity, details_json = excluded.details_json, raw_object_hash = excluded.raw_object_hash;
735
+
736
+ INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
737
+ SELECT source.activity_source_id, 'swim_length_data_unavailable', 'info',
738
+ json_object('reason', 'No explicit provider-supplied per-length records were imported.'), source.raw_object_hash
739
+ FROM activity_sources AS source
740
+ JOIN activities AS activity USING (activity_id)
741
+ WHERE source.provider = 'garmin' AND lower(activity.sport) = 'lap_swimming'
742
+ AND NOT EXISTS (SELECT 1 FROM swim_lengths AS length WHERE length.activity_source_id = source.activity_source_id)
743
+ ON CONFLICT (activity_source_id, flag_code) DO UPDATE SET severity = excluded.severity, details_json = excluded.details_json, raw_object_hash = excluded.raw_object_hash;
744
+
745
+ WITH comparisons AS (
746
+ SELECT activity_id,
747
+ max(activity_source_id) FILTER (WHERE provider = 'garmin') AS garmin_source_id,
748
+ max(distance_m) FILTER (WHERE provider = 'garmin') AS garmin_distance_m,
749
+ max(activity_source_id) FILTER (WHERE provider = 'intervals') AS intervals_source_id,
750
+ max(distance_m) FILTER (WHERE provider = 'intervals') AS intervals_distance_m
751
+ FROM activity_summary_facts
752
+ GROUP BY activity_id
753
+ )
754
+ INSERT INTO activity_quality_flags (activity_source_id, flag_code, severity, details_json, raw_object_hash)
755
+ SELECT source.activity_source_id, 'provider_distance_disagreement', 'warning',
756
+ json_object('garminDistanceM', comparison.garmin_distance_m, 'intervalsDistanceM', comparison.intervals_distance_m,
757
+ 'differenceM', abs(comparison.garmin_distance_m - comparison.intervals_distance_m), 'toleranceM', greatest(50, comparison.garmin_distance_m * 0.05)),
758
+ source.raw_object_hash
759
+ FROM comparisons AS comparison
760
+ JOIN activity_sources AS source ON source.activity_id = comparison.activity_id AND source.provider IN ('garmin', 'intervals')
761
+ WHERE comparison.garmin_distance_m IS NOT NULL AND comparison.intervals_distance_m IS NOT NULL
762
+ AND abs(comparison.garmin_distance_m - comparison.intervals_distance_m) > greatest(50, comparison.garmin_distance_m * 0.05)
763
+ ON CONFLICT (activity_source_id, flag_code) DO UPDATE SET severity = excluded.severity, details_json = excluded.details_json, raw_object_hash = excluded.raw_object_hash;
764
+
765
+ CREATE OR REPLACE VIEW swim_length_facts AS
766
+ SELECT length.*, source.activity_id, source.provider, activity.started_at_utc, activity.sport, activity.name
767
+ FROM swim_lengths AS length
768
+ JOIN activity_sources AS source USING (activity_source_id)
769
+ JOIN activities AS activity USING (activity_id);
770
+
771
+ CREATE OR REPLACE VIEW swim_set_facts AS
772
+ SELECT swim_set.*, source.activity_id, source.provider, activity.started_at_utc, activity.sport, activity.name
773
+ FROM swim_sets AS swim_set
774
+ JOIN activity_sources AS source USING (activity_source_id)
775
+ JOIN activities AS activity USING (activity_id);
776
+
777
+ CREATE OR REPLACE VIEW activity_quality_flag_facts AS
778
+ SELECT flag.*, source.activity_id, source.provider, activity.started_at_utc, activity.sport, activity.name
779
+ FROM activity_quality_flags AS flag
780
+ JOIN activity_sources AS source USING (activity_source_id)
781
+ JOIN activities AS activity USING (activity_id);
782
+
783
+ CREATE OR REPLACE VIEW canonical_activity_facts AS
784
+ WITH source_metrics AS (
785
+ SELECT source.activity_id, source.activity_source_id, source.provider,
786
+ summary.distance_m, summary.moving_s, summary.elapsed_s, summary.avg_hr
787
+ FROM activity_sources AS source
788
+ LEFT JOIN activity_summaries AS summary USING (activity_source_id)
789
+ ), per_activity AS (
790
+ SELECT activity_id,
791
+ max(activity_source_id) FILTER (WHERE provider = 'garmin') AS garmin_activity_source_id,
792
+ max(distance_m) FILTER (WHERE provider = 'garmin') AS garmin_distance_m,
793
+ max(moving_s) FILTER (WHERE provider = 'garmin') AS garmin_moving_s,
794
+ max(elapsed_s) FILTER (WHERE provider = 'garmin') AS garmin_elapsed_s,
795
+ max(avg_hr) FILTER (WHERE provider = 'garmin') AS garmin_avg_hr,
796
+ max(activity_source_id) FILTER (WHERE provider = 'intervals') AS intervals_activity_source_id,
797
+ max(distance_m) FILTER (WHERE provider = 'intervals') AS intervals_distance_m,
798
+ max(moving_s) FILTER (WHERE provider = 'intervals') AS intervals_moving_s,
799
+ max(elapsed_s) FILTER (WHERE provider = 'intervals') AS intervals_elapsed_s,
800
+ max(avg_hr) FILTER (WHERE provider = 'intervals') AS intervals_avg_hr
801
+ FROM source_metrics GROUP BY activity_id
802
+ ), flag_rollup AS (
803
+ SELECT source.activity_id,
804
+ json_group_array(json_object('activitySourceId', flag.activity_source_id, 'code', flag.flag_code,
805
+ 'severity', flag.severity, 'details', flag.details_json)) AS quality_flags
806
+ FROM activity_quality_flags AS flag
807
+ JOIN activity_sources AS source USING (activity_source_id)
808
+ GROUP BY source.activity_id
809
+ )
810
+ SELECT activity.activity_id, activity.started_at_utc, activity.started_at_local, activity.timezone, activity.sport, activity.name, activity.link_state,
811
+ per_activity.garmin_activity_source_id, per_activity.garmin_distance_m, per_activity.garmin_moving_s, per_activity.garmin_elapsed_s, per_activity.garmin_avg_hr,
812
+ per_activity.intervals_activity_source_id, per_activity.intervals_distance_m, per_activity.intervals_moving_s, per_activity.intervals_elapsed_s, per_activity.intervals_avg_hr,
813
+ coalesce(per_activity.garmin_distance_m, per_activity.intervals_distance_m) AS resolved_distance_m,
814
+ CASE WHEN per_activity.garmin_distance_m IS NOT NULL THEN 'garmin' WHEN per_activity.intervals_distance_m IS NOT NULL THEN 'intervals' ELSE NULL END AS distance_source,
815
+ coalesce(per_activity.garmin_moving_s, per_activity.intervals_moving_s) AS resolved_moving_s,
816
+ CASE WHEN per_activity.garmin_moving_s IS NOT NULL THEN 'garmin' WHEN per_activity.intervals_moving_s IS NOT NULL THEN 'intervals' ELSE NULL END AS moving_s_source,
817
+ coalesce(per_activity.garmin_elapsed_s, per_activity.intervals_elapsed_s) AS resolved_elapsed_s,
818
+ CASE WHEN per_activity.garmin_elapsed_s IS NOT NULL THEN 'garmin' WHEN per_activity.intervals_elapsed_s IS NOT NULL THEN 'intervals' ELSE NULL END AS elapsed_s_source,
819
+ coalesce(per_activity.garmin_avg_hr, per_activity.intervals_avg_hr) AS resolved_avg_hr,
820
+ CASE WHEN per_activity.garmin_avg_hr IS NOT NULL THEN 'garmin' WHEN per_activity.intervals_avg_hr IS NOT NULL THEN 'intervals' ELSE NULL END AS avg_hr_source,
821
+ CASE WHEN per_activity.garmin_distance_m IS NOT NULL AND per_activity.intervals_distance_m IS NOT NULL
822
+ THEN abs(per_activity.garmin_distance_m - per_activity.intervals_distance_m) ELSE NULL END AS provider_distance_difference_m,
823
+ coalesce(flag_rollup.quality_flags, cast('[]' AS JSON)) AS quality_flags
824
+ FROM activities AS activity
825
+ LEFT JOIN per_activity USING (activity_id)
826
+ LEFT JOIN flag_rollup USING (activity_id);
827
+
828
+ UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
829
+ `,
830
+ };
831
+ export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration];
832
+ //# sourceMappingURL=migrations.js.map