@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.
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +6 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/types.d.ts +13 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js +1 -1
- package/dist/client/types.js.map +1 -1
- package/dist/component/garminBackfill.d.ts +9 -1
- package/dist/component/garminBackfill.d.ts.map +1 -1
- package/dist/component/garminBackfill.js +27 -6
- package/dist/component/garminBackfill.js.map +1 -1
- package/dist/component/garminWebhooks.d.ts +44 -0
- package/dist/component/garminWebhooks.d.ts.map +1 -1
- package/dist/component/garminWebhooks.js +248 -14
- package/dist/component/garminWebhooks.js.map +1 -1
- package/dist/component/oauthActions.d.ts.map +1 -1
- package/dist/component/oauthActions.js +5 -0
- package/dist/component/oauthActions.js.map +1 -1
- package/dist/component/providers/garmin.d.ts +4 -0
- package/dist/component/providers/garmin.d.ts.map +1 -1
- package/dist/component/providers/garmin.js +23 -8
- package/dist/component/providers/garmin.js.map +1 -1
- package/dist/component/schema.d.ts +27 -0
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +19 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sdkPush.d.ts +24 -0
- package/dist/component/sdkPush.d.ts.map +1 -1
- package/dist/component/sdkPush.js +101 -6
- package/dist/component/sdkPush.js.map +1 -1
- package/dist/component/syncWorkflow.d.ts.map +1 -1
- package/dist/component/syncWorkflow.js +5 -3
- package/dist/component/syncWorkflow.js.map +1 -1
- package/dist/test.d.ts +27 -0
- package/dist/test.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +13 -1
- package/src/client/types.ts +13 -1
- package/src/component/garminBackfill.ts +33 -6
- package/src/component/garminWebhooks.test.ts +112 -9
- package/src/component/garminWebhooks.ts +279 -14
- package/src/component/oauthActions.ts +6 -0
- package/src/component/providers/garmin.ts +36 -12
- package/src/component/schema.ts +25 -0
- package/src/component/sdkPush.test.ts +54 -0
- package/src/component/sdkPush.ts +120 -6
- 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 {
|
|
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
|
-
|
|
238
|
-
|
|
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, "
|
|
264
|
+
addSignal(signalBuckets, "allDayRespiration", connection._id);
|
|
251
265
|
}
|
|
252
266
|
}
|
|
253
267
|
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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, "
|
|
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 "
|
|
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 "
|
|
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:
|
|
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: "
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
"
|
|
1163
|
+
"allDayRespiration",
|
|
1151
1164
|
"pulseOx",
|
|
1152
1165
|
"bloodPressures",
|
|
1153
1166
|
"userMetrics",
|
|
1154
1167
|
"skinTemp",
|
|
1155
1168
|
"healthSnapshot",
|
|
1156
|
-
"
|
|
1169
|
+
"moveIQActivities",
|
|
1157
1170
|
"mct",
|
|
1158
1171
|
];
|
|
1159
|
-
if (!validTypes.includes(
|
|
1172
|
+
if (!validTypes.includes(endpointDataType)) {
|
|
1160
1173
|
throw new Error(`Invalid backfill data type: ${dataType}`);
|
|
1161
1174
|
}
|
|
1162
1175
|
|
|
1163
|
-
await makeAuthenticatedRequest(
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
}
|
package/src/component/schema.ts
CHANGED
|
@@ -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) => {
|