@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
@@ -1,12 +1,54 @@
1
1
  import { v } from "convex/values";
2
- import { internalMutation, query } from "./_generated/server";
2
+ import { internal } from "./_generated/api";
3
+ import { internalMutation, mutation, query, } from "./_generated/server";
4
+ import { providerName, timeSeriesAggregation } from "./schema";
5
+ import { buildBuiltinFullTiers, comparePolicyScopes, createTimeSeriesPolicyScopeKey, DEFAULT_MAINTENANCE_INTERVAL_MS, DEFAULT_POLICY_SET_KEY, DEFAULT_TIME_SERIES_AGGREGATIONS, findTierForAge, getBucketEnd, getBucketStart, getRawTier, inferTimeSeriesPolicyScope, normalizeAggregations, normalizeTimeSeriesPolicyRuleInputs, parseDurationInput, resolveScopedPolicyRule, } from "./timeSeriesPolicyUtils";
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+ const MAX_QUERY_LIMIT = 2000;
8
+ const MAINTENANCE_SETTINGS_KEY = "default";
9
+ const MAINTENANCE_BATCH_SIZE = 20;
10
+ const MAINTENANCE_POINT_BATCH_SIZE = 2000;
11
+ const MAINTENANCE_ROLLUP_BATCH_SIZE = 500;
12
+ const LONG_IDLE_MAINTENANCE_MS = 30 * DAY_MS;
13
+ const durationInputValidator = v.union(v.string(), v.number());
14
+ const tierInputValidator = v.union(v.object({
15
+ kind: v.literal("raw"),
16
+ fromAge: durationInputValidator,
17
+ toAge: v.union(durationInputValidator, v.null()),
18
+ }), v.object({
19
+ kind: v.literal("rollup"),
20
+ fromAge: durationInputValidator,
21
+ toAge: v.union(durationInputValidator, v.null()),
22
+ bucket: durationInputValidator,
23
+ aggregations: v.optional(v.array(timeSeriesAggregation)),
24
+ }));
25
+ const policyRuleInputValidator = v.object({
26
+ provider: v.optional(providerName),
27
+ seriesType: v.optional(v.string()),
28
+ tiers: v.array(tierInputValidator),
29
+ });
30
+ const policyPresetInputValidator = v.object({
31
+ key: v.string(),
32
+ rules: v.array(policyRuleInputValidator),
33
+ });
34
+ const maintenanceInputValidator = v.object({
35
+ enabled: v.optional(v.boolean()),
36
+ interval: v.optional(durationInputValidator),
37
+ });
38
+ const timeSeriesPointValidator = v.object({
39
+ timestamp: v.number(),
40
+ value: v.number(),
41
+ resolution: v.optional(v.union(v.literal("raw"), v.literal("rollup"))),
42
+ bucketMinutes: v.optional(v.number()),
43
+ avg: v.optional(v.number()),
44
+ min: v.optional(v.number()),
45
+ max: v.optional(v.number()),
46
+ last: v.optional(v.number()),
47
+ count: v.optional(v.number()),
48
+ });
3
49
  // ---------------------------------------------------------------------------
4
50
  // Queries
5
51
  // ---------------------------------------------------------------------------
