@clipin/convex-wearables 0.1.2 → 0.2.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 (47) hide show
  1. package/README.md +11 -2
  2. package/dist/client/index.d.ts +5 -0
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +3 -0
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/client/types.d.ts +6 -0
  7. package/dist/client/types.d.ts.map +1 -1
  8. package/dist/client/types.js.map +1 -1
  9. package/dist/component/dataPoints.d.ts +1 -0
  10. package/dist/component/dataPoints.d.ts.map +1 -1
  11. package/dist/component/dataPoints.js +140 -71
  12. package/dist/component/dataPoints.js.map +1 -1
  13. package/dist/component/garminWebhooks.js +3 -1
  14. package/dist/component/garminWebhooks.js.map +1 -1
  15. package/dist/component/providers/types.d.ts +2 -0
  16. package/dist/component/providers/types.d.ts.map +1 -1
  17. package/dist/component/schema.d.ts +11 -1
  18. package/dist/component/schema.d.ts.map +1 -1
  19. package/dist/component/schema.js +6 -0
  20. package/dist/component/schema.js.map +1 -1
  21. package/dist/component/sdkPush.d.ts +4 -0
  22. package/dist/component/sdkPush.d.ts.map +1 -1
  23. package/dist/component/sdkPush.js +5 -0
  24. package/dist/component/sdkPush.js.map +1 -1
  25. package/dist/component/summaries.d.ts +16 -1
  26. package/dist/component/summaries.d.ts.map +1 -1
  27. package/dist/component/summaries.js +72 -38
  28. package/dist/component/summaries.js.map +1 -1
  29. package/dist/component/syncWorkflow.d.ts.map +1 -1
  30. package/dist/component/syncWorkflow.js +2 -0
  31. package/dist/component/syncWorkflow.js.map +1 -1
  32. package/dist/test.d.ts +11 -1
  33. package/dist/test.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/client/index.ts +5 -0
  36. package/src/client/types.ts +6 -0
  37. package/src/component/dataPoints.test.ts +214 -0
  38. package/src/component/dataPoints.ts +176 -79
  39. package/src/component/garminWebhooks.test.ts +2 -0
  40. package/src/component/garminWebhooks.ts +3 -1
  41. package/src/component/providers/types.ts +2 -0
  42. package/src/component/schema.ts +6 -0
  43. package/src/component/sdkPush.test.ts +83 -0
  44. package/src/component/sdkPush.ts +5 -0
  45. package/src/component/summaries.test.ts +99 -0
  46. package/src/component/summaries.ts +89 -39
  47. package/src/component/syncWorkflow.ts +2 -0
@@ -34,9 +34,10 @@ import {
34
34
  const DAY_MS = 24 * 60 * 60 * 1000;
35
35
  const MAX_QUERY_LIMIT = 2000;
36
36
  const MAINTENANCE_SETTINGS_KEY = "default";
37
- const MAINTENANCE_BATCH_SIZE = 20;
38
- const MAINTENANCE_POINT_BATCH_SIZE = 2000;
37
+ const MAINTENANCE_BATCH_SIZE = 2;
38
+ const MAINTENANCE_POINT_BATCH_SIZE = 1000;
39
39
  const MAINTENANCE_ROLLUP_BATCH_SIZE = 500;
40
+ const PROMPT_MAINTENANCE_DELAY_MS = 60 * 1000;
40
41
  const LONG_IDLE_MAINTENANCE_MS = 30 * DAY_MS;
41
42
 
42
43
  type StoredPolicyRule = Doc<"timeSeriesPolicyRules">;
@@ -72,6 +73,11 @@ type AggregatedStats = {
72
73
  count: number;
73
74
  };
74
75
 
76
+ type MaintenanceResult = {
77
+ didWork: boolean;
78
+ hasBacklog: boolean;
79
+ };
80
+
75
81
  type EffectivePolicy = {
76
82
  tiers: NormalizedTimeSeriesTier[];
77
83
  sourceKind: "preset" | "default" | "builtin";
@@ -206,10 +212,13 @@ export const getTimeSeriesForUser = query({
206
212
  startDate: v.number(),
207
213
  endDate: v.number(),
208
214
  limit: v.optional(v.number()),
215
+ order: v.optional(v.union(v.literal("asc"), v.literal("desc"))),
209
216
  },
210
217
  returns: v.array(timeSeriesPointValidator),
211
218
  handler: async (ctx, args) => {
212
219
  const limit = Math.min(args.limit ?? 500, MAX_QUERY_LIMIT);
220
+ const selectionOrder = args.order ?? "desc";
221
+ const responseOrder = args.order ?? "asc";
213
222
 
214
223
  const sources = await ctx.db
215
224
  .query("dataSources")
@@ -227,13 +236,19 @@ export const getTimeSeriesForUser = query({
227
236
  startDate: args.startDate,
228
237
  endDate: args.endDate,
229
238
  limit,
230
- order: "asc",
239
+ order: selectionOrder,
231
240
  })),
232
241
  );
233
242
  }
234
243
 
235
- points.sort((a, b) => a.timestamp - b.timestamp);
236
- return points.slice(0, limit);
244
+ points.sort((a, b) =>
245
+ selectionOrder === "asc" ? a.timestamp - b.timestamp : b.timestamp - a.timestamp,
246
+ );
247
+
248
+ const limited = points.slice(0, limit);
249
+ return limited.sort((a, b) =>
250
+ responseOrder === "asc" ? a.timestamp - b.timestamp : b.timestamp - a.timestamp,
251
+ );
237
252
  },
