@clipin/convex-wearables 0.2.0 → 0.3.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 (48) hide show
  1. package/dist/client/index.d.ts +3 -0
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +6 -0
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/client/types.d.ts +13 -1
  6. package/dist/client/types.d.ts.map +1 -1
  7. package/dist/client/types.js +1 -1
  8. package/dist/client/types.js.map +1 -1
  9. package/dist/component/garminBackfill.d.ts +9 -1
  10. package/dist/component/garminBackfill.d.ts.map +1 -1
  11. package/dist/component/garminBackfill.js +27 -6
  12. package/dist/component/garminBackfill.js.map +1 -1
  13. package/dist/component/garminWebhooks.d.ts +44 -0
  14. package/dist/component/garminWebhooks.d.ts.map +1 -1
  15. package/dist/component/garminWebhooks.js +248 -14
  16. package/dist/component/garminWebhooks.js.map +1 -1
  17. package/dist/component/oauthActions.d.ts.map +1 -1
  18. package/dist/component/oauthActions.js +5 -0
  19. package/dist/component/oauthActions.js.map +1 -1
  20. package/dist/component/providers/garmin.d.ts +4 -0
  21. package/dist/component/providers/garmin.d.ts.map +1 -1
  22. package/dist/component/providers/garmin.js +23 -8
  23. package/dist/component/providers/garmin.js.map +1 -1
  24. package/dist/component/schema.d.ts +27 -0
  25. package/dist/component/schema.d.ts.map +1 -1
  26. package/dist/component/schema.js +19 -0
  27. package/dist/component/schema.js.map +1 -1
  28. package/dist/component/sdkPush.d.ts +24 -0
  29. package/dist/component/sdkPush.d.ts.map +1 -1
  30. package/dist/component/sdkPush.js +101 -6
  31. package/dist/component/sdkPush.js.map +1 -1
  32. package/dist/component/syncWorkflow.d.ts.map +1 -1
  33. package/dist/component/syncWorkflow.js +5 -3
  34. package/dist/component/syncWorkflow.js.map +1 -1
  35. package/dist/test.d.ts +27 -0
  36. package/dist/test.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/src/client/index.ts +13 -1
  39. package/src/client/types.ts +13 -1
  40. package/src/component/garminBackfill.ts +33 -6
  41. package/src/component/garminWebhooks.test.ts +112 -9
  42. package/src/component/garminWebhooks.ts +279 -14
  43. package/src/component/oauthActions.ts +6 -0
  44. package/src/component/providers/garmin.ts +36 -12
  45. package/src/component/schema.ts +25 -0
  46. package/src/component/sdkPush.test.ts +54 -0
  47. package/src/component/sdkPush.ts +120 -6
  48. package/src/component/syncWorkflow.ts +5 -3
@@ -8,7 +8,13 @@
8
8
  import { v } from "convex/values";
9
9
  import { api, internal } from "./_generated/api";
10
10
  import type { Doc, Id } from "./_generated/dataModel";
