@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.
- package/README.md +11 -2
- package/dist/client/index.d.ts +5 -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/dataPoints.d.ts +1 -0
- package/dist/component/dataPoints.d.ts.map +1 -1
- package/dist/component/dataPoints.js +140 -71
- package/dist/component/dataPoints.js.map +1 -1
- package/dist/component/garminWebhooks.js +3 -1
- package/dist/component/garminWebhooks.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 +11 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +6 -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 +11 -1
- package/dist/test.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +5 -0
- package/src/client/types.ts +6 -0
- package/src/component/dataPoints.test.ts +214 -0
- package/src/component/dataPoints.ts +176 -79
- package/src/component/garminWebhooks.test.ts +2 -0
- package/src/component/garminWebhooks.ts +3 -1
- package/src/component/providers/types.ts +2 -0
- package/src/component/schema.ts +6 -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
|
@@ -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 =
|
|
38
|
-
const MAINTENANCE_POINT_BATCH_SIZE =
|
|
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:
|
|
239
|
+
order: selectionOrder,
|
|
231
240
|
})),
|
|
232
241
|
);
|
|
233
242
|
}
|
|
234
243
|
|
|
235
|
-
points.sort((a, b) =>
|
|
236
|
-
|
|
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:
|
|
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 (
|
|
943
|
-
await
|
|
944
|
-
|
|
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 =
|
|
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
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
|
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",
|
|
1050
|
-
.
|
|
1091
|
+
.eq("bucketMs", bucketMs)
|
|
1092
|
+
.gte("bucketStart", minBucketStart)
|
|
1093
|
+
.lte("bucketStart", maxBucketStart),
|
|
1051
1094
|
)
|
|
1052
|
-
.
|
|
1095
|
+
.collect();
|
|
1053
1096
|
|
|
1054
|
-
const
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
1219
|
-
? now
|
|
1220
|
-
:
|
|
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(
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
normalizeUserMetricsDataPoints,
|
|
34
34
|
} from "./providers/garmin";
|
|
35
35
|
|
|
36
|
-
const DATA_POINT_BATCH_SIZE =
|
|
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;
|
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
|
|
|
@@ -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
|
|