From 1478efdeb061bb6d468496aaa8a6a9f183ca9923 Mon Sep 17 00:00:00 2001 From: Miles McGuire Date: Wed, 25 Mar 2026 16:40:20 +0100 Subject: [PATCH 1/5] feat: add resize, deploy request, and event timeline tools (public API) Add three new tools that use the PlanetScale public v1 API: - list_resizes: List VTGate resize operations with completed_at filtering - list_deploy_requests: List deploy requests with deployed_at filtering - get_events: Unified chronological timeline combining resizes and deploys for incident investigation All tools support time range filtering using PlanetScale's range syntax (e.g. deployed_at=start..end). Paginated tools return next_page for explicit pagination. get_events returns a slim index (type + time + one-line summary) so callers can drill down with the detail tools. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/gram.ts | 6 + src/tools/get-events.ts | 249 ++++++++++++++++++++++++++++++ src/tools/list-deploy-requests.ts | 247 +++++++++++++++++++++++++++++ src/tools/list-resizes.ts | 198 ++++++++++++++++++++++++ 4 files changed, 700 insertions(+) create mode 100644 src/tools/get-events.ts create mode 100644 src/tools/list-deploy-requests.ts create mode 100644 src/tools/list-resizes.ts 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..322c5e5 --- /dev/null +++ b/src/tools/get-events.ts @@ -0,0 +1,249 @@ +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}`, + }; +} + +// ── 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}`; +} + +// ── Tool definition ─────────────────────────────────────────────────── + +export const getEventsGram = new Gram().tool({ + name: "get_events", + description: + "Get a unified chronological timeline of PlanetScale events for a database branch within a time range. Combines VTGate resizes and deploy requests (schema migrations) into a single sorted event stream. 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); + + // Fetch all event sources in parallel + const [branchResizes, deployRequests] = + await Promise.allSettled([ + apiFetch>( + buildBranchResizesUrl(organization, database, branch, range), + authHeader, + "branch resizes", + ), + apiFetch>( + buildDeployRequestsUrl(organization, database, branch, range), + authHeader, + "deploy requests", + ), + ]); + + 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)}`); + } + + // 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..f9d552a --- /dev/null +++ b/src/tools/list-deploy-requests.ts @@ -0,0 +1,247 @@ +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; +} + +export const listDeployRequestsGram = new Gram().tool({ + name: "list_deploy_requests", + description: + "List deploy requests (schema migrations) for a PlanetScale database. Deploy requests show DDL operations (ALTER TABLE, CREATE INDEX, etc.) and their progress.", + 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. 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'.", + ), + 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 list = await fetchDeployRequests(organization, database, authHeader, { + intoBranch: input.into_branch, + state: input.state, + deployedAtFrom: input.from, + deployedAtTo: input.to, + page, + perPage, + }); + + return ctx.json({ + organization, + database, + total: list.data.length, + page: list.current_page, + next_page: list.next_page, + requests: list.data.map(summarizeDeployRequest), + }); + } 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..824db2d --- /dev/null +++ b/src/tools/list-resizes.ts @@ -0,0 +1,198 @@ +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; +} + +export const listResizesGram = new Gram().tool({ + name: "list_resizes", + description: + "List recent VTGate (connection proxy) resize operations for a PlanetScale database branch. Each resize entry shows what changed (size, count, autoscaling settings) 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')"), + 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 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 list = await fetchBranchResizes( + organization, database, branch, authHeader, page, perPage, completedAtRange, + ); + + return ctx.json({ + organization, + database, + branch, + total: list.data.length, + page: list.current_page, + next_page: list.next_page, + resizes: list.data.map(summarizeBranchResize), + }); + } 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"); + } + }, +}); From b9e1b93dfc360dabc89eea26ca9cc0c93256b429 Mon Sep 17 00:00:00 2001 From: Miles McGuire Date: Wed, 25 Mar 2026 16:52:12 +0100 Subject: [PATCH 2/5] feat: add keyspace and shard resize support with automatic discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-keyspace resize events (both keyspace resizes and individual shard resizes) to list_resizes and get_events. Both use the public v1 API endpoints: - /v1/.../keyspaces/{keyspace}/resizes (keyspace resizes) - /v1/.../keyspaces/{keyspace}/shard-resizes (shard resizes) Both tools discover keyspaces automatically via the keyspaces list endpoint, then fan out resize calls in parallel — keyspace resizes for all keyspaces, shard resizes for sharded keyspaces only. Client-side time filtering is applied since these endpoints ignore completed_at range params. list_resizes gains a type filter (all/vtgate/keyspace/shard). get_events includes both in its unified timeline. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tools/get-events.ts | 151 +++++++++++++++- src/tools/list-resizes.ts | 364 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 502 insertions(+), 13 deletions(-) diff --git a/src/tools/get-events.ts b/src/tools/get-events.ts index 322c5e5..b9afe3a 100644 --- a/src/tools/get-events.ts +++ b/src/tools/get-events.ts @@ -64,6 +64,53 @@ function deployRequestToEvent(entry: DeployRequest): TimelineEvent { }; } +// ── Keyspace resizes ────────────────────────────────────────────────── + +interface KeyspaceResizeEntry { + id: string; + state: string; + completed_at: string | null; + created_at: string; + cluster_rate_display_name: string; + actor: Actor; +} + +function keyspaceResizeToEvent(keyspace: string, entry: KeyspaceResizeEntry): TimelineEvent { + return { + type: "keyspace_resize", + at: entry.completed_at ?? entry.created_at, + summary: `Keyspace resize (${keyspace}) to ${entry.cluster_rate_display_name} by ${entry.actor.display_name} (${entry.state})`, + }; +} + +// ── Shard resizes ───────────────────────────────────────────────────── + +interface ShardResizeEntry { + id: string; + state: string; + key_range: 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 { + return { + type: "shard_resize", + at: entry.completed_at ?? entry.created_at, + summary: `Shard resize (${keyspace} ${entry.key_range}) ${entry.previous_cluster_display_name} → ${entry.cluster_display_name} by ${entry.actor.display_name} (${entry.state})`, + }; +} + +// ── Keyspace discovery ──────────────────────────────────────────────── + +interface BranchKeyspace { + name: string; + sharded: boolean; +} + // ── Fetch helpers ───────────────────────────────────────────────────── interface PaginatedList { @@ -117,12 +164,34 @@ function buildDeployRequestsUrl( return `${API_BASE}/organizations/${e(org)}/databases/${e(db)}/deploy-requests?${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, +): string { + const params = new URLSearchParams(); + params.set("per_page", "25"); + 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, +): string { + const params = new URLSearchParams(); + params.set("per_page", "25"); + return `${API_BASE}/organizations/${e(org)}/databases/${e(db)}/branches/${e(branch)}/keyspaces/${e(keyspace)}/shard-resizes?${params}`; +} + // ── Tool definition ─────────────────────────────────────────────────── export const getEventsGram = new Gram().tool({ name: "get_events", description: - "Get a unified chronological timeline of PlanetScale events for a database branch within a time range. Combines VTGate resizes and deploy requests (schema migrations) into a single sorted event stream. Useful for incident investigation — call with the incident time window to see everything that changed.", + "Get a unified chronological timeline of PlanetScale events for a database branch within a time range. Combines VTGate resizes, keyspace/VTTablet resizes, individual shard resizes, and deploy requests (schema migrations) into a single sorted event stream. 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"), @@ -159,9 +228,11 @@ export const getEventsGram = new Gram().tool({ const authHeader = getAuthHeader(env); const range = buildRange(from, to); + const fromTime = new Date(from).getTime(); + const toTime = new Date(to).getTime(); - // Fetch all event sources in parallel - const [branchResizes, deployRequests] = + // Phase 1: Fetch event sources + keyspace list in parallel + const [branchResizes, deployRequests, keyspaceList] = await Promise.allSettled([ apiFetch>( buildBranchResizesUrl(organization, database, branch, range), @@ -173,8 +244,46 @@ export const getEventsGram = new Gram().tool({ authHeader, "deploy requests", ), + 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), + authHeader, + `keyspace resizes (${ks})`, + ).then((list) => ({ keyspace: ks, list })) + ), + ), + Promise.allSettled( + shardedKeyspaces.map((ks) => + apiFetch>( + buildShardResizesUrl(organization, database, branch, ks), + authHeader, + `shard resizes (${ks})`, + ).then((list) => ({ keyspace: ks, list })) + ), + ), + ]); + const events: TimelineEvent[] = []; const errors: string[] = []; const truncated: string[] = []; @@ -203,6 +312,42 @@ export const getEventsGram = new Gram().tool({ errors.push(`deploy requests: ${formatError(deployRequests.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; + for (const entry of list.data) { + const at = new Date(entry.completed_at ?? entry.created_at).getTime(); + if (at >= fromTime && at <= toTime) { + events.push(keyspaceResizeToEvent(keyspace, entry)); + } + } + } else { + errors.push(`keyspace resizes: ${formatError(r.reason)}`); + } + } + + 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) { + const at = new Date(entry.completed_at ?? entry.created_at).getTime(); + if (at >= fromTime && at <= toTime) { + events.push(shardResizeToEvent(keyspace, entry)); + } + } + if (list.next_page != null) { + truncated.push(`shard_resizes(${r.value.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()); diff --git a/src/tools/list-resizes.ts b/src/tools/list-resizes.ts index 824db2d..771feec 100644 --- a/src/tools/list-resizes.ts +++ b/src/tools/list-resizes.ts @@ -123,14 +123,209 @@ async function fetchBranchResizes( 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_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 }; + } + + 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, + }; + } + + 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 VTGate (connection proxy) resize operations for a PlanetScale database branch. Each resize entry shows what changed (size, count, autoscaling settings) with before/after values, the current state, and who initiated it.", + "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() @@ -166,25 +361,174 @@ export const listResizesGram = new Gram().tool({ 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), + ]); - const list = await fetchBranchResizes( - organization, database, branch, authHeader, page, perPage, completedAtRange, - ); + // 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 })) + ) + : [], + ), + ]); - return ctx.json({ + const result: Record = { organization, database, branch, - total: list.data.length, - page: list.current_page, - next_page: list.next_page, - resizes: list.data.map(summarizeBranchResize), - }); + }; + + 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})`); From c1e9694dba3d142ad310e4f3d6c1c628a1d6e6f3 Mon Sep 17 00:00:00 2001 From: Miles McGuire Date: Wed, 25 Mar 2026 17:13:42 +0100 Subject: [PATCH 3/5] feat: add VReplication workflow support via public API Add workflow (MoveTables/Reshard) support to list_deploy_requests and get_events using the public v1 /workflows endpoint with between=start..end server-side time filtering. list_deploy_requests gains an include_workflows flag that returns full workflow details with milestone timestamps. get_events decomposes each workflow into individual milestone events (started, data copy completed, data verified, replicas switched, primaries switched, cutover, completed/cancelled/reversed) so they appear as separate entries in the chronological timeline. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tools/get-events.ts | 81 ++++++++++++++- src/tools/list-deploy-requests.ts | 165 ++++++++++++++++++++++++++---- 2 files changed, 225 insertions(+), 21 deletions(-) diff --git a/src/tools/get-events.ts b/src/tools/get-events.ts index b9afe3a..5ebb117 100644 --- a/src/tools/get-events.ts +++ b/src/tools/get-events.ts @@ -104,6 +104,57 @@ function shardResizeToEvent(keyspace: string, entry: ShardResizeEntry): Timeline }; } +// ── 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 { @@ -164,6 +215,15 @@ function buildDeployRequestsUrl( 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 { @@ -191,7 +251,7 @@ function buildShardResizesUrl( export const getEventsGram = new Gram().tool({ name: "get_events", description: - "Get a unified chronological timeline of PlanetScale events for a database branch within a time range. Combines VTGate resizes, keyspace/VTTablet resizes, individual shard resizes, and deploy requests (schema migrations) into a single sorted event stream. Automatically discovers keyspaces and fetches per-keyspace resize history. Useful for incident investigation — call with the incident time window to see everything that changed.", + "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"), @@ -232,7 +292,7 @@ export const getEventsGram = new Gram().tool({ const toTime = new Date(to).getTime(); // Phase 1: Fetch event sources + keyspace list in parallel - const [branchResizes, deployRequests, keyspaceList] = + const [branchResizes, deployRequests, workflows, keyspaceList] = await Promise.allSettled([ apiFetch>( buildBranchResizesUrl(organization, database, branch, range), @@ -244,6 +304,11 @@ export const getEventsGram = new Gram().tool({ authHeader, "deploy requests", ), + apiFetch>( + buildWorkflowsUrl(organization, database, range), + authHeader, + "workflows", + ), apiFetch>( buildKeyspacesUrl(organization, database, branch), authHeader, @@ -312,6 +377,18 @@ export const getEventsGram = new Gram().tool({ 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)}`); } diff --git a/src/tools/list-deploy-requests.ts b/src/tools/list-deploy-requests.ts index f9d552a..e7116dd 100644 --- a/src/tools/list-deploy-requests.ts +++ b/src/tools/list-deploy-requests.ts @@ -163,10 +163,91 @@ async function fetchDeployRequests( 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) for a PlanetScale database. Deploy requests show DDL operations (ALTER TABLE, CREATE INDEX, etc.) and their progress.", + "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"), @@ -182,7 +263,7 @@ export const listDeployRequestsGram = new Gram().tool({ .string() .optional() .describe( - "Start of time range (ISO 8601, e.g., '2026-03-25T00:00:00.000Z'). Filters deploy requests by deployed_at. Must be paired with 'to'.", + "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() @@ -190,6 +271,12 @@ export const listDeployRequestsGram = new Gram().tool({ .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() @@ -216,24 +303,64 @@ export const listDeployRequestsGram = new Gram().tool({ 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", + }; + } + } - const list = await fetchDeployRequests(organization, database, authHeader, { - intoBranch: input.into_branch, - state: input.state, - deployedAtFrom: input.from, - deployedAtTo: input.to, - page, - perPage, - }); - - return ctx.json({ - organization, - database, - total: list.data.length, - page: list.current_page, - next_page: list.next_page, - requests: list.data.map(summarizeDeployRequest), - }); + return ctx.json(result); } catch (error) { if (error instanceof PlanetScaleAPIError) { return ctx.text(`Error: ${error.message} (status: ${error.statusCode})`); From 69a390d1ce11bae1dd9912d8c3afbe1895161c83 Mon Sep 17 00:00:00 2001 From: Miles McGuire Date: Wed, 25 Mar 2026 17:29:56 +0100 Subject: [PATCH 4/5] fix: accurate truncation reporting and pagination for shard resizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shard-resizes API doesn't support server-side time filtering, so we were fetching only the first 25 results and reporting false truncation warnings when `next_page` was set — even if all in-range events were already included. - Add paginating fetcher for shard resizes that walks backwards through pages until the oldest entry predates the requested time range - Wire `completed_at` range filter into keyspace resizes (confirmed the API supports it, unlike shard resizes) - Cap pagination at 10 pages (250 entries) as a safety valve Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tools/get-events.ts | 83 ++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 17 deletions(-) diff --git a/src/tools/get-events.ts b/src/tools/get-events.ts index 5ebb117..eb9b48c 100644 --- a/src/tools/get-events.ts +++ b/src/tools/get-events.ts @@ -231,21 +231,71 @@ function buildKeyspacesUrl( } function buildKeyspaceResizesUrl( - org: string, db: string, branch: string, keyspace: string, + 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, + 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({ @@ -332,7 +382,7 @@ export const getEventsGram = new Gram().tool({ Promise.allSettled( allKeyspaces.map((ks) => apiFetch>( - buildKeyspaceResizesUrl(organization, database, branch, ks), + buildKeyspaceResizesUrl(organization, database, branch, ks, range), authHeader, `keyspace resizes (${ks})`, ).then((list) => ({ keyspace: ks, list })) @@ -340,11 +390,8 @@ export const getEventsGram = new Gram().tool({ ), Promise.allSettled( shardedKeyspaces.map((ks) => - apiFetch>( - buildShardResizesUrl(organization, database, branch, ks), - authHeader, - `shard resizes (${ks})`, - ).then((list) => ({ keyspace: ks, list })) + fetchAllShardResizes(organization, database, branch, ks, authHeader, fromTime) + .then((result) => ({ keyspace: ks, ...result })) ), ), ]); @@ -396,11 +443,12 @@ export const getEventsGram = new Gram().tool({ 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) { - const at = new Date(entry.completed_at ?? entry.created_at).getTime(); - if (at >= fromTime && at <= toTime) { - events.push(keyspaceResizeToEvent(keyspace, entry)); - } + events.push(keyspaceResizeToEvent(keyspace, entry)); + } + if (list.next_page != null) { + truncated.push(`keyspace_resizes(${keyspace})`); } } else { errors.push(`keyspace resizes: ${formatError(r.reason)}`); @@ -409,16 +457,17 @@ export const getEventsGram = new Gram().tool({ 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) { + 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 (list.next_page != null) { - truncated.push(`shard_resizes(${r.value.keyspace})`); + if (isTruncated) { + truncated.push(`shard_resizes(${keyspace})`); } } else { errors.push(`shard resizes: ${formatError(r.reason)}`); From 97826950b3ea74119814a574599cb356722a2236 Mon Sep 17 00:00:00 2001 From: Miles McGuire Date: Wed, 25 Mar 2026 17:39:29 +0100 Subject: [PATCH 5/5] fix: detect hardware generation changes in resize summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cluster_display_name (e.g. M-640) hides hardware generation reprovisioning — when a shard moves from i4i to i7ie hardware at the same tier, the display name stays the same but the cluster_name SKU changes (e.g. M4_640_D_METAL_1866 → M_640_D_METAL_4992). Both list_resizes and get_events now compare cluster_name in addition to cluster_display_name, surfacing SKU changes that would otherwise be invisible. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tools/get-events.ts | 23 +++++++++++++++++++++-- src/tools/list-resizes.ts | 14 ++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/tools/get-events.ts b/src/tools/get-events.ts index eb9b48c..6112b72 100644 --- a/src/tools/get-events.ts +++ b/src/tools/get-events.ts @@ -71,15 +71,24 @@ interface KeyspaceResizeEntry { 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}) to ${entry.cluster_rate_display_name} by ${entry.actor.display_name} (${entry.state})`, + summary: `Keyspace resize (${keyspace}) ${desc} by ${entry.actor.display_name} (${entry.state})`, }; } @@ -89,6 +98,8 @@ 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; @@ -97,10 +108,18 @@ interface ShardResizeEntry { } 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}) ${entry.previous_cluster_display_name} → ${entry.cluster_display_name} by ${entry.actor.display_name} (${entry.state})`, + summary: `Shard resize (${keyspace} ${entry.key_range}) ${desc} by ${entry.actor.display_name} (${entry.state})`, }; } diff --git a/src/tools/list-resizes.ts b/src/tools/list-resizes.ts index 771feec..5ede755 100644 --- a/src/tools/list-resizes.ts +++ b/src/tools/list-resizes.ts @@ -131,6 +131,8 @@ interface KeyspaceResizeEntry { 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; @@ -156,6 +158,12 @@ function summarizeKeyspaceResize(keyspace: string, entry: KeyspaceResizeEntry) { 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, @@ -196,6 +204,12 @@ function summarizeShardResize(keyspace: string, entry: ShardResizeEntry) { 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,