diff --git a/package.json b/package.json index 539d273..9db6d16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tinybirdco/sdk", - "version": "0.0.54", + "version": "0.0.55", "description": "TypeScript SDK for Tinybird Forward - define datasources and pipes as TypeScript", "type": "module", "main": "./dist/index.js", diff --git a/src/api/deploy.test.ts b/src/api/deploy.test.ts index 6c1c700..2824791 100644 --- a/src/api/deploy.test.ts +++ b/src/api/deploy.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterEach, afterAll, vi } from "vitest"; +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; import { deployToMain } from "./deploy.js"; @@ -6,14 +6,25 @@ import type { BuildConfig } from "./build.js"; import { BASE_URL, createDeploySuccessResponse, + createDeploymentStatusResponse, + createSetLiveSuccessResponse, createBuildFailureResponse, createBuildMultipleErrorsResponse, + createDeploymentsListResponse, } from "../test/handlers.js"; import type { GeneratedResources } from "../generator/index.js"; const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +beforeEach(() => { + // Set up default handler for deployments list (used by stale deployment cleanup) + server.use( + http.get(`${BASE_URL}/v1/deployments`, () => { + return HttpResponse.json(createDeploymentsListResponse()); + }) + ); +}); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); @@ -24,12 +35,17 @@ describe("Deploy API", () => { }; const resources: GeneratedResources = { - datasources: [{ name: "events", content: "SCHEMA > timestamp DateTime" }], - pipes: [{ name: "top_events", content: "NODE main\nSQL > SELECT * FROM events" }], + datasources: [ + { name: "events", content: "SCHEMA > timestamp DateTime" }, + ], + pipes: [ + { name: "top_events", content: "NODE main\nSQL > SELECT * FROM events" }, + ], connections: [], }; - function setupAutoPromoteSuccessFlow(deploymentId = "deploy-abc") { + // Helper to set up successful deploy flow + function setupSuccessfulDeployFlow(deploymentId = "deploy-abc") { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return HttpResponse.json( @@ -37,42 +53,64 @@ describe("Deploy API", () => { ); }), http.get(`${BASE_URL}/v1/deployments/${deploymentId}`, () => { - return HttpResponse.json({ - result: "success", - deployment: { - id: deploymentId, - status: "data_ready", - live: true, - }, - }); + return HttpResponse.json( + createDeploymentStatusResponse({ deploymentId, status: "data_ready" }) + ); + }), + http.post(`${BASE_URL}/v1/deployments/${deploymentId}/set-live`, () => { + return HttpResponse.json(createSetLiveSuccessResponse()); }) ); } describe("deployToMain", () => { - it("successfully deploys resources with auto-promote flow", async () => { - setupAutoPromoteSuccessFlow("deploy-abc"); + it("successfully deploys resources with full flow", async () => { + setupSuccessfulDeployFlow("deploy-abc"); - const onDeploymentLive = vi.fn(); - const result = await deployToMain(config, resources, { - pollIntervalMs: 1, - callbacks: { onDeploymentLive }, - }); + const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); expect(result.success).toBe(true); expect(result.result).toBe("success"); expect(result.buildId).toBe("deploy-abc"); expect(result.datasourceCount).toBe(1); expect(result.pipeCount).toBe(1); - expect(onDeploymentLive).toHaveBeenCalledWith("deploy-abc"); + }); + + it("polls until deployment is ready", async () => { + let pollCount = 0; + + server.use( + http.post(`${BASE_URL}/v1/deploy`, () => { + return HttpResponse.json( + createDeploySuccessResponse({ deploymentId: "deploy-poll", status: "pending" }) + ); + }), + http.get(`${BASE_URL}/v1/deployments/deploy-poll`, () => { + pollCount++; + // Return pending for first 2 polls, then data_ready + const status = pollCount < 3 ? "pending" : "data_ready"; + return HttpResponse.json( + createDeploymentStatusResponse({ deploymentId: "deploy-poll", status }) + ); + }), + http.post(`${BASE_URL}/v1/deployments/deploy-poll/set-live`, () => { + return HttpResponse.json(createSetLiveSuccessResponse()); + }) + ); + + const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); + + expect(result.success).toBe(true); + expect(pollCount).toBe(3); }); it("handles deploy failure with single error", async () => { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { - return HttpResponse.json(createBuildFailureResponse("Permission denied"), { - status: 200, - }); + return HttpResponse.json( + createBuildFailureResponse("Permission denied"), + { status: 200 } + ); }) ); @@ -130,7 +168,10 @@ describe("Deploy API", () => { it("handles HTTP error responses", async () => { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { - return HttpResponse.json({ result: "failed", error: "Forbidden" }, { status: 403 }); + return HttpResponse.json( + { result: "failed", error: "Forbidden" }, + { status: 403 } + ); }) ); @@ -150,24 +191,28 @@ describe("Deploy API", () => { }) ); - await expect(deployToMain(config, resources)).rejects.toThrow("Failed to parse response"); + await expect(deployToMain(config, resources)).rejects.toThrow( + "Failed to parse response" + ); }); - it("uses /v1/deploy endpoint and sends auto_promote by default", async () => { + it("uses /v1/deploy endpoint (not /v1/build)", async () => { let capturedUrl: string | null = null; server.use( http.post(`${BASE_URL}/v1/deploy`, ({ request }) => { capturedUrl = request.url; return HttpResponse.json( - createDeploySuccessResponse({ deploymentId: "deploy-url-test", status: "pending" }) + createDeploySuccessResponse({ deploymentId: "deploy-url-test" }) ); }), http.get(`${BASE_URL}/v1/deployments/deploy-url-test`, () => { - return HttpResponse.json({ - result: "success", - deployment: { id: "deploy-url-test", status: "data_ready", live: true }, - }); + return HttpResponse.json( + createDeploymentStatusResponse({ deploymentId: "deploy-url-test", status: "data_ready" }) + ); + }), + http.post(`${BASE_URL}/v1/deployments/deploy-url-test/set-live`, () => { + return HttpResponse.json(createSetLiveSuccessResponse()); }) ); @@ -176,7 +221,6 @@ describe("Deploy API", () => { const parsed = new URL(capturedUrl ?? ""); expect(parsed.pathname).toBe("/v1/deploy"); expect(parsed.searchParams.get("from")).toBe("ts-sdk"); - expect(parsed.searchParams.get("auto_promote")).toBe("true"); }); it("passes allow_destructive_operations when explicitly enabled", async () => { @@ -186,14 +230,19 @@ describe("Deploy API", () => { http.post(`${BASE_URL}/v1/deploy`, ({ request }) => { capturedUrl = request.url; return HttpResponse.json( - createDeploySuccessResponse({ deploymentId: "deploy-destructive", status: "pending" }) + createDeploySuccessResponse({ deploymentId: "deploy-destructive" }) ); }), http.get(`${BASE_URL}/v1/deployments/deploy-destructive`, () => { - return HttpResponse.json({ - result: "success", - deployment: { id: "deploy-destructive", status: "data_ready", live: true }, - }); + return HttpResponse.json( + createDeploymentStatusResponse({ + deploymentId: "deploy-destructive", + status: "data_ready", + }) + ); + }), + http.post(`${BASE_URL}/v1/deployments/deploy-destructive/set-live`, () => { + return HttpResponse.json(createSetLiveSuccessResponse()); }) ); @@ -204,25 +253,6 @@ describe("Deploy API", () => { const parsed = new URL(capturedUrl ?? ""); expect(parsed.searchParams.get("allow_destructive_operations")).toBe("true"); - expect(parsed.searchParams.get("auto_promote")).toBe("true"); - }); - - it("does not send auto_promote in check mode", async () => { - let capturedUrl: string | null = null; - - server.use( - http.post(`${BASE_URL}/v1/deploy`, ({ request }) => { - capturedUrl = request.url; - return HttpResponse.json({ result: "success" }); - }) - ); - - const result = await deployToMain(config, resources, { check: true }); - - expect(result.success).toBe(true); - const parsed = new URL(capturedUrl ?? ""); - expect(parsed.searchParams.get("check")).toBe("true"); - expect(parsed.searchParams.get("auto_promote")).toBeNull(); }); it("adds actionable guidance to Forward/Classic workspace errors", async () => { @@ -248,6 +278,49 @@ describe("Deploy API", () => { ); }); + it("handles failed deployment status", async () => { + server.use( + http.post(`${BASE_URL}/v1/deploy`, () => { + return HttpResponse.json( + createDeploySuccessResponse({ deploymentId: "deploy-fail", status: "pending" }) + ); + }), + http.get(`${BASE_URL}/v1/deployments/deploy-fail`, () => { + return HttpResponse.json( + createDeploymentStatusResponse({ deploymentId: "deploy-fail", status: "failed" }) + ); + }) + ); + + const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Deployment failed with status: failed"); + }); + + it("handles set-live failure", async () => { + server.use( + http.post(`${BASE_URL}/v1/deploy`, () => { + return HttpResponse.json( + createDeploySuccessResponse({ deploymentId: "deploy-setlive-fail" }) + ); + }), + http.get(`${BASE_URL}/v1/deployments/deploy-setlive-fail`, () => { + return HttpResponse.json( + createDeploymentStatusResponse({ deploymentId: "deploy-setlive-fail", status: "data_ready" }) + ); + }), + http.post(`${BASE_URL}/v1/deployments/deploy-setlive-fail/set-live`, () => { + return HttpResponse.json({ error: "Set live failed" }, { status: 500 }); + }) + ); + + const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Failed to set deployment as live"); + }); + it("normalizes baseUrl with trailing slash", async () => { let capturedUrl: string | null = null; @@ -255,14 +328,16 @@ describe("Deploy API", () => { http.post(`${BASE_URL}/v1/deploy`, ({ request }) => { capturedUrl = request.url; return HttpResponse.json( - createDeploySuccessResponse({ deploymentId: "deploy-slash", status: "pending" }) + createDeploySuccessResponse({ deploymentId: "deploy-slash" }) ); }), http.get(`${BASE_URL}/v1/deployments/deploy-slash`, () => { - return HttpResponse.json({ - result: "success", - deployment: { id: "deploy-slash", status: "data_ready", live: true }, - }); + return HttpResponse.json( + createDeploymentStatusResponse({ deploymentId: "deploy-slash", status: "data_ready" }) + ); + }), + http.post(`${BASE_URL}/v1/deployments/deploy-slash/set-live`, () => { + return HttpResponse.json(createSetLiveSuccessResponse()); }) ); @@ -275,7 +350,29 @@ describe("Deploy API", () => { const parsed = new URL(capturedUrl ?? ""); expect(parsed.pathname).toBe("/v1/deploy"); expect(parsed.searchParams.get("from")).toBe("ts-sdk"); - expect(parsed.searchParams.get("auto_promote")).toBe("true"); + }); + + it("times out when deployment never becomes ready", async () => { + server.use( + http.post(`${BASE_URL}/v1/deploy`, () => { + return HttpResponse.json( + createDeploySuccessResponse({ deploymentId: "deploy-timeout", status: "pending" }) + ); + }), + http.get(`${BASE_URL}/v1/deployments/deploy-timeout`, () => { + return HttpResponse.json( + createDeploymentStatusResponse({ deploymentId: "deploy-timeout", status: "pending" }) + ); + }) + ); + + const result = await deployToMain(config, resources, { + pollIntervalMs: 1, + maxPollAttempts: 3, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Deployment timed out"); }); }); }); diff --git a/src/api/deploy.ts b/src/api/deploy.ts index f92d34e..6020f33 100644 --- a/src/api/deploy.ts +++ b/src/api/deploy.ts @@ -1,6 +1,6 @@ /** * Deploy resources to Tinybird main workspace - * Uses the /v1/deploy endpoint with auto-promotion enabled + * Uses the /v1/deploy endpoint to create a deployment, then sets it live */ import type { GeneratedResources } from "../generator/index.js"; @@ -31,6 +31,13 @@ export interface Deployment { feedback?: DeploymentFeedback[]; } +/** + * Response from /v1/deployments list endpoint + */ +export interface DeploymentsListResponse { + deployments: Deployment[]; +} + /** * Response from /v1/deploy endpoint */ @@ -41,14 +48,6 @@ export interface DeployResponse { errors?: Array<{ filename?: string; error: string }>; } -/** - * Response from /v1/deployments/{id} endpoint - */ -export interface DeploymentStatusResponse { - result: string; - deployment: Deployment; -} - /** * Detailed deployment information with resource changes */ @@ -75,11 +74,21 @@ export interface DeploymentDetails extends Deployment { errors?: Array<{ filename?: string; error: string }>; } +/** + * Response from /v1/deployments/{id} endpoint + */ +export interface DeploymentStatusResponse { + result: string; + deployment: Deployment; +} + /** * Deploy generated resources to Tinybird main workspace * * Uses the /v1/deploy endpoint which accepts all resources in a single - * multipart form request. + * multipart form request. After creating the deployment, this function: + * 1. Polls until the deployment is ready (status === 'data_ready') + * 2. Sets the deployment as live via /v1/deployments/{id}/set-live * * @param config - Build configuration with API URL and token * @param resources - Generated resources to deploy @@ -152,14 +161,13 @@ export async function deployToMain( pollIntervalMs?: number; maxPollAttempts?: number; check?: boolean; - autoPromote?: boolean; allowDestructiveOperations?: boolean; callbacks?: DeployCallbacks; } ): Promise { const debug = options?.debug ?? !!process.env.TINYBIRD_DEBUG; const pollIntervalMs = options?.pollIntervalMs ?? 1000; - const maxPollAttempts = options?.maxPollAttempts ?? 120; + const maxPollAttempts = options?.maxPollAttempts ?? 120; // 2 minutes max const baseUrl = config.baseUrl.replace(/\/$/, ""); const formData = new FormData(); @@ -194,17 +202,46 @@ export async function deployToMain( ); } - // Create deployment via /v1/deploy. - // `auto_promote=true` makes the API promote the deployment automatically. + // Step 0: Clean up any stale non-live deployments that might block the new deployment + try { + const deploymentsUrl = `${baseUrl}/v1/deployments`; + const deploymentsResponse = await tinybirdFetch(deploymentsUrl, { + headers: { + Authorization: `Bearer ${config.token}`, + }, + }); + + if (deploymentsResponse.ok) { + const deploymentsBody = (await deploymentsResponse.json()) as DeploymentsListResponse; + const staleDeployments = deploymentsBody.deployments.filter( + (d) => !d.live && d.status !== "live" + ); + + for (const stale of staleDeployments) { + if (debug) { + console.log(`[debug] Cleaning up stale deployment: ${stale.id} (status: ${stale.status})`); + } + await tinybirdFetch(`${baseUrl}/v1/deployments/${stale.id}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${config.token}`, + }, + }); + } + } + } catch (e) { + // Ignore errors during cleanup - we'll try to deploy anyway + if (debug) { + console.log(`[debug] Failed to clean up stale deployments: ${e}`); + } + } + + // Step 1: Create deployment via /v1/deploy const deployUrlBase = `${baseUrl}/v1/deploy`; const urlParams = new URLSearchParams(); if (options?.check) { urlParams.set("check", "true"); } - const autoPromote = options?.autoPromote ?? !options?.check; - if (autoPromote) { - urlParams.set("auto_promote", "true"); - } if (options?.allowDestructiveOperations) { urlParams.set("allow_destructive_operations", "true"); } @@ -371,22 +408,18 @@ export async function deployToMain( }); } - let deployment = deploymentDetails; - let statusAttempts = 0; + // Step 2: Poll until deployment is ready + let deployment = body.deployment; + let attempts = 0; options?.callbacks?.onWaitingForReady?.(); - while ( - deployment.status !== "data_ready" && - deployment.status !== "failed" && - deployment.status !== "error" && - statusAttempts < maxPollAttempts - ) { + while (deployment.status !== "data_ready" && attempts < maxPollAttempts) { await sleep(pollIntervalMs); - statusAttempts++; + attempts++; if (debug) { - console.log(`[debug] Polling deployment status (attempt ${statusAttempts})...`); + console.log(`[debug] Polling deployment status (attempt ${attempts})...`); } const statusUrl = `${baseUrl}/v1/deployments/${deploymentId}`; @@ -410,18 +443,23 @@ export async function deployToMain( const statusBody = (await statusResponse.json()) as DeploymentStatusResponse; deployment = statusBody.deployment; - } - if (deployment.status === "failed" || deployment.status === "error") { - return { - success: false, - result: "failed", - error: `Deployment failed with status: ${deployment.status}`, - datasourceCount: resources.datasources.length, - pipeCount: resources.pipes.length, - connectionCount: resources.connections?.length ?? 0, - buildId: deploymentId, - }; + if (debug) { + console.log(`[debug] Deployment status: ${deployment.status}`); + } + + // Check for failed status + if (deployment.status === "failed" || deployment.status === "error") { + return { + success: false, + result: "failed", + error: `Deployment failed with status: ${deployment.status}`, + datasourceCount: resources.datasources.length, + pipeCount: resources.pipes.length, + connectionCount: resources.connections?.length ?? 0, + buildId: deploymentId, + }; + } } if (deployment.status !== "data_ready") { @@ -438,64 +476,38 @@ export async function deployToMain( options?.callbacks?.onDeploymentReady?.(); - if (autoPromote && !deployment.live) { - let promotionAttempts = 0; - options?.callbacks?.onWaitingForPromote?.(); - - while (!deployment.live && promotionAttempts < maxPollAttempts) { - await sleep(pollIntervalMs); - promotionAttempts++; + // Step 3: Set the deployment as live + const setLiveUrl = `${baseUrl}/v1/deployments/${deploymentId}/set-live`; - if (debug) { - console.log(`[debug] Polling auto-promote status (attempt ${promotionAttempts})...`); - } - - const statusUrl = `${baseUrl}/v1/deployments/${deploymentId}`; - const statusResponse = await tinybirdFetch(statusUrl, { - headers: { - Authorization: `Bearer ${config.token}`, - }, - }); - - if (!statusResponse.ok) { - return { - success: false, - result: "failed", - error: `Failed to check deployment status: ${statusResponse.status} ${statusResponse.statusText}`, - datasourceCount: resources.datasources.length, - pipeCount: resources.pipes.length, - connectionCount: resources.connections?.length ?? 0, - buildId: deploymentId, - }; - } - - const statusBody = (await statusResponse.json()) as DeploymentStatusResponse; - deployment = statusBody.deployment; - } + if (debug) { + console.log(`[debug] POST ${setLiveUrl}`); + } - if (!deployment.live) { - return { - success: false, - result: "failed", - error: `Deployment reached data_ready but auto-promote did not complete after ${maxPollAttempts} attempts`, - datasourceCount: resources.datasources.length, - pipeCount: resources.pipes.length, - connectionCount: resources.connections?.length ?? 0, - buildId: deploymentId, - }; - } + const setLiveResponse = await tinybirdFetch(setLiveUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${config.token}`, + }, + }); - options?.callbacks?.onDeploymentPromoted?.(); + if (!setLiveResponse.ok) { + const setLiveBody = await setLiveResponse.text(); + return { + success: false, + result: "failed", + error: `Failed to set deployment as live: ${setLiveResponse.status} ${setLiveResponse.statusText}\n${setLiveBody}`, + datasourceCount: resources.datasources.length, + pipeCount: resources.pipes.length, + connectionCount: resources.connections?.length ?? 0, + buildId: deploymentId, + }; } if (debug) { - const stateLabel = deployment.live ? "live" : "ready"; - console.log(`[debug] Deployment ${deploymentId} is now ${stateLabel}`); + console.log(`[debug] Deployment ${deploymentId} is now live`); } - if (deployment.live) { - options?.callbacks?.onDeploymentLive?.(deploymentId); - } + options?.callbacks?.onDeploymentLive?.(deploymentId); return { success: true, @@ -517,6 +529,9 @@ export async function deployToMain( }; } +/** + * Helper function to sleep for a given number of milliseconds + */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }