@clipin/convex-wearables 0.0.2 → 0.0.3
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/index.d.ts +9 -4
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +50 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/backfillJobs.d.ts +11 -11
- package/dist/component/connections.d.ts +9 -9
- package/dist/component/connections.d.ts.map +1 -1
- package/dist/component/connections.js +2 -0
- package/dist/component/connections.js.map +1 -1
- package/dist/component/dataPoints.d.ts +5 -5
- package/dist/component/events.d.ts +13 -13
- package/dist/component/garminBackfill.d.ts +2 -2
- package/dist/component/garminWebhooks.d.ts +2 -2
- package/dist/component/garminWebhooks.d.ts.map +1 -1
- package/dist/component/garminWebhooks.js +2 -0
- package/dist/component/garminWebhooks.js.map +1 -1
- package/dist/component/lifecycle.d.ts +1 -1
- package/dist/component/lifecycle.d.ts.map +1 -1
- package/dist/component/lifecycle.js +2 -0
- package/dist/component/lifecycle.js.map +1 -1
- package/dist/component/oauthStates.d.ts +3 -3
- package/dist/component/schema.d.ts +26 -26
- package/dist/component/sdkPush.d.ts +11 -11
- package/dist/component/summaries.d.ts +4 -4
- package/dist/component/syncJobs.d.ts +23 -23
- package/dist/component/syncWorkflow.d.ts +2 -2
- package/dist/test.d.ts +421 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +17 -0
- package/dist/test.js.map +1 -0
- package/package.json +12 -2
- package/src/client/_generated/_ignore.ts +2 -0
- package/src/client/index.test.ts +52 -0
- package/src/client/index.ts +784 -0
- package/src/client/types.ts +533 -0
- package/src/component/_generated/_ignore.ts +2 -0
- package/src/component/_generated/api.ts +16 -0
- package/src/component/_generated/component.ts +74 -0
- package/src/component/_generated/dataModel.ts +40 -0
- package/src/component/_generated/server.ts +48 -0
- package/src/component/backfillJobs.test.ts +47 -0
- package/src/component/backfillJobs.ts +245 -0
- package/src/component/connections.test.ts +297 -0
- package/src/component/connections.ts +329 -0
- package/src/component/convex.config.ts +7 -0
- package/src/component/dataPoints.test.ts +282 -0
- package/src/component/dataPoints.ts +305 -0
- package/src/component/dataSources.test.ts +247 -0
- package/src/component/dataSources.ts +109 -0
- package/src/component/events.test.ts +380 -0
- package/src/component/events.ts +288 -0
- package/src/component/garminBackfill.ts +343 -0
- package/src/component/garminWebhooks.test.ts +609 -0
- package/src/component/garminWebhooks.ts +656 -0
- package/src/component/httpHandlers.ts +153 -0
- package/src/component/lifecycle.test.ts +179 -0
- package/src/component/lifecycle.ts +87 -0
- package/src/component/menstrualCycles.ts +124 -0
- package/src/component/oauthActions.ts +261 -0
- package/src/component/oauthStates.test.ts +170 -0
- package/src/component/oauthStates.ts +85 -0
- package/src/component/providerSettings.ts +66 -0
- package/src/component/providers/additionalProviders.test.ts +401 -0
- package/src/component/providers/garmin.ts +1169 -0
- package/src/component/providers/oauth.test.ts +174 -0
- package/src/component/providers/oauth.ts +246 -0
- package/src/component/providers/polar.ts +220 -0
- package/src/component/providers/registry.ts +37 -0
- package/src/component/providers/strava.test.ts +195 -0
- package/src/component/providers/strava.ts +253 -0
- package/src/component/providers/suunto.ts +592 -0
- package/src/component/providers/types.ts +189 -0
- package/src/component/providers/whoop.ts +600 -0
- package/src/component/schema.ts +339 -0
- package/src/component/sdkPush.test.ts +367 -0
- package/src/component/sdkPush.ts +440 -0
- package/src/component/summaries.test.ts +201 -0
- package/src/component/summaries.ts +143 -0
- package/src/component/syncJobs.test.ts +254 -0
- package/src/component/syncJobs.ts +140 -0
- package/src/component/syncWorkflow.test.ts +87 -0
- package/src/component/syncWorkflow.ts +739 -0
- package/src/component/test.setup.ts +6 -0
- package/src/component/workflowManager.ts +19 -0
- package/src/test.ts +25 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { internalMutation, query } from "./_generated/server";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Queries
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get time-series data points with cursor-based pagination.
|
|
10
|
+
* Uses the by_source_type_time index for efficient range queries.
|
|
11
|
+
*/
|
|
12
|
+
export const getTimeSeries = query({
|
|
13
|
+
args: {
|
|
14
|
+
dataSourceId: v.id("dataSources"),
|
|
15
|
+
seriesType: v.string(),
|
|
16
|
+
startDate: v.number(),
|
|
17
|
+
endDate: v.number(),
|
|
18
|
+
limit: v.optional(v.number()),
|
|
19
|
+
cursor: v.optional(v.string()),
|
|
20
|
+
order: v.optional(v.union(v.literal("asc"), v.literal("desc"))),
|
|
21
|
+
},
|
|
22
|
+
returns: v.object({
|
|
23
|
+
points: v.array(
|
|
24
|
+
v.object({
|
|
25
|
+
timestamp: v.number(),
|
|
26
|
+
value: v.number(),
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
nextCursor: v.union(v.string(), v.null()),
|
|
30
|
+
hasMore: v.boolean(),
|
|
31
|
+
}),
|
|
32
|
+
handler: async (ctx, args) => {
|
|
33
|
+
const limit = Math.min(args.limit ?? 500, 2000);
|
|
34
|
+
const order = args.order ?? "asc";
|
|
35
|
+
|
|
36
|
+
const startDate = args.cursor ? Number(args.cursor) : args.startDate;
|
|
37
|
+
|
|
38
|
+
const results = await ctx.db
|
|
39
|
+
.query("dataPoints")
|
|
40
|
+
.withIndex("by_source_type_time", (idx) =>
|
|
41
|
+
order === "asc"
|
|
42
|
+
? idx
|
|
43
|
+
.eq("dataSourceId", args.dataSourceId)
|
|
44
|
+
.eq("seriesType", args.seriesType)
|
|
45
|
+
.gte("recordedAt", startDate)
|
|
46
|
+
.lte("recordedAt", args.endDate)
|
|
47
|
+
: idx
|
|
48
|
+
.eq("dataSourceId", args.dataSourceId)
|
|
49
|
+
.eq("seriesType", args.seriesType)
|
|
50
|
+
.gte("recordedAt", args.startDate)
|
|
51
|
+
.lte("recordedAt", startDate),
|
|
52
|
+
)
|
|
53
|
+
.order(order)
|
|
54
|
+
.take(limit + 1);
|
|
55
|
+
|
|
56
|
+
const hasMore = results.length > limit;
|
|
57
|
+
const items = hasMore ? results.slice(0, limit) : results;
|
|
58
|
+
const points = items.map((dp) => ({
|
|
59
|
+
timestamp: dp.recordedAt,
|
|
60
|
+
value: dp.value,
|
|
61
|
+
}));
|
|
62
|
+
const nextCursor =
|
|
63
|
+
hasMore && items.length > 0 ? String(items[items.length - 1].recordedAt) : null;
|
|
64
|
+
|
|
65
|
+
return { points, nextCursor, hasMore };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get time-series data for a user across all their data sources for a given series type.
|
|
71
|
+
* This is a user-facing query that resolves data sources internally.
|
|
72
|
+
*/
|
|
73
|
+
export const getTimeSeriesForUser = query({
|
|
74
|
+
args: {
|
|
75
|
+
userId: v.string(),
|
|
76
|
+
seriesType: v.string(),
|
|
77
|
+
startDate: v.number(),
|
|
78
|
+
endDate: v.number(),
|
|
79
|
+
limit: v.optional(v.number()),
|
|
80
|
+
},
|
|
81
|
+
returns: v.array(
|
|
82
|
+
v.object({
|
|
83
|
+
timestamp: v.number(),
|
|
84
|
+
value: v.number(),
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
handler: async (ctx, args) => {
|
|
88
|
+
const limit = Math.min(args.limit ?? 500, 2000);
|
|
89
|
+
|
|
90
|
+
// Get all data sources for this user
|
|
91
|
+
const sources = await ctx.db
|
|
92
|
+
.query("dataSources")
|
|
93
|
+
.withIndex("by_user_provider", (idx) => idx.eq("userId", args.userId))
|
|
94
|
+
.collect();
|
|
95
|
+
|
|
96
|
+
// Collect data from all sources (merge and sort)
|
|
97
|
+
const allPoints: { timestamp: number; value: number }[] = [];
|
|
98
|
+
for (const source of sources) {
|
|
99
|
+
const points = await ctx.db
|
|
100
|
+
.query("dataPoints")
|
|
101
|
+
.withIndex("by_source_type_time", (idx) =>
|
|
102
|
+
idx
|
|
103
|
+
.eq("dataSourceId", source._id)
|
|
104
|
+
.eq("seriesType", args.seriesType)
|
|
105
|
+
.gte("recordedAt", args.startDate)
|
|
106
|
+
.lte("recordedAt", args.endDate),
|
|
107
|
+
)
|
|
108
|
+
.take(limit);
|
|
109
|
+
|
|
110
|
+
for (const dp of points) {
|
|
111
|
+
allPoints.push({ timestamp: dp.recordedAt, value: dp.value });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Sort by timestamp and limit
|
|
116
|
+
allPoints.sort((a, b) => a.timestamp - b.timestamp);
|
|
117
|
+
return allPoints.slice(0, limit);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get the latest data point for a user and series type.
|
|
123
|
+
*/
|
|
124
|
+
export const getLatestDataPoint = query({
|
|
125
|
+
args: {
|
|
126
|
+
userId: v.string(),
|
|
127
|
+
seriesType: v.string(),
|
|
128
|
+
},
|
|
129
|
+
returns: v.union(
|
|
130
|
+
v.object({
|
|
131
|
+
timestamp: v.number(),
|
|
132
|
+
value: v.number(),
|
|
133
|
+
provider: v.string(),
|
|
134
|
+
}),
|
|
135
|
+
v.null(),
|
|
136
|
+
),
|
|
137
|
+
handler: async (ctx, args) => {
|
|
138
|
+
const sources = await ctx.db
|
|
139
|
+
.query("dataSources")
|
|
140
|
+
.withIndex("by_user_provider", (idx) => idx.eq("userId", args.userId))
|
|
141
|
+
.collect();
|
|
142
|
+
|
|
143
|
+
let latest: { timestamp: number; value: number; provider: string } | null = null;
|
|
144
|
+
|
|
145
|
+
for (const source of sources) {
|
|
146
|
+
const point = await ctx.db
|
|
147
|
+
.query("dataPoints")
|
|
148
|
+
.withIndex("by_source_type_time", (idx) =>
|
|
149
|
+
idx.eq("dataSourceId", source._id).eq("seriesType", args.seriesType),
|
|
150
|
+
)
|
|
151
|
+
.order("desc")
|
|
152
|
+
.first();
|
|
153
|
+
|
|
154
|
+
if (point && (latest === null || point.recordedAt > latest.timestamp)) {
|
|
155
|
+
latest = {
|
|
156
|
+
timestamp: point.recordedAt,
|
|
157
|
+
value: point.value,
|
|
158
|
+
provider: source.provider,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return latest;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get all available series types for a user (i.e., types that have at least one data point).
|
|
169
|
+
*/
|
|
170
|
+
export const getAvailableSeriesTypes = query({
|
|
171
|
+
args: { userId: v.string() },
|
|
172
|
+
returns: v.array(v.string()),
|
|
173
|
+
handler: async (ctx, args) => {
|
|
174
|
+
const sources = await ctx.db
|
|
175
|
+
.query("dataSources")
|
|
176
|
+
.withIndex("by_user_provider", (idx) => idx.eq("userId", args.userId))
|
|
177
|
+
.collect();
|
|
178
|
+
|
|
179
|
+
const types = new Set<string>();
|
|
180
|
+
|
|
181
|
+
for (const source of sources) {
|
|
182
|
+
// Sample a few data points to discover types
|
|
183
|
+
// This is efficient because we only need one per type
|
|
184
|
+
const points = await ctx.db
|
|
185
|
+
.query("dataPoints")
|
|
186
|
+
.withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", source._id))
|
|
187
|
+
.take(200);
|
|
188
|
+
|
|
189
|
+
for (const point of points) {
|
|
190
|
+
types.add(point.seriesType);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return Array.from(types).sort();
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Mutations
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Store a single data point. Deduplicates by (source, type, time).
|
|
204
|
+
*/
|
|
205
|
+
export const storeDataPoint = internalMutation({
|
|
206
|
+
args: {
|
|
207
|
+
dataSourceId: v.id("dataSources"),
|
|
208
|
+
seriesType: v.string(),
|
|
209
|
+
recordedAt: v.number(),
|
|
210
|
+
value: v.number(),
|
|
211
|
+
externalId: v.optional(v.string()),
|
|
212
|
+
},
|
|
213
|
+
returns: v.id("dataPoints"),
|
|
214
|
+
handler: async (ctx, args) => {
|
|
215
|
+
// Deduplicate by (source, type, time)
|
|
216
|
+
const existing = await ctx.db
|
|
217
|
+
.query("dataPoints")
|
|
218
|
+
.withIndex("by_source_type_time", (idx) =>
|
|
219
|
+
idx
|
|
220
|
+
.eq("dataSourceId", args.dataSourceId)
|
|
221
|
+
.eq("seriesType", args.seriesType)
|
|
222
|
+
.eq("recordedAt", args.recordedAt),
|
|
223
|
+
)
|
|
224
|
+
.first();
|
|
225
|
+
|
|
226
|
+
if (existing) {
|
|
227
|
+
await ctx.db.patch(existing._id, { value: args.value });
|
|
228
|
+
return existing._id;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return await ctx.db.insert("dataPoints", args);
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Store a batch of data points. Used by sync workflows.
|
|
237
|
+
* Must complete within 1 second — caller is responsible for batch sizing.
|
|
238
|
+
*/
|
|
239
|
+
export const storeBatch = internalMutation({
|
|
240
|
+
args: {
|
|
241
|
+
dataSourceId: v.id("dataSources"),
|
|
242
|
+
seriesType: v.string(),
|
|
243
|
+
points: v.array(
|
|
244
|
+
v.object({
|
|
245
|
+
recordedAt: v.number(),
|
|
246
|
+
value: v.number(),
|
|
247
|
+
externalId: v.optional(v.string()),
|
|
248
|
+
}),
|
|
249
|
+
),
|
|
250
|
+
},
|
|
251
|
+
returns: v.number(),
|
|
252
|
+
handler: async (ctx, args) => {
|
|
253
|
+
let count = 0;
|
|
254
|
+
for (const point of args.points) {
|
|
255
|
+
// Deduplicate by (source, type, time)
|
|
256
|
+
const existing = await ctx.db
|
|
257
|
+
.query("dataPoints")
|
|
258
|
+
.withIndex("by_source_type_time", (idx) =>
|
|
259
|
+
idx
|
|
260
|
+
.eq("dataSourceId", args.dataSourceId)
|
|
261
|
+
.eq("seriesType", args.seriesType)
|
|
262
|
+
.eq("recordedAt", point.recordedAt),
|
|
263
|
+
)
|
|
264
|
+
.first();
|
|
265
|
+
|
|
266
|
+
if (existing) {
|
|
267
|
+
await ctx.db.patch(existing._id, { value: point.value });
|
|
268
|
+
} else {
|
|
269
|
+
await ctx.db.insert("dataPoints", {
|
|
270
|
+
dataSourceId: args.dataSourceId,
|
|
271
|
+
seriesType: args.seriesType,
|
|
272
|
+
recordedAt: point.recordedAt,
|
|
273
|
+
value: point.value,
|
|
274
|
+
externalId: point.externalId,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
count++;
|
|
278
|
+
}
|
|
279
|
+
return count;
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Delete all data points for a data source. Used during user/connection cleanup.
|
|
285
|
+
*/
|
|
286
|
+
export const deleteByDataSource = internalMutation({
|
|
287
|
+
args: { dataSourceId: v.id("dataSources") },
|
|
288
|
+
handler: async (ctx, args) => {
|
|
289
|
+
// Delete in batches to avoid hitting limits
|
|
290
|
+
let batch = await ctx.db
|
|
291
|
+
.query("dataPoints")
|
|
292
|
+
.withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", args.dataSourceId))
|
|
293
|
+
.take(1000);
|
|
294
|
+
|
|
295
|
+
while (batch.length > 0) {
|
|
296
|
+
for (const dp of batch) {
|
|
297
|
+
await ctx.db.delete(dp._id);
|
|
298
|
+
}
|
|
299
|
+
batch = await ctx.db
|
|
300
|
+
.query("dataPoints")
|
|
301
|
+
.withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", args.dataSourceId))
|
|
302
|
+
.take(1000);
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { convexTest } from "convex-test";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import schema from "./schema";
|
|
4
|
+
import { modules } from "./test.setup";
|
|
5
|
+
|
|
6
|
+
describe("dataSources", () => {
|
|
7
|
+
it("creates a new data source", async () => {
|
|
8
|
+
const t = convexTest(schema, modules);
|
|
9
|
+
|
|
10
|
+
const id = await t.run(async (ctx) => {
|
|
11
|
+
return await ctx.db.insert("dataSources", {
|
|
12
|
+
userId: "user-1",
|
|
13
|
+
provider: "strava",
|
|
14
|
+
source: "strava",
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const ds = await t.run(async (ctx) => {
|
|
19
|
+
return await ctx.db.get(id);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(ds?.userId).toBe("user-1");
|
|
23
|
+
expect(ds?.provider).toBe("strava");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("finds data sources by user via index", async () => {
|
|
27
|
+
const t = convexTest(schema, modules);
|
|
28
|
+
|
|
29
|
+
await t.run(async (ctx) => {
|
|
30
|
+
await ctx.db.insert("dataSources", {
|
|
31
|
+
userId: "user-1",
|
|
32
|
+
provider: "strava",
|
|
33
|
+
source: "strava",
|
|
34
|
+
});
|
|
35
|
+
await ctx.db.insert("dataSources", {
|
|
36
|
+
userId: "user-1",
|
|
37
|
+
provider: "garmin",
|
|
38
|
+
deviceModel: "Forerunner 965",
|
|
39
|
+
source: "garmin-connect",
|
|
40
|
+
});
|
|
41
|
+
await ctx.db.insert("dataSources", {
|
|
42
|
+
userId: "user-2",
|
|
43
|
+
provider: "whoop",
|
|
44
|
+
source: "whoop",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const user1Sources = await t.run(async (ctx) => {
|
|
49
|
+
return await ctx.db
|
|
50
|
+
.query("dataSources")
|
|
51
|
+
.withIndex("by_user_provider", (idx) => idx.eq("userId", "user-1"))
|
|
52
|
+
.collect();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(user1Sources).toHaveLength(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("finds data sources by user + provider via index", async () => {
|
|
59
|
+
const t = convexTest(schema, modules);
|
|
60
|
+
|
|
61
|
+
await t.run(async (ctx) => {
|
|
62
|
+
await ctx.db.insert("dataSources", {
|
|
63
|
+
userId: "user-1",
|
|
64
|
+
provider: "strava",
|
|
65
|
+
source: "strava",
|
|
66
|
+
});
|
|
67
|
+
await ctx.db.insert("dataSources", {
|
|
68
|
+
userId: "user-1",
|
|
69
|
+
provider: "garmin",
|
|
70
|
+
source: "garmin-connect",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const stravaSources = await t.run(async (ctx) => {
|
|
75
|
+
return await ctx.db
|
|
76
|
+
.query("dataSources")
|
|
77
|
+
.withIndex("by_user_provider", (idx) => idx.eq("userId", "user-1").eq("provider", "strava"))
|
|
78
|
+
.collect();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(stravaSources).toHaveLength(1);
|
|
82
|
+
expect(stravaSources[0].provider).toBe("strava");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("upserts by user/provider/device/source (getOrCreate pattern)", async () => {
|
|
86
|
+
const t = convexTest(schema, modules);
|
|
87
|
+
|
|
88
|
+
// Create initial data source
|
|
89
|
+
const id1 = await t.run(async (ctx) => {
|
|
90
|
+
return await ctx.db.insert("dataSources", {
|
|
91
|
+
userId: "user-1",
|
|
92
|
+
provider: "garmin",
|
|
93
|
+
deviceModel: "Forerunner 965",
|
|
94
|
+
source: "garmin-connect",
|
|
95
|
+
softwareVersion: "1.0",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Simulate getOrCreate — same user/provider/device/source should find existing
|
|
100
|
+
const found = await t.run(async (ctx) => {
|
|
101
|
+
return await ctx.db
|
|
102
|
+
.query("dataSources")
|
|
103
|
+
.withIndex("by_user_provider_device", (idx) =>
|
|
104
|
+
idx
|
|
105
|
+
.eq("userId", "user-1")
|
|
106
|
+
.eq("provider", "garmin")
|
|
107
|
+
.eq("deviceModel", "Forerunner 965")
|
|
108
|
+
.eq("source", "garmin-connect"),
|
|
109
|
+
)
|
|
110
|
+
.first();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(found?._id).toBe(id1);
|
|
114
|
+
|
|
115
|
+
// Update the software version (simulating upsert update)
|
|
116
|
+
await t.run(async (ctx) => {
|
|
117
|
+
if (found) {
|
|
118
|
+
await ctx.db.patch(found._id, { softwareVersion: "2.0" });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const updated = await t.run(async (ctx) => {
|
|
123
|
+
return await ctx.db.get(id1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(updated?.softwareVersion).toBe("2.0");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("creates separate entries for different devices", async () => {
|
|
130
|
+
const t = convexTest(schema, modules);
|
|
131
|
+
|
|
132
|
+
await t.run(async (ctx) => {
|
|
133
|
+
await ctx.db.insert("dataSources", {
|
|
134
|
+
userId: "user-1",
|
|
135
|
+
provider: "garmin",
|
|
136
|
+
deviceModel: "Forerunner 965",
|
|
137
|
+
source: "garmin-connect",
|
|
138
|
+
});
|
|
139
|
+
await ctx.db.insert("dataSources", {
|
|
140
|
+
userId: "user-1",
|
|
141
|
+
provider: "garmin",
|
|
142
|
+
deviceModel: "Venu 3",
|
|
143
|
+
source: "garmin-connect",
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const sources = await t.run(async (ctx) => {
|
|
148
|
+
return await ctx.db
|
|
149
|
+
.query("dataSources")
|
|
150
|
+
.withIndex("by_user_provider", (idx) => idx.eq("userId", "user-1").eq("provider", "garmin"))
|
|
151
|
+
.collect();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(sources).toHaveLength(2);
|
|
155
|
+
const devices = sources.map((s) => s.deviceModel);
|
|
156
|
+
expect(devices).toContain("Forerunner 965");
|
|
157
|
+
expect(devices).toContain("Venu 3");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("finds data sources by connectionId", async () => {
|
|
161
|
+
const t = convexTest(schema, modules);
|
|
162
|
+
|
|
163
|
+
const connId = await t.run(async (ctx) => {
|
|
164
|
+
return await ctx.db.insert("connections", {
|
|
165
|
+
userId: "user-1",
|
|
166
|
+
provider: "strava",
|
|
167
|
+
accessToken: "tok",
|
|
168
|
+
status: "active",
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await t.run(async (ctx) => {
|
|
173
|
+
await ctx.db.insert("dataSources", {
|
|
174
|
+
userId: "user-1",
|
|
175
|
+
provider: "strava",
|
|
176
|
+
connectionId: connId,
|
|
177
|
+
source: "strava",
|
|
178
|
+
});
|
|
179
|
+
// Unrelated data source
|
|
180
|
+
await ctx.db.insert("dataSources", {
|
|
181
|
+
userId: "user-2",
|
|
182
|
+
provider: "garmin",
|
|
183
|
+
source: "garmin",
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const byConn = await t.run(async (ctx) => {
|
|
188
|
+
return await ctx.db
|
|
189
|
+
.query("dataSources")
|
|
190
|
+
.withIndex("by_connection", (idx) => idx.eq("connectionId", connId))
|
|
191
|
+
.collect();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(byConn).toHaveLength(1);
|
|
195
|
+
expect(byConn[0].provider).toBe("strava");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("deletes all data sources for a user", async () => {
|
|
199
|
+
const t = convexTest(schema, modules);
|
|
200
|
+
|
|
201
|
+
await t.run(async (ctx) => {
|
|
202
|
+
await ctx.db.insert("dataSources", {
|
|
203
|
+
userId: "user-1",
|
|
204
|
+
provider: "strava",
|
|
205
|
+
source: "strava",
|
|
206
|
+
});
|
|
207
|
+
await ctx.db.insert("dataSources", {
|
|
208
|
+
userId: "user-1",
|
|
209
|
+
provider: "garmin",
|
|
210
|
+
source: "garmin",
|
|
211
|
+
});
|
|
212
|
+
await ctx.db.insert("dataSources", {
|
|
213
|
+
userId: "user-2",
|
|
214
|
+
provider: "whoop",
|
|
215
|
+
source: "whoop",
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Delete user-1 sources
|
|
220
|
+
await t.run(async (ctx) => {
|
|
221
|
+
const sources = await ctx.db
|
|
222
|
+
.query("dataSources")
|
|
223
|
+
.withIndex("by_user_provider", (idx) => idx.eq("userId", "user-1"))
|
|
224
|
+
.collect();
|
|
225
|
+
for (const s of sources) {
|
|
226
|
+
await ctx.db.delete(s._id);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const user1 = await t.run(async (ctx) => {
|
|
231
|
+
return await ctx.db
|
|
232
|
+
.query("dataSources")
|
|
233
|
+
.withIndex("by_user_provider", (idx) => idx.eq("userId", "user-1"))
|
|
234
|
+
.collect();
|
|
235
|
+
});
|
|
236
|
+
expect(user1).toHaveLength(0);
|
|
237
|
+
|
|
238
|
+
// user-2 unaffected
|
|
239
|
+
const user2 = await t.run(async (ctx) => {
|
|
240
|
+
return await ctx.db
|
|
241
|
+
.query("dataSources")
|
|
242
|
+
.withIndex("by_user_provider", (idx) => idx.eq("userId", "user-2"))
|
|
243
|
+
.collect();
|
|
244
|
+
});
|
|
245
|
+
expect(user2).toHaveLength(1);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { internalMutation, mutation, query } from "./_generated/server";
|
|
3
|
+
import { providerName } from "./schema";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Queries
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get all data sources for a user.
|
|
11
|
+
*/
|
|
12
|
+
export const getByUser = query({
|
|
13
|
+
args: { userId: v.string() },
|
|
14
|
+
returns: v.array(v.any()),
|
|
15
|
+
handler: async (ctx, args) => {
|
|
16
|
+
return await ctx.db
|
|
17
|
+
.query("dataSources")
|
|
18
|
+
.withIndex("by_user_provider", (idx) => idx.eq("userId", args.userId))
|
|
19
|
+
.collect();
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get data sources for a user + provider combination.
|
|
25
|
+
*/
|
|
26
|
+
export const getByUserProvider = query({
|
|
27
|
+
args: {
|
|
28
|
+
userId: v.string(),
|
|
29
|
+
provider: providerName,
|
|
30
|
+
},
|
|
31
|
+
returns: v.array(v.any()),
|
|
32
|
+
handler: async (ctx, args) => {
|
|
33
|
+
return await ctx.db
|
|
34
|
+
.query("dataSources")
|
|
35
|
+
.withIndex("by_user_provider", (idx) =>
|
|
36
|
+
idx.eq("userId", args.userId).eq("provider", args.provider),
|
|
37
|
+
)
|
|
38
|
+
.collect();
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Mutations
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get or create a data source. Used during data ingestion to ensure
|
|
48
|
+
* a data source exists for a given user/provider/device combination.
|
|
49
|
+
*/
|
|
50
|
+
export const getOrCreate = mutation({
|
|
51
|
+
args: {
|
|
52
|
+
userId: v.string(),
|
|
53
|
+
provider: providerName,
|
|
54
|
+
connectionId: v.optional(v.id("connections")),
|
|
55
|
+
deviceModel: v.optional(v.string()),
|
|
56
|
+
softwareVersion: v.optional(v.string()),
|
|
57
|
+
source: v.optional(v.string()),
|
|
58
|
+
deviceType: v.optional(v.string()),
|
|
59
|
+
originalSourceName: v.optional(v.string()),
|
|
60
|
+
},
|
|
61
|
+
returns: v.id("dataSources"),
|
|
62
|
+
handler: async (ctx, args) => {
|
|
63
|
+
// Look for existing data source matching this user/provider/device/source
|
|
64
|
+
const existing = await ctx.db
|
|
65
|
+
.query("dataSources")
|
|
66
|
+
.withIndex("by_user_provider_device", (idx) =>
|
|
67
|
+
idx
|
|
68
|
+
.eq("userId", args.userId)
|
|
69
|
+
.eq("provider", args.provider)
|
|
70
|
+
.eq("deviceModel", args.deviceModel ?? undefined)
|
|
71
|
+
.eq("source", args.source ?? undefined),
|
|
72
|
+
)
|
|
73
|
+
.first();
|
|
74
|
+
|
|
75
|
+
if (existing) {
|
|
76
|
+
// Update fields that may have changed
|
|
77
|
+
if (
|
|
78
|
+
args.softwareVersion !== existing.softwareVersion ||
|
|
79
|
+
args.deviceType !== existing.deviceType
|
|
80
|
+
) {
|
|
81
|
+
await ctx.db.patch(existing._id, {
|
|
82
|
+
softwareVersion: args.softwareVersion,
|
|
83
|
+
deviceType: args.deviceType,
|
|
84
|
+
connectionId: args.connectionId ?? existing.connectionId,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return existing._id;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return await ctx.db.insert("dataSources", args);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Delete all data sources for a user. Used during account deletion.
|
|
96
|
+
*/
|
|
97
|
+
export const deleteByUser = internalMutation({
|
|
98
|
+
args: { userId: v.string() },
|
|
99
|
+
handler: async (ctx, args) => {
|
|
100
|
+
const sources = await ctx.db
|
|
101
|
+
.query("dataSources")
|
|
102
|
+
.withIndex("by_user_provider", (idx) => idx.eq("userId", args.userId))
|
|
103
|
+
.collect();
|
|
104
|
+
|
|
105
|
+
for (const source of sources) {
|
|
106
|
+
await ctx.db.delete(source._id);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
});
|