238
253
  });
239
254
 
@@ -470,7 +485,9 @@ export const replaceTimeSeriesPolicyConfiguration = mutation({
470
485
 
471
486
  await upsertTimeSeriesPolicySettings(ctx, args.maintenance);
472
487
  await markAllSeriesStateDue(ctx, updatedAt);
473
- await ensureTimeSeriesMaintenanceScheduled(ctx);
488
+ await ensureTimeSeriesMaintenanceScheduled(ctx, {
489
+ delayMs: PROMPT_MAINTENANCE_DELAY_MS,
490
+ });
474
491
 
475
492
  return {
476
493
  defaultRulesStored: normalizedDefaultRules.length,
@@ -496,7 +513,9 @@ export const setUserTimeSeriesPolicyPreset = mutation({
496
513
  await ctx.db.delete(existing._id);
497
514
  }
498
515
  await markSeriesStateDueForUser(ctx, args.userId, Date.now());
499
- await ensureTimeSeriesMaintenanceScheduled(ctx);
516
+ await ensureTimeSeriesMaintenanceScheduled(ctx, {
517
+ delayMs: PROMPT_MAINTENANCE_DELAY_MS,
518
+ });
500
519
  return null;
501
520
  }
502
521
 
@@ -522,7 +541,9 @@ export const setUserTimeSeriesPolicyPreset = mutation({
522
541
  }
523
542
 
524
543
  await markSeriesStateDueForUser(ctx, args.userId, Date.now());
525
- await ensureTimeSeriesMaintenanceScheduled(ctx);
544
+ await ensureTimeSeriesMaintenanceScheduled(ctx, {
545
+ delayMs: PROMPT_MAINTENANCE_DELAY_MS,
546
+ });
526
547
  return null;
527
548
  },
528
549
  });
@@ -602,16 +623,19 @@ export const runTimeSeriesMaintenance = internalMutation({
602
623
  .withIndex("by_next_maintenance", (idx) => idx.lte("nextMaintenanceAt", now))
603
624
  .take(MAINTENANCE_BATCH_SIZE);
604
625
 
626
+ let hasStateBacklog = false;
627
+
605
628
  for (const state of dueStates) {
606
629
  try {
607
- await maintainSeriesState(ctx.db, state, now);
630
+ const result = await maintainSeriesState(ctx.db, state, now);
631
+ hasStateBacklog ||= result.hasBacklog;
608
632
  } catch (error) {
609
633
  lastError = error instanceof Error ? error.message : String(error);
610
634
  }
611
635
  }
612
636
 
613
637
  const refreshed = await ensureTimeSeriesPolicySettingsDoc(ctx.db);
614
- const hasBacklog = dueStates.length === MAINTENANCE_BATCH_SIZE;
638
+ const hasBacklog = dueStates.length === MAINTENANCE_BATCH_SIZE || hasStateBacklog;
615
639
  const delayMs = hasBacklog
616
640
  ? Math.min(refreshed.maintenanceIntervalMs, 60 * 1000)
617
641
  : refreshed.maintenanceIntervalMs;
@@ -892,6 +916,7 @@ async function storePointsWithPolicy(
892
916
  const settings = await getTimeSeriesPolicySettings(ctx.db);
893
917
  const now = Date.now();
894
918
  const points = dedupeIncomingPoints(args.points);
919
+ const requiresMaintenance = policyRequiresMaintenance(effectivePolicy.tiers);
895
920
 
896
921
  const rawPoints: StoredPointInput[] = [];
897
922
  const rollupGroups = new Map<
@@ -934,24 +959,13 @@ async function storePointsWithPolicy(
934
959
  provider: dataSource.provider,
935
960
  seriesType: args.seriesType,
936
961
  latestRecordedAt: points.length > 0 ? points[points.length - 1].recordedAt : now,
937
- nextMaintenanceAt: policyRequiresMaintenance(effectivePolicy.tiers)
938
- ? now + settings.maintenanceIntervalMs
939
- : now + LONG_IDLE_MAINTENANCE_MS,
962
+ nextMaintenanceAt: requiresMaintenance ? now : now + LONG_IDLE_MAINTENANCE_MS,
940
963
  });
941
964
 
942
- if (policyRequiresMaintenance(effectivePolicy.tiers)) {
943
- await maintainSeriesState(
944
- ctx.db,
945
- {
946
- dataSourceId: dataSource._id,
947
- connectionId: dataSource.connectionId,
948
- userId: dataSource.userId,
949
- provider: dataSource.provider,
950
- seriesType: args.seriesType,
951
- },
952
- now,
953
- );
954
- await ensureTimeSeriesMaintenanceScheduled(ctx);
965
+ if (requiresMaintenance) {
966
+ await ensureTimeSeriesMaintenanceScheduled(ctx, {
967
+ delayMs: Math.min(settings.maintenanceIntervalMs, PROMPT_MAINTENANCE_DELAY_MS),
968
+ });
955
969
  }
956
970
 
957
971
  return {
@@ -975,23 +989,38 @@ async function upsertRawPoints(
975
989
  points: StoredPointInput[],
976
990
  ) {
977
991
  let lastId: Id<"dataPoints"> | null = null;
992
+ if (points.length === 0) {
993
+ return lastId;
994
+ }
995
+
996
+ const minRecordedAt = points[0].recordedAt;
997
+ const maxRecordedAt = points[points.length - 1].recordedAt;
998
+ const existingPoints = await db
999
+ .query("dataPoints")
1000
+ .withIndex("by_source_type_time", (idx) =>
1001
+ idx
1002
+ .eq("dataSourceId", dataSourceId)
1003
+ .eq("seriesType", seriesType)
1004
+ .gte("recordedAt", minRecordedAt)
1005
+ .lte("recordedAt", maxRecordedAt),
1006
+ )
1007
+ .collect();
1008
+
1009
+ const existingByRecordedAt = new Map<number, Doc<"dataPoints">>();
1010
+ for (const existing of existingPoints) {
1011
+ existingByRecordedAt.set(existing.recordedAt, existing);
1012
+ }
978
1013
 
979
1014
  for (const point of points) {
980
- const existing = await db
981
- .query("dataPoints")
982
- .withIndex("by_source_type_time", (idx) =>
983
- idx
984
- .eq("dataSourceId", dataSourceId)
985
- .eq("seriesType", seriesType)
986
- .eq("recordedAt", point.recordedAt),
987
- )
988
- .first();
1015
+ const existing = existingByRecordedAt.get(point.recordedAt);
989
1016
 
990
1017
  if (existing) {
991
- await db.patch(existing._id, {
992
- value: point.value,
993
- externalId: point.externalId,
994
- });
1018
+ if (existing.value !== point.value || existing.externalId !== point.externalId) {
1019
+ await db.patch(existing._id, {
1020
+ value: point.value,
1021
+ externalId: point.externalId,
1022
+ });
1023
+ }
995
1024
  lastId = existing._id;
996
1025
  } else {
997
1026
  lastId = await db.insert("dataPoints", {
@@ -1039,44 +1068,87 @@ async function upsertRollupStatsGroups(
1039
1068
  seriesType: string,
1040
1069
  groups: Iterable<{ bucketMs: number; bucketStart: number; stats: AggregatedStats }>,
1041
1070
  ) {
1071
+ const groupedByBucketMs = new Map<
1072
+ number,
1073
+ Array<{ bucketMs: number; bucketStart: number; stats: AggregatedStats }>
1074
+ >();
1042
1075
  for (const group of groups) {
1043
- const existing = await db
1076
+ const bucketGroups = groupedByBucketMs.get(group.bucketMs) ?? [];
1077
+ bucketGroups.push(group);
1078
+ groupedByBucketMs.set(group.bucketMs, bucketGroups);
1079
+ }
1080
+
1081
+ for (const [bucketMs, bucketGroups] of groupedByBucketMs) {
1082
+ bucketGroups.sort((a, b) => a.bucketStart - b.bucketStart);
1083
+ const minBucketStart = bucketGroups[0].bucketStart;
1084
+ const maxBucketStart = bucketGroups[bucketGroups.length - 1].bucketStart;
1085
+ const existingRollups = await db
1044
1086
  .query("timeSeriesRollups")
1045
1087
  .withIndex("by_source_type_bucket_size", (idx) =>
1046
1088
  idx
1047
1089
  .eq("dataSourceId", dataSourceId)
1048
1090
  .eq("seriesType", seriesType)
1049
- .eq("bucketMs", group.bucketMs)
1050
- .eq("bucketStart", group.bucketStart),
1091
+ .eq("bucketMs", bucketMs)
1092
+ .gte("bucketStart", minBucketStart)
1093
+ .lte("bucketStart", maxBucketStart),
1051
1094
  )
1052
- .first();
1095
+ .collect();
1053
1096
 
1054
- const combined = existing
1055
- ? combineAggregatedStats([rollupDocToStats(existing), group.stats])
1056
- : group.stats;
1057
- const patch = {
1058
- dataSourceId,
1059
- seriesType,
1060
- bucketMs: group.bucketMs,
1061
- bucketStart: group.bucketStart,
1062
- bucketEnd: getBucketEnd(group.bucketStart, group.bucketMs),
1063
- avg: combined.avg,
1064
- min: combined.min,
1065
- max: combined.max,
1066
- last: combined.last,
1067
- lastRecordedAt: combined.lastRecordedAt,
1068
- count: combined.count,
1069
- updatedAt: Date.now(),
1070
- };
1097
+ const existingByBucketStart = new Map<number, StoredRollup>();
1098
+ for (const existing of existingRollups as StoredRollup[]) {
1099
+ existingByBucketStart.set(existing.bucketStart, existing);
1100
+ }
1071
1101
 
1072
- if (existing) {
1073
- await db.patch(existing._id, patch);
1074
- } else {
1075
- await db.insert("timeSeriesRollups", patch);
1102
+ for (const group of bucketGroups) {
1103
+ const existing = existingByBucketStart.get(group.bucketStart);
1104
+ const combined = existing
1105
+ ? combineAggregatedStats([rollupDocToStats(existing), group.stats])
1106
+ : group.stats;
1107
+ const patch = {
1108
+ dataSourceId,
1109
+ seriesType,
1110
+ bucketMs: group.bucketMs,
1111
+ bucketStart: group.bucketStart,
1112
+ bucketEnd: getBucketEnd(group.bucketStart, group.bucketMs),
1113
+ avg: combined.avg,
1114
+ min: combined.min,
1115
+ max: combined.max,
1116
+ last: combined.last,
1117
+ lastRecordedAt: combined.lastRecordedAt,
1118
+ count: combined.count,
1119
+ updatedAt: Date.now(),
1120
+ };
1121
+
1122
+ if (existing) {
1123
+ if (!rollupMatches(existing, patch)) {
1124
+ await db.patch(existing._id, patch);
1125
+ }
1126
+ } else {
1127
+ await db.insert("timeSeriesRollups", patch);
1128
+ }
1076
1129
  }
1077
1130
  }
1078
1131
  }
1079
1132
 
1133
+ function rollupMatches(
1134
+ rollup: StoredRollup,
1135
+ patch: Omit<StoredRollup, "_creationTime" | "_id" | "updatedAt"> & { updatedAt: number },
1136
+ ) {
1137
+ return (
1138
+ rollup.dataSourceId === patch.dataSourceId &&
1139
+ rollup.seriesType === patch.seriesType &&
1140
+ rollup.bucketMs === patch.bucketMs &&
1141
+ rollup.bucketStart === patch.bucketStart &&
1142
+ rollup.bucketEnd === patch.bucketEnd &&
1143
+ rollup.avg === patch.avg &&
1144
+ rollup.min === patch.min &&
1145
+ rollup.max === patch.max &&
1146
+ rollup.last === patch.last &&
1147
+ rollup.lastRecordedAt === patch.lastRecordedAt &&
1148
+ rollup.count === patch.count
1149
+ );
1150
+ }
1151
+
1080
1152
  async function upsertSeriesState(
1081
1153
  db: TimeSeriesWriteDb,
1082
1154
  args: {
@@ -1122,19 +1194,26 @@ async function upsertSeriesState(
1122
1194
  // Maintenance
1123
1195
  // ---------------------------------------------------------------------------
1124
1196
 
1125
- async function ensureTimeSeriesMaintenanceScheduled(ctx: TimeSeriesMutationContext) {
1197
+ async function ensureTimeSeriesMaintenanceScheduled(
1198
+ ctx: TimeSeriesMutationContext,
1199
+ options?: { delayMs?: number },
1200
+ ) {
1126
1201
  const settings = await ensureTimeSeriesPolicySettingsDoc(ctx.db);
1127
1202
  if (!settings.maintenanceEnabled) {
1128
1203
  return;
1129
1204
  }
1130
1205
 
1131
1206
  const now = Date.now();
1132
- if (settings.scheduledAt !== undefined && settings.scheduledAt > now) {
1207
+ const delayMs = options?.delayMs ?? settings.maintenanceIntervalMs;
1208
+ const scheduledAt = now + delayMs;
1209
+ if (
1210
+ settings.scheduledAt !== undefined &&
1211
+ settings.scheduledAt > now &&
1212
+ settings.scheduledAt <= scheduledAt
1213
+ ) {
1133
1214
  return;
1134
1215
  }
1135
1216
 
1136
- const delayMs = settings.maintenanceIntervalMs;
1137
- const scheduledAt = now + delayMs;
1138
1217
  await ctx.scheduler.runAfter(delayMs, internal.dataPoints.runTimeSeriesMaintenance, {});
1139
1218
  await ctx.db.patch(settings._id, {
1140
1219
  scheduledAt,
@@ -1191,7 +1270,7 @@ async function maintainSeriesState(
1191
1270
  if (storedState) {
1192
1271
  await db.delete(storedState._id);
1193
1272
  }
1194
- return;
1273
+ return { didWork: storedState !== null, hasBacklog: false };
1195
1274
  }
1196
1275
 
1197
1276
  const effective = await resolveEffectivePolicy(
@@ -1200,8 +1279,9 @@ async function maintainSeriesState(
1200
1279
  storedState.provider,
1201
1280
  storedState.seriesType,
1202
1281
  );
1203
- await compactRawPoints(db, storedState, effective.tiers, now);
1204
- await compactRollupPoints(db, storedState, effective.tiers, now);
1282
+ const rawResult = await compactRawPoints(db, storedState, effective.tiers, now);
1283
+ const rollupResult = await compactRollupPoints(db, storedState, effective.tiers, now);
1284
+ const hasBacklog = rawResult.hasBacklog || rollupResult.hasBacklog;
1205
1285
 
1206
1286
  const stillHasData = await sourceSeriesHasData(
1207
1287
  db,
@@ -1210,17 +1290,24 @@ async function maintainSeriesState(
1210
1290
  );
1211
1291
  if (!stillHasData) {
1212
1292
  await db.delete(storedState._id);
1213
- return;
1293
+ return { didWork: true, hasBacklog: false };
1214
1294
  }
1215
1295
 
1216
1296
  const settings = await getTimeSeriesPolicySettings(db);
1217
1297
  await db.patch(storedState._id, {
1218
- nextMaintenanceAt: policyRequiresMaintenance(effective.tiers)
1219
- ? now + settings.maintenanceIntervalMs
1220
- : now + LONG_IDLE_MAINTENANCE_MS,
1298
+ nextMaintenanceAt: hasBacklog
1299
+ ? now
1300
+ : policyRequiresMaintenance(effective.tiers)
1301
+ ? now + settings.maintenanceIntervalMs
1302
+ : now + LONG_IDLE_MAINTENANCE_MS,
1221
1303
  lastMaintenanceAt: now,
1222
1304
  updatedAt: now,
1223
1305
  });
1306
+
1307
+ return {
1308
+ didWork: rawResult.didWork || rollupResult.didWork,
1309
+ hasBacklog,
1310
+ };
1224
1311
  }
1225
1312
 
1226
1313
  async function compactRawPoints(
@@ -1228,7 +1315,7 @@ async function compactRawPoints(
1228
1315
  state: StoredSeriesState,
1229
1316
  tiers: NormalizedTimeSeriesTier[],
1230
1317
  now: number,
1231
- ) {
1318
+ ): Promise<MaintenanceResult> {
1232
1319
  const rawTier = getRawTier(tiers);
1233
1320
  const rawCutoff = rawTier?.toAgeMs ?? null;
1234
1321
  const query =
@@ -1252,7 +1339,7 @@ async function compactRawPoints(
1252
1339
  .take(MAINTENANCE_POINT_BATCH_SIZE);
1253
1340
 
1254
1341
  if (query.length === 0) {
1255
- return;
1342
+ return { didWork: false, hasBacklog: false };
1256
1343
  }
1257
1344
 
1258
1345
  const rollupGroups = new Map<
@@ -1289,6 +1376,11 @@ async function compactRawPoints(
1289
1376
  for (const pointId of toDelete) {
1290
1377
  await db.delete(pointId);
1291
1378
  }
1379
+
1380
+ return {
1381
+ didWork: toDelete.length > 0,
1382
+ hasBacklog: query.length === MAINTENANCE_POINT_BATCH_SIZE && toDelete.length > 0,
1383
+ };
1292
1384
  }
1293
1385
 
1294
1386
  async function compactRollupPoints(
@@ -1296,7 +1388,7 @@ async function compactRollupPoints(
1296
1388
  state: StoredSeriesState,
1297
1389
  tiers: NormalizedTimeSeriesTier[],
1298
1390
  now: number,
1299
- ) {
1391
+ ): Promise<MaintenanceResult> {
1300
1392
  const rollups = await db
1301
1393
  .query("timeSeriesRollups")
1302
1394
  .withIndex("by_source_type_bucket", (idx) =>
@@ -1305,7 +1397,7 @@ async function compactRollupPoints(
1305
1397
  .take(MAINTENANCE_ROLLUP_BATCH_SIZE);
1306
1398
 
1307
1399
  if (rollups.length === 0) {
1308
- return;
1400
+ return { didWork: false, hasBacklog: false };
1309
1401
  }
1310
1402
 
1311
1403
  const destinationGroups = new Map<
@@ -1357,6 +1449,11 @@ async function compactRollupPoints(
1357
1449
  for (const rollupId of toDelete) {
1358
1450
  await db.delete(rollupId);
1359
1451
  }
1452
+
1453
+ return {
1454
+ didWork: toDelete.length > 0,
1455
+ hasBacklog: rollups.length === MAINTENANCE_ROLLUP_BATCH_SIZE && toDelete.length > 0,
1456
+ };
1360
1457
  }
1361
1458
 
1362
1459
  async function sourceSeriesHasData(
@@ -346,6 +346,8 @@ describe("garminWebhooks", () => {
346
346
  );
347
347
 
348
348
  expect(activitySummary).toMatchObject({
349
+ provider: "garmin",
350
+ source: "garmin",
349
351
  totalSteps: 12345,
350
352
  totalCalories: 2190,
351
353
  activeCalories: 640,
@@ -33,7 +33,7 @@ import {
33
33
  normalizeUserMetricsDataPoints,
34
34
  } from "./providers/garmin";
35
35
 
36
- const DATA_POINT_BATCH_SIZE = 100;
36
+ const DATA_POINT_BATCH_SIZE = 500;
37
37
 
38
38
  function decodePushPayload(args: { payload?: unknown; payloadJson?: string }): GarminPushPayload {
39
39
  if (args.payloadJson !== undefined) {
@@ -559,9 +559,11 @@ async function upsertSummary<T extends { date: string; category: string }>(
559
559
 
560
560
  await ctx.runMutation(internal.summaries.upsert, {
561
561
  userId,
562
+ provider: "garmin",
562
563
  date,
563
564
  category,
564
565
  ...metrics,
566
+ source: "garmin",
565
567
  });
566
568
  }
567
569
 
@@ -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;
@@ -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
 
@@ -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
 
@@ -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