@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.
Files changed (88) hide show
  1. package/dist/client/index.d.ts +9 -4
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js.map +1 -1
  4. package/dist/component/_generated/component.d.ts +50 -0
  5. package/dist/component/_generated/component.d.ts.map +1 -0
  6. package/dist/component/_generated/component.js +11 -0
  7. package/dist/component/_generated/component.js.map +1 -0
  8. package/dist/component/backfillJobs.d.ts +11 -11
  9. package/dist/component/connections.d.ts +9 -9
  10. package/dist/component/connections.d.ts.map +1 -1
  11. package/dist/component/connections.js +2 -0
  12. package/dist/component/connections.js.map +1 -1
  13. package/dist/component/dataPoints.d.ts +5 -5
  14. package/dist/component/events.d.ts +13 -13
  15. package/dist/component/garminBackfill.d.ts +2 -2
  16. package/dist/component/garminWebhooks.d.ts +2 -2
  17. package/dist/component/garminWebhooks.d.ts.map +1 -1
  18. package/dist/component/garminWebhooks.js +2 -0
  19. package/dist/component/garminWebhooks.js.map +1 -1
  20. package/dist/component/lifecycle.d.ts +1 -1
  21. package/dist/component/lifecycle.d.ts.map +1 -1
  22. package/dist/component/lifecycle.js +2 -0
  23. package/dist/component/lifecycle.js.map +1 -1
  24. package/dist/component/oauthStates.d.ts +3 -3
  25. package/dist/component/schema.d.ts +26 -26
  26. package/dist/component/sdkPush.d.ts +11 -11
  27. package/dist/component/summaries.d.ts +4 -4
  28. package/dist/component/syncJobs.d.ts +23 -23
  29. package/dist/component/syncWorkflow.d.ts +2 -2
  30. package/dist/test.d.ts +421 -0
  31. package/dist/test.d.ts.map +1 -0
  32. package/dist/test.js +17 -0
  33. package/dist/test.js.map +1 -0
  34. package/package.json +12 -2
  35. package/src/client/_generated/_ignore.ts +2 -0
  36. package/src/client/index.test.ts +52 -0
  37. package/src/client/index.ts +784 -0
  38. package/src/client/types.ts +533 -0
  39. package/src/component/_generated/_ignore.ts +2 -0
  40. package/src/component/_generated/api.ts +16 -0
  41. package/src/component/_generated/component.ts +74 -0
  42. package/src/component/_generated/dataModel.ts +40 -0
  43. package/src/component/_generated/server.ts +48 -0
  44. package/src/component/backfillJobs.test.ts +47 -0
  45. package/src/component/backfillJobs.ts +245 -0
  46. package/src/component/connections.test.ts +297 -0
  47. package/src/component/connections.ts +329 -0
  48. package/src/component/convex.config.ts +7 -0
  49. package/src/component/dataPoints.test.ts +282 -0
  50. package/src/component/dataPoints.ts +305 -0
  51. package/src/component/dataSources.test.ts +247 -0
  52. package/src/component/dataSources.ts +109 -0
  53. package/src/component/events.test.ts +380 -0
  54. package/src/component/events.ts +288 -0
  55. package/src/component/garminBackfill.ts +343 -0
  56. package/src/component/garminWebhooks.test.ts +609 -0
  57. package/src/component/garminWebhooks.ts +656 -0
  58. package/src/component/httpHandlers.ts +153 -0
  59. package/src/component/lifecycle.test.ts +179 -0
  60. package/src/component/lifecycle.ts +87 -0
  61. package/src/component/menstrualCycles.ts +124 -0
  62. package/src/component/oauthActions.ts +261 -0
  63. package/src/component/oauthStates.test.ts +170 -0
  64. package/src/component/oauthStates.ts +85 -0
  65. package/src/component/providerSettings.ts +66 -0
  66. package/src/component/providers/additionalProviders.test.ts +401 -0
  67. package/src/component/providers/garmin.ts +1169 -0
  68. package/src/component/providers/oauth.test.ts +174 -0
  69. package/src/component/providers/oauth.ts +246 -0
  70. package/src/component/providers/polar.ts +220 -0
  71. package/src/component/providers/registry.ts +37 -0
  72. package/src/component/providers/strava.test.ts +195 -0
  73. package/src/component/providers/strava.ts +253 -0
  74. package/src/component/providers/suunto.ts +592 -0
  75. package/src/component/providers/types.ts +189 -0
  76. package/src/component/providers/whoop.ts +600 -0
  77. package/src/component/schema.ts +339 -0
  78. package/src/component/sdkPush.test.ts +367 -0
  79. package/src/component/sdkPush.ts +440 -0
  80. package/src/component/summaries.test.ts +201 -0
  81. package/src/component/summaries.ts +143 -0
  82. package/src/component/syncJobs.test.ts +254 -0
  83. package/src/component/syncJobs.ts +140 -0
  84. package/src/component/syncWorkflow.test.ts +87 -0
  85. package/src/component/syncWorkflow.ts +739 -0
  86. package/src/component/test.setup.ts +6 -0
  87. package/src/component/workflowManager.ts +19 -0
  88. package/src/test.ts +25 -0
@@ -0,0 +1,329 @@
1
+ import { v } from "convex/values";
2
+ import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
3
+ import { connectionStatus, providerName } from "./schema";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Queries
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * Get all connections for a user.
11
+ */
12
+ export const getConnections = query({
13
+ args: { userId: v.string() },
14
+ returns: v.array(v.any()),
15
+ handler: async (ctx, args) => {
16
+ const connections = await ctx.db
17
+ .query("connections")
18
+ .withIndex("by_user", (idx) => idx.eq("userId", args.userId))
19
+ .collect();
20
+
21
+ // Strip sensitive token fields before returning
22
+ return connections.map((conn) => ({
23
+ _id: conn._id,
24
+ _creationTime: conn._creationTime,
25
+ userId: conn.userId,
26
+ provider: conn.provider,
27
+ providerUserId: conn.providerUserId,
28
+ providerUsername: conn.providerUsername,
29
+ status: conn.status,
30
+ lastSyncedAt: conn.lastSyncedAt,
31
+ }));
32
+ },
33
+ });
34
+
35
+ /**
36
+ * Get a specific connection by user + provider.
37
+ */
38
+ export const getByUserProvider = query({
39
+ args: {
40
+ userId: v.string(),
41
+ provider: providerName,
42
+ },
43
+ returns: v.union(v.any(), v.null()),
44
+ handler: async (ctx, args) => {
45
+ const conn = await ctx.db
46
+ .query("connections")
47
+ .withIndex("by_user_provider", (idx) =>
48
+ idx.eq("userId", args.userId).eq("provider", args.provider),
49
+ )
50
+ .first();
51
+
52
+ if (!conn) {
53
+ return null;
54
+ }
55
+
56
+ return {
57
+ _id: conn._id,
58
+ _creationTime: conn._creationTime,
59
+ userId: conn.userId,
60
+ provider: conn.provider,
61
+ providerUserId: conn.providerUserId,
62
+ providerUsername: conn.providerUsername,
63
+ status: conn.status,
64
+ lastSyncedAt: conn.lastSyncedAt,
65
+ };
66
+ },
67
+ });
68
+
69
+ /**
70
+ * Get all active connections (for periodic sync).
71
+ */
72
+ export const getAllActive = internalQuery({
73
+ args: {},
74
+ returns: v.array(v.any()),
75
+ handler: async (ctx) => {
76
+ return await ctx.db
77
+ .query("connections")
78
+ .withIndex("by_status", (idx) => idx.eq("status", "active"))
79
+ .collect();
80
+ },
81
+ });
82
+
83
+ export const getById = internalQuery({
84
+ args: {
85
+ connectionId: v.id("connections"),
86
+ },
87
+ returns: v.union(v.any(), v.null()),
88
+ handler: async (ctx, args) => {
89
+ return await ctx.db.get(args.connectionId);
90
+ },
91
+ });
92
+
93
+ export const getByProviderUser = internalQuery({
94
+ args: {
95
+ provider: providerName,
96
+ providerUserId: v.string(),
97
+ },
98
+ returns: v.union(v.any(), v.null()),
99
+ handler: async (ctx, args) => {
100
+ return await ctx.db
101
+ .query("connections")
102
+ .withIndex("by_provider_user", (idx) =>
103
+ idx.eq("provider", args.provider).eq("providerUserId", args.providerUserId),
104
+ )
105
+ .first();
106
+ },
107
+ });
108
+
109
+ /**
110
+ * Get sync status for a user across all their connections.
111
+ */
112
+ export const getSyncStatus = query({
113
+ args: { userId: v.string() },
114
+ returns: v.array(v.any()),
115
+ handler: async (ctx, args) => {
116
+ const connections = await ctx.db
117
+ .query("connections")
118
+ .withIndex("by_user", (idx) => idx.eq("userId", args.userId))
119
+ .collect();
120
+
121
+ const statuses = [];
122
+ for (const conn of connections) {
123
+ const latestJob = await ctx.db
124
+ .query("syncJobs")
125
+ .withIndex("by_connection", (idx) => idx.eq("connectionId", conn._id))
126
+ .order("desc")
127
+ .first();
128
+
129
+ statuses.push({
130
+ provider: conn.provider,
131
+ connectionStatus: conn.status,
132
+ lastSyncedAt: conn.lastSyncedAt,
133
+ syncJobStatus: latestJob?.status ?? null,
134
+ syncJobError: latestJob?.error ?? null,
135
+ });
136
+ }
137
+
138
+ return statuses;
139
+ },
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Mutations
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Create a new connection (after successful OAuth token exchange).
148
+ */
149
+ export const createConnection = internalMutation({
150
+ args: {
151
+ userId: v.string(),
152
+ provider: providerName,
153
+ providerUserId: v.optional(v.string()),
154
+ providerUsername: v.optional(v.string()),
155
+ accessToken: v.string(),
156
+ refreshToken: v.optional(v.string()),
157
+ tokenExpiresAt: v.optional(v.number()),
158
+ scope: v.optional(v.string()),
159
+ },
160
+ returns: v.id("connections"),
161
+ handler: async (ctx, args) => {
162
+ // Check if connection already exists
163
+ const existing = await ctx.db
164
+ .query("connections")
165
+ .withIndex("by_user_provider", (idx) =>
166
+ idx.eq("userId", args.userId).eq("provider", args.provider),
167
+ )
168
+ .first();
169
+
170
+ if (existing) {
171
+ // Re-activate and update tokens
172
+ await ctx.db.patch(existing._id, {
173
+ ...args,
174
+ status: "active",
175
+ });
176
+ return existing._id;
177
+ }
178
+
179
+ return await ctx.db.insert("connections", {
180
+ ...args,
181
+ status: "active",
182
+ });
183
+ },
184
+ });
185
+
186
+ /**
187
+ * Ensure a push-based provider connection exists for SDK-ingested providers
188
+ * like Apple Health or Google Health Connect.
189
+ */
190
+ export const ensurePushConnection = internalMutation({
191
+ args: {
192
+ userId: v.string(),
193
+ provider: providerName,
194
+ providerUserId: v.optional(v.string()),
195
+ providerUsername: v.optional(v.string()),
196
+ },
197
+ returns: v.id("connections"),
198
+ handler: async (ctx, args) => {
199
+ const existing = await ctx.db
200
+ .query("connections")
201
+ .withIndex("by_user_provider", (idx) =>
202
+ idx.eq("userId", args.userId).eq("provider", args.provider),
203
+ )
204
+ .first();
205
+
206
+ if (existing) {
207
+ await ctx.db.patch(existing._id, {
208
+ providerUserId: args.providerUserId ?? existing.providerUserId,
209
+ providerUsername: args.providerUsername ?? existing.providerUsername,
210
+ status: "active",
211
+ });
212
+ return existing._id;
213
+ }
214
+
215
+ return await ctx.db.insert("connections", {
216
+ userId: args.userId,
217
+ provider: args.provider,
218
+ providerUserId: args.providerUserId,
219
+ providerUsername: args.providerUsername,
220
+ status: "active",
221
+ });
222
+ },
223
+ });
224
+
225
+ /**
226
+ * Update OAuth tokens (e.g., after refresh).
227
+ */
228
+ export const updateTokens = internalMutation({
229
+ args: {
230
+ connectionId: v.id("connections"),
231
+ accessToken: v.string(),
232
+ refreshToken: v.optional(v.string()),
233
+ tokenExpiresAt: v.optional(v.number()),
234
+ },
235
+ handler: async (ctx, args) => {
236
+ const { connectionId, ...updates } = args;
237
+ await ctx.db.patch(connectionId, updates);
238
+ },
239
+ });
240
+
241
+ /**
242
+ * Mark a connection as synced (update lastSyncedAt).
243
+ */
244
+ export const markSynced = internalMutation({
245
+ args: { connectionId: v.id("connections") },
246
+ handler: async (ctx, args) => {
247
+ await ctx.db.patch(args.connectionId, {
248
+ lastSyncedAt: Date.now(),
249
+ });
250
+ },
251
+ });
252
+
253
+ /**
254
+ * Update connection status.
255
+ */
256
+ export const updateStatus = internalMutation({
257
+ args: {
258
+ connectionId: v.id("connections"),
259
+ status: connectionStatus,
260
+ },
261
+ handler: async (ctx, args) => {
262
+ await ctx.db.patch(args.connectionId, { status: args.status });
263
+ },
264
+ });
265
+
266
+ /**
267
+ * Update the stored OAuth scope / permissions for a connection.
268
+ */
269
+ export const updateScope = internalMutation({
270
+ args: {
271
+ connectionId: v.id("connections"),
272
+ scope: v.optional(v.string()),
273
+ },
274
+ handler: async (ctx, args) => {
275
+ await ctx.db.patch(args.connectionId, {
276
+ scope: args.scope,
277
+ });
278
+ },
279
+ });
280
+
281
+ /**
282
+ * Disconnect a provider — sets status to inactive.
283
+ */
284
+ export const disconnect = mutation({
285
+ args: {
286
+ userId: v.string(),
287
+ provider: providerName,
288
+ },
289
+ returns: v.null(),
290
+ handler: async (ctx, args) => {
291
+ const conn = await ctx.db
292
+ .query("connections")
293
+ .withIndex("by_user_provider", (idx) =>
294
+ idx.eq("userId", args.userId).eq("provider", args.provider),
295
+ )
296
+ .first();
297
+
298
+ if (conn) {
299
+ await ctx.db.patch(conn._id, {
300
+ status: "inactive",
301
+ accessToken: undefined,
302
+ refreshToken: undefined,
303
+ tokenExpiresAt: undefined,
304
+ });
305
+ }
306
+
307
+ return null;
308
+ },
309
+ });
310
+
311
+ /**
312
+ * Delete a connection and all associated data sources.
313
+ */
314
+ export const deleteConnection = internalMutation({
315
+ args: { connectionId: v.id("connections") },
316
+ handler: async (ctx, args) => {
317
+ // Delete associated data sources
318
+ const sources = await ctx.db
319
+ .query("dataSources")
320
+ .withIndex("by_connection", (idx) => idx.eq("connectionId", args.connectionId))
321
+ .collect();
322
+
323
+ for (const source of sources) {
324
+ await ctx.db.delete(source._id);
325
+ }
326
+
327
+ await ctx.db.delete(args.connectionId);
328
+ },
329
+ });
@@ -0,0 +1,7 @@
1
+ import workflow from "@convex-dev/workflow/convex.config";
2
+ import { defineComponent } from "convex/server";
3
+
4
+ const component = defineComponent("wearables");
5
+ component.use(workflow, { name: "workflow" });
6
+
7
+ export default component;
@@ -0,0 +1,282 @@
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
+ async function seedDataSource(
7
+ t: ReturnType<typeof convexTest>,
8
+ userId = "user-1",
9
+ provider: "garmin" | "strava" = "garmin",
10
+ ) {
11
+ return await t.run(async (ctx) => {
12
+ return await ctx.db.insert("dataSources", {
13
+ userId,
14
+ provider,
15
+ deviceModel: "Forerunner 965",
16
+ source: "garmin-api",
17
+ });
18
+ });
19
+ }
20
+
21
+ describe("dataPoints", () => {
22
+ describe("store and query", () => {
23
+ it("stores and retrieves a data point", async () => {
24
+ const t = convexTest(schema, modules);
25
+ const dsId = await seedDataSource(t);
26
+
27
+ await t.run(async (ctx) => {
28
+ await ctx.db.insert("dataPoints", {
29
+ dataSourceId: dsId,
30
+ seriesType: "heart_rate",
31
+ recordedAt: 1710000000000,
32
+ value: 72,
33
+ });
34
+ });
35
+
36
+ const points = await t.run(async (ctx) => {
37
+ return await ctx.db
38
+ .query("dataPoints")
39
+ .withIndex("by_source_type_time", (idx) =>
40
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
41
+ )
42
+ .collect();
43
+ });
44
+
45
+ expect(points).toHaveLength(1);
46
+ expect(points[0].value).toBe(72);
47
+ });
48
+
49
+ it("deduplicates by source + type + time (upsert pattern)", async () => {
50
+ const t = convexTest(schema, modules);
51
+ const dsId = await seedDataSource(t);
52
+
53
+ await t.run(async (ctx) => {
54
+ await ctx.db.insert("dataPoints", {
55
+ dataSourceId: dsId,
56
+ seriesType: "heart_rate",
57
+ recordedAt: 1710000000000,
58
+ value: 72,
59
+ });
60
+ });
61
+
62
+ // Upsert: find existing, update value
63
+ await t.run(async (ctx) => {
64
+ const existing = await ctx.db
65
+ .query("dataPoints")
66
+ .withIndex("by_source_type_time", (idx) =>
67
+ idx
68
+ .eq("dataSourceId", dsId)
69
+ .eq("seriesType", "heart_rate")
70
+ .eq("recordedAt", 1710000000000),
71
+ )
72
+ .first();
73
+ if (existing) {
74
+ await ctx.db.patch(existing._id, { value: 75 });
75
+ }
76
+ });
77
+
78
+ const points = await t.run(async (ctx) => {
79
+ return await ctx.db
80
+ .query("dataPoints")
81
+ .withIndex("by_source_type_time", (idx) =>
82
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
83
+ )
84
+ .collect();
85
+ });
86
+
87
+ expect(points).toHaveLength(1);
88
+ expect(points[0].value).toBe(75);
89
+ });
90
+ });
91
+
92
+ describe("time-series queries", () => {
93
+ it("returns data within date range", async () => {
94
+ const t = convexTest(schema, modules);
95
+ const dsId = await seedDataSource(t);
96
+
97
+ await t.run(async (ctx) => {
98
+ for (let i = 0; i < 10; i++) {
99
+ await ctx.db.insert("dataPoints", {
100
+ dataSourceId: dsId,
101
+ seriesType: "heart_rate",
102
+ recordedAt: 1710000000000 + i * 60000,
103
+ value: 70 + i,
104
+ });
105
+ }
106
+ });
107
+
108
+ const result = await t.run(async (ctx) => {
109
+ return await ctx.db
110
+ .query("dataPoints")
111
+ .withIndex("by_source_type_time", (idx) =>
112
+ idx
113
+ .eq("dataSourceId", dsId)
114
+ .eq("seriesType", "heart_rate")
115
+ .gte("recordedAt", 1710000000000)
116
+ .lte("recordedAt", 1710000300000),
117
+ )
118
+ .collect();
119
+ });
120
+
121
+ expect(result).toHaveLength(6); // 0,1,2,3,4,5 minutes
122
+ expect(result[0].value).toBe(70);
123
+ expect(result[5].value).toBe(75);
124
+ });
125
+
126
+ it("paginates with take()", async () => {
127
+ const t = convexTest(schema, modules);
128
+ const dsId = await seedDataSource(t);
129
+
130
+ await t.run(async (ctx) => {
131
+ for (let i = 0; i < 20; i++) {
132
+ await ctx.db.insert("dataPoints", {
133
+ dataSourceId: dsId,
134
+ seriesType: "steps",
135
+ recordedAt: 1710000000000 + i * 60000,
136
+ value: 100 + i,
137
+ });
138
+ }
139
+ });
140
+
141
+ const page1 = await t.run(async (ctx) => {
142
+ return await ctx.db
143
+ .query("dataPoints")
144
+ .withIndex("by_source_type_time", (idx) =>
145
+ idx
146
+ .eq("dataSourceId", dsId)
147
+ .eq("seriesType", "steps")
148
+ .gte("recordedAt", 1710000000000)
149
+ .lte("recordedAt", 1710001200000),
150
+ )
151
+ .take(5);
152
+ });
153
+
154
+ expect(page1).toHaveLength(5);
155
+
156
+ // Next page starts after last item
157
+ const lastTime = page1[page1.length - 1].recordedAt;
158
+ const page2 = await t.run(async (ctx) => {
159
+ return await ctx.db
160
+ .query("dataPoints")
161
+ .withIndex("by_source_type_time", (idx) =>
162
+ idx
163
+ .eq("dataSourceId", dsId)
164
+ .eq("seriesType", "steps")
165
+ .gt("recordedAt", lastTime)
166
+ .lte("recordedAt", 1710001200000),
167
+ )
168
+ .take(5);
169
+ });
170
+
171
+ expect(page2).toHaveLength(5);
172
+ expect(page2[0].recordedAt).toBeGreaterThan(lastTime);
173
+ });
174
+
175
+ it("separates different series types", async () => {
176
+ const t = convexTest(schema, modules);
177
+ const dsId = await seedDataSource(t);
178
+
179
+ await t.run(async (ctx) => {
180
+ await ctx.db.insert("dataPoints", {
181
+ dataSourceId: dsId,
182
+ seriesType: "heart_rate",
183
+ recordedAt: 1710000000000,
184
+ value: 72,
185
+ });
186
+ await ctx.db.insert("dataPoints", {
187
+ dataSourceId: dsId,
188
+ seriesType: "steps",
189
+ recordedAt: 1710000000000,
190
+ value: 150,
191
+ });
192
+ });
193
+
194
+ const hr = await t.run(async (ctx) => {
195
+ return await ctx.db
196
+ .query("dataPoints")
197
+ .withIndex("by_source_type_time", (idx) =>
198
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
199
+ )
200
+ .collect();
201
+ });
202
+ const steps = await t.run(async (ctx) => {
203
+ return await ctx.db
204
+ .query("dataPoints")
205
+ .withIndex("by_source_type_time", (idx) =>
206
+ idx.eq("dataSourceId", dsId).eq("seriesType", "steps"),
207
+ )
208
+ .collect();
209
+ });
210
+
211
+ expect(hr).toHaveLength(1);
212
+ expect(hr[0].value).toBe(72);
213
+ expect(steps).toHaveLength(1);
214
+ expect(steps[0].value).toBe(150);
215
+ });
216
+ });
217
+
218
+ describe("latest data point", () => {
219
+ it("finds the most recent value across sources", async () => {
220
+ const t = convexTest(schema, modules);
221
+ const dsId = await seedDataSource(t);
222
+
223
+ await t.run(async (ctx) => {
224
+ await ctx.db.insert("dataPoints", {
225
+ dataSourceId: dsId,
226
+ seriesType: "weight",
227
+ recordedAt: 1710000000000,
228
+ value: 80.5,
229
+ });
230
+ await ctx.db.insert("dataPoints", {
231
+ dataSourceId: dsId,
232
+ seriesType: "weight",
233
+ recordedAt: 1710100000000,
234
+ value: 80.2,
235
+ });
236
+ });
237
+
238
+ const latest = await t.run(async (ctx) => {
239
+ return await ctx.db
240
+ .query("dataPoints")
241
+ .withIndex("by_source_type_time", (idx) =>
242
+ idx.eq("dataSourceId", dsId).eq("seriesType", "weight"),
243
+ )
244
+ .order("desc")
245
+ .first();
246
+ });
247
+
248
+ expect(latest).not.toBeNull();
249
+ expect(latest?.value).toBe(80.2);
250
+ expect(latest?.recordedAt).toBe(1710100000000);
251
+ });
252
+ });
253
+
254
+ describe("batch insert", () => {
255
+ it("stores multiple data points", async () => {
256
+ const t = convexTest(schema, modules);
257
+ const dsId = await seedDataSource(t);
258
+
259
+ await t.run(async (ctx) => {
260
+ for (let i = 0; i < 10; i++) {
261
+ await ctx.db.insert("dataPoints", {
262
+ dataSourceId: dsId,
263
+ seriesType: "heart_rate",
264
+ recordedAt: 1710000000000 + i * 60000,
265
+ value: 70 + i,
266
+ });
267
+ }
268
+ });
269
+
270
+ const stored = await t.run(async (ctx) => {
271
+ return await ctx.db
272
+ .query("dataPoints")
273
+ .withIndex("by_source_type_time", (idx) =>
274
+ idx.eq("dataSourceId", dsId).eq("seriesType", "heart_rate"),
275
+ )
276
+ .collect();
277
+ });
278
+
279
+ expect(stored).toHaveLength(10);
280
+ });
281
+ });
282
+ });