$v > 0); if (!empty($kinds)) { $placeholders = []; foreach ($kinds as $i => $k) { $key = ':kind_' . $i; $placeholders[] = $key; $params[$key] = $k; } $conds[] = 'e.kind IN (' . implode(',', $placeholders) . ')'; } } // Date/time range filter (supports YYYY-MM-DD or YYYY-MM-DD HH:MM) $from_date = $opts['from_date'] ?? ''; $to_date = $opts['to_date'] ?? ''; if ($from_date !== '') { if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $from_date)) { $conds[] = 'e.created_at >= EXTRACT(EPOCH FROM :from_date::date)::BIGINT'; $params[':from_date'] = $from_date; } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $from_date)) { $conds[] = 'e.created_at >= EXTRACT(EPOCH FROM :from_date::timestamp)::BIGINT'; $params[':from_date'] = $from_date . ':00'; } } if ($to_date !== '') { if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $to_date)) { $conds[] = 'e.created_at < (EXTRACT(EPOCH FROM :to_date::date)::BIGINT + 86400)'; $params[':to_date'] = $to_date; } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $to_date)) { $conds[] = 'e.created_at < EXTRACT(EPOCH FROM :to_date::timestamp)::BIGINT'; $params[':to_date'] = $to_date . ':00'; } } // Follows filter $follows_filter = $opts['follows_filter'] ?? 'all'; if ($follows_filter === 'follows') { $conds[] = 'e.pubkey IN (SELECT pubkey FROM caching_followed_pubkeys)'; } elseif ($follows_filter === 'non_follows') { $conds[] = 'e.pubkey NOT IN (SELECT pubkey FROM caching_followed_pubkeys)'; } return [$conds, $params]; } // ── Build a WHERE clause string from conditions ──────────────────────── function where_clause(array $conds): string { if (empty($conds)) return ''; return 'AND ' . implode("\n AND ", $conds); } // ── Format bytes as human-readable ───────────────────────────────────── function format_bytes(int $bytes): string { if ($bytes < 1024) return $bytes . ' B'; if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB'; if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB'; return round($bytes / 1073741824, 2) . ' GB'; } // ── GET: Preview ─────────────────────────────────────────────────────── if ($method === 'GET') { $opts = [ 'follows_filter' => $_GET['follows_filter'] ?? 'all', 'kinds' => $_GET['kinds'] ?? '', 'from_date' => $_GET['from_date'] ?? '', 'to_date' => $_GET['to_date'] ?? '', 'max_events' => intval($_GET['max_events'] ?? 0), ]; list($conds, $params) = build_filters($opts); $where = where_clause($conds); // Preview SQL (for display) $sql_preview = "SELECT COUNT(*) AS match_count,\n" . " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n" . "FROM events e\n" . "WHERE 1=1\n" . ($where ? " $where\n" : ''); // Count + size query $count_sql = "SELECT COUNT(*) AS match_count,\n" . " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n" . "FROM events e\n" . "WHERE 1=1 $where"; try { $stmt = $pdo->prepare($count_sql); $stmt->execute($params); $row = $stmt->fetch(); $match_count = intval($row['match_count'] ?? 0); $total_size_bytes = intval($row['total_size_bytes'] ?? 0); } catch (PDOException $e) { json_response(['error' => 'Preview query failed: ' . $e->getMessage()]); exit; } // Kind breakdown $breakdown = []; if ($match_count > 0) { $breakdown_sql = "SELECT e.kind,\n" . " COUNT(*) AS count,\n" . " COALESCE(SUM(pg_column_size(e.event_json)), 0) AS size_bytes\n" . "FROM events e\n" . "WHERE 1=1 $where\n" . "GROUP BY e.kind\n" . "ORDER BY count DESC\n" . "LIMIT 50"; try { $stmt = $pdo->prepare($breakdown_sql); $stmt->execute($params); $rows = $stmt->fetchAll(); foreach ($rows as $r) { $breakdown[] = [ 'kind' => intval($r['kind']), 'count' => intval($r['count']), 'size_bytes' => intval($r['size_bytes']), ]; } } catch (PDOException $e) { // Breakdown is non-critical } } // If a query_id was provided, update last_preview_count and last_preview_size_bytes $query_id = intval($_GET['query_id'] ?? 0); if ($query_id > 0) { try { $pdo->prepare( "UPDATE cleanup_saved_queries\n" . " SET last_preview_count = :count,\n" . " last_preview_size_bytes = :size\n" . " WHERE id = :id" )->execute([ ':id' => $query_id, ':count' => $match_count, ':size' => $total_size_bytes, ]); } catch (PDOException $e) {} } json_response([ 'match_count' => $match_count, 'total_size_bytes' => $total_size_bytes, 'total_size_human' => format_bytes($total_size_bytes), 'avg_size_per_event' => $match_count > 0 ? intval($total_size_bytes / $match_count) : 0, 'kinds_breakdown' => $breakdown, 'sql_preview' => $sql_preview, ]); exit; } // ── POST: Execute (or dry-run) ───────────────────────────────────────── if ($method === 'POST') { $body = json_decode(file_get_contents('php://input'), true); if (!$body) { json_response(['error' => 'Invalid JSON body']); exit; } $dry_run = !empty($body['dry_run']); $opts = [ 'follows_filter' => $body['follows_filter'] ?? 'all', 'kinds' => $body['kinds'] ?? [], 'from_date' => $body['from_date'] ?? '', 'to_date' => $body['to_date'] ?? '', 'max_events' => intval($body['max_events'] ?? 0), ]; list($conds, $params) = build_filters($opts); $where = where_clause($conds); // If dry_run, return preview (same as GET) if ($dry_run) { $count_sql = "SELECT COUNT(*) AS match_count,\n" . " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n" . "FROM events e\n" . "WHERE 1=1 $where"; try { $stmt = $pdo->prepare($count_sql); $stmt->execute($params); $row = $stmt->fetch(); $match_count = intval($row['match_count'] ?? 0); $total_size_bytes = intval($row['total_size_bytes'] ?? 0); } catch (PDOException $e) { json_response(['error' => 'Preview query failed: ' . $e->getMessage()]); exit; } // Kind breakdown $breakdown = []; if ($match_count > 0) { $breakdown_sql = "SELECT e.kind, COUNT(*) AS count,\n" . " COALESCE(SUM(pg_column_size(e.event_json)), 0) AS size_bytes\n" . "FROM events e\n" . "WHERE 1=1 $where\n" . "GROUP BY e.kind\n" . "ORDER BY count DESC\n" . "LIMIT 50"; try { $stmt = $pdo->prepare($breakdown_sql); $stmt->execute($params); foreach ($stmt->fetchAll() as $r) { $breakdown[] = [ 'kind' => intval($r['kind']), 'count' => intval($r['count']), 'size_bytes' => intval($r['size_bytes']), ]; } } catch (PDOException $e) {} } json_response([ 'match_count' => $match_count, 'total_size_bytes' => $total_size_bytes, 'total_size_human' => format_bytes($total_size_bytes), 'avg_size_per_event' => $match_count > 0 ? intval($total_size_bytes / $match_count) : 0, 'kinds_breakdown' => $breakdown, 'dry_run' => true, ]); exit; } // ── Actual DELETE ────────────────────────────────────────────────── $max_events = intval($body['max_events'] ?? 0); $limit_clause = $max_events > 0 ? 'LIMIT :max_events' : ''; // First, get the count and size of what will be deleted $preview_sql = "SELECT COUNT(*) AS match_count,\n" . " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n" . "FROM events e\n" . "WHERE 1=1 $where"; try { $stmt = $pdo->prepare($preview_sql); $stmt->execute($params); $row = $stmt->fetch(); $expected_count = intval($row['match_count'] ?? 0); $expected_bytes = intval($row['total_size_bytes'] ?? 0); } catch (PDOException $e) { json_response(['error' => 'Pre-delete count failed: ' . $e->getMessage()]); exit; } // Build the DELETE using id IN (subquery) for safe LIMIT + ORDER BY $delete_sql = "DELETE FROM events e\n" . "WHERE e.id IN (\n" . " SELECT e2.id FROM events e2\n" . " WHERE 1=1 $where\n" . " ORDER BY e2.created_at ASC\n" . " $limit_clause\n" . ")"; $start_time = microtime(true); try { $stmt = $pdo->prepare($delete_sql); if ($max_events > 0) { $params[':max_events'] = $max_events; } $stmt->execute($params); $deleted_count = $stmt->rowCount(); } catch (PDOException $e) { json_response(['error' => 'Delete query failed: ' . $e->getMessage()]); exit; } $duration_ms = round((microtime(true) - $start_time) * 1000); // If we deleted fewer than expected, the actual freed bytes are proportional $freed_bytes = $expected_count > 0 ? intval($expected_bytes * ($deleted_count / $expected_count)) : 0; // Clear stats cache so the dashboard reflects changes immediately $cache_dir = __DIR__ . '/../cache'; foreach (['stats_kinds.json', 'stats_pubkeys.json'] as $cache_file) { $path = $cache_dir . '/' . $cache_file; if (is_file($path)) @unlink($path); } json_response([ 'deleted_count' => $deleted_count, 'freed_bytes' => $freed_bytes, 'freed_human' => format_bytes($freed_bytes), 'duration_ms' => $duration_ms, ]); exit; } // Unsupported method json_response(['error' => 'Method not allowed']);