@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.
@@ -3,6 +3,7 @@ import {
3
3
  getSdkSyncPath,
4
4
  getSdkSyncUrl,
5
5
  oauthCallback,
6
+ registerRoutes,
6
7
  stravaWebhookEvent,
7
8
  stravaWebhookVerify,
8
9
  WearablesClient,
@@ -49,4 +50,182 @@ describe("package exports", () => {
49
50
  expect(typeof stravaWebhookVerify).toBe("function");
50
51
  expect(typeof stravaWebhookEvent).toBe("function");
51
52
  });
53
+
54
+ it("exposes time-series storage policy helpers on the client", async () => {
55
+ const component = {
56
+ dataPoints: {
57
+ getTimeSeriesPolicyConfiguration: "getPolicyConfiguration",
58
+ getUserTimeSeriesPolicyPreset: "getUserPreset",
59
+ getEffectiveTimeSeriesPolicy: "getEffectivePolicy",
60
+ replaceTimeSeriesPolicyConfiguration: "replacePolicyConfiguration",
61
+ setUserTimeSeriesPolicyPreset: "setUserPreset",
62
+ },
63
+ } as unknown as WearablesComponent;
64
+
65
+ const client = new WearablesClient(component, { providers: {} });
66
+
67
+ const queryCalls: Array<{ ref: unknown; args: unknown }> = [];
68
+ const mutationCalls: Array<{ ref: unknown; args: unknown }> = [];
69
+
70
+ const queryCtx: Parameters<WearablesClient["getTimeSeriesPolicyConfiguration"]>[0] = {
71
+ runQuery: async (...args: unknown[]) => {
72
+ const [ref, queryArgs] = args as unknown as [unknown, unknown?];
73
+ queryCalls.push({ ref, args: queryArgs });
74
+ return null as never;
75
+ },
76
+ };
77
+ const mutationCtx: Parameters<WearablesClient["replaceTimeSeriesPolicyConfiguration"]>[0] = {
78
+ runMutation: async (...args: unknown[]) => {
79
+ const [ref, mutationArgs] = args as unknown as [unknown, unknown?];
80
+ mutationCalls.push({ ref, args: mutationArgs });
81
+ return null as never;
82
+ },
83
+ };
84
+
85
+ await client.getTimeSeriesPolicyConfiguration(queryCtx);
86
+ await client.getUserTimeSeriesPolicyPreset(queryCtx, {
87
+ userId: "user-1",
88
+ });
89
+ await client.getEffectiveTimeSeriesPolicy(queryCtx, {
90
+ userId: "user-1",
91
+ provider: "garmin",
92
+ seriesType: "heart_rate",
93
+ });
94
+ await client.replaceTimeSeriesPolicyConfiguration(mutationCtx, {
95
+ defaultRules: [
96
+ {
97
+ provider: "garmin",
98
+ seriesType: "heart_rate",
99
+ tiers: [
100
+ { kind: "raw", fromAge: "0m", toAge: "24h" },
101
+ { kind: "rollup", fromAge: "24h", toAge: "7d", bucket: "30m" },
102
+ { kind: "rollup", fromAge: "7d", toAge: null, bucket: "3h" },
103
+ ],
104
+ },
105
+ ],
106
+ });
107
+ await client.setUserTimeSeriesPolicyPreset(mutationCtx, {
108
+ userId: "user-1",
109
+ presetKey: "pro",
110
+ });
111
+
112
+ expect(queryCalls).toEqual([
113
+ { ref: "getPolicyConfiguration", args: {} },
114
+ { ref: "getUserPreset", args: { userId: "user-1" } },
115
+ {
116
+ ref: "getEffectivePolicy",
117
+ args: {
118
+ userId: "user-1",
119
+ provider: "garmin",
120
+ seriesType: "heart_rate",
121
+ },
122
+ },
123
+ ]);
124
+ expect(mutationCalls).toEqual([
125
+ {
126
+ ref: "replacePolicyConfiguration",
127
+ args: {
128
+ defaultRules: [
129
+ {
130
+ provider: "garmin",
131
+ seriesType: "heart_rate",
132
+ tiers: [
133
+ { kind: "raw", fromAge: "0m", toAge: "24h" },
134
+ { kind: "rollup", fromAge: "24h", toAge: "7d", bucket: "30m" },
135
+ { kind: "rollup", fromAge: "7d", toAge: null, bucket: "3h" },
136
+ ],
137
+ },
138
+ ],
139
+ },
140
+ },
141
+ {
142
+ ref: "setUserPreset",
143
+ args: {
144
+ userId: "user-1",
145
+ presetKey: "pro",
146
+ },
147
+ },
148
+ ]);
149
+ });
150
+ });
151
+
152
+ describe("registerRoutes", () => {
153
+ it("schedules Garmin push ingestion and acknowledges the webhook immediately", async () => {
154
+ const routes: Array<{
155
+ handler: { _handler: (ctx: unknown, request: Request) => Promise<Response> };
156
+ method: string;
157
+ path: string;
158
+ }> = [];
159
+ const http = {
160
+ route: (route: (typeof routes)[number]) => {
161
+ routes.push(route);
162
+ },
163
+ };
164
+ const processPushPayload = "wearables.garminWebhooks.processPushPayload";
165
+ const component = {
166
+ garminWebhooks: {
167
+ processPushPayload,
168
+ },
169
+ } as unknown as WearablesComponent;
170
+
171
+ registerRoutes(http as never, component, {
172
+ garmin: {
173
+ clientId: "garmin-client-id",
174
+ oauthCallbackPath: false,
175
+ webhookPath: "/webhooks/garmin/push",
176
+ healthPath: false,
177
+ },
178
+ });
179
+
180
+ const pushRoute = routes.find(
181
+ (route) => route.path === "/webhooks/garmin/push" && route.method === "POST",
182
+ );
183
+ expect(pushRoute).toBeDefined();
184
+
185
+ const scheduled: Array<{ delayMs: number; functionRef: unknown; args: unknown }> = [];
186
+ const ctx = {
187
+ scheduler: {
188
+ runAfter: async (delayMs: number, functionRef: unknown, args: unknown) => {
189
+ scheduled.push({ delayMs, functionRef, args });
190
+ return "scheduled-garmin-push";
191
+ },
192
+ },
193
+ };
194
+
195
+ const response = await pushRoute!.handler._handler(
196
+ ctx,
197
+ new Request("https://example.com/webhooks/garmin/push", {
198
+ method: "POST",
199
+ headers: {
200
+ "content-type": "application/json",
201
+ "garmin-client-id": "garmin-client-id",
202
+ },
203
+ body: JSON.stringify({
204
+ dailies: [
205
+ {
206
+ userId: "garmin-user-1",
207
+ summaryId: "daily-1",
208
+ startTimeInSeconds: 1_776_988_800,
209
+ durationInSeconds: 86_400,
210
+ steps: 12_345,
211
+ },
212
+ ],
213
+ }),
214
+ }),
215
+ );
216
+
217
+ expect(response.status).toBe(200);
218
+ expect(await response.text()).toBe("OK");
219
+ expect(scheduled).toHaveLength(1);
220
+ expect(scheduled[0]).toMatchObject({
221
+ delayMs: 0,
222
+ functionRef: processPushPayload,
223
+ });
224
+ expect(scheduled[0]?.args).toMatchObject({
225
+ garminClientId: "garmin-client-id",
226
+ });
227
+ expect(JSON.parse((scheduled[0]?.args as { payloadJson: string }).payloadJson)).toMatchObject({
228
+ dailies: [{ summaryId: "daily-1" }],
229
+ });
230
+ });
52
231
  });
