@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,48 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * Generated utilities for implementing server-side Convex query and mutation functions.
4
+ *
5
+ * THIS CODE IS AUTOMATICALLY GENERATED.
6
+ *
7
+ * To regenerate, run `npx convex dev`.
8
+ * @module
9
+ */
10
+
11
+ import type {
12
+ ActionBuilder,
13
+ HttpActionBuilder,
14
+ MutationBuilder,
15
+ QueryBuilder,
16
+ GenericActionCtx,
17
+ GenericMutationCtx,
18
+ GenericQueryCtx,
19
+ GenericDatabaseReader,
20
+ GenericDatabaseWriter,
21
+ } from "convex/server";
22
+ import {
23
+ actionGeneric,
24
+ httpActionGeneric,
25
+ queryGeneric,
26
+ mutationGeneric,
27
+ internalActionGeneric,
28
+ internalMutationGeneric,
29
+ internalQueryGeneric,
30
+ } from "convex/server";
31
+ import type { DataModel } from "./dataModel.js";
32
+
33
+ export const query: QueryBuilder<DataModel, "public"> = queryGeneric;
34
+ export const internalQuery: QueryBuilder<DataModel, "internal"> =
35
+ internalQueryGeneric;
36
+ export const mutation: MutationBuilder<DataModel, "public"> = mutationGeneric;
37
+ export const internalMutation: MutationBuilder<DataModel, "internal"> =
38
+ internalMutationGeneric;
39
+ export const action: ActionBuilder<DataModel, "public"> = actionGeneric;
40
+ export const internalAction: ActionBuilder<DataModel, "internal"> =
41
+ internalActionGeneric;
42
+ export const httpAction: HttpActionBuilder = httpActionGeneric;
43
+
44
+ export type QueryCtx = GenericQueryCtx<DataModel>;
45
+ export type MutationCtx = GenericMutationCtx<DataModel>;
46
+ export type ActionCtx = GenericActionCtx<DataModel>;
47
+ export type DatabaseReader = GenericDatabaseReader<DataModel>;
48
+ export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
@@ -0,0 +1,47 @@
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("backfillJobs", () => {
8
+ it("returns the latest backfill job for a connection", async () => {
9
+ const t = convexTest(schema, modules);
10
+
11
+ const connectionId = await t.run(async (ctx) => {
12
+ return await ctx.db.insert("connections", {
13
+ userId: "user-1",
14
+ provider: "garmin",
15
+ accessToken: "token",
16
+ status: "active",
17
+ });
18
+ });
19
+
20
+ await t.run(async (ctx) => {
21
+ await ctx.db.insert("backfillJobs", {
22
+ connectionId,
23
+ userId: "user-1",
24
+ provider: "garmin",
25
+ dataType: "full",
26
+ status: "failed",
27
+ startedAt: 1,
28
+ completedAt: 2,
29
+ });
30
+ await ctx.db.insert("backfillJobs", {
31
+ connectionId,
32
+ userId: "user-1",
33
+ provider: "garmin",
34
+ dataType: "full",
35
+ status: "running",
36
+ startedAt: 3,
37
+ });
38
+ });
39
+
40
+ const job = await t.query(api.backfillJobs.getLatestByConnection, {
41
+ connectionId,
42
+ });
43
+
44
+ expect(job?.status).toBe("running");
45
+ expect(job?.startedAt).toBe(3);
46
+ });
47
+ });
@@ -0,0 +1,245 @@
1
+ import { v } from "convex/values";
2
+ import { internal } from "./_generated/api";
3
+ import { internalMutation, internalQuery, query } from "./_generated/server";
4
+ import { backfillStatus, providerName } from "./schema";
5
+ import { durableWorkflow } from "./workflowManager";
6
+
7
+ export const backfillSignal = v.object({
8
+ kind: v.union(v.literal("webhook"), v.literal("timeout")),
9
+ dataType: v.string(),
10
+ itemCount: v.optional(v.number()),
11
+ });
12
+
13
+ export const getActiveByConnection = internalQuery({
14
+ args: {
15
+ connectionId: v.id("connections"),
16
+ },
17
+ returns: v.union(v.any(), v.null()),
18
+ handler: async (ctx, args) => {
19
+ const jobs = await ctx.db
20
+ .query("backfillJobs")
21
+ .withIndex("by_connection", (idx) => idx.eq("connectionId", args.connectionId))
22
+ .order("desc")
23
+ .take(10);
24
+
25
+ return jobs.find((job) => job.status === "queued" || job.status === "running") ?? null;
26
+ },
27
+ });
28
+
29
+ export const getById = internalQuery({
30
+ args: {
31
+ backfillJobId: v.id("backfillJobs"),
32
+ },
33
+ returns: v.union(v.any(), v.null()),
34
+ handler: async (ctx, args) => {
35
+ return await ctx.db.get(args.backfillJobId);
36
+ },
37
+ });
38
+
39
+ export const getLatestByConnection = query({
40
+ args: {
41
+ connectionId: v.id("connections"),
42
+ },
43
+ returns: v.union(v.any(), v.null()),
44
+ handler: async (ctx, args) => {
45
+ return await ctx.db
46
+ .query("backfillJobs")
47
+ .withIndex("by_connection", (idx) => idx.eq("connectionId", args.connectionId))
48
+ .order("desc")
49
+ .first();
50
+ },
51
+ });
52
+
53
+ export const create = internalMutation({
54
+ args: {
55
+ connectionId: v.id("connections"),
56
+ userId: v.string(),
57
+ provider: providerName,
58
+ dataType: v.string(),
59
+ windowStart: v.number(),
60
+ windowEnd: v.number(),
61
+ },
62
+ returns: v.id("backfillJobs"),
63
+ handler: async (ctx, args) => {
64
+ return await ctx.db.insert("backfillJobs", {
65
+ connectionId: args.connectionId,
66
+ userId: args.userId,
67
+ provider: args.provider,
68
+ dataType: args.dataType,
69
+ status: "queued",
70
+ startedAt: Date.now(),
71
+ windowStart: args.windowStart,
72
+ windowEnd: args.windowEnd,
73
+ completedDataTypes: [],
74
+ currentAttempt: 0,
75
+ });
76
+ },
77
+ });
78
+
79
+ export const updateStatus = internalMutation({
80
+ args: {
81
+ backfillJobId: v.id("backfillJobs"),
82
+ status: backfillStatus,
83
+ workflowId: v.optional(v.string()),
84
+ error: v.optional(v.string()),
85
+ currentDataType: v.optional(v.string()),
86
+ currentAttempt: v.optional(v.number()),
87
+ currentEventId: v.optional(v.string()),
88
+ completedDataTypes: v.optional(v.array(v.string())),
89
+ lastHeartbeatAt: v.optional(v.number()),
90
+ },
91
+ handler: async (ctx, args) => {
92
+ const updates: Record<string, unknown> = {
93
+ status: args.status,
94
+ };
95
+ if (args.workflowId !== undefined) updates.workflowId = args.workflowId;
96
+ if (args.error !== undefined) updates.error = args.error;
97
+ if (args.currentDataType !== undefined) updates.currentDataType = args.currentDataType;
98
+ if (args.currentAttempt !== undefined) updates.currentAttempt = args.currentAttempt;
99
+ if (args.currentEventId !== undefined) updates.currentEventId = args.currentEventId;
100
+ if (args.completedDataTypes !== undefined) updates.completedDataTypes = args.completedDataTypes;
101
+ if (args.lastHeartbeatAt !== undefined) updates.lastHeartbeatAt = args.lastHeartbeatAt;
102
+ if (args.status === "completed" || args.status === "failed" || args.status === "canceled") {
103
+ updates.completedAt = Date.now();
104
+ updates.currentEventId = undefined;
105
+ }
106
+ await ctx.db.patch(args.backfillJobId, updates);
107
+ },
108
+ });
109
+
110
+ export const beginAwaitingType = internalMutation({
111
+ args: {
112
+ backfillJobId: v.id("backfillJobs"),
113
+ workflowId: v.string(),
114
+ dataType: v.string(),
115
+ attempt: v.number(),
116
+ },
117
+ returns: v.string(),
118
+ handler: async (ctx, args) => {
119
+ const eventId = await durableWorkflow.createEvent(ctx, {
120
+ workflowId: args.workflowId as never,
121
+ name: `garmin-backfill:${args.dataType}:${args.attempt}`,
122
+ });
123
+
124
+ await ctx.db.patch(args.backfillJobId, {
125
+ status: "running",
126
+ currentDataType: args.dataType,
127
+ currentAttempt: args.attempt,
128
+ currentEventId: eventId,
129
+ lastHeartbeatAt: Date.now(),
130
+ });
131
+
132
+ return eventId;
133
+ },
134
+ });
135
+
136
+ export const markTypeCompleted = internalMutation({
137
+ args: {
138
+ backfillJobId: v.id("backfillJobs"),
139
+ dataType: v.string(),
140
+ },
141
+ handler: async (ctx, args) => {
142
+ const job = await ctx.db.get(args.backfillJobId);
143
+ if (!job) return;
144
+
145
+ const completed = new Set(job.completedDataTypes ?? []);
146
+ completed.add(args.dataType);
147
+
148
+ await ctx.db.patch(args.backfillJobId, {
149
+ completedDataTypes: Array.from(completed),
150
+ currentDataType: undefined,
151
+ currentAttempt: undefined,
152
+ currentEventId: undefined,
153
+ lastHeartbeatAt: Date.now(),
154
+ });
155
+ },
156
+ });
157
+
158
+ export const scheduleTimeout = internalMutation({
159
+ args: {
160
+ backfillJobId: v.id("backfillJobs"),
161
+ eventId: v.string(),
162
+ dataType: v.string(),
163
+ delayMs: v.number(),
164
+ },
165
+ handler: async (ctx, args) => {
166
+ await ctx.scheduler.runAfter(args.delayMs, internal.backfillJobs.emitTimeout, {
167
+ backfillJobId: args.backfillJobId,
168
+ eventId: args.eventId,
169
+ dataType: args.dataType,
170
+ });
171
+ },
172
+ });
173
+
174
+ export const emitTimeout = internalMutation({
175
+ args: {
176
+ backfillJobId: v.id("backfillJobs"),
177
+ eventId: v.string(),
178
+ dataType: v.string(),
179
+ },
180
+ handler: async (ctx, args) => {
181
+ const job = await ctx.db.get(args.backfillJobId);
182
+ if (
183
+ !job ||
184
+ job.status !== "running" ||
185
+ job.currentEventId !== args.eventId ||
186
+ job.currentDataType !== args.dataType
187
+ ) {
188
+ return;
189
+ }
190
+
191
+ try {
192
+ await durableWorkflow.sendEvent(ctx, {
193
+ id: args.eventId as never,
194
+ value: {
195
+ kind: "timeout",
196
+ dataType: args.dataType,
197
+ },
198
+ });
199
+ } catch {
200
+ // Timeout events are best-effort. If the workflow already moved on,
201
+ // duplicate or stale sends should not fail the scheduler.
202
+ }
203
+ },
204
+ });
205
+
206
+ export const signalWebhookData = internalMutation({
207
+ args: {
208
+ connectionId: v.id("connections"),
209
+ dataType: v.string(),
210
+ itemCount: v.optional(v.number()),
211
+ },
212
+ handler: async (ctx, args) => {
213
+ const activeJob = await ctx.db
214
+ .query("backfillJobs")
215
+ .withIndex("by_connection", (idx) => idx.eq("connectionId", args.connectionId))
216
+ .order("desc")
217
+ .first();
218
+
219
+ if (
220
+ !activeJob ||
221
+ activeJob.status !== "running" ||
222
+ activeJob.currentDataType !== args.dataType ||
223
+ !activeJob.currentEventId
224
+ ) {
225
+ return;
226
+ }
227
+
228
+ try {
229
+ await durableWorkflow.sendEvent(ctx, {
230
+ id: activeJob.currentEventId as never,
231
+ value: {
232
+ kind: "webhook",
233
+ dataType: args.dataType,
234
+ itemCount: args.itemCount,
235
+ },
236
+ });
237
+ await ctx.db.patch(activeJob._id, {
238
+ currentEventId: undefined,
239
+ lastHeartbeatAt: Date.now(),
240
+ });
241
+ } catch {
242
+ // Duplicate Garmin webhook pushes should not fail ingestion.
243
+ }
244
+ },
245
+ });
@@ -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
+ });