@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.
- package/README.md +395 -0
- package/dist/client/index.d.ts +47 -6
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +30 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/types.d.ts +83 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js.map +1 -1
- package/dist/component/_generated/component.d.ts +50 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/backfillJobs.d.ts +11 -11
- package/dist/component/connections.d.ts +9 -9
- package/dist/component/connections.d.ts.map +1 -1
- package/dist/component/connections.js +2 -0
- package/dist/component/connections.js.map +1 -1
- package/dist/component/dataPoints.d.ts +153 -39
- package/dist/component/dataPoints.d.ts.map +1 -1
- package/dist/component/dataPoints.js +1048 -139
- package/dist/component/dataPoints.js.map +1 -1
- package/dist/component/events.d.ts +13 -13
- package/dist/component/garminBackfill.d.ts +2 -2
- package/dist/component/garminWebhooks.d.ts +2 -2
- package/dist/component/garminWebhooks.d.ts.map +1 -1
- package/dist/component/garminWebhooks.js +2 -0
- package/dist/component/garminWebhooks.js.map +1 -1
- package/dist/component/lifecycle.d.ts +1 -1
- package/dist/component/lifecycle.d.ts.map +1 -1
- package/dist/component/lifecycle.js +39 -1
- package/dist/component/lifecycle.js.map +1 -1
- package/dist/component/oauthStates.d.ts +3 -3
- package/dist/component/schema.d.ts +192 -28
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +89 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sdkPush.d.ts +11 -11
- package/dist/component/summaries.d.ts +4 -4
- package/dist/component/syncJobs.d.ts +23 -23
- package/dist/component/syncWorkflow.d.ts +2 -2
- package/dist/component/timeSeriesPolicyUtils.d.ts +97 -0
- package/dist/component/timeSeriesPolicyUtils.d.ts.map +1 -0
- package/dist/component/timeSeriesPolicyUtils.js +163 -0
- package/dist/component/timeSeriesPolicyUtils.js.map +1 -0
- package/dist/test.d.ts +581 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +17 -0
- package/dist/test.js.map +1 -0
- package/package.json +12 -2
- package/src/client/_generated/_ignore.ts +2 -0
- package/src/client/index.test.ts +149 -0
- package/src/client/index.ts +859 -0
- package/src/client/types.ts +632 -0
- package/src/component/_generated/_ignore.ts +2 -0
- package/src/component/_generated/api.ts +16 -0
- package/src/component/_generated/component.ts +74 -0
- package/src/component/_generated/dataModel.ts +40 -0
- package/src/component/_generated/server.ts +48 -0
- package/src/component/backfillJobs.test.ts +47 -0
- package/src/component/backfillJobs.ts +245 -0
- package/src/component/connections.test.ts +297 -0
- package/src/component/connections.ts +329 -0
- package/src/component/convex.config.ts +7 -0
- package/src/component/dataPoints.test.ts +827 -0
- package/src/component/dataPoints.ts +1676 -0
- package/src/component/dataSources.test.ts +247 -0
- package/src/component/dataSources.ts +109 -0
- package/src/component/events.test.ts +380 -0
- package/src/component/events.ts +288 -0
- package/src/component/garminBackfill.ts +343 -0
- package/src/component/garminWebhooks.test.ts +609 -0
- package/src/component/garminWebhooks.ts +656 -0
- package/src/component/httpHandlers.ts +153 -0
- package/src/component/lifecycle.test.ts +179 -0
- package/src/component/lifecycle.ts +128 -0
- package/src/component/menstrualCycles.ts +124 -0
- package/src/component/oauthActions.ts +261 -0
- package/src/component/oauthStates.test.ts +170 -0
- package/src/component/oauthStates.ts +85 -0
- package/src/component/providerSettings.ts +66 -0
- package/src/component/providers/additionalProviders.test.ts +401 -0
- package/src/component/providers/garmin.ts +1169 -0
- package/src/component/providers/oauth.test.ts +174 -0
- package/src/component/providers/oauth.ts +246 -0
- package/src/component/providers/polar.ts +220 -0
- package/src/component/providers/registry.ts +37 -0
- package/src/component/providers/strava.test.ts +195 -0
- package/src/component/providers/strava.ts +253 -0
- package/src/component/providers/suunto.ts +592 -0
- package/src/component/providers/types.ts +189 -0
- package/src/component/providers/whoop.ts +600 -0
- package/src/component/schema.ts +445 -0
- package/src/component/sdkPush.test.ts +367 -0
- package/src/component/sdkPush.ts +440 -0
- package/src/component/summaries.test.ts +201 -0
- package/src/component/summaries.ts +143 -0
- package/src/component/syncJobs.test.ts +254 -0
- package/src/component/syncJobs.ts +140 -0
- package/src/component/syncWorkflow.test.ts +87 -0
- package/src/component/syncWorkflow.ts +739 -0
- package/src/component/test.setup.ts +6 -0
- package/src/component/timeSeriesPolicyUtils.ts +243 -0
- package/src/component/workflowManager.ts +19 -0
- package/src/test.ts +25 -0
|
@@ -1,12 +1,54 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
|
-
import {
|
|
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(
|
|
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
|
|
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
|
|
32
|
-
const
|
|
33
|
-
.
|
|
34
|
-
.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
81
|
-
const allPoints = [];
|
|
113
|
+
const points = [];
|
|
82
114
|
for (const source of sources) {
|
|
83
|
-
|
|
84
|
-
.
|
|
85
|
-
.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
144
|
-
.query("
|
|
145
|
-
.withIndex("
|
|
189
|
+
const states = await ctx.db
|
|
190
|
+
.query("timeSeriesSeriesState")
|
|
191
|
+
.withIndex("by_user", (idx) => idx.eq("userId", args.userId))
|
|
146
192
|
.collect();
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
248
|
-
for (const
|
|
249
|
-
await ctx.db.delete(
|
|
466
|
+
while (rawBatch.length > 0) {
|
|
467
|
+
for (const point of rawBatch) {
|
|
468
|
+
await ctx.db.delete(point._id);
|
|
250
469
|
}
|
|
251
|
-
|
|
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
|