import { and, count, eq, gte, inArray, lte, type SQL } from "drizzle-orm"; import { z } from "zod"; import { ValidationError } from "../api/errors"; import { projectRoots, timelineEvents } from "../db/schema"; import { defineCapability } from "./context"; import { buildTimelineScope } from "./scope"; const purgeScopeSchema = z.object({ projectId: z .string() .optional() .describe("Delete events under this project's roots."), rootIds: z .array(z.string()) .optional() .describe("Delete events under these project_root ids."), source: z .string() .optional() .describe("Only events from this source (git, file, claude, ...)."), deviceId: z.string().optional().describe("Only events from this device."), since: z.string().optional().describe("ISO date lower bound (inclusive)."), until: z.string().optional().describe("ISO date upper bound (inclusive)."), }); type PurgeScope = z.infer; function normalizeScope(scope: PurgeScope): Record { const normalized: Record = {}; if (scope.deviceId !== undefined) normalized.deviceId = scope.deviceId; if (scope.projectId !== undefined) normalized.projectId = scope.projectId; if (scope.rootIds !== undefined) { normalized.rootIds = [...new Set(scope.rootIds)].sort(); } if (scope.since !== undefined) normalized.since = scope.since; if (scope.source !== undefined) normalized.source = scope.source; if (scope.until !== undefined) normalized.until = scope.until; return normalized; } function shortHash(value: string): string { let hash = 5381; for (let i = 0; i < value.length; i++) { hash = (hash * 33) ^ value.charCodeAt(i); } return (hash >>> 0).toString(16).padStart(8, "0"); } function parseDate(value: string, field: "since" | "until"): Date { const date = new Date(value); if (Number.isNaN(date.getTime())) { throw new ValidationError(`${field} must be a valid ISO date.`); } return date; } export const purgeWork = defineCapability({ name: "purgeWork", description: "DESTRUCTIVE: permanently delete timeline events (work data) for a scope — for junk that was synced but shouldn't be tracked at all (e.g. a teammate's unrelated folders). This deletes the events, unlike deleteProject which only un-groups. SAFETY: you MUST call it FIRST without `confirm` to get a count + a confirm token, show the user EXACTLY what will be deleted, and only call again with that token AFTER the user explicitly approves. Never purge without user confirmation. Requires at least one scope filter.", inputSchema: z.object({ scope: purgeScopeSchema, confirm: z .string() .optional() .describe("The confirm token from the preview call. Omit to preview."), }), requiredScope: "projects:write", requiredPermission: "projects:edit", execute: async (ctx, { scope, confirm }) => { const hasFilter = Boolean( scope.projectId || scope.rootIds?.length || scope.source || scope.deviceId || scope.since || scope.until, ); if (!hasFilter) { throw new ValidationError( "Refusing to purge without a scope filter. Specify a project, root(s), source, device, or date range.", ); } const conditions: SQL[] = []; const timelineScope = buildTimelineScope(ctx); if (timelineScope) conditions.push(timelineScope); if (scope.projectId) { const roots = await ctx.db .select({ id: projectRoots.id }) .from(projectRoots) .where(eq(projectRoots.projectId, scope.projectId)); const rootIds = roots.map((root) => root.id); if (rootIds.length === 0) { return { dryRun: !confirm, wouldDelete: 0, deleted: confirm ? 0 : undefined, scope, }; } conditions.push(inArray(timelineEvents.projectRootId, rootIds)); } if (scope.rootIds?.length) { conditions.push(inArray(timelineEvents.projectRootId, scope.rootIds)); } if (scope.source) { conditions.push( eq( timelineEvents.source, scope.source as typeof timelineEvents.$inferSelect.source, ), ); } if (scope.deviceId) { conditions.push(eq(timelineEvents.deviceId, scope.deviceId)); } if (scope.since) { conditions.push( gte(timelineEvents.timestamp, parseDate(scope.since, "since")), ); } if (scope.until) { conditions.push( lte(timelineEvents.timestamp, parseDate(scope.until, "until")), ); } const [{ count: wouldDelete }] = await ctx.db .select({ count: count() }) .from(timelineEvents) .where(and(...conditions)); const token = `purge-${shortHash(JSON.stringify(normalizeScope(scope)))}`; if (confirm !== token) { return { dryRun: true, wouldDelete, scope, confirm: token, note: "Nothing deleted. Show the user the count, then call again with this confirm token AFTER they approve.", }; } await ctx.db.delete(timelineEvents).where(and(...conditions)); // Cached timeline rollup counts may remain stale until the next sync recompute. return { deleted: wouldDelete, scope }; }, });