@clipin/convex-wearables 0.0.2 → 0.1.0

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 (104) hide show
  1. package/README.md +395 -0
  2. package/dist/client/index.d.ts +47 -6
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +30 -0
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/client/types.d.ts +83 -0
  7. package/dist/client/types.d.ts.map +1 -1
  8. package/dist/client/types.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +50 -0
  10. package/dist/component/_generated/component.d.ts.map +1 -0
  11. package/dist/component/_generated/component.js +11 -0
  12. package/dist/component/_generated/component.js.map +1 -0
  13. package/dist/component/backfillJobs.d.ts +11 -11
  14. package/dist/component/connections.d.ts +9 -9
  15. package/dist/component/connections.d.ts.map +1 -1
  16. package/dist/component/connections.js +2 -0
  17. package/dist/component/connections.js.map +1 -1
  18. package/dist/component/dataPoints.d.ts +153 -39
  19. package/dist/component/dataPoints.d.ts.map +1 -1
  20. package/dist/component/dataPoints.js +1048 -139
  21. package/dist/component/dataPoints.js.map +1 -1
  22. package/dist/component/events.d.ts +13 -13
  23. package/dist/component/garminBackfill.d.ts +2 -2
  24. package/dist/component/garminWebhooks.d.ts +2 -2
  25. package/dist/component/garminWebhooks.d.ts.map +1 -1
  26. package/dist/component/garminWebhooks.js +2 -0
  27. package/dist/component/garminWebhooks.js.map +1 -1
  28. package/dist/component/lifecycle.d.ts +1 -1
  29. package/dist/component/lifecycle.d.ts.map +1 -1
  30. package/dist/component/lifecycle.js +39 -1
  31. package/dist/component/lifecycle.js.map +1 -1
  32. package/dist/component/oauthStates.d.ts +3 -3
  33. package/dist/component/schema.d.ts +192 -28
  34. package/dist/component/schema.d.ts.map +1 -1
  35. package/dist/component/schema.js +89 -0
  36. package/dist/component/schema.js.map +1 -1
  37. package/dist/component/sdkPush.d.ts +11 -11
  38. package/dist/component/summaries.d.ts +4 -4
  39. package/dist/component/syncJobs.d.ts +23 -23
  40. package/dist/component/syncWorkflow.d.ts +2 -2
  41. package/dist/component/timeSeriesPolicyUtils.d.ts +97 -0
  42. package/dist/component/timeSeriesPolicyUtils.d.ts.map +1 -0
  43. package/dist/component/timeSeriesPolicyUtils.js +163 -0
  44. package/dist/component/timeSeriesPolicyUtils.js.map +1 -0
  45. package/dist/test.d.ts +581 -0
  46. package/dist/test.d.ts.map +1 -0
  47. package/dist/test.js +17 -0
  48. package/dist/test.js.map +1 -0
  49. package/package.json +12 -2
  50. package/src/client/_generated/_ignore.ts +2 -0
  51. package/src/client/index.test.ts +149 -0
  52. package/src/client/index.ts +859 -0
  53. package/src/client/types.ts +632 -0
  54. package/src/component/_generated/_ignore.ts +2 -0
  55. package/src/component/_generated/api.ts +16 -0
  56. package/src/component/_generated/component.ts +74 -0
  57. package/src/component/_generated/dataModel.ts +40 -0
  58. package/src/component/_generated/server.ts +48 -0
  59. package/src/component/backfillJobs.test.ts +47 -0
  60. package/src/component/backfillJobs.ts +245 -0
  61. package/src/component/connections.test.ts +297 -0
  62. package/src/component/connections.ts +329 -0
  63. package/src/component/convex.config.ts +7 -0
  64. package/src/component/dataPoints.test.ts +827 -0
  65. package/src/component/dataPoints.ts +1676 -0
  66. package/src/component/dataSources.test.ts +247 -0
  67. package/src/component/dataSources.ts +109 -0
  68. package/src/component/events.test.ts +380 -0
  69. package/src/component/events.ts +288 -0
  70. package/src/component/garminBackfill.ts +343 -0
  71. package/src/component/garminWebhooks.test.ts +609 -0
  72. package/src/component/garminWebhooks.ts +656 -0
  73. package/src/component/httpHandlers.ts +153 -0
  74. package/src/component/lifecycle.test.ts +179 -0
  75. package/src/component/lifecycle.ts +128 -0
  76. package/src/component/menstrualCycles.ts +124 -0
  77. package/src/component/oauthActions.ts +261 -0
  78. package/src/component/oauthStates.test.ts +170 -0
  79. package/src/component/oauthStates.ts +85 -0
  80. package/src/component/providerSettings.ts +66 -0
  81. package/src/component/providers/additionalProviders.test.ts +401 -0
  82. package/src/component/providers/garmin.ts +1169 -0
  83. package/src/component/providers/oauth.test.ts +174 -0
  84. package/src/component/providers/oauth.ts +246 -0
  85. package/src/component/providers/polar.ts +220 -0
  86. package/src/component/providers/registry.ts +37 -0
  87. package/src/component/providers/strava.test.ts +195 -0
  88. package/src/component/providers/strava.ts +253 -0
  89. package/src/component/providers/suunto.ts +592 -0
  90. package/src/component/providers/types.ts +189 -0
  91. package/src/component/providers/whoop.ts +600 -0
  92. package/src/component/schema.ts +445 -0
  93. package/src/component/sdkPush.test.ts +367 -0
  94. package/src/component/sdkPush.ts +440 -0
  95. package/src/component/summaries.test.ts +201 -0
  96. package/src/component/summaries.ts +143 -0
  97. package/src/component/syncJobs.test.ts +254 -0
  98. package/src/component/syncJobs.ts +140 -0
  99. package/src/component/syncWorkflow.test.ts +87 -0
  100. package/src/component/syncWorkflow.ts +739 -0
  101. package/src/component/test.setup.ts +6 -0
  102. package/src/component/timeSeriesPolicyUtils.ts +243 -0
  103. package/src/component/workflowManager.ts +19 -0
  104. package/src/test.ts +25 -0
@@ -0,0 +1,297 @@
1
+ import { convexTest } from "convex-test";
2
+ import { describe, expect, it } from "vitest";
3
+ import { api } from "./_generated/api";
4
+ import schema from "./schema";
5
+ import { modules } from "./test.setup";
6
+
7
+ describe("connections", () => {
8
+ describe("createConnection", () => {
9
+ it("creates a new active connection", async () => {
10
+ const t = convexTest(schema, modules);
11
+
12
+ const id = await t.run(async (ctx) => {
13
+ return await ctx.db.insert("connections", {
14
+ userId: "user-1",
15
+ provider: "garmin",
16
+ accessToken: "access-token-123",
17
+ refreshToken: "refresh-token-456",
18
+ tokenExpiresAt: Date.now() + 3600000,
19
+ providerUserId: "garmin-user-789",
20
+ status: "active",
21
+ });
22
+ });
23
+
24
+ expect(id).toBeDefined();
25
+
26
+ const conn = await t.run(async (ctx) => {
27
+ return await ctx.db.get(id);
28
+ });
29
+ expect(conn).toMatchObject({
30
+ provider: "garmin",
31
+ status: "active",
32
+ providerUserId: "garmin-user-789",
33
+ });
34
+ });
35
+
36
+ it("re-activates existing connection with patch", async () => {
37
+ const t = convexTest(schema, modules);
38
+
39
+ const id = await t.run(async (ctx) => {
40
+ return await ctx.db.insert("connections", {
41
+ userId: "user-1",
42
+ provider: "strava",
43
+ accessToken: "old-token",
44
+ status: "active",
45
+ });
46
+ });
47
+
48
+ // Disconnect
49
+ await t.run(async (ctx) => {
50
+ await ctx.db.patch(id, {
51
+ status: "inactive",
52
+ accessToken: undefined,
53
+ refreshToken: undefined,
54
+ });
55
+ });
56
+
57
+ // Re-activate with new tokens
58
+ await t.run(async (ctx) => {
59
+ await ctx.db.patch(id, {
60
+ status: "active",
61
+ accessToken: "new-token",
62
+ refreshToken: "new-refresh",
63
+ });
64
+ });
65
+
66
+ const conn = await t.run(async (ctx) => {
67
+ return await ctx.db.get(id);
68
+ });
69
+ expect(conn?.status).toBe("active");
70
+ expect(conn?.accessToken).toBe("new-token");
71
+ });
72
+ });
73
+
74
+ describe("queries", () => {
75
+ it("finds connections by user via index", async () => {
76
+ const t = convexTest(schema, modules);
77
+
78
+ await t.run(async (ctx) => {
79
+ await ctx.db.insert("connections", {
80
+ userId: "user-1",
81
+ provider: "garmin",
82
+ accessToken: "t1",
83
+ status: "active",
84
+ });
85
+ await ctx.db.insert("connections", {
86
+ userId: "user-1",
87
+ provider: "strava",
88
+ accessToken: "t2",
89
+ status: "active",
90
+ });
91
+ await ctx.db.insert("connections", {
92
+ userId: "user-2",
93
+ provider: "whoop",
94
+ accessToken: "t3",
95
+ status: "active",
96
+ });
97
+ });
98
+
99
+ const user1 = await t.run(async (ctx) => {
100
+ return await ctx.db
101
+ .query("connections")
102
+ .withIndex("by_user", (idx) => idx.eq("userId", "user-1"))
103
+ .collect();
104
+ });
105
+
106
+ expect(user1).toHaveLength(2);
107
+ const providers = user1.map((c) => c.provider);
108
+ expect(providers).toContain("garmin");
109
+ expect(providers).toContain("strava");
110
+ });
111
+
112
+ it("finds connection by user + provider via index", async () => {
113
+ const t = convexTest(schema, modules);
114
+
115
+ await t.run(async (ctx) => {
116
+ await ctx.db.insert("connections", {
117
+ userId: "user-1",
118
+ provider: "garmin",
119
+ accessToken: "t1",
120
+ status: "active",
121
+ });
122
+ await ctx.db.insert("connections", {
123
+ userId: "user-1",
124
+ provider: "strava",
125
+ accessToken: "t2",
126
+ status: "active",
127
+ });
128
+ });
129
+
130
+ const conn = await t.run(async (ctx) => {
131
+ return await ctx.db
132
+ .query("connections")
133
+ .withIndex("by_user_provider", (idx) =>
134
+ idx.eq("userId", "user-1").eq("provider", "garmin"),
135
+ )
136
+ .first();
137
+ });
138
+
139
+ expect(conn).not.toBeNull();
140
+ expect(conn?.provider).toBe("garmin");
141
+ });
142
+
143
+ it("redacts token fields from getByUserProvider", async () => {
144
+ const t = convexTest(schema, modules);
145
+
146
+ await t.run(async (ctx) => {
147
+ await ctx.db.insert("connections", {
148
+ userId: "user-1",
149
+ provider: "garmin",
150
+ providerUserId: "garmin-user",
151
+ accessToken: "secret-access",
152
+ refreshToken: "secret-refresh",
153
+ tokenExpiresAt: Date.now() + 60_000,
154
+ status: "active",
155
+ });
156
+ });
157
+
158
+ const conn = await t.query(api.connections.getByUserProvider, {
159
+ userId: "user-1",
160
+ provider: "garmin",
161
+ });
162
+
163
+ expect(conn).toMatchObject({
164
+ userId: "user-1",
165
+ provider: "garmin",
166
+ providerUserId: "garmin-user",
167
+ status: "active",
168
+ });
169
+ expect(conn).not.toHaveProperty("accessToken");
170
+ expect(conn).not.toHaveProperty("refreshToken");
171
+ expect(conn).not.toHaveProperty("tokenExpiresAt");
172
+ });
173
+
174
+ it("finds active connections via index", async () => {
175
+ const t = convexTest(schema, modules);
176
+
177
+ await t.run(async (ctx) => {
178
+ await ctx.db.insert("connections", {
179
+ userId: "user-1",
180
+ provider: "garmin",
181
+ accessToken: "t1",
182
+ status: "active",
183
+ });
184
+ await ctx.db.insert("connections", {
185
+ userId: "user-2",
186
+ provider: "strava",
187
+ accessToken: "t2",
188
+ status: "inactive",
189
+ });
190
+ });
191
+
192
+ const active = await t.run(async (ctx) => {
193
+ return await ctx.db
194
+ .query("connections")
195
+ .withIndex("by_status", (idx) => idx.eq("status", "active"))
196
+ .collect();
197
+ });
198
+
199
+ expect(active).toHaveLength(1);
200
+ expect(active[0].userId).toBe("user-1");
201
+ });
202
+
203
+ it("returns the latest sync job per provider in getSyncStatus", async () => {
204
+ const t = convexTest(schema, modules);
205
+
206
+ await t.run(async (ctx) => {
207
+ const garminConnectionId = await ctx.db.insert("connections", {
208
+ userId: "user-1",
209
+ provider: "garmin",
210
+ accessToken: "garmin-token",
211
+ status: "active",
212
+ lastSyncedAt: 1_000,
213
+ });
214
+ const stravaConnectionId = await ctx.db.insert("connections", {
215
+ userId: "user-1",
216
+ provider: "strava",
217
+ accessToken: "strava-token",
218
+ status: "active",
219
+ lastSyncedAt: 2_000,
220
+ });
221
+
222
+ await ctx.db.insert("syncJobs", {
223
+ connectionId: garminConnectionId,
224
+ userId: "user-1",
225
+ provider: "garmin",
226
+ idempotencyKey: "garmin-1",
227
+ status: "completed",
228
+ startedAt: 100,
229
+ });
230
+ await ctx.db.insert("syncJobs", {
231
+ connectionId: stravaConnectionId,
232
+ userId: "user-1",
233
+ provider: "strava",
234
+ idempotencyKey: "strava-1",
235
+ status: "failed",
236
+ startedAt: 200,
237
+ error: "rate limited",
238
+ });
239
+ });
240
+
241
+ const statuses = await t.query(api.connections.getSyncStatus, {
242
+ userId: "user-1",
243
+ });
244
+
245
+ expect(statuses).toEqual(
246
+ expect.arrayContaining([
247
+ {
248
+ provider: "garmin",
249
+ connectionStatus: "active",
250
+ lastSyncedAt: 1_000,
251
+ syncJobStatus: "completed",
252
+ syncJobError: null,
253
+ },
254
+ {
255
+ provider: "strava",
256
+ connectionStatus: "active",
257
+ lastSyncedAt: 2_000,
258
+ syncJobStatus: "failed",
259
+ syncJobError: "rate limited",
260
+ },
261
+ ]),
262
+ );
263
+ });
264
+ });
265
+
266
+ describe("disconnect", () => {
267
+ it("sets status to inactive and clears tokens", async () => {
268
+ const t = convexTest(schema, modules);
269
+
270
+ const id = await t.run(async (ctx) => {
271
+ return await ctx.db.insert("connections", {
272
+ userId: "user-1",
273
+ provider: "whoop",
274
+ accessToken: "secret",
275
+ refreshToken: "secret-r",
276
+ status: "active",
277
+ });
278
+ });
279
+
280
+ await t.run(async (ctx) => {
281
+ await ctx.db.patch(id, {
282
+ status: "inactive",
283
+ accessToken: undefined,
284
+ refreshToken: undefined,
285
+ tokenExpiresAt: undefined,
286
+ });
287
+ });
288
+
289
+ const conn = await t.run(async (ctx) => {
290
+ return await ctx.db.get(id);
291
+ });
292
+ expect(conn?.status).toBe("inactive");
293
+ expect(conn?.accessToken).toBeUndefined();
294
+ expect(conn?.refreshToken).toBeUndefined();
295
+ });
296
+ });
297
+ });
@@ -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;