@clipin/convex-wearables 0.0.3 → 0.1.1

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.
@@ -52,6 +52,29 @@ export const deleteAllUserData = mutation({
52
52
  .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", source._id))
53
53
  .take(500);
54
54
  }
55
+
56
+ let rollups = await ctx.db
57
+ .query("timeSeriesRollups")
58
+ .withIndex("by_source_type_bucket", (idx) => idx.eq("dataSourceId", source._id))
59
+ .take(500);
60
+ while (rollups.length > 0) {
61
+ for (const rollup of rollups) {
62
+ await ctx.db.delete(rollup._id);
63
+ }
64
+ rollups = await ctx.db
65
+ .query("timeSeriesRollups")
66
+ .withIndex("by_source_type_bucket", (idx) => idx.eq("dataSourceId", source._id))
67
+ .take(500);
68
+ }
69
+
70
+ const seriesStates = await ctx.db
71
+ .query("timeSeriesSeriesState")
72
+ .withIndex("by_source_series", (idx) => idx.eq("dataSourceId", source._id))
73
+ .collect();
74
+ for (const state of seriesStates) {
75
+ await ctx.db.delete(state._id);
76
+ }
77
+
55
78
  await ctx.db.delete(source._id);
56
79
  }
57
80
 
@@ -73,7 +96,25 @@ export const deleteAllUserData = mutation({
73
96
  await ctx.db.delete(summary._id);
74
97
  }
75
98
 
76
- // 5. Delete sync jobs
99
+ // 5. Delete menstrual cycles
100
+ const cycles = await ctx.db
101
+ .query("menstrualCycles")
102
+ .withIndex("by_user_date", (idx) => idx.eq("userId", userId))
103
+ .collect();
104
+ for (const cycle of cycles) {
105
+ await ctx.db.delete(cycle._id);
106
+ }
107
+
108
+ // 6. Delete user-level time-series policy assignments
109
+ const policyAssignments = await ctx.db
110
+ .query("timeSeriesPolicyAssignments")
111
+ .withIndex("by_user", (idx) => idx.eq("userId", userId))
112
+ .collect();
113
+ for (const assignment of policyAssignments) {
114
+ await ctx.db.delete(assignment._id);
115
+ }
116
+
117
+ // 7. Delete sync jobs
77
118
  const jobs = await ctx.db
78
119
  .query("syncJobs")
79
120
  .withIndex("by_user", (idx) => idx.eq("userId", userId))
@@ -53,6 +53,17 @@ export const backfillStatus = v.union(
53
53
  v.literal("canceled"),
54
54
  );
55
55
 
56
+ /**
57
+ * Supported rollup aggregations.
58
+ */
59
+ export const timeSeriesAggregation = v.union(
60
+ v.literal("avg"),
61
+ v.literal("min"),
62
+ v.literal("max"),
63
+ v.literal("last"),
64
+ v.literal("count"),
65
+ );
66
+
56
67
  // ---------------------------------------------------------------------------
57
68
  // Schema
58
69
  // ---------------------------------------------------------------------------
@@ -108,6 +119,28 @@ export default defineSchema({
108
119
  .index("by_source_type_time", ["dataSourceId", "seriesType", "recordedAt"])
109
120
  .index("by_type_time", ["seriesType", "recordedAt"]),
110
121
 
122
+ // -------------------------------------------------------------------------
123
+ // Time-Series Rollups — bucketed storage for dense historical series
124
+ // -------------------------------------------------------------------------
125
+ timeSeriesRollups: defineTable({
126
+ dataSourceId: v.id("dataSources"),
127
+ seriesType: v.string(),
128
+ bucketMs: v.number(),
129
+ bucketStart: v.number(),
130
+ bucketEnd: v.number(),
131
+ avg: v.number(),
132
+ min: v.number(),
133
+ max: v.number(),
134
+ last: v.number(),
135
+ lastRecordedAt: v.number(),
136
+ count: v.number(),
137
+ updatedAt: v.number(),
138
+ })
139
+ .index("by_source_type_bucket", ["dataSourceId", "seriesType", "bucketStart"])
140
+ .index("by_source_type_bucket_size", ["dataSourceId", "seriesType", "bucketMs", "bucketStart"])
141
+ .index("by_source_bucket", ["dataSourceId", "bucketStart"])
142
+ .index("by_type_bucket", ["seriesType", "bucketStart"]),
143
+
111
144
  // -------------------------------------------------------------------------
112
145
  // Events — workouts and sleep sessions
113
146
  // -------------------------------------------------------------------------
@@ -268,6 +301,79 @@ export default defineSchema({
268
301
  updatedAt: v.optional(v.number()),
269
302
  }).index("by_provider", ["provider"]),
270
303
 
304
+ // -------------------------------------------------------------------------
305
+ // Time-Series Policy Rules — default and preset-based storage rules
306
+ // -------------------------------------------------------------------------
307
+ timeSeriesPolicyRules: defineTable({
308
+ policySetKind: v.union(v.literal("default"), v.literal("preset")),
309
+ policySetKey: v.string(), // "__default__" or host-defined preset key
310
+ scopeKey: v.string(),
311
+ provider: v.optional(providerName),
312
+ seriesType: v.optional(v.string()),
313
+ tiers: v.array(
314
+ v.union(
315
+ v.object({
316
+ kind: v.literal("raw"),
317
+ fromAgeMs: v.number(),
318
+ toAgeMs: v.union(v.number(), v.null()),
319
+ }),
320
+ v.object({
321
+ kind: v.literal("rollup"),
322
+ fromAgeMs: v.number(),
323
+ toAgeMs: v.union(v.number(), v.null()),
324
+ bucketMs: v.number(),
325
+ aggregations: v.array(timeSeriesAggregation),
326
+ }),
327
+ ),
328
+ ),
329
+ updatedAt: v.number(),
330
+ })
331
+ .index("by_set", ["policySetKind", "policySetKey"])
332
+ .index("by_set_scope", ["policySetKind", "policySetKey", "provider", "seriesType"]),
333
+
334
+ // -------------------------------------------------------------------------
335
+ // Time-Series Policy Assignments — per-user preset selection
336
+ // -------------------------------------------------------------------------
337
+ timeSeriesPolicyAssignments: defineTable({
338
+ userId: v.string(),
339
+ presetKey: v.string(),
340
+ updatedAt: v.number(),
341
+ })
342
+ .index("by_user", ["userId"])
343
+ .index("by_preset", ["presetKey"]),
344
+
345
+ // -------------------------------------------------------------------------
346
+ // Time-Series Policy Settings — singleton settings for maintenance
347
+ // -------------------------------------------------------------------------
348
+ timeSeriesPolicySettings: defineTable({
349
+ key: v.string(),
350
+ maintenanceEnabled: v.boolean(),
351
+ maintenanceIntervalMs: v.number(),
352
+ scheduledAt: v.optional(v.number()),
353
+ lastRunAt: v.optional(v.number()),
354
+ lastError: v.optional(v.string()),
355
+ updatedAt: v.number(),
356
+ }).index("by_key", ["key"]),
357
+
358
+ // -------------------------------------------------------------------------
359
+ // Time-Series Series State — per source/series maintenance cursor
360
+ // -------------------------------------------------------------------------
361
+ timeSeriesSeriesState: defineTable({
362
+ dataSourceId: v.id("dataSources"),
363
+ connectionId: v.optional(v.id("connections")),
364
+ userId: v.string(),
365
+ provider: providerName,
366
+ seriesType: v.string(),
367
+ latestRecordedAt: v.number(),
368
+ lastIngestedAt: v.number(),
369
+ nextMaintenanceAt: v.number(),
370
+ lastMaintenanceAt: v.optional(v.number()),
371
+ updatedAt: v.number(),
372
+ })
373
+ .index("by_source_series", ["dataSourceId", "seriesType"])
374
+ .index("by_next_maintenance", ["nextMaintenanceAt"])
375
+ .index("by_user", ["userId"]),
376
+
271
377
  // -------------------------------------------------------------------------
272
378
  // Provider Priorities — sync order when multiple providers have same data
273
379
  // -------------------------------------------------------------------------
@@ -0,0 +1,243 @@
1
+ import type { Doc } from "./_generated/dataModel";
2
+
3
+ export const DEFAULT_POLICY_SET_KEY = "__default__";
4
+ export const DEFAULT_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1000;
5
+ export const DEFAULT_TIME_SERIES_AGGREGATIONS = ["avg", "min", "max", "last", "count"] as const;
6
+
7
+ export type DurationInput = string | number;
8
+ export type TimeSeriesAggregation = Doc<"timeSeriesPolicyRules">["tiers"][number] extends infer Tier
9
+ ? Tier extends { aggregations: infer Aggregations }
10
+ ? Aggregations extends Array<infer Aggregation>
11
+ ? Aggregation
12
+ : never
13
+ : never
14
+ : never;
15
+
16
+ export type TimeSeriesTierInput =
17
+ | {
18
+ kind: "raw";
19
+ fromAge: DurationInput;
20
+ toAge: DurationInput | null;
21
+ }
22
+ | {
23
+ kind: "rollup";
24
+ fromAge: DurationInput;
25
+ toAge: DurationInput | null;
26
+ bucket: DurationInput;
27
+ aggregations?: TimeSeriesAggregation[];
28
+ };
29
+
30
+ export type NormalizedTimeSeriesTier = Doc<"timeSeriesPolicyRules">["tiers"][number];
31
+
32
+ export type TimeSeriesPolicyRuleInput = {
33
+ provider?: Doc<"timeSeriesPolicyRules">["provider"];
34
+ seriesType?: string;
35
+ tiers: TimeSeriesTierInput[];
36
+ };
37
+
38
+ const DURATION_UNITS = {
39
+ ms: 1,
40
+ s: 1000,
41
+ m: 60 * 1000,
42
+ h: 60 * 60 * 1000,
43
+ d: 24 * 60 * 60 * 1000,
44
+ w: 7 * 24 * 60 * 60 * 1000,
45
+ } as const;
46
+
47
+ export function parseDurationInput(input: DurationInput, label: string) {
48
+ if (typeof input === "number") {
49
+ if (!Number.isFinite(input) || input < 0) {
50
+ throw new Error(`${label} must be a non-negative duration`);
51
+ }
52
+ return Math.floor(input);
53
+ }
54
+
55
+ const trimmed = input.trim().toLowerCase();
56
+ const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/.exec(trimmed);
57
+ if (!match) {
58
+ throw new Error(
59
+ `${label} must be a duration like "30m", "24h", "7d", or a numeric millisecond value`,
60
+ );
61
+ }
62
+
63
+ const value = Number(match[1]);
64
+ const unit = match[2] as keyof typeof DURATION_UNITS;
65
+ return Math.floor(value * DURATION_UNITS[unit]);
66
+ }
67
+
68
+ export function createTimeSeriesPolicyScopeKey(provider?: string, seriesType?: string) {
69
+ return `${provider ?? "*"}::${seriesType ?? "*"}`;
70
+ }
71
+
72
+ export function inferTimeSeriesPolicyScope(provider?: string, seriesType?: string) {
73
+ if (provider && seriesType) {
74
+ return "provider_series" as const;
75
+ }
76
+ if (seriesType) {
77
+ return "series" as const;
78
+ }
79
+ if (provider) {
80
+ return "provider" as const;
81
+ }
82
+ return "global" as const;
83
+ }
84
+
85
+ export function normalizeAggregations(aggregations?: readonly TimeSeriesAggregation[]) {
86
+ const unique = new Set(aggregations ?? (DEFAULT_TIME_SERIES_AGGREGATIONS as readonly string[]));
87
+ if (unique.size === 0) {
88
+ throw new Error("Rollup tiers must include at least one aggregation");
89
+ }
90
+ return Array.from(unique) as TimeSeriesAggregation[];
91
+ }
92
+
93
+ export function normalizeTimeSeriesTierInputs(tiers: TimeSeriesTierInput[]) {
94
+ if (tiers.length === 0) {
95
+ throw new Error("A time-series policy rule must include at least one tier");
96
+ }
97
+
98
+ let rawTierCount = 0;
99
+ const normalized = tiers.map((tier, index) => {
100
+ const fromAgeMs = parseDurationInput(tier.fromAge, `tiers[${index}].fromAge`);
101
+ const toAgeMs =
102
+ tier.toAge === null ? null : parseDurationInput(tier.toAge, `tiers[${index}].toAge`);
103
+
104
+ if (toAgeMs !== null && toAgeMs <= fromAgeMs) {
105
+ throw new Error(`tiers[${index}] must have toAge greater than fromAge`);
106
+ }
107
+
108
+ if (tier.kind === "raw") {
109
+ rawTierCount += 1;
110
+ return {
111
+ kind: "raw" as const,
112
+ fromAgeMs,
113
+ toAgeMs,
114
+ };
115
+ }
116
+
117
+ const bucketMs = parseDurationInput(tier.bucket, `tiers[${index}].bucket`);
118
+ if (bucketMs <= 0 || bucketMs % (60 * 1000) !== 0) {
119
+ throw new Error(`tiers[${index}].bucket must be a positive whole-minute duration`);
120
+ }
121
+
122
+ return {
123
+ kind: "rollup" as const,
124
+ fromAgeMs,
125
+ toAgeMs,
126
+ bucketMs,
127
+ aggregations: normalizeAggregations(tier.aggregations),
128
+ };
129
+ });
130
+
131
+ if (normalized[0].fromAgeMs !== 0) {
132
+ throw new Error("The first tier must start at age 0");
133
+ }
134
+ if (rawTierCount > 1) {
135
+ throw new Error("Only one raw tier is supported in a single policy rule");
136
+ }
137
+
138
+ for (let index = 0; index < normalized.length - 1; index += 1) {
139
+ const current = normalized[index];
140
+ const next = normalized[index + 1];
141
+
142
+ if (current.toAgeMs === null) {
143
+ throw new Error("Open-ended tiers must be the final tier");
144
+ }
145
+ if (next.fromAgeMs !== current.toAgeMs) {
146
+ throw new Error("Policy tiers must be contiguous without gaps or overlap");
147
+ }
148
+ }
149
+
150
+ return normalized;
151
+ }
152
+
153
+ export function normalizeTimeSeriesPolicyRuleInputs(rules: TimeSeriesPolicyRuleInput[]) {
154
+ const seenScopes = new Set<string>();
155
+
156
+ return rules.map((rule, index) => {
157
+ const scopeKey = createTimeSeriesPolicyScopeKey(rule.provider, rule.seriesType);
158
+ if (seenScopes.has(scopeKey)) {
159
+ throw new Error(`Duplicate time-series policy rule scope "${scopeKey}" at index ${index}`);
160
+ }
161
+ seenScopes.add(scopeKey);
162
+
163
+ return {
164
+ provider: rule.provider,
165
+ seriesType: rule.seriesType,
166
+ tiers: normalizeTimeSeriesTierInputs(rule.tiers),
167
+ };
168
+ });
169
+ }
170
+
171
+ export function findTierForAge(tiers: NormalizedTimeSeriesTier[], ageMs: number) {
172
+ return (
173
+ tiers.find(
174
+ (tier) => ageMs >= tier.fromAgeMs && (tier.toAgeMs === null || ageMs < tier.toAgeMs),
175
+ ) ?? null
176
+ );
177
+ }
178
+
179
+ export function getRawTier(tiers: NormalizedTimeSeriesTier[]) {
180
+ return tiers.find((tier) => tier.kind === "raw") ?? null;
181
+ }
182
+
183
+ export function getRollupTiers(tiers: NormalizedTimeSeriesTier[]) {
184
+ return tiers.filter((tier) => tier.kind === "rollup");
185
+ }
186
+
187
+ export function buildBuiltinFullTiers(): NormalizedTimeSeriesTier[] {
188
+ return [
189
+ {
190
+ kind: "raw",
191
+ fromAgeMs: 0,
192
+ toAgeMs: null,
193
+ },
194
+ ];
195
+ }
196
+
197
+ export function resolveScopedPolicyRule<
198
+ Rule extends {
199
+ provider?: string;
200
+ seriesType?: string;
201
+ },
202
+ >(rules: Rule[], provider: string, seriesType: string) {
203
+ return (
204
+ rules.find((rule) => rule.provider === provider && rule.seriesType === seriesType) ??
205
+ rules.find((rule) => rule.provider === undefined && rule.seriesType === seriesType) ??
206
+ rules.find((rule) => rule.provider === provider && rule.seriesType === undefined) ??
207
+ rules.find((rule) => rule.provider === undefined && rule.seriesType === undefined) ??
208
+ null
209
+ );
210
+ }
211
+
212
+ export function comparePolicyScopes(
213
+ a: { provider?: string; seriesType?: string },
214
+ b: { provider?: string; seriesType?: string },
215
+ ) {
216
+ const weight = (item: { provider?: string; seriesType?: string }) => {
217
+ const scope = inferTimeSeriesPolicyScope(item.provider, item.seriesType);
218
+ switch (scope) {
219
+ case "global":
220
+ return 0;
221
+ case "provider":
222
+ return 1;
223
+ case "series":
224
+ return 2;
225
+ case "provider_series":
226
+ return 3;
227
+ }
228
+ };
229
+
230
+ return (
231
+ weight(a) - weight(b) ||
232
+ (a.provider ?? "").localeCompare(b.provider ?? "") ||
233
+ (a.seriesType ?? "").localeCompare(b.seriesType ?? "")
234
+ );
235
+ }
236
+
237
+ export function getBucketStart(recordedAt: number, bucketMs: number) {
238
+ return Math.floor(recordedAt / bucketMs) * bucketMs;
239
+ }
240
+
241
+ export function getBucketEnd(bucketStart: number, bucketMs: number) {
242
+ return bucketStart + bucketMs - 1;
243
+ }