@iann29/rastro 0.1.0-alpha.0 → 0.1.0-alpha.10

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 (164) hide show
  1. package/README.md +579 -57
  2. package/agent/integration.md +661 -0
  3. package/agent/manifest.json +187 -0
  4. package/agent/manifest.schema.json +411 -0
  5. package/dist/client/federation.d.ts +236 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +196 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +2467 -18
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +461 -32
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +18 -0
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +302 -4
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +4 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/affiliates.d.ts.map +1 -1
  22. package/dist/component/affiliates.js +6 -2
  23. package/dist/component/affiliates.js.map +1 -1
  24. package/dist/component/cardinality.d.ts +12 -0
  25. package/dist/component/cardinality.d.ts.map +1 -0
  26. package/dist/component/cardinality.js +94 -0
  27. package/dist/component/cardinality.js.map +1 -0
  28. package/dist/component/constants.d.ts +34 -7
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +47 -7
  31. package/dist/component/constants.js.map +1 -1
  32. package/dist/component/convex.config.d.ts +6 -1
  33. package/dist/component/convex.config.js +9 -1
  34. package/dist/component/convex.config.js.map +1 -1
  35. package/dist/component/coverage.d.ts +19 -0
  36. package/dist/component/coverage.d.ts.map +1 -0
  37. package/dist/component/coverage.js +30 -0
  38. package/dist/component/coverage.js.map +1 -0
  39. package/dist/component/diagnostics.d.ts +9 -0
  40. package/dist/component/diagnostics.d.ts.map +1 -0
  41. package/dist/component/diagnostics.js +47 -0
  42. package/dist/component/diagnostics.js.map +1 -0
  43. package/dist/component/errors.d.ts +1 -1
  44. package/dist/component/errors.d.ts.map +1 -1
  45. package/dist/component/errors.js.map +1 -1
  46. package/dist/component/eventStore.d.ts +106 -0
  47. package/dist/component/eventStore.d.ts.map +1 -0
  48. package/dist/component/eventStore.js +294 -0
  49. package/dist/component/eventStore.js.map +1 -0
  50. package/dist/component/funnels.d.ts.map +1 -1
  51. package/dist/component/funnels.js +5 -3
  52. package/dist/component/funnels.js.map +1 -1
  53. package/dist/component/geo.d.ts +71 -0
  54. package/dist/component/geo.d.ts.map +1 -0
  55. package/dist/component/geo.js +611 -0
  56. package/dist/component/geo.js.map +1 -0
  57. package/dist/component/goals.d.ts.map +1 -1
  58. package/dist/component/goals.js +25 -9
  59. package/dist/component/goals.js.map +1 -1
  60. package/dist/component/guards.d.ts.map +1 -1
  61. package/dist/component/guards.js.map +1 -1
  62. package/dist/component/http.d.ts.map +1 -1
  63. package/dist/component/http.js +284 -53
  64. package/dist/component/http.js.map +1 -1
  65. package/dist/component/identity.d.ts +13 -0
  66. package/dist/component/identity.d.ts.map +1 -0
  67. package/dist/component/identity.js +58 -0
  68. package/dist/component/identity.js.map +1 -0
  69. package/dist/component/ingest.d.ts +3 -1
  70. package/dist/component/ingest.d.ts.map +1 -1
  71. package/dist/component/ingest.js +1105 -243
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts +8 -0
  74. package/dist/component/live.d.ts.map +1 -1
  75. package/dist/component/live.js +74 -9
  76. package/dist/component/live.js.map +1 -1
  77. package/dist/component/reports.d.ts +273 -24
  78. package/dist/component/reports.d.ts.map +1 -1
  79. package/dist/component/reports.js +1320 -150
  80. package/dist/component/reports.js.map +1 -1
  81. package/dist/component/retention.d.ts +75 -1
  82. package/dist/component/retention.d.ts.map +1 -1
  83. package/dist/component/retention.js +541 -31
  84. package/dist/component/retention.js.map +1 -1
  85. package/dist/component/sanitize.d.ts +24 -1
  86. package/dist/component/sanitize.d.ts.map +1 -1
  87. package/dist/component/sanitize.js +107 -19
  88. package/dist/component/sanitize.js.map +1 -1
  89. package/dist/component/schema.d.ts +462 -55
  90. package/dist/component/schema.js +195 -15
  91. package/dist/component/schema.js.map +1 -1
  92. package/dist/component/sites.d.ts.map +1 -1
  93. package/dist/component/sites.js +38 -25
  94. package/dist/component/sites.js.map +1 -1
  95. package/dist/component/useragent.d.ts +9 -0
  96. package/dist/component/useragent.d.ts.map +1 -0
  97. package/dist/component/useragent.js +152 -0
  98. package/dist/component/useragent.js.map +1 -0
  99. package/dist/component/validators.d.ts +101 -16
  100. package/dist/component/validators.d.ts.map +1 -1
  101. package/dist/component/validators.js +19 -2
  102. package/dist/component/validators.js.map +1 -1
  103. package/dist/component/visitors.d.ts +19 -0
  104. package/dist/component/visitors.d.ts.map +1 -0
  105. package/dist/component/visitors.js +86 -0
  106. package/dist/component/visitors.js.map +1 -0
  107. package/dist/component/vitals.d.ts +41 -0
  108. package/dist/component/vitals.d.ts.map +1 -0
  109. package/dist/component/vitals.js +115 -0
  110. package/dist/component/vitals.js.map +1 -0
  111. package/dist/react/index.d.ts.map +1 -1
  112. package/dist/react/index.js.map +1 -1
  113. package/dist/tracker/generated.d.ts +8 -4
  114. package/dist/tracker/generated.d.ts.map +1 -1
  115. package/dist/tracker/generated.js +8 -4
  116. package/dist/tracker/generated.js.map +1 -1
  117. package/dist/tracker/tracker.d.ts +1 -1
  118. package/dist/tracker/tracker.d.ts.map +1 -1
  119. package/dist/tracker/tracker.js +81 -33
  120. package/dist/tracker/tracker.js.map +1 -1
  121. package/dist/tracker/vitals.d.ts +10 -0
  122. package/dist/tracker/vitals.d.ts.map +1 -0
  123. package/dist/tracker/vitals.js +140 -0
  124. package/dist/tracker/vitals.js.map +1 -0
  125. package/dist/tracker.min.js +1 -1
  126. package/dist/vitals.min.js +1 -0
  127. package/docs/benchmarks/2026-08-20-realistic.md +171 -0
  128. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  129. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  130. package/docs/federation-setup.md +397 -0
  131. package/docs/federation.md +262 -0
  132. package/docs/upgrading.md +259 -0
  133. package/llms.txt +71 -0
  134. package/package.json +61 -11
  135. package/scripts/benchmark-ingest.mjs +601 -0
  136. package/scripts/generate-federation-keys.mjs +20 -0
  137. package/src/component/_generated/api.ts +18 -0
  138. package/src/component/_generated/component.ts +366 -4
  139. package/src/component/_generated/server.ts +4 -0
  140. package/src/component/affiliates.ts +20 -5
  141. package/src/component/cardinality.ts +117 -0
  142. package/src/component/constants.ts +47 -7
  143. package/src/component/convex.config.ts +11 -1
  144. package/src/component/coverage.ts +36 -0
  145. package/src/component/diagnostics.ts +65 -0
  146. package/src/component/errors.ts +2 -1
  147. package/src/component/eventStore.ts +489 -0
  148. package/src/component/funnels.ts +19 -16
  149. package/src/component/geo.ts +781 -0
  150. package/src/component/goals.ts +45 -16
  151. package/src/component/guards.ts +3 -1
  152. package/src/component/http.ts +403 -60
  153. package/src/component/identity.ts +74 -0
  154. package/src/component/ingest.ts +1652 -291
  155. package/src/component/live.ts +90 -9
  156. package/src/component/reports.ts +1929 -199
  157. package/src/component/retention.ts +774 -97
  158. package/src/component/sanitize.ts +154 -32
  159. package/src/component/schema.ts +219 -15
  160. package/src/component/sites.ts +56 -28
  161. package/src/component/useragent.ts +171 -0
  162. package/src/component/validators.ts +29 -1
  163. package/src/component/visitors.ts +116 -0
  164. package/src/component/vitals.ts +146 -0
