@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,143 @@
1
+ import { v } from "convex/values";
2
+ import { internalMutation, internalQuery, query } from "./_generated/server";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Queries
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * Get daily summaries for a user by category and date range.
10
+ * Returns one document per day — very efficient (365 docs for a full year).
11
+ */
12
+ export const getDailySummaries = query({
13
+ args: {
14
+ userId: v.string(),
15
+ category: v.string(),
16
+ startDate: v.string(), // "2026-03-01"
17
+ endDate: v.string(), // "2026-03-15"
18
+ },
19
+ returns: v.array(v.any()),
20
+ handler: async (ctx, args) => {
21
+ return await ctx.db
22
+ .query("dailySummaries")
23
+ .withIndex("by_user_category_date", (idx) =>
24
+ idx
25
+ .eq("userId", args.userId)
26
+ .eq("category", args.category)
27
+ .gte("date", args.startDate)
28
+ .lte("date", args.endDate),
29
+ )
30
+ .collect();
31
+ },
32
+ });
33
+
34
+ /**
35
+ * Get all summaries for a user on a specific date (across all categories).
36
+ */
37
+ export const getByUserDate = internalQuery({
38
+ args: {
39
+ userId: v.string(),
40
+ date: v.string(),
41
+ },
42
+ returns: v.array(v.any()),
43
+ handler: async (ctx, args) => {
44
+ return await ctx.db
45
+ .query("dailySummaries")
46
+ .withIndex("by_user_date", (idx) => idx.eq("userId", args.userId).eq("date", args.date))
47
+ .collect();
48
+ },
49
+ });
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Mutations
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Upsert a daily summary. Called during data ingestion to update
57
+ * precomputed aggregates for the affected date.
58
+ */
59
+ export const upsert = internalMutation({
60
+ args: {
61
+ userId: v.string(),
62
+ date: v.string(),
63
+ category: v.string(),
64
+ // All metric fields are optional — only provided fields are updated
65
+ totalSteps: v.optional(v.number()),
66
+ totalCalories: v.optional(v.number()),
67
+ activeCalories: v.optional(v.number()),
68
+ activeMinutes: v.optional(v.number()),
69
+ totalDistance: v.optional(v.number()),
70
+ floorsClimbed: v.optional(v.number()),
71
+ avgHeartRate: v.optional(v.number()),
72
+ maxHeartRate: v.optional(v.number()),
73
+ minHeartRate: v.optional(v.number()),
74
+ sleepDurationMinutes: v.optional(v.number()),
75
+ sleepEfficiency: v.optional(v.number()),
76
+ deepSleepMinutes: v.optional(v.number()),
77
+ remSleepMinutes: v.optional(v.number()),
78
+ lightSleepMinutes: v.optional(v.number()),
79
+ awakeDuringMinutes: v.optional(v.number()),
80
+ timeInBedMinutes: v.optional(v.number()),
81
+ hrvAvg: v.optional(v.number()),
82
+ hrvRmssd: v.optional(v.number()),
83
+ restingHeartRate: v.optional(v.number()),
84
+ recoveryScore: v.optional(v.number()),
85
+ weight: v.optional(v.number()),
86
+ bodyFatPercentage: v.optional(v.number()),
87
+ bodyMassIndex: v.optional(v.number()),
88
+ leanBodyMass: v.optional(v.number()),
89
+ bodyTemperature: v.optional(v.number()),
90
+ avgStressLevel: v.optional(v.number()),
91
+ bodyBattery: v.optional(v.number()),
92
+ spo2Avg: v.optional(v.number()),
93
+ },
94
+ returns: v.id("dailySummaries"),
95
+ handler: async (ctx, args) => {
96
+ const { userId, date, category, ...metrics } = args;
97
+
98
+ // Find existing summary for this user/date/category
99
+ const existing = await ctx.db
100
+ .query("dailySummaries")
101
+ .withIndex("by_user_category_date", (idx) =>
102
+ idx.eq("userId", userId).eq("category", category).eq("date", date),
103
+ )
104
+ .first();
105
+
106
+ // Filter out undefined values from metrics
107
+ const definedMetrics: Record<string, number> = {};
108
+ for (const [key, value] of Object.entries(metrics)) {
109
+ if (value !== undefined) {
110
+ definedMetrics[key] = value;
111
+ }
112
+ }
113
+
114
+ if (existing) {
115
+ await ctx.db.patch(existing._id, definedMetrics);
116
+ return existing._id;
117
+ }
118
+
119
+ return await ctx.db.insert("dailySummaries", {
120
+ userId,
121
+ date,
122
+ category,
123
+ ...definedMetrics,
124
+ });
125
+ },
126
+ });
127
+
128
+ /**
129
+ * Delete all summaries for a user. Used during account deletion.
130
+ */
131
+ export const deleteByUser = internalMutation({
132
+ args: { userId: v.string() },
133
+ handler: async (ctx, args) => {
134
+ const summaries = await ctx.db
135
+ .query("dailySummaries")
136
+ .withIndex("by_user_date", (idx) => idx.eq("userId", args.userId))
137
+ .collect();
138
+
139
+ for (const summary of summaries) {
140
+ await ctx.db.delete(summary._id);
141
+ }
142
+ },
143
+ });
@@ -0,0 +1,254 @@
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 seedConnection(
7
+ t: ReturnType<typeof convexTest>,
8
+ args: { userId?: string; provider?: "garmin" | "strava" } = {},
9
+ ) {
10
+ return await t.run(async (ctx) => {
11
+ return await ctx.db.insert("connections", {
12
+ userId: args.userId ?? "user-1",
13
+ provider: args.provider ?? "strava",
14
+ accessToken: "token",
15
+ status: "active",
16
+ });
17
+ });
18
+ }
19
+
20
+ describe("syncJobs", () => {
21
+ it("creates a sync job with pending status", async () => {
22
+ const t = convexTest(schema, modules);
23
+ const connectionId = await seedConnection(t);
24
+
25
+ const id = await t.run(async (ctx) => {
26
+ return await ctx.db.insert("syncJobs", {
27
+ connectionId,
28
+ userId: "user-1",
29
+ provider: "strava",
30
+ idempotencyKey: "job-1",
31
+ status: "queued",
32
+ startedAt: Date.now(),
33
+ });
34
+ });
35
+
36
+ const job = await t.run(async (ctx) => {
37
+ return await ctx.db.get(id);
38
+ });
39
+
40
+ expect(job?.status).toBe("queued");
41
+ expect(job?.userId).toBe("user-1");
42
+ expect(job?.provider).toBe("strava");
43
+ });
44
+
45
+ it("transitions status from queued to running to completed", async () => {
46
+ const t = convexTest(schema, modules);
47
+ const connectionId = await seedConnection(t);
48
+
49
+ const id = await t.run(async (ctx) => {
50
+ return await ctx.db.insert("syncJobs", {
51
+ connectionId,
52
+ userId: "user-1",
53
+ provider: "strava",
54
+ idempotencyKey: "job-2",
55
+ status: "queued",
56
+ startedAt: Date.now(),
57
+ });
58
+ });
59
+
60
+ // Move to running
61
+ await t.run(async (ctx) => {
62
+ await ctx.db.patch(id, { status: "running" });
63
+ });
64
+
65
+ const running = await t.run(async (ctx) => {
66
+ return await ctx.db.get(id);
67
+ });
68
+ expect(running?.status).toBe("running");
69
+
70
+ // Complete
71
+ await t.run(async (ctx) => {
72
+ await ctx.db.patch(id, {
73
+ status: "completed",
74
+ completedAt: Date.now(),
75
+ recordsProcessed: 42,
76
+ });
77
+ });
78
+
79
+ const completed = await t.run(async (ctx) => {
80
+ return await ctx.db.get(id);
81
+ });
82
+ expect(completed?.status).toBe("completed");
83
+ expect(completed?.recordsProcessed).toBe(42);
84
+ expect(completed?.completedAt).toBeDefined();
85
+ });
86
+
87
+ it("records error on failed sync job", async () => {
88
+ const t = convexTest(schema, modules);
89
+ const connectionId = await seedConnection(t, { provider: "garmin" });
90
+
91
+ const id = await t.run(async (ctx) => {
92
+ return await ctx.db.insert("syncJobs", {
93
+ connectionId,
94
+ userId: "user-1",
95
+ provider: "garmin",
96
+ idempotencyKey: "job-3",
97
+ status: "running",
98
+ startedAt: Date.now(),
99
+ });
100
+ });
101
+
102
+ await t.run(async (ctx) => {
103
+ await ctx.db.patch(id, {
104
+ status: "failed",
105
+ completedAt: Date.now(),
106
+ error: "Token refresh failed (401): invalid_grant",
107
+ });
108
+ });
109
+
110
+ const failed = await t.run(async (ctx) => {
111
+ return await ctx.db.get(id);
112
+ });
113
+
114
+ expect(failed?.status).toBe("failed");
115
+ expect(failed?.error).toContain("invalid_grant");
116
+ });
117
+
118
+ it("queries sync jobs by user (most recent first)", async () => {
119
+ const t = convexTest(schema, modules);
120
+ const connectionId = await seedConnection(t);
121
+ const otherConnectionId = await seedConnection(t, {
122
+ userId: "user-2",
123
+ provider: "garmin",
124
+ });
125
+
126
+ await t.run(async (ctx) => {
127
+ await ctx.db.insert("syncJobs", {
128
+ connectionId,
129
+ userId: "user-1",
130
+ provider: "strava",
131
+ idempotencyKey: "job-4",
132
+ status: "completed",
133
+ startedAt: 1710000000000,
134
+ completedAt: 1710000060000,
135
+ });
136
+ await ctx.db.insert("syncJobs", {
137
+ connectionId,
138
+ userId: "user-1",
139
+ provider: "strava",
140
+ idempotencyKey: "job-5",
141
+ status: "completed",
142
+ startedAt: 1710100000000,
143
+ completedAt: 1710100060000,
144
+ });
145
+ await ctx.db.insert("syncJobs", {
146
+ connectionId: otherConnectionId,
147
+ userId: "user-2",
148
+ provider: "garmin",
149
+ idempotencyKey: "job-6",
150
+ status: "completed",
151
+ startedAt: 1710000000000,
152
+ });
153
+ });
154
+
155
+ const user1Jobs = await t.run(async (ctx) => {
156
+ return await ctx.db
157
+ .query("syncJobs")
158
+ .withIndex("by_user", (idx) => idx.eq("userId", "user-1"))
159
+ .order("desc")
160
+ .collect();
161
+ });
162
+
163
+ expect(user1Jobs).toHaveLength(2);
164
+ });
165
+
166
+ it("queries running jobs via status index", async () => {
167
+ const t = convexTest(schema, modules);
168
+ const connectionId = await seedConnection(t);
169
+ const otherConnectionId = await seedConnection(t, { userId: "user-2" });
170
+ const thirdConnectionId = await seedConnection(t, {
171
+ userId: "user-3",
172
+ provider: "garmin",
173
+ });
174
+
175
+ await t.run(async (ctx) => {
176
+ await ctx.db.insert("syncJobs", {
177
+ connectionId,
178
+ userId: "user-1",
179
+ provider: "strava",
180
+ idempotencyKey: "job-7",
181
+ status: "running",
182
+ startedAt: Date.now(),
183
+ });
184
+ await ctx.db.insert("syncJobs", {
185
+ connectionId: otherConnectionId,
186
+ userId: "user-2",
187
+ provider: "strava",
188
+ idempotencyKey: "job-8",
189
+ status: "completed",
190
+ startedAt: Date.now(),
191
+ });
192
+ await ctx.db.insert("syncJobs", {
193
+ connectionId: thirdConnectionId,
194
+ userId: "user-3",
195
+ provider: "garmin",
196
+ idempotencyKey: "job-9",
197
+ status: "running",
198
+ startedAt: Date.now(),
199
+ });
200
+ });
201
+
202
+ const running = await t.run(async (ctx) => {
203
+ return await ctx.db
204
+ .query("syncJobs")
205
+ .withIndex("by_status", (idx) => idx.eq("status", "running"))
206
+ .collect();
207
+ });
208
+
209
+ expect(running).toHaveLength(2);
210
+ });
211
+
212
+ it("queries by user + status via composite index", async () => {
213
+ const t = convexTest(schema, modules);
214
+ const connectionId = await seedConnection(t);
215
+
216
+ await t.run(async (ctx) => {
217
+ await ctx.db.insert("syncJobs", {
218
+ connectionId,
219
+ userId: "user-1",
220
+ provider: "strava",
221
+ idempotencyKey: "job-10",
222
+ status: "completed",
223
+ startedAt: Date.now(),
224
+ });
225
+ await ctx.db.insert("syncJobs", {
226
+ connectionId,
227
+ userId: "user-1",
228
+ provider: "strava",
229
+ idempotencyKey: "job-11",
230
+ status: "failed",
231
+ startedAt: Date.now(),
232
+ error: "timeout",
233
+ });
234
+ await ctx.db.insert("syncJobs", {
235
+ connectionId,
236
+ userId: "user-1",
237
+ provider: "strava",
238
+ idempotencyKey: "job-12",
239
+ status: "running",
240
+ startedAt: Date.now(),
241
+ });
242
+ });
243
+
244
+ const failedJobs = await t.run(async (ctx) => {
245
+ return await ctx.db
246
+ .query("syncJobs")
247
+ .withIndex("by_user_status", (idx) => idx.eq("userId", "user-1").eq("status", "failed"))
248
+ .collect();
249
+ });
250
+
251
+ expect(failedJobs).toHaveLength(1);
252
+ expect(failedJobs[0].error).toBe("timeout");
253
+ });
254
+ });
@@ -0,0 +1,140 @@
1
+ import { v } from "convex/values";
2
+ import { internalMutation, internalQuery, query } from "./_generated/server";
3
+ import { providerName, syncJobStatus } from "./schema";
4
+
5
+ const syncPhase = v.union(v.literal("events"), v.literal("dataPoints"), v.literal("summaries"));
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Queries
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export const getByUser = query({
12
+ args: {
13
+ userId: v.string(),
14
+ limit: v.optional(v.number()),
15
+ },
16
+ returns: v.array(v.any()),
17
+ handler: async (ctx, args) => {
18
+ return await ctx.db
19
+ .query("syncJobs")
20
+ .withIndex("by_user", (idx) => idx.eq("userId", args.userId))
21
+ .order("desc")
22
+ .take(args.limit ?? 10);
23
+ },
24
+ });
25
+
26
+ export const getRunning = internalQuery({
27
+ args: {},
28
+ returns: v.array(v.any()),
29
+ handler: async (ctx) => {
30
+ return await ctx.db
31
+ .query("syncJobs")
32
+ .withIndex("by_status", (idx) => idx.eq("status", "running"))
33
+ .collect();
34
+ },
35
+ });
36
+
37
+ export const getById = internalQuery({
38
+ args: {
39
+ jobId: v.id("syncJobs"),
40
+ },
41
+ returns: v.union(v.any(), v.null()),
42
+ handler: async (ctx, args) => {
43
+ return await ctx.db.get(args.jobId);
44
+ },
45
+ });
46
+
47
+ export const getActiveByIdempotencyKey = internalQuery({
48
+ args: {
49
+ idempotencyKey: v.string(),
50
+ },
51
+ returns: v.union(v.any(), v.null()),
52
+ handler: async (ctx, args) => {
53
+ const job = await ctx.db
54
+ .query("syncJobs")
55
+ .withIndex("by_idempotency_key", (idx) => idx.eq("idempotencyKey", args.idempotencyKey))
56
+ .order("desc")
57
+ .first();
58
+
59
+ if (!job) {
60
+ return null;
61
+ }
62
+
63
+ return job.status === "queued" || job.status === "running" ? job : null;
64
+ },
65
+ });
66
+
67
+ export const getByWorkflowId = internalQuery({
68
+ args: {
69
+ workflowId: v.string(),
70
+ },
71
+ returns: v.union(v.any(), v.null()),
72
+ handler: async (ctx, args) => {
73
+ return await ctx.db
74
+ .query("syncJobs")
75
+ .withIndex("by_workflow", (idx) => idx.eq("workflowId", args.workflowId))
76
+ .first();
77
+ },
78
+ });
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Mutations
82
+ // ---------------------------------------------------------------------------
83
+
84
+ export const create = internalMutation({
85
+ args: {
86
+ connectionId: v.id("connections"),
87
+ userId: v.string(),
88
+ provider: providerName,
89
+ mode: v.optional(v.union(v.literal("manual"), v.literal("cron"), v.literal("webhook"))),
90
+ triggerSource: v.optional(v.string()),
91
+ idempotencyKey: v.string(),
92
+ startedAt: v.optional(v.number()),
93
+ windowStart: v.optional(v.number()),
94
+ windowEnd: v.optional(v.number()),
95
+ },
96
+ returns: v.id("syncJobs"),
97
+ handler: async (ctx, args) => {
98
+ return await ctx.db.insert("syncJobs", {
99
+ connectionId: args.connectionId,
100
+ userId: args.userId,
101
+ provider: args.provider,
102
+ mode: args.mode,
103
+ triggerSource: args.triggerSource,
104
+ idempotencyKey: args.idempotencyKey,
105
+ status: "queued",
106
+ startedAt: args.startedAt ?? Date.now(),
107
+ windowStart: args.windowStart,
108
+ windowEnd: args.windowEnd,
109
+ attempt: 0,
110
+ });
111
+ },
112
+ });
113
+
114
+ export const updateStatus = internalMutation({
115
+ args: {
116
+ jobId: v.id("syncJobs"),
117
+ status: syncJobStatus,
118
+ error: v.optional(v.string()),
119
+ recordsProcessed: v.optional(v.number()),
120
+ workflowId: v.optional(v.string()),
121
+ attempt: v.optional(v.number()),
122
+ lastHeartbeatAt: v.optional(v.number()),
123
+ cursor: v.optional(v.string()),
124
+ currentPhase: v.optional(syncPhase),
125
+ },
126
+ handler: async (ctx, args) => {
127
+ const updates: Record<string, unknown> = { status: args.status };
128
+ if (args.error !== undefined) updates.error = args.error;
129
+ if (args.recordsProcessed !== undefined) updates.recordsProcessed = args.recordsProcessed;
130
+ if (args.workflowId !== undefined) updates.workflowId = args.workflowId;
131
+ if (args.attempt !== undefined) updates.attempt = args.attempt;
132
+ if (args.lastHeartbeatAt !== undefined) updates.lastHeartbeatAt = args.lastHeartbeatAt;
133
+ if (args.cursor !== undefined) updates.cursor = args.cursor;
134
+ if (args.currentPhase !== undefined) updates.currentPhase = args.currentPhase;
135
+ if (args.status === "completed" || args.status === "failed" || args.status === "canceled") {
136
+ updates.completedAt = Date.now();
137
+ }
138
+ await ctx.db.patch(args.jobId, updates);
139
+ },
140
+ });
@@ -0,0 +1,87 @@
1
+ import workflowTest from "@convex-dev/workflow/test";
2
+ import workpoolTest from "@convex-dev/workpool/test";
3
+ import { convexTest } from "convex-test";
4
+ import { describe, expect, it } from "vitest";
5
+ import { api, internal } from "./_generated/api";
6
+ import schema from "./schema";
7
+ import { modules } from "./test.setup";
8
+
9
+ function createWorkflowTest() {
10
+ const t = convexTest(schema, modules);
11
+ t.registerComponent("workflow", workflowTest.schema, workflowTest.modules);
12
+ workpoolTest.register(t, "workflow/workpool");
13
+ return t;
14
+ }
15
+
16
+ describe("syncWorkflow", () => {
17
+ it("reuses an active sync job with the same idempotency key", async () => {
18
+ const t = createWorkflowTest();
19
+
20
+ const connectionId = await t.run(async (ctx) => {
21
+ return await ctx.db.insert("connections", {
22
+ userId: "user-1",
23
+ provider: "garmin",
24
+ accessToken: "garmin-token",
25
+ tokenExpiresAt: Date.now() + 60_000,
26
+ status: "active",
27
+ });
28
+ });
29
+
30
+ const existingJobId = await t.run(async (ctx) => {
31
+ return await ctx.db.insert("syncJobs", {
32
+ connectionId,
33
+ userId: "user-1",
34
+ provider: "garmin",
35
+ idempotencyKey: `${connectionId}::manual::1000::2000`,
36
+ status: "queued",
37
+ startedAt: Date.now(),
38
+ });
39
+ });
40
+
41
+ const result = await t.mutation(internal.syncWorkflow.requestConnectionSync, {
42
+ connectionId,
43
+ mode: "manual",
44
+ triggerSource: "test",
45
+ windowStart: 1000,
46
+ windowEnd: 2000,
47
+ });
48
+
49
+ expect(String(result.syncJobId)).toBe(String(existingJobId));
50
+ expect(result.deduped).toBe(true);
51
+ });
52
+
53
+ it("enqueues a garmin sync on the durable workflow", async () => {
54
+ const t = createWorkflowTest();
55
+
56
+ const connectionId = await t.run(async (ctx) => {
57
+ return await ctx.db.insert("connections", {
58
+ userId: "user-1",
59
+ provider: "garmin",
60
+ accessToken: "garmin-token",
61
+ tokenExpiresAt: Date.now() + 60 * 60 * 1000,
62
+ status: "active",
63
+ });
64
+ });
65
+
66
+ await t.mutation(internal.providerSettings.upsertCredentials, {
67
+ provider: "garmin",
68
+ clientId: "garmin-client",
69
+ clientSecret: "garmin-secret",
70
+ });
71
+
72
+ const result = await t.action(api.syncWorkflow.syncConnection, {
73
+ connectionId,
74
+ provider: "garmin",
75
+ clientId: "garmin-client",
76
+ clientSecret: "garmin-secret",
77
+ });
78
+
79
+ const job = await t.query(internal.syncJobs.getById, {
80
+ jobId: result.syncJobId as never,
81
+ });
82
+
83
+ expect(job?.status).toBe("queued");
84
+ expect(job?.workflowId).toBeDefined();
85
+ expect(result.deduped).toBe(false);
86
+ });
87
+ });