@clipin/convex-wearables 0.0.2 → 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 (104) hide show
  1. package/README.md +395 -0
  2. package/dist/client/index.d.ts +47 -6
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +30 -0
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/client/types.d.ts +83 -0
  7. package/dist/client/types.d.ts.map +1 -1
  8. package/dist/client/types.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +50 -0
  10. package/dist/component/_generated/component.d.ts.map +1 -0
  11. package/dist/component/_generated/component.js +11 -0
  12. package/dist/component/_generated/component.js.map +1 -0
  13. package/dist/component/backfillJobs.d.ts +11 -11
  14. package/dist/component/connections.d.ts +9 -9
  15. package/dist/component/connections.d.ts.map +1 -1
  16. package/dist/component/connections.js +2 -0
  17. package/dist/component/connections.js.map +1 -1
  18. package/dist/component/dataPoints.d.ts +153 -39
  19. package/dist/component/dataPoints.d.ts.map +1 -1
  20. package/dist/component/dataPoints.js +1048 -139
  21. package/dist/component/dataPoints.js.map +1 -1
  22. package/dist/component/events.d.ts +13 -13
  23. package/dist/component/garminBackfill.d.ts +2 -2
  24. package/dist/component/garminWebhooks.d.ts +2 -2
  25. package/dist/component/garminWebhooks.d.ts.map +1 -1
  26. package/dist/component/garminWebhooks.js +2 -0
  27. package/dist/component/garminWebhooks.js.map +1 -1
  28. package/dist/component/lifecycle.d.ts +1 -1
  29. package/dist/component/lifecycle.d.ts.map +1 -1
  30. package/dist/component/lifecycle.js +39 -1
  31. package/dist/component/lifecycle.js.map +1 -1
  32. package/dist/component/oauthStates.d.ts +3 -3
  33. package/dist/component/schema.d.ts +192 -28
  34. package/dist/component/schema.d.ts.map +1 -1
  35. package/dist/component/schema.js +89 -0
  36. package/dist/component/schema.js.map +1 -1
  37. package/dist/component/sdkPush.d.ts +11 -11
  38. package/dist/component/summaries.d.ts +4 -4
  39. package/dist/component/syncJobs.d.ts +23 -23
  40. package/dist/component/syncWorkflow.d.ts +2 -2
  41. package/dist/component/timeSeriesPolicyUtils.d.ts +97 -0
  42. package/dist/component/timeSeriesPolicyUtils.d.ts.map +1 -0
  43. package/dist/component/timeSeriesPolicyUtils.js +163 -0
  44. package/dist/component/timeSeriesPolicyUtils.js.map +1 -0
  45. package/dist/test.d.ts +581 -0
  46. package/dist/test.d.ts.map +1 -0
  47. package/dist/test.js +17 -0
  48. package/dist/test.js.map +1 -0
  49. package/package.json +12 -2
  50. package/src/client/_generated/_ignore.ts +2 -0
  51. package/src/client/index.test.ts +149 -0
  52. package/src/client/index.ts +859 -0
  53. package/src/client/types.ts +632 -0
  54. package/src/component/_generated/_ignore.ts +2 -0
  55. package/src/component/_generated/api.ts +16 -0
  56. package/src/component/_generated/component.ts +74 -0
  57. package/src/component/_generated/dataModel.ts +40 -0
  58. package/src/component/_generated/server.ts +48 -0
  59. package/src/component/backfillJobs.test.ts +47 -0
  60. package/src/component/backfillJobs.ts +245 -0
  61. package/src/component/connections.test.ts +297 -0
  62. package/src/component/connections.ts +329 -0
  63. package/src/component/convex.config.ts +7 -0
  64. package/src/component/dataPoints.test.ts +827 -0
  65. package/src/component/dataPoints.ts +1676 -0
  66. package/src/component/dataSources.test.ts +247 -0
  67. package/src/component/dataSources.ts +109 -0
  68. package/src/component/events.test.ts +380 -0
  69. package/src/component/events.ts +288 -0
  70. package/src/component/garminBackfill.ts +343 -0
  71. package/src/component/garminWebhooks.test.ts +609 -0
  72. package/src/component/garminWebhooks.ts +656 -0
  73. package/src/component/httpHandlers.ts +153 -0
  74. package/src/component/lifecycle.test.ts +179 -0
  75. package/src/component/lifecycle.ts +128 -0
  76. package/src/component/menstrualCycles.ts +124 -0
  77. package/src/component/oauthActions.ts +261 -0
  78. package/src/component/oauthStates.test.ts +170 -0
  79. package/src/component/oauthStates.ts +85 -0
  80. package/src/component/providerSettings.ts +66 -0
  81. package/src/component/providers/additionalProviders.test.ts +401 -0
  82. package/src/component/providers/garmin.ts +1169 -0
  83. package/src/component/providers/oauth.test.ts +174 -0
  84. package/src/component/providers/oauth.ts +246 -0
  85. package/src/component/providers/polar.ts +220 -0
  86. package/src/component/providers/registry.ts +37 -0
  87. package/src/component/providers/strava.test.ts +195 -0
  88. package/src/component/providers/strava.ts +253 -0
  89. package/src/component/providers/suunto.ts +592 -0
  90. package/src/component/providers/types.ts +189 -0
  91. package/src/component/providers/whoop.ts +600 -0
  92. package/src/component/schema.ts +445 -0
  93. package/src/component/sdkPush.test.ts +367 -0
  94. package/src/component/sdkPush.ts +440 -0
  95. package/src/component/summaries.test.ts +201 -0
  96. package/src/component/summaries.ts +143 -0
  97. package/src/component/syncJobs.test.ts +254 -0
  98. package/src/component/syncJobs.ts +140 -0
  99. package/src/component/syncWorkflow.test.ts +87 -0
  100. package/src/component/syncWorkflow.ts +739 -0
  101. package/src/component/test.setup.ts +6 -0
  102. package/src/component/timeSeriesPolicyUtils.ts +243 -0
  103. package/src/component/workflowManager.ts +19 -0
  104. package/src/test.ts +25 -0