@@ -1,11 +1,22 @@
1
1
  import { v } from "convex/values";
2
+ import { internal } from "./_generated/api.js";
2
3
  import type { Id } from "./_generated/dataModel.js";
3
4
  import type { MutationCtx } from "./_generated/server.js";
4
- import { mutation } from "./_generated/server.js";
5
- import { MAX_RETENTION_BATCH } from "./constants.js";
5
+ import { internalMutation, mutation, query } from "./_generated/server.js";
6
+ import {
7
+ DAY_MS,
8
+ HOUR_MS,
9
+ MAX_RETENTION_BATCH,
10
+ MINUTE_MS,
11
+ RATE_LIMIT_WINDOW_MS,
12
+ RETENTION_LEASE_MS,
13
+ RETENTION_POLICY_INTERVAL_MS,
14
+ } from "./constants.js";
15
+ import { ensureAnalyticsControl } from "./coverage.js";
6
16
  import { fail } from "./errors.js";
17
+ import { deleteTelemetryBefore } from "./eventStore.js";
7
18
 
8
- const cleanupKindValidator = v.union(
19
+ export const cleanupKindValidator = v.union(
9
20
  v.literal("events"),
10
21
  v.literal("sessions"),
11
22
  v.literal("conversions"),
@@ -15,12 +26,23 @@ const cleanupKindValidator = v.union(
15
26
  v.literal("funnelProgress"),
16
27
  v.literal("funnelSteps"),
17
28
  v.literal("affiliateStats"),
29
+ v.literal("goalRollups"),
30
+ v.literal("funnelRollups"),
31
+ v.literal("affiliateRollups"),
32
+ v.literal("vitalRollups"),
33
+ v.literal("siteMapRollups"),
18
34
  v.literal("attributions"),
19
35
  v.literal("liveSessions"),
20
36
  v.literal("ingestWindows"),
21
37
  v.literal("siteIngestWindows"),
22
38
  );
23
39
 
40
+ const cleanupResultValidator = v.object({
41
+ deleted: v.number(),
42
+ hasMore: v.boolean(),
43
+ before: v.number(),
44
+ });
45
+
24
46
  export const cleanup = mutation({
25
47
  args: {
26
48
  siteId: v.id("sites"),
@@ -28,28 +50,385 @@ export const cleanup = mutation({
28
50
  before: v.number(),
29
51
  batchSize: v.optional(v.number()),
30
52
  },
31
- returns: v.object({ deleted: v.number(), hasMore: v.boolean() }),
53
+ returns: cleanupResultValidator,
54
+ handler: async (ctx, args) => {
55
+ const policy = await ctx.db
56
+ .query("retentionPolicies")
57
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
58
+ .unique();
59
+ if (policy) {
60
+ fail(
61
+ "CONFLICT",
62
+ "disable the retention policy before running manual cleanup",
63
+ );
64
+ }
65
+ const before = safeCleanupBoundary(args.kind, args.before, Date.now());
66
+ const result = await executeCleanupBatch(ctx, { ...args, before });
67
+ await persistDirectState(ctx, { ...args, before }, result);
68
+ await recordRetentionBoundary(ctx, args.siteId, args.kind, before);
69
+ return { ...result, before };
70
+ },
71
+ });
72
+
73
+ const policyValidator = v.object({
74
+ eventsDays: v.number(),
75
+ sessionsDays: v.number(),
76
+ conversionsDays: v.number(),
77
+ aggregatesDays: v.number(),
78
+ reportsDays: v.number(),
79
+ });
80
+
81
+ export const setPolicy = mutation({
82
+ args: { siteId: v.id("sites"), ...policyValidator.fields },
83
+ returns: v.null(),
84
+ handler: async (ctx, args) => {
85
+ validatePolicy(args);
86
+ const now = Date.now();
87
+ const existingPolicy = await ctx.db
88
+ .query("retentionPolicies")
89
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
90
+ .unique();
91
+ const policy = { ...args, updatedAt: now };
92
+ if (existingPolicy) {
93
+ await ctx.db.replace("retentionPolicies", existingPolicy._id, policy);
94
+ } else {
95
+ await ctx.db.insert("retentionPolicies", policy);
96
+ }
97
+
98
+ for (const kind of POLICY_CLEANUP_KINDS) {
99
+ const requestedBefore = now - retentionDaysForKind(policy, kind) * DAY_MS;
100
+ const before = safeCleanupBoundary(kind, requestedBefore, now);
101
+ const existing = await loadState(ctx, args.siteId, kind);
102
+ const generation = (existing?.generation ?? 0) + 1;
103
+ const fields = {
104
+ siteId: args.siteId,
105
+ kind,
106
+ before,
107
+ status: "pending" as const,
108
+ generation,
109
+ attempt: 0,
110
+ scheduledAt: now,
111
+ deleted: 0,
112
+ updatedAt: now,
113
+ };
114
+ const stateId = existing
115
+ ? (await ctx.db.replace("retentionStates", existing._id, fields),
116
+ existing._id)
117
+ : await ctx.db.insert("retentionStates", fields);
118
+ await ctx.scheduler.runAfter(0, internal.retention.runScheduled, {
119
+ stateId,
120
+ generation,
121
+ });
122
+ }
123
+ return null;
124
+ },
125
+ });
126
+
127
+ export const disablePolicy = mutation({
128
+ args: { siteId: v.id("sites") },
129
+ returns: v.null(),
130
+ handler: async (ctx, args) => {
131
+ const policy = await ctx.db
132
+ .query("retentionPolicies")
133
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
134
+ .unique();
135
+ if (policy) await ctx.db.delete("retentionPolicies", policy._id);
136
+ const now = Date.now();
137
+ const states = await ctx.db
138
+ .query("retentionStates")
139
+ .withIndex("by_siteId_and_kind", (range) =>
140
+ range.eq("siteId", args.siteId),
141
+ )
142
+ .take(POLICY_CLEANUP_KINDS.length + 1);
143
+ for (const state of states) {
144
+ await ctx.db.patch("retentionStates", state._id, {
145
+ status: "complete",
146
+ generation: state.generation + 1,
147
+ leaseExpiresAt: undefined,
148
+ scheduledAt: undefined,
149
+ updatedAt: now,
150
+ });
151
+ }
152
+ return null;
153
+ },
154
+ });
155
+
156
+ export const runPolicy = mutation({
157
+ args: {
158
+ siteId: v.id("sites"),
159
+ kind: cleanupKindValidator,
160
+ batchSize: v.optional(v.number()),
161
+ },
162
+ returns: cleanupResultValidator,
163
+ handler: async (ctx, args) => {
164
+ const policy = await ctx.db
165
+ .query("retentionPolicies")
166
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
167
+ .unique();
168
+ if (!policy) fail("NOT_FOUND", "retention policy not found");
169
+ const now = Date.now();
170
+ const existing = await loadState(ctx, args.siteId, args.kind);
171
+ if (
172
+ existing?.status === "running" &&
173
+ existing.leaseExpiresAt !== undefined &&
174
+ existing.leaseExpiresAt > now
175
+ ) {
176
+ return { deleted: 0, hasMore: true, before: existing.before };
177
+ }
178
+ const before =
179
+ existing && existing.status !== "complete"
180
+ ? existing.before
181
+ : safeCleanupBoundary(
182
+ args.kind,
183
+ now - retentionDaysForKind(policy, args.kind) * DAY_MS,
184
+ now,
185
+ );
186
+ const result = await executeCleanupBatch(ctx, { ...args, before });
187
+ const generation = existing?.generation ?? 1;
188
+ const fields = {
189
+ siteId: args.siteId,
190
+ kind: args.kind,
191
+ before,
192
+ status: result.hasMore ? ("pending" as const) : ("complete" as const),
193
+ generation,
194
+ attempt: existing?.attempt ?? 0,
195
+ scheduledAt: result.hasMore ? now : now + RETENTION_POLICY_INTERVAL_MS,
196
+ deleted:
197
+ existing?.before === before
198
+ ? existing.deleted + result.deleted
199
+ : result.deleted,
200
+ updatedAt: now,
201
+ };
202
+ const stateId = existing
203
+ ? (await ctx.db.replace("retentionStates", existing._id, fields),
204
+ existing._id)
205
+ : await ctx.db.insert("retentionStates", fields);
206
+ await recordRetentionBoundary(ctx, args.siteId, args.kind, before);
207
+ if (result.hasMore) {
208
+ await ctx.scheduler.runAfter(0, internal.retention.runScheduled, {
209
+ stateId,
210
+ generation,
211
+ });
212
+ } else {
213
+ await ctx.scheduler.runAt(
214
+ now + RETENTION_POLICY_INTERVAL_MS,
215
+ internal.retention.startScheduledCycle,
216
+ { stateId, generation },
217
+ );
218
+ }
219
+ return { ...result, before };
220
+ },
221
+ });
222
+
223
+ export const runScheduled = internalMutation({
224
+ args: { stateId: v.id("retentionStates"), generation: v.number() },
225
+ returns: v.object({
226
+ claimed: v.boolean(),
227
+ stale: v.boolean(),
228
+ attempt: v.number(),
229
+ }),
32
230
  handler: async (ctx, args) => {
33
- if (!Number.isFinite(args.before) || args.before < 0) {
34
- fail("INVALID_ARGUMENT", "before must be a positive timestamp");
231
+ const state = await ctx.db.get("retentionStates", args.stateId);
232
+ const now = Date.now();
233
+ if (
234
+ !state ||
235
+ state.generation !== args.generation ||
236
+ state.status === "complete" ||
237
+ (state.status === "running" &&
238
+ state.leaseExpiresAt !== undefined &&
239
+ state.leaseExpiresAt > now)
240
+ ) {
241
+ return { claimed: false, stale: true, attempt: state?.attempt ?? 0 };
35
242
  }
36
- const batchSize = args.batchSize ?? 100;
37
- if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > MAX_RETENTION_BATCH) {
38
- fail("INVALID_ARGUMENT", `batchSize must be an integer between 1 and ${MAX_RETENTION_BATCH}`);
243
+ const attempt = state.attempt + 1;
244
+ const leaseExpiresAt = now + RETENTION_LEASE_MS;
245
+ await ctx.db.patch("retentionStates", state._id, {
246
+ status: "running",
247
+ attempt,
248
+ leaseExpiresAt,
249
+ scheduledAt: now,
250
+ updatedAt: now,
251
+ });
252
+ await ctx.scheduler.runAfter(0, internal.retention.executeScheduled, {
253
+ stateId: state._id,
254
+ generation: state.generation,
255
+ attempt,
256
+ });
257
+ await ctx.scheduler.runAt(leaseExpiresAt, internal.retention.runScheduled, {
258
+ stateId: state._id,
259
+ generation: state.generation,
260
+ });
261
+ return { claimed: true, stale: false, attempt };
262
+ },
263
+ });
264
+
265
+ export const executeScheduled = internalMutation({
266
+ args: {
267
+ stateId: v.id("retentionStates"),
268
+ generation: v.number(),
269
+ attempt: v.number(),
270
+ },
271
+ returns: v.object({
272
+ deleted: v.number(),
273
+ hasMore: v.boolean(),
274
+ stale: v.boolean(),
275
+ }),
276
+ handler: async (ctx, args) => {
277
+ const state = await ctx.db.get("retentionStates", args.stateId);
278
+ if (
279
+ !state ||
280
+ state.generation !== args.generation ||
281
+ state.attempt !== args.attempt ||
282
+ state.status !== "running"
283
+ ) {
284
+ return { deleted: 0, hasMore: false, stale: true };
39
285
  }
40
- const deletions = await deletionsForCleanup(
286
+ const result = await executeCleanupBatch(ctx, {
287
+ siteId: state.siteId,
288
+ kind: state.kind as CleanupKind,
289
+ before: state.before,
290
+ batchSize: 100,
291
+ });
292
+ const now = Date.now();
293
+ const policy = result.hasMore
294
+ ? null
295
+ : await ctx.db
296
+ .query("retentionPolicies")
297
+ .withIndex("by_siteId", (range) => range.eq("siteId", state.siteId))
298
+ .unique();
299
+ const nextCycleAt = policy ? now + RETENTION_POLICY_INTERVAL_MS : undefined;
300
+ await ctx.db.patch("retentionStates", state._id, {
301
+ status: result.hasMore ? "pending" : "complete",
302
+ leaseExpiresAt: undefined,
303
+ scheduledAt: result.hasMore ? now : nextCycleAt,
304
+ deleted: state.deleted + result.deleted,
305
+ updatedAt: now,
306
+ });
307
+ await recordRetentionBoundary(
41
308
  ctx,
42
- args.siteId,
43
- args.kind,
44
- args.before,
45
- batchSize + 1,
309
+ state.siteId,
310
+ state.kind as CleanupKind,
311
+ state.before,
46
312
  );
47
- for (const remove of deletions.slice(0, batchSize)) {
48
- await remove();
313
+ if (result.hasMore) {
314
+ await ctx.scheduler.runAfter(0, internal.retention.runScheduled, {
315
+ stateId: state._id,
316
+ generation: state.generation,
317
+ });
318
+ } else if (nextCycleAt !== undefined) {
319
+ await ctx.scheduler.runAt(
320
+ nextCycleAt,
321
+ internal.retention.startScheduledCycle,
322
+ { stateId: state._id, generation: state.generation },
323
+ );
49
324
  }
325
+ return { ...result, stale: false };
326
+ },
327
+ });
328
+
329
+ export const startScheduledCycle = internalMutation({
330
+ args: { stateId: v.id("retentionStates"), generation: v.number() },
331
+ returns: v.object({
332
+ started: v.boolean(),
333
+ stale: v.boolean(),
334
+ before: v.union(v.number(), v.null()),
335
+ }),
336
+ handler: async (ctx, args) => {
337
+ const state = await ctx.db.get("retentionStates", args.stateId);
338
+ const now = Date.now();
339
+ if (
340
+ !state ||
341
+ state.generation !== args.generation ||
342
+ state.status !== "complete" ||
343
+ (state.scheduledAt !== undefined && state.scheduledAt > now)
344
+ ) {
345
+ return { started: false, stale: true, before: null };
346
+ }
347
+ const policy = await ctx.db
348
+ .query("retentionPolicies")
349
+ .withIndex("by_siteId", (range) => range.eq("siteId", state.siteId))
350
+ .unique();
351
+ if (!policy) return { started: false, stale: true, before: null };
352
+ const kind = state.kind as CleanupKind;
353
+ const before = safeCleanupBoundary(
354
+ kind,
355
+ now - retentionDaysForKind(policy, kind) * DAY_MS,
356
+ now,
357
+ );
358
+ await ctx.db.patch("retentionStates", state._id, {
359
+ before,
360
+ status: "pending",
361
+ attempt: 0,
362
+ leaseExpiresAt: undefined,
363
+ scheduledAt: now,
364
+ deleted: 0,
365
+ updatedAt: now,
366
+ });
367
+ await ctx.scheduler.runAfter(0, internal.retention.runScheduled, {
368
+ stateId: state._id,
369
+ generation: state.generation,
370
+ });
371
+ return { started: true, stale: false, before };
372
+ },
373
+ });
374
+
375
+ export const status = query({
376
+ args: { siteId: v.id("sites") },
377
+ returns: v.object({
378
+ policy: v.union(
379
+ policyValidator.extend({ updatedAt: v.number() }),
380
+ v.null(),
381
+ ),
382
+ jobs: v.array(
383
+ v.object({
384
+ kind: cleanupKindValidator,
385
+ before: v.number(),
386
+ status: v.union(
387
+ v.literal("pending"),
388
+ v.literal("running"),
389
+ v.literal("complete"),
390
+ ),
391
+ deleted: v.number(),
392
+ scheduledAt: v.union(v.number(), v.null()),
393
+ updatedAt: v.number(),
394
+ }),
395
+ ),
396
+ backlog: v.number(),
397
+ }),
398
+ handler: async (ctx, args) => {
399
+ const policy = await ctx.db
400
+ .query("retentionPolicies")
401
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
402
+ .unique();
403
+ const jobs = await ctx.db
404
+ .query("retentionStates")
405
+ .withIndex("by_siteId_and_kind", (range) =>
406
+ range.eq("siteId", args.siteId),
407
+ )
408
+ .take(POLICY_CLEANUP_KINDS.length + 1);
409
+ const publicJobs = jobs.map(
410
+ ({ kind, before, status, deleted, scheduledAt, updatedAt }) => ({
411
+ kind: kind as CleanupKind,
412
+ before,
413
+ status,
414
+ deleted,
415
+ scheduledAt: scheduledAt ?? null,
416
+ updatedAt,
417
+ }),
418
+ );
50
419
  return {
51
- deleted: Math.min(deletions.length, batchSize),
52
- hasMore: deletions.length > batchSize,
420
+ policy: policy
421
+ ? {
422
+ eventsDays: policy.eventsDays,
423
+ sessionsDays: policy.sessionsDays,
424
+ conversionsDays: policy.conversionsDays,
425
+ aggregatesDays: policy.aggregatesDays,
426
+ reportsDays: policy.reportsDays,
427
+ updatedAt: policy.updatedAt,
428
+ }
429
+ : null,
430
+ jobs: publicJobs,
431
+ backlog: publicJobs.filter((job) => job.status !== "complete").length,
53
432
  };
54
433
  },
55
434
  });
@@ -64,11 +443,247 @@ type CleanupKind =
64
443
  | "funnelProgress"
65
444
  | "funnelSteps"
66
445
  | "affiliateStats"
446
+ | "goalRollups"
447
+ | "funnelRollups"
448
+ | "affiliateRollups"
449
+ | "vitalRollups"
450
+ | "siteMapRollups"
67
451
  | "attributions"
68
452
  | "liveSessions"
69
453
  | "ingestWindows"
70
454
  | "siteIngestWindows";
71
455
 
456
+ const POLICY_CLEANUP_KINDS: CleanupKind[] = [
457
+ "events",
458
+ "sessions",
459
+ "conversions",
460
+ "aggregateHour",
461
+ "aggregateDay",
462
+ "goalCompletions",
463
+ "funnelProgress",
464
+ "funnelSteps",
465
+ "affiliateStats",
466
+ "goalRollups",
467
+ "funnelRollups",
468
+ "affiliateRollups",
469
+ "vitalRollups",
470
+ "siteMapRollups",
471
+ "attributions",
472
+ "ingestWindows",
473
+ "siteIngestWindows",
474
+ ];
475
+
476
+ function validatePolicy(policy: {
477
+ eventsDays: number;
478
+ sessionsDays: number;
479
+ conversionsDays: number;
480
+ aggregatesDays: number;
481
+ reportsDays: number;
482
+ }) {
483
+ for (const [name, days] of Object.entries(policy)) {
484
+ if (!name.endsWith("Days")) continue;
485
+ if (!Number.isSafeInteger(days) || days < 1 || days > 3_650) {
486
+ fail("INVALID_ARGUMENT", `${name} must be an integer between 1 and 3650`);
487
+ }
488
+ }
489
+ }
490
+
491
+ function retentionDaysForKind(
492
+ policy: {
493
+ eventsDays: number;
494
+ sessionsDays: number;
495
+ conversionsDays: number;
496
+ aggregatesDays: number;
497
+ reportsDays: number;
498
+ },
499
+ kind: CleanupKind,
500
+ ) {
501
+ if (kind === "events") return policy.eventsDays;
502
+ if (kind === "sessions") return policy.sessionsDays;
503
+ if (kind === "conversions") return policy.conversionsDays;
504
+ if (kind === "aggregateHour" || kind === "aggregateDay") {
505
+ return policy.aggregatesDays;
506
+ }
507
+ return policy.reportsDays;
508
+ }
509
+
510
+ function safeCleanupBoundary(
511
+ kind: CleanupKind,
512
+ requested: number,
513
+ now: number,
514
+ ) {
515
+ if (!Number.isFinite(requested) || requested < 0) {
516
+ fail("INVALID_ARGUMENT", "before must be a positive timestamp");
517
+ }
518
+ if (kind === "events") return safeBucketBoundary(requested, now, MINUTE_MS);
519
+ if (kind === "aggregateHour")
520
+ return safeBucketBoundary(requested, now, HOUR_MS);
521
+ if (
522
+ kind === "aggregateDay" ||
523
+ kind === "funnelSteps" ||
524
+ kind === "affiliateStats" ||
525
+ kind === "goalRollups" ||
526
+ kind === "funnelRollups" ||
527
+ kind === "affiliateRollups" ||
528
+ kind === "vitalRollups" ||
529
+ kind === "siteMapRollups"
530
+ ) {
531
+ return safeBucketBoundary(requested, now, DAY_MS);
532
+ }
533
+ if (kind === "ingestWindows" || kind === "siteIngestWindows") {
534
+ return Math.min(
535
+ Math.floor(requested / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS,
536
+ Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS,
537
+ );
538
+ }
539
+ return Math.trunc(requested);
540
+ }
541
+
542
+ function safeBucketBoundary(
543
+ requested: number,
544
+ now: number,
545
+ intervalMs: number,
546
+ ) {
547
+ return Math.min(
548
+ Math.floor(requested / intervalMs) * intervalMs,
549
+ Math.floor(now / intervalMs) * intervalMs,
550
+ );
551
+ }
552
+
553
+ async function loadState(
554
+ ctx: MutationCtx,
555
+ siteId: Id<"sites">,
556
+ kind: CleanupKind,
557
+ ) {
558
+ return ctx.db
559
+ .query("retentionStates")
560
+ .withIndex("by_siteId_and_kind", (range) =>
561
+ range.eq("siteId", siteId).eq("kind", kind),
562
+ )
563
+ .unique();
564
+ }
565
+
566
+ async function persistDirectState(
567
+ ctx: MutationCtx,
568
+ args: { siteId: Id<"sites">; kind: CleanupKind; before: number },
569
+ result: { deleted: number; hasMore: boolean },
570
+ ) {
571
+ const now = Date.now();
572
+ const existing = await loadState(ctx, args.siteId, args.kind);
573
+ const fields = {
574
+ siteId: args.siteId,
575
+ kind: args.kind,
576
+ before: args.before,
577
+ status: result.hasMore ? ("pending" as const) : ("complete" as const),
578
+ generation: (existing?.generation ?? 0) + 1,
579
+ attempt: 0,
580
+ deleted:
581
+ existing?.before === args.before
582
+ ? existing.deleted + result.deleted
583
+ : result.deleted,
584
+ updatedAt: now,
585
+ };
586
+ if (existing) {
587
+ await ctx.db.replace("retentionStates", existing._id, fields);
588
+ } else {
589
+ await ctx.db.insert("retentionStates", fields);
590
+ }
591
+ }
592
+
593
+ async function recordRetentionBoundary(
594
+ ctx: MutationCtx,
595
+ siteId: Id<"sites">,
596
+ kind: CleanupKind,
597
+ before: number,
598
+ ) {
599
+ const datasets = coverageDatasetsForKind(kind);
600
+ if (datasets.length === 0) return;
601
+ const now = Date.now();
602
+ const coverage = await ensureAnalyticsControl(
603
+ ctx,
604
+ siteId,
605
+ now,
606
+ datasets.map((dataset) => ({ dataset, before })),
607
+ );
608
+ const retained = coverage.retained.filter(
609
+ (item) => !datasets.includes(item.dataset),
610
+ );
611
+ let changed = retained.length !== coverage.retained.length;
612
+ for (const dataset of datasets) {
613
+ const prior =
614
+ coverage.retained.find((item) => item.dataset === dataset)?.before ?? 0;
615
+ const next = Math.max(prior, before);
616
+ retained.push({ dataset, before: next });
617
+ changed ||= next !== prior;
618
+ }
619
+ if (changed) {
620
+ await ctx.db.patch("analyticsCoverage", coverage._id, {
621
+ retained,
622
+ updatedAt: now,
623
+ });
624
+ }
625
+ }
626
+
627
+ function coverageDatasetsForKind(kind: CleanupKind): string[] {
628
+ if (kind === "events") return ["events"];
629
+ if (kind === "sessions") return ["sessions"];
630
+ if (kind === "conversions") return ["conversions"];
631
+ if (kind === "aggregateHour") return ["overviewHour"];
632
+ if (kind === "aggregateDay") return ["overviewDay"];
633
+ if (kind === "goalCompletions") return ["goalCompletions"];
634
+ if (kind === "funnelSteps") return ["funnelSteps"];
635
+ if (kind === "affiliateStats") return ["affiliateStats"];
636
+ if (kind === "goalRollups") return ["goalRollups"];
637
+ if (kind === "funnelRollups") return ["funnelRollups"];
638
+ if (kind === "affiliateRollups") return ["affiliateRollups"];
639
+ if (kind === "vitalRollups") return ["vitalRollups"];
640
+ if (kind === "siteMapRollups") return ["siteMapRollups"];
641
+ return [];
642
+ }
643
+
644
+ async function executeCleanupBatch(
645
+ ctx: MutationCtx,
646
+ args: {
647
+ siteId: Id<"sites">;
648
+ kind: CleanupKind;
649
+ before: number;
650
+ batchSize?: number;
651
+ },
652
+ ) {
653
+ const batchSize = args.batchSize ?? 100;
654
+ if (
655
+ !Number.isSafeInteger(batchSize) ||
656
+ batchSize < 1 ||
657
+ batchSize > MAX_RETENTION_BATCH
658
+ ) {
659
+ fail(
660
+ "INVALID_ARGUMENT",
661
+ `batchSize must be an integer between 1 and ${MAX_RETENTION_BATCH}`,
662
+ );
663
+ }
664
+ const effectiveBatchSize =
665
+ args.kind === "events" ? Math.min(batchSize, 32) : batchSize;
666
+ if (args.kind === "events") {
667
+ return deleteTelemetryBefore(ctx, {
668
+ siteId: args.siteId,
669
+ before: args.before,
670
+ batchSize: effectiveBatchSize,
671
+ });
672
+ }
673
+ const deletions = await deletionsForCleanup(
674
+ ctx,
675
+ args.siteId,
676
+ args.kind,
677
+ args.before,
678
+ effectiveBatchSize + 1,
679
+ );
680
+ for (const remove of deletions.slice(0, effectiveBatchSize)) await remove();
681
+ return {
682
+ deleted: Math.min(deletions.length, effectiveBatchSize),
683
+ hasMore: deletions.length > effectiveBatchSize,
684
+ };
685
+ }
686
+
72
687
  type CleanupDelete = () => Promise<void>;
73
688
 
74
689
  async function deletionsForCleanup(
@@ -78,103 +693,165 @@ async function deletionsForCleanup(
78
693
  before: number,
79
694
  limit: number,
80
695
  ): Promise<CleanupDelete[]> {
81
- if (kind === "events") {
82
- return (await ctx.db
83
- .query("events")
84
- .withIndex("by_siteId_and_timestamp", (range) =>
85
- range.eq("siteId", siteId).lt("timestamp", before),
86
- )
87
- .take(limit)).map((row) => () => ctx.db.delete("events", row._id));
88
- }
89
696
  if (kind === "sessions") {
90
- return (await ctx.db
91
- .query("sessions")
92
- .withIndex("by_siteId_and_lastSeenAt", (range) =>
93
- range.eq("siteId", siteId).lt("lastSeenAt", before),
94
- )
95
- .take(limit)).map((row) => () => ctx.db.delete("sessions", row._id));
697
+ return (
698
+ await ctx.db
699
+ .query("sessions")
700
+ .withIndex("by_siteId_and_lastSeenAt", (q) =>
701
+ q.eq("siteId", siteId).lt("lastSeenAt", before),
702
+ )
703
+ .take(limit)
704
+ ).map((row) => () => ctx.db.delete("sessions", row._id));
96
705
  }
97
706
  if (kind === "conversions") {
98
- return (await ctx.db
99
- .query("conversions")
100
- .withIndex("by_siteId_and_timestamp", (range) =>
101
- range.eq("siteId", siteId).lt("timestamp", before),
102
- )
103
- .take(limit)).map((row) => () => ctx.db.delete("conversions", row._id));
707
+ return (
708
+ await ctx.db
709
+ .query("conversions")
710
+ .withIndex("by_siteId_and_timestamp", (q) =>
711
+ q.eq("siteId", siteId).lt("timestamp", before),
712
+ )
713
+ .take(limit)
714
+ ).map((row) => () => ctx.db.delete("conversions", row._id));
104
715
  }
105
716
  if (kind === "goalCompletions") {
106
- return (await ctx.db
107
- .query("goalCompletions")
108
- .withIndex("by_siteId_and_timestamp", (range) =>
109
- range.eq("siteId", siteId).lt("timestamp", before),
110
- )
111
- .take(limit)).map((row) => () => ctx.db.delete("goalCompletions", row._id));
717
+ return (
718
+ await ctx.db
719
+ .query("goalCompletions")
720
+ .withIndex("by_siteId_and_timestamp", (q) =>
721
+ q.eq("siteId", siteId).lt("timestamp", before),
722
+ )
723
+ .take(limit)
724
+ ).map((row) => () => ctx.db.delete("goalCompletions", row._id));
112
725
  }
113
726
  if (kind === "aggregateHour" || kind === "aggregateDay") {
114
- return (await ctx.db
115
- .query("aggregateBuckets")
116
- .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
117
- range
118
- .eq("siteId", siteId)
119
- .eq("granularity", kind === "aggregateHour" ? "hour" : "day")
120
- .lt("bucketStart", before),
121
- )
122
- .take(limit)).map((row) => () => ctx.db.delete("aggregateBuckets", row._id));
727
+ return (
728
+ await ctx.db
729
+ .query("aggregateBuckets")
730
+ .withIndex("by_siteId_and_granularity_and_bucketStart", (q) =>
731
+ q
732
+ .eq("siteId", siteId)
733
+ .eq("granularity", kind === "aggregateHour" ? "hour" : "day")
734
+ .lt("bucketStart", before),
735
+ )
736
+ .take(limit)
737
+ ).map((row) => () => ctx.db.delete("aggregateBuckets", row._id));
123
738
  }
124
739
  if (kind === "funnelProgress") {
125
- return (await ctx.db
126
- .query("funnelProgress")
127
- .withIndex("by_siteId_and_lastStepAt", (range) =>
128
- range.eq("siteId", siteId).lt("lastStepAt", before),
129
- )
130
- .take(limit)).map((row) => () => ctx.db.delete("funnelProgress", row._id));
740
+ return (
741
+ await ctx.db
742
+ .query("funnelProgress")
743
+ .withIndex("by_siteId_and_lastStepAt", (q) =>
744
+ q.eq("siteId", siteId).lt("lastStepAt", before),
745
+ )
746
+ .take(limit)
747
+ ).map((row) => () => ctx.db.delete("funnelProgress", row._id));
131
748
  }
132
749
  if (kind === "funnelSteps") {
133
- return (await ctx.db
134
- .query("funnelStepBuckets")
135
- .withIndex("by_siteId_and_bucketStart", (range) =>
136
- range.eq("siteId", siteId).lt("bucketStart", before),
137
- )
138
- .take(limit)).map((row) => () => ctx.db.delete("funnelStepBuckets", row._id));
750
+ return (
751
+ await ctx.db
752
+ .query("funnelStepBuckets")
753
+ .withIndex("by_siteId_and_bucketStart", (q) =>
754
+ q.eq("siteId", siteId).lt("bucketStart", before),
755
+ )
756
+ .take(limit)
757
+ ).map((row) => () => ctx.db.delete("funnelStepBuckets", row._id));
139
758
  }
140
759
  if (kind === "affiliateStats") {
141
- return (await ctx.db
142
- .query("affiliateDailyStats")
143
- .withIndex("by_siteId_and_bucketStart", (range) =>
144
- range.eq("siteId", siteId).lt("bucketStart", before),
145
- )
146
- .take(limit)).map((row) => () => ctx.db.delete("affiliateDailyStats", row._id));
760
+ return (
761
+ await ctx.db
762
+ .query("affiliateDailyStats")
763
+ .withIndex("by_siteId_and_bucketStart", (q) =>
764
+ q.eq("siteId", siteId).lt("bucketStart", before),
765
+ )
766
+ .take(limit)
767
+ ).map((row) => () => ctx.db.delete("affiliateDailyStats", row._id));
768
+ }
769
+ if (kind === "goalRollups") {
770
+ return (
771
+ await ctx.db
772
+ .query("goalDailyRollups")
773
+ .withIndex("by_siteId_and_bucketStart", (q) =>
774
+ q.eq("siteId", siteId).lt("bucketStart", before),
775
+ )
776
+ .take(limit)
777
+ ).map((row) => () => ctx.db.delete("goalDailyRollups", row._id));
778
+ }
779
+ if (kind === "funnelRollups") {
780
+ return (
781
+ await ctx.db
782
+ .query("funnelDailyRollups")
783
+ .withIndex("by_siteId_and_bucketStart", (q) =>
784
+ q.eq("siteId", siteId).lt("bucketStart", before),
785
+ )
786
+ .take(limit)
787
+ ).map((row) => () => ctx.db.delete("funnelDailyRollups", row._id));
788
+ }
789
+ if (kind === "affiliateRollups") {
790
+ return (
791
+ await ctx.db
792
+ .query("affiliateDailyRollups")
793
+ .withIndex("by_siteId_and_bucketStart", (q) =>
794
+ q.eq("siteId", siteId).lt("bucketStart", before),
795
+ )
796
+ .take(limit)
797
+ ).map((row) => () => ctx.db.delete("affiliateDailyRollups", row._id));
798
+ }
799
+ if (kind === "vitalRollups") {
800
+ return (
801
+ await ctx.db
802
+ .query("vitalRollups")
803
+ .withIndex("by_siteId_and_bucketStart", (q) =>
804
+ q.eq("siteId", siteId).lt("bucketStart", before),
805
+ )
806
+ .take(limit)
807
+ ).map((row) => () => ctx.db.delete("vitalRollups", row._id));
808
+ }
809
+ if (kind === "siteMapRollups") {
810
+ return (
811
+ await ctx.db
812
+ .query("routeDailyRollups")
813
+ .withIndex("by_siteId_and_bucketStart", (q) =>
814
+ q.eq("siteId", siteId).lt("bucketStart", before),
815
+ )
816
+ .take(limit)
817
+ ).map((row) => () => ctx.db.delete("routeDailyRollups", row._id));
147
818
  }
148
819
  if (kind === "attributions") {
149
- return (await ctx.db
150
- .query("visitorAttributions")
151
- .withIndex("by_siteId_and_expiresAt", (range) =>
152
- range.eq("siteId", siteId).lt("expiresAt", before),
153
- )
154
- .take(limit)).map((row) => () =>
155
- ctx.db.delete("visitorAttributions", row._id),
156
- );
820
+ return (
821
+ await ctx.db
822
+ .query("visitorAttributions")
823
+ .withIndex("by_siteId_and_expiresAt", (q) =>
824
+ q.eq("siteId", siteId).lt("expiresAt", before),
825
+ )
826
+ .take(limit)
827
+ ).map((row) => () => ctx.db.delete("visitorAttributions", row._id));
157
828
  }
158
829
  if (kind === "liveSessions") {
159
- return (await ctx.db
160
- .query("liveSessions")
161
- .withIndex("by_siteId_and_expiresAt", (range) =>
162
- range.eq("siteId", siteId).lt("expiresAt", before),
163
- )
164
- .take(limit)).map((row) => () => ctx.db.delete("liveSessions", row._id));
830
+ return (
831
+ await ctx.db
832
+ .query("liveSessions")
833
+ .withIndex("by_siteId_and_expiresAt", (q) =>
834
+ q.eq("siteId", siteId).lt("expiresAt", before),
835
+ )
836
+ .take(limit)
837
+ ).map((row) => () => ctx.db.delete("liveSessions", row._id));
165
838
  }
166
839
  if (kind === "siteIngestWindows") {
167
- return (await ctx.db
168
- .query("siteIngestWindows")
169
- .withIndex("by_siteId_and_updatedAt", (range) =>
170
- range.eq("siteId", siteId).lt("updatedAt", before),
171
- )
172
- .take(limit)).map((row) => () => ctx.db.delete("siteIngestWindows", row._id));
840
+ return (
841
+ await ctx.db
842
+ .query("siteIngestWindows")
843
+ .withIndex("by_siteId_and_updatedAt", (q) =>
844
+ q.eq("siteId", siteId).lt("updatedAt", before),
845
+ )
846
+ .take(limit)
847
+ ).map((row) => () => ctx.db.delete("siteIngestWindows", row._id));
173
848
  }
174
- return (await ctx.db
175
- .query("ingestWindows")
176
- .withIndex("by_siteId_and_updatedAt", (range) =>
177
- range.eq("siteId", siteId).lt("updatedAt", before),
178
- )
179
- .take(limit)).map((row) => () => ctx.db.delete("ingestWindows", row._id));
849
+ return (
850
+ await ctx.db
851
+ .query("ingestWindows")
852
+ .withIndex("by_siteId_and_updatedAt", (q) =>
853
+ q.eq("siteId", siteId).lt("updatedAt", before),
854
+ )
855
+ .take(limit)
856
+ ).map((row) => () => ctx.db.delete("ingestWindows", row._id));
180
857
  }