@nativesquare/soma 0.16.4 → 0.16.6

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 (56) hide show
  1. package/dist/client/garmin.d.ts.map +1 -1
  2. package/dist/client/garmin.js +2 -0
  3. package/dist/client/garmin.js.map +1 -1
  4. package/dist/client/index.d.ts +150 -0
  5. package/dist/client/index.d.ts.map +1 -1
  6. package/dist/client/index.js +30 -0
  7. package/dist/client/index.js.map +1 -1
  8. package/dist/client/types.d.ts +3 -3
  9. package/dist/client/types.d.ts.map +1 -1
  10. package/dist/component/_generated/api.d.ts +2 -0
  11. package/dist/component/_generated/api.d.ts.map +1 -1
  12. package/dist/component/_generated/api.js.map +1 -1
  13. package/dist/component/_generated/component.d.ts +127 -0
  14. package/dist/component/_generated/component.d.ts.map +1 -1
  15. package/dist/component/garmin/schemas/deregistration.d.ts +8 -0
  16. package/dist/component/garmin/schemas/deregistration.d.ts.map +1 -0
  17. package/dist/component/garmin/schemas/deregistration.js +12 -0
  18. package/dist/component/garmin/schemas/deregistration.js.map +1 -0
  19. package/dist/component/garmin/webhooks.d.ts +14 -0
  20. package/dist/component/garmin/webhooks.d.ts.map +1 -1
  21. package/dist/component/garmin/webhooks.js +65 -0
  22. package/dist/component/garmin/webhooks.js.map +1 -1
  23. package/dist/component/private.d.ts +90 -0
  24. package/dist/component/private.d.ts.map +1 -1
  25. package/dist/component/public.d.ts +118 -6
  26. package/dist/component/public.d.ts.map +1 -1
  27. package/dist/component/public.js +91 -21
  28. package/dist/component/public.js.map +1 -1
  29. package/dist/component/schema.d.ts +111 -1
  30. package/dist/component/schema.d.ts.map +1 -1
  31. package/dist/component/utils.d.ts +27 -0
  32. package/dist/component/utils.d.ts.map +1 -1
  33. package/dist/component/utils.js +64 -0
  34. package/dist/component/utils.js.map +1 -1
  35. package/dist/component/validators/connection.d.ts +160 -0
  36. package/dist/component/validators/connection.d.ts.map +1 -1
  37. package/dist/component/validators/connection.js +16 -0
  38. package/dist/component/validators/connection.js.map +1 -1
  39. package/dist/component/validators/shared.d.ts +1 -1
  40. package/dist/component/validators/shared.d.ts.map +1 -1
  41. package/dist/component/validators/shared.js.map +1 -1
  42. package/dist/validators.d.ts +80 -0
  43. package/dist/validators.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/client/garmin.ts +3 -1
  46. package/src/client/index.ts +34 -0
  47. package/src/client/types.ts +4 -3
  48. package/src/component/_generated/api.ts +2 -0
  49. package/src/component/_generated/component.ts +49 -0
  50. package/src/component/garmin/schemas/deregistration.ts +14 -0
  51. package/src/component/garmin/webhooks.ts +84 -0
  52. package/src/component/healthkit/public.ts +597 -597
  53. package/src/component/public.ts +130 -21
  54. package/src/component/utils.ts +116 -22
  55. package/src/component/validators/connection.ts +18 -0
  56. package/src/component/validators/shared.ts +1 -0
@@ -1,6 +1,7 @@
1
1
  import { paginationOptsValidator } from "convex/server";
2
2
  import { v } from "convex/values";
3
3
  import { mutation, query } from "./_generated/server.js";
4
+ import type { Id } from "./_generated/dataModel.js";
4
5
  import { activityValidator } from "./validators/activity.js";
5
6
  import { athleteValidator } from "./validators/athlete.js";
6
7
  import { bodyValidator } from "./validators/body.js";
@@ -9,6 +10,8 @@ import { sleepValidator } from "./validators/sleep.js";
9
10
  import { menstruationValidator } from "./validators/menstruation.js";
10
11
  import { nutritionValidator } from "./validators/nutrition.js";
11
12
  import { plannedWorkoutValidator } from "./validators/plannedWorkout.js";
13
+ import { statsValidator } from "./validators/connection.js";
14
+ import { normalizeStats, updateStatsOnIngest } from "./utils.js";
12
15
 
13
16
  // ─── Return Validators ──────────────────────────────────────────────────────
14
17
 
@@ -20,6 +23,7 @@ const connectionDoc = v.object({
20
23
  providerUserId: v.optional(v.string()),
21
24
  active: v.optional(v.boolean()),
22
25
  lastDataUpdate: v.optional(v.string()),
26
+ stats: v.optional(statsValidator),
23
27
  });
24
28
 
25
29
  // ─── Connect / Disconnect ───────────────────────────────────────────────────
@@ -130,6 +134,41 @@ export const getConnectionByProvider = query({
130
134
  },
131
135
  });
132
136
 
137
+ /**
138
+ * Get per-table row counts and oldest-data timestamps for a user–provider pair.
139
+ *
140
+ * Stats are denormalized onto the connection document and maintained
141
+ * automatically by ingest mutations, so this is an O(1) read regardless of
142
+ * how much data has been ingested.
143
+ *
144
+ * `oldest` is a lower bound on the earliest `metadata.start_time` (or
145
+ * `metadata.planned_date` for planned workouts). It can theoretically go
146
+ * stale if a provider mutates `start_time` on an existing record to a later
147
+ * value — this would also break the existing dedup strategy, so treat it as
148
+ * pathological.
149
+ *
150
+ * Returns `null` if the user has never connected to that provider. When a
151
+ * connection exists but no data has been ingested, every table returns
152
+ * `{ count: 0, oldest: null }` — callers never need to guard optional keys.
153
+ */
154
+ export const getProviderStats = query({
155
+ args: {
156
+ userId: v.string(),
157
+ provider: v.string(),
158
+ },
159
+ returns: v.union(v.null(), statsValidator),
160
+ handler: async (ctx, args) => {
161
+ const connection = await ctx.db
162
+ .query("connections")
163
+ .withIndex("by_userId_provider", (q) =>
164
+ q.eq("userId", args.userId).eq("provider", args.provider),
165
+ )
166
+ .first();
167
+ if (!connection) return null;
168
+ return normalizeStats(connection.stats);
169
+ },
170
+ });
171
+
133
172
  /**
134
173
  * List all connections for a user (active and inactive).
135
174
  */
@@ -212,11 +251,21 @@ export const ingestActivity = mutation({
212
251
  )
213
252
  .first();
214
253
 
254
+ let id: Id<"activities">;
215
255
  if (existing) {
216
256
  await ctx.db.patch(existing._id, args);
217
- return existing._id;
257
+ id = existing._id;
258
+ } else {
259
+ id = await ctx.db.insert("activities", args);
218
260
  }
219
- return await ctx.db.insert("activities", args);
261
+ await updateStatsOnIngest(
262
+ ctx,
263
+ args.connectionId,
264
+ "activities",
265
+ args.metadata.start_time,
266
+ !existing,
267
+ );
268
+ return id;
220
269
  },
221
270
  });
222
271
 
@@ -231,8 +280,9 @@ export const ingestSleep = mutation({
231
280
  returns: v.id("sleep"),
232
281
  handler: async (ctx, args) => {
233
282
  const summaryId = args.metadata.summary_id;
283
+ let existing: { _id: Id<"sleep"> } | null = null;
234
284
  if (summaryId) {
235
- const existing = await ctx.db
285
+ existing = await ctx.db
236
286
  .query("sleep")
237
287
  .withIndex("by_connectionId_summaryId", (q) =>
238
288
  q
@@ -240,13 +290,23 @@ export const ingestSleep = mutation({
240
290
  .eq("metadata.summary_id", summaryId),
241
291
  )
242
292
  .first();
293
+ }
243
294
 
244
- if (existing) {
245
- await ctx.db.patch(existing._id, args);
246
- return existing._id;
247
- }
295
+ let id: Id<"sleep">;
296
+ if (existing) {
297
+ await ctx.db.patch(existing._id, args);
298
+ id = existing._id;
299
+ } else {
300
+ id = await ctx.db.insert("sleep", args);
248
301
  }
249
- return await ctx.db.insert("sleep", args);
302
+ await updateStatsOnIngest(
303
+ ctx,
304
+ args.connectionId,
305
+ "sleep",
306
+ args.metadata.start_time,
307
+ !existing,
308
+ );
309
+ return id;
250
310
  },
251
311
  });
252
312
 
@@ -269,11 +329,21 @@ export const ingestBody = mutation({
269
329
  )
270
330
  .first();
271
331
 
332
+ let id: Id<"body">;
272
333
  if (existing) {
273
334
  await ctx.db.patch(existing._id, args);
274
- return existing._id;
335
+ id = existing._id;
336
+ } else {
337
+ id = await ctx.db.insert("body", args);
275
338
  }
276
- return await ctx.db.insert("body", args);
339
+ await updateStatsOnIngest(
340
+ ctx,
341
+ args.connectionId,
342
+ "body",
343
+ args.metadata.start_time,
344
+ !existing,
345
+ );
346
+ return id;
277
347
  },
278
348
  });
279
349
 
@@ -296,11 +366,21 @@ export const ingestDaily = mutation({
296
366
  )
297
367
  .first();
298
368
 
369
+ let id: Id<"daily">;
299
370
  if (existing) {
300
371
  await ctx.db.patch(existing._id, args);
301
- return existing._id;
372
+ id = existing._id;
373
+ } else {
374
+ id = await ctx.db.insert("daily", args);
302
375
  }
303
- return await ctx.db.insert("daily", args);
376
+ await updateStatsOnIngest(
377
+ ctx,
378
+ args.connectionId,
379
+ "daily",
380
+ args.metadata.start_time,
381
+ !existing,
382
+ );
383
+ return id;
304
384
  },
305
385
  });
306
386
 
@@ -323,11 +403,21 @@ export const ingestNutrition = mutation({
323
403
  )
324
404
  .first();
325
405
 
406
+ let id: Id<"nutrition">;
326
407
  if (existing) {
327
408
  await ctx.db.patch(existing._id, args);
328
- return existing._id;
409
+ id = existing._id;
410
+ } else {
411
+ id = await ctx.db.insert("nutrition", args);
329
412
  }
330
- return await ctx.db.insert("nutrition", args);
413
+ await updateStatsOnIngest(
414
+ ctx,
415
+ args.connectionId,
416
+ "nutrition",
417
+ args.metadata.start_time,
418
+ !existing,
419
+ );
420
+ return id;
331
421
  },
332
422
  });
333
423
 
@@ -340,7 +430,15 @@ export const ingestMenstruation = mutation({
340
430
  args: menstruationValidator,
341
431
  returns: v.id("menstruation"),
342
432
  handler: async (ctx, args) => {
343
- return await ctx.db.insert("menstruation", args);
433
+ const id = await ctx.db.insert("menstruation", args);
434
+ await updateStatsOnIngest(
435
+ ctx,
436
+ args.connectionId,
437
+ "menstruation",
438
+ args.metadata.start_time,
439
+ true,
440
+ );
441
+ return id;
344
442
  },
345
443
  });
346
444
 
@@ -799,6 +897,7 @@ export const ingestPlannedWorkout = mutation({
799
897
  returns: v.id("plannedWorkouts"),
800
898
  handler: async (ctx, args) => {
801
899
  const metadataId = args.metadata.id;
900
+ let existing: { _id: Id<"plannedWorkouts"> } | null = null;
802
901
  if (metadataId) {
803
902
  const results = await ctx.db
804
903
  .query("plannedWorkouts")
@@ -806,14 +905,24 @@ export const ingestPlannedWorkout = mutation({
806
905
  q.eq("connectionId", args.connectionId),
807
906
  )
808
907
  .collect();
809
- const existing = results.find((r) => r.metadata.id === metadataId);
908
+ existing = results.find((r) => r.metadata.id === metadataId) ?? null;
909
+ }
810
910
 
811
- if (existing) {
812
- await ctx.db.patch(existing._id, args);
813
- return existing._id;
814
- }
911
+ let id: Id<"plannedWorkouts">;
912
+ if (existing) {
913
+ await ctx.db.patch(existing._id, args);
914
+ id = existing._id;
915
+ } else {
916
+ id = await ctx.db.insert("plannedWorkouts", args);
815
917
  }
816
- return await ctx.db.insert("plannedWorkouts", args);
918
+ await updateStatsOnIngest(
919
+ ctx,
920
+ args.connectionId,
921
+ "plannedWorkouts",
922
+ args.metadata.planned_date,
923
+ !existing,
924
+ );
925
+ return id;
817
926
  },
818
927
  });
819
928
 
@@ -1,22 +1,116 @@
1
- // ─── Shared Helpers ─────────────────────────────────────────────────────────
2
- // Provider-agnostic utilities shared across providers (Strava, Garmin, etc.).
3
-
4
- /**
5
- * Normalized result from any provider's OAuth refresh-token call.
6
- * Each provider's `refreshToken` maps its raw API response into this shape.
7
- */
8
- export interface OAuthRefreshResult {
9
- access_token: string;
10
- refresh_token: string;
11
- /** Absolute Unix timestamp (seconds) when the access token expires. */
12
- expiresAt: number;
13
- }
14
-
15
- /**
16
- * Generate a random state parameter for CSRF protection.
17
- */
18
- export function generateState(): string {
19
- const bytes = new Uint8Array(32);
20
- crypto.getRandomValues(bytes);
21
- return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
22
- }
1
+ // ─── Shared Helpers ─────────────────────────────────────────────────────────
2
+ // Provider-agnostic utilities shared across providers (Strava, Garmin, etc.).
3
+
4
+ import type { MutationCtx } from "./_generated/server.js";
5
+ import type { Id } from "./_generated/dataModel.js";
6
+
7
+ /**
8
+ * Normalized result from any provider's OAuth refresh-token call.
9
+ * Each provider's `refreshToken` maps its raw API response into this shape.
10
+ */
11
+ export interface OAuthRefreshResult {
12
+ access_token: string;
13
+ refresh_token: string;
14
+ /** Absolute Unix timestamp (seconds) when the access token expires. */
15
+ expiresAt: number;
16
+ }
17
+
18
+ /**
19
+ * Generate a random state parameter for CSRF protection.
20
+ */
21
+ export function generateState(): string {
22
+ const bytes = new Uint8Array(32);
23
+ crypto.getRandomValues(bytes);
24
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
25
+ }
26
+
27
+ // ─── Connection Stats ───────────────────────────────────────────────────────
28
+
29
+ export type StatsTable =
30
+ | "activities"
31
+ | "sleep"
32
+ | "body"
33
+ | "daily"
34
+ | "nutrition"
35
+ | "menstruation"
36
+ | "plannedWorkouts";
37
+
38
+ export interface StatsEntry {
39
+ count: number;
40
+ oldest: string | null;
41
+ }
42
+
43
+ export type ConnectionStats = Record<StatsTable, StatsEntry>;
44
+
45
+ const STATS_TABLES: readonly StatsTable[] = [
46
+ "activities",
47
+ "sleep",
48
+ "body",
49
+ "daily",
50
+ "nutrition",
51
+ "menstruation",
52
+ "plannedWorkouts",
53
+ ];
54
+
55
+ export function defaultStats(): ConnectionStats {
56
+ return {
57
+ activities: { count: 0, oldest: null },
58
+ sleep: { count: 0, oldest: null },
59
+ body: { count: 0, oldest: null },
60
+ daily: { count: 0, oldest: null },
61
+ nutrition: { count: 0, oldest: null },
62
+ menstruation: { count: 0, oldest: null },
63
+ plannedWorkouts: { count: 0, oldest: null },
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Fill in any missing sub-objects with zeroed defaults so callers always
69
+ * see every table key populated.
70
+ */
71
+ export function normalizeStats(
72
+ stats: Partial<ConnectionStats> | undefined,
73
+ ): ConnectionStats {
74
+ const base = defaultStats();
75
+ if (!stats) return base;
76
+ for (const t of STATS_TABLES) {
77
+ const entry = stats[t];
78
+ if (entry) base[t] = { count: entry.count, oldest: entry.oldest };
79
+ }
80
+ return base;
81
+ }
82
+
83
+ /**
84
+ * Update the denormalized stats on a connection after an ingest mutation.
85
+ *
86
+ * On a new insert: increment `count` and min-check `oldest`.
87
+ * On an upsert (existing row): only min-check `oldest` — in case the provider
88
+ * supplied an earlier `start_time` than what was first ingested.
89
+ *
90
+ * No-ops if the connection row is missing, so ingests remain resilient against
91
+ * dangling connectionIds. Safe under concurrent ingests: Convex mutations are
92
+ * serializable (OCC with retry), so parallel patches on the same connection
93
+ * do not race.
94
+ */
95
+ export async function updateStatsOnIngest(
96
+ ctx: MutationCtx,
97
+ connectionId: Id<"connections">,
98
+ table: StatsTable,
99
+ oldestCandidate: string | undefined,
100
+ isNewInsert: boolean,
101
+ ): Promise<void> {
102
+ const connection = await ctx.db.get(connectionId);
103
+ if (!connection) return;
104
+
105
+ const next = normalizeStats(connection.stats);
106
+ const sub = { ...next[table] };
107
+ if (isNewInsert) sub.count += 1;
108
+ if (oldestCandidate !== undefined) {
109
+ sub.oldest =
110
+ sub.oldest === null || oldestCandidate < sub.oldest
111
+ ? oldestCandidate
112
+ : sub.oldest;
113
+ }
114
+ next[table] = sub;
115
+ await ctx.db.patch(connectionId, { stats: next });
116
+ }
@@ -4,6 +4,21 @@ import { v } from "convex/values";
4
4
  // Represents a link between a host app user and a wearable provider.
5
5
  // Provider-agnostic: Soma doesn't care if data comes via Terra, direct API, etc.
6
6
  // One document per user-provider pair.
7
+ const statsEntryValidator = v.object({
8
+ count: v.number(),
9
+ oldest: v.union(v.string(), v.null()),
10
+ });
11
+
12
+ export const statsValidator = v.object({
13
+ activities: statsEntryValidator,
14
+ sleep: statsEntryValidator,
15
+ body: statsEntryValidator,
16
+ daily: statsEntryValidator,
17
+ nutrition: statsEntryValidator,
18
+ menstruation: statsEntryValidator,
19
+ plannedWorkouts: statsEntryValidator,
20
+ });
21
+
7
22
  export const connectionValidator = {
8
23
  // Host app's user identifier (their user ID, Clerk ID, etc.)
9
24
  userId: v.string(),
@@ -15,4 +30,7 @@ export const connectionValidator = {
15
30
  active: v.optional(v.boolean()),
16
31
  // ISO-8601 timestamp of last data update
17
32
  lastDataUpdate: v.optional(v.string()),
33
+ // Per-table denormalized counters maintained by ingest mutations.
34
+ // `oldest` is the earliest metadata.start_time (or planned_date) observed.
35
+ stats: v.optional(statsValidator),
18
36
  };
@@ -26,6 +26,7 @@ export type SomaErrorType =
26
26
  // Operation types
27
27
  | "deleteSchedule"
28
28
  | "deleteWorkout"
29
+ | "deregistration"
29
30
  | "ingest"
30
31
  | "pushSchedule"
31
32
  | "pushWorkout";