6
- /**
7
- * Get time-series data points with cursor-based pagination.
8
- * Uses the by_source_type_time index for efficient range queries.
9
- */
10
52
  export const getTimeSeries = query({
11
53
  args: {
12
54
  dataSourceId: v.id("dataSources"),
@@ -18,46 +60,41 @@ export const getTimeSeries = query({
18
60
  order: v.optional(v.union(v.literal("asc"), v.literal("desc"))),
19
61
  },
20
62
  returns: v.object({
21
- points: v.array(v.object({
22
- timestamp: v.number(),
23
- value: v.number(),
24
- })),
63
+ points: v.array(timeSeriesPointValidator),
25
64
  nextCursor: v.union(v.string(), v.null()),
26
65
  hasMore: v.boolean(),
27
66
  }),
28
67
  handler: async (ctx, args) => {
29
- const limit = Math.min(args.limit ?? 500, 2000);
68
+ const source = await ctx.db.get(args.dataSourceId);
69
+ if (!source) {
70
+ return { points: [], nextCursor: null, hasMore: false };
71
+ }
72
+ const limit = Math.min(args.limit ?? 500, MAX_QUERY_LIMIT);
30
73
  const order = args.order ?? "asc";
31
- const startDate = args.cursor ? Number(args.cursor) : args.startDate;
32
- const results = await ctx.db
33
- .query("dataPoints")
34
- .withIndex("by_source_type_time", (idx) => order === "asc"
35
- ? idx
36
- .eq("dataSourceId", args.dataSourceId)
37
- .eq("seriesType", args.seriesType)
38
- .gte("recordedAt", startDate)
39
- .lte("recordedAt", args.endDate)
40
- : idx
41
- .eq("dataSourceId", args.dataSourceId)
42
- .eq("seriesType", args.seriesType)
43
- .gte("recordedAt", args.startDate)
44
- .lte("recordedAt", startDate))
45
- .order(order)
46
- .take(limit + 1);
47
- const hasMore = results.length > limit;
48
- const items = hasMore ? results.slice(0, limit) : results;
49
- const points = items.map((dp) => ({
50
- timestamp: dp.recordedAt,
51
- value: dp.value,
52
- }));
53
- const nextCursor = hasMore && items.length > 0 ? String(items[items.length - 1].recordedAt) : null;
54
- return { points, nextCursor, hasMore };
74
+ const cursor = args.cursor ? Number(args.cursor) : undefined;
75
+ const points = await getPolicyAwarePointsForSource(ctx.db, {
76
+ dataSourceId: source._id,
77
+ userId: source.userId,
78
+ provider: source.provider,
79
+ seriesType: args.seriesType,
80
+ startDate: args.startDate,
81
+ endDate: args.endDate,
82
+ limit: limit + 2,
83
+ order,
84
+ });
85
+ const filtered = cursor === undefined
86
+ ? points
87
+ : points.filter((point) => order === "asc" ? point.timestamp > cursor : point.timestamp < cursor);
88
+ const hasMore = filtered.length > limit;
89
+ const items = hasMore ? filtered.slice(0, limit) : filtered;
90
+ const nextCursor = hasMore && items.length > 0 ? String(items[items.length - 1].timestamp) : null;
91
+ return {
92
+ points: items,
93
+ nextCursor,
94
+ hasMore,
95
+ };
55
96
  },
56
97
  });
57
- /**
58
- * Get time-series data for a user across all their data sources for a given series type.
59
- * This is a user-facing query that resolves data sources internally.
60
- */
61
98
  export const getTimeSeriesForUser = query({
62
99
  args: {
63
100
  userId: v.string(),
@@ -66,40 +103,30 @@ export const getTimeSeriesForUser = query({
66
103
  endDate: v.number(),
67
104
  limit: v.optional(v.number()),
68
105
  },
69
- returns: v.array(v.object({
70
- timestamp: v.number(),
71
- value: v.number(),
72
- })),
106
+ returns: v.array(timeSeriesPointValidator),
73
107
  handler: async (ctx, args) => {
74
- const limit = Math.min(args.limit ?? 500, 2000);
75
- // Get all data sources for this user
108
+ const limit = Math.min(args.limit ?? 500, MAX_QUERY_LIMIT);
76
109
  const sources = await ctx.db
77
110
  .query("dataSources")
78
111
  .withIndex("by_user_provider", (idx) => idx.eq("userId", args.userId))
79
112
  .collect();
80
- // Collect data from all sources (merge and sort)
81
- const allPoints = [];
113
+ const points = [];
82
114
  for (const source of sources) {
83
- const points = await ctx.db
84
- .query("dataPoints")
85
- .withIndex("by_source_type_time", (idx) => idx
86
- .eq("dataSourceId", source._id)
87
- .eq("seriesType", args.seriesType)
88
- .gte("recordedAt", args.startDate)
89
- .lte("recordedAt", args.endDate))
90
- .take(limit);
91
- for (const dp of points) {
92
- allPoints.push({ timestamp: dp.recordedAt, value: dp.value });
93
- }
115
+ points.push(...(await getPolicyAwarePointsForSource(ctx.db, {
116
+ dataSourceId: source._id,
117
+ userId: source.userId,
118
+ provider: source.provider,
119
+ seriesType: args.seriesType,
120
+ startDate: args.startDate,
121
+ endDate: args.endDate,
122
+ limit,
123
+ order: "asc",
124
+ })));
94
125
  }
95
- // Sort by timestamp and limit
96
- allPoints.sort((a, b) => a.timestamp - b.timestamp);
97
- return allPoints.slice(0, limit);
126
+ points.sort((a, b) => a.timestamp - b.timestamp);
127
+ return points.slice(0, limit);
98
128
  },
99
129
  });
100
- /**
101
- * Get the latest data point for a user and series type.
102
- */
103
130
  export const getLatestDataPoint = query({
104
131
  args: {
105
132
  userId: v.string(),
@@ -117,54 +144,227 @@ export const getLatestDataPoint = query({
117
144
  .collect();
118
145
  let latest = null;
119
146
  for (const source of sources) {
120
- const point = await ctx.db
147
+ const rawPoint = await ctx.db
121
148
  .query("dataPoints")
122
149
  .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", source._id).eq("seriesType", args.seriesType))
123
150
  .order("desc")
124
151
  .first();
125
- if (point && (latest === null || point.recordedAt > latest.timestamp)) {
126
- latest = {
127
- timestamp: point.recordedAt,
128
- value: point.value,
152
+ const rollupPoint = await ctx.db
153
+ .query("timeSeriesRollups")
154
+ .withIndex("by_source_type_bucket", (idx) => idx.eq("dataSourceId", source._id).eq("seriesType", args.seriesType))
155
+ .order("desc")
156
+ .first();
157
+ const rawCandidate = rawPoint
158
+ ? {
159
+ timestamp: rawPoint.recordedAt,
160
+ value: rawPoint.value,
161
+ provider: source.provider,
162
+ }
163
+ : null;
164
+ const rollupCandidate = rollupPoint
165
+ ? {
166
+ timestamp: rollupPoint.lastRecordedAt,
167
+ value: rollupPoint.last,
129
168
  provider: source.provider,
130
- };
169
+ }
170
+ : null;
171
+ const candidate = rawCandidate === null
172
+ ? rollupCandidate
173
+ : rollupCandidate === null
174
+ ? rawCandidate
175
+ : rawCandidate.timestamp >= rollupCandidate.timestamp
176
+ ? rawCandidate
177
+ : rollupCandidate;
178
+ if (candidate && (latest === null || candidate.timestamp > latest.timestamp)) {
179
+ latest = candidate;
131
180
  }
132
181
  }
133
182
  return latest;
134
183
  },
135
184
  });
136
- /**
137
- * Get all available series types for a user (i.e., types that have at least one data point).
138
- */
139
185
  export const getAvailableSeriesTypes = query({
140
186
  args: { userId: v.string() },
141
187
  returns: v.array(v.string()),
142
188
  handler: async (ctx, args) => {
143
- const sources = await ctx.db
144
- .query("dataSources")
145
- .withIndex("by_user_provider", (idx) => idx.eq("userId", args.userId))
189
+ const states = await ctx.db
190
+ .query("timeSeriesSeriesState")
191
+ .withIndex("by_user", (idx) => idx.eq("userId", args.userId))
146
192
  .collect();
147
- const types = new Set();
148
- for (const source of sources) {
149
- // Sample a few data points to discover types
150
- // This is efficient because we only need one per type
151
- const points = await ctx.db
152
- .query("dataPoints")
153
- .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", source._id))
154
- .take(200);
155
- for (const point of points) {
156
- types.add(point.seriesType);
157
- }
193
+ return Array.from(new Set(states.map((state) => state.seriesType))).sort();
194
+ },
195
+ });
196
+ export const getTimeSeriesPolicyConfiguration = query({
197
+ args: {},
198
+ returns: v.any(),
199
+ handler: async (ctx) => {
200
+ const settings = await getTimeSeriesPolicySettings(ctx.db);
201
+ const rules = await ctx.db.query("timeSeriesPolicyRules").collect();
202
+ return {
203
+ maintenance: {
204
+ enabled: settings.maintenanceEnabled,
205
+ intervalMs: settings.maintenanceIntervalMs,
206
+ },
207
+ defaultRules: formatPolicyRulesForResponse(rules.filter((rule) => rule.policySetKind === "default" && rule.policySetKey === DEFAULT_POLICY_SET_KEY)),
208
+ presets: groupPolicyRulesByPreset(rules.filter((rule) => rule.policySetKind === "preset")),
209
+ };
210
+ },
211
+ });
212
+ export const getUserTimeSeriesPolicyPreset = query({
213
+ args: {
214
+ userId: v.string(),
215
+ },
216
+ returns: v.union(v.object({
217
+ userId: v.string(),
218
+ presetKey: v.string(),
219
+ updatedAt: v.number(),
220
+ }), v.null()),
221
+ handler: async (ctx, args) => {
222
+ const assignment = await ctx.db
223
+ .query("timeSeriesPolicyAssignments")
224
+ .withIndex("by_user", (idx) => idx.eq("userId", args.userId))
225
+ .first();
226
+ if (!assignment) {
227
+ return null;
158
228
  }
159
- return Array.from(types).sort();
229
+ return {
230
+ userId: assignment.userId,
231
+ presetKey: assignment.presetKey,
232
+ updatedAt: assignment.updatedAt,
233
+ };
234
+ },
235
+ });
236
+ export const getEffectiveTimeSeriesPolicy = query({
237
+ args: {
238
+ userId: v.string(),
239
+ provider: providerName,
240
+ seriesType: v.string(),
241
+ },
242
+ returns: v.any(),
243
+ handler: async (ctx, args) => {
244
+ const effective = await resolveEffectivePolicy(ctx.db, args.userId, args.provider, args.seriesType);
245
+ return {
246
+ provider: args.provider,
247
+ seriesType: args.seriesType,
248
+ sourceKind: effective.sourceKind,
249
+ sourceKey: effective.sourceKey,
250
+ matchedScope: effective.matchedScope,
251
+ tiers: effective.tiers.map(formatTierForResponse),
252
+ };
160
253
  },
161
254
  });
162
255
  // ---------------------------------------------------------------------------
163
256
  // Mutations
164
257
  // ---------------------------------------------------------------------------
165
- /**
166
- * Store a single data point. Deduplicates by (source, type, time).
167
- */
258
+ export const replaceTimeSeriesPolicyConfiguration = mutation({
259
+ args: {
260
+ defaultRules: v.array(policyRuleInputValidator),
261
+ presets: v.optional(v.array(policyPresetInputValidator)),
262
+ maintenance: v.optional(maintenanceInputValidator),
263
+ },
264
+ returns: v.object({
265
+ defaultRulesStored: v.number(),
266
+ presetsStored: v.number(),
267
+ }),
268
+ handler: async (ctx, args) => {
269
+ const normalizedDefaultRules = normalizeTimeSeriesPolicyRuleInputs(args.defaultRules);
270
+ const presetKeys = new Set();
271
+ const normalizedPresets = (args.presets ?? []).map((preset) => {
272
+ if (!preset.key.trim()) {
273
+ throw new Error("Preset keys must not be empty");
274
+ }
275
+ if (preset.key === DEFAULT_POLICY_SET_KEY) {
276
+ throw new Error(`Preset key "${DEFAULT_POLICY_SET_KEY}" is reserved`);
277
+ }
278
+ if (presetKeys.has(preset.key)) {
279
+ throw new Error(`Duplicate time-series policy preset key "${preset.key}"`);
280
+ }
281
+ presetKeys.add(preset.key);
282
+ return {
283
+ key: preset.key,
284
+ rules: normalizeTimeSeriesPolicyRuleInputs(preset.rules),
285
+ };
286
+ });
287
+ const existingRules = await ctx.db.query("timeSeriesPolicyRules").collect();
288
+ for (const rule of existingRules) {
289
+ await ctx.db.delete(rule._id);
290
+ }
291
+ const updatedAt = Date.now();
292
+ for (const rule of normalizedDefaultRules) {
293
+ await ctx.db.insert("timeSeriesPolicyRules", {
294
+ policySetKind: "default",
295
+ policySetKey: DEFAULT_POLICY_SET_KEY,
296
+ scopeKey: createTimeSeriesPolicyScopeKey(rule.provider, rule.seriesType),
297
+ provider: rule.provider,
298
+ seriesType: rule.seriesType,
299
+ tiers: rule.tiers,
300
+ updatedAt,
301
+ });
302
+ }
303
+ for (const preset of normalizedPresets) {
304
+ for (const rule of preset.rules) {
305
+ await ctx.db.insert("timeSeriesPolicyRules", {
306
+ policySetKind: "preset",
307
+ policySetKey: preset.key,
308
+ scopeKey: createTimeSeriesPolicyScopeKey(rule.provider, rule.seriesType),
309
+ provider: rule.provider,
310
+ seriesType: rule.seriesType,
311
+ tiers: rule.tiers,
312
+ updatedAt,
313
+ });
314
+ }
315
+ }
316
+ await upsertTimeSeriesPolicySettings(ctx, args.maintenance);
317
+ await markAllSeriesStateDue(ctx, updatedAt);
318
+ await ensureTimeSeriesMaintenanceScheduled(ctx);
319
+ return {
320
+ defaultRulesStored: normalizedDefaultRules.length,
321
+ presetsStored: normalizedPresets.reduce((sum, preset) => sum + preset.rules.length, 0),
322
+ };
323
+ },
324
+ });
325
+ export const setUserTimeSeriesPolicyPreset = mutation({
326
+ args: {
327
+ userId: v.string(),
328
+ presetKey: v.union(v.string(), v.null()),
329
+ },
330
+ returns: v.null(),
331
+ handler: async (ctx, args) => {
332
+ const existing = await ctx.db
333
+ .query("timeSeriesPolicyAssignments")
334
+ .withIndex("by_user", (idx) => idx.eq("userId", args.userId))
335
+ .first();
336
+ if (args.presetKey === null) {
337
+ if (existing) {
338
+ await ctx.db.delete(existing._id);
339
+ }
340
+ await markSeriesStateDueForUser(ctx, args.userId, Date.now());
341
+ await ensureTimeSeriesMaintenanceScheduled(ctx);
342
+ return null;
343
+ }
344
+ const presetKey = args.presetKey;
345
+ const presetRules = await ctx.db
346
+ .query("timeSeriesPolicyRules")
347
+ .withIndex("by_set", (idx) => idx.eq("policySetKind", "preset").eq("policySetKey", presetKey))
348
+ .take(1);
349
+ if (presetRules.length === 0) {
350
+ throw new Error(`Time-series policy preset "${presetKey}" does not exist`);
351
+ }
352
+ const patch = {
353
+ userId: args.userId,
354
+ presetKey,
355
+ updatedAt: Date.now(),
356
+ };
357
+ if (existing) {
358
+ await ctx.db.patch(existing._id, patch);
359
+ }
360
+ else {
361
+ await ctx.db.insert("timeSeriesPolicyAssignments", patch);
362
+ }
363
+ await markSeriesStateDueForUser(ctx, args.userId, Date.now());
364
+ await ensureTimeSeriesMaintenanceScheduled(ctx);
365
+ return null;
366
+ },
367
+ });
168
368
  export const storeDataPoint = internalMutation({
169
369
  args: {
170
370
  dataSourceId: v.id("dataSources"),
@@ -173,27 +373,22 @@ export const storeDataPoint = internalMutation({
173
373
  value: v.number(),
174
374
  externalId: v.optional(v.string()),
175
375
  },
176
- returns: v.id("dataPoints"),
376
+ returns: v.union(v.id("dataPoints"), v.null()),
177
377
  handler: async (ctx, args) => {
178
- // Deduplicate by (source, type, time)
179
- const existing = await ctx.db
180
- .query("dataPoints")
181
- .withIndex("by_source_type_time", (idx) => idx
182
- .eq("dataSourceId", args.dataSourceId)
183
- .eq("seriesType", args.seriesType)
184
- .eq("recordedAt", args.recordedAt))
185
- .first();
186
- if (existing) {
187
- await ctx.db.patch(existing._id, { value: args.value });
188
- return existing._id;
189
- }
190
- return await ctx.db.insert("dataPoints", args);
378
+ const result = await storePointsWithPolicy(ctx, {
379
+ dataSourceId: args.dataSourceId,
380
+ seriesType: args.seriesType,
381
+ points: [
382
+ {
383
+ recordedAt: args.recordedAt,
384
+ value: args.value,
385
+ externalId: args.externalId,
386
+ },
387
+ ],
388
+ });
389
+ return result.lastRawId;
191
390
  },
192
391
  });
193
- /**
194
- * Store a batch of data points. Used by sync workflows.
195
- * Must complete within 1 second — caller is responsible for batch sizing.
196
- */
197
392
  export const storeBatch = internalMutation({
198
393
  args: {
199
394
  dataSourceId: v.id("dataSources"),
@@ -206,53 +401,767 @@ export const storeBatch = internalMutation({
206
401
  },
207
402
  returns: v.number(),
208
403
  handler: async (ctx, args) => {
209
- let count = 0;
210
- for (const point of args.points) {
211
- // Deduplicate by (source, type, time)
212
- const existing = await ctx.db
213
- .query("dataPoints")
214
- .withIndex("by_source_type_time", (idx) => idx
215
- .eq("dataSourceId", args.dataSourceId)
216
- .eq("seriesType", args.seriesType)
217
- .eq("recordedAt", point.recordedAt))
218
- .first();
219
- if (existing) {
220
- await ctx.db.patch(existing._id, { value: point.value });
404
+ const result = await storePointsWithPolicy(ctx, args);
405
+ return result.processedCount;
406
+ },
407
+ });
408
+ export const runTimeSeriesMaintenance = internalMutation({
409
+ args: {},
410
+ returns: v.null(),
411
+ handler: async (ctx) => {
412
+ const settingsDoc = await ensureTimeSeriesPolicySettingsDoc(ctx.db);
413
+ const now = Date.now();
414
+ if (!settingsDoc.maintenanceEnabled) {
415
+ await ctx.db.patch(settingsDoc._id, {
416
+ scheduledAt: undefined,
417
+ lastRunAt: now,
418
+ lastError: undefined,
419
+ updatedAt: now,
420
+ });
421
+ return null;
422
+ }
423
+ await ctx.db.patch(settingsDoc._id, {
424
+ scheduledAt: undefined,
425
+ lastRunAt: now,
426
+ lastError: undefined,
427
+ updatedAt: now,
428
+ });
429
+ let lastError;
430
+ const dueStates = await ctx.db
431
+ .query("timeSeriesSeriesState")
432
+ .withIndex("by_next_maintenance", (idx) => idx.lte("nextMaintenanceAt", now))
433
+ .take(MAINTENANCE_BATCH_SIZE);
434
+ for (const state of dueStates) {
435
+ try {
436
+ await maintainSeriesState(ctx.db, state, now);
221
437
  }
222
- else {
223
- await ctx.db.insert("dataPoints", {
224
- dataSourceId: args.dataSourceId,
225
- seriesType: args.seriesType,
226
- recordedAt: point.recordedAt,
227
- value: point.value,
228
- externalId: point.externalId,
229
- });
438
+ catch (error) {
439
+ lastError = error instanceof Error ? error.message : String(error);
230
440
  }
231
- count++;
232
441
  }
233
- return count;
442
+ const refreshed = await ensureTimeSeriesPolicySettingsDoc(ctx.db);
443
+ const hasBacklog = dueStates.length === MAINTENANCE_BATCH_SIZE;
444
+ const delayMs = hasBacklog
445
+ ? Math.min(refreshed.maintenanceIntervalMs, 60 * 1000)
446
+ : refreshed.maintenanceIntervalMs;
447
+ const scheduledAt = now + delayMs;
448
+ await ctx.scheduler.runAfter(delayMs, internal.dataPoints.runTimeSeriesMaintenance, {});
449
+ await ctx.db.patch(refreshed._id, {
450
+ scheduledAt,
451
+ lastRunAt: now,
452
+ lastError,
453
+ updatedAt: now,
454
+ });
455
+ return null;
234
456
  },
235
457
  });
236
- /**
237
- * Delete all data points for a data source. Used during user/connection cleanup.
238
- */
239
458
  export const deleteByDataSource = internalMutation({
240
459
  args: { dataSourceId: v.id("dataSources") },
460
+ returns: v.null(),
241
461
  handler: async (ctx, args) => {
242
- // Delete in batches to avoid hitting limits
243
- let batch = await ctx.db
462
+ let rawBatch = await ctx.db
244
463
  .query("dataPoints")
245
464
  .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", args.dataSourceId))
246
465
  .take(1000);
247
- while (batch.length > 0) {
248
- for (const dp of batch) {
249
- await ctx.db.delete(dp._id);
466
+ while (rawBatch.length > 0) {
467
+ for (const point of rawBatch) {
468
+ await ctx.db.delete(point._id);
250
469
  }
251
- batch = await ctx.db
470
+ rawBatch = await ctx.db
252
471
  .query("dataPoints")
253
472
  .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", args.dataSourceId))
254
473
  .take(1000);
255
474
  }
475
+ let rollupBatch = await ctx.db
476
+ .query("timeSeriesRollups")
477
+ .withIndex("by_source_type_bucket", (idx) => idx.eq("dataSourceId", args.dataSourceId))
478
+ .take(1000);
479
+ while (rollupBatch.length > 0) {
480
+ for (const rollup of rollupBatch) {
481
+ await ctx.db.delete(rollup._id);
482
+ }
483
+ rollupBatch = await ctx.db
484
+ .query("timeSeriesRollups")
485
+ .withIndex("by_source_type_bucket", (idx) => idx.eq("dataSourceId", args.dataSourceId))
486
+ .take(1000);
487
+ }
488
+ const state = await ctx.db
489
+ .query("timeSeriesSeriesState")
490
+ .withIndex("by_source_series", (idx) => idx.eq("dataSourceId", args.dataSourceId))
491
+ .collect();
492
+ for (const doc of state) {
493
+ await ctx.db.delete(doc._id);
494
+ }
495
+ return null;
256
496
  },
257
497
  });
498
+ // ---------------------------------------------------------------------------
499
+ // Policy helpers
500
+ // ---------------------------------------------------------------------------
501
+ async function getTimeSeriesPolicySettings(db) {
502
+ const existing = await db
503
+ .query("timeSeriesPolicySettings")
504
+ .withIndex("by_key", (idx) => idx.eq("key", MAINTENANCE_SETTINGS_KEY))
505
+ .first();
506
+ return (existing ?? {
507
+ key: MAINTENANCE_SETTINGS_KEY,
508
+ maintenanceEnabled: true,
509
+ maintenanceIntervalMs: DEFAULT_MAINTENANCE_INTERVAL_MS,
510
+ updatedAt: 0,
511
+ });
512
+ }
513
+ async function ensureTimeSeriesPolicySettingsDoc(db) {
514
+ const existing = await db
515
+ .query("timeSeriesPolicySettings")
516
+ .withIndex("by_key", (idx) => idx.eq("key", MAINTENANCE_SETTINGS_KEY))
517
+ .first();
518
+ if (existing) {
519
+ return existing;
520
+ }
521
+ const id = await db.insert("timeSeriesPolicySettings", {
522
+ key: MAINTENANCE_SETTINGS_KEY,
523
+ maintenanceEnabled: true,
524
+ maintenanceIntervalMs: DEFAULT_MAINTENANCE_INTERVAL_MS,
525
+ updatedAt: Date.now(),
526
+ });
527
+ const inserted = await db.get(id);
528
+ if (!inserted) {
529
+ throw new Error("Failed to create time-series policy settings");
530
+ }
531
+ return inserted;
532
+ }
533
+ async function upsertTimeSeriesPolicySettings(ctx, maintenance) {
534
+ const existing = await ensureTimeSeriesPolicySettingsDoc(ctx.db);
535
+ const patch = {
536
+ maintenanceEnabled: maintenance?.enabled ?? existing.maintenanceEnabled,
537
+ maintenanceIntervalMs: maintenance?.interval !== undefined
538
+ ? parseDurationInput(maintenance.interval, "maintenance.interval")
539
+ : existing.maintenanceIntervalMs,
540
+ updatedAt: Date.now(),
541
+ };
542
+ await ctx.db.patch(existing._id, patch);
543
+ }
544
+ async function resolveEffectivePolicy(db, userId, provider, seriesType) {
545
+ const assignment = await db
546
+ .query("timeSeriesPolicyAssignments")
547
+ .withIndex("by_user", (idx) => idx.eq("userId", userId))
548
+ .first();
549
+ if (assignment) {
550
+ const presetRules = (await db
551
+ .query("timeSeriesPolicyRules")
552
+ .withIndex("by_set", (idx) => idx.eq("policySetKind", "preset").eq("policySetKey", assignment.presetKey))
553
+ .collect());
554
+ const presetMatch = resolveScopedPolicyRule(presetRules, provider, seriesType);
555
+ if (presetMatch) {
556
+ return {
557
+ provider: presetMatch.provider,
558
+ seriesType: presetMatch.seriesType,
559
+ tiers: presetMatch.tiers,
560
+ sourceKind: "preset",
561
+ sourceKey: assignment.presetKey,
562
+ matchedScope: inferTimeSeriesPolicyScope(presetMatch.provider, presetMatch.seriesType),
563
+ };
564
+ }
565
+ }
566
+ const defaultRules = (await db
567
+ .query("timeSeriesPolicyRules")
568
+ .withIndex("by_set", (idx) => idx.eq("policySetKind", "default").eq("policySetKey", DEFAULT_POLICY_SET_KEY))
569
+ .collect());
570
+ const defaultMatch = resolveScopedPolicyRule(defaultRules, provider, seriesType);
571
+ if (defaultMatch) {
572
+ return {
573
+ provider: defaultMatch.provider,
574
+ seriesType: defaultMatch.seriesType,
575
+ tiers: defaultMatch.tiers,
576
+ sourceKind: "default",
577
+ sourceKey: DEFAULT_POLICY_SET_KEY,
578
+ matchedScope: inferTimeSeriesPolicyScope(defaultMatch.provider, defaultMatch.seriesType),
579
+ };
580
+ }
581
+ return {
582
+ tiers: buildBuiltinFullTiers(),
583
+ sourceKind: "builtin",
584
+ sourceKey: null,
585
+ matchedScope: "default",
586
+ };
587
+ }
588
+ function formatTierForResponse(tier) {
589
+ if (tier.kind === "raw") {
590
+ return {
591
+ kind: "raw",
592
+ fromAgeMs: tier.fromAgeMs,
593
+ toAgeMs: tier.toAgeMs,
594
+ };
595
+ }
596
+ return {
597
+ kind: "rollup",
598
+ fromAgeMs: tier.fromAgeMs,
599
+ toAgeMs: tier.toAgeMs,
600
+ bucketMs: tier.bucketMs,
601
+ aggregations: tier.aggregations,
602
+ };
603
+ }
604
+ function formatPolicyRulesForResponse(rules) {
605
+ return rules
606
+ .map((rule) => ({
607
+ _id: rule._id,
608
+ provider: rule.provider,
609
+ seriesType: rule.seriesType,
610
+ scope: inferTimeSeriesPolicyScope(rule.provider, rule.seriesType),
611
+ policySetKind: rule.policySetKind,
612
+ policySetKey: rule.policySetKey,
613
+ tiers: rule.tiers.map(formatTierForResponse),
614
+ updatedAt: rule.updatedAt,
615
+ }))
616
+ .sort(comparePolicyScopes);
617
+ }
618
+ function groupPolicyRulesByPreset(rules) {
619
+ const grouped = new Map();
620
+ for (const rule of rules) {
621
+ const existing = grouped.get(rule.policySetKey) ?? [];
622
+ existing.push(rule);
623
+ grouped.set(rule.policySetKey, existing);
624
+ }
625
+ return Array.from(grouped.entries())
626
+ .sort((a, b) => a[0].localeCompare(b[0]))
627
+ .map(([key, groupedRules]) => ({
628
+ key,
629
+ rules: formatPolicyRulesForResponse(groupedRules),
630
+ }));
631
+ }
632
+ function policyRequiresMaintenance(tiers) {
633
+ const rawTier = getRawTier(tiers);
634
+ if (!rawTier) {
635
+ return true;
636
+ }
637
+ if (tiers.length > 1) {
638
+ return true;
639
+ }
640
+ return rawTier.toAgeMs !== null;
641
+ }
642
+ // ---------------------------------------------------------------------------
643
+ // Write path
644
+ // ---------------------------------------------------------------------------
645
+ async function storePointsWithPolicy(ctx, args) {
646
+ const dataSource = await ctx.db.get(args.dataSourceId);
647
+ if (!dataSource) {
648
+ throw new Error(`Data source ${args.dataSourceId} not found`);
649
+ }
650
+ const effectivePolicy = await resolveEffectivePolicy(ctx.db, dataSource.userId, dataSource.provider, args.seriesType);
651
+ const settings = await getTimeSeriesPolicySettings(ctx.db);
652
+ const now = Date.now();
653
+ const points = dedupeIncomingPoints(args.points);
654
+ const rawPoints = [];
655
+ const rollupGroups = new Map();
656
+ for (const point of points) {
657
+ const ageMs = Math.max(0, now - point.recordedAt);
658
+ const destinationTier = findTierForAge(effectivePolicy.tiers, ageMs);
659
+ if (!destinationTier) {
660
+ continue;
661
+ }
662
+ if (destinationTier.kind === "raw") {
663
+ rawPoints.push(point);
664
+ continue;
665
+ }
666
+ addRawPointToRollupGroup(rollupGroups, point, destinationTier.bucketMs);
667
+ }
668
+ let lastRawId = null;
669
+ if (rawPoints.length > 0) {
670
+ lastRawId = await upsertRawPoints(ctx.db, args.dataSourceId, args.seriesType, rawPoints);
671
+ }
672
+ if (rollupGroups.size > 0) {
673
+ await upsertRollupStatsGroups(ctx.db, args.dataSourceId, args.seriesType, rollupGroups.values());
674
+ }
675
+ await upsertSeriesState(ctx.db, {
676
+ dataSourceId: dataSource._id,
677
+ connectionId: dataSource.connectionId,
678
+ userId: dataSource.userId,
679
+ provider: dataSource.provider,
680
+ seriesType: args.seriesType,
681
+ latestRecordedAt: points.length > 0 ? points[points.length - 1].recordedAt : now,
682
+ nextMaintenanceAt: policyRequiresMaintenance(effectivePolicy.tiers)
683
+ ? now + settings.maintenanceIntervalMs
684
+ : now + LONG_IDLE_MAINTENANCE_MS,
685
+ });
686
+ if (policyRequiresMaintenance(effectivePolicy.tiers)) {
687
+ await maintainSeriesState(ctx.db, {
688
+ dataSourceId: dataSource._id,
689
+ connectionId: dataSource.connectionId,
690
+ userId: dataSource.userId,
691
+ provider: dataSource.provider,
692
+ seriesType: args.seriesType,
693
+ }, now);
694
+ await ensureTimeSeriesMaintenanceScheduled(ctx);
695
+ }
696
+ return {
697
+ processedCount: points.length,
698
+ lastRawId,
699
+ };
700
+ }
701
+ function dedupeIncomingPoints(points) {
702
+ const deduped = new Map();
703
+ for (const point of points) {
704
+ deduped.set(point.recordedAt, point);
705
+ }
706
+ return Array.from(deduped.values()).sort((a, b) => a.recordedAt - b.recordedAt);
707
+ }
708
+ async function upsertRawPoints(db, dataSourceId, seriesType, points) {
709
+ let lastId = null;
710
+ for (const point of points) {
711
+ const existing = await db
712
+ .query("dataPoints")
713
+ .withIndex("by_source_type_time", (idx) => idx
714
+ .eq("dataSourceId", dataSourceId)
715
+ .eq("seriesType", seriesType)
716
+ .eq("recordedAt", point.recordedAt))
717
+ .first();
718
+ if (existing) {
719
+ await db.patch(existing._id, {
720
+ value: point.value,
721
+ externalId: point.externalId,
722
+ });
723
+ lastId = existing._id;
724
+ }
725
+ else {
726
+ lastId = await db.insert("dataPoints", {
727
+ dataSourceId,
728
+ seriesType,
729
+ recordedAt: point.recordedAt,
730
+ value: point.value,
731
+ externalId: point.externalId,
732
+ });
733
+ }
734
+ }
735
+ return lastId;
736
+ }
737
+ function addRawPointToRollupGroup(groups, point, bucketMs) {
738
+ const bucketStart = getBucketStart(point.recordedAt, bucketMs);
739
+ const key = `${bucketMs}:${bucketStart}`;
740
+ const existing = groups.get(key);
741
+ const stats = aggregateRawPoints([
742
+ {
743
+ recordedAt: point.recordedAt,
744
+ value: point.value,
745
+ },
746
+ ]);
747
+ if (existing) {
748
+ existing.stats = combineAggregatedStats([existing.stats, stats]);
749
+ }
750
+ else {
751
+ groups.set(key, {
752
+ bucketMs,
753
+ bucketStart,
754
+ stats,
755
+ });
756
+ }
757
+ }
758
+ async function upsertRollupStatsGroups(db, dataSourceId, seriesType, groups) {
759
+ for (const group of groups) {
760
+ const existing = await db
761
+ .query("timeSeriesRollups")
762
+ .withIndex("by_source_type_bucket_size", (idx) => idx
763
+ .eq("dataSourceId", dataSourceId)
764
+ .eq("seriesType", seriesType)
765
+ .eq("bucketMs", group.bucketMs)
766
+ .eq("bucketStart", group.bucketStart))
767
+ .first();
768
+ const combined = existing
769
+ ? combineAggregatedStats([rollupDocToStats(existing), group.stats])
770
+ : group.stats;
771
+ const patch = {
772
+ dataSourceId,
773
+ seriesType,
774
+ bucketMs: group.bucketMs,
775
+ bucketStart: group.bucketStart,
776
+ bucketEnd: getBucketEnd(group.bucketStart, group.bucketMs),
777
+ avg: combined.avg,
778
+ min: combined.min,
779
+ max: combined.max,
780
+ last: combined.last,
781
+ lastRecordedAt: combined.lastRecordedAt,
782
+ count: combined.count,
783
+ updatedAt: Date.now(),
784
+ };
785
+ if (existing) {
786
+ await db.patch(existing._id, patch);
787
+ }
788
+ else {
789
+ await db.insert("timeSeriesRollups", patch);
790
+ }
791
+ }
792
+ }
793
+ async function upsertSeriesState(db, args) {
794
+ const existing = await db
795
+ .query("timeSeriesSeriesState")
796
+ .withIndex("by_source_series", (idx) => idx.eq("dataSourceId", args.dataSourceId).eq("seriesType", args.seriesType))
797
+ .first();
798
+ const patch = {
799
+ dataSourceId: args.dataSourceId,
800
+ connectionId: args.connectionId,
801
+ userId: args.userId,
802
+ provider: args.provider,
803
+ seriesType: args.seriesType,
804
+ latestRecordedAt: existing
805
+ ? Math.max(existing.latestRecordedAt, args.latestRecordedAt)
806
+ : args.latestRecordedAt,
807
+ lastIngestedAt: Date.now(),
808
+ nextMaintenanceAt: args.nextMaintenanceAt,
809
+ updatedAt: Date.now(),
810
+ };
811
+ if (existing) {
812
+ await db.patch(existing._id, patch);
813
+ return existing._id;
814
+ }
815
+ return await db.insert("timeSeriesSeriesState", patch);
816
+ }
817
+ // ---------------------------------------------------------------------------
818
+ // Maintenance
819
+ // ---------------------------------------------------------------------------
820
+ async function ensureTimeSeriesMaintenanceScheduled(ctx) {
821
+ const settings = await ensureTimeSeriesPolicySettingsDoc(ctx.db);
822
+ if (!settings.maintenanceEnabled) {
823
+ return;
824
+ }
825
+ const now = Date.now();
826
+ if (settings.scheduledAt !== undefined && settings.scheduledAt > now) {
827
+ return;
828
+ }
829
+ const delayMs = settings.maintenanceIntervalMs;
830
+ const scheduledAt = now + delayMs;
831
+ await ctx.scheduler.runAfter(delayMs, internal.dataPoints.runTimeSeriesMaintenance, {});
832
+ await ctx.db.patch(settings._id, {
833
+ scheduledAt,
834
+ updatedAt: now,
835
+ });
836
+ }
837
+ async function markAllSeriesStateDue(ctx, dueAt) {
838
+ const states = await ctx.db.query("timeSeriesSeriesState").collect();
839
+ for (const state of states) {
840
+ await ctx.db.patch(state._id, {
841
+ nextMaintenanceAt: dueAt,
842
+ updatedAt: Date.now(),
843
+ });
844
+ }
845
+ }
846
+ async function markSeriesStateDueForUser(ctx, userId, dueAt) {
847
+ const batch = await ctx.db
848
+ .query("timeSeriesSeriesState")
849
+ .withIndex("by_user", (idx) => idx.eq("userId", userId))
850
+ .collect();
851
+ for (const state of batch) {
852
+ await ctx.db.patch(state._id, {
853
+ nextMaintenanceAt: dueAt,
854
+ updatedAt: Date.now(),
855
+ });
856
+ }
857
+ }
858
+ async function maintainSeriesState(db, state, now) {
859
+ const source = await db.get(state.dataSourceId);
860
+ const storedState = await db
861
+ .query("timeSeriesSeriesState")
862
+ .withIndex("by_source_series", (idx) => idx.eq("dataSourceId", state.dataSourceId).eq("seriesType", state.seriesType))
863
+ .first();
864
+ if (!source || !storedState) {
865
+ if (storedState) {
866
+ await db.delete(storedState._id);
867
+ }
868
+ return;
869
+ }
870
+ const effective = await resolveEffectivePolicy(db, storedState.userId, storedState.provider, storedState.seriesType);
871
+ await compactRawPoints(db, storedState, effective.tiers, now);
872
+ await compactRollupPoints(db, storedState, effective.tiers, now);
873
+ const stillHasData = await sourceSeriesHasData(db, storedState.dataSourceId, storedState.seriesType);
874
+ if (!stillHasData) {
875
+ await db.delete(storedState._id);
876
+ return;
877
+ }
878
+ const settings = await getTimeSeriesPolicySettings(db);
879
+ await db.patch(storedState._id, {
880
+ nextMaintenanceAt: policyRequiresMaintenance(effective.tiers)
881
+ ? now + settings.maintenanceIntervalMs
882
+ : now + LONG_IDLE_MAINTENANCE_MS,
883
+ lastMaintenanceAt: now,
884
+ updatedAt: now,
885
+ });
886
+ }
887
+ async function compactRawPoints(db, state, tiers, now) {
888
+ const rawTier = getRawTier(tiers);
889
+ const rawCutoff = rawTier?.toAgeMs ?? null;
890
+ const query = rawCutoff === null
891
+ ? rawTier
892
+ ? []
893
+ : await db
894
+ .query("dataPoints")
895
+ .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", state.dataSourceId).eq("seriesType", state.seriesType))
896
+ .take(MAINTENANCE_POINT_BATCH_SIZE)
897
+ : await db
898
+ .query("dataPoints")
899
+ .withIndex("by_source_type_time", (idx) => idx
900
+ .eq("dataSourceId", state.dataSourceId)
901
+ .eq("seriesType", state.seriesType)
902
+ .lt("recordedAt", now - rawCutoff))
903
+ .take(MAINTENANCE_POINT_BATCH_SIZE);
904
+ if (query.length === 0) {
905
+ return;
906
+ }
907
+ const rollupGroups = new Map();
908
+ const toDelete = [];
909
+ for (const point of query) {
910
+ const ageMs = Math.max(0, now - point.recordedAt);
911
+ const destinationTier = findTierForAge(tiers, ageMs);
912
+ if (!destinationTier) {
913
+ toDelete.push(point._id);
914
+ continue;
915
+ }
916
+ if (destinationTier.kind === "raw") {
917
+ continue;
918
+ }
919
+ const bucketStart = getBucketStart(point.recordedAt, destinationTier.bucketMs);
920
+ const bucketEnd = getBucketEnd(bucketStart, destinationTier.bucketMs);
921
+ if (bucketEnd > now - destinationTier.fromAgeMs) {
922
+ continue;
923
+ }
924
+ addRawPointToRollupGroup(rollupGroups, point, destinationTier.bucketMs);
925
+ toDelete.push(point._id);
926
+ }
927
+ if (rollupGroups.size > 0) {
928
+ await upsertRollupStatsGroups(db, state.dataSourceId, state.seriesType, rollupGroups.values());
929
+ }
930
+ for (const pointId of toDelete) {
931
+ await db.delete(pointId);
932
+ }
933
+ }
934
+ async function compactRollupPoints(db, state, tiers, now) {
935
+ const rollups = await db
936
+ .query("timeSeriesRollups")
937
+ .withIndex("by_source_type_bucket", (idx) => idx.eq("dataSourceId", state.dataSourceId).eq("seriesType", state.seriesType))
938
+ .take(MAINTENANCE_ROLLUP_BATCH_SIZE);
939
+ if (rollups.length === 0) {
940
+ return;
941
+ }
942
+ const destinationGroups = new Map();
943
+ const toDelete = [];
944
+ for (const rollup of rollups) {
945
+ const ageMs = Math.max(0, now - rollup.bucketEnd);
946
+ const destinationTier = findTierForAge(tiers, ageMs);
947
+ if (!destinationTier) {
948
+ toDelete.push(rollup._id);
949
+ continue;
950
+ }
951
+ if (destinationTier.kind === "raw") {
952
+ continue;
953
+ }
954
+ const destinationBucketStart = getBucketStart(rollup.bucketStart, destinationTier.bucketMs);
955
+ const destinationBucketEnd = getBucketEnd(destinationBucketStart, destinationTier.bucketMs);
956
+ if (destinationTier.bucketMs === rollup.bucketMs) {
957
+ continue;
958
+ }
959
+ if (destinationBucketEnd > now - destinationTier.fromAgeMs) {
960
+ continue;
961
+ }
962
+ addStatsToRollupGroup(destinationGroups, destinationTier.bucketMs, destinationBucketStart, rollupDocToStats(rollup));
963
+ toDelete.push(rollup._id);
964
+ }
965
+ if (destinationGroups.size > 0) {
966
+ await upsertRollupStatsGroups(db, state.dataSourceId, state.seriesType, destinationGroups.values());
967
+ }
968
+ for (const rollupId of toDelete) {
969
+ await db.delete(rollupId);
970
+ }
971
+ }
972
+ async function sourceSeriesHasData(db, dataSourceId, seriesType) {
973
+ const raw = await db
974
+ .query("dataPoints")
975
+ .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", dataSourceId).eq("seriesType", seriesType))
976
+ .first();
977
+ if (raw) {
978
+ return true;
979
+ }
980
+ const rollup = await db
981
+ .query("timeSeriesRollups")
982
+ .withIndex("by_source_type_bucket", (idx) => idx.eq("dataSourceId", dataSourceId).eq("seriesType", seriesType))
983
+ .first();
984
+ return rollup !== null;
985
+ }
986
+ function aggregateRawPoints(points) {
987
+ const values = points.map((point) => point.value);
988
+ const lastPoint = points.reduce((latest, point) => point.recordedAt >= latest.recordedAt ? point : latest);
989
+ return {
990
+ avg: values.reduce((sum, value) => sum + value, 0) / values.length,
991
+ min: Math.min(...values),
992
+ max: Math.max(...values),
993
+ last: lastPoint.value,
994
+ lastRecordedAt: lastPoint.recordedAt,
995
+ count: points.length,
996
+ };
997
+ }
998
+ function combineAggregatedStats(stats) {
999
+ const totalCount = stats.reduce((sum, item) => sum + item.count, 0);
1000
+ const latest = stats.reduce((current, item) => item.lastRecordedAt >= current.lastRecordedAt ? item : current);
1001
+ return {
1002
+ avg: stats.reduce((sum, item) => sum + item.avg * item.count, 0) / totalCount,
1003
+ min: Math.min(...stats.map((item) => item.min)),
1004
+ max: Math.max(...stats.map((item) => item.max)),
1005
+ last: latest.last,
1006
+ lastRecordedAt: latest.lastRecordedAt,
1007
+ count: totalCount,
1008
+ };
1009
+ }
1010
+ function rollupDocToStats(rollup) {
1011
+ return {
1012
+ avg: rollup.avg,
1013
+ min: rollup.min,
1014
+ max: rollup.max,
1015
+ last: rollup.last,
1016
+ lastRecordedAt: rollup.lastRecordedAt,
1017
+ count: rollup.count,
1018
+ };
1019
+ }
1020
+ function addStatsToRollupGroup(groups, bucketMs, bucketStart, stats) {
1021
+ const key = `${bucketMs}:${bucketStart}`;
1022
+ const existing = groups.get(key);
1023
+ if (existing) {
1024
+ existing.stats = combineAggregatedStats([existing.stats, stats]);
1025
+ return;
1026
+ }
1027
+ groups.set(key, {
1028
+ bucketMs,
1029
+ bucketStart,
1030
+ stats,
1031
+ });
1032
+ }
1033
+ // ---------------------------------------------------------------------------
1034
+ // Read path
1035
+ // ---------------------------------------------------------------------------
1036
+ async function getPolicyAwarePointsForSource(db, args) {
1037
+ const effective = await resolveEffectivePolicy(db, args.userId, args.provider, args.seriesType);
1038
+ const now = Date.now();
1039
+ const points = [];
1040
+ for (const tier of effective.tiers) {
1041
+ const interval = getAbsoluteTimeRangeForTier(tier, now, args.startDate, args.endDate);
1042
+ if (!interval) {
1043
+ continue;
1044
+ }
1045
+ const preferred = tier.kind === "raw"
1046
+ ? await queryRawPoints(db, {
1047
+ dataSourceId: args.dataSourceId,
1048
+ seriesType: args.seriesType,
1049
+ startDate: interval.start,
1050
+ endDate: interval.end,
1051
+ limit: args.limit + 1,
1052
+ order: args.order,
1053
+ })
1054
+ : await queryRollupPoints(db, {
1055
+ dataSourceId: args.dataSourceId,
1056
+ seriesType: args.seriesType,
1057
+ bucketMs: tier.bucketMs,
1058
+ startDate: interval.start,
1059
+ endDate: interval.end,
1060
+ limit: args.limit + 1,
1061
+ order: args.order,
1062
+ aggregations: tier.aggregations,
1063
+ });
1064
+ if (preferred.length > 0) {
1065
+ points.push(...preferred);
1066
+ continue;
1067
+ }
1068
+ points.push(...(await queryFallbackPoints(db, {
1069
+ dataSourceId: args.dataSourceId,
1070
+ seriesType: args.seriesType,
1071
+ startDate: interval.start,
1072
+ endDate: interval.end,
1073
+ limit: args.limit + 1,
1074
+ order: args.order,
1075
+ })));
1076
+ }
1077
+ points.sort((a, b) => args.order === "asc" ? a.timestamp - b.timestamp : b.timestamp - a.timestamp);
1078
+ return points.slice(0, args.limit);
1079
+ }
1080
+ function getAbsoluteTimeRangeForTier(tier, now, startDate, endDate) {
1081
+ const absoluteStart = tier.toAgeMs === null ? startDate : Math.max(startDate, now - tier.toAgeMs + 1);
1082
+ const absoluteEnd = Math.min(endDate, now - tier.fromAgeMs);
1083
+ if (absoluteStart > absoluteEnd) {
1084
+ return null;
1085
+ }
1086
+ return {
1087
+ start: absoluteStart,
1088
+ end: absoluteEnd,
1089
+ };
1090
+ }
1091
+ async function queryRawPoints(db, args) {
1092
+ const raw = await db
1093
+ .query("dataPoints")
1094
+ .withIndex("by_source_type_time", (idx) => idx
1095
+ .eq("dataSourceId", args.dataSourceId)
1096
+ .eq("seriesType", args.seriesType)
1097
+ .gte("recordedAt", args.startDate)
1098
+ .lte("recordedAt", args.endDate))
1099
+ .order(args.order)
1100
+ .take(args.limit);
1101
+ return raw.map((point) => ({
1102
+ timestamp: point.recordedAt,
1103
+ value: point.value,
1104
+ resolution: "raw",
1105
+ }));
1106
+ }
1107
+ async function queryRollupPoints(db, args) {
1108
+ const rows = await db
1109
+ .query("timeSeriesRollups")
1110
+ .withIndex("by_source_type_bucket_size", (idx) => idx
1111
+ .eq("dataSourceId", args.dataSourceId)
1112
+ .eq("seriesType", args.seriesType)
1113
+ .eq("bucketMs", args.bucketMs)
1114
+ .gte("bucketStart", Math.max(0, args.startDate - args.bucketMs))
1115
+ .lte("bucketStart", args.endDate))
1116
+ .order(args.order)
1117
+ .take(args.limit);
1118
+ return rows
1119
+ .filter((row) => row.bucketEnd >= args.startDate && row.bucketStart <= args.endDate)
1120
+ .map((row) => toRollupPoint(row, args.aggregations));
1121
+ }
1122
+ async function queryFallbackPoints(db, args) {
1123
+ const raw = await queryRawPoints(db, args);
1124
+ if (raw.length > 0) {
1125
+ return raw;
1126
+ }
1127
+ const rows = await db
1128
+ .query("timeSeriesRollups")
1129
+ .withIndex("by_source_type_bucket", (idx) => idx
1130
+ .eq("dataSourceId", args.dataSourceId)
1131
+ .eq("seriesType", args.seriesType)
1132
+ .gte("bucketStart", Math.max(0, args.startDate - DAY_MS))
1133
+ .lte("bucketStart", args.endDate))
1134
+ .take(args.limit * 4);
1135
+ rows.sort((a, b) => a.bucketMs === b.bucketMs ? a.bucketStart - b.bucketStart : a.bucketMs - b.bucketMs);
1136
+ const filtered = rows.filter((row) => row.bucketEnd >= args.startDate && row.bucketStart <= args.endDate);
1137
+ return filtered.map((row) => toRollupPoint(row, normalizeAggregations(DEFAULT_TIME_SERIES_AGGREGATIONS)));
1138
+ }
1139
+ function toRollupPoint(rollup, aggregations) {
1140
+ return {
1141
+ timestamp: rollup.bucketStart,
1142
+ value: selectPrimaryRollupValue(rollup, aggregations),
1143
+ resolution: "rollup",
1144
+ bucketMinutes: Math.floor(rollup.bucketMs / (60 * 1000)),
1145
+ avg: rollup.avg,
1146
+ min: rollup.min,
1147
+ max: rollup.max,
1148
+ last: rollup.last,
1149
+ count: rollup.count,
1150
+ };
1151
+ }
1152
+ function selectPrimaryRollupValue(rollup, aggregations) {
1153
+ if (aggregations.includes("avg")) {
1154
+ return rollup.avg;
1155
+ }
1156
+ if (aggregations.includes("last")) {
1157
+ return rollup.last;
1158
+ }
1159
+ if (aggregations.includes("max")) {
1160
+ return rollup.max;
1161
+ }
1162
+ if (aggregations.includes("min")) {
1163
+ return rollup.min;
1164
+ }
1165
+ return rollup.count;
1166
+ }
258
1167
  //# sourceMappingURL=dataPoints.js.map