11
- import { type ActionCtx, action } from "./_generated/server";
11
+ import {
12
+ type ActionCtx,
13
+ action,
14
+ internalAction,
15
+ internalMutation,
16
+ internalQuery,
17
+ } from "./_generated/server";
12
18
  import {
13
19
  type GarminPushPayload,
14
20
  normalizeActivity,
@@ -34,6 +40,8 @@ import {
34
40
  } from "./providers/garmin";
35
41
 
36
42
  const DATA_POINT_BATCH_SIZE = 500;
43
+ const PENDING_GARMIN_PUSH_TTL_MS = 30 * 60 * 1000;
44
+ const PENDING_GARMIN_PUSH_REPLAY_LIMIT = 20;
37
45
 
38
46
  function decodePushPayload(args: { payload?: unknown; payloadJson?: string }): GarminPushPayload {
39
47
  if (args.payloadJson !== undefined) {
@@ -61,6 +69,8 @@ export const processPushPayload = action({
61
69
  const payload = decodePushPayload(args);
62
70
  const signalBuckets = new Map<string, Set<string>>();
63
71
 
72
+ await queuePendingPayloadForInactiveConnections(ctx, payload, args.garminClientId);
73
+
64
74
  await processActivityEntries(ctx, payload.activities, "activities", signalBuckets);
65
75
  await processActivityEntries(ctx, payload.activityDetails, "activityDetails", signalBuckets);
66
76
 
@@ -234,8 +244,12 @@ export const processPushPayload = action({
234
244
  }
235
245
  }
236
246
 
237
- if (payload.respiration?.length) {
238
- for (const respiration of payload.respiration) {
247
+ const respirationEntries =
248
+ payload.allDayRespiration && payload.allDayRespiration.length > 0
249
+ ? payload.allDayRespiration
250
+ : payload.respiration;
251
+ if (respirationEntries?.length) {
252
+ for (const respiration of respirationEntries) {
239
253
  const connection = await resolveConnection(ctx, respiration.userId);
240
254
  if (!connection) continue;
241
255
 
@@ -247,12 +261,14 @@ export const processPushPayload = action({
247
261
  dataSourceId,
248
262
  normalizeRespirationDataPoints(respiration),
249
263
  );
250
- addSignal(signalBuckets, "respiration", connection._id);
264
+ addSignal(signalBuckets, "allDayRespiration", connection._id);
251
265
  }
252
266
  }
253
267
 
254
- if (payload.pulseOx?.length) {
255
- for (const pulseOx of payload.pulseOx) {
268
+ const pulseOxEntries =
269
+ payload.pulseOx && payload.pulseOx.length > 0 ? payload.pulseOx : payload.pulseox;
270
+ if (pulseOxEntries?.length) {
271
+ for (const pulseOx of pulseOxEntries) {
256
272
  const connection = await resolveConnection(ctx, pulseOx.userId);
257
273
  if (!connection) continue;
258
274
 
@@ -334,8 +350,12 @@ export const processPushPayload = action({
334
350
  }
335
351
  }
336
352
 
337
- if (payload.moveiq?.length) {
338
- for (const moveIQ of payload.moveiq) {
353
+ const moveIQEntries =
354
+ payload.moveIQActivities && payload.moveIQActivities.length > 0
355
+ ? payload.moveIQActivities
356
+ : payload.moveiq;
357
+ if (moveIQEntries?.length) {
358
+ for (const moveIQ of moveIQEntries) {
339
359
  const connection = await resolveConnection(ctx, moveIQ.userId);
340
360
  if (!connection) continue;
341
361
 
@@ -355,7 +375,7 @@ export const processPushPayload = action({
355
375
  externalId: event.externalId,
356
376
  });
357
377
 
358
- addSignal(signalBuckets, "moveiq", connection._id);
378
+ addSignal(signalBuckets, "moveIQActivities", connection._id);
359
379
  }
360
380
  }
361
381
 
@@ -434,10 +454,158 @@ export const processPushPayload = action({
434
454
  }
435
455
  }
436
456
 
457
+ const syncedConnectionIds = new Set(
458
+ [...signalBuckets.values()].flatMap((connectionIds) => [...connectionIds]),
459
+ );
460
+ for (const connectionId of syncedConnectionIds) {
461
+ await ctx.runMutation(internal.connections.markSynced, {
462
+ connectionId: connectionId as Id<"connections">,
463
+ });
464
+ }
465
+
437
466
  return null;
438
467
  },
439
468
  });
440
469
 
470
+ export const replayPendingForConnection = internalAction({
471
+ args: {
472
+ connectionId: v.id("connections"),
473
+ },
474
+ returns: v.object({
475
+ failed: v.number(),
476
+ replayed: v.number(),
477
+ skipped: v.number(),
478
+ }),
479
+ handler: async (ctx, args) => {
480
+ const skipped = await ctx.runMutation(internal.garminWebhooks.markExpiredPendingForConnection, {
481
+ connectionId: args.connectionId,
482
+ now: Date.now(),
483
+ });
484
+ const pendingPayloads = await ctx.runQuery(internal.garminWebhooks.getReplayablePending, {
485
+ connectionId: args.connectionId,
486
+ now: Date.now(),
487
+ });
488
+ let failed = 0;
489
+ let replayed = 0;
490
+
491
+ for (const pending of pendingPayloads) {
492
+ try {
493
+ await ctx.runAction(api.garminWebhooks.processPushPayload, {
494
+ garminClientId: pending.garminClientId,
495
+ payloadJson: pending.payloadJson,
496
+ });
497
+ await ctx.runMutation(internal.garminWebhooks.markPendingReplayed, {
498
+ pendingId: pending._id,
499
+ });
500
+ replayed += 1;
501
+ } catch (error) {
502
+ failed += 1;
503
+ await ctx.runMutation(internal.garminWebhooks.markPendingFailed, {
504
+ pendingId: pending._id,
505
+ error: error instanceof Error ? error.message : String(error),
506
+ });
507
+ }
508
+ }
509
+
510
+ return { failed, replayed, skipped };
511
+ },
512
+ });
513
+
514
+ export const getReplayablePending = internalQuery({
515
+ args: {
516
+ connectionId: v.id("connections"),
517
+ now: v.number(),
518
+ },
519
+ returns: v.array(v.any()),
520
+ handler: async (ctx, args) => {
521
+ const rows = await ctx.db
522
+ .query("pendingGarminPushPayloads")
523
+ .withIndex("by_connection_status", (idx) =>
524
+ idx.eq("connectionId", args.connectionId).eq("status", "pending"),
525
+ )
526
+ .order("asc")
527
+ .take(PENDING_GARMIN_PUSH_REPLAY_LIMIT);
528
+
529
+ return rows.filter((row) => row.expiresAt > args.now);
530
+ },
531
+ });
532
+
533
+ export const storePendingPayload = internalMutation({
534
+ args: {
535
+ connectionId: v.id("connections"),
536
+ userId: v.string(),
537
+ providerUserId: v.string(),
538
+ garminClientId: v.string(),
539
+ payloadJson: v.string(),
540
+ receivedAt: v.number(),
541
+ expiresAt: v.number(),
542
+ },
543
+ returns: v.id("pendingGarminPushPayloads"),
544
+ handler: async (ctx, args) => {
545
+ return await ctx.db.insert("pendingGarminPushPayloads", {
546
+ ...args,
547
+ status: "pending",
548
+ });
549
+ },
550
+ });
551
+
552
+ export const markPendingReplayed = internalMutation({
553
+ args: {
554
+ pendingId: v.id("pendingGarminPushPayloads"),
555
+ },
556
+ returns: v.null(),
557
+ handler: async (ctx, args) => {
558
+ await ctx.db.patch(args.pendingId, {
559
+ replayedAt: Date.now(),
560
+ status: "replayed",
561
+ error: undefined,
562
+ });
563
+ return null;
564
+ },
565
+ });
566
+
567
+ export const markPendingFailed = internalMutation({
568
+ args: {
569
+ pendingId: v.id("pendingGarminPushPayloads"),
570
+ error: v.string(),
571
+ },
572
+ returns: v.null(),
573
+ handler: async (ctx, args) => {
574
+ await ctx.db.patch(args.pendingId, {
575
+ error: args.error,
576
+ status: "failed",
577
+ });
578
+ return null;
579
+ },
580
+ });
581
+
582
+ export const markExpiredPendingForConnection = internalMutation({
583
+ args: {
584
+ connectionId: v.id("connections"),
585
+ now: v.number(),
586
+ },
587
+ returns: v.number(),
588
+ handler: async (ctx, args) => {
589
+ const expired = await ctx.db
590
+ .query("pendingGarminPushPayloads")
591
+ .withIndex("by_connection_status", (idx) =>
592
+ idx.eq("connectionId", args.connectionId).eq("status", "pending"),
593
+ )
594
+ .filter((q) => q.lte(q.field("expiresAt"), args.now))
595
+ .collect();
596
+
597
+ await Promise.all(
598
+ expired.map((row) =>
599
+ ctx.db.patch(row._id, {
600
+ status: "expired",
601
+ }),
602
+ ),
603
+ );
604
+
605
+ return expired.length;
606
+ },
607
+ });
608
+
441
609
  async function processActivityEntries(
442
610
  ctx: Pick<ActionCtx, "runQuery" | "runMutation">,
443
611
  activities: GarminPushPayload["activities"] | GarminPushPayload["activityDetails"],
@@ -595,10 +763,10 @@ function getPayloadItemCount(payload: GarminPushPayload, dataType: string): numb
595
763
  return payload.hrv?.length;
596
764
  case "stressDetails":
597
765
  return payload.stressDetails?.length;
598
- case "respiration":
599
- return payload.respiration?.length;
766
+ case "allDayRespiration":
767
+ return payload.allDayRespiration?.length ?? payload.respiration?.length;
600
768
  case "pulseOx":
601
- return payload.pulseOx?.length;
769
+ return payload.pulseOx?.length ?? payload.pulseox?.length;
602
770
  case "bloodPressures":
603
771
  return payload.bloodPressures?.length;
604
772
  case "userMetrics":
@@ -607,8 +775,8 @@ function getPayloadItemCount(payload: GarminPushPayload, dataType: string): numb
607
775
  return payload.skinTemp?.length;
608
776
  case "healthSnapshot":
609
777
  return payload.healthSnapshot?.length;
610
- case "moveiq":
611
- return payload.moveiq?.length;
778
+ case "moveIQActivities":
779
+ return payload.moveIQActivities?.length ?? payload.moveiq?.length;
612
780
  case "mct":
613
781
  return payload.menstrualCycleTracking && payload.menstrualCycleTracking.length > 0
614
782
  ? payload.menstrualCycleTracking.length
@@ -646,6 +814,103 @@ async function resolveConnection(
646
814
  return connection;
647
815
  }
648
816
 
817
+ function collectGarminUserIds(payload: GarminPushPayload): string[] {
818
+ const userIds = new Set<string>();
819
+ const maybeAdd = (entry: { userId?: unknown } | null | undefined) => {
820
+ if (typeof entry?.userId === "string" && entry.userId.length > 0) {
821
+ userIds.add(entry.userId);
822
+ }
823
+ };
824
+
825
+ for (const entry of payload.activities ?? []) maybeAdd(entry);
826
+ for (const entry of payload.activityDetails ?? []) maybeAdd(entry);
827
+ for (const entry of payload.sleeps ?? []) maybeAdd(entry);
828
+ for (const entry of payload.dailies ?? []) maybeAdd(entry);
829
+ for (const entry of payload.epochs ?? []) maybeAdd(entry);
830
+ for (const entry of payload.bodyComps ?? []) maybeAdd(entry);
831
+ for (const entry of payload.hrv ?? []) maybeAdd(entry);
832
+ for (const entry of payload.stressDetails ?? []) maybeAdd(entry);
833
+ for (const entry of payload.respiration ?? []) maybeAdd(entry);
834
+ for (const entry of payload.pulseOx ?? []) maybeAdd(entry);
835
+ for (const entry of payload.bloodPressures ?? []) maybeAdd(entry);
836
+ for (const entry of payload.userMetrics ?? []) maybeAdd(entry);
837
+ for (const entry of payload.skinTemp ?? []) maybeAdd(entry);
838
+ for (const entry of payload.healthSnapshot ?? []) maybeAdd(entry);
839
+ for (const entry of payload.moveiq ?? []) maybeAdd(entry);
840
+ for (const entry of payload.menstrualCycleTracking ?? []) maybeAdd(entry);
841
+ for (const entry of payload.mct ?? []) maybeAdd(entry);
842
+ for (const entry of payload.userPermissionsChange ?? []) maybeAdd(entry);
843
+ for (const entry of payload.deregistrations ?? []) maybeAdd(entry);
844
+
845
+ return [...userIds];
846
+ }
847
+
848
+ function filterGarminPayloadByUserId(
849
+ payload: GarminPushPayload,
850
+ providerUserId: string,
851
+ ): GarminPushPayload {
852
+ const filterEntries = <T extends { userId?: unknown }>(entries: T[] | undefined): T[] =>
853
+ (entries ?? []).filter((entry) => entry.userId === providerUserId);
854
+
855
+ return {
856
+ activities: filterEntries(payload.activities),
857
+ activityDetails: filterEntries(payload.activityDetails),
858
+ sleeps: filterEntries(payload.sleeps),
859
+ dailies: filterEntries(payload.dailies),
860
+ epochs: filterEntries(payload.epochs),
861
+ bodyComps: filterEntries(payload.bodyComps),
862
+ hrv: filterEntries(payload.hrv),
863
+ stressDetails: filterEntries(payload.stressDetails),
864
+ respiration: filterEntries(payload.respiration),
865
+ pulseOx: filterEntries(payload.pulseOx),
866
+ bloodPressures: filterEntries(payload.bloodPressures),
867
+ userMetrics: filterEntries(payload.userMetrics),
868
+ skinTemp: filterEntries(payload.skinTemp),
869
+ healthSnapshot: filterEntries(payload.healthSnapshot),
870
+ moveiq: filterEntries(payload.moveiq),
871
+ menstrualCycleTracking: filterEntries(payload.menstrualCycleTracking),
872
+ mct: filterEntries(payload.mct),
873
+ userPermissionsChange: filterEntries(payload.userPermissionsChange),
874
+ deregistrations: filterEntries(payload.deregistrations),
875
+ };
876
+ }
877
+
878
+ async function queuePendingPayloadForInactiveConnections(
879
+ ctx: Pick<ActionCtx, "runMutation" | "runQuery">,
880
+ payload: GarminPushPayload,
881
+ garminClientId: string,
882
+ ) {
883
+ const userIds = collectGarminUserIds(payload);
884
+ if (userIds.length === 0) {
885
+ return;
886
+ }
887
+
888
+ const receivedAt = Date.now();
889
+
890
+ for (const providerUserId of userIds) {
891
+ const conn = (await ctx.runQuery(internal.connections.getByProviderUser, {
892
+ provider: "garmin",
893
+ providerUserId,
894
+ })) as Doc<"connections"> | null;
895
+
896
+ if (!conn || conn.status === "active") {
897
+ continue;
898
+ }
899
+
900
+ const payloadJson = JSON.stringify(filterGarminPayloadByUserId(payload, providerUserId));
901
+
902
+ await ctx.runMutation(internal.garminWebhooks.storePendingPayload, {
903
+ connectionId: conn._id,
904
+ userId: conn.userId,
905
+ providerUserId,
906
+ garminClientId,
907
+ payloadJson,
908
+ receivedAt,
909
+ expiresAt: receivedAt + PENDING_GARMIN_PUSH_TTL_MS,
910
+ });
911
+ }
912
+ }
913
+
649
914
  /**
650
915
  * Resolve a Garmin userId to a dataSource ID, creating one if needed.
651
916
  */
@@ -192,6 +192,12 @@ export const handleCallback = action({
192
192
  source: oauthState.provider,
193
193
  });
194
194
 
195
+ if (oauthState.provider === "garmin") {
196
+ await ctx.runAction(internal.garminWebhooks.replayPendingForConnection, {
197
+ connectionId,
198
+ });
199
+ }
200
+
195
201
  return {
196
202
  provider: oauthState.provider,
197
203
  userId: oauthState.userId,
@@ -148,6 +148,7 @@ export interface GarminHrv {
148
148
  userId: string;
149
149
  summaryId?: string;
150
150
  startTimeInSeconds: number;
151
+ startTimeOffsetInSeconds?: number;
151
152
  calendarDate?: string;
152
153
  lastNightAvg?: number;
153
154
  hrvValues?: Record<string, number>;
@@ -258,12 +259,15 @@ export interface GarminPushPayload {
258
259
  bodyComps?: GarminBodyComp[];
259
260
  hrv?: GarminHrv[];
260
261
  stressDetails?: GarminStressDetails[];
262
+ allDayRespiration?: GarminRespiration[];
261
263
  respiration?: GarminRespiration[];
262
264
  pulseOx?: GarminPulseOx[];
265
+ pulseox?: GarminPulseOx[];
263
266
  bloodPressures?: GarminBloodPressure[];
264
267
  userMetrics?: GarminUserMetrics[];
265
268
  skinTemp?: GarminSkinTemp[];
266
269
  healthSnapshot?: GarminHealthSnapshot[];
270
+ moveIQActivities?: GarminMoveIQ[];
267
271
  moveiq?: GarminMoveIQ[];
268
272
  menstrualCycleTracking?: GarminMCTSummary[];
269
273
  mct?: GarminMCTSummary[];
@@ -282,6 +286,13 @@ function isoDateFromCalendarDate(
282
286
  return calendarDate ?? isoDateFromTimestamp(fallbackTimestampMs);
283
287
  }
284
288
 
289
+ function isoDateFromTimestampWithOffset(timestampMs: number, offsetSeconds?: number): string {
290
+ if (offsetSeconds === undefined) {
291
+ return isoDateFromTimestamp(timestampMs);
292
+ }
293
+ return isoDateFromTimestamp(timestampMs + offsetSeconds * 1000);
294
+ }
295
+
285
296
  function calendarDateToMiddayTimestamp(calendarDate: string | undefined): number | null {
286
297
  if (!calendarDate) {
287
298
  return null;
@@ -541,8 +552,9 @@ export function normalizeSleep(sleep: GarminSleep): NormalizedEvent {
541
552
  }
542
553
 
543
554
  export function normalizeSleepSummary(sleep: GarminSleep): NormalizedDailySummary {
555
+ const endMs = (sleep.startTimeInSeconds + sleep.durationInSeconds) * 1000;
544
556
  return {
545
- date: isoDateFromTimestamp(sleep.startTimeInSeconds * 1000),
557
+ date: isoDateFromTimestampWithOffset(endMs, sleep.startTimeOffsetInSeconds),
546
558
  category: "sleep",
547
559
  sleepDurationMinutes: Math.floor(sleep.durationInSeconds / 60),
548
560
  sleepEfficiency: sleep.overallSleepScore?.value,
@@ -734,7 +746,7 @@ export function normalizeHrvDataPoints(hrv: GarminHrv): NormalizedDataPoint[] {
734
746
  const points: NormalizedDataPoint[] = [];
735
747
  if (hrv.lastNightAvg != null) {
736
748
  points.push({
737
- seriesType: "heart_rate_variability_sdnn",
749
+ seriesType: "heart_rate_variability_rmssd",
738
750
  recordedAt: hrv.startTimeInSeconds * 1000,
739
751
  value: hrv.lastNightAvg,
740
752
  externalId: hrv.summaryId,
@@ -745,7 +757,7 @@ export function normalizeHrvDataPoints(hrv: GarminHrv): NormalizedDataPoint[] {
745
757
  ...buildOffsetDataPoints(
746
758
  hrv.hrvValues,
747
759
  hrv.startTimeInSeconds,
748
- "heart_rate_variability_sdnn",
760
+ "heart_rate_variability_rmssd",
749
761
  hrv.summaryId,
750
762
  ),
751
763
  );
@@ -761,7 +773,7 @@ export function normalizeHrvSummary(hrv: GarminHrv): NormalizedDailySummary | nu
761
773
  return {
762
774
  date: isoDateFromCalendarDate(hrv.calendarDate, hrv.startTimeInSeconds * 1000),
763
775
  category: "recovery",
764
- hrvAvg: hrv.lastNightAvg,
776
+ hrvRmssd: hrv.lastNightAvg,
765
777
  };
766
778
  }
767
779
 
@@ -1138,6 +1150,7 @@ export async function triggerBackfill(
1138
1150
  startTimeSeconds: number,
1139
1151
  endTimeSeconds: number,
1140
1152
  ): Promise<void> {
1153
+ const endpointDataType = normalizeBackfillDataType(dataType);
1141
1154
  const validTypes = [
1142
1155
  "activities",
1143
1156
  "activityDetails",
@@ -1147,23 +1160,34 @@ export async function triggerBackfill(
1147
1160
  "bodyComps",
1148
1161
  "hrv",
1149
1162
  "stressDetails",
1150
- "respiration",
1163
+ "allDayRespiration",
1151
1164
  "pulseOx",
1152
1165
  "bloodPressures",
1153
1166
  "userMetrics",
1154
1167
  "skinTemp",
1155
1168
  "healthSnapshot",
1156
- "moveiq",
1169
+ "moveIQActivities",
1157
1170
  "mct",
1158
1171
  ];
1159
- if (!validTypes.includes(dataType)) {
1172
+ if (!validTypes.includes(endpointDataType)) {
1160
1173
  throw new Error(`Invalid backfill data type: ${dataType}`);
1161
1174
  }
1162
1175
 
1163
- await makeAuthenticatedRequest(API_BASE, `/wellness-api/rest/backfill/${dataType}`, accessToken, {
1164
- params: {
1165
- summaryStartTimeInSeconds: String(startTimeSeconds),
1166
- summaryEndTimeInSeconds: String(endTimeSeconds),
1176
+ await makeAuthenticatedRequest(
1177
+ API_BASE,
1178
+ `/wellness-api/rest/backfill/${endpointDataType}`,
1179
+ accessToken,
1180
+ {
1181
+ params: {
1182
+ summaryStartTimeInSeconds: String(startTimeSeconds),
1183
+ summaryEndTimeInSeconds: String(endTimeSeconds),
1184
+ },
1167
1185
  },
1168
- });
1186
+ );
1187
+ }
1188
+
1189
+ function normalizeBackfillDataType(dataType: string): string {
1190
+ if (dataType === "respiration") return "allDayRespiration";
1191
+ if (dataType === "moveiq") return "moveIQActivities";
1192
+ return dataType;
1169
1193
  }
@@ -307,6 +307,31 @@ export default defineSchema({
307
307
  updatedAt: v.optional(v.number()),
308
308
  }).index("by_provider", ["provider"]),
309
309
 
310
+ // -------------------------------------------------------------------------
311
+ // Pending Garmin Push Payloads — short-lived replay queue for OAuth reconnect
312
+ // races where Garmin pushes data before the connection is active again.
313
+ // -------------------------------------------------------------------------
314
+ pendingGarminPushPayloads: defineTable({
315
+ connectionId: v.id("connections"),
316
+ userId: v.string(),
317
+ providerUserId: v.string(),
318
+ garminClientId: v.string(),
319
+ payloadJson: v.string(),
320
+ receivedAt: v.number(),
321
+ expiresAt: v.number(),
322
+ replayedAt: v.optional(v.number()),
323
+ status: v.union(
324
+ v.literal("pending"),
325
+ v.literal("replayed"),
326
+ v.literal("expired"),
327
+ v.literal("failed"),
328
+ ),
329
+ error: v.optional(v.string()),
330
+ })
331
+ .index("by_connection_status", ["connectionId", "status"])
332
+ .index("by_provider_user_status", ["providerUserId", "status"])
333
+ .index("by_expires", ["expiresAt"]),
334
+
310
335
  // -------------------------------------------------------------------------
311
336
  // Time-Series Policy Rules — default and preset-based storage rules
312
337
  // -------------------------------------------------------------------------
@@ -245,7 +245,17 @@ describe("sdkPush", () => {
245
245
  model: "Pixel 9 Pro",
246
246
  softwareVersion: "Android 16",
247
247
  source: "health-connect",
248
+ appId: "com.thirdparty.writer",
248
249
  },
250
+ events: [
251
+ {
252
+ category: "workout",
253
+ type: "CYCLING_STATIONARY",
254
+ startDatetime: Date.parse("2026-03-18T08:00:00Z"),
255
+ endDatetime: Date.parse("2026-03-18T08:45:00Z"),
256
+ externalId: "hc-cycling-1",
257
+ },
258
+ ],
249
259
  dataPoints: [
250
260
  {
251
261
  seriesType: "hrv_rmssd",
@@ -271,6 +281,30 @@ describe("sdkPush", () => {
271
281
  value: 340,
272
282
  externalId: "hc-active-calories-1",
273
283
  },
284
+ {
285
+ seriesType: "POWER",
286
+ recordedAt: Date.parse("2026-03-18T12:03:00Z"),
287
+ value: 220,
288
+ externalId: "hc-power-1",
289
+ },
290
+ {
291
+ seriesType: "SPEED",
292
+ recordedAt: Date.parse("2026-03-18T12:04:00Z"),
293
+ value: 7.5,
294
+ externalId: "hc-speed-1",
295
+ },
296
+ {
297
+ seriesType: "CYCLING_PEDALING_CADENCE",
298
+ recordedAt: Date.parse("2026-03-18T12:05:00Z"),
299
+ value: 88,
300
+ externalId: "hc-cadence-1",
301
+ },
302
+ {
303
+ seriesType: "TOTAL_CALORIES_BURNED",
304
+ recordedAt: Date.parse("2026-03-18T12:06:00Z"),
305
+ value: 830,
306
+ externalId: "hc-total-calories-1",
307
+ },
274
308
  ],
275
309
  dailySummaries: [
276
310
  {
@@ -293,6 +327,22 @@ describe("sdkPush", () => {
293
327
  deviceModel: "Pixel 9 Pro",
294
328
  softwareVersion: "Android 16",
295
329
  source: "health-connect",
330
+ originalSourceName: "com.thirdparty.writer",
331
+ });
332
+
333
+ const events = await t.run(async (ctx) => {
334
+ return await ctx.db
335
+ .query("events")
336
+ .withIndex("by_user_category_time", (idx) =>
337
+ idx.eq("userId", "user-3").eq("category", "workout"),
338
+ )
339
+ .collect();
340
+ });
341
+
342
+ expect(events).toHaveLength(1);
343
+ expect(events[0]).toMatchObject({
344
+ type: "indoor_cycling",
345
+ externalId: "hc-cycling-1",
296
346
  });
297
347
 
298
348
  const points = await t.run(async (ctx) => {
@@ -304,9 +354,13 @@ describe("sdkPush", () => {
304
354
 
305
355
  expect(points.map((point) => point.seriesType).sort()).toEqual([
306
356
  "active_calories",
357
+ "cadence",
307
358
  "distance",
308
359
  "floors_climbed",
309
360
  "heart_rate_variability_rmssd",
361
+ "power",
362
+ "speed",
363
+ "total_calories",
310
364
  ]);
311
365
 
312
366
  const summaries = await t.run(async (ctx) => {