@@ -0,0 +1,445 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ /**
5
+ * Provider name union — all supported wearable providers.
6
+ */
7
+ export const providerName = v.union(
8
+ v.literal("garmin"),
9
+ v.literal("suunto"),
10
+ v.literal("polar"),
11
+ v.literal("whoop"),
12
+ v.literal("strava"),
13
+ v.literal("apple"),
14
+ v.literal("samsung"),
15
+ v.literal("google"),
16
+ );
17
+
18
+ /**
19
+ * Connection status enum.
20
+ */
21
+ export const connectionStatus = v.union(
22
+ v.literal("active"),
23
+ v.literal("inactive"),
24
+ v.literal("revoked"),
25
+ v.literal("expired"),
26
+ v.literal("error"),
27
+ );
28
+
29
+ /**
30
+ * Event category — top-level classification.
31
+ */
32
+ export const eventCategory = v.union(v.literal("workout"), v.literal("sleep"));
33
+
34
+ /**
35
+ * Sync job status.
36
+ */
37
+ export const syncJobStatus = v.union(
38
+ v.literal("queued"),
39
+ v.literal("running"),
40
+ v.literal("completed"),
41
+ v.literal("failed"),
42
+ v.literal("canceled"),
43
+ );
44
+
45
+ /**
46
+ * Backfill job status.
47
+ */
48
+ export const backfillStatus = v.union(
49
+ v.literal("queued"),
50
+ v.literal("running"),
51
+ v.literal("completed"),
52
+ v.literal("failed"),
53
+ v.literal("canceled"),
54
+ );
55
+
56
+ /**
57
+ * Supported rollup aggregations.
58
+ */
59
+ export const timeSeriesAggregation = v.union(
60
+ v.literal("avg"),
61
+ v.literal("min"),
62
+ v.literal("max"),
63
+ v.literal("last"),
64
+ v.literal("count"),
65
+ );
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Schema
69
+ // ---------------------------------------------------------------------------
70
+
71
+ export default defineSchema({
72
+ // -------------------------------------------------------------------------
73
+ // Connections — OAuth tokens + provider link per user
74
+ // -------------------------------------------------------------------------
75
+ connections: defineTable({
76
+ userId: v.string(), // app-provided user identifier
77
+ provider: providerName,
78
+ providerUserId: v.optional(v.string()),
79
+ providerUsername: v.optional(v.string()),
80
+ accessToken: v.optional(v.string()),
81
+ refreshToken: v.optional(v.string()),
82
+ tokenExpiresAt: v.optional(v.number()), // unix ms
83
+ scope: v.optional(v.string()),
84
+ status: connectionStatus,
85
+ lastSyncedAt: v.optional(v.number()), // unix ms
86
+ })
87
+ .index("by_user", ["userId"])
88
+ .index("by_user_provider", ["userId", "provider"])
89
+ .index("by_provider_user", ["provider", "providerUserId"])
90
+ .index("by_status", ["status"]),
91
+
92
+ // -------------------------------------------------------------------------
93
+ // Data Sources — user + provider + device combination
94
+ // -------------------------------------------------------------------------
95
+ dataSources: defineTable({
96
+ userId: v.string(),
97
+ provider: providerName,
98
+ connectionId: v.optional(v.id("connections")),
99
+ deviceModel: v.optional(v.string()),
100
+ softwareVersion: v.optional(v.string()),
101
+ source: v.optional(v.string()),
102
+ deviceType: v.optional(v.string()),
103
+ originalSourceName: v.optional(v.string()),
104
+ })
105
+ .index("by_user_provider", ["userId", "provider"])
106
+ .index("by_user_provider_device", ["userId", "provider", "deviceModel", "source"])
107
+ .index("by_connection", ["connectionId"]),
108
+
109
+ // -------------------------------------------------------------------------
110
+ // Data Points — time-series health metrics (heart rate, steps, SpO2, etc.)
111
+ // -------------------------------------------------------------------------
112
+ dataPoints: defineTable({
113
+ dataSourceId: v.id("dataSources"),
114
+ seriesType: v.string(), // "heart_rate", "steps", "spo2", etc.
115
+ recordedAt: v.number(), // unix ms
116
+ value: v.number(),
117
+ externalId: v.optional(v.string()),
118
+ })
119
+ .index("by_source_type_time", ["dataSourceId", "seriesType", "recordedAt"])
120
+ .index("by_type_time", ["seriesType", "recordedAt"]),
121
+
122
+ // -------------------------------------------------------------------------
123
+ // Time-Series Rollups — bucketed storage for dense historical series
124
+ // -------------------------------------------------------------------------
125
+ timeSeriesRollups: defineTable({
126
+ dataSourceId: v.id("dataSources"),
127
+ seriesType: v.string(),
128
+ bucketMs: v.number(),
129
+ bucketStart: v.number(),
130
+ bucketEnd: v.number(),
131
+ avg: v.number(),
132
+ min: v.number(),
133
+ max: v.number(),
134
+ last: v.number(),
135
+ lastRecordedAt: v.number(),
136
+ count: v.number(),
137
+ updatedAt: v.number(),
138
+ })
139
+ .index("by_source_type_bucket", ["dataSourceId", "seriesType", "bucketStart"])
140
+ .index("by_source_type_bucket_size", ["dataSourceId", "seriesType", "bucketMs", "bucketStart"])
141
+ .index("by_source_bucket", ["dataSourceId", "bucketStart"])
142
+ .index("by_type_bucket", ["seriesType", "bucketStart"]),
143
+
144
+ // -------------------------------------------------------------------------
145
+ // Events — workouts and sleep sessions
146
+ // -------------------------------------------------------------------------
147
+ events: defineTable({
148
+ dataSourceId: v.id("dataSources"),
149
+ userId: v.string(), // denormalized for direct user queries
150
+ category: eventCategory,
151
+ type: v.optional(v.string()), // "running", "cycling", "night_sleep", etc.
152
+ sourceName: v.optional(v.string()),
153
+ durationSeconds: v.optional(v.number()),
154
+ startDatetime: v.number(), // unix ms
155
+ endDatetime: v.optional(v.number()), // unix ms
156
+ externalId: v.optional(v.string()),
157
+
158
+ // Workout detail fields (present when category == "workout")
159
+ heartRateMin: v.optional(v.number()),
160
+ heartRateMax: v.optional(v.number()),
161
+ heartRateAvg: v.optional(v.number()),
162
+ energyBurned: v.optional(v.number()),
163
+ distance: v.optional(v.number()),
164
+ stepsCount: v.optional(v.number()),
165
+ maxSpeed: v.optional(v.number()),
166
+ maxWatts: v.optional(v.number()),
167
+ movingTimeSeconds: v.optional(v.number()),
168
+ totalElevationGain: v.optional(v.number()),
169
+ averageSpeed: v.optional(v.number()),
170
+ averageWatts: v.optional(v.number()),
171
+ elevHigh: v.optional(v.number()),
172
+ elevLow: v.optional(v.number()),
173
+
174
+ // Sleep detail fields (present when category == "sleep")
175
+ sleepTotalDurationMinutes: v.optional(v.number()),
176
+ sleepTimeInBedMinutes: v.optional(v.number()),
177
+ sleepEfficiencyScore: v.optional(v.number()),
178
+ sleepDeepMinutes: v.optional(v.number()),
179
+ sleepRemMinutes: v.optional(v.number()),
180
+ sleepLightMinutes: v.optional(v.number()),
181
+ sleepAwakeMinutes: v.optional(v.number()),
182
+ isNap: v.optional(v.boolean()),
183
+ sleepStages: v.optional(
184
+ v.array(
185
+ v.object({
186
+ stage: v.string(), // "deep", "rem", "light", "awake"
187
+ startTime: v.number(), // unix ms
188
+ endTime: v.number(), // unix ms
189
+ }),
190
+ ),
191
+ ),
192
+ })
193
+ .index("by_user_category_time", ["userId", "category", "startDatetime"])
194
+ .index("by_source_category_time", ["dataSourceId", "category", "startDatetime"])
195
+ .index("by_source_start_end", ["dataSourceId", "startDatetime", "endDatetime"])
196
+ .index("by_external_id", ["externalId"]),
197
+
198
+ // -------------------------------------------------------------------------
199
+ // Daily Summaries — precomputed daily aggregates
200
+ // -------------------------------------------------------------------------
201
+ dailySummaries: defineTable({
202
+ userId: v.string(),
203
+ date: v.string(), // "2026-03-15" (ISO date string)
204
+ category: v.string(), // "activity" | "sleep" | "recovery" | "body"
205
+
206
+ // Activity metrics
207
+ totalSteps: v.optional(v.number()),
208
+ totalCalories: v.optional(v.number()),
209
+ activeCalories: v.optional(v.number()),
210
+ activeMinutes: v.optional(v.number()),
211
+ totalDistance: v.optional(v.number()),
212
+ floorsClimbed: v.optional(v.number()),
213
+ avgHeartRate: v.optional(v.number()),
214
+ maxHeartRate: v.optional(v.number()),
215
+ minHeartRate: v.optional(v.number()),
216
+
217
+ // Sleep metrics
218
+ sleepDurationMinutes: v.optional(v.number()),
219
+ sleepEfficiency: v.optional(v.number()),
220
+ deepSleepMinutes: v.optional(v.number()),
221
+ remSleepMinutes: v.optional(v.number()),
222
+ lightSleepMinutes: v.optional(v.number()),
223
+ awakeDuringMinutes: v.optional(v.number()),
224
+ timeInBedMinutes: v.optional(v.number()),
225
+
226
+ // Recovery metrics
227
+ hrvAvg: v.optional(v.number()),
228
+ hrvRmssd: v.optional(v.number()),
229
+ restingHeartRate: v.optional(v.number()),
230
+ recoveryScore: v.optional(v.number()),
231
+
232
+ // Body metrics
233
+ weight: v.optional(v.number()),
234
+ bodyFatPercentage: v.optional(v.number()),
235
+ bodyMassIndex: v.optional(v.number()),
236
+ leanBodyMass: v.optional(v.number()),
237
+ bodyTemperature: v.optional(v.number()),
238
+
239
+ // Stress / other
240
+ avgStressLevel: v.optional(v.number()),
241
+ bodyBattery: v.optional(v.number()),
242
+ spo2Avg: v.optional(v.number()),
243
+ })
244
+ .index("by_user_category_date", ["userId", "category", "date"])
245
+ .index("by_user_date", ["userId", "date"]),
246
+
247
+ // -------------------------------------------------------------------------
248
+ // Sync Jobs — workflow tracking for data syncs
249
+ // -------------------------------------------------------------------------
250
+ syncJobs: defineTable({
251
+ connectionId: v.id("connections"),
252
+ userId: v.string(),
253
+ provider: providerName,
254
+ mode: v.optional(v.union(v.literal("manual"), v.literal("cron"), v.literal("webhook"))),
255
+ triggerSource: v.optional(v.string()),
256
+ idempotencyKey: v.string(),
257
+ status: syncJobStatus,
258
+ startedAt: v.number(), // unix ms
259
+ completedAt: v.optional(v.number()),
260
+ error: v.optional(v.string()),
261
+ recordsProcessed: v.optional(v.number()),
262
+ workflowId: v.optional(v.string()),
263
+ windowStart: v.optional(v.number()),
264
+ windowEnd: v.optional(v.number()),
265
+ attempt: v.optional(v.number()),
266
+ lastHeartbeatAt: v.optional(v.number()),
267
+ cursor: v.optional(v.string()),
268
+ currentPhase: v.optional(
269
+ v.union(v.literal("events"), v.literal("dataPoints"), v.literal("summaries")),
270
+ ),
271
+ })
272
+ .index("by_user", ["userId"])
273
+ .index("by_connection", ["connectionId"])
274
+ .index("by_user_provider", ["userId", "provider"])
275
+ .index("by_user_status", ["userId", "status"])
276
+ .index("by_status", ["status"])
277
+ .index("by_idempotency_key", ["idempotencyKey"])
278
+ .index("by_workflow", ["workflowId"]),
279
+
280
+ // -------------------------------------------------------------------------
281
+ // OAuth States — temporary state for OAuth PKCE flows
282
+ // -------------------------------------------------------------------------
283
+ oauthStates: defineTable({
284
+ state: v.string(), // random state token
285
+ userId: v.string(),
286
+ provider: providerName,
287
+ codeVerifier: v.optional(v.string()), // PKCE
288
+ redirectUri: v.optional(v.string()),
289
+ createdAt: v.number(), // unix ms
290
+ }).index("by_state", ["state"]),
291
+
292
+ // -------------------------------------------------------------------------
293
+ // Provider Settings — which providers are enabled + config
294
+ // -------------------------------------------------------------------------
295
+ providerSettings: defineTable({
296
+ provider: providerName,
297
+ isEnabled: v.boolean(),
298
+ clientId: v.optional(v.string()),
299
+ clientSecret: v.optional(v.string()),
300
+ subscriptionKey: v.optional(v.string()),
301
+ updatedAt: v.optional(v.number()),
302
+ }).index("by_provider", ["provider"]),
303
+
304
+ // -------------------------------------------------------------------------
305
+ // Time-Series Policy Rules — default and preset-based storage rules
306
+ // -------------------------------------------------------------------------
307
+ timeSeriesPolicyRules: defineTable({
308
+ policySetKind: v.union(v.literal("default"), v.literal("preset")),
309
+ policySetKey: v.string(), // "__default__" or host-defined preset key
310
+ scopeKey: v.string(),
311
+ provider: v.optional(providerName),
312
+ seriesType: v.optional(v.string()),
313
+ tiers: v.array(
314
+ v.union(
315
+ v.object({
316
+ kind: v.literal("raw"),
317
+ fromAgeMs: v.number(),
318
+ toAgeMs: v.union(v.number(), v.null()),
319
+ }),
320
+ v.object({
321
+ kind: v.literal("rollup"),
322
+ fromAgeMs: v.number(),
323
+ toAgeMs: v.union(v.number(), v.null()),
324
+ bucketMs: v.number(),
325
+ aggregations: v.array(timeSeriesAggregation),
326
+ }),
327
+ ),
328
+ ),
329
+ updatedAt: v.number(),
330
+ })
331
+ .index("by_set", ["policySetKind", "policySetKey"])
332
+ .index("by_set_scope", ["policySetKind", "policySetKey", "provider", "seriesType"]),
333
+
334
+ // -------------------------------------------------------------------------
335
+ // Time-Series Policy Assignments — per-user preset selection
336
+ // -------------------------------------------------------------------------
337
+ timeSeriesPolicyAssignments: defineTable({
338
+ userId: v.string(),
339
+ presetKey: v.string(),
340
+ updatedAt: v.number(),
341
+ })
342
+ .index("by_user", ["userId"])
343
+ .index("by_preset", ["presetKey"]),
344
+
345
+ // -------------------------------------------------------------------------
346
+ // Time-Series Policy Settings — singleton settings for maintenance
347
+ // -------------------------------------------------------------------------
348
+ timeSeriesPolicySettings: defineTable({
349
+ key: v.string(),
350
+ maintenanceEnabled: v.boolean(),
351
+ maintenanceIntervalMs: v.number(),
352
+ scheduledAt: v.optional(v.number()),
353
+ lastRunAt: v.optional(v.number()),
354
+ lastError: v.optional(v.string()),
355
+ updatedAt: v.number(),
356
+ }).index("by_key", ["key"]),
357
+
358
+ // -------------------------------------------------------------------------
359
+ // Time-Series Series State — per source/series maintenance cursor
360
+ // -------------------------------------------------------------------------
361
+ timeSeriesSeriesState: defineTable({
362
+ dataSourceId: v.id("dataSources"),
363
+ connectionId: v.optional(v.id("connections")),
364
+ userId: v.string(),
365
+ provider: providerName,
366
+ seriesType: v.string(),
367
+ latestRecordedAt: v.number(),
368
+ lastIngestedAt: v.number(),
369
+ nextMaintenanceAt: v.number(),
370
+ lastMaintenanceAt: v.optional(v.number()),
371
+ updatedAt: v.number(),
372
+ })
373
+ .index("by_source_series", ["dataSourceId", "seriesType"])
374
+ .index("by_next_maintenance", ["nextMaintenanceAt"])
375
+ .index("by_user", ["userId"]),
376
+
377
+ // -------------------------------------------------------------------------
378
+ // Provider Priorities — sync order when multiple providers have same data
379
+ // -------------------------------------------------------------------------
380
+ providerPriorities: defineTable({
381
+ provider: providerName,
382
+ priority: v.number(), // 1 = highest
383
+ })
384
+ .index("by_provider", ["provider"])
385
+ .index("by_priority", ["priority"]),
386
+
387
+ // -------------------------------------------------------------------------
388
+ // Menstrual Cycle Tracking (MCT) — Women's Health data
389
+ // -------------------------------------------------------------------------
390
+ menstrualCycles: defineTable({
391
+ userId: v.string(),
392
+ provider: providerName,
393
+ externalId: v.optional(v.string()), // summaryId from provider
394
+ periodStartDate: v.string(), // "2026-03-01" ISO date
395
+ dayInCycle: v.optional(v.number()),
396
+ cycleLength: v.optional(v.number()),
397
+ predictedCycleLength: v.optional(v.number()),
398
+ periodLength: v.optional(v.number()),
399
+ currentPhase: v.optional(v.number()), // numeric phase ID
400
+ currentPhaseType: v.optional(v.string()), // "MENSTRUAL", "FOLLICULAR", "OVULATION", "LUTEAL", "SECOND_TRIMESTER", etc.
401
+ lengthOfCurrentPhase: v.optional(v.number()),
402
+ daysUntilNextPhase: v.optional(v.number()),
403
+ isPredictedCycle: v.optional(v.boolean()),
404
+ fertileWindowStart: v.optional(v.number()), // day in cycle
405
+ lengthOfFertileWindow: v.optional(v.number()),
406
+ lastUpdatedAt: v.optional(v.number()), // unix ms
407
+
408
+ // Pregnancy data (present when in pregnant phase)
409
+ isPregnant: v.optional(v.boolean()),
410
+ pregnancyDueDate: v.optional(v.string()), // "2026-09-15" ISO date
411
+ pregnancyOriginalDueDate: v.optional(v.string()),
412
+ pregnancyCycleStartDate: v.optional(v.string()),
413
+ pregnancyTitle: v.optional(v.string()),
414
+ numberOfBabies: v.optional(v.string()), // "SINGLE", "TWINS", etc.
415
+ })
416
+ .index("by_user_date", ["userId", "periodStartDate"])
417
+ .index("by_user_provider", ["userId", "provider"])
418
+ .index("by_external_id", ["externalId"]),
419
+
420
+ // -------------------------------------------------------------------------
421
+ // Backfill Jobs — tracks long-running backfill operations (e.g. Garmin)
422
+ // -------------------------------------------------------------------------
423
+ backfillJobs: defineTable({
424
+ connectionId: v.id("connections"),
425
+ userId: v.string(),
426
+ provider: providerName,
427
+ dataType: v.string(), // "full" for a full run; current type tracked separately
428
+ status: backfillStatus,
429
+ startedAt: v.number(),
430
+ completedAt: v.optional(v.number()),
431
+ error: v.optional(v.string()),
432
+ workflowId: v.optional(v.string()),
433
+ windowStart: v.optional(v.number()),
434
+ windowEnd: v.optional(v.number()),
435
+ currentDataType: v.optional(v.string()),
436
+ currentAttempt: v.optional(v.number()),
437
+ currentEventId: v.optional(v.string()),
438
+ completedDataTypes: v.optional(v.array(v.string())),
439
+ lastHeartbeatAt: v.optional(v.number()),
440
+ })
441
+ .index("by_connection", ["connectionId"])
442
+ .index("by_connection_type", ["connectionId", "dataType"])
443
+ .index("by_status", ["status"])
444
+ .index("by_workflow", ["workflowId"]),
445
+ });