@clipin/convex-wearables 0.0.2 → 0.0.3

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 (88) hide show
  1. package/dist/client/index.d.ts +9 -4
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js.map +1 -1
  4. package/dist/component/_generated/component.d.ts +50 -0
  5. package/dist/component/_generated/component.d.ts.map +1 -0
  6. package/dist/component/_generated/component.js +11 -0
  7. package/dist/component/_generated/component.js.map +1 -0
  8. package/dist/component/backfillJobs.d.ts +11 -11
  9. package/dist/component/connections.d.ts +9 -9
  10. package/dist/component/connections.d.ts.map +1 -1
  11. package/dist/component/connections.js +2 -0
  12. package/dist/component/connections.js.map +1 -1
  13. package/dist/component/dataPoints.d.ts +5 -5
  14. package/dist/component/events.d.ts +13 -13
  15. package/dist/component/garminBackfill.d.ts +2 -2
  16. package/dist/component/garminWebhooks.d.ts +2 -2
  17. package/dist/component/garminWebhooks.d.ts.map +1 -1
  18. package/dist/component/garminWebhooks.js +2 -0
  19. package/dist/component/garminWebhooks.js.map +1 -1
  20. package/dist/component/lifecycle.d.ts +1 -1
  21. package/dist/component/lifecycle.d.ts.map +1 -1
  22. package/dist/component/lifecycle.js +2 -0
  23. package/dist/component/lifecycle.js.map +1 -1
  24. package/dist/component/oauthStates.d.ts +3 -3
  25. package/dist/component/schema.d.ts +26 -26
  26. package/dist/component/sdkPush.d.ts +11 -11
  27. package/dist/component/summaries.d.ts +4 -4
  28. package/dist/component/syncJobs.d.ts +23 -23
  29. package/dist/component/syncWorkflow.d.ts +2 -2
  30. package/dist/test.d.ts +421 -0
  31. package/dist/test.d.ts.map +1 -0
  32. package/dist/test.js +17 -0
  33. package/dist/test.js.map +1 -0
  34. package/package.json +12 -2
  35. package/src/client/_generated/_ignore.ts +2 -0
  36. package/src/client/index.test.ts +52 -0
  37. package/src/client/index.ts +784 -0
  38. package/src/client/types.ts +533 -0
  39. package/src/component/_generated/_ignore.ts +2 -0
  40. package/src/component/_generated/api.ts +16 -0
  41. package/src/component/_generated/component.ts +74 -0
  42. package/src/component/_generated/dataModel.ts +40 -0
  43. package/src/component/_generated/server.ts +48 -0
  44. package/src/component/backfillJobs.test.ts +47 -0
  45. package/src/component/backfillJobs.ts +245 -0
  46. package/src/component/connections.test.ts +297 -0
  47. package/src/component/connections.ts +329 -0
  48. package/src/component/convex.config.ts +7 -0
  49. package/src/component/dataPoints.test.ts +282 -0
  50. package/src/component/dataPoints.ts +305 -0
  51. package/src/component/dataSources.test.ts +247 -0
  52. package/src/component/dataSources.ts +109 -0
  53. package/src/component/events.test.ts +380 -0
  54. package/src/component/events.ts +288 -0
  55. package/src/component/garminBackfill.ts +343 -0
  56. package/src/component/garminWebhooks.test.ts +609 -0
  57. package/src/component/garminWebhooks.ts +656 -0
  58. package/src/component/httpHandlers.ts +153 -0
  59. package/src/component/lifecycle.test.ts +179 -0
  60. package/src/component/lifecycle.ts +87 -0
  61. package/src/component/menstrualCycles.ts +124 -0
  62. package/src/component/oauthActions.ts +261 -0
  63. package/src/component/oauthStates.test.ts +170 -0
  64. package/src/component/oauthStates.ts +85 -0
  65. package/src/component/providerSettings.ts +66 -0
  66. package/src/component/providers/additionalProviders.test.ts +401 -0
  67. package/src/component/providers/garmin.ts +1169 -0
  68. package/src/component/providers/oauth.test.ts +174 -0
  69. package/src/component/providers/oauth.ts +246 -0
  70. package/src/component/providers/polar.ts +220 -0
  71. package/src/component/providers/registry.ts +37 -0
  72. package/src/component/providers/strava.test.ts +195 -0
  73. package/src/component/providers/strava.ts +253 -0
  74. package/src/component/providers/suunto.ts +592 -0
  75. package/src/component/providers/types.ts +189 -0
  76. package/src/component/providers/whoop.ts +600 -0
  77. package/src/component/schema.ts +339 -0
  78. package/src/component/sdkPush.test.ts +367 -0
  79. package/src/component/sdkPush.ts +440 -0
  80. package/src/component/summaries.test.ts +201 -0
  81. package/src/component/summaries.ts +143 -0
  82. package/src/component/syncJobs.test.ts +254 -0
  83. package/src/component/syncJobs.ts +140 -0
  84. package/src/component/syncWorkflow.test.ts +87 -0
  85. package/src/component/syncWorkflow.ts +739 -0
  86. package/src/component/test.setup.ts +6 -0
  87. package/src/component/workflowManager.ts +19 -0
  88. package/src/test.ts +25 -0
