diff --git a/src/gram.ts b/src/gram.ts index 9459ebf..f35e408 100644 --- a/src/gram.ts +++ b/src/gram.ts @@ -3,8 +3,11 @@ import { Gram } from "@gram-ai/functions"; import { z } from "zod"; import { executeReadQueryGram } from "./tools/execute-read-query.ts"; import { executeWriteQueryGram } from "./tools/execute-write-query.ts"; +import { getEventsGram } from "./tools/get-events.ts"; import { getInsightsGram } from "./tools/get-insights.ts"; import { listClusterSizesGram } from "./tools/list-cluster-sizes.ts"; +import { listDeployRequestsGram } from "./tools/list-deploy-requests.ts"; +import { listResizesGram } from "./tools/list-resizes.ts"; import { searchDocumentationGram } from "./tools/search-documentation.ts"; const gram = new Gram({ @@ -23,8 +26,11 @@ const gram = new Gram({ }) .extend(executeReadQueryGram) .extend(executeWriteQueryGram) + .extend(getEventsGram) .extend(getInsightsGram) .extend(listClusterSizesGram) + .extend(listDeployRequestsGram) + .extend(listResizesGram) .extend(searchDocumentationGram); export default gram; diff --git a/src/tools/get-events.ts b/src/tools/get-events.ts new file mode 100644 index 0000000..6112b72 --- /dev/null +++ b/src/tools/get-events.ts @@ -0,0 +1,539 @@ +import { Gram } from "@gram-ai/functions"; +import { z } from "zod"; +import { PlanetScaleAPIError } from "../lib/planetscale-api.ts"; +import { getAuthToken, getAuthHeader } from "../lib/auth.ts"; + +const API_BASE = "https://api.planetscale.com/v1"; + +/** + * Build a PlanetScale range filter string: "start..end" + */ +function buildRange(from: string, to: string): string { + return `${from}..${to}`; +} + +// ── Shared types ────────────────────────────────────────────────────── + +interface Actor { + id: string; + type: string; + display_name: string; + avatar_url: string; +} + +interface TimelineEvent { + type: string; + at: string; + summary: string; +} + +// ── Branch resizes ──────────────────────────────────────────────────── + +interface BranchResizeEntry { + id: string; + state: string; + completed_at: string | null; + created_at: string; + vtgate_display_name: string; + actor: Actor; +} + +function branchResizeToEvent(entry: BranchResizeEntry): TimelineEvent { + return { + type: "vtgate_resize", + at: entry.completed_at ?? entry.created_at, + summary: `VTGate resize to ${entry.vtgate_display_name} by ${entry.actor.display_name} (${entry.state})`, + }; +} + +// ── Deploy requests ─────────────────────────────────────────────────── + +interface DeployRequest { + number: number; + deployment_state: string; + deployed_at: string | null; + created_at: string; + actor: Actor; +} + +function deployRequestToEvent(entry: DeployRequest): TimelineEvent { + return { + type: "deploy_request", + at: entry.deployed_at ?? entry.created_at, + summary: `Deploy #${entry.number} (${entry.deployment_state}) by ${entry.actor.display_name}`, + }; +} + +// ── Keyspace resizes ────────────────────────────────────────────────── + +interface KeyspaceResizeEntry { + id: string; + state: string; + completed_at: string | null; + created_at: string; + cluster_name: string; + previous_cluster_name: string; + cluster_rate_display_name: string; + previous_cluster_rate_display_name: string; + actor: Actor; +} + +function keyspaceResizeToEvent(keyspace: string, entry: KeyspaceResizeEntry): TimelineEvent { + let desc = entry.cluster_rate_display_name; + if (entry.previous_cluster_rate_display_name !== entry.cluster_rate_display_name) { + desc = `${entry.previous_cluster_rate_display_name} → ${entry.cluster_rate_display_name}`; + } else if (entry.previous_cluster_name !== entry.cluster_name) { + desc = `${entry.cluster_rate_display_name} (SKU: ${entry.previous_cluster_name} → ${entry.cluster_name})`; + } + return { + type: "keyspace_resize", + at: entry.completed_at ?? entry.created_at, + summary: `Keyspace resize (${keyspace}) ${desc} by ${entry.actor.display_name} (${entry.state})`, + }; +} + +// ── Shard resizes ───────────────────────────────────────────────────── + +interface ShardResizeEntry { + id: string; + state: string; + key_range: string; + cluster_name: string; + previous_cluster_name: string; + cluster_display_name: string; + previous_cluster_display_name: string; + completed_at: string | null; + created_at: string; + actor: Actor; +} + +function shardResizeToEvent(keyspace: string, entry: ShardResizeEntry): TimelineEvent { + let desc: string; + if (entry.previous_cluster_display_name !== entry.cluster_display_name) { + desc = `${entry.previous_cluster_display_name} → ${entry.cluster_display_name}`; + } else if (entry.previous_cluster_name !== entry.cluster_name) { + desc = `${entry.cluster_display_name} (SKU: ${entry.previous_cluster_name} → ${entry.cluster_name})`; + } else { + desc = entry.cluster_display_name; + } + return { + type: "shard_resize", + at: entry.completed_at ?? entry.created_at, + summary: `Shard resize (${keyspace} ${entry.key_range}) ${desc} by ${entry.actor.display_name} (${entry.state})`, + }; +} + +// ── Workflows (decomposed into milestone events) ───────────────────── + +interface Workflow { + name: string; + number: number; + state: string; + workflow_type: string; + source_keyspace: { name: string } | null; + target_keyspace: { name: string } | null; + actor: Actor | null; + started_at: string | null; + data_copy_completed_at: string | null; + verify_data_at: string | null; + switch_replicas_at: string | null; + switch_primaries_at: string | null; + cutover_at: string | null; + completed_at: string | null; + cancelled_at: string | null; + reversed_at: string | null; + workflow_errors: string | null; +} + +function workflowToEvents(entry: Workflow): TimelineEvent[] { + const label = `Workflow #${entry.number} (${entry.workflow_type}): ${entry.name}`; + const events: TimelineEvent[] = []; + + const milestones: [string | null, string][] = [ + [entry.started_at, "started"], + [entry.data_copy_completed_at, "data copy completed"], + [entry.verify_data_at, "data verified"], + [entry.switch_replicas_at, "replicas switched"], + [entry.switch_primaries_at, "primaries switched"], + [entry.cutover_at, "cutover"], + [entry.completed_at, "completed"], + [entry.cancelled_at, "cancelled"], + [entry.reversed_at, "reversed"], + ]; + + for (const [ts, milestone] of milestones) { + if (ts) { + events.push({ + type: "workflow", + at: ts, + summary: `${label} — ${milestone}`, + }); + } + } + + return events; +} + +// ── Keyspace discovery ──────────────────────────────────────────────── + +interface BranchKeyspace { + name: string; + sharded: boolean; +} + +// ── Fetch helpers ───────────────────────────────────────────────────── + +interface PaginatedList { + data: T[]; + next_page: number | null; +} + +async function apiFetch(url: string, authHeader: string, label: string): Promise { + const response = await fetch(url, { + method: "GET", + headers: { Authorization: authHeader, Accept: "application/json" }, + }); + + if (!response.ok) { + let details: unknown; + try { + details = await response.json(); + } catch { + details = await response.text(); + } + throw new PlanetScaleAPIError( + `Failed to fetch ${label}: ${response.statusText}`, + response.status, + details, + ); + } + + return (await response.json()) as T; +} + +function e(s: string): string { + return encodeURIComponent(s); +} + +function buildBranchResizesUrl( + org: string, db: string, branch: string, range: string, +): string { + const params = new URLSearchParams(); + params.set("completed_at", range); + params.set("per_page", "25"); + return `${API_BASE}/organizations/${e(org)}/databases/${e(db)}/branches/${e(branch)}/resizes?${params}`; +} + +function buildDeployRequestsUrl( + org: string, db: string, branch: string, range: string, +): string { + const params = new URLSearchParams(); + params.set("deployed_at", range); + params.set("into_branch", branch); + params.set("per_page", "25"); + return `${API_BASE}/organizations/${e(org)}/databases/${e(db)}/deploy-requests?${params}`; +} + +function buildWorkflowsUrl( + org: string, db: string, range: string, +): string { + const params = new URLSearchParams(); + params.set("between", range); + params.set("per_page", "25"); + return `${API_BASE}/organizations/${e(org)}/databases/${e(db)}/workflows?${params}`; +} + +function buildKeyspacesUrl( + org: string, db: string, branch: string, +): string { + return `${API_BASE}/organizations/${e(org)}/databases/${e(db)}/branches/${e(branch)}/keyspaces`; +} + +function buildKeyspaceResizesUrl( + org: string, db: string, branch: string, keyspace: string, range?: string, +): string { + const params = new URLSearchParams(); + params.set("per_page", "25"); + if (range) { + params.set("completed_at", range); + } + return `${API_BASE}/organizations/${e(org)}/databases/${e(db)}/branches/${e(branch)}/keyspaces/${e(keyspace)}/resizes?${params}`; +} + +function buildShardResizesUrl( + org: string, db: string, branch: string, keyspace: string, page: number, +): string { + const params = new URLSearchParams(); + params.set("per_page", "25"); + params.set("page", String(page)); + return `${API_BASE}/organizations/${e(org)}/databases/${e(db)}/branches/${e(branch)}/keyspaces/${e(keyspace)}/shard-resizes?${params}`; +} + +/** Max pages to paginate through for endpoints without server-side time filtering. */ +const MAX_PAGINATION_PAGES = 10; + +/** + * Fetch shard resizes page-by-page until we've covered the requested time + * range (oldest entry is before `fromTime`) or run out of pages. + */ +async function fetchAllShardResizes( + org: string, db: string, branch: string, keyspace: string, + authHeader: string, fromTime: number, +): Promise<{ entries: ShardResizeEntry[]; truncated: boolean }> { + const allEntries: ShardResizeEntry[] = []; + let page = 1; + let truncated = false; + + while (page <= MAX_PAGINATION_PAGES) { + const url = buildShardResizesUrl(org, db, branch, keyspace, page); + const list = await apiFetch>( + url, authHeader, `shard resizes (${keyspace}) page ${page}`, + ); + + allEntries.push(...list.data); + + // Check if the oldest entry on this page is before our from time + const oldest = list.data.at(-1); + if (!oldest) break; // empty page + + const oldestAt = new Date(oldest.completed_at ?? oldest.created_at).getTime(); + if (oldestAt < fromTime) break; // we've reached past the start of our range + + if (list.next_page == null) break; // no more pages + page++; + } + + // If we hit the page cap and the oldest entry is still in range, we're truncated + if (page > MAX_PAGINATION_PAGES && allEntries.length > 0) { + const oldest = allEntries.at(-1)!; + const oldestAt = new Date(oldest.completed_at ?? oldest.created_at).getTime(); + if (oldestAt >= fromTime) { + truncated = true; + } + } + + return { entries: allEntries, truncated }; +} + +// ── Tool definition ─────────────────────────────────────────────────── + +export const getEventsGram = new Gram().tool({ + name: "get_events", + description: + "Get a unified chronological timeline of all PlanetScale events for a database branch within a time range. Combines VTGate resizes, keyspace/VTTablet resizes, individual shard resizes, deploy requests (schema migrations), and VReplication workflow milestones (MoveTables/Reshard) into a single sorted event stream. Workflows are decomposed into individual milestone events (started, data copy, verify, switch replicas, switch primaries, cutover, completed). Automatically discovers keyspaces and fetches per-keyspace resize history. Useful for incident investigation — call with the incident time window to see everything that changed.", + inputSchema: { + organization: z.string().describe("PlanetScale organization name"), + database: z.string().describe("Database name"), + branch: z.string().describe("Branch name (e.g., 'main')"), + from: z + .string() + .describe( + "Start of time range (ISO 8601, e.g., '2026-03-25T00:00:00.000Z')", + ), + to: z + .string() + .describe( + "End of time range (ISO 8601, e.g., '2026-03-25T23:59:00.000Z')", + ), + }, + 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, branch, from, to } = input; + if (!organization || !database || !branch || !from || !to) { + return ctx.text( + "Error: organization, database, branch, from, and to are all required.", + ); + } + + const authHeader = getAuthHeader(env); + const range = buildRange(from, to); + const fromTime = new Date(from).getTime(); + const toTime = new Date(to).getTime(); + + // Phase 1: Fetch event sources + keyspace list in parallel + const [branchResizes, deployRequests, workflows, keyspaceList] = + await Promise.allSettled([ + apiFetch>( + buildBranchResizesUrl(organization, database, branch, range), + authHeader, + "branch resizes", + ), + apiFetch>( + buildDeployRequestsUrl(organization, database, branch, range), + authHeader, + "deploy requests", + ), + apiFetch>( + buildWorkflowsUrl(organization, database, range), + authHeader, + "workflows", + ), + apiFetch>( + buildKeyspacesUrl(organization, database, branch), + authHeader, + "keyspaces", + ), + ]); + + // Phase 2: Fan out per-keyspace resize calls + const allKeyspaces: string[] = []; + const shardedKeyspaces: string[] = []; + if (keyspaceList.status === "fulfilled") { + for (const ks of keyspaceList.value.data) { + allKeyspaces.push(ks.name); + if (ks.sharded) { + shardedKeyspaces.push(ks.name); + } + } + } + + const [keyspaceResizeResults, shardResizeResults] = await Promise.all([ + Promise.allSettled( + allKeyspaces.map((ks) => + apiFetch>( + buildKeyspaceResizesUrl(organization, database, branch, ks, range), + authHeader, + `keyspace resizes (${ks})`, + ).then((list) => ({ keyspace: ks, list })) + ), + ), + Promise.allSettled( + shardedKeyspaces.map((ks) => + fetchAllShardResizes(organization, database, branch, ks, authHeader, fromTime) + .then((result) => ({ keyspace: ks, ...result })) + ), + ), + ]); + + const events: TimelineEvent[] = []; + const errors: string[] = []; + const truncated: string[] = []; + + if (branchResizes.status === "fulfilled") { + const list = branchResizes.value; + for (const entry of list.data) { + events.push(branchResizeToEvent(entry)); + } + if (list.next_page != null) { + truncated.push("vtgate_resizes"); + } + } else { + errors.push(`branch resizes: ${formatError(branchResizes.reason)}`); + } + + if (deployRequests.status === "fulfilled") { + const list = deployRequests.value; + for (const entry of list.data) { + events.push(deployRequestToEvent(entry)); + } + if (list.next_page != null) { + truncated.push("deploy_requests"); + } + } else { + errors.push(`deploy requests: ${formatError(deployRequests.reason)}`); + } + + if (workflows.status === "fulfilled") { + const list = workflows.value; + for (const entry of list.data) { + events.push(...workflowToEvents(entry)); + } + if (list.next_page != null) { + truncated.push("workflows"); + } + } else { + errors.push(`workflows: ${formatError(workflows.reason)}`); + } + + if (keyspaceList.status === "rejected") { + errors.push(`keyspace discovery: ${formatError(keyspaceList.reason)}`); + } + + for (const r of keyspaceResizeResults) { + if (r.status === "fulfilled") { + const { keyspace, list } = r.value; + // Server-side completed_at filtering is applied, so all results are in range + for (const entry of list.data) { + events.push(keyspaceResizeToEvent(keyspace, entry)); + } + if (list.next_page != null) { + truncated.push(`keyspace_resizes(${keyspace})`); + } + } else { + errors.push(`keyspace resizes: ${formatError(r.reason)}`); + } + } + + for (const r of shardResizeResults) { + if (r.status === "fulfilled") { + const { keyspace, entries, truncated: isTruncated } = r.value; + // Client-side time filtering — the paginating fetcher already walked + // back far enough to cover the full from..to range. + for (const entry of entries) { + const at = new Date(entry.completed_at ?? entry.created_at).getTime(); + if (at >= fromTime && at <= toTime) { + events.push(shardResizeToEvent(keyspace, entry)); + } + } + if (isTruncated) { + truncated.push(`shard_resizes(${keyspace})`); + } + } else { + errors.push(`shard resizes: ${formatError(r.reason)}`); + } + } + + // Sort chronologically + events.sort((a, b) => new Date(a.at).getTime() - new Date(b.at).getTime()); + + const result: Record = { + organization, + database, + branch, + from, + to, + total_events: events.length, + events, + }; + + if (truncated.length > 0) { + result["truncated_sources"] = truncated; + result["warning"] = `Some sources had more than 25 results and were truncated: ${truncated.join(", ")}. Narrow the time range for complete results.`; + } + + if (errors.length > 0) { + result["errors"] = errors; + } + + return ctx.json(result); + } catch (error) { + if (error instanceof PlanetScaleAPIError) { + 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"); + } + }, +}); + +function formatError(reason: unknown): string { + if (reason instanceof PlanetScaleAPIError) { + return `${reason.message} (status: ${reason.statusCode})`; + } + if (reason instanceof Error) { + return reason.message; + } + return "unknown error"; +} diff --git a/src/tools/list-deploy-requests.ts b/src/tools/list-deploy-requests.ts new file mode 100644 index 0000000..e7116dd --- /dev/null +++ b/src/tools/list-deploy-requests.ts @@ -0,0 +1,374 @@ +import { Gram } from "@gram-ai/functions"; +import { z } from "zod"; +import { PlanetScaleAPIError } from "../lib/planetscale-api.ts"; +import { getAuthToken, getAuthHeader } from "../lib/auth.ts"; + +const API_BASE = "https://api.planetscale.com/v1"; + +interface Actor { + id: string; + type: string; + display_name: string; + avatar_url: string; +} + +interface DeployOperation { + id: string; + type: string; + state: string; + keyspace_name: string; + table_name: string; + operation_name: string; + ddl_statement: string; + eta_seconds: number | null; + progress_percentage: number | null; + can_drop_data: boolean; + created_at: string; + updated_at: string; +} + +interface Deployment { + id: string; + type: string; + state: string; + into_branch: string; + deploy_request_number: number; + auto_cutover: boolean; + deploy_operations: DeployOperation[]; + created_at: string; + finished_at: string | null; + cutover_at: string | null; +} + +interface DeployRequest { + id: string; + type: string; + number: number; + state: string; + deployment_state: string; + branch: string; + branch_id: string; + into_branch: string; + approved: boolean; + actor: Actor; + closed_by: Actor | null; + deployment: Deployment; + created_at: string; + updated_at: string; + closed_at: string | null; + deployed_at: string | null; + html_url: string; +} + +interface PaginatedList { + type: "list"; + current_page: number; + next_page: number | null; + data: T[]; +} + +function summarizeDeployRequest(entry: DeployRequest) { + const ops = entry.deployment.deploy_operations.map((op) => { + const summary: Record = { + keyspace: op.keyspace_name, + table: op.table_name, + operation: op.operation_name, + ddl: op.ddl_statement, + state: op.state, + }; + if (op.progress_percentage != null) { + summary["progress_pct"] = op.progress_percentage; + } + if (op.eta_seconds != null && op.eta_seconds > 0) { + summary["eta_seconds"] = op.eta_seconds; + } + if (op.can_drop_data) { + summary["can_drop_data"] = true; + } + return summary; + }); + + return { + number: entry.number, + state: entry.state, + deployment_state: entry.deployment_state, + branch: entry.branch, + into_branch: entry.into_branch, + actor: entry.actor.display_name, + auto_cutover: entry.deployment.auto_cutover, + created_at: entry.created_at, + deployed_at: entry.deployed_at, + closed_at: entry.closed_at, + operations: ops, + }; +} + +/** + * Build a PlanetScale range filter string: "start..end" + */ +function buildRangeFilter(from: string, to: string): string { + return `${from}..${to}`; +} + +async function fetchDeployRequests( + organization: string, + database: string, + authHeader: string, + options: { + intoBranch?: string; + state?: string; + deployedAtFrom?: string; + deployedAtTo?: string; + page: number; + perPage: number; + }, +): Promise> { + const params = new URLSearchParams(); + params.set("page", String(options.page)); + params.set("per_page", String(options.perPage)); + if (options.intoBranch) { + params.set("into_branch", options.intoBranch); + } + if (options.state) { + params.set("state", options.state); + } + if (options.deployedAtFrom && options.deployedAtTo) { + params.set("deployed_at", buildRangeFilter(options.deployedAtFrom, options.deployedAtTo)); + } + + const url = `${API_BASE}/organizations/${encodeURIComponent(organization)}/databases/${encodeURIComponent(database)}/deploy-requests?${params}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: authHeader, + Accept: "application/json", + }, + }); + + if (!response.ok) { + let details: unknown; + try { + details = await response.json(); + } catch { + details = await response.text(); + } + throw new PlanetScaleAPIError( + `Failed to fetch deploy requests: ${response.statusText}`, + response.status, + details, + ); + } + + return (await response.json()) as PaginatedList; +} + +interface Workflow { + id: string; + name: string; + number: number; + state: string; + workflow_type: string; + workflow_subtype: string; + started_at: string | null; + completed_at: string | null; + cancelled_at: string | null; + reversed_at: string | null; + data_copy_completed_at: string | null; + verify_data_at: string | null; + switch_replicas_at: string | null; + switch_primaries_at: string | null; + cutover_at: string | null; + replicas_switched: boolean; + primaries_switched: boolean; + workflow_errors: string | null; + source_keyspace: { name: string } | null; + target_keyspace: { name: string } | null; + actor: Actor | null; + created_at: string; + updated_at: string; +} + +function summarizeWorkflow(entry: Workflow) { + return { + number: entry.number, + name: entry.name, + state: entry.state, + workflow_type: entry.workflow_type, + source_keyspace: entry.source_keyspace?.name ?? null, + target_keyspace: entry.target_keyspace?.name ?? null, + actor: entry.actor?.display_name ?? null, + created_at: entry.created_at, + started_at: entry.started_at, + data_copy_completed_at: entry.data_copy_completed_at, + verify_data_at: entry.verify_data_at, + switch_replicas_at: entry.switch_replicas_at, + switch_primaries_at: entry.switch_primaries_at, + cutover_at: entry.cutover_at, + completed_at: entry.completed_at, + ...(entry.workflow_errors ? { errors: entry.workflow_errors } : {}), + }; +} + +async function fetchWorkflows( + organization: string, + database: string, + authHeader: string, + options: { + from?: string; + to?: string; + page: number; + perPage: number; + }, +): Promise> { + const params = new URLSearchParams(); + params.set("page", String(options.page)); + params.set("per_page", String(options.perPage)); + if (options.from && options.to) { + params.set("between", buildRangeFilter(options.from, options.to)); + } + + const url = `${API_BASE}/organizations/${encodeURIComponent(organization)}/databases/${encodeURIComponent(database)}/workflows?${params}`; + + const response = await fetch(url, { + method: "GET", + headers: { Authorization: authHeader, Accept: "application/json" }, + }); + + if (!response.ok) { + let details: unknown; + try { details = await response.json(); } catch { details = await response.text(); } + throw new PlanetScaleAPIError(`Failed to fetch workflows: ${response.statusText}`, response.status, details); + } + + return (await response.json()) as PaginatedList; +} + +export const listDeployRequestsGram = new Gram().tool({ + name: "list_deploy_requests", + description: + "List deploy requests (schema migrations) and VReplication workflows for a PlanetScale database. Deploy requests show DDL operations (ALTER TABLE, CREATE INDEX, etc.) and their progress. Workflows show MoveTables/Reshard operations with milestone timestamps (data copy, verify, switch replicas, switch primaries, cutover, complete).", + inputSchema: { + organization: z.string().describe("PlanetScale organization name"), + database: z.string().describe("Database name"), + into_branch: z + .string() + .optional() + .describe("Filter by target branch (e.g., 'main')"), + state: z + .enum(["open", "closed"]) + .optional() + .describe("Filter by deploy request state: 'open' or 'closed'"), + from: z + .string() + .optional() + .describe( + "Start of time range (ISO 8601, e.g., '2026-03-25T00:00:00.000Z'). Filters deploy requests by deployed_at and workflows by active time range. Must be paired with 'to'.", + ), + to: z + .string() + .optional() + .describe( + "End of time range (ISO 8601, e.g., '2026-03-25T23:59:00.000Z'). Must be paired with 'from'.", + ), + include_workflows: z + .boolean() + .optional() + .describe( + "Include VReplication workflows (MoveTables, Reshard) with milestone timestamps (default: false).", + ), + page: z.number().optional().describe("Page number (default: 1)"), + per_page: z + .number() + .optional() + .describe("Results per page (default: 10, 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 page = input.page ?? 1; + const perPage = Math.min(input.per_page ?? 10, 25); + const authHeader = getAuthHeader(env); + const includeWorkflows = input.include_workflows ?? false; + + const [deployResult, workflowResult] = await Promise.allSettled([ + fetchDeployRequests(organization, database, authHeader, { + intoBranch: input.into_branch, + state: input.state, + deployedAtFrom: input.from, + deployedAtTo: input.to, + page, + perPage, + }), + includeWorkflows + ? fetchWorkflows(organization, database, authHeader, { + from: input.from, + to: input.to, + page, + perPage, + }) + : Promise.resolve(null), + ]); + + const result: Record = { organization, database }; + + if (deployResult.status === "fulfilled") { + const list = deployResult.value; + result["deploy_requests"] = { + total: list.data.length, + page: list.current_page, + next_page: list.next_page, + requests: list.data.map(summarizeDeployRequest), + }; + } else { + result["deploy_requests"] = { + error: deployResult.reason instanceof PlanetScaleAPIError + ? `${deployResult.reason.message} (status: ${deployResult.reason.statusCode})` + : "Failed to fetch deploy requests", + }; + } + + if (includeWorkflows) { + if (workflowResult.status === "fulfilled" && workflowResult.value) { + const list = workflowResult.value; + result["workflows"] = { + total: list.data.length, + page: list.current_page, + next_page: list.next_page, + workflows: list.data.map(summarizeWorkflow), + }; + } else if (workflowResult.status === "rejected") { + result["workflows"] = { + error: workflowResult.reason instanceof PlanetScaleAPIError + ? `${workflowResult.reason.message} (status: ${workflowResult.reason.statusCode})` + : "Failed to fetch workflows", + }; + } + } + + return ctx.json(result); + } catch (error) { + if (error instanceof PlanetScaleAPIError) { + 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-resizes.ts b/src/tools/list-resizes.ts new file mode 100644 index 0000000..5ede755 --- /dev/null +++ b/src/tools/list-resizes.ts @@ -0,0 +1,556 @@ +import { Gram } from "@gram-ai/functions"; +import { z } from "zod"; +import { PlanetScaleAPIError } from "../lib/planetscale-api.ts"; +import { getAuthToken, getAuthHeader } from "../lib/auth.ts"; + +const API_BASE = "https://api.planetscale.com/v1"; + +interface Actor { + id: string; + type: string; + display_name: string; + avatar_url: string; +} + +interface BranchResizeEntry { + id: string; + type: "BranchResizeRequest"; + state: string; + started_at: string | null; + completed_at: string | null; + created_at: string; + updated_at: string; + vtgate_size: string; + previous_vtgate_size: string; + vtgate_count: number; + previous_vtgate_count: number; + vtgate_max_count: number; + previous_vtgate_max_count: number; + vtgate_autoscaling: boolean; + previous_vtgate_autoscaling: boolean; + vtgate_target_cpu_utilization: number; + previous_vtgate_target_cpu_utilization: number; + vtgate_name: string; + vtgate_display_name: string; + previous_vtgate_name: string; + previous_vtgate_display_name: string; + actor: Actor; +} + +interface PaginatedList { + type: "list"; + current_page: number; + next_page: number | null; + data: T[]; +} + +/** + * Summarize a branch (VTGate) resize into the fields that actually changed. + */ +function summarizeBranchResize(entry: BranchResizeEntry) { + const changes: Record = {}; + + if (entry.vtgate_display_name !== entry.previous_vtgate_display_name) { + changes["vtgate_size"] = { from: entry.previous_vtgate_display_name, to: entry.vtgate_display_name }; + } + if (entry.vtgate_count !== entry.previous_vtgate_count) { + changes["vtgate_count"] = { from: entry.previous_vtgate_count, to: entry.vtgate_count }; + } + if (entry.vtgate_max_count !== entry.previous_vtgate_max_count) { + changes["vtgate_max_count"] = { from: entry.previous_vtgate_max_count, to: entry.vtgate_max_count }; + } + if (entry.vtgate_autoscaling !== entry.previous_vtgate_autoscaling) { + changes["vtgate_autoscaling"] = { from: entry.previous_vtgate_autoscaling, to: entry.vtgate_autoscaling }; + } + if (entry.vtgate_target_cpu_utilization !== entry.previous_vtgate_target_cpu_utilization) { + changes["vtgate_target_cpu_utilization"] = { + from: entry.previous_vtgate_target_cpu_utilization, + to: entry.vtgate_target_cpu_utilization, + }; + } + + return { + id: entry.id, + state: entry.state, + vtgate: entry.vtgate_display_name, + created_at: entry.created_at, + started_at: entry.started_at, + completed_at: entry.completed_at, + changes, + actor: entry.actor.display_name, + }; +} + +async function fetchBranchResizes( + organization: string, + database: string, + branch: string, + authHeader: string, + page: number, + perPage: number, + completedAtRange?: string, +): Promise> { + const params = new URLSearchParams(); + params.set("page", String(page)); + params.set("per_page", String(perPage)); + if (completedAtRange) { + params.set("completed_at", completedAtRange); + } + const url = `${API_BASE}/organizations/${encodeURIComponent(organization)}/databases/${encodeURIComponent(database)}/branches/${encodeURIComponent(branch)}/resizes?${params}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: authHeader, + Accept: "application/json", + }, + }); + + if (!response.ok) { + let details: unknown; + try { + details = await response.json(); + } catch { + details = await response.text(); + } + throw new PlanetScaleAPIError( + `Failed to fetch branch resizes: ${response.statusText}`, + response.status, + details, + ); + } + + return (await response.json()) as PaginatedList; +} + +interface KeyspaceResizeEntry { + id: string; + type: "KeyspaceResizeRequest"; + state: string; + started_at: string | null; + completed_at: string | null; + created_at: string; + updated_at: string; + cluster_name: string; + previous_cluster_name: string; + cluster_rate_display_name: string; + previous_cluster_rate_display_name: string; + replicas: number; + previous_replicas: number; + cluster_rank: number; + previous_cluster_rank: number; + name?: string; + actor: Actor; +} + +function summarizeKeyspaceResize(keyspace: string, entry: KeyspaceResizeEntry) { + const changes: Record = {}; + + if (entry.cluster_rate_display_name !== entry.previous_cluster_rate_display_name) { + changes["cluster_size"] = { + from: entry.previous_cluster_rate_display_name, + to: entry.cluster_rate_display_name, + }; + } + if (entry.replicas !== entry.previous_replicas) { + changes["replicas"] = { from: entry.previous_replicas, to: entry.replicas }; + } + if (entry.cluster_rank !== entry.previous_cluster_rank) { + changes["cluster_rank"] = { from: entry.previous_cluster_rank, to: entry.cluster_rank }; + } + if (entry.cluster_name !== entry.previous_cluster_name) { + changes["cluster_sku"] = { + from: entry.previous_cluster_name, + to: entry.cluster_name, + }; + } + + return { + id: entry.id, + state: entry.state, + keyspace, + cluster_size: entry.cluster_rate_display_name, + created_at: entry.created_at, + started_at: entry.started_at, + completed_at: entry.completed_at, + changes, + actor: entry.actor.display_name, + }; +} + +interface ShardResizeEntry { + id: string; + type: "VitessShardResizeRequest"; + state: string; + key_range: string; + cluster_name: string; + cluster_display_name: string; + previous_cluster_name: string; + previous_cluster_display_name: string; + reset: boolean; + started_at: string | null; + completed_at: string | null; + created_at: string; + updated_at: string; + actor: Actor; +} + +function summarizeShardResize(keyspace: string, entry: ShardResizeEntry) { + const changes: Record = {}; + + if (entry.cluster_display_name !== entry.previous_cluster_display_name) { + changes["cluster_size"] = { + from: entry.previous_cluster_display_name, + to: entry.cluster_display_name, + }; + } + if (entry.cluster_name !== entry.previous_cluster_name) { + changes["cluster_sku"] = { + from: entry.previous_cluster_name, + to: entry.cluster_name, + }; + } + + return { + id: entry.id, + state: entry.state, + keyspace, + key_range: entry.key_range, + cluster_size: entry.cluster_display_name, + created_at: entry.created_at, + started_at: entry.started_at, + completed_at: entry.completed_at, + changes, + actor: entry.actor.display_name, + }; +} + +interface BranchKeyspace { + name: string; + sharded: boolean; +} + +async function fetchKeyspaces( + organization: string, + database: string, + branch: string, + authHeader: string, +): Promise> { + const url = `${API_BASE}/organizations/${encodeURIComponent(organization)}/databases/${encodeURIComponent(database)}/branches/${encodeURIComponent(branch)}/keyspaces`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: authHeader, + Accept: "application/json", + }, + }); + + if (!response.ok) { + let details: unknown; + try { + details = await response.json(); + } catch { + details = await response.text(); + } + throw new PlanetScaleAPIError( + `Failed to fetch keyspaces: ${response.statusText}`, + response.status, + details, + ); + } + + return (await response.json()) as PaginatedList; +} + +async function fetchShardResizes( + organization: string, + database: string, + branch: string, + keyspace: string, + authHeader: string, + perPage: number, +): Promise> { + const params = new URLSearchParams(); + params.set("per_page", String(perPage)); + const url = `${API_BASE}/organizations/${encodeURIComponent(organization)}/databases/${encodeURIComponent(database)}/branches/${encodeURIComponent(branch)}/keyspaces/${encodeURIComponent(keyspace)}/shard-resizes?${params}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: authHeader, + Accept: "application/json", + }, + }); + + if (!response.ok) { + let details: unknown; + try { + details = await response.json(); + } catch { + details = await response.text(); + } + throw new PlanetScaleAPIError( + `Failed to fetch shard resizes: ${response.statusText}`, + response.status, + details, + ); + } + + return (await response.json()) as PaginatedList; +} + +async function fetchKeyspaceResizes( + organization: string, + database: string, + branch: string, + keyspace: string, + authHeader: string, + perPage: number, +): Promise> { + const params = new URLSearchParams(); + params.set("per_page", String(perPage)); + const url = `${API_BASE}/organizations/${encodeURIComponent(organization)}/databases/${encodeURIComponent(database)}/branches/${encodeURIComponent(branch)}/keyspaces/${encodeURIComponent(keyspace)}/resizes?${params}`; + + const response = await fetch(url, { + method: "GET", + headers: { Authorization: authHeader, Accept: "application/json" }, + }); + + if (!response.ok) { + let details: unknown; + try { details = await response.json(); } catch { details = await response.text(); } + throw new PlanetScaleAPIError(`Failed to fetch keyspace resizes: ${response.statusText}`, response.status, details); + } + + return (await response.json()) as PaginatedList; +} + +export const listResizesGram = new Gram().tool({ + name: "list_resizes", + description: + "List recent resize operations for a PlanetScale database branch. Returns VTGate (connection proxy) resizes, keyspace/VTTablet (storage compute) resizes, and individual shard resizes. Automatically discovers keyspaces and fetches per-keyspace resize history. Each resize entry shows what changed with before/after values, the current state, and who initiated it.", + inputSchema: { + organization: z.string().describe("PlanetScale organization name"), + database: z.string().describe("Database name"), + branch: z.string().describe("Branch name (e.g., 'main')"), + type: z + .enum(["all", "vtgate", "keyspace", "shard"]) + .optional() + .describe( + "Type of resizes to list: 'all' (default) returns all resize types, 'vtgate' returns only VTGate/connection proxy resizes, 'keyspace' returns only keyspace/VTTablet resizes, 'shard' returns only individual shard resizes", + ), + from: z + .string() + .optional() + .describe( + "Start of time range to filter by completed_at (ISO 8601, e.g., '2026-03-25T00:00:00.000Z'). Must be paired with 'to'.", + ), + to: z + .string() + .optional() + .describe( + "End of time range to filter by completed_at (ISO 8601). Must be paired with 'from'.", + ), + page: z.number().optional().describe("Page number (default: 1)"), + per_page: z + .number() + .optional() + .describe("Results per page (default: 10, 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, branch } = input; + if (!organization || !database || !branch) { + return ctx.text("Error: organization, database, and branch are required."); + } + + const resizeType = input.type ?? "all"; + const page = input.page ?? 1; + const perPage = Math.min(input.per_page ?? 10, 25); + const authHeader = getAuthHeader(env); + const completedAtRange = + input.from && input.to ? `${input.from}..${input.to}` : undefined; + const fromTime = input.from ? new Date(input.from).getTime() : null; + const toTime = input.to ? new Date(input.to).getTime() : null; + + const wantVtgate = resizeType === "all" || resizeType === "vtgate"; + const wantKeyspace = resizeType === "all" || resizeType === "keyspace"; + const wantShard = resizeType === "all" || resizeType === "shard"; + const needKeyspaceDiscovery = wantKeyspace || wantShard; + + // Phase 1: Fetch VTGate resizes + keyspace discovery in parallel + const [vtgateResult, keyspaceListResult] = await Promise.allSettled([ + wantVtgate + ? fetchBranchResizes(organization, database, branch, authHeader, page, perPage, completedAtRange) + : Promise.resolve(null), + needKeyspaceDiscovery + ? fetchKeyspaces(organization, database, branch, authHeader) + : Promise.resolve(null), + ]); + + // Phase 2: Fan out per-keyspace resize calls + const allKeyspaces: string[] = []; + const shardedKeyspaces: string[] = []; + if (needKeyspaceDiscovery && keyspaceListResult.status === "fulfilled" && keyspaceListResult.value) { + for (const ks of keyspaceListResult.value.data) { + allKeyspaces.push(ks.name); + if (ks.sharded) { + shardedKeyspaces.push(ks.name); + } + } + } + + const [keyspaceResizeResults, shardResizeResults] = await Promise.all([ + Promise.allSettled( + wantKeyspace + ? allKeyspaces.map((ks) => + fetchKeyspaceResizes(organization, database, branch, ks, authHeader, perPage) + .then((list) => ({ keyspace: ks, list })) + ) + : [], + ), + Promise.allSettled( + wantShard + ? shardedKeyspaces.map((ks) => + fetchShardResizes(organization, database, branch, ks, authHeader, perPage) + .then((list) => ({ keyspace: ks, list })) + ) + : [], + ), + ]); + + const result: Record = { + organization, + database, + branch, + }; + + if (wantVtgate) { + if (vtgateResult.status === "fulfilled" && vtgateResult.value) { + const list = vtgateResult.value; + result["vtgate_resizes"] = { + total: list.data.length, + page: list.current_page, + next_page: list.next_page, + resizes: list.data.map(summarizeBranchResize), + }; + } else { + const reason = + vtgateResult.status === "rejected" ? vtgateResult.reason : null; + result["vtgate_resizes"] = { + error: + reason instanceof PlanetScaleAPIError + ? `${reason.message} (status: ${reason.statusCode})` + : "Failed to fetch VTGate resizes", + }; + } + } + + if (wantKeyspace) { + if (keyspaceListResult.status === "rejected") { + result["keyspace_resizes"] = { + error: `keyspace discovery: ${ + keyspaceListResult.reason instanceof PlanetScaleAPIError + ? `${keyspaceListResult.reason.message} (status: ${keyspaceListResult.reason.statusCode})` + : "Failed to fetch keyspaces" + }`, + }; + } else { + const allKsResizes: ReturnType[] = []; + const ksErrors: string[] = []; + for (const r of keyspaceResizeResults) { + if (r.status === "fulfilled") { + const { keyspace, list } = r.value; + for (const entry of list.data) { + if (fromTime != null && toTime != null) { + const at = new Date(entry.completed_at ?? entry.created_at).getTime(); + if (at < fromTime || at > toTime) continue; + } + allKsResizes.push(summarizeKeyspaceResize(keyspace, entry)); + } + } else { + ksErrors.push( + r.reason instanceof PlanetScaleAPIError + ? `${r.reason.message} (status: ${r.reason.statusCode})` + : "Failed to fetch keyspace resizes", + ); + } + } + const ksResult: Record = { + total: allKsResizes.length, + keyspaces_checked: allKeyspaces, + resizes: allKsResizes, + }; + if (ksErrors.length > 0) { + ksResult["errors"] = ksErrors; + } + result["keyspace_resizes"] = ksResult; + } + } + + if (wantShard) { + if (keyspaceListResult.status === "rejected") { + result["shard_resizes"] = { + error: `keyspace discovery: ${ + keyspaceListResult.reason instanceof PlanetScaleAPIError + ? `${keyspaceListResult.reason.message} (status: ${keyspaceListResult.reason.statusCode})` + : "Failed to fetch keyspaces" + }`, + }; + } else { + const allShardResizes: ReturnType[] = []; + const shardErrors: string[] = []; + for (const r of shardResizeResults) { + if (r.status === "fulfilled") { + const { keyspace, list } = r.value; + // Client-side time filtering since the API ignores completed_at + for (const entry of list.data) { + if (fromTime != null && toTime != null) { + const at = new Date(entry.completed_at ?? entry.created_at).getTime(); + if (at < fromTime || at > toTime) continue; + } + allShardResizes.push(summarizeShardResize(keyspace, entry)); + } + } else { + shardErrors.push( + r.reason instanceof PlanetScaleAPIError + ? `${r.reason.message} (status: ${r.reason.statusCode})` + : "Failed to fetch shard resizes", + ); + } + } + const shardResult: Record = { + total: allShardResizes.length, + keyspaces_checked: shardedKeyspaces, + resizes: allShardResizes, + }; + if (shardErrors.length > 0) { + shardResult["errors"] = shardErrors; + } + result["shard_resizes"] = shardResult; + } + } + + return ctx.json(result); + } catch (error) { + if (error instanceof PlanetScaleAPIError) { + 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"); + } + }, +});