diff --git a/src/gram.ts b/src/gram.ts index 9459ebf..3a89350 100644 --- a/src/gram.ts +++ b/src/gram.ts @@ -6,6 +6,8 @@ import { executeWriteQueryGram } from "./tools/execute-write-query.ts"; import { getInsightsGram } from "./tools/get-insights.ts"; import { listClusterSizesGram } from "./tools/list-cluster-sizes.ts"; import { searchDocumentationGram } from "./tools/search-documentation.ts"; +import { listMaintenanceWindowsGram } from "./tools/list-maintenance-windows.ts"; +import { listBackupsGram } from "./tools/list-backups.ts"; const gram = new Gram({ envSchema: { @@ -25,6 +27,8 @@ const gram = new Gram({ .extend(executeWriteQueryGram) .extend(getInsightsGram) .extend(listClusterSizesGram) - .extend(searchDocumentationGram); + .extend(searchDocumentationGram) + .extend(listMaintenanceWindowsGram) + .extend(listBackupsGram); export default gram; diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..5bedf20 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,11 @@ +/** + * Format byte count to human-readable string (e.g. 1073741824 -> "1 GB") + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + const unit = units[Math.min(i, units.length - 1)]; + return value % 1 === 0 ? `${value} ${unit}` : `${value.toFixed(1)} ${unit}`; +} diff --git a/src/lib/planetscale-api.ts b/src/lib/planetscale-api.ts index b5957fa..94fb9e3 100644 --- a/src/lib/planetscale-api.ts +++ b/src/lib/planetscale-api.ts @@ -71,7 +71,7 @@ export class PlanetScaleAPIError extends Error { } } -async function apiRequest( +export async function apiRequest( endpoint: string, authHeader: string, options: RequestInit = {} diff --git a/src/tools/list-backups.ts b/src/tools/list-backups.ts new file mode 100644 index 0000000..6f2b0fc --- /dev/null +++ b/src/tools/list-backups.ts @@ -0,0 +1,201 @@ +import { Gram } from "@gram-ai/functions"; +import { z } from "zod"; +import { PlanetScaleAPIError, apiRequest } from "../lib/planetscale-api.ts"; +import { getAuthToken, getAuthHeader } from "../lib/auth.ts"; +import { formatBytes } from "../lib/format.ts"; + +interface BackupPolicy { + id: string; + type: string; + display_name: string; + name: string; + target: "production" | "development"; + retention_value: number; + retention_unit: string; + frequency_value: number; + frequency_unit: string; + schedule_time: string; + schedule_day: number | null; + schedule_week: number | null; + created_at: string; + updated_at: string; + last_ran_at: string | null; + next_run_at: string | null; + required: boolean; +} + +interface Backup { + id: string; + type: string; + name: string; + state: "pending" | "running" | "success" | "failed" | "canceled" | "ignored"; + size: number; + estimated_storage_cost: number; + created_at: string; + updated_at: string; + started_at: string | null; + expires_at: string | null; + completed_at: string | null; + deleted_at: string | null; + pvc_size: number; + protected: boolean; + required: boolean; + backup_policy: BackupPolicy | null; + database_branch: { id: string; name: string } | null; + restored_branches: { id: string; name: string }[]; +} + +interface PaginatedList { + type: string; + current_page: number; + next_page: number | null; + next_page_url: string | null; + prev_page: number | null; + prev_page_url: string | null; + data: T[]; +} + +function formatPolicy(p: BackupPolicy) { + const freq = + p.frequency_unit === "hour" && p.frequency_value === 24 + ? "daily" + : `every ${p.frequency_value > 1 ? `${p.frequency_value} ${p.frequency_unit}s` : p.frequency_unit}`; + const retention = `${p.retention_value} ${p.retention_unit}${p.retention_value > 1 ? "s" : ""}`; + + return { + id: p.id, + name: p.display_name, + target: p.target, + schedule: `${freq} at ${p.schedule_time} UTC`, + retention, + required: p.required, + last_ran_at: p.last_ran_at, + next_run_at: p.next_run_at, + }; +} + +function formatBackup(b: Backup) { + const duration = + b.started_at && b.completed_at + ? `${((new Date(b.completed_at).getTime() - new Date(b.started_at).getTime()) / 60000).toFixed(0)} min` + : null; + + return { + id: b.id, + name: b.name, + state: b.state, + size: formatBytes(b.size), + started_at: b.started_at, + completed_at: b.completed_at, + duration, + expires_at: b.expires_at, + policy: b.backup_policy?.display_name ?? null, + protected: b.protected, + }; +} + +export const listBackupsGram = new Gram().tool({ + name: "list_backups", + description: + "List backup policies and recent backups for a PlanetScale database. Shows configured backup schedules (frequency, retention, next run) and recent backup history with size, duration, and state. Useful for verifying backup health, checking when the last successful backup ran, or understanding retention policies.", + inputSchema: { + organization: z.string().describe("PlanetScale organization name"), + database: z.string().describe("Database name"), + branch: z + .string() + .optional() + .describe( + "Branch name to list backups for (e.g., 'main'). Required when include_backups is true.", + ), + include_backups: z + .boolean() + .optional() + .describe( + "Fetch recent backups for the specified branch (default: false). Requires branch to be set.", + ), + backup_state: z + .enum(["pending", "running", "success", "failed", "canceled", "ignored"]) + .optional() + .describe("Filter backups by state (default: all states)."), + per_page: z + .number() + .optional() + .describe( + "Number of recent backups to return when include_backups is true (default: 5, max: 25).", + ), + }, + async execute(ctx, input) { + try { + const env = + Object.keys(ctx.env).length > 0 + ? (ctx.env as Record) + : process.env; + + const auth = getAuthToken(env); + if (!auth) { + return ctx.text("Error: No PlanetScale authentication configured."); + } + + const { organization, database } = input; + if (!organization || !database) { + return ctx.text("Error: organization and database are required."); + } + + const authHeader = getAuthHeader(env); + const e = encodeURIComponent; + + const policies = await apiRequest>( + `/organizations/${e(organization)}/databases/${e(database)}/backup-policies`, + authHeader, + ); + + const formattedPolicies = policies.data.map(formatPolicy); + + if (!input.include_backups) { + return ctx.json({ + organization, + database, + policies: formattedPolicies, + }); + } + + if (!input.branch) { + return ctx.text( + "Error: branch is required when include_backups is true.", + ); + } + + const perPage = Math.min(input.per_page ?? 5, 25); + const params = new URLSearchParams({ per_page: String(perPage) }); + if (input.backup_state) params.set("state", input.backup_state); + + const backups = await apiRequest>( + `/organizations/${e(organization)}/databases/${e(database)}/branches/${e(input.branch)}/backups?${params}`, + authHeader, + ); + + return ctx.json({ + organization, + database, + branch: input.branch, + policies: formattedPolicies, + recent_backups: backups.data.map(formatBackup), + }); + } catch (error) { + if (error instanceof PlanetScaleAPIError) { + if (error.statusCode === 404) { + return ctx.text( + "Error: Not found. Check that the organization, database, and branch names are correct. (status: 404)", + ); + } + return ctx.text( + `Error: ${error.message} (status: ${error.statusCode})`, + ); + } + if (error instanceof Error) { + return ctx.text(`Error: ${error.message}`); + } + return ctx.text("Error: An unexpected error occurred"); + } + }, +}); diff --git a/src/tools/list-cluster-sizes.ts b/src/tools/list-cluster-sizes.ts index 28e36e1..b52a0bc 100644 --- a/src/tools/list-cluster-sizes.ts +++ b/src/tools/list-cluster-sizes.ts @@ -2,6 +2,7 @@ import { Gram } from "@gram-ai/functions"; import { z } from "zod"; import { PlanetScaleAPIError } from "../lib/planetscale-api.ts"; import { getAuthToken, getAuthHeader } from "../lib/auth.ts"; +import { formatBytes } from "../lib/format.ts"; const API_BASE = "https://api.planetscale.com/v1"; @@ -44,18 +45,6 @@ export interface TierSummary { storage_options?: string[]; } -/** - * Format byte count to human-readable string (e.g. 1073741824 -> "1 GB") - */ -function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - const value = bytes / Math.pow(1024, i); - const unit = units[Math.min(i, units.length - 1)]; - return value % 1 === 0 ? `${value} ${unit}` : `${value.toFixed(1)} ${unit}`; -} - /** * Format CPU string for display (e.g. "1" -> "1 vCPU", "1/2" -> "1/2 vCPU") */ diff --git a/src/tools/list-maintenance-windows.ts b/src/tools/list-maintenance-windows.ts new file mode 100644 index 0000000..9a6df51 --- /dev/null +++ b/src/tools/list-maintenance-windows.ts @@ -0,0 +1,194 @@ +import { Gram } from "@gram-ai/functions"; +import { z } from "zod"; +import { PlanetScaleAPIError, apiRequest } from "../lib/planetscale-api.ts"; +import { getAuthToken, getAuthHeader } from "../lib/auth.ts"; + +interface Actor { + id: string; + type: string; + display_name: string; + avatar_url: string; +} + +interface DatabaseBranch { + id: string; + type: string; + name: string; + created_at: string; + updated_at: string; +} + +interface MaintenanceSchedule { + id: string; + type: string; + name: string; + created_at: string; + updated_at: string; + last_window_datetime: string | null; + next_window_datetime: string | null; + duration: number; + day: number; + hour: number; + week: number | null; + frequency_value: number; + frequency_unit: "day" | "week" | "month" | "once"; + enabled: boolean; + expires_at: string | null; + deadline_at: string | null; + required: boolean; + pending_vitess_version_update: boolean; + pending_vitess_version: string | null; + actor: Actor; + database_branch: DatabaseBranch; +} + +interface MaintenanceWindow { + id: string; + type: string; + created_at: string; + updated_at: string; + started_at: string; + finished_at: string | null; +} + +interface PaginatedList { + type: string; + current_page: number; + next_page: number | null; + next_page_url: string | null; + prev_page: number | null; + prev_page_url: string | null; + data: T[]; +} + +const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Every day"]; + +function formatSchedule(s: MaintenanceSchedule) { + const day = DAY_NAMES[s.day] ?? `day ${s.day}`; + const time = `${String(s.hour).padStart(2, "0")}:00 UTC`; + const freq = + s.frequency_unit === "once" + ? "one-time" + : `every ${s.frequency_value > 1 ? `${s.frequency_value} ${s.frequency_unit}s` : s.frequency_unit}`; + + return { + id: s.id, + name: s.name, + branch: s.database_branch.name, + schedule: `${freq}, ${day} at ${time}, ${s.duration}h window`, + enabled: s.enabled, + required: s.required, + next_window: s.next_window_datetime, + last_window: s.last_window_datetime, + pending_vitess_update: s.pending_vitess_version_update + ? s.pending_vitess_version + : null, + }; +} + +export const listMaintenanceWindowsGram = new Gram().tool({ + name: "list_maintenance_windows", + description: + "Vitess/MySQL databases only. List maintenance schedules and their recent windows for a PlanetScale database. Shows when maintenance is scheduled (day, time, frequency, duration), whether a Vitess version update is pending, and the history of recent maintenance windows with start/finish times. Useful for understanding when a database was or will be under maintenance.", + inputSchema: { + organization: z.string().describe("PlanetScale organization name"), + database: z.string().describe("Database name"), + include_windows: z + .boolean() + .optional() + .describe( + "Fetch recent maintenance windows for each schedule (default: false). Shows the start/finish times of past maintenance runs.", + ), + windows_per_schedule: z + .number() + .optional() + .describe( + "Number of recent windows to fetch per schedule when include_windows is true (default: 5, max: 25).", + ), + }, + async execute(ctx, input) { + try { + const env = + Object.keys(ctx.env).length > 0 + ? (ctx.env as Record) + : process.env; + + const auth = getAuthToken(env); + if (!auth) { + return ctx.text("Error: No PlanetScale authentication configured."); + } + + const { organization, database } = input; + if (!organization || !database) { + return ctx.text("Error: organization and database are required."); + } + + const authHeader = getAuthHeader(env); + const e = encodeURIComponent; + + const schedules = await apiRequest>( + `/organizations/${e(organization)}/databases/${e(database)}/maintenance-schedules`, + authHeader, + ); + + if (schedules.data.length === 0) { + return ctx.json({ + organization, + database, + schedules: [], + message: "No maintenance schedules found for this database.", + }); + } + + const formatted = schedules.data.map(formatSchedule); + + if (!input.include_windows) { + return ctx.json({ organization, database, schedules: formatted }); + } + + const perPage = Math.min(input.windows_per_schedule ?? 5, 25); + + const windowResults = await Promise.allSettled( + schedules.data.map((s) => + apiRequest>( + `/organizations/${e(organization)}/databases/${e(database)}/maintenance-schedules/${e(s.id)}/windows?per_page=${perPage}`, + authHeader, + ).then((res) => ({ + schedule_id: s.id, + windows: res.data.map((w) => ({ + started_at: w.started_at, + finished_at: w.finished_at, + })), + })), + ), + ); + + const windowsBySchedule = new Map(); + for (const r of windowResults) { + if (r.status === "fulfilled") { + windowsBySchedule.set(r.value.schedule_id, r.value.windows); + } + } + + const schedulesWithWindows = formatted.map((s) => ({ + ...s, + recent_windows: windowsBySchedule.get(s.id) ?? [], + })); + + return ctx.json({ organization, database, schedules: schedulesWithWindows }); + } catch (error) { + if (error instanceof PlanetScaleAPIError) { + if (error.statusCode === 404) { + return ctx.text( + "Error: Not found. Check that the organization and database names are correct, and that the database is a Vitess/MySQL database (maintenance schedules are not available for Postgres). (status: 404)", + ); + } + return ctx.text(`Error: ${error.message} (status: ${error.statusCode})`); + } + if (error instanceof Error) { + return ctx.text(`Error: ${error.message}`); + } + return ctx.text("Error: An unexpected error occurred"); + } + }, +});