@@ -21,6 +21,7 @@ import type {
21
21
  Connection,
22
22
  DailySummary,
23
23
  DataPoint,
24
+ EffectiveTimeSeriesPolicy,
24
25
  EventCategory,
25
26
  EventsPage,
26
27
  GarminRoutesConfig,
@@ -36,7 +37,12 @@ import type {
36
37
  SdkSyncPayload,
37
38
  SyncJob,
38
39
  SyncStatus,
40
+ TimeSeriesMaintenanceInput,
39
41
  TimeSeriesPage,
42
+ TimeSeriesPolicyConfiguration,
43
+ TimeSeriesPolicyPresetInput,
44
+ TimeSeriesPolicyRuleInput,
45
+ UserTimeSeriesPolicyPreset,
40
46
  WearablesConfig,
41
47
  } from "./types.js";
42
48
 
@@ -54,6 +60,7 @@ export type {
54
60
  Connection,
55
61
  DailySummary,
56
62
  DataPoint,
63
+ EffectiveTimeSeriesPolicy,
57
64
  EventCategory,
58
65
  EventsPage,
59
66
  GarminRoutesConfig,
@@ -69,7 +76,12 @@ export type {
69
76
  SdkSyncPayload,
70
77
  SyncJob,
71
78
  SyncStatus,
79
+ TimeSeriesMaintenanceInput,
72
80
  TimeSeriesPage,
81
+ TimeSeriesPolicyConfiguration,
82
+ TimeSeriesPolicyPresetInput,
83
+ TimeSeriesPolicyRuleInput,
84
+ UserTimeSeriesPolicyPreset,
73
85
  WearablesConfig,
74
86
  };
75
87
 
@@ -234,6 +246,69 @@ export class WearablesClient {
234
246
  return await ctx.runQuery(this.component.dataPoints.getAvailableSeriesTypes, args);
235
247
  }
236
248
 
249
+ /**
250
+ * Get persisted time-series policy configuration.
251
+ */
252
+ async getTimeSeriesPolicyConfiguration(ctx: QueryRunner): Promise<TimeSeriesPolicyConfiguration> {
253
+ return (await ctx.runQuery(
254
+ this.component.dataPoints.getTimeSeriesPolicyConfiguration,
255
+ {},
256
+ )) as TimeSeriesPolicyConfiguration;
257
+ }
258
+
259
+ /**
260
+ * Get the preset assigned to a user, if any.
261
+ */
262
+ async getUserTimeSeriesPolicyPreset(
263
+ ctx: QueryRunner,
264
+ args: { userId: string },
265
+ ): Promise<UserTimeSeriesPolicyPreset | null> {
266
+ return (await ctx.runQuery(
267
+ this.component.dataPoints.getUserTimeSeriesPolicyPreset,
268
+ args,
269
+ )) as UserTimeSeriesPolicyPreset | null;
270
+ }
271
+
272
+ /**
273
+ * Resolve the effective time-series policy for a user/provider/series triple.
274
+ */
275
+ async getEffectiveTimeSeriesPolicy(
276
+ ctx: QueryRunner,
277
+ args: { userId: string; provider: ProviderName; seriesType: string },
278
+ ): Promise<EffectiveTimeSeriesPolicy> {
279
+ return (await ctx.runQuery(
280
+ this.component.dataPoints.getEffectiveTimeSeriesPolicy,
281
+ args,
282
+ )) as EffectiveTimeSeriesPolicy;
283
+ }
284
+
285
+ /**
286
+ * Replace the persisted time-series policy configuration.
287
+ */
288
+ async replaceTimeSeriesPolicyConfiguration(
289
+ ctx: MutationRunner,
290
+ args: {
291
+ defaultRules: TimeSeriesPolicyRuleInput[];
292
+ presets?: TimeSeriesPolicyPresetInput[];
293
+ maintenance?: TimeSeriesMaintenanceInput;
294
+ },
295
+ ): Promise<{ defaultRulesStored: number; presetsStored: number }> {
296
+ return await ctx.runMutation(
297
+ this.component.dataPoints.replaceTimeSeriesPolicyConfiguration,
298
+ args,
299
+ );
300
+ }
301
+
302
+ /**
303
+ * Assign or clear a user-specific policy preset.
304
+ */
305
+ async setUserTimeSeriesPolicyPreset(
306
+ ctx: MutationRunner,
307
+ args: { userId: string; presetKey: string | null },
308
+ ): Promise<null> {
309
+ return await ctx.runMutation(this.component.dataPoints.setUserTimeSeriesPolicyPreset, args);
310
+ }
311
+
237
312
  // -----------------------------------------------------------------------
238
313
  // Data Access — Summaries
239
314
  // -----------------------------------------------------------------------
@@ -506,13 +581,18 @@ export function registerRoutes(
506
581
  });
507
582
 
508
583
  try {
509
- await ctx.runAction(component.garminWebhooks.processPushPayload, {
510
- payloadJson: JSON.stringify(payload),
511
- garminClientId: garminClientId ?? "",
512
- });
513
-
514
- console.info("Garmin webhook processed successfully", {
584
+ const scheduledFunctionId = await ctx.scheduler.runAfter(
585
+ 0,
586
+ component.garminWebhooks.processPushPayload,
587
+ {
588
+ payloadJson: JSON.stringify(payload),
589
+ garminClientId: garminClientId ?? "",
590
+ },
591
+ );
592
+
593
+ console.info("Garmin webhook ingestion scheduled", {
515
594
  componentFunction: GARMIN_PUSH_COMPONENT_FUNCTION,
595
+ scheduledFunctionId,
516
596
  payloadSummary,
517
597
  });
518
598
 
@@ -523,7 +603,7 @@ export function registerRoutes(
523
603
  serializedError.message.includes("Couldn't resolve") &&
524
604
  serializedError.message.includes(GARMIN_PUSH_COMPONENT_FUNCTION);
525
605
 
526
- console.error("Garmin webhook processing failed", {
606
+ console.error("Garmin webhook scheduling failed", {
527
607
  componentFunction: GARMIN_PUSH_COMPONENT_FUNCTION,
528
608
  payloadSummary,
529
609
  error: serializedError,
@@ -26,6 +26,10 @@ export type SyncJobStatus = "queued" | "running" | "completed" | "failed" | "can
26
26
 
27
27
  export type BackfillJobStatus = "queued" | "running" | "completed" | "failed" | "canceled";
28
28
 
29
+ export type DurationInput = string | number;
30
+
31
+ export type TimeSeriesRollupAggregation = "avg" | "min" | "max" | "last" | "count";
32
+
29
33
  // ---------------------------------------------------------------------------
30
34
  // Provider configuration (passed by app)
31
35
  // ---------------------------------------------------------------------------
@@ -287,6 +291,13 @@ export type HealthEvent = WorkoutEvent | SleepEvent;
287
291
  export interface DataPoint {
288
292
  timestamp: number;
289
293
  value: number;
294
+ resolution?: "raw" | "rollup";
295
+ bucketMinutes?: number;
296
+ avg?: number;
297
+ min?: number;
298
+ max?: number;
299
+ last?: number;
300
+ count?: number;
290
301
  }
291
302
 
292
303
  export interface TimeSeriesPage {
@@ -295,6 +306,94 @@ export interface TimeSeriesPage {
295
306
  hasMore: boolean;
296
307
  }
297
308
 
309
+ export interface TimeSeriesRawTierInput {
310
+ kind: "raw";
311
+ fromAge: DurationInput;
312
+ toAge: DurationInput | null;
313
+ }
314
+
315
+ export interface TimeSeriesRollupTierInput {
316
+ kind: "rollup";
317
+ fromAge: DurationInput;
318
+ toAge: DurationInput | null;
319
+ bucket: DurationInput;
320
+ aggregations?: TimeSeriesRollupAggregation[];
321
+ }
322
+
323
+ export type TimeSeriesTierInput = TimeSeriesRawTierInput | TimeSeriesRollupTierInput;
324
+
325
+ export interface TimeSeriesPolicyRuleInput {
326
+ provider?: ProviderName;
327
+ seriesType?: string;
328
+ tiers: TimeSeriesTierInput[];
329
+ }
330
+
331
+ export interface TimeSeriesPolicyPresetInput {
332
+ key: string;
333
+ rules: TimeSeriesPolicyRuleInput[];
334
+ }
335
+
336
+ export interface TimeSeriesMaintenanceInput {
337
+ enabled?: boolean;
338
+ interval?: DurationInput;
339
+ }
340
+
341
+ export interface TimeSeriesRawTier {
342
+ kind: "raw";
343
+ fromAgeMs: number;
344
+ toAgeMs: number | null;
345
+ }
346
+
347
+ export interface TimeSeriesRollupTier {
348
+ kind: "rollup";
349
+ fromAgeMs: number;
350
+ toAgeMs: number | null;
351
+ bucketMs: number;
352
+ aggregations: TimeSeriesRollupAggregation[];
353
+ }
354
+
355
+ export type TimeSeriesTier = TimeSeriesRawTier | TimeSeriesRollupTier;
356
+
357
+ export interface TimeSeriesPolicyRule {
358
+ _id: string;
359
+ policySetKind: "default" | "preset";
360
+ policySetKey: string;
361
+ updatedAt: number;
362
+ scope: "global" | "provider" | "series" | "provider_series";
363
+ provider?: ProviderName;
364
+ seriesType?: string;
365
+ tiers: TimeSeriesTier[];
366
+ }
367
+
368
+ export interface TimeSeriesPolicyPreset {
369
+ key: string;
370
+ rules: TimeSeriesPolicyRule[];
371
+ }
372
+
373
+ export interface TimeSeriesPolicyConfiguration {
374
+ maintenance: {
375
+ enabled: boolean;
376
+ intervalMs: number;
377
+ };
378
+ defaultRules: TimeSeriesPolicyRule[];
379
+ presets: TimeSeriesPolicyPreset[];
380
+ }
381
+
382
+ export interface UserTimeSeriesPolicyPreset {
383
+ userId: string;
384
+ presetKey: string;
385
+ updatedAt: number;
386
+ }
387
+
388
+ export interface EffectiveTimeSeriesPolicy {
389
+ provider: ProviderName;
390
+ seriesType: string;
391
+ sourceKind: "preset" | "default" | "builtin";
392
+ sourceKey: string | null;
393
+ matchedScope: "default" | "global" | "provider" | "series" | "provider_series";
394
+ tiers: TimeSeriesTier[];
395
+ }
396
+
298
397
  // ---------------------------------------------------------------------------
299
398
  // Events page
300
399
  // ---------------------------------------------------------------------------