@iann29/rastro 0.1.0-alpha.2 → 0.1.0-alpha.4

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