@clipin/convex-wearables 0.1.3 → 0.2.1
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/README.md +11 -2
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +3 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/types.d.ts +6 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.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 +224 -1
- 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/types.d.ts +2 -0
- package/dist/component/providers/types.d.ts.map +1 -1
- package/dist/component/schema.d.ts +38 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +25 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sdkPush.d.ts +4 -0
- package/dist/component/sdkPush.d.ts.map +1 -1
- package/dist/component/sdkPush.js +5 -0
- package/dist/component/sdkPush.js.map +1 -1
- package/dist/component/summaries.d.ts +16 -1
- package/dist/component/summaries.d.ts.map +1 -1
- package/dist/component/summaries.js +72 -38
- package/dist/component/summaries.js.map +1 -1
- package/dist/component/syncWorkflow.d.ts.map +1 -1
- package/dist/component/syncWorkflow.js +2 -0
- package/dist/component/syncWorkflow.js.map +1 -1
- package/dist/test.d.ts +38 -1
- package/dist/test.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +4 -0
- package/src/client/types.ts +6 -0
- package/src/component/garminWebhooks.test.ts +90 -2
- package/src/component/garminWebhooks.ts +249 -1
- package/src/component/oauthActions.ts +6 -0
- package/src/component/providers/types.ts +2 -0
- package/src/component/schema.ts +31 -0
- package/src/component/sdkPush.test.ts +83 -0
- package/src/component/sdkPush.ts +5 -0
- package/src/component/summaries.test.ts +99 -0
- package/src/component/summaries.ts +89 -39
- package/src/component/syncWorkflow.ts +2 -0
|
@@ -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
|
|
|
@@ -438,6 +448,145 @@ export const processPushPayload = action({
|
|
|
438
448
|
},
|
|
439
449
|
});
|
|
440
450
|
|
|
451
|
+
export const replayPendingForConnection = internalAction({
|
|
452
|
+
args: {
|
|
453
|
+
connectionId: v.id("connections"),
|
|
454
|
+
},
|
|
455
|
+
returns: v.object({
|
|
456
|
+
failed: v.number(),
|
|
457
|
+
replayed: v.number(),
|
|
458
|
+
skipped: v.number(),
|
|
459
|
+
}),
|
|
460
|
+
handler: async (ctx, args) => {
|
|
461
|
+
const skipped = await ctx.runMutation(internal.garminWebhooks.markExpiredPendingForConnection, {
|
|
462
|
+
connectionId: args.connectionId,
|
|
463
|
+
now: Date.now(),
|
|
464
|
+
});
|
|
465
|
+
const pendingPayloads = await ctx.runQuery(internal.garminWebhooks.getReplayablePending, {
|
|
466
|
+
connectionId: args.connectionId,
|
|
467
|
+
now: Date.now(),
|
|
468
|
+
});
|
|
469
|
+
let failed = 0;
|
|
470
|
+
let replayed = 0;
|
|
471
|
+
|
|
472
|
+
for (const pending of pendingPayloads) {
|
|
473
|
+
try {
|
|
474
|
+
await ctx.runAction(api.garminWebhooks.processPushPayload, {
|
|
475
|
+
garminClientId: pending.garminClientId,
|
|
476
|
+
payloadJson: pending.payloadJson,
|
|
477
|
+
});
|
|
478
|
+
await ctx.runMutation(internal.garminWebhooks.markPendingReplayed, {
|
|
479
|
+
pendingId: pending._id,
|
|
480
|
+
});
|
|
481
|
+
replayed += 1;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
failed += 1;
|
|
484
|
+
await ctx.runMutation(internal.garminWebhooks.markPendingFailed, {
|
|
485
|
+
pendingId: pending._id,
|
|
486
|
+
error: error instanceof Error ? error.message : String(error),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return { failed, replayed, skipped };
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
export const getReplayablePending = internalQuery({
|
|
496
|
+
args: {
|
|
497
|
+
connectionId: v.id("connections"),
|
|
498
|
+
now: v.number(),
|
|
499
|
+
},
|
|
500
|
+
returns: v.array(v.any()),
|
|
501
|
+
handler: async (ctx, args) => {
|
|
502
|
+
const rows = await ctx.db
|
|
503
|
+
.query("pendingGarminPushPayloads")
|
|
504
|
+
.withIndex("by_connection_status", (idx) =>
|
|
505
|
+
idx.eq("connectionId", args.connectionId).eq("status", "pending"),
|
|
506
|
+
)
|
|
507
|
+
.order("asc")
|
|
508
|
+
.take(PENDING_GARMIN_PUSH_REPLAY_LIMIT);
|
|
509
|
+
|
|
510
|
+
return rows.filter((row) => row.expiresAt > args.now);
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
export const storePendingPayload = internalMutation({
|
|
515
|
+
args: {
|
|
516
|
+
connectionId: v.id("connections"),
|
|
517
|
+
userId: v.string(),
|
|
518
|
+
providerUserId: v.string(),
|
|
519
|
+
garminClientId: v.string(),
|
|
520
|
+
payloadJson: v.string(),
|
|
521
|
+
receivedAt: v.number(),
|
|
522
|
+
expiresAt: v.number(),
|
|
523
|
+
},
|
|
524
|
+
returns: v.id("pendingGarminPushPayloads"),
|
|
525
|
+
handler: async (ctx, args) => {
|
|
526
|
+
return await ctx.db.insert("pendingGarminPushPayloads", {
|
|
527
|
+
...args,
|
|
528
|
+
status: "pending",
|
|
529
|
+
});
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
export const markPendingReplayed = internalMutation({
|
|
534
|
+
args: {
|
|
535
|
+
pendingId: v.id("pendingGarminPushPayloads"),
|
|
536
|
+
},
|
|
537
|
+
returns: v.null(),
|
|
538
|
+
handler: async (ctx, args) => {
|
|
539
|
+
await ctx.db.patch(args.pendingId, {
|
|
540
|
+
replayedAt: Date.now(),
|
|
541
|
+
status: "replayed",
|
|
542
|
+
error: undefined,
|
|
543
|
+
});
|
|
544
|
+
return null;
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
export const markPendingFailed = internalMutation({
|
|
549
|
+
args: {
|
|
550
|
+
pendingId: v.id("pendingGarminPushPayloads"),
|
|
551
|
+
error: v.string(),
|
|
552
|
+
},
|
|
553
|
+
returns: v.null(),
|
|
554
|
+
handler: async (ctx, args) => {
|
|
555
|
+
await ctx.db.patch(args.pendingId, {
|
|
556
|
+
error: args.error,
|
|
557
|
+
status: "failed",
|
|
558
|
+
});
|
|
559
|
+
return null;
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
export const markExpiredPendingForConnection = internalMutation({
|
|
564
|
+
args: {
|
|
565
|
+
connectionId: v.id("connections"),
|
|
566
|
+
now: v.number(),
|
|
567
|
+
},
|
|
568
|
+
returns: v.number(),
|
|
569
|
+
handler: async (ctx, args) => {
|
|
570
|
+
const expired = await ctx.db
|
|
571
|
+
.query("pendingGarminPushPayloads")
|
|
572
|
+
.withIndex("by_connection_status", (idx) =>
|
|
573
|
+
idx.eq("connectionId", args.connectionId).eq("status", "pending"),
|
|
574
|
+
)
|
|
575
|
+
.filter((q) => q.lte(q.field("expiresAt"), args.now))
|
|
576
|
+
.collect();
|
|
577
|
+
|
|
578
|
+
await Promise.all(
|
|
579
|
+
expired.map((row) =>
|
|
580
|
+
ctx.db.patch(row._id, {
|
|
581
|
+
status: "expired",
|
|
582
|
+
}),
|
|
583
|
+
),
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
return expired.length;
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
|
|
441
590
|
async function processActivityEntries(
|
|
442
591
|
ctx: Pick<ActionCtx, "runQuery" | "runMutation">,
|
|
443
592
|
activities: GarminPushPayload["activities"] | GarminPushPayload["activityDetails"],
|
|
@@ -559,9 +708,11 @@ async function upsertSummary<T extends { date: string; category: string }>(
|
|
|
559
708
|
|
|
560
709
|
await ctx.runMutation(internal.summaries.upsert, {
|
|
561
710
|
userId,
|
|
711
|
+
provider: "garmin",
|
|
562
712
|
date,
|
|
563
713
|
category,
|
|
564
714
|
...metrics,
|
|
715
|
+
source: "garmin",
|
|
565
716
|
});
|
|
566
717
|
}
|
|
567
718
|
|
|
@@ -644,6 +795,103 @@ async function resolveConnection(
|
|
|
644
795
|
return connection;
|
|
645
796
|
}
|
|
646
797
|
|
|
798
|
+
function collectGarminUserIds(payload: GarminPushPayload): string[] {
|
|
799
|
+
const userIds = new Set<string>();
|
|
800
|
+
const maybeAdd = (entry: { userId?: unknown } | null | undefined) => {
|
|
801
|
+
if (typeof entry?.userId === "string" && entry.userId.length > 0) {
|
|
802
|
+
userIds.add(entry.userId);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
for (const entry of payload.activities ?? []) maybeAdd(entry);
|
|
807
|
+
for (const entry of payload.activityDetails ?? []) maybeAdd(entry);
|
|
808
|
+
for (const entry of payload.sleeps ?? []) maybeAdd(entry);
|
|
809
|
+
for (const entry of payload.dailies ?? []) maybeAdd(entry);
|
|
810
|
+
for (const entry of payload.epochs ?? []) maybeAdd(entry);
|
|
811
|
+
for (const entry of payload.bodyComps ?? []) maybeAdd(entry);
|
|
812
|
+
for (const entry of payload.hrv ?? []) maybeAdd(entry);
|
|
813
|
+
for (const entry of payload.stressDetails ?? []) maybeAdd(entry);
|
|
814
|
+
for (const entry of payload.respiration ?? []) maybeAdd(entry);
|
|
815
|
+
for (const entry of payload.pulseOx ?? []) maybeAdd(entry);
|
|
816
|
+
for (const entry of payload.bloodPressures ?? []) maybeAdd(entry);
|
|
817
|
+
for (const entry of payload.userMetrics ?? []) maybeAdd(entry);
|
|
818
|
+
for (const entry of payload.skinTemp ?? []) maybeAdd(entry);
|
|
819
|
+
for (const entry of payload.healthSnapshot ?? []) maybeAdd(entry);
|
|
820
|
+
for (const entry of payload.moveiq ?? []) maybeAdd(entry);
|
|
821
|
+
for (const entry of payload.menstrualCycleTracking ?? []) maybeAdd(entry);
|
|
822
|
+
for (const entry of payload.mct ?? []) maybeAdd(entry);
|
|
823
|
+
for (const entry of payload.userPermissionsChange ?? []) maybeAdd(entry);
|
|
824
|
+
for (const entry of payload.deregistrations ?? []) maybeAdd(entry);
|
|
825
|
+
|
|
826
|
+
return [...userIds];
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function filterGarminPayloadByUserId(
|
|
830
|
+
payload: GarminPushPayload,
|
|
831
|
+
providerUserId: string,
|
|
832
|
+
): GarminPushPayload {
|
|
833
|
+
const filterEntries = <T extends { userId?: unknown }>(entries: T[] | undefined): T[] =>
|
|
834
|
+
(entries ?? []).filter((entry) => entry.userId === providerUserId);
|
|
835
|
+
|
|
836
|
+
return {
|
|
837
|
+
activities: filterEntries(payload.activities),
|
|
838
|
+
activityDetails: filterEntries(payload.activityDetails),
|
|
839
|
+
sleeps: filterEntries(payload.sleeps),
|
|
840
|
+
dailies: filterEntries(payload.dailies),
|
|
841
|
+
epochs: filterEntries(payload.epochs),
|
|
842
|
+
bodyComps: filterEntries(payload.bodyComps),
|
|
843
|
+
hrv: filterEntries(payload.hrv),
|
|
844
|
+
stressDetails: filterEntries(payload.stressDetails),
|
|
845
|
+
respiration: filterEntries(payload.respiration),
|
|
846
|
+
pulseOx: filterEntries(payload.pulseOx),
|
|
847
|
+
bloodPressures: filterEntries(payload.bloodPressures),
|
|
848
|
+
userMetrics: filterEntries(payload.userMetrics),
|
|
849
|
+
skinTemp: filterEntries(payload.skinTemp),
|
|
850
|
+
healthSnapshot: filterEntries(payload.healthSnapshot),
|
|
851
|
+
moveiq: filterEntries(payload.moveiq),
|
|
852
|
+
menstrualCycleTracking: filterEntries(payload.menstrualCycleTracking),
|
|
853
|
+
mct: filterEntries(payload.mct),
|
|
854
|
+
userPermissionsChange: filterEntries(payload.userPermissionsChange),
|
|
855
|
+
deregistrations: filterEntries(payload.deregistrations),
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function queuePendingPayloadForInactiveConnections(
|
|
860
|
+
ctx: Pick<ActionCtx, "runMutation" | "runQuery">,
|
|
861
|
+
payload: GarminPushPayload,
|
|
862
|
+
garminClientId: string,
|
|
863
|
+
) {
|
|
864
|
+
const userIds = collectGarminUserIds(payload);
|
|
865
|
+
if (userIds.length === 0) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const receivedAt = Date.now();
|
|
870
|
+
|
|
871
|
+
for (const providerUserId of userIds) {
|
|
872
|
+
const conn = (await ctx.runQuery(internal.connections.getByProviderUser, {
|
|
873
|
+
provider: "garmin",
|
|
874
|
+
providerUserId,
|
|
875
|
+
})) as Doc<"connections"> | null;
|
|
876
|
+
|
|
877
|
+
if (!conn || conn.status === "active") {
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const payloadJson = JSON.stringify(filterGarminPayloadByUserId(payload, providerUserId));
|
|
882
|
+
|
|
883
|
+
await ctx.runMutation(internal.garminWebhooks.storePendingPayload, {
|
|
884
|
+
connectionId: conn._id,
|
|
885
|
+
userId: conn.userId,
|
|
886
|
+
providerUserId,
|
|
887
|
+
garminClientId,
|
|
888
|
+
payloadJson,
|
|
889
|
+
receivedAt,
|
|
890
|
+
expiresAt: receivedAt + PENDING_GARMIN_PUSH_TTL_MS,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
647
895
|
/**
|
|
648
896
|
* Resolve a Garmin userId to a dataSource ID, creating one if needed.
|
|
649
897
|
*/
|
|
@@ -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,
|
|
@@ -113,6 +113,8 @@ export interface NormalizedDataPoint {
|
|
|
113
113
|
export interface NormalizedDailySummary {
|
|
114
114
|
date: string;
|
|
115
115
|
category: string;
|
|
116
|
+
source?: string;
|
|
117
|
+
originalSourceName?: string;
|
|
116
118
|
totalSteps?: number;
|
|
117
119
|
totalCalories?: number;
|
|
118
120
|
activeCalories?: number;
|
package/src/component/schema.ts
CHANGED
|
@@ -200,6 +200,10 @@ export default defineSchema({
|
|
|
200
200
|
// -------------------------------------------------------------------------
|
|
201
201
|
dailySummaries: defineTable({
|
|
202
202
|
userId: v.string(),
|
|
203
|
+
provider: v.optional(providerName),
|
|
204
|
+
dataSourceId: v.optional(v.id("dataSources")),
|
|
205
|
+
source: v.optional(v.string()),
|
|
206
|
+
originalSourceName: v.optional(v.string()),
|
|
203
207
|
date: v.string(), // "2026-03-15" (ISO date string)
|
|
204
208
|
category: v.string(), // "activity" | "sleep" | "recovery" | "body"
|
|
205
209
|
|
|
@@ -241,6 +245,8 @@ export default defineSchema({
|
|
|
241
245
|
bodyBattery: v.optional(v.number()),
|
|
242
246
|
spo2Avg: v.optional(v.number()),
|
|
243
247
|
})
|
|
248
|
+
.index("by_user_provider_category_date", ["userId", "provider", "category", "date"])
|
|
249
|
+
.index("by_user_provider_date", ["userId", "provider", "date"])
|
|
244
250
|
.index("by_user_category_date", ["userId", "category", "date"])
|
|
245
251
|
.index("by_user_date", ["userId", "date"]),
|
|
246
252
|
|
|
@@ -301,6 +307,31 @@ export default defineSchema({
|
|
|
301
307
|
updatedAt: v.optional(v.number()),
|
|
302
308
|
}).index("by_provider", ["provider"]),
|
|
303
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
|
+
|
|
304
335
|
// -------------------------------------------------------------------------
|
|
305
336
|
// Time-Series Policy Rules — default and preset-based storage rules
|
|
306
337
|
// -------------------------------------------------------------------------
|
|
@@ -130,6 +130,20 @@ describe("sdkPush", () => {
|
|
|
130
130
|
.collect();
|
|
131
131
|
});
|
|
132
132
|
expect(summaries).toHaveLength(2);
|
|
133
|
+
expect(summaries).toEqual(
|
|
134
|
+
expect.arrayContaining([
|
|
135
|
+
expect.objectContaining({
|
|
136
|
+
provider: "google",
|
|
137
|
+
source: "health-connect",
|
|
138
|
+
category: "activity",
|
|
139
|
+
}),
|
|
140
|
+
expect.objectContaining({
|
|
141
|
+
provider: "google",
|
|
142
|
+
source: "health-connect",
|
|
143
|
+
category: "recovery",
|
|
144
|
+
}),
|
|
145
|
+
]),
|
|
146
|
+
);
|
|
133
147
|
});
|
|
134
148
|
|
|
135
149
|
it("deduplicates SDK pushes by external id and source-time keys", async () => {
|
|
@@ -304,12 +318,81 @@ describe("sdkPush", () => {
|
|
|
304
318
|
|
|
305
319
|
expect(summaries).toHaveLength(1);
|
|
306
320
|
expect(summaries[0]).toMatchObject({
|
|
321
|
+
provider: "google",
|
|
322
|
+
source: "health-connect",
|
|
307
323
|
category: "activity",
|
|
308
324
|
totalSteps: 12345,
|
|
309
325
|
totalCalories: 780,
|
|
310
326
|
});
|
|
311
327
|
});
|
|
312
328
|
|
|
329
|
+
it("keeps Apple and Google daily summaries separate for the same user date and category", async () => {
|
|
330
|
+
const t = convexTest(schema, modules);
|
|
331
|
+
|
|
332
|
+
await t.action(api.sdkPush.ingestNormalizedPayload, {
|
|
333
|
+
userId: "user-mixed",
|
|
334
|
+
provider: "apple",
|
|
335
|
+
sourceMetadata: {
|
|
336
|
+
source: "healthkit",
|
|
337
|
+
originalSourceName: "Apple Watch",
|
|
338
|
+
},
|
|
339
|
+
summaries: [
|
|
340
|
+
{
|
|
341
|
+
date: "2026-03-18",
|
|
342
|
+
category: "activity",
|
|
343
|
+
totalSteps: 9000,
|
|
344
|
+
activeCalories: 450,
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
await t.action(api.sdkPush.ingestNormalizedPayload, {
|
|
350
|
+
userId: "user-mixed",
|
|
351
|
+
provider: "google",
|
|
352
|
+
sourceMetadata: {
|
|
353
|
+
source: "health-connect",
|
|
354
|
+
originalSourceName: "com.google.android.apps.fitness",
|
|
355
|
+
},
|
|
356
|
+
summaries: [
|
|
357
|
+
{
|
|
358
|
+
date: "2026-03-18",
|
|
359
|
+
category: "activity",
|
|
360
|
+
totalSteps: 7200,
|
|
361
|
+
activeCalories: 330,
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const summaries = await t.run(async (ctx) => {
|
|
367
|
+
return await ctx.db
|
|
368
|
+
.query("dailySummaries")
|
|
369
|
+
.withIndex("by_user_category_date", (idx) =>
|
|
370
|
+
idx.eq("userId", "user-mixed").eq("category", "activity").eq("date", "2026-03-18"),
|
|
371
|
+
)
|
|
372
|
+
.collect();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(summaries).toHaveLength(2);
|
|
376
|
+
expect(summaries).toEqual(
|
|
377
|
+
expect.arrayContaining([
|
|
378
|
+
expect.objectContaining({
|
|
379
|
+
provider: "apple",
|
|
380
|
+
source: "healthkit",
|
|
381
|
+
originalSourceName: "Apple Watch",
|
|
382
|
+
totalSteps: 9000,
|
|
383
|
+
activeCalories: 450,
|
|
384
|
+
}),
|
|
385
|
+
expect.objectContaining({
|
|
386
|
+
provider: "google",
|
|
387
|
+
source: "health-connect",
|
|
388
|
+
originalSourceName: "com.google.android.apps.fitness",
|
|
389
|
+
totalSteps: 7200,
|
|
390
|
+
activeCalories: 330,
|
|
391
|
+
}),
|
|
392
|
+
]),
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
313
396
|
it("batches large data-point payloads across multiple writes", async () => {
|
|
314
397
|
const t = convexTest(schema, modules);
|
|
315
398
|
|
package/src/component/sdkPush.ts
CHANGED
|
@@ -93,6 +93,8 @@ const sdkDataPointValidator = v.object({
|
|
|
93
93
|
const sdkSummaryValidator = v.object({
|
|
94
94
|
date: v.string(),
|
|
95
95
|
category: v.string(),
|
|
96
|
+
source: v.optional(v.string()),
|
|
97
|
+
originalSourceName: v.optional(v.string()),
|
|
96
98
|
totalSteps: v.optional(v.number()),
|
|
97
99
|
totalCalories: v.optional(v.number()),
|
|
98
100
|
activeCalories: v.optional(v.number()),
|
|
@@ -360,7 +362,10 @@ export const ingestNormalizedPayload = action({
|
|
|
360
362
|
for (const summary of summaries) {
|
|
361
363
|
await ctx.runMutation(internal.summaries.upsert, {
|
|
362
364
|
userId: args.userId,
|
|
365
|
+
provider: args.provider,
|
|
363
366
|
...summary,
|
|
367
|
+
source: summary.source ?? defaultMetadata.source,
|
|
368
|
+
originalSourceName: summary.originalSourceName ?? defaultMetadata.originalSourceName,
|
|
364
369
|
});
|
|
365
370
|
}
|
|
366
371
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { convexTest } from "convex-test";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { api, internal } from "./_generated/api";
|
|
3
4
|
import schema from "./schema";
|
|
4
5
|
import { modules } from "./test.setup";
|
|
5
6
|
|
|
@@ -81,6 +82,62 @@ describe("summaries", () => {
|
|
|
81
82
|
expect(summaries[0].totalSteps).toBe(10000);
|
|
82
83
|
expect(summaries[0].activeMinutes).toBe(45);
|
|
83
84
|
});
|
|
85
|
+
|
|
86
|
+
it("upserts summaries by user provider category and date", async () => {
|
|
87
|
+
const t = convexTest(schema, modules);
|
|
88
|
+
|
|
89
|
+
await t.mutation(internal.summaries.upsert, {
|
|
90
|
+
userId: "user-1",
|
|
91
|
+
provider: "garmin",
|
|
92
|
+
date: "2026-03-15",
|
|
93
|
+
category: "activity",
|
|
94
|
+
totalSteps: 10000,
|
|
95
|
+
activeCalories: 600,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await t.mutation(internal.summaries.upsert, {
|
|
99
|
+
userId: "user-1",
|
|
100
|
+
provider: "apple",
|
|
101
|
+
date: "2026-03-15",
|
|
102
|
+
category: "activity",
|
|
103
|
+
source: "healthkit",
|
|
104
|
+
originalSourceName: "Apple Watch",
|
|
105
|
+
totalSteps: 8500,
|
|
106
|
+
activeCalories: 430,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await t.mutation(internal.summaries.upsert, {
|
|
110
|
+
userId: "user-1",
|
|
111
|
+
provider: "garmin",
|
|
112
|
+
date: "2026-03-15",
|
|
113
|
+
category: "activity",
|
|
114
|
+
totalSteps: 11000,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const summaries = await t.run(async (ctx) => {
|
|
118
|
+
return await ctx.db
|
|
119
|
+
.query("dailySummaries")
|
|
120
|
+
.withIndex("by_user_category_date", (idx) =>
|
|
121
|
+
idx.eq("userId", "user-1").eq("category", "activity").eq("date", "2026-03-15"),
|
|
122
|
+
)
|
|
123
|
+
.collect();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(summaries).toHaveLength(2);
|
|
127
|
+
const garmin = summaries.find((summary) => summary.provider === "garmin");
|
|
128
|
+
const apple = summaries.find((summary) => summary.provider === "apple");
|
|
129
|
+
|
|
130
|
+
expect(garmin).toMatchObject({
|
|
131
|
+
totalSteps: 11000,
|
|
132
|
+
activeCalories: 600,
|
|
133
|
+
});
|
|
134
|
+
expect(apple).toMatchObject({
|
|
135
|
+
source: "healthkit",
|
|
136
|
+
originalSourceName: "Apple Watch",
|
|
137
|
+
totalSteps: 8500,
|
|
138
|
+
activeCalories: 430,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
84
141
|
});
|
|
85
142
|
|
|
86
143
|
describe("getDailySummaries", () => {
|
|
@@ -156,6 +213,48 @@ describe("summaries", () => {
|
|
|
156
213
|
expect(sleep).toHaveLength(1);
|
|
157
214
|
expect(sleep[0].sleepDurationMinutes).toBe(480);
|
|
158
215
|
});
|
|
216
|
+
|
|
217
|
+
it("filters summaries by provider when requested", async () => {
|
|
218
|
+
const t = convexTest(schema, modules);
|
|
219
|
+
|
|
220
|
+
await t.mutation(internal.summaries.upsert, {
|
|
221
|
+
userId: "user-1",
|
|
222
|
+
provider: "garmin",
|
|
223
|
+
date: "2026-03-15",
|
|
224
|
+
category: "activity",
|
|
225
|
+
totalSteps: 10000,
|
|
226
|
+
});
|
|
227
|
+
await t.mutation(internal.summaries.upsert, {
|
|
228
|
+
userId: "user-1",
|
|
229
|
+
provider: "google",
|
|
230
|
+
date: "2026-03-15",
|
|
231
|
+
category: "activity",
|
|
232
|
+
source: "health-connect",
|
|
233
|
+
totalSteps: 9200,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const google = await t.query(api.summaries.getDailySummaries, {
|
|
237
|
+
userId: "user-1",
|
|
238
|
+
provider: "google",
|
|
239
|
+
category: "activity",
|
|
240
|
+
startDate: "2026-03-15",
|
|
241
|
+
endDate: "2026-03-15",
|
|
242
|
+
});
|
|
243
|
+
const mixed = await t.query(api.summaries.getDailySummaries, {
|
|
244
|
+
userId: "user-1",
|
|
245
|
+
category: "activity",
|
|
246
|
+
startDate: "2026-03-15",
|
|
247
|
+
endDate: "2026-03-15",
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(google).toHaveLength(1);
|
|
251
|
+
expect(google[0]).toMatchObject({
|
|
252
|
+
provider: "google",
|
|
253
|
+
source: "health-connect",
|
|
254
|
+
totalSteps: 9200,
|
|
255
|
+
});
|
|
256
|
+
expect(mixed).toHaveLength(2);
|
|
257
|
+
});
|
|
159
258
|
});
|
|
160
259
|
|
|
161
260
|
describe("getByUserDate", () => {
|