@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,380 @@
1
+ import { convexTest } from "convex-test";
2
+ import { describe, expect, it } from "vitest";
3
+ import { internal } from "./_generated/api";
4
+ import schema from "./schema";
5
+ import { modules } from "./test.setup";
6
+
7
+ async function seedDataSource(
8
+ t: ReturnType<typeof convexTest>,
9
+ userId = "user-1",
10
+ provider: "garmin" | "strava" = "garmin",
11
+ ) {
12
+ return await t.run(async (ctx) => {
13
+ return await ctx.db.insert("dataSources", {
14
+ userId,
15
+ provider,
16
+ deviceModel: "Forerunner 965",
17
+ source: "garmin-api",
18
+ });
19
+ });
20
+ }
21
+
22
+ describe("events", () => {
23
+ describe("storeEvent", () => {
24
+ it("creates a new workout event", async () => {
25
+ const t = convexTest(schema, modules);
26
+ const dsId = await seedDataSource(t);
27
+
28
+ const eventId = await t.run(async (ctx) => {
29
+ return await ctx.db.insert("events", {
30
+ dataSourceId: dsId,
31
+ userId: "user-1",
32
+ category: "workout",
33
+ type: "running",
34
+ startDatetime: 1710000000000,
35
+ endDatetime: 1710003600000,
36
+ durationSeconds: 3600,
37
+ distance: 10000,
38
+ heartRateAvg: 145,
39
+ energyBurned: 750,
40
+ });
41
+ });
42
+
43
+ expect(eventId).toBeDefined();
44
+
45
+ const event = await t.run(async (ctx) => {
46
+ return await ctx.db.get(eventId);
47
+ });
48
+ expect(event).toMatchObject({
49
+ category: "workout",
50
+ type: "running",
51
+ distance: 10000,
52
+ heartRateAvg: 145,
53
+ });
54
+ });
55
+
56
+ it("creates a sleep event with stages", async () => {
57
+ const t = convexTest(schema, modules);
58
+ const dsId = await seedDataSource(t);
59
+
60
+ const eventId = await t.run(async (ctx) => {
61
+ return await ctx.db.insert("events", {
62
+ dataSourceId: dsId,
63
+ userId: "user-1",
64
+ category: "sleep",
65
+ type: "night_sleep",
66
+ startDatetime: 1710010000000,
67
+ endDatetime: 1710040000000,
68
+ sleepTotalDurationMinutes: 480,
69
+ sleepDeepMinutes: 90,
70
+ sleepRemMinutes: 120,
71
+ sleepLightMinutes: 200,
72
+ sleepAwakeMinutes: 70,
73
+ sleepEfficiencyScore: 85.5,
74
+ sleepStages: [
75
+ { stage: "light", startTime: 1710010000000, endTime: 1710015000000 },
76
+ { stage: "deep", startTime: 1710015000000, endTime: 1710020000000 },
77
+ { stage: "rem", startTime: 1710020000000, endTime: 1710025000000 },
78
+ ],
79
+ });
80
+ });
81
+
82
+ const event = await t.run(async (ctx) => {
83
+ return await ctx.db.get(eventId);
84
+ });
85
+ expect(event?.sleepStages).toHaveLength(3);
86
+ expect(event?.sleepDeepMinutes).toBe(90);
87
+ });
88
+
89
+ it("deduplicates by externalId via index lookup", async () => {
90
+ const t = convexTest(schema, modules);
91
+ const dsId = await seedDataSource(t);
92
+
93
+ // Insert first
94
+ const id1 = await t.run(async (ctx) => {
95
+ return await ctx.db.insert("events", {
96
+ dataSourceId: dsId,
97
+ userId: "user-1",
98
+ category: "workout",
99
+ type: "running",
100
+ startDatetime: 1710000000000,
101
+ externalId: "strava-123",
102
+ distance: 5000,
103
+ });
104
+ });
105
+
106
+ // "Upsert" — find by externalId, patch
107
+ await t.run(async (ctx) => {
108
+ const existing = await ctx.db
109
+ .query("events")
110
+ .withIndex("by_external_id", (idx) => idx.eq("externalId", "strava-123"))
111
+ .first();
112
+ if (existing) {
113
+ await ctx.db.patch(existing._id, { distance: 5500 });
114
+ }
115
+ });
116
+
117
+ const event = await t.run(async (ctx) => {
118
+ return await ctx.db.get(id1);
119
+ });
120
+ expect(event?.distance).toBe(5500);
121
+ });
122
+
123
+ it("deduplicates batch writes by source + start + end when externalId is missing", async () => {
124
+ const t = convexTest(schema, modules);
125
+ const dsId = await seedDataSource(t);
126
+
127
+ await t.run(async (ctx) => {
128
+ await ctx.db.insert("events", {
129
+ dataSourceId: dsId,
130
+ userId: "user-1",
131
+ category: "workout",
132
+ type: "running",
133
+ startDatetime: 1710000000000,
134
+ endDatetime: 1710003600000,
135
+ distance: 5000,
136
+ });
137
+ });
138
+
139
+ await t.mutation(internal.events.storeEventBatch, {
140
+ events: [
141
+ {
142
+ dataSourceId: dsId,
143
+ userId: "user-1",
144
+ category: "workout",
145
+ type: "running",
146
+ startDatetime: 1710000000000,
147
+ endDatetime: 1710003600000,
148
+ distance: 5500,
149
+ },
150
+ ],
151
+ });
152
+
153
+ const events = await t.run(async (ctx) => {
154
+ return await ctx.db
155
+ .query("events")
156
+ .withIndex("by_user_category_time", (idx) =>
157
+ idx.eq("userId", "user-1").eq("category", "workout"),
158
+ )
159
+ .collect();
160
+ });
161
+
162
+ expect(events).toHaveLength(1);
163
+ expect(events[0].distance).toBe(5500);
164
+ });
165
+ });
166
+
167
+ describe("getEvents", () => {
168
+ it("returns events filtered by category via index", async () => {
169
+ const t = convexTest(schema, modules);
170
+ const dsId = await seedDataSource(t);
171
+
172
+ await t.run(async (ctx) => {
173
+ await ctx.db.insert("events", {
174
+ dataSourceId: dsId,
175
+ userId: "user-1",
176
+ category: "workout",
177
+ type: "running",
178
+ startDatetime: 1710000000000,
179
+ });
180
+ await ctx.db.insert("events", {
181
+ dataSourceId: dsId,
182
+ userId: "user-1",
183
+ category: "workout",
184
+ type: "cycling",
185
+ startDatetime: 1710100000000,
186
+ });
187
+ await ctx.db.insert("events", {
188
+ dataSourceId: dsId,
189
+ userId: "user-1",
190
+ category: "sleep",
191
+ type: "night_sleep",
192
+ startDatetime: 1710050000000,
193
+ });
194
+ });
195
+
196
+ const workouts = await t.run(async (ctx) => {
197
+ return await ctx.db
198
+ .query("events")
199
+ .withIndex("by_user_category_time", (idx) =>
200
+ idx.eq("userId", "user-1").eq("category", "workout"),
201
+ )
202
+ .collect();
203
+ });
204
+
205
+ expect(workouts).toHaveLength(2);
206
+ });
207
+
208
+ it("filters by date range", async () => {
209
+ const t = convexTest(schema, modules);
210
+ const dsId = await seedDataSource(t);
211
+
212
+ await t.run(async (ctx) => {
213
+ await ctx.db.insert("events", {
214
+ dataSourceId: dsId,
215
+ userId: "user-1",
216
+ category: "workout",
217
+ type: "running",
218
+ startDatetime: 1710000000000,
219
+ });
220
+ await ctx.db.insert("events", {
221
+ dataSourceId: dsId,
222
+ userId: "user-1",
223
+ category: "workout",
224
+ type: "cycling",
225
+ startDatetime: 1710100000000,
226
+ });
227
+ await ctx.db.insert("events", {
228
+ dataSourceId: dsId,
229
+ userId: "user-1",
230
+ category: "workout",
231
+ type: "swimming",
232
+ startDatetime: 1710200000000,
233
+ });
234
+ });
235
+
236
+ const filtered = await t.run(async (ctx) => {
237
+ return await ctx.db
238
+ .query("events")
239
+ .withIndex("by_user_category_time", (idx) =>
240
+ idx
241
+ .eq("userId", "user-1")
242
+ .eq("category", "workout")
243
+ .gte("startDatetime", 1710050000000)
244
+ .lte("startDatetime", 1710150000000),
245
+ )
246
+ .collect();
247
+ });
248
+
249
+ expect(filtered).toHaveLength(1);
250
+ expect(filtered[0].type).toBe("cycling");
251
+ });
252
+
253
+ it("paginates results with take()", async () => {
254
+ const t = convexTest(schema, modules);
255
+ const dsId = await seedDataSource(t);
256
+
257
+ await t.run(async (ctx) => {
258
+ for (let i = 0; i < 5; i++) {
259
+ await ctx.db.insert("events", {
260
+ dataSourceId: dsId,
261
+ userId: "user-1",
262
+ category: "workout",
263
+ type: `run-${i}`,
264
+ startDatetime: 1710000000000 + i * 100000,
265
+ });
266
+ }
267
+ });
268
+
269
+ const page1 = await t.run(async (ctx) => {
270
+ return await ctx.db
271
+ .query("events")
272
+ .withIndex("by_user_category_time", (idx) =>
273
+ idx.eq("userId", "user-1").eq("category", "workout"),
274
+ )
275
+ .order("desc")
276
+ .take(3);
277
+ });
278
+
279
+ expect(page1).toHaveLength(3);
280
+ // Most recent first
281
+ expect(page1[0].type).toBe("run-4");
282
+ expect(page1[2].type).toBe("run-2");
283
+
284
+ // Next page: events before the last one in page1
285
+ const page2 = await t.run(async (ctx) => {
286
+ return await ctx.db
287
+ .query("events")
288
+ .withIndex("by_user_category_time", (idx) =>
289
+ idx
290
+ .eq("userId", "user-1")
291
+ .eq("category", "workout")
292
+ .lt("startDatetime", page1[2].startDatetime),
293
+ )
294
+ .order("desc")
295
+ .take(3);
296
+ });
297
+
298
+ expect(page2).toHaveLength(2);
299
+ expect(page2[0].type).toBe("run-1");
300
+ expect(page2[1].type).toBe("run-0");
301
+ });
302
+
303
+ it("isolates data between users", async () => {
304
+ const t = convexTest(schema, modules);
305
+ const ds1 = await seedDataSource(t, "user-1");
306
+ const ds2 = await seedDataSource(t, "user-2", "strava");
307
+
308
+ await t.run(async (ctx) => {
309
+ await ctx.db.insert("events", {
310
+ dataSourceId: ds1,
311
+ userId: "user-1",
312
+ category: "workout",
313
+ type: "running",
314
+ startDatetime: 1710000000000,
315
+ });
316
+ await ctx.db.insert("events", {
317
+ dataSourceId: ds2,
318
+ userId: "user-2",
319
+ category: "workout",
320
+ type: "cycling",
321
+ startDatetime: 1710000000000,
322
+ });
323
+ });
324
+
325
+ const user1 = await t.run(async (ctx) => {
326
+ return await ctx.db
327
+ .query("events")
328
+ .withIndex("by_user_category_time", (idx) =>
329
+ idx.eq("userId", "user-1").eq("category", "workout"),
330
+ )
331
+ .collect();
332
+ });
333
+ const user2 = await t.run(async (ctx) => {
334
+ return await ctx.db
335
+ .query("events")
336
+ .withIndex("by_user_category_time", (idx) =>
337
+ idx.eq("userId", "user-2").eq("category", "workout"),
338
+ )
339
+ .collect();
340
+ });
341
+
342
+ expect(user1).toHaveLength(1);
343
+ expect(user1[0].type).toBe("running");
344
+ expect(user2).toHaveLength(1);
345
+ expect(user2[0].type).toBe("cycling");
346
+ });
347
+
348
+ it("deduplicates by source + start + end via index", async () => {
349
+ const t = convexTest(schema, modules);
350
+ const dsId = await seedDataSource(t);
351
+
352
+ await t.run(async (ctx) => {
353
+ await ctx.db.insert("events", {
354
+ dataSourceId: dsId,
355
+ userId: "user-1",
356
+ category: "workout",
357
+ type: "cycling",
358
+ startDatetime: 1710000000000,
359
+ endDatetime: 1710003600000,
360
+ });
361
+ });
362
+
363
+ // Check if duplicate exists before inserting
364
+ const exists = await t.run(async (ctx) => {
365
+ return await ctx.db
366
+ .query("events")
367
+ .withIndex("by_source_start_end", (idx) =>
368
+ idx
369
+ .eq("dataSourceId", dsId)
370
+ .eq("startDatetime", 1710000000000)
371
+ .eq("endDatetime", 1710003600000),
372
+ )
373
+ .first();
374
+ });
375
+
376
+ expect(exists).not.toBeNull();
377
+ expect(exists?.type).toBe("cycling");
378
+ });
379
+ });
380
+ });
@@ -0,0 +1,288 @@
1
+ import { v } from "convex/values";
2
+ import type { Id } from "./_generated/dataModel";
3
+ import { internalMutation, internalQuery, query } from "./_generated/server";
4
+ import { eventCategory } from "./schema";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Queries
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /**
11
+ * Get events (workouts or sleep) for a user with cursor-based pagination.
12
+ */
13
+ export const getEvents = query({
14
+ args: {
15
+ userId: v.string(),
16
+ category: eventCategory,
17
+ startDate: v.optional(v.number()),
18
+ endDate: v.optional(v.number()),
19
+ limit: v.optional(v.number()),
20
+ cursor: v.optional(v.string()),
21
+ },
22
+ returns: v.object({
23
+ events: v.array(v.any()),
24
+ nextCursor: v.union(v.string(), v.null()),
25
+ hasMore: v.boolean(),
26
+ }),
27
+ handler: async (ctx, args) => {
28
+ const limit = Math.min(args.limit ?? 20, 100);
29
+
30
+ const buildQuery = () => {
31
+ if (args.cursor) {
32
+ const cursorTime = Number(args.cursor);
33
+ return ctx.db
34
+ .query("events")
35
+ .withIndex("by_user_category_time", (idx) =>
36
+ idx
37
+ .eq("userId", args.userId)
38
+ .eq("category", args.category)
39
+ .lt("startDatetime", cursorTime),
40
+ )
41
+ .order("desc");
42
+ }
43
+
44
+ if (args.startDate !== undefined && args.endDate !== undefined) {
45
+ const { startDate, endDate } = args;
46
+ return ctx.db
47
+ .query("events")
48
+ .withIndex("by_user_category_time", (idx) =>
49
+ idx
50
+ .eq("userId", args.userId)
51
+ .eq("category", args.category)
52
+ .gte("startDatetime", startDate)
53
+ .lte("startDatetime", endDate),
54
+ )
55
+ .order("desc");
56
+ }
57
+ if (args.startDate !== undefined) {
58
+ const { startDate } = args;
59
+ return ctx.db
60
+ .query("events")
61
+ .withIndex("by_user_category_time", (idx) =>
62
+ idx
63
+ .eq("userId", args.userId)
64
+ .eq("category", args.category)
65
+ .gte("startDatetime", startDate),
66
+ )
67
+ .order("desc");
68
+ }
69
+ if (args.endDate !== undefined) {
70
+ const { endDate } = args;
71
+ return ctx.db
72
+ .query("events")
73
+ .withIndex("by_user_category_time", (idx) =>
74
+ idx
75
+ .eq("userId", args.userId)
76
+ .eq("category", args.category)
77
+ .lte("startDatetime", endDate),
78
+ )
79
+ .order("desc");
80
+ }
81
+ return ctx.db
82
+ .query("events")
83
+ .withIndex("by_user_category_time", (idx) =>
84
+ idx.eq("userId", args.userId).eq("category", args.category),
85
+ )
86
+ .order("desc");
87
+ };
88
+
89
+ const q = buildQuery();
90
+
91
+ const results = await q.take(limit + 1);
92
+ const hasMore = results.length > limit;
93
+ const events = hasMore ? results.slice(0, limit) : results;
94
+ const nextCursor =
95
+ hasMore && events.length > 0 ? String(events[events.length - 1].startDatetime) : null;
96
+
97
+ return { events, nextCursor, hasMore };
98
+ },
99
+ });
100
+
101
+ /**
102
+ * Get a single event by its ID.
103
+ */
104
+ export const getEvent = query({
105
+ args: { eventId: v.id("events") },
106
+ returns: v.any(),
107
+ handler: async (ctx, args) => {
108
+ return await ctx.db.get(args.eventId);
109
+ },
110
+ });
111
+
112
+ /**
113
+ * Get an event by its external ID (for deduplication checks).
114
+ */
115
+ export const getByExternalId = internalQuery({
116
+ args: { externalId: v.string() },
117
+ returns: v.any(),
118
+ handler: async (ctx, args) => {
119
+ return await ctx.db
120
+ .query("events")
121
+ .withIndex("by_external_id", (idx) => idx.eq("externalId", args.externalId))
122
+ .first();
123
+ },
124
+ });
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Mutations
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Store a single event (workout or sleep). Deduplicates by externalId if provided.
132
+ */
133
+ export const storeEvent = internalMutation({
134
+ args: {
135
+ dataSourceId: v.id("dataSources"),
136
+ userId: v.string(),
137
+ category: eventCategory,
138
+ type: v.optional(v.string()),
139
+ sourceName: v.optional(v.string()),
140
+ durationSeconds: v.optional(v.number()),
141
+ startDatetime: v.number(),
142
+ endDatetime: v.optional(v.number()),
143
+ externalId: v.optional(v.string()),
144
+ // Workout fields
145
+ heartRateMin: v.optional(v.number()),
146
+ heartRateMax: v.optional(v.number()),
147
+ heartRateAvg: v.optional(v.number()),
148
+ energyBurned: v.optional(v.number()),
149
+ distance: v.optional(v.number()),
150
+ stepsCount: v.optional(v.number()),
151
+ maxSpeed: v.optional(v.number()),
152
+ maxWatts: v.optional(v.number()),
153
+ movingTimeSeconds: v.optional(v.number()),
154
+ totalElevationGain: v.optional(v.number()),
155
+ averageSpeed: v.optional(v.number()),
156
+ averageWatts: v.optional(v.number()),
157
+ elevHigh: v.optional(v.number()),
158
+ elevLow: v.optional(v.number()),
159
+ // Sleep fields
160
+ sleepTotalDurationMinutes: v.optional(v.number()),
161
+ sleepTimeInBedMinutes: v.optional(v.number()),
162
+ sleepEfficiencyScore: v.optional(v.number()),
163
+ sleepDeepMinutes: v.optional(v.number()),
164
+ sleepRemMinutes: v.optional(v.number()),
165
+ sleepLightMinutes: v.optional(v.number()),
166
+ sleepAwakeMinutes: v.optional(v.number()),
167
+ isNap: v.optional(v.boolean()),
168
+ sleepStages: v.optional(
169
+ v.array(
170
+ v.object({
171
+ stage: v.string(),
172
+ startTime: v.number(),
173
+ endTime: v.number(),
174
+ }),
175
+ ),
176
+ ),
177
+ },
178
+ returns: v.id("events"),
179
+ handler: async (ctx, args) => {
180
+ // Deduplicate by externalId
181
+ if (args.externalId) {
182
+ const existing = await ctx.db
183
+ .query("events")
184
+ .withIndex("by_external_id", (idx) => idx.eq("externalId", args.externalId))
185
+ .first();
186
+ if (existing) {
187
+ // Update existing record
188
+ await ctx.db.patch(existing._id, args);
189
+ return existing._id;
190
+ }
191
+ }
192
+
193
+ // Deduplicate by source + start + end
194
+ const existing = await ctx.db
195
+ .query("events")
196
+ .withIndex("by_source_start_end", (idx) =>
197
+ idx
198
+ .eq("dataSourceId", args.dataSourceId)
199
+ .eq("startDatetime", args.startDatetime)
200
+ .eq("endDatetime", args.endDatetime ?? undefined),
201
+ )
202
+ .first();
203
+
204
+ if (existing) {
205
+ await ctx.db.patch(existing._id, args);
206
+ return existing._id;
207
+ }
208
+
209
+ return await ctx.db.insert("events", args);
210
+ },
211
+ });
212
+
213
+ /**
214
+ * Store a batch of events. Used by sync workflows to write multiple events
215
+ * in a single mutation (must complete within 1 second).
216
+ */
217
+ export const storeEventBatch = internalMutation({
218
+ args: {
219
+ events: v.array(v.any()),
220
+ },
221
+ returns: v.array(v.id("events")),
222
+ handler: async (ctx, args) => {
223
+ const ids: Id<"events">[] = [];
224
+ for (const event of args.events) {
225
+ // Deduplicate by externalId
226
+ if (event.externalId) {
227
+ const existing = await ctx.db
228
+ .query("events")
229
+ .withIndex("by_external_id", (idx) => idx.eq("externalId", event.externalId))
230
+ .first();
231
+ if (existing) {
232
+ await ctx.db.patch(existing._id, event);
233
+ ids.push(existing._id);
234
+ continue;
235
+ }
236
+ }
237
+ const existing = await ctx.db
238
+ .query("events")
239
+ .withIndex("by_source_start_end", (idx) =>
240
+ idx
241
+ .eq("dataSourceId", event.dataSourceId)
242
+ .eq("startDatetime", event.startDatetime)
243
+ .eq("endDatetime", event.endDatetime ?? undefined),
244
+ )
245
+ .first();
246
+ if (existing) {
247
+ await ctx.db.patch(existing._id, event);
248
+ ids.push(existing._id);
249
+ continue;
250
+ }
251
+ const id = await ctx.db.insert("events", event);
252
+ ids.push(id);
253
+ }
254
+ return ids;
255
+ },
256
+ });
257
+
258
+ export const deleteByExternalId = internalMutation({
259
+ args: {
260
+ externalId: v.string(),
261
+ },
262
+ handler: async (ctx, args) => {
263
+ const event = await ctx.db
264
+ .query("events")
265
+ .withIndex("by_external_id", (idx) => idx.eq("externalId", args.externalId))
266
+ .first();
267
+
268
+ if (event) {
269
+ await ctx.db.delete(event._id);
270
+ }
271
+ },
272
+ });
273
+
274
+ /**
275
+ * Delete all events for a user. Used for GDPR/account deletion.
276
+ */
277
+ export const deleteUserEvents = internalMutation({
278
+ args: { userId: v.string() },
279
+ handler: async (ctx, args) => {
280
+ const events = await ctx.db
281
+ .query("events")
282
+ .withIndex("by_user_category_time", (idx) => idx.eq("userId", args.userId))
283
+ .collect();
284
+ for (const event of events) {
285
+ await ctx.db.delete(event._id);
286
+ }
287
+ },
288
+ });