@@ -0,0 +1,339 @@
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
+ // Schema
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export default defineSchema({
61
+ // -------------------------------------------------------------------------
62
+ // Connections — OAuth tokens + provider link per user
63
+ // -------------------------------------------------------------------------
64
+ connections: defineTable({
65
+ userId: v.string(), // app-provided user identifier
66
+ provider: providerName,
67
+ providerUserId: v.optional(v.string()),
68
+ providerUsername: v.optional(v.string()),
69
+ accessToken: v.optional(v.string()),
70
+ refreshToken: v.optional(v.string()),
71
+ tokenExpiresAt: v.optional(v.number()), // unix ms
72
+ scope: v.optional(v.string()),
73
+ status: connectionStatus,
74
+ lastSyncedAt: v.optional(v.number()), // unix ms
75
+ })
76
+ .index("by_user", ["userId"])
77
+ .index("by_user_provider", ["userId", "provider"])
78
+ .index("by_provider_user", ["provider", "providerUserId"])
79
+ .index("by_status", ["status"]),
80
+
81
+ // -------------------------------------------------------------------------
82
+ // Data Sources — user + provider + device combination
83
+ // -------------------------------------------------------------------------
84
+ dataSources: defineTable({
85
+ userId: v.string(),
86
+ provider: providerName,
87
+ connectionId: v.optional(v.id("connections")),
88
+ deviceModel: v.optional(v.string()),
89
+ softwareVersion: v.optional(v.string()),
90
+ source: v.optional(v.string()),
91
+ deviceType: v.optional(v.string()),
92
+ originalSourceName: v.optional(v.string()),
93
+ })
94
+ .index("by_user_provider", ["userId", "provider"])
95
+ .index("by_user_provider_device", ["userId", "provider", "deviceModel", "source"])
96
+ .index("by_connection", ["connectionId"]),
97
+
98
+ // -------------------------------------------------------------------------
99
+ // Data Points — time-series health metrics (heart rate, steps, SpO2, etc.)
100
+ // -------------------------------------------------------------------------
101
+ dataPoints: defineTable({
102
+ dataSourceId: v.id("dataSources"),
103
+ seriesType: v.string(), // "heart_rate", "steps", "spo2", etc.
104
+ recordedAt: v.number(), // unix ms
105
+ value: v.number(),
106
+ externalId: v.optional(v.string()),
107
+ })
108
+ .index("by_source_type_time", ["dataSourceId", "seriesType", "recordedAt"])
109
+ .index("by_type_time", ["seriesType", "recordedAt"]),
110
+
111
+ // -------------------------------------------------------------------------
112
+ // Events — workouts and sleep sessions
113
+ // -------------------------------------------------------------------------
114
+ events: defineTable({
115
+ dataSourceId: v.id("dataSources"),
116
+ userId: v.string(), // denormalized for direct user queries
117
+ category: eventCategory,
118
+ type: v.optional(v.string()), // "running", "cycling", "night_sleep", etc.
119
+ sourceName: v.optional(v.string()),
120
+ durationSeconds: v.optional(v.number()),
121
+ startDatetime: v.number(), // unix ms
122
+ endDatetime: v.optional(v.number()), // unix ms
123
+ externalId: v.optional(v.string()),
124
+
125
+ // Workout detail fields (present when category == "workout")
126
+ heartRateMin: v.optional(v.number()),
127
+ heartRateMax: v.optional(v.number()),
128
+ heartRateAvg: v.optional(v.number()),
129
+ energyBurned: v.optional(v.number()),
130
+ distance: v.optional(v.number()),
131
+ stepsCount: v.optional(v.number()),
132
+ maxSpeed: v.optional(v.number()),
133
+ maxWatts: v.optional(v.number()),
134
+ movingTimeSeconds: v.optional(v.number()),
135
+ totalElevationGain: v.optional(v.number()),
136
+ averageSpeed: v.optional(v.number()),
137
+ averageWatts: v.optional(v.number()),
138
+ elevHigh: v.optional(v.number()),
139
+ elevLow: v.optional(v.number()),
140
+
141
+ // Sleep detail fields (present when category == "sleep")
142
+ sleepTotalDurationMinutes: v.optional(v.number()),
143
+ sleepTimeInBedMinutes: v.optional(v.number()),
144
+ sleepEfficiencyScore: v.optional(v.number()),
145
+ sleepDeepMinutes: v.optional(v.number()),
146
+ sleepRemMinutes: v.optional(v.number()),
147
+ sleepLightMinutes: v.optional(v.number()),
148
+ sleepAwakeMinutes: v.optional(v.number()),
149
+ isNap: v.optional(v.boolean()),
150
+ sleepStages: v.optional(
151
+ v.array(
152
+ v.object({
153
+ stage: v.string(), // "deep", "rem", "light", "awake"
154
+ startTime: v.number(), // unix ms
155
+ endTime: v.number(), // unix ms
156
+ }),
157
+ ),
158
+ ),
159
+ })
160
+ .index("by_user_category_time", ["userId", "category", "startDatetime"])
161
+ .index("by_source_category_time", ["dataSourceId", "category", "startDatetime"])
162
+ .index("by_source_start_end", ["dataSourceId", "startDatetime", "endDatetime"])
163
+ .index("by_external_id", ["externalId"]),
164
+
165
+ // -------------------------------------------------------------------------
166
+ // Daily Summaries — precomputed daily aggregates
167
+ // -------------------------------------------------------------------------
168
+ dailySummaries: defineTable({
169
+ userId: v.string(),
170
+ date: v.string(), // "2026-03-15" (ISO date string)
171
+ category: v.string(), // "activity" | "sleep" | "recovery" | "body"
172
+
173
+ // Activity metrics
174
+ totalSteps: v.optional(v.number()),
175
+ totalCalories: v.optional(v.number()),
176
+ activeCalories: v.optional(v.number()),
177
+ activeMinutes: v.optional(v.number()),
178
+ totalDistance: v.optional(v.number()),
179
+ floorsClimbed: v.optional(v.number()),
180
+ avgHeartRate: v.optional(v.number()),
181
+ maxHeartRate: v.optional(v.number()),
182
+ minHeartRate: v.optional(v.number()),
183
+
184
+ // Sleep metrics
185
+ sleepDurationMinutes: v.optional(v.number()),
186
+ sleepEfficiency: v.optional(v.number()),
187
+ deepSleepMinutes: v.optional(v.number()),
188
+ remSleepMinutes: v.optional(v.number()),
189
+ lightSleepMinutes: v.optional(v.number()),
190
+ awakeDuringMinutes: v.optional(v.number()),
191
+ timeInBedMinutes: v.optional(v.number()),
192
+
193
+ // Recovery metrics
194
+ hrvAvg: v.optional(v.number()),
195
+ hrvRmssd: v.optional(v.number()),
196
+ restingHeartRate: v.optional(v.number()),
197
+ recoveryScore: v.optional(v.number()),
198
+
199
+ // Body metrics
200
+ weight: v.optional(v.number()),
201
+ bodyFatPercentage: v.optional(v.number()),
202
+ bodyMassIndex: v.optional(v.number()),
203
+ leanBodyMass: v.optional(v.number()),
204
+ bodyTemperature: v.optional(v.number()),
205
+
206
+ // Stress / other
207
+ avgStressLevel: v.optional(v.number()),
208
+ bodyBattery: v.optional(v.number()),
209
+ spo2Avg: v.optional(v.number()),
210
+ })
211
+ .index("by_user_category_date", ["userId", "category", "date"])
212
+ .index("by_user_date", ["userId", "date"]),
213
+
214
+ // -------------------------------------------------------------------------
215
+ // Sync Jobs — workflow tracking for data syncs
216
+ // -------------------------------------------------------------------------
217
+ syncJobs: defineTable({
218
+ connectionId: v.id("connections"),
219
+ userId: v.string(),
220
+ provider: providerName,
221
+ mode: v.optional(v.union(v.literal("manual"), v.literal("cron"), v.literal("webhook"))),
222
+ triggerSource: v.optional(v.string()),
223
+ idempotencyKey: v.string(),
224
+ status: syncJobStatus,
225
+ startedAt: v.number(), // unix ms
226
+ completedAt: v.optional(v.number()),
227
+ error: v.optional(v.string()),
228
+ recordsProcessed: v.optional(v.number()),
229
+ workflowId: v.optional(v.string()),
230
+ windowStart: v.optional(v.number()),
231
+ windowEnd: v.optional(v.number()),
232
+ attempt: v.optional(v.number()),
233
+ lastHeartbeatAt: v.optional(v.number()),
234
+ cursor: v.optional(v.string()),
235
+ currentPhase: v.optional(
236
+ v.union(v.literal("events"), v.literal("dataPoints"), v.literal("summaries")),
237
+ ),
238
+ })
239
+ .index("by_user", ["userId"])
240
+ .index("by_connection", ["connectionId"])
241
+ .index("by_user_provider", ["userId", "provider"])
242
+ .index("by_user_status", ["userId", "status"])
243
+ .index("by_status", ["status"])
244
+ .index("by_idempotency_key", ["idempotencyKey"])
245
+ .index("by_workflow", ["workflowId"]),
246
+
247
+ // -------------------------------------------------------------------------
248
+ // OAuth States — temporary state for OAuth PKCE flows
249
+ // -------------------------------------------------------------------------
250
+ oauthStates: defineTable({
251
+ state: v.string(), // random state token
252
+ userId: v.string(),
253
+ provider: providerName,
254
+ codeVerifier: v.optional(v.string()), // PKCE
255
+ redirectUri: v.optional(v.string()),
256
+ createdAt: v.number(), // unix ms
257
+ }).index("by_state", ["state"]),
258
+
259
+ // -------------------------------------------------------------------------
260
+ // Provider Settings — which providers are enabled + config
261
+ // -------------------------------------------------------------------------
262
+ providerSettings: defineTable({
263
+ provider: providerName,
264
+ isEnabled: v.boolean(),
265
+ clientId: v.optional(v.string()),
266
+ clientSecret: v.optional(v.string()),
267
+ subscriptionKey: v.optional(v.string()),
268
+ updatedAt: v.optional(v.number()),
269
+ }).index("by_provider", ["provider"]),
270
+
271
+ // -------------------------------------------------------------------------
272
+ // Provider Priorities — sync order when multiple providers have same data
273
+ // -------------------------------------------------------------------------
274
+ providerPriorities: defineTable({
275
+ provider: providerName,
276
+ priority: v.number(), // 1 = highest
277
+ })
278
+ .index("by_provider", ["provider"])
279
+ .index("by_priority", ["priority"]),
280
+
281
+ // -------------------------------------------------------------------------
282
+ // Menstrual Cycle Tracking (MCT) — Women's Health data
283
+ // -------------------------------------------------------------------------
284
+ menstrualCycles: defineTable({
285
+ userId: v.string(),
286
+ provider: providerName,
287
+ externalId: v.optional(v.string()), // summaryId from provider
288
+ periodStartDate: v.string(), // "2026-03-01" ISO date
289
+ dayInCycle: v.optional(v.number()),
290
+ cycleLength: v.optional(v.number()),
291
+ predictedCycleLength: v.optional(v.number()),
292
+ periodLength: v.optional(v.number()),
293
+ currentPhase: v.optional(v.number()), // numeric phase ID
294
+ currentPhaseType: v.optional(v.string()), // "MENSTRUAL", "FOLLICULAR", "OVULATION", "LUTEAL", "SECOND_TRIMESTER", etc.
295
+ lengthOfCurrentPhase: v.optional(v.number()),
296
+ daysUntilNextPhase: v.optional(v.number()),
297
+ isPredictedCycle: v.optional(v.boolean()),
298
+ fertileWindowStart: v.optional(v.number()), // day in cycle
299
+ lengthOfFertileWindow: v.optional(v.number()),
300
+ lastUpdatedAt: v.optional(v.number()), // unix ms
301
+
302
+ // Pregnancy data (present when in pregnant phase)
303
+ isPregnant: v.optional(v.boolean()),
304
+ pregnancyDueDate: v.optional(v.string()), // "2026-09-15" ISO date
305
+ pregnancyOriginalDueDate: v.optional(v.string()),
306
+ pregnancyCycleStartDate: v.optional(v.string()),
307
+ pregnancyTitle: v.optional(v.string()),
308
+ numberOfBabies: v.optional(v.string()), // "SINGLE", "TWINS", etc.
309
+ })
310
+ .index("by_user_date", ["userId", "periodStartDate"])
311
+ .index("by_user_provider", ["userId", "provider"])
312
+ .index("by_external_id", ["externalId"]),
313
+
314
+ // -------------------------------------------------------------------------
315
+ // Backfill Jobs — tracks long-running backfill operations (e.g. Garmin)
316
+ // -------------------------------------------------------------------------
317
+ backfillJobs: defineTable({
318
+ connectionId: v.id("connections"),
319
+ userId: v.string(),
320
+ provider: providerName,
321
+ dataType: v.string(), // "full" for a full run; current type tracked separately
322
+ status: backfillStatus,
323
+ startedAt: v.number(),
324
+ completedAt: v.optional(v.number()),
325
+ error: v.optional(v.string()),
326
+ workflowId: v.optional(v.string()),
327
+ windowStart: v.optional(v.number()),
328
+ windowEnd: v.optional(v.number()),
329
+ currentDataType: v.optional(v.string()),
330
+ currentAttempt: v.optional(v.number()),
331
+ currentEventId: v.optional(v.string()),
332
+ completedDataTypes: v.optional(v.array(v.string())),
333
+ lastHeartbeatAt: v.optional(v.number()),
334
+ })
335
+ .index("by_connection", ["connectionId"])
336
+ .index("by_connection_type", ["connectionId", "dataType"])
337
+ .index("by_status", ["status"])
338
+ .index("by_workflow", ["workflowId"]),
339
+ });
@@ -0,0 +1,367 @@
1
+ import { convexTest } from "convex-test";
2
+ import { describe, expect, it } from "vitest";
3
+ import { api } from "./_generated/api";
4
+ import schema from "./schema";
5
+ import { modules } from "./test.setup";
6
+
7
+ describe("sdkPush", () => {
8
+ it("ingests normalized Google Health Connect data into connections, sources, events, points, and summaries", async () => {
9
+ const t = convexTest(schema, modules);
10
+
11
+ const result = await t.action(api.sdkPush.ingestNormalizedPayload, {
12
+ userId: "user-1",
13
+ provider: "google",
14
+ providerUserId: "hc-user-1",
15
+ providerUsername: "denis@example.com",
16
+ sourceMetadata: {
17
+ deviceModel: "Pixel Watch 3",
18
+ source: "health-connect",
19
+ },
20
+ events: [
21
+ {
22
+ category: "sleep",
23
+ type: "sleep_session",
24
+ startDatetime: Date.parse("2026-03-17T22:30:00Z"),
25
+ endDatetime: Date.parse("2026-03-18T06:30:00Z"),
26
+ durationSeconds: 8 * 60 * 60,
27
+ externalId: "hc-sleep-1",
28
+ sleepTotalDurationMinutes: 440,
29
+ sleepTimeInBedMinutes: 480,
30
+ sleepDeepMinutes: 90,
31
+ sleepLightMinutes: 240,
32
+ sleepRemMinutes: 110,
33
+ sleepAwakeMinutes: 40,
34
+ sleepEfficiencyScore: 91,
35
+ },
36
+ ],
37
+ dataPoints: [
38
+ {
39
+ seriesType: "heart_rate",
40
+ recordedAt: Date.parse("2026-03-18T07:00:00Z"),
41
+ value: 58,
42
+ externalId: "hc-hr-1",
43
+ },
44
+ {
45
+ seriesType: "steps",
46
+ recordedAt: Date.parse("2026-03-18T12:00:00Z"),
47
+ value: 4200,
48
+ externalId: "hc-steps-1",
49
+ },
50
+ {
51
+ seriesType: "resting_heart_rate",
52
+ recordedAt: Date.parse("2026-03-18T07:00:00Z"),
53
+ value: 49,
54
+ externalId: "hc-rhr-1",
55
+ },
56
+ ],
57
+ summaries: [
58
+ {
59
+ date: "2026-03-18",
60
+ category: "activity",
61
+ totalSteps: 10000,
62
+ totalCalories: 650,
63
+ },
64
+ {
65
+ date: "2026-03-18",
66
+ category: "recovery",
67
+ restingHeartRate: 49,
68
+ },
69
+ ],
70
+ });
71
+
72
+ expect(result.eventsStored).toBe(1);
73
+ expect(result.dataPointsStored).toBe(3);
74
+ expect(result.summariesStored).toBe(2);
75
+
76
+ const connection = await t.run(async (ctx) => {
77
+ return await ctx.db
78
+ .query("connections")
79
+ .withIndex("by_user_provider", (idx) => idx.eq("userId", "user-1").eq("provider", "google"))
80
+ .first();
81
+ });
82
+ expect(connection).toMatchObject({
83
+ userId: "user-1",
84
+ provider: "google",
85
+ providerUserId: "hc-user-1",
86
+ providerUsername: "denis@example.com",
87
+ status: "active",
88
+ });
89
+ expect(connection?.lastSyncedAt).toBeTypeOf("number");
90
+
91
+ const dataSources = await t.run(async (ctx) => {
92
+ return await ctx.db
93
+ .query("dataSources")
94
+ .withIndex("by_user_provider", (idx) => idx.eq("userId", "user-1").eq("provider", "google"))
95
+ .collect();
96
+ });
97
+ expect(dataSources).toHaveLength(1);
98
+ expect(dataSources[0]).toMatchObject({
99
+ deviceModel: "Pixel Watch 3",
100
+ source: "health-connect",
101
+ });
102
+
103
+ const events = await t.run(async (ctx) => {
104
+ return await ctx.db
105
+ .query("events")
106
+ .withIndex("by_user_category_time", (idx) =>
107
+ idx.eq("userId", "user-1").eq("category", "sleep"),
108
+ )
109
+ .collect();
110
+ });
111
+ expect(events).toHaveLength(1);
112
+ expect(events[0]).toMatchObject({
113
+ externalId: "hc-sleep-1",
114
+ sleepTotalDurationMinutes: 440,
115
+ sourceName: "Google Health Connect",
116
+ });
117
+
118
+ const dataPoints = await t.run(async (ctx) => {
119
+ return await ctx.db
120
+ .query("dataPoints")
121
+ .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", dataSources[0]._id))
122
+ .collect();
123
+ });
124
+ expect(dataPoints).toHaveLength(3);
125
+
126
+ const summaries = await t.run(async (ctx) => {
127
+ return await ctx.db
128
+ .query("dailySummaries")
129
+ .withIndex("by_user_date", (idx) => idx.eq("userId", "user-1").eq("date", "2026-03-18"))
130
+ .collect();
131
+ });
132
+ expect(summaries).toHaveLength(2);
133
+ });
134
+
135
+ it("deduplicates SDK pushes by external id and source-time keys", async () => {
136
+ const t = convexTest(schema, modules);
137
+
138
+ await t.action(api.sdkPush.ingestNormalizedPayload, {
139
+ userId: "user-2",
140
+ provider: "apple",
141
+ sourceMetadata: {
142
+ deviceModel: "Apple Watch Ultra 2",
143
+ source: "healthkit",
144
+ },
145
+ events: [
146
+ {
147
+ category: "workout",
148
+ type: "running",
149
+ startDatetime: Date.parse("2026-03-18T10:00:00Z"),
150
+ endDatetime: Date.parse("2026-03-18T10:30:00Z"),
151
+ externalId: "apple-workout-1",
152
+ distance: 5000,
153
+ },
154
+ ],
155
+ dataPoints: [
156
+ {
157
+ seriesType: "heart_rate",
158
+ recordedAt: Date.parse("2026-03-18T10:15:00Z"),
159
+ value: 148,
160
+ },
161
+ ],
162
+ });
163
+
164
+ await t.action(api.sdkPush.ingestNormalizedPayload, {
165
+ userId: "user-2",
166
+ provider: "apple",
167
+ sourceMetadata: {
168
+ deviceModel: "Apple Watch Ultra 2",
169
+ source: "healthkit",
170
+ },
171
+ events: [
172
+ {
173
+ category: "workout",
174
+ type: "running",
175
+ startDatetime: Date.parse("2026-03-18T10:00:00Z"),
176
+ endDatetime: Date.parse("2026-03-18T10:30:00Z"),
177
+ externalId: "apple-workout-1",
178
+ distance: 5200,
179
+ },
180
+ ],
181
+ dataPoints: [
182
+ {
183
+ seriesType: "heart_rate",
184
+ recordedAt: Date.parse("2026-03-18T10:15:00Z"),
185
+ value: 150,
186
+ },
187
+ ],
188
+ });
189
+
190
+ const sources = await t.run(async (ctx) => {
191
+ return await ctx.db
192
+ .query("dataSources")
193
+ .withIndex("by_user_provider", (idx) => idx.eq("userId", "user-2").eq("provider", "apple"))
194
+ .collect();
195
+ });
196
+
197
+ const events = await t.run(async (ctx) => {
198
+ return await ctx.db
199
+ .query("events")
200
+ .withIndex("by_user_category_time", (idx) =>
201
+ idx.eq("userId", "user-2").eq("category", "workout"),
202
+ )
203
+ .collect();
204
+ });
205
+ expect(events).toHaveLength(1);
206
+ expect(events[0].distance).toBe(5200);
207
+
208
+ const dataPoints = await t.run(async (ctx) => {
209
+ return await ctx.db
210
+ .query("dataPoints")
211
+ .withIndex("by_source_type_time", (idx) =>
212
+ idx
213
+ .eq("dataSourceId", sources[0]._id)
214
+ .eq("seriesType", "heart_rate")
215
+ .eq("recordedAt", Date.parse("2026-03-18T10:15:00Z")),
216
+ )
217
+ .collect();
218
+ });
219
+ expect(dataPoints).toHaveLength(1);
220
+ expect(dataPoints[0].value).toBe(150);
221
+ });
222
+
223
+ it("accepts plan-compatible payload aliases and normalizes series types", async () => {
224
+ const t = convexTest(schema, modules);
225
+
226
+ await t.action(api.sdkPush.ingestNormalizedPayload, {
227
+ userId: "user-3",
228
+ provider: "google",
229
+ syncTimestamp: Date.parse("2026-03-18T18:00:00Z"),
230
+ device: {
231
+ model: "Pixel 9 Pro",
232
+ softwareVersion: "Android 16",
233
+ source: "health-connect",
234
+ },
235
+ dataPoints: [
236
+ {
237
+ seriesType: "hrv_rmssd",
238
+ recordedAt: Date.parse("2026-03-18T07:00:00Z"),
239
+ value: 42,
240
+ externalId: "hc-hrv-1",
241
+ },
242
+ {
243
+ seriesType: "floors_climbed",
244
+ recordedAt: Date.parse("2026-03-18T12:00:00Z"),
245
+ value: 12,
246
+ externalId: "hc-floors-1",
247
+ },
248
+ {
249
+ seriesType: "distance",
250
+ recordedAt: Date.parse("2026-03-18T12:01:00Z"),
251
+ value: 1500,
252
+ externalId: "hc-distance-1",
253
+ },
254
+ {
255
+ seriesType: "active_calories",
256
+ recordedAt: Date.parse("2026-03-18T12:02:00Z"),
257
+ value: 340,
258
+ externalId: "hc-active-calories-1",
259
+ },
260
+ ],
261
+ dailySummaries: [
262
+ {
263
+ date: "2026-03-18",
264
+ category: "activity",
265
+ totalSteps: 12345,
266
+ totalCalories: 780,
267
+ },
268
+ ],
269
+ });
270
+
271
+ const source = await t.run(async (ctx) => {
272
+ return await ctx.db
273
+ .query("dataSources")
274
+ .withIndex("by_user_provider", (idx) => idx.eq("userId", "user-3").eq("provider", "google"))
275
+ .first();
276
+ });
277
+
278
+ expect(source).toMatchObject({
279
+ deviceModel: "Pixel 9 Pro",
280
+ softwareVersion: "Android 16",
281
+ source: "health-connect",
282
+ });
283
+
284
+ const points = await t.run(async (ctx) => {
285
+ return await ctx.db
286
+ .query("dataPoints")
287
+ .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", source!._id))
288
+ .collect();
289
+ });
290
+
291
+ expect(points.map((point) => point.seriesType).sort()).toEqual([
292
+ "active_calories",
293
+ "distance",
294
+ "floors_climbed",
295
+ "heart_rate_variability_rmssd",
296
+ ]);
297
+
298
+ const summaries = await t.run(async (ctx) => {
299
+ return await ctx.db
300
+ .query("dailySummaries")
301
+ .withIndex("by_user_date", (idx) => idx.eq("userId", "user-3").eq("date", "2026-03-18"))
302
+ .collect();
303
+ });
304
+
305
+ expect(summaries).toHaveLength(1);
306
+ expect(summaries[0]).toMatchObject({
307
+ category: "activity",
308
+ totalSteps: 12345,
309
+ totalCalories: 780,
310
+ });
311
+ });
312
+
313
+ it("batches large data-point payloads across multiple writes", async () => {
314
+ const t = convexTest(schema, modules);
315
+
316
+ await t.action(api.sdkPush.ingestNormalizedPayload, {
317
+ userId: "user-4",
318
+ provider: "google",
319
+ sourceMetadata: {
320
+ deviceModel: "Pixel Watch 3",
321
+ source: "health-connect",
322
+ },
323
+ dataPoints: Array.from({ length: 205 }, (_, index) => ({
324
+ seriesType: "heart_rate",
325
+ recordedAt: Date.parse("2026-03-18T10:00:00Z") + index * 60_000,
326
+ value: 120 + (index % 5),
327
+ externalId: `hc-batch-${index}`,
328
+ })),
329
+ });
330
+
331
+ const source = await t.run(async (ctx) => {
332
+ return await ctx.db
333
+ .query("dataSources")
334
+ .withIndex("by_user_provider", (idx) => idx.eq("userId", "user-4").eq("provider", "google"))
335
+ .first();
336
+ });
337
+
338
+ const points = await t.run(async (ctx) => {
339
+ return await ctx.db
340
+ .query("dataPoints")
341
+ .withIndex("by_source_type_time", (idx) =>
342
+ idx.eq("dataSourceId", source!._id).eq("seriesType", "heart_rate"),
343
+ )
344
+ .collect();
345
+ });
346
+
347
+ expect(points).toHaveLength(205);
348
+ });
349
+
350
+ it("rejects unsupported series types", async () => {
351
+ const t = convexTest(schema, modules);
352
+
353
+ await expect(
354
+ t.action(api.sdkPush.ingestNormalizedPayload, {
355
+ userId: "user-5",
356
+ provider: "google",
357
+ dataPoints: [
358
+ {
359
+ seriesType: "totally_unknown_metric",
360
+ recordedAt: Date.parse("2026-03-18T10:00:00Z"),
361
+ value: 1,
362
+ },
363
+ ],
364
+ }),
365
+ ).rejects.toThrow('Unsupported series type "totally_unknown_metric"');
366
+ });
367
+ });