@clipin/convex-wearables 0.0.2 → 0.1.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 (104) hide show
  1. package/README.md +395 -0
  2. package/dist/client/index.d.ts +47 -6
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +30 -0
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/client/types.d.ts +83 -0
  7. package/dist/client/types.d.ts.map +1 -1
  8. package/dist/client/types.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +50 -0
  10. package/dist/component/_generated/component.d.ts.map +1 -0
  11. package/dist/component/_generated/component.js +11 -0
  12. package/dist/component/_generated/component.js.map +1 -0
  13. package/dist/component/backfillJobs.d.ts +11 -11
  14. package/dist/component/connections.d.ts +9 -9
  15. package/dist/component/connections.d.ts.map +1 -1
  16. package/dist/component/connections.js +2 -0
  17. package/dist/component/connections.js.map +1 -1
  18. package/dist/component/dataPoints.d.ts +153 -39
  19. package/dist/component/dataPoints.d.ts.map +1 -1
  20. package/dist/component/dataPoints.js +1048 -139
  21. package/dist/component/dataPoints.js.map +1 -1
  22. package/dist/component/events.d.ts +13 -13
  23. package/dist/component/garminBackfill.d.ts +2 -2
  24. package/dist/component/garminWebhooks.d.ts +2 -2
  25. package/dist/component/garminWebhooks.d.ts.map +1 -1
  26. package/dist/component/garminWebhooks.js +2 -0
  27. package/dist/component/garminWebhooks.js.map +1 -1
  28. package/dist/component/lifecycle.d.ts +1 -1
  29. package/dist/component/lifecycle.d.ts.map +1 -1
  30. package/dist/component/lifecycle.js +39 -1
  31. package/dist/component/lifecycle.js.map +1 -1
  32. package/dist/component/oauthStates.d.ts +3 -3
  33. package/dist/component/schema.d.ts +192 -28
  34. package/dist/component/schema.d.ts.map +1 -1
  35. package/dist/component/schema.js +89 -0
  36. package/dist/component/schema.js.map +1 -1
  37. package/dist/component/sdkPush.d.ts +11 -11
  38. package/dist/component/summaries.d.ts +4 -4
  39. package/dist/component/syncJobs.d.ts +23 -23
  40. package/dist/component/syncWorkflow.d.ts +2 -2
  41. package/dist/component/timeSeriesPolicyUtils.d.ts +97 -0
  42. package/dist/component/timeSeriesPolicyUtils.d.ts.map +1 -0
  43. package/dist/component/timeSeriesPolicyUtils.js +163 -0
  44. package/dist/component/timeSeriesPolicyUtils.js.map +1 -0
  45. package/dist/test.d.ts +581 -0
  46. package/dist/test.d.ts.map +1 -0
  47. package/dist/test.js +17 -0
  48. package/dist/test.js.map +1 -0
  49. package/package.json +12 -2
  50. package/src/client/_generated/_ignore.ts +2 -0
  51. package/src/client/index.test.ts +149 -0
  52. package/src/client/index.ts +859 -0
  53. package/src/client/types.ts +632 -0
  54. package/src/component/_generated/_ignore.ts +2 -0
  55. package/src/component/_generated/api.ts +16 -0
  56. package/src/component/_generated/component.ts +74 -0
  57. package/src/component/_generated/dataModel.ts +40 -0
  58. package/src/component/_generated/server.ts +48 -0
  59. package/src/component/backfillJobs.test.ts +47 -0
  60. package/src/component/backfillJobs.ts +245 -0
  61. package/src/component/connections.test.ts +297 -0
  62. package/src/component/connections.ts +329 -0
  63. package/src/component/convex.config.ts +7 -0
  64. package/src/component/dataPoints.test.ts +827 -0
  65. package/src/component/dataPoints.ts +1676 -0
  66. package/src/component/dataSources.test.ts +247 -0
  67. package/src/component/dataSources.ts +109 -0
  68. package/src/component/events.test.ts +380 -0
  69. package/src/component/events.ts +288 -0
  70. package/src/component/garminBackfill.ts +343 -0
  71. package/src/component/garminWebhooks.test.ts +609 -0
  72. package/src/component/garminWebhooks.ts +656 -0
  73. package/src/component/httpHandlers.ts +153 -0
  74. package/src/component/lifecycle.test.ts +179 -0
  75. package/src/component/lifecycle.ts +128 -0
  76. package/src/component/menstrualCycles.ts +124 -0
  77. package/src/component/oauthActions.ts +261 -0
  78. package/src/component/oauthStates.test.ts +170 -0
  79. package/src/component/oauthStates.ts +85 -0
  80. package/src/component/providerSettings.ts +66 -0
  81. package/src/component/providers/additionalProviders.test.ts +401 -0
  82. package/src/component/providers/garmin.ts +1169 -0
  83. package/src/component/providers/oauth.test.ts +174 -0
  84. package/src/component/providers/oauth.ts +246 -0
  85. package/src/component/providers/polar.ts +220 -0
  86. package/src/component/providers/registry.ts +37 -0
  87. package/src/component/providers/strava.test.ts +195 -0
  88. package/src/component/providers/strava.ts +253 -0
  89. package/src/component/providers/suunto.ts +592 -0
  90. package/src/component/providers/types.ts +189 -0
  91. package/src/component/providers/whoop.ts +600 -0
  92. package/src/component/schema.ts +445 -0
  93. package/src/component/sdkPush.test.ts +367 -0
  94. package/src/component/sdkPush.ts +440 -0
  95. package/src/component/summaries.test.ts +201 -0
  96. package/src/component/summaries.ts +143 -0
  97. package/src/component/syncJobs.test.ts +254 -0
  98. package/src/component/syncJobs.ts +140 -0
  99. package/src/component/syncWorkflow.test.ts +87 -0
  100. package/src/component/syncWorkflow.ts +739 -0
  101. package/src/component/test.setup.ts +6 -0
  102. package/src/component/timeSeriesPolicyUtils.ts +243 -0
  103. package/src/component/workflowManager.ts +19 -0
  104. package/src/test.ts +25 -0
@@ -0,0 +1,827 @@
1
+ import { convexTest } from "convex-test";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { api, internal } from "./_generated/api";
4
+ import schema from "./schema";
5
+ import { modules } from "./test.setup";
6
+
7
+ async function seedDataSource(
8
+ t: ReturnType<typeof convexTest>,
9
+ userId = "user-1",
10
+ provider: "garmin" | "strava" = "garmin",
11
+ ) {
12
+ return await t.run(async (ctx) => {
13
+ return await ctx.db.insert("dataSources", {
14
+ userId,
15
+ provider,
16
+ deviceModel: "Forerunner 965",
17
+ source: "garmin-api",
18
+ });
19
+ });
20
+ }
21
+
22
+ describe("dataPoints", () => {
23
+ describe("store and query", () => {
24
+ it("stores and retrieves a data point", async () => {
25
+ const t = convexTest(schema, modules);
26
+ const dsId = await seedDataSource(t);
27
+
28
+ await t.run(async (ctx) => {
29
+ await ctx.db.insert("dataPoints", {
30
+ dataSourceId: dsId,
31
+ seriesType: "heart_rate",
32
+ recordedAt: 1710000000000,
33
+ value: 72,
34
+ });
35
+ });
36
+
37
+ const points = await t.run(async (ctx) => {
38
+ return await ctx.db
39
+ .query("dataPoints")
40
+ .withIndex("by_source_type_time", (idx) =>
41
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
42
+ )
43
+ .collect();
44
+ });
45
+
46
+ expect(points).toHaveLength(1);
47
+ expect(points[0].value).toBe(72);
48
+ });
49
+
50
+ it("deduplicates by source + type + time (upsert pattern)", async () => {
51
+ const t = convexTest(schema, modules);
52
+ const dsId = await seedDataSource(t);
53
+
54
+ await t.run(async (ctx) => {
55
+ await ctx.db.insert("dataPoints", {
56
+ dataSourceId: dsId,
57
+ seriesType: "heart_rate",
58
+ recordedAt: 1710000000000,
59
+ value: 72,
60
+ });
61
+ });
62
+
63
+ // Upsert: find existing, update value
64
+ await t.run(async (ctx) => {
65
+ const existing = await ctx.db
66
+ .query("dataPoints")
67
+ .withIndex("by_source_type_time", (idx) =>
68
+ idx
69
+ .eq("dataSourceId", dsId)
70
+ .eq("seriesType", "heart_rate")
71
+ .eq("recordedAt", 1710000000000),
72
+ )
73
+ .first();
74
+ if (existing) {
75
+ await ctx.db.patch(existing._id, { value: 75 });
76
+ }
77
+ });
78
+
79
+ const points = await t.run(async (ctx) => {
80
+ return await ctx.db
81
+ .query("dataPoints")
82
+ .withIndex("by_source_type_time", (idx) =>
83
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
84
+ )
85
+ .collect();
86
+ });
87
+
88
+ expect(points).toHaveLength(1);
89
+ expect(points[0].value).toBe(75);
90
+ });
91
+ });
92
+
93
+ describe("time-series queries", () => {
94
+ it("returns data within date range", async () => {
95
+ const t = convexTest(schema, modules);
96
+ const dsId = await seedDataSource(t);
97
+
98
+ await t.run(async (ctx) => {
99
+ for (let i = 0; i < 10; i++) {
100
+ await ctx.db.insert("dataPoints", {
101
+ dataSourceId: dsId,
102
+ seriesType: "heart_rate",
103
+ recordedAt: 1710000000000 + i * 60000,
104
+ value: 70 + i,
105
+ });
106
+ }
107
+ });
108
+
109
+ const result = await t.run(async (ctx) => {
110
+ return await ctx.db
111
+ .query("dataPoints")
112
+ .withIndex("by_source_type_time", (idx) =>
113
+ idx
114
+ .eq("dataSourceId", dsId)
115
+ .eq("seriesType", "heart_rate")
116
+ .gte("recordedAt", 1710000000000)
117
+ .lte("recordedAt", 1710000300000),
118
+ )
119
+ .collect();
120
+ });
121
+
122
+ expect(result).toHaveLength(6); // 0,1,2,3,4,5 minutes
123
+ expect(result[0].value).toBe(70);
124
+ expect(result[5].value).toBe(75);
125
+ });
126
+
127
+ it("paginates with take()", async () => {
128
+ const t = convexTest(schema, modules);
129
+ const dsId = await seedDataSource(t);
130
+
131
+ await t.run(async (ctx) => {
132
+ for (let i = 0; i < 20; i++) {
133
+ await ctx.db.insert("dataPoints", {
134
+ dataSourceId: dsId,
135
+ seriesType: "steps",
136
+ recordedAt: 1710000000000 + i * 60000,
137
+ value: 100 + i,
138
+ });
139
+ }
140
+ });
141
+
142
+ const page1 = await t.run(async (ctx) => {
143
+ return await ctx.db
144
+ .query("dataPoints")
145
+ .withIndex("by_source_type_time", (idx) =>
146
+ idx
147
+ .eq("dataSourceId", dsId)
148
+ .eq("seriesType", "steps")
149
+ .gte("recordedAt", 1710000000000)
150
+ .lte("recordedAt", 1710001200000),
151
+ )
152
+ .take(5);
153
+ });
154
+
155
+ expect(page1).toHaveLength(5);
156
+
157
+ // Next page starts after last item
158
+ const lastTime = page1[page1.length - 1].recordedAt;
159
+ const page2 = await t.run(async (ctx) => {
160
+ return await ctx.db
161
+ .query("dataPoints")
162
+ .withIndex("by_source_type_time", (idx) =>
163
+ idx
164
+ .eq("dataSourceId", dsId)
165
+ .eq("seriesType", "steps")
166
+ .gt("recordedAt", lastTime)
167
+ .lte("recordedAt", 1710001200000),
168
+ )
169
+ .take(5);
170
+ });
171
+
172
+ expect(page2).toHaveLength(5);
173
+ expect(page2[0].recordedAt).toBeGreaterThan(lastTime);
174
+ });
175
+
176
+ it("separates different series types", async () => {
177
+ const t = convexTest(schema, modules);
178
+ const dsId = await seedDataSource(t);
179
+
180
+ await t.run(async (ctx) => {
181
+ await ctx.db.insert("dataPoints", {
182
+ dataSourceId: dsId,
183
+ seriesType: "heart_rate",
184
+ recordedAt: 1710000000000,
185
+ value: 72,
186
+ });
187
+ await ctx.db.insert("dataPoints", {
188
+ dataSourceId: dsId,
189
+ seriesType: "steps",
190
+ recordedAt: 1710000000000,
191
+ value: 150,
192
+ });
193
+ });
194
+
195
+ const hr = await t.run(async (ctx) => {
196
+ return await ctx.db
197
+ .query("dataPoints")
198
+ .withIndex("by_source_type_time", (idx) =>
199
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
200
+ )
201
+ .collect();
202
+ });
203
+ const steps = await t.run(async (ctx) => {
204
+ return await ctx.db
205
+ .query("dataPoints")
206
+ .withIndex("by_source_type_time", (idx) =>
207
+ idx.eq("dataSourceId", dsId).eq("seriesType", "steps"),
208
+ )
209
+ .collect();
210
+ });
211
+
212
+ expect(hr).toHaveLength(1);
213
+ expect(hr[0].value).toBe(72);
214
+ expect(steps).toHaveLength(1);
215
+ expect(steps[0].value).toBe(150);
216
+ });
217
+ });
218
+
219
+ describe("latest data point", () => {
220
+ it("finds the most recent value across sources", async () => {
221
+ const t = convexTest(schema, modules);
222
+ const dsId = await seedDataSource(t);
223
+
224
+ await t.run(async (ctx) => {
225
+ await ctx.db.insert("dataPoints", {
226
+ dataSourceId: dsId,
227
+ seriesType: "weight",
228
+ recordedAt: 1710000000000,
229
+ value: 80.5,
230
+ });
231
+ await ctx.db.insert("dataPoints", {
232
+ dataSourceId: dsId,
233
+ seriesType: "weight",
234
+ recordedAt: 1710100000000,
235
+ value: 80.2,
236
+ });
237
+ });
238
+
239
+ const latest = await t.run(async (ctx) => {
240
+ return await ctx.db
241
+ .query("dataPoints")
242
+ .withIndex("by_source_type_time", (idx) =>
243
+ idx.eq("dataSourceId", dsId).eq("seriesType", "weight"),
244
+ )
245
+ .order("desc")
246
+ .first();
247
+ });
248
+
249
+ expect(latest).not.toBeNull();
250
+ expect(latest?.value).toBe(80.2);
251
+ expect(latest?.recordedAt).toBe(1710100000000);
252
+ });
253
+ });
254
+
255
+ describe("batch insert", () => {
256
+ it("stores multiple data points", async () => {
257
+ const t = convexTest(schema, modules);
258
+ const dsId = await seedDataSource(t);
259
+
260
+ await t.run(async (ctx) => {
261
+ for (let i = 0; i < 10; i++) {
262
+ await ctx.db.insert("dataPoints", {
263
+ dataSourceId: dsId,
264
+ seriesType: "heart_rate",
265
+ recordedAt: 1710000000000 + i * 60000,
266
+ value: 70 + i,
267
+ });
268
+ }
269
+ });
270
+
271
+ const stored = await t.run(async (ctx) => {
272
+ return await ctx.db
273
+ .query("dataPoints")
274
+ .withIndex("by_source_type_time", (idx) =>
275
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
276
+ )
277
+ .collect();
278
+ });
279
+
280
+ expect(stored).toHaveLength(10);
281
+ });
282
+ });
283
+
284
+ describe("storage policies", () => {
285
+ it("persists tier policies, presets, and resolves precedence", async () => {
286
+ const t = convexTest(schema, modules);
287
+
288
+ const replaceResult = await t.mutation(api.dataPoints.replaceTimeSeriesPolicyConfiguration, {
289
+ defaultRules: [
290
+ {
291
+ tiers: [{ kind: "raw", fromAge: "0m", toAge: null }],
292
+ },
293
+ {
294
+ provider: "garmin",
295
+ tiers: [
296
+ { kind: "raw", fromAge: "0m", toAge: "7d" },
297
+ {
298
+ kind: "rollup",
299
+ fromAge: "7d",
300
+ toAge: null,
301
+ bucket: "5m",
302
+ aggregations: ["avg", "last"],
303
+ },
304
+ ],
305
+ },
306
+ {
307
+ seriesType: "heart_rate",
308
+ tiers: [
309
+ { kind: "rollup", fromAge: "0m", toAge: null, bucket: "1m", aggregations: ["last"] },
310
+ ],
311
+ },
312
+ {
313
+ provider: "garmin",
314
+ seriesType: "heart_rate",
315
+ tiers: [
316
+ { kind: "raw", fromAge: "0m", toAge: "24h" },
317
+ {
318
+ kind: "rollup",
319
+ fromAge: "24h",
320
+ toAge: "7d",
321
+ bucket: "30m",
322
+ aggregations: ["avg", "min", "max", "last", "count"],
323
+ },
324
+ {
325
+ kind: "rollup",
326
+ fromAge: "7d",
327
+ toAge: null,
328
+ bucket: "3h",
329
+ aggregations: ["avg", "last", "count"],
330
+ },
331
+ ],
332
+ },
333
+ ],
334
+ presets: [
335
+ {
336
+ key: "pro",
337
+ rules: [
338
+ {
339
+ provider: "garmin",
340
+ seriesType: "heart_rate",
341
+ tiers: [
342
+ { kind: "raw", fromAge: "0m", toAge: "12h" },
343
+ { kind: "rollup", fromAge: "12h", toAge: "14d", bucket: "15m" },
344
+ { kind: "rollup", fromAge: "14d", toAge: null, bucket: "6h" },
345
+ ],
346
+ },
347
+ ],
348
+ },
349
+ ],
350
+ maintenance: {
351
+ enabled: true,
352
+ interval: "2h",
353
+ },
354
+ });
355
+
356
+ expect(replaceResult).toEqual({
357
+ defaultRulesStored: 4,
358
+ presetsStored: 1,
359
+ });
360
+
361
+ const configuration = await t.query(api.dataPoints.getTimeSeriesPolicyConfiguration, {});
362
+ expect(configuration.maintenance).toEqual({
363
+ enabled: true,
364
+ intervalMs: 2 * 60 * 60 * 1000,
365
+ });
366
+ expect(configuration.defaultRules).toHaveLength(4);
367
+ expect(configuration.defaultRules.map((policy: { scope: string }) => policy.scope)).toEqual([
368
+ "global",
369
+ "provider",
370
+ "series",
371
+ "provider_series",
372
+ ]);
373
+ expect(configuration.presets).toHaveLength(1);
374
+ expect(configuration.presets[0].key).toBe("pro");
375
+
376
+ const exact = await t.query(api.dataPoints.getEffectiveTimeSeriesPolicy, {
377
+ userId: "user-1",
378
+ provider: "garmin",
379
+ seriesType: "heart_rate",
380
+ });
381
+ expect(exact).toMatchObject({
382
+ matchedScope: "provider_series",
383
+ sourceKind: "default",
384
+ sourceKey: "__default__",
385
+ });
386
+ expect(exact.tiers).toHaveLength(3);
387
+ expect(exact.tiers[0]).toMatchObject({
388
+ kind: "raw",
389
+ fromAgeMs: 0,
390
+ toAgeMs: 24 * 60 * 60 * 1000,
391
+ });
392
+ expect(exact.tiers[1]).toMatchObject({
393
+ kind: "rollup",
394
+ bucketMs: 30 * 60 * 1000,
395
+ });
396
+
397
+ const series = await t.query(api.dataPoints.getEffectiveTimeSeriesPolicy, {
398
+ userId: "user-1",
399
+ provider: "strava",
400
+ seriesType: "heart_rate",
401
+ });
402
+ expect(series).toMatchObject({
403
+ matchedScope: "series",
404
+ sourceKind: "default",
405
+ });
406
+ expect(series.tiers).toEqual([
407
+ {
408
+ kind: "rollup",
409
+ fromAgeMs: 0,
410
+ toAgeMs: null,
411
+ bucketMs: 60 * 1000,
412
+ aggregations: ["last"],
413
+ },
414
+ ]);
415
+
416
+ const provider = await t.query(api.dataPoints.getEffectiveTimeSeriesPolicy, {
417
+ userId: "user-1",
418
+ provider: "garmin",
419
+ seriesType: "steps",
420
+ });
421
+ expect(provider).toMatchObject({
422
+ matchedScope: "provider",
423
+ sourceKind: "default",
424
+ });
425
+ expect(provider.tiers[1]).toMatchObject({
426
+ kind: "rollup",
427
+ bucketMs: 5 * 60 * 1000,
428
+ });
429
+
430
+ const fallback = await t.query(api.dataPoints.getEffectiveTimeSeriesPolicy, {
431
+ userId: "user-1",
432
+ provider: "polar",
433
+ seriesType: "weight",
434
+ });
435
+ expect(fallback).toMatchObject({
436
+ matchedScope: "global",
437
+ sourceKind: "default",
438
+ });
439
+ expect(fallback.tiers).toEqual([
440
+ {
441
+ kind: "raw",
442
+ fromAgeMs: 0,
443
+ toAgeMs: null,
444
+ },
445
+ ]);
446
+
447
+ await t.mutation(api.dataPoints.setUserTimeSeriesPolicyPreset, {
448
+ userId: "user-1",
449
+ presetKey: "pro",
450
+ });
451
+
452
+ const assignment = await t.query(api.dataPoints.getUserTimeSeriesPolicyPreset, {
453
+ userId: "user-1",
454
+ });
455
+ expect(assignment).toMatchObject({
456
+ userId: "user-1",
457
+ presetKey: "pro",
458
+ });
459
+
460
+ const presetEffective = await t.query(api.dataPoints.getEffectiveTimeSeriesPolicy, {
461
+ userId: "user-1",
462
+ provider: "garmin",
463
+ seriesType: "heart_rate",
464
+ });
465
+ expect(presetEffective).toMatchObject({
466
+ sourceKind: "preset",
467
+ sourceKey: "pro",
468
+ matchedScope: "provider_series",
469
+ });
470
+ expect(presetEffective.tiers[1]).toMatchObject({
471
+ kind: "rollup",
472
+ bucketMs: 15 * 60 * 1000,
473
+ });
474
+ });
475
+
476
+ it("stores older points in rollups and newer points as raw with explicit tiers", async () => {
477
+ vi.useFakeTimers();
478
+ vi.setSystemTime(new Date("2026-03-22T12:00:00Z"));
479
+
480
+ try {
481
+ const t = convexTest(schema, modules);
482
+ const dsId = await seedDataSource(t, "user-1", "garmin");
483
+
484
+ await t.mutation(api.dataPoints.replaceTimeSeriesPolicyConfiguration, {
485
+ defaultRules: [
486
+ {
487
+ provider: "garmin",
488
+ seriesType: "heart_rate",
489
+ tiers: [
490
+ { kind: "raw", fromAge: "0m", toAge: "24h" },
491
+ { kind: "rollup", fromAge: "24h", toAge: "7d", bucket: "30m" },
492
+ ],
493
+ },
494
+ ],
495
+ });
496
+
497
+ await t.mutation(internal.dataPoints.storeBatch, {
498
+ dataSourceId: dsId,
499
+ seriesType: "heart_rate",
500
+ points: [
501
+ {
502
+ recordedAt: Date.parse("2026-03-20T10:00:10Z"),
503
+ value: 100,
504
+ },
505
+ {
506
+ recordedAt: Date.parse("2026-03-20T10:10:00Z"),
507
+ value: 120,
508
+ },
509
+ {
510
+ recordedAt: Date.parse("2026-03-20T10:20:00Z"),
511
+ value: 110,
512
+ },
513
+ {
514
+ recordedAt: Date.parse("2026-03-22T11:30:00Z"),
515
+ value: 70,
516
+ },
517
+ {
518
+ recordedAt: Date.parse("2026-03-22T11:45:00Z"),
519
+ value: 72,
520
+ },
521
+ ],
522
+ });
523
+
524
+ const rawPoints = await t.run(async (ctx) => {
525
+ return await ctx.db
526
+ .query("dataPoints")
527
+ .withIndex("by_source_type_time", (idx) =>
528
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
529
+ )
530
+ .collect();
531
+ });
532
+ expect(rawPoints).toHaveLength(2);
533
+ expect(rawPoints.map((point) => point.value)).toEqual([70, 72]);
534
+
535
+ const rollups = await t.run(async (ctx) => {
536
+ return await ctx.db
537
+ .query("timeSeriesRollups")
538
+ .withIndex("by_source_type_bucket", (idx) =>
539
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
540
+ )
541
+ .collect();
542
+ });
543
+ expect(rollups).toHaveLength(1);
544
+ expect(rollups[0]).toMatchObject({
545
+ count: 3,
546
+ avg: 110,
547
+ min: 100,
548
+ max: 120,
549
+ last: 110,
550
+ bucketMs: 30 * 60 * 1000,
551
+ });
552
+
553
+ const points = await t.query(api.dataPoints.getTimeSeries, {
554
+ dataSourceId: dsId,
555
+ seriesType: "heart_rate",
556
+ startDate: Date.parse("2026-03-20T10:00:00Z"),
557
+ endDate: Date.parse("2026-03-22T12:00:00Z"),
558
+ });
559
+
560
+ expect(points.points).toHaveLength(3);
561
+ expect(points.points[0]).toMatchObject({
562
+ timestamp: Date.parse("2026-03-20T10:00:00Z"),
563
+ value: 110,
564
+ resolution: "rollup",
565
+ bucketMinutes: 30,
566
+ avg: 110,
567
+ min: 100,
568
+ max: 120,
569
+ last: 110,
570
+ count: 3,
571
+ });
572
+ expect(points.points[1]).toMatchObject({
573
+ timestamp: Date.parse("2026-03-22T11:30:00Z"),
574
+ value: 70,
575
+ resolution: "raw",
576
+ });
577
+ expect(points.points[2]).toMatchObject({
578
+ timestamp: Date.parse("2026-03-22T11:45:00Z"),
579
+ value: 72,
580
+ resolution: "raw",
581
+ });
582
+ } finally {
583
+ vi.useRealTimers();
584
+ }
585
+ });
586
+
587
+ it("stores rollups only when a policy starts directly with a rollup tier", async () => {
588
+ vi.useFakeTimers();
589
+ vi.setSystemTime(new Date("2026-03-22T12:00:00Z"));
590
+
591
+ try {
592
+ const t = convexTest(schema, modules);
593
+ const dsId = await seedDataSource(t, "user-summary", "garmin");
594
+
595
+ await t.mutation(api.dataPoints.replaceTimeSeriesPolicyConfiguration, {
596
+ defaultRules: [
597
+ {
598
+ provider: "garmin",
599
+ seriesType: "heart_rate",
600
+ tiers: [
601
+ {
602
+ kind: "rollup",
603
+ fromAge: "0m",
604
+ toAge: null,
605
+ bucket: "5m",
606
+ aggregations: ["last"],
607
+ },
608
+ ],
609
+ },
610
+ ],
611
+ });
612
+
613
+ await t.mutation(internal.dataPoints.storeBatch, {
614
+ dataSourceId: dsId,
615
+ seriesType: "heart_rate",
616
+ points: [
617
+ {
618
+ recordedAt: Date.parse("2026-03-20T10:00:00Z"),
619
+ value: 88,
620
+ },
621
+ {
622
+ recordedAt: Date.parse("2026-03-20T10:02:00Z"),
623
+ value: 92,
624
+ },
625
+ ],
626
+ });
627
+
628
+ const rawCount = await t.run(async (ctx) => {
629
+ return (
630
+ await ctx.db
631
+ .query("dataPoints")
632
+ .withIndex("by_source_type_time", (idx) =>
633
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
634
+ )
635
+ .collect()
636
+ ).length;
637
+ });
638
+ expect(rawCount).toBe(0);
639
+
640
+ const userPoints = await t.query(api.dataPoints.getTimeSeriesForUser, {
641
+ userId: "user-summary",
642
+ seriesType: "heart_rate",
643
+ startDate: Date.parse("2026-03-20T00:00:00Z"),
644
+ endDate: Date.parse("2026-03-20T23:59:59Z"),
645
+ });
646
+ expect(userPoints).toHaveLength(1);
647
+ expect(userPoints[0]).toMatchObject({
648
+ timestamp: Date.parse("2026-03-20T10:00:00Z"),
649
+ value: 92,
650
+ resolution: "rollup",
651
+ bucketMinutes: 5,
652
+ last: 92,
653
+ count: 2,
654
+ });
655
+
656
+ const latest = await t.query(api.dataPoints.getLatestDataPoint, {
657
+ userId: "user-summary",
658
+ seriesType: "heart_rate",
659
+ });
660
+ expect(latest).toMatchObject({
661
+ timestamp: Date.parse("2026-03-20T10:02:00Z"),
662
+ value: 92,
663
+ provider: "garmin",
664
+ });
665
+
666
+ const available = await t.query(api.dataPoints.getAvailableSeriesTypes, {
667
+ userId: "user-summary",
668
+ });
669
+ expect(available).toContain("heart_rate");
670
+ } finally {
671
+ vi.useRealTimers();
672
+ }
673
+ });
674
+
675
+ it("deletes data older than total retention during maintenance", async () => {
676
+ vi.useFakeTimers();
677
+ vi.setSystemTime(new Date("2026-03-22T12:00:00Z"));
678
+
679
+ try {
680
+ const t = convexTest(schema, modules);
681
+ const dsId = await seedDataSource(t, "user-retention", "garmin");
682
+
683
+ await t.mutation(internal.dataPoints.storeBatch, {
684
+ dataSourceId: dsId,
685
+ seriesType: "heart_rate",
686
+ points: [
687
+ {
688
+ recordedAt: Date.parse("2026-03-14T10:00:00Z"),
689
+ value: 101,
690
+ },
691
+ {
692
+ recordedAt: Date.parse("2026-03-20T10:00:00Z"),
693
+ value: 105,
694
+ },
695
+ ],
696
+ });
697
+
698
+ await t.mutation(api.dataPoints.replaceTimeSeriesPolicyConfiguration, {
699
+ defaultRules: [
700
+ {
701
+ provider: "garmin",
702
+ seriesType: "heart_rate",
703
+ tiers: [
704
+ { kind: "raw", fromAge: "0m", toAge: "24h" },
705
+ { kind: "rollup", fromAge: "24h", toAge: "7d", bucket: "30m" },
706
+ ],
707
+ },
708
+ ],
709
+ maintenance: {
710
+ enabled: true,
711
+ interval: "1h",
712
+ },
713
+ });
714
+
715
+ await t.mutation(internal.dataPoints.runTimeSeriesMaintenance, {});
716
+
717
+ const rawAfter = await t.run(async (ctx) => {
718
+ return await ctx.db
719
+ .query("dataPoints")
720
+ .withIndex("by_source_type_time", (idx) =>
721
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
722
+ )
723
+ .collect();
724
+ });
725
+ expect(rawAfter).toHaveLength(0);
726
+
727
+ const rollups = await t.run(async (ctx) => {
728
+ return await ctx.db
729
+ .query("timeSeriesRollups")
730
+ .withIndex("by_source_type_bucket", (idx) =>
731
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
732
+ )
733
+ .collect();
734
+ });
735
+ expect(rollups).toHaveLength(1);
736
+ expect(rollups[0]).toMatchObject({
737
+ bucketMs: 30 * 60 * 1000,
738
+ count: 1,
739
+ avg: 105,
740
+ });
741
+ expect(rollups[0].bucketStart).toBe(Date.parse("2026-03-20T10:00:00Z"));
742
+ } finally {
743
+ vi.useRealTimers();
744
+ }
745
+ });
746
+
747
+ it("migrates older rollups into a coarser tier during maintenance", async () => {
748
+ vi.useFakeTimers();
749
+ vi.setSystemTime(new Date("2026-03-22T12:00:00Z"));
750
+
751
+ try {
752
+ const t = convexTest(schema, modules);
753
+ const dsId = await seedDataSource(t, "user-migrate", "garmin");
754
+
755
+ await t.mutation(api.dataPoints.replaceTimeSeriesPolicyConfiguration, {
756
+ defaultRules: [
757
+ {
758
+ provider: "garmin",
759
+ seriesType: "heart_rate",
760
+ tiers: [
761
+ { kind: "raw", fromAge: "0m", toAge: "24h" },
762
+ { kind: "rollup", fromAge: "24h", toAge: null, bucket: "30m" },
763
+ ],
764
+ },
765
+ ],
766
+ });
767
+
768
+ await t.mutation(internal.dataPoints.storeBatch, {
769
+ dataSourceId: dsId,
770
+ seriesType: "heart_rate",
771
+ points: [
772
+ {
773
+ recordedAt: Date.parse("2026-03-14T10:00:00Z"),
774
+ value: 90,
775
+ },
776
+ {
777
+ recordedAt: Date.parse("2026-03-14T10:30:00Z"),
778
+ value: 120,
779
+ },
780
+ {
781
+ recordedAt: Date.parse("2026-03-14T11:00:00Z"),
782
+ value: 105,
783
+ },
784
+ ],
785
+ });
786
+
787
+ await t.mutation(api.dataPoints.replaceTimeSeriesPolicyConfiguration, {
788
+ defaultRules: [
789
+ {
790
+ provider: "garmin",
791
+ seriesType: "heart_rate",
792
+ tiers: [
793
+ { kind: "raw", fromAge: "0m", toAge: "24h" },
794
+ { kind: "rollup", fromAge: "24h", toAge: "7d", bucket: "30m" },
795
+ { kind: "rollup", fromAge: "7d", toAge: null, bucket: "3h" },
796
+ ],
797
+ },
798
+ ],
799
+ });
800
+
801
+ await t.mutation(internal.dataPoints.runTimeSeriesMaintenance, {});
802
+
803
+ const rollups = await t.run(async (ctx) => {
804
+ return await ctx.db
805
+ .query("timeSeriesRollups")
806
+ .withIndex("by_source_type_bucket", (idx) =>
807
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
808
+ )
809
+ .collect();
810
+ });
811
+
812
+ expect(rollups).toHaveLength(1);
813
+ expect(rollups[0]).toMatchObject({
814
+ bucketMs: 3 * 60 * 60 * 1000,
815
+ bucketStart: Date.parse("2026-03-14T09:00:00Z"),
816
+ count: 3,
817
+ avg: 105,
818
+ min: 90,
819
+ max: 120,
820
+ last: 105,
821
+ });
822
+ } finally {
823
+ vi.useRealTimers();
824
+ }
825
+ });
826
+ });
827
+ });