@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,739 @@
1
+ /**
2
+ * Durable sync orchestration for provider pull syncs.
3
+ *
4
+ * The old implementation executed a whole sync inline in one action. This
5
+ * module now enqueues a durable workflow per connection and keeps syncJobs as
6
+ * the user-visible progress surface.
7
+ */
8
+
9
+ import { v } from "convex/values";
10
+ import { api, internal } from "./_generated/api";
11
+ import type { Id } from "./_generated/dataModel";
12
+ import { action, internalAction, internalMutation } from "./_generated/server";
13
+ import { getProvider } from "./providers/registry";
14
+ import type {
15
+ NormalizedDailySummary,
16
+ NormalizedDataPoint,
17
+ NormalizedEvent,
18
+ } from "./providers/types";
19
+ import { providerName } from "./schema";
20
+ import { durableWorkflow } from "./workflowManager";
21
+
22
+ const EVENT_BATCH_SIZE = 50;
23
+ const DATA_POINT_BATCH_SIZE = 200;
24
+ const SUMMARY_BATCH_SIZE = 50;
25
+ const DEFAULT_SYNC_WINDOW_HOURS = 24;
26
+
27
+ const syncPhase = v.union(v.literal("events"), v.literal("dataPoints"), v.literal("summaries"));
28
+
29
+ type SyncPhase = "events" | "dataPoints" | "summaries";
30
+ type DataSourceCache = Map<string, Id<"dataSources">>;
31
+ type DataSourceMutationRunner = {
32
+ runMutation: (
33
+ mutation: typeof api.dataSources.getOrCreate,
34
+ args: {
35
+ userId: string;
36
+ provider: string;
37
+ connectionId: Id<"connections">;
38
+ deviceModel?: string;
39
+ softwareVersion?: string;
40
+ source: string;
41
+ deviceType?: string;
42
+ originalSourceName?: string;
43
+ },
44
+ ) => Promise<Id<"dataSources">>;
45
+ };
46
+
47
+ function buildSyncIdempotencyKey(args: {
48
+ connectionId: Id<"connections">;
49
+ mode: "manual" | "cron" | "webhook";
50
+ windowStart: number;
51
+ windowEnd: number;
52
+ }): string {
53
+ return [args.connectionId, args.mode, args.windowStart, args.windowEnd].join("::");
54
+ }
55
+
56
+ function sourceCacheKey(
57
+ provider: string,
58
+ metadata: {
59
+ deviceModel?: string;
60
+ softwareVersion?: string;
61
+ source?: string;
62
+ deviceType?: string;
63
+ originalSourceName?: string;
64
+ },
65
+ ): string {
66
+ return [
67
+ provider,
68
+ metadata.deviceModel ?? "",
69
+ metadata.softwareVersion ?? "",
70
+ metadata.source ?? provider,
71
+ metadata.deviceType ?? "",
72
+ metadata.originalSourceName ?? "",
73
+ ].join("::");
74
+ }
75
+
76
+ async function ensureDataSource(
77
+ ctx: DataSourceMutationRunner,
78
+ args: {
79
+ userId: string;
80
+ provider: string;
81
+ connectionId: Id<"connections">;
82
+ },
83
+ cache: DataSourceCache,
84
+ metadata: {
85
+ deviceModel?: string;
86
+ softwareVersion?: string;
87
+ source?: string;
88
+ deviceType?: string;
89
+ originalSourceName?: string;
90
+ } = {},
91
+ ): Promise<Id<"dataSources">> {
92
+ const key = sourceCacheKey(args.provider, metadata);
93
+ const cached = cache.get(key);
94
+ if (cached) return cached;
95
+
96
+ const id = await ctx.runMutation(api.dataSources.getOrCreate, {
97
+ userId: args.userId,
98
+ provider: args.provider,
99
+ connectionId: args.connectionId,
100
+ deviceModel: metadata.deviceModel,
101
+ softwareVersion: metadata.softwareVersion,
102
+ source: metadata.source ?? args.provider,
103
+ deviceType: metadata.deviceType,
104
+ originalSourceName: metadata.originalSourceName,
105
+ });
106
+
107
+ cache.set(key, id);
108
+ return id;
109
+ }
110
+
111
+ function toEventDoc(event: NormalizedEvent, dataSourceId: Id<"dataSources">, userId: string) {
112
+ return {
113
+ dataSourceId,
114
+ userId,
115
+ category: event.category,
116
+ type: event.type,
117
+ sourceName: event.sourceName,
118
+ durationSeconds: event.durationSeconds,
119
+ startDatetime: event.startDatetime,
120
+ endDatetime: event.endDatetime,
121
+ externalId: event.externalId,
122
+ heartRateAvg: event.heartRateAvg,
123
+ heartRateMax: event.heartRateMax,
124
+ heartRateMin: event.heartRateMin,
125
+ energyBurned: event.energyBurned,
126
+ distance: event.distance,
127
+ stepsCount: event.stepsCount,
128
+ maxSpeed: event.maxSpeed,
129
+ maxWatts: event.maxWatts,
130
+ movingTimeSeconds: event.movingTimeSeconds,
131
+ totalElevationGain: event.totalElevationGain,
132
+ averageSpeed: event.averageSpeed,
133
+ averageWatts: event.averageWatts,
134
+ elevHigh: event.elevHigh,
135
+ elevLow: event.elevLow,
136
+ sleepTotalDurationMinutes: event.sleepTotalDurationMinutes,
137
+ sleepTimeInBedMinutes: event.sleepTimeInBedMinutes,
138
+ sleepEfficiencyScore: event.sleepEfficiencyScore,
139
+ sleepDeepMinutes: event.sleepDeepMinutes,
140
+ sleepRemMinutes: event.sleepRemMinutes,
141
+ sleepLightMinutes: event.sleepLightMinutes,
142
+ sleepAwakeMinutes: event.sleepAwakeMinutes,
143
+ isNap: event.isNap,
144
+ sleepStages: event.sleepStages,
145
+ };
146
+ }
147
+
148
+ function sortEvents(events: NormalizedEvent[]): NormalizedEvent[] {
149
+ return [...events].sort((a, b) => {
150
+ if (a.startDatetime !== b.startDatetime) {
151
+ return a.startDatetime - b.startDatetime;
152
+ }
153
+ return (a.externalId ?? "").localeCompare(b.externalId ?? "");
154
+ });
155
+ }
156
+
157
+ function sortDataPoints(points: NormalizedDataPoint[]): NormalizedDataPoint[] {
158
+ return [...points].sort((a, b) => {
159
+ if (a.recordedAt !== b.recordedAt) {
160
+ return a.recordedAt - b.recordedAt;
161
+ }
162
+ if (a.seriesType !== b.seriesType) {
163
+ return a.seriesType.localeCompare(b.seriesType);
164
+ }
165
+ return (a.externalId ?? "").localeCompare(b.externalId ?? "");
166
+ });
167
+ }
168
+
169
+ function sortSummaries(summaries: NormalizedDailySummary[]): NormalizedDailySummary[] {
170
+ return [...summaries].sort((a, b) => {
171
+ if (a.date !== b.date) {
172
+ return a.date.localeCompare(b.date);
173
+ }
174
+ return a.category.localeCompare(b.category);
175
+ });
176
+ }
177
+
178
+ export const requestConnectionSync = internalMutation({
179
+ args: {
180
+ connectionId: v.id("connections"),
181
+ mode: v.optional(v.union(v.literal("manual"), v.literal("cron"), v.literal("webhook"))),
182
+ triggerSource: v.optional(v.string()),
183
+ windowStart: v.number(),
184
+ windowEnd: v.number(),
185
+ },
186
+ returns: v.object({
187
+ syncJobId: v.id("syncJobs"),
188
+ workflowId: v.string(),
189
+ deduped: v.boolean(),
190
+ }),
191
+ handler: async (ctx, args) => {
192
+ const connection = await ctx.db.get(args.connectionId);
193
+ if (!connection) {
194
+ throw new Error(`Connection ${args.connectionId} not found`);
195
+ }
196
+ if (connection.status !== "active") {
197
+ throw new Error(`Connection ${args.connectionId} is not active`);
198
+ }
199
+
200
+ const mode = args.mode ?? "manual";
201
+ const idempotencyKey = buildSyncIdempotencyKey({
202
+ connectionId: args.connectionId,
203
+ mode,
204
+ windowStart: args.windowStart,
205
+ windowEnd: args.windowEnd,
206
+ });
207
+
208
+ const existing = await ctx.db
209
+ .query("syncJobs")
210
+ .withIndex("by_idempotency_key", (idx) => idx.eq("idempotencyKey", idempotencyKey))
211
+ .order("desc")
212
+ .first();
213
+
214
+ if (existing && (existing.status === "queued" || existing.status === "running")) {
215
+ return {
216
+ syncJobId: existing._id,
217
+ workflowId: existing.workflowId ?? "",
218
+ deduped: true,
219
+ };
220
+ }
221
+
222
+ const syncJobId = await ctx.db.insert("syncJobs", {
223
+ connectionId: connection._id,
224
+ userId: connection.userId,
225
+ provider: connection.provider,
226
+ mode,
227
+ triggerSource: args.triggerSource,
228
+ idempotencyKey,
229
+ status: "queued",
230
+ startedAt: Date.now(),
231
+ attempt: 0,
232
+ windowStart: args.windowStart,
233
+ windowEnd: args.windowEnd,
234
+ });
235
+
236
+ const workflowId = await durableWorkflow.start(
237
+ ctx,
238
+ internal.syncWorkflow.runConnectionSync,
239
+ { syncJobId },
240
+ {
241
+ startAsync: true,
242
+ onComplete: internal.syncWorkflow.handleConnectionSyncComplete,
243
+ context: { syncJobId },
244
+ },
245
+ );
246
+
247
+ await ctx.db.patch(syncJobId, { workflowId });
248
+
249
+ return {
250
+ syncJobId,
251
+ workflowId,
252
+ deduped: false,
253
+ };
254
+ },
255
+ });
256
+
257
+ export const fetchSyncPhaseBatch = internalAction({
258
+ args: {
259
+ syncJobId: v.id("syncJobs"),
260
+ phase: syncPhase,
261
+ cursor: v.optional(v.string()),
262
+ },
263
+ returns: v.object({
264
+ events: v.array(v.any()),
265
+ dataPoints: v.array(v.any()),
266
+ summaries: v.array(v.any()),
267
+ nextCursor: v.union(v.string(), v.null()),
268
+ }),
269
+ handler: async (ctx, args) => {
270
+ const job = await ctx.runQuery(internal.syncJobs.getById, {
271
+ jobId: args.syncJobId,
272
+ });
273
+ if (!job) {
274
+ throw new Error(`Sync job ${args.syncJobId} not found`);
275
+ }
276
+
277
+ const connection = await ctx.runQuery(internal.connections.getById, {
278
+ connectionId: job.connectionId,
279
+ });
280
+ if (!connection) {
281
+ throw new Error(`Connection ${job.connectionId} not found`);
282
+ }
283
+
284
+ const credentials = await ctx.runQuery(internal.providerSettings.getCredentials, {
285
+ provider: job.provider,
286
+ });
287
+ if (!credentials) {
288
+ throw new Error(`Missing stored credentials for provider "${job.provider}"`);
289
+ }
290
+
291
+ const providerDef = getProvider(job.provider);
292
+ if (!providerDef) {
293
+ throw new Error(`Provider "${job.provider}" is not implemented`);
294
+ }
295
+
296
+ try {
297
+ const accessToken = await ctx.runAction(internal.oauthActions.ensureValidToken, {
298
+ connectionId: connection._id,
299
+ provider: connection.provider,
300
+ accessToken: connection.accessToken ?? "",
301
+ refreshToken: connection.refreshToken,
302
+ tokenExpiresAt: connection.tokenExpiresAt,
303
+ clientId: credentials.clientId,
304
+ clientSecret: credentials.clientSecret,
305
+ subscriptionKey: credentials.subscriptionKey,
306
+ });
307
+
308
+ const offset = Number(args.cursor ?? "0");
309
+ const startDate = job.windowStart ?? Date.now() - DEFAULT_SYNC_WINDOW_HOURS * 60 * 60 * 1000;
310
+ const endDate = job.windowEnd ?? Date.now();
311
+
312
+ if (args.phase === "events") {
313
+ const all = sortEvents(
314
+ providerDef.fetchEvents
315
+ ? await providerDef.fetchEvents(accessToken, startDate, endDate, credentials)
316
+ : [],
317
+ );
318
+ const batch = all.slice(offset, offset + EVENT_BATCH_SIZE);
319
+ return {
320
+ events: batch,
321
+ dataPoints: [],
322
+ summaries: [],
323
+ nextCursor: offset + batch.length < all.length ? String(offset + batch.length) : null,
324
+ };
325
+ }
326
+
327
+ if (args.phase === "dataPoints") {
328
+ const all = sortDataPoints(
329
+ providerDef.fetchDataPoints
330
+ ? await providerDef.fetchDataPoints(accessToken, startDate, endDate, credentials)
331
+ : [],
332
+ );
333
+ const batch = all.slice(offset, offset + DATA_POINT_BATCH_SIZE);
334
+ return {
335
+ events: [],
336
+ dataPoints: batch,
337
+ summaries: [],
338
+ nextCursor: offset + batch.length < all.length ? String(offset + batch.length) : null,
339
+ };
340
+ }
341
+
342
+ const all = sortSummaries(
343
+ providerDef.fetchDailySummaries
344
+ ? await providerDef.fetchDailySummaries(accessToken, startDate, endDate, credentials)
345
+ : [],
346
+ );
347
+ const batch = all.slice(offset, offset + SUMMARY_BATCH_SIZE);
348
+ return {
349
+ events: [],
350
+ dataPoints: [],
351
+ summaries: batch,
352
+ nextCursor: offset + batch.length < all.length ? String(offset + batch.length) : null,
353
+ };
354
+ } catch (error) {
355
+ const message = error instanceof Error ? error.message : String(error);
356
+ if (
357
+ message.includes("Authorization expired") ||
358
+ message.includes("Token expired") ||
359
+ message.includes("Token refresh failed")
360
+ ) {
361
+ await ctx.runMutation(internal.connections.updateStatus, {
362
+ connectionId: connection._id,
363
+ status: "expired",
364
+ });
365
+ }
366
+ throw error;
367
+ }
368
+ },
369
+ });
370
+
371
+ export const runConnectionSync = durableWorkflow.define({
372
+ args: {
373
+ syncJobId: v.id("syncJobs"),
374
+ },
375
+ returns: v.object({
376
+ recordsProcessed: v.number(),
377
+ }),
378
+ handler: async (step, args): Promise<{ recordsProcessed: number }> => {
379
+ const job = await step.runQuery(internal.syncJobs.getById, {
380
+ jobId: args.syncJobId,
381
+ });
382
+ if (!job) {
383
+ throw new Error(`Sync job ${args.syncJobId} not found`);
384
+ }
385
+
386
+ const connection = await step.runQuery(internal.connections.getById, {
387
+ connectionId: job.connectionId,
388
+ });
389
+ if (!connection) {
390
+ throw new Error(`Connection ${job.connectionId} not found`);
391
+ }
392
+
393
+ let processed = 0;
394
+ const sourceCache: DataSourceCache = new Map();
395
+
396
+ await step.runMutation(internal.syncJobs.updateStatus, {
397
+ jobId: args.syncJobId,
398
+ status: "running",
399
+ workflowId: job.workflowId,
400
+ lastHeartbeatAt: Date.now(),
401
+ });
402
+
403
+ for (const phase of ["events", "dataPoints", "summaries"] as SyncPhase[]) {
404
+ let cursor: string | undefined;
405
+
406
+ while (true) {
407
+ const batch = await step.runAction(internal.syncWorkflow.fetchSyncPhaseBatch, {
408
+ syncJobId: args.syncJobId,
409
+ phase,
410
+ cursor,
411
+ });
412
+
413
+ if (phase === "events" && batch.events.length > 0) {
414
+ const eventDocs = await Promise.all(
415
+ batch.events.map(async (event: NormalizedEvent) => {
416
+ const dataSourceId = await ensureDataSource(
417
+ step,
418
+ {
419
+ userId: job.userId,
420
+ provider: job.provider,
421
+ connectionId: connection._id,
422
+ },
423
+ sourceCache,
424
+ {
425
+ deviceModel: event.deviceModel,
426
+ softwareVersion: event.softwareVersion,
427
+ source: event.source ?? job.provider,
428
+ deviceType: event.deviceType,
429
+ originalSourceName: event.originalSourceName,
430
+ },
431
+ );
432
+
433
+ return toEventDoc(event, dataSourceId, job.userId);
434
+ }),
435
+ );
436
+
437
+ await step.runMutation(internal.events.storeEventBatch, {
438
+ events: eventDocs,
439
+ });
440
+ processed += batch.events.length;
441
+ }
442
+
443
+ if (phase === "dataPoints" && batch.dataPoints.length > 0) {
444
+ const grouped = new Map<
445
+ string,
446
+ {
447
+ dataSourceId: Id<"dataSources">;
448
+ seriesType: string;
449
+ points: Array<{
450
+ recordedAt: number;
451
+ value: number;
452
+ externalId?: string;
453
+ }>;
454
+ }
455
+ >();
456
+
457
+ for (const point of batch.dataPoints as NormalizedDataPoint[]) {
458
+ const dataSourceId = await ensureDataSource(
459
+ step,
460
+ {
461
+ userId: job.userId,
462
+ provider: job.provider,
463
+ connectionId: connection._id,
464
+ },
465
+ sourceCache,
466
+ {
467
+ deviceModel: point.deviceModel,
468
+ softwareVersion: point.softwareVersion,
469
+ source: point.source ?? job.provider,
470
+ deviceType: point.deviceType,
471
+ originalSourceName: point.originalSourceName,
472
+ },
473
+ );
474
+
475
+ const key = `${dataSourceId}::${point.seriesType}`;
476
+ const group = grouped.get(key) ?? {
477
+ dataSourceId,
478
+ seriesType: point.seriesType,
479
+ points: [],
480
+ };
481
+ group.points.push({
482
+ recordedAt: point.recordedAt,
483
+ value: point.value,
484
+ externalId: point.externalId,
485
+ });
486
+ grouped.set(key, group);
487
+ }
488
+
489
+ for (const group of grouped.values()) {
490
+ await step.runMutation(internal.dataPoints.storeBatch, {
491
+ dataSourceId: group.dataSourceId,
492
+ seriesType: group.seriesType,
493
+ points: group.points,
494
+ });
495
+ }
496
+
497
+ processed += batch.dataPoints.length;
498
+ }
499
+
500
+ if (phase === "summaries" && batch.summaries.length > 0) {
501
+ for (const summary of batch.summaries as NormalizedDailySummary[]) {
502
+ await step.runMutation(internal.summaries.upsert, {
503
+ userId: job.userId,
504
+ ...summary,
505
+ });
506
+ }
507
+ processed += batch.summaries.length;
508
+ }
509
+
510
+ cursor = batch.nextCursor ?? undefined;
511
+ await step.runMutation(internal.syncJobs.updateStatus, {
512
+ jobId: args.syncJobId,
513
+ status: "running",
514
+ currentPhase: phase,
515
+ cursor,
516
+ recordsProcessed: processed,
517
+ lastHeartbeatAt: Date.now(),
518
+ });
519
+
520
+ if (!cursor) {
521
+ break;
522
+ }
523
+ }
524
+ }
525
+
526
+ await step.runMutation(internal.connections.markSynced, {
527
+ connectionId: connection._id,
528
+ });
529
+
530
+ return { recordsProcessed: processed };
531
+ },
532
+ });
533
+
534
+ export const handleConnectionSyncComplete = internalMutation({
535
+ args: {
536
+ workflowId: v.string(),
537
+ result: v.any(),
538
+ context: v.object({
539
+ syncJobId: v.id("syncJobs"),
540
+ }),
541
+ },
542
+ handler: async (ctx, args) => {
543
+ const job = await ctx.db.get(args.context.syncJobId);
544
+ if (!job) return;
545
+
546
+ if (args.result.kind === "success") {
547
+ const returnValue = (args.result.returnValue ?? {}) as { recordsProcessed?: number };
548
+ await ctx.db.patch(job._id, {
549
+ status: "completed",
550
+ completedAt: Date.now(),
551
+ recordsProcessed: returnValue.recordsProcessed ?? job.recordsProcessed ?? 0,
552
+ workflowId: args.workflowId,
553
+ lastHeartbeatAt: Date.now(),
554
+ });
555
+ return;
556
+ }
557
+
558
+ if (args.result.kind === "canceled") {
559
+ await ctx.db.patch(job._id, {
560
+ status: "canceled",
561
+ completedAt: Date.now(),
562
+ workflowId: args.workflowId,
563
+ lastHeartbeatAt: Date.now(),
564
+ });
565
+ return;
566
+ }
567
+
568
+ await ctx.db.patch(job._id, {
569
+ status: "failed",
570
+ completedAt: Date.now(),
571
+ error: args.result.error,
572
+ workflowId: args.workflowId,
573
+ lastHeartbeatAt: Date.now(),
574
+ });
575
+ },
576
+ });
577
+
578
+ export const syncConnection = action({
579
+ args: {
580
+ connectionId: v.id("connections"),
581
+ provider: providerName,
582
+ startDate: v.optional(v.number()),
583
+ endDate: v.optional(v.number()),
584
+ syncWindowHours: v.optional(v.number()),
585
+ clientId: v.optional(v.string()),
586
+ clientSecret: v.optional(v.string()),
587
+ subscriptionKey: v.optional(v.string()),
588
+ },
589
+ returns: v.object({
590
+ syncJobId: v.string(),
591
+ workflowId: v.string(),
592
+ deduped: v.boolean(),
593
+ }),
594
+ handler: async (ctx, args) => {
595
+ const connection = await ctx.runQuery(internal.connections.getById, {
596
+ connectionId: args.connectionId,
597
+ });
598
+ if (!connection) {
599
+ throw new Error(`Connection ${args.connectionId} not found`);
600
+ }
601
+ if (connection.provider !== args.provider) {
602
+ throw new Error(
603
+ `Connection ${args.connectionId} does not belong to provider "${args.provider}"`,
604
+ );
605
+ }
606
+
607
+ if ((args.clientId && !args.clientSecret) || (!args.clientId && args.clientSecret)) {
608
+ throw new Error("clientId and clientSecret must be provided together");
609
+ }
610
+
611
+ if (args.clientId && args.clientSecret) {
612
+ await ctx.runMutation(internal.providerSettings.upsertCredentials, {
613
+ provider: args.provider,
614
+ clientId: args.clientId,
615
+ clientSecret: args.clientSecret,
616
+ subscriptionKey: args.subscriptionKey,
617
+ });
618
+ }
619
+
620
+ const endDate = args.endDate ?? Date.now();
621
+ const defaultWindowMs = (args.syncWindowHours ?? DEFAULT_SYNC_WINDOW_HOURS) * 60 * 60 * 1000;
622
+ const startDate =
623
+ args.startDate ??
624
+ Math.max(connection.lastSyncedAt ?? endDate - defaultWindowMs, endDate - defaultWindowMs);
625
+
626
+ const result = await ctx.runMutation(internal.syncWorkflow.requestConnectionSync, {
627
+ connectionId: args.connectionId,
628
+ mode: "manual",
629
+ triggerSource: "manual:syncConnection",
630
+ windowStart: startDate,
631
+ windowEnd: endDate,
632
+ });
633
+
634
+ return {
635
+ syncJobId: String(result.syncJobId),
636
+ workflowId: result.workflowId,
637
+ deduped: result.deduped,
638
+ };
639
+ },
640
+ });
641
+
642
+ export const syncAllActive = action({
643
+ args: {
644
+ clientCredentials: v.object({
645
+ strava: v.optional(
646
+ v.object({
647
+ clientId: v.string(),
648
+ clientSecret: v.string(),
649
+ }),
650
+ ),
651
+ garmin: v.optional(
652
+ v.object({
653
+ clientId: v.string(),
654
+ clientSecret: v.string(),
655
+ }),
656
+ ),
657
+ polar: v.optional(
658
+ v.object({
659
+ clientId: v.string(),
660
+ clientSecret: v.string(),
661
+ }),
662
+ ),
663
+ whoop: v.optional(
664
+ v.object({
665
+ clientId: v.string(),
666
+ clientSecret: v.string(),
667
+ }),
668
+ ),
669
+ suunto: v.optional(
670
+ v.object({
671
+ clientId: v.string(),
672
+ clientSecret: v.string(),
673
+ subscriptionKey: v.optional(v.string()),
674
+ }),
675
+ ),
676
+ }),
677
+ syncWindowHours: v.optional(v.number()),
678
+ },
679
+ returns: v.object({
680
+ enqueued: v.number(),
681
+ deduped: v.number(),
682
+ skipped: v.number(),
683
+ }),
684
+ handler: async (ctx, args) => {
685
+ const activeConnections = await ctx.runQuery(internal.connections.getAllActive, {});
686
+ const endDate = Date.now();
687
+ let enqueued = 0;
688
+ let deduped = 0;
689
+ let skipped = 0;
690
+
691
+ for (const conn of activeConnections) {
692
+ const creds = args.clientCredentials[conn.provider as keyof typeof args.clientCredentials];
693
+ if (creds) {
694
+ await ctx.runMutation(internal.providerSettings.upsertCredentials, {
695
+ provider: conn.provider,
696
+ clientId: creds.clientId,
697
+ clientSecret: creds.clientSecret,
698
+ subscriptionKey: "subscriptionKey" in creds ? creds.subscriptionKey : undefined,
699
+ });
700
+ } else {
701
+ const stored = await ctx.runQuery(internal.providerSettings.getCredentials, {
702
+ provider: conn.provider,
703
+ });
704
+ if (!stored) {
705
+ skipped += 1;
706
+ continue;
707
+ }
708
+ }
709
+
710
+ if (!getProvider(conn.provider)) {
711
+ skipped += 1;
712
+ continue;
713
+ }
714
+
715
+ const windowMs = (args.syncWindowHours ?? DEFAULT_SYNC_WINDOW_HOURS) * 60 * 60 * 1000;
716
+ const startDate = Math.max(conn.lastSyncedAt ?? endDate - windowMs, endDate - windowMs);
717
+
718
+ try {
719
+ const result = await ctx.runMutation(internal.syncWorkflow.requestConnectionSync, {
720
+ connectionId: conn._id,
721
+ mode: "cron",
722
+ triggerSource: "cron:syncAllActive",
723
+ windowStart: startDate,
724
+ windowEnd: endDate,
725
+ });
726
+
727
+ if (result.deduped) {
728
+ deduped += 1;
729
+ } else {
730
+ enqueued += 1;
731
+ }
732
+ } catch {
733
+ skipped += 1;
734
+ }
735
+ }
736
+
737
+ return { enqueued, deduped, skipped };
738
+ },
739
+ });