@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.
- package/dist/client/garmin.d.ts.map +1 -1
- package/dist/client/garmin.js +2 -0
- package/dist/client/garmin.js.map +1 -1
- package/dist/client/index.d.ts +150 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +30 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/types.d.ts +3 -3
- package/dist/client/types.d.ts.map +1 -1
- package/dist/component/_generated/api.d.ts +2 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +127 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin/schemas/deregistration.d.ts +8 -0
- package/dist/component/garmin/schemas/deregistration.d.ts.map +1 -0
- package/dist/component/garmin/schemas/deregistration.js +12 -0
- package/dist/component/garmin/schemas/deregistration.js.map +1 -0
- package/dist/component/garmin/webhooks.d.ts +14 -0
- package/dist/component/garmin/webhooks.d.ts.map +1 -1
- package/dist/component/garmin/webhooks.js +65 -0
- package/dist/component/garmin/webhooks.js.map +1 -1
- package/dist/component/private.d.ts +90 -0
- package/dist/component/private.d.ts.map +1 -1
- package/dist/component/public.d.ts +118 -6
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +91 -21
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +111 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/utils.d.ts +27 -0
- package/dist/component/utils.d.ts.map +1 -1
- package/dist/component/utils.js +64 -0
- package/dist/component/utils.js.map +1 -1
- package/dist/component/validators/connection.d.ts +160 -0
- package/dist/component/validators/connection.d.ts.map +1 -1
- package/dist/component/validators/connection.js +16 -0
- package/dist/component/validators/connection.js.map +1 -1
- package/dist/component/validators/shared.d.ts +1 -1
- package/dist/component/validators/shared.d.ts.map +1 -1
- package/dist/component/validators/shared.js.map +1 -1
- package/dist/validators.d.ts +80 -0
- package/dist/validators.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/garmin.ts +3 -1
- package/src/client/index.ts +34 -0
- package/src/client/types.ts +4 -3
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +49 -0
- package/src/component/garmin/schemas/deregistration.ts +14 -0
- package/src/component/garmin/webhooks.ts +84 -0
- package/src/component/healthkit/public.ts +597 -597
- package/src/component/public.ts +130 -21
- package/src/component/utils.ts +116 -22
- package/src/component/validators/connection.ts +18 -0
- package/src/component/validators/shared.ts +1 -0
package/src/component/public.ts
CHANGED
|
@@ -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
|
-
|
|
257
|
+
id = existing._id;
|
|
258
|
+
} else {
|
|
259
|
+
id = await ctx.db.insert("activities", args);
|
|
218
260
|
}
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
+
id = existing._id;
|
|
336
|
+
} else {
|
|
337
|
+
id = await ctx.db.insert("body", args);
|
|
275
338
|
}
|
|
276
|
-
|
|
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
|
-
|
|
372
|
+
id = existing._id;
|
|
373
|
+
} else {
|
|
374
|
+
id = await ctx.db.insert("daily", args);
|
|
302
375
|
}
|
|
303
|
-
|
|
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
|
-
|
|
409
|
+
id = existing._id;
|
|
410
|
+
} else {
|
|
411
|
+
id = await ctx.db.insert("nutrition", args);
|
|
329
412
|
}
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
908
|
+
existing = results.find((r) => r.metadata.id === metadataId) ?? null;
|
|
909
|
+
}
|
|
810
910
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
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
|
|
package/src/component/utils.ts
CHANGED
|
@@ -1,22 +1,116 @@
|
|
|
1
|
-
// ─── Shared Helpers ─────────────────────────────────────────────────────────
|
|
2
|
-
// Provider-agnostic utilities shared across providers (Strava, Garmin, etc.).
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
};
|