@clipin/convex-wearables 0.0.3 → 0.1.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.
@@ -1,5 +1,6 @@
1
1
  import { convexTest } from "convex-test";
2
- import { describe, expect, it } from "vitest";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { api, internal } from "./_generated/api";
3
4
  import schema from "./schema";
4
5
  import { modules } from "./test.setup";
5
6
 
@@ -279,4 +280,548 @@ describe("dataPoints", () => {
279
280
  expect(stored).toHaveLength(10);
280
281
  });
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
+ });
282
827
  });