@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,343 @@
1
+ import { v } from "convex/values";
2
+ import { internal } from "./_generated/api";
3
+ import { action, internalAction, internalMutation } from "./_generated/server";
4
+ import { backfillSignal } from "./backfillJobs";
5
+ import { triggerBackfill } from "./providers/garmin";
6
+ import { durableWorkflow } from "./workflowManager";
7
+
8
+ const DEFAULT_LOOKBACK_DAYS = 30;
9
+ const BACKFILL_TIMEOUT_MS = 5 * 60 * 1000;
10
+ export const GARMIN_BACKFILL_TYPES = [
11
+ "activities",
12
+ "activityDetails",
13
+ "dailies",
14
+ "epochs",
15
+ "sleeps",
16
+ "bodyComps",
17
+ "hrv",
18
+ "stressDetails",
19
+ "respiration",
20
+ "pulseOx",
21
+ "bloodPressures",
22
+ "userMetrics",
23
+ "skinTemp",
24
+ "healthSnapshot",
25
+ "moveiq",
26
+ "mct",
27
+ ] as const;
28
+
29
+ type GarminBackfillType = (typeof GARMIN_BACKFILL_TYPES)[number];
30
+
31
+ export const requestGarminBackfill = internalMutation({
32
+ args: {
33
+ connectionId: v.id("connections"),
34
+ windowStart: v.number(),
35
+ windowEnd: v.number(),
36
+ },
37
+ returns: v.object({
38
+ backfillJobId: v.id("backfillJobs"),
39
+ workflowId: v.string(),
40
+ deduped: v.boolean(),
41
+ }),
42
+ handler: async (ctx, args) => {
43
+ const connection = await ctx.db.get(args.connectionId);
44
+ if (!connection) {
45
+ throw new Error(`Connection ${args.connectionId} not found`);
46
+ }
47
+ if (connection.provider !== "garmin") {
48
+ throw new Error("Garmin backfill is only supported for Garmin connections");
49
+ }
50
+
51
+ const existing = await ctx.db
52
+ .query("backfillJobs")
53
+ .withIndex("by_connection", (idx) => idx.eq("connectionId", args.connectionId))
54
+ .order("desc")
55
+ .first();
56
+
57
+ if (existing && (existing.status === "queued" || existing.status === "running")) {
58
+ return {
59
+ backfillJobId: existing._id,
60
+ workflowId: existing.workflowId ?? "",
61
+ deduped: true,
62
+ };
63
+ }
64
+
65
+ const backfillJobId = await ctx.db.insert("backfillJobs", {
66
+ connectionId: connection._id,
67
+ userId: connection.userId,
68
+ provider: "garmin",
69
+ dataType: "full",
70
+ status: "queued",
71
+ startedAt: Date.now(),
72
+ windowStart: args.windowStart,
73
+ windowEnd: args.windowEnd,
74
+ completedDataTypes: [],
75
+ currentAttempt: 0,
76
+ });
77
+
78
+ const workflowId = await durableWorkflow.start(
79
+ ctx,
80
+ internal.garminBackfill.runGarminBackfill,
81
+ { backfillJobId },
82
+ {
83
+ startAsync: true,
84
+ onComplete: internal.garminBackfill.handleGarminBackfillComplete,
85
+ context: { backfillJobId },
86
+ },
87
+ );
88
+
89
+ await ctx.db.patch(backfillJobId, { workflowId });
90
+
91
+ return {
92
+ backfillJobId,
93
+ workflowId,
94
+ deduped: false,
95
+ };
96
+ },
97
+ });
98
+
99
+ export const triggerGarminBackfillType = internalAction({
100
+ args: {
101
+ backfillJobId: v.id("backfillJobs"),
102
+ dataType: v.string(),
103
+ },
104
+ handler: async (ctx, args) => {
105
+ if (!GARMIN_BACKFILL_TYPES.includes(args.dataType as GarminBackfillType)) {
106
+ throw new Error(`Unsupported Garmin backfill type: ${args.dataType}`);
107
+ }
108
+
109
+ const backfillJob = await ctx.runQuery(internal.backfillJobs.getById, {
110
+ backfillJobId: args.backfillJobId,
111
+ });
112
+ if (!backfillJob) {
113
+ throw new Error(`Backfill job ${args.backfillJobId} not found`);
114
+ }
115
+
116
+ const connection = await ctx.runQuery(internal.connections.getById, {
117
+ connectionId: backfillJob.connectionId,
118
+ });
119
+ if (!connection) {
120
+ throw new Error(`Connection ${backfillJob.connectionId} not found`);
121
+ }
122
+
123
+ const credentials = await ctx.runQuery(internal.providerSettings.getCredentials, {
124
+ provider: "garmin",
125
+ });
126
+ if (!credentials) {
127
+ throw new Error("Missing stored Garmin credentials");
128
+ }
129
+
130
+ try {
131
+ const accessToken = await ctx.runAction(internal.oauthActions.ensureValidToken, {
132
+ connectionId: connection._id,
133
+ provider: "garmin",
134
+ accessToken: connection.accessToken ?? "",
135
+ refreshToken: connection.refreshToken,
136
+ tokenExpiresAt: connection.tokenExpiresAt,
137
+ clientId: credentials.clientId,
138
+ clientSecret: credentials.clientSecret,
139
+ subscriptionKey: credentials.subscriptionKey,
140
+ });
141
+
142
+ await triggerBackfill(
143
+ accessToken,
144
+ args.dataType,
145
+ Math.floor((backfillJob.windowStart ?? Date.now()) / 1000),
146
+ Math.floor((backfillJob.windowEnd ?? Date.now()) / 1000),
147
+ );
148
+ } catch (error) {
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ if (
151
+ message.includes("Authorization expired") ||
152
+ message.includes("Token expired") ||
153
+ message.includes("Token refresh failed")
154
+ ) {
155
+ await ctx.runMutation(internal.connections.updateStatus, {
156
+ connectionId: connection._id,
157
+ status: "expired",
158
+ });
159
+ }
160
+ throw error;
161
+ }
162
+ },
163
+ });
164
+
165
+ export const runGarminBackfill = durableWorkflow.define({
166
+ args: {
167
+ backfillJobId: v.id("backfillJobs"),
168
+ },
169
+ returns: v.object({
170
+ completedTypes: v.array(v.string()),
171
+ }),
172
+ handler: async (step, args): Promise<{ completedTypes: string[] }> => {
173
+ const job = await step.runQuery(internal.backfillJobs.getById, {
174
+ backfillJobId: args.backfillJobId,
175
+ });
176
+ if (!job) {
177
+ throw new Error(`Backfill job ${args.backfillJobId} not found`);
178
+ }
179
+
180
+ await step.runMutation(internal.backfillJobs.updateStatus, {
181
+ backfillJobId: args.backfillJobId,
182
+ status: "running",
183
+ workflowId: job.workflowId,
184
+ lastHeartbeatAt: Date.now(),
185
+ });
186
+
187
+ const completed = new Set<string>(job.completedDataTypes ?? []);
188
+
189
+ for (const dataType of GARMIN_BACKFILL_TYPES) {
190
+ if (completed.has(dataType)) {
191
+ continue;
192
+ }
193
+
194
+ let completedType = false;
195
+ for (let attempt = 1; attempt <= 2; attempt++) {
196
+ const eventId = await step.runMutation(internal.backfillJobs.beginAwaitingType, {
197
+ backfillJobId: args.backfillJobId,
198
+ workflowId: step.workflowId,
199
+ dataType,
200
+ attempt,
201
+ });
202
+
203
+ await step.runAction(internal.garminBackfill.triggerGarminBackfillType, {
204
+ backfillJobId: args.backfillJobId,
205
+ dataType,
206
+ });
207
+
208
+ await step.runMutation(internal.backfillJobs.scheduleTimeout, {
209
+ backfillJobId: args.backfillJobId,
210
+ eventId,
211
+ dataType,
212
+ delayMs: BACKFILL_TIMEOUT_MS,
213
+ });
214
+
215
+ const signal = await step.awaitEvent({
216
+ id: eventId as never,
217
+ validator: backfillSignal,
218
+ });
219
+
220
+ if (signal.kind === "webhook") {
221
+ await step.runMutation(internal.backfillJobs.markTypeCompleted, {
222
+ backfillJobId: args.backfillJobId,
223
+ dataType,
224
+ });
225
+ completed.add(dataType);
226
+ completedType = true;
227
+ break;
228
+ }
229
+ }
230
+
231
+ if (!completedType) {
232
+ throw new Error(`Garmin backfill timed out for ${dataType}`);
233
+ }
234
+
235
+ await step.runMutation(internal.backfillJobs.updateStatus, {
236
+ backfillJobId: args.backfillJobId,
237
+ status: "running",
238
+ completedDataTypes: Array.from(completed),
239
+ lastHeartbeatAt: Date.now(),
240
+ });
241
+ }
242
+
243
+ return {
244
+ completedTypes: Array.from(completed),
245
+ };
246
+ },
247
+ });
248
+
249
+ export const handleGarminBackfillComplete = internalMutation({
250
+ args: {
251
+ workflowId: v.string(),
252
+ result: v.any(),
253
+ context: v.object({
254
+ backfillJobId: v.id("backfillJobs"),
255
+ }),
256
+ },
257
+ handler: async (ctx, args) => {
258
+ const job = await ctx.db.get(args.context.backfillJobId);
259
+ if (!job) return;
260
+
261
+ if (args.result.kind === "success") {
262
+ const returnValue = (args.result.returnValue ?? {}) as { completedTypes?: string[] };
263
+ await ctx.db.patch(job._id, {
264
+ status: "completed",
265
+ completedAt: Date.now(),
266
+ workflowId: args.workflowId,
267
+ completedDataTypes: returnValue.completedTypes ?? job.completedDataTypes ?? [],
268
+ lastHeartbeatAt: Date.now(),
269
+ });
270
+ return;
271
+ }
272
+
273
+ if (args.result.kind === "canceled") {
274
+ await ctx.db.patch(job._id, {
275
+ status: "canceled",
276
+ completedAt: Date.now(),
277
+ workflowId: args.workflowId,
278
+ lastHeartbeatAt: Date.now(),
279
+ });
280
+ return;
281
+ }
282
+
283
+ await ctx.db.patch(job._id, {
284
+ status: "failed",
285
+ completedAt: Date.now(),
286
+ error: args.result.error,
287
+ workflowId: args.workflowId,
288
+ lastHeartbeatAt: Date.now(),
289
+ });
290
+ },
291
+ });
292
+
293
+ export const startGarminBackfill = action({
294
+ args: {
295
+ connectionId: v.id("connections"),
296
+ lookbackDays: v.optional(v.number()),
297
+ clientId: v.optional(v.string()),
298
+ clientSecret: v.optional(v.string()),
299
+ },
300
+ returns: v.object({
301
+ backfillJobId: v.string(),
302
+ workflowId: v.string(),
303
+ deduped: v.boolean(),
304
+ }),
305
+ handler: async (ctx, args) => {
306
+ const connection = await ctx.runQuery(internal.connections.getById, {
307
+ connectionId: args.connectionId,
308
+ });
309
+ if (!connection) {
310
+ throw new Error(`Connection ${args.connectionId} not found`);
311
+ }
312
+ if (connection.provider !== "garmin") {
313
+ throw new Error("Garmin backfill is only supported for Garmin connections");
314
+ }
315
+
316
+ if ((args.clientId && !args.clientSecret) || (!args.clientId && args.clientSecret)) {
317
+ throw new Error("clientId and clientSecret must be provided together");
318
+ }
319
+
320
+ if (args.clientId && args.clientSecret) {
321
+ await ctx.runMutation(internal.providerSettings.upsertCredentials, {
322
+ provider: "garmin",
323
+ clientId: args.clientId,
324
+ clientSecret: args.clientSecret,
325
+ });
326
+ }
327
+
328
+ const now = Date.now();
329
+ const lookbackMs = (args.lookbackDays ?? DEFAULT_LOOKBACK_DAYS) * 24 * 60 * 60 * 1000;
330
+
331
+ const result = await ctx.runMutation(internal.garminBackfill.requestGarminBackfill, {
332
+ connectionId: args.connectionId,
333
+ windowStart: now - lookbackMs,
334
+ windowEnd: now,
335
+ });
336
+
337
+ return {
338
+ backfillJobId: String(result.backfillJobId),
339
+ workflowId: result.workflowId,
340
+ deduped: result.deduped,
341
+ };
342
+ },
343
+ });