@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,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,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
|
+
});
|