From c0b16671f31c6914733bd00f01e61bd3a534fc03 Mon Sep 17 00:00:00 2001 From: jordiori Date: Thu, 2 Jul 2026 12:53:03 +0200 Subject: [PATCH 1/3] autopromote deployments using API --- src/api/deploy.test.ts | 293 +++++++++++++++++++++++++------------ src/api/deploy.ts | 253 ++++++++++++++++---------------- src/cli/commands/deploy.ts | 10 ++ src/cli/index.ts | 18 +++ src/cli/output.ts | 27 ++++ src/test/handlers.ts | 16 +- 6 files changed, 387 insertions(+), 230 deletions(-) diff --git a/src/api/deploy.test.ts b/src/api/deploy.test.ts index eaf60d9..fbdd324 100644 --- a/src/api/deploy.test.ts +++ b/src/api/deploy.test.ts @@ -1,13 +1,27 @@ -import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from "vitest"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; + +// Mock the timing module so tests never wait real wall-clock time. The 5s +// poll cadence and 60-poll safety valve are semantic (matched to the CLI), +// but tests only care about the sequence of calls — not the delay between +// them — so we swap sleep for a no-op and shrink the safety threshold to +// something we can drive in a single test. +const { MOCK_MAX_CONSECUTIVE_FAILED_POLLS } = vi.hoisted(() => ({ + MOCK_MAX_CONSECUTIVE_FAILED_POLLS: 3, +})); +vi.mock("./_deploy_timing.js", () => ({ + POLL_INTERVAL_MS: 0, + MAX_CONSECUTIVE_FAILED_POLLS: MOCK_MAX_CONSECUTIVE_FAILED_POLLS, + sleep: () => Promise.resolve(), +})); + import { deployToMain } from "./deploy.js"; import type { BuildConfig } from "./build.js"; import { BASE_URL, createDeploySuccessResponse, createDeploymentStatusResponse, - createSetLiveSuccessResponse, createBuildFailureResponse, createBuildMultipleErrorsResponse, createDeploymentsListResponse, @@ -44,7 +58,9 @@ describe("Deploy API", () => { connections: [], }; - // Helper to set up successful deploy flow + // Helper to set up successful deploy flow. By default the deployment shows + // up as live on the very first status poll, which matches the server-side + // auto_promote=true behavior the SDK now relies on. function setupSuccessfulDeployFlow(deploymentId = "deploy-abc") { server.use( http.post(`${BASE_URL}/v1/deploy`, () => { @@ -54,11 +70,12 @@ describe("Deploy API", () => { }), http.get(`${BASE_URL}/v1/deployments/${deploymentId}`, () => { return HttpResponse.json( - createDeploymentStatusResponse({ deploymentId, status: "data_ready" }) + createDeploymentStatusResponse({ + deploymentId, + status: "data_ready", + live: true, + }) ); - }), - http.post(`${BASE_URL}/v1/deployments/${deploymentId}/set-live`, () => { - return HttpResponse.json(createSetLiveSuccessResponse()); }) ); } @@ -67,7 +84,7 @@ describe("Deploy API", () => { it("successfully deploys resources with full flow", async () => { setupSuccessfulDeployFlow("deploy-abc"); - const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); + const result = await deployToMain(config, resources); expect(result.success).toBe(true); expect(result.result).toBe("success"); @@ -76,7 +93,7 @@ describe("Deploy API", () => { expect(result.pipeCount).toBe(1); }); - it("polls until deployment is ready", async () => { + it("polls until deployment is ready and live", async () => { let pollCount = 0; server.use( @@ -87,21 +104,39 @@ describe("Deploy API", () => { }), 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"; + // pending → data_ready (not live yet) → data_ready + live. + if (pollCount < 3) { + return HttpResponse.json( + createDeploymentStatusResponse({ + deploymentId: "deploy-poll", + status: "pending", + live: false, + }) + ); + } + if (pollCount === 3) { + return HttpResponse.json( + createDeploymentStatusResponse({ + deploymentId: "deploy-poll", + status: "data_ready", + live: false, + }) + ); + } return HttpResponse.json( - createDeploymentStatusResponse({ deploymentId: "deploy-poll", status }) + createDeploymentStatusResponse({ + deploymentId: "deploy-poll", + status: "data_ready", + live: true, + }) ); - }), - http.post(`${BASE_URL}/v1/deployments/deploy-poll/set-live`, () => { - return HttpResponse.json(createSetLiveSuccessResponse()); }) ); - const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); + const result = await deployToMain(config, resources); expect(result.success).toBe(true); - expect(pollCount).toBe(3); + expect(pollCount).toBe(4); }); it("handles deploy failure with single error", async () => { @@ -196,7 +231,7 @@ describe("Deploy API", () => { ); }); - it("uses /v1/deploy endpoint (not /v1/build)", async () => { + it("uses /v1/deploy endpoint with auto_promote by default", async () => { let capturedUrl: string | null = null; server.use( @@ -208,19 +243,81 @@ describe("Deploy API", () => { }), http.get(`${BASE_URL}/v1/deployments/deploy-url-test`, () => { return HttpResponse.json( - createDeploymentStatusResponse({ deploymentId: "deploy-url-test", status: "data_ready" }) + createDeploymentStatusResponse({ + deploymentId: "deploy-url-test", + status: "data_ready", + live: true, + }) ); - }), - http.post(`${BASE_URL}/v1/deployments/deploy-url-test/set-live`, () => { - return HttpResponse.json(createSetLiveSuccessResponse()); }) ); - await deployToMain(config, resources, { pollIntervalMs: 1 }); + await deployToMain(config, resources); 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("omits auto_promote when auto is false", async () => { + let capturedUrl: string | null = null; + + server.use( + http.post(`${BASE_URL}/v1/deploy`, ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json( + createDeploySuccessResponse({ deploymentId: "deploy-no-auto" }) + ); + }), + http.get(`${BASE_URL}/v1/deployments/deploy-no-auto`, () => { + return HttpResponse.json( + createDeploymentStatusResponse({ + deploymentId: "deploy-no-auto", + status: "data_ready", + live: false, + }) + ); + }) + ); + + const result = await deployToMain(config, resources, { auto: false }); + + const parsed = new URL(capturedUrl ?? ""); + expect(parsed.searchParams.get("auto_promote")).toBeNull(); + // When !auto, we return success as soon as the deployment is data_ready, + // even though `live` is still false (user must promote it separately). + expect(result.success).toBe(true); + expect(result.buildId).toBe("deploy-no-auto"); + }); + + it("returns immediately when wait is false", async () => { + let statusPolls = 0; + + server.use( + http.post(`${BASE_URL}/v1/deploy`, () => { + return HttpResponse.json( + createDeploySuccessResponse({ deploymentId: "deploy-no-wait" }) + ); + }), + http.get(`${BASE_URL}/v1/deployments/deploy-no-wait`, () => { + statusPolls++; + return HttpResponse.json( + createDeploymentStatusResponse({ + deploymentId: "deploy-no-wait", + status: "pending", + live: false, + }) + ); + }) + ); + + const result = await deployToMain(config, resources, { wait: false }); + + expect(result.success).toBe(true); + expect(result.buildId).toBe("deploy-no-wait"); + // No polling should have happened. + expect(statusPolls).toBe(0); }); it("passes allow_destructive_operations when explicitly enabled", async () => { @@ -238,16 +335,13 @@ describe("Deploy API", () => { createDeploymentStatusResponse({ deploymentId: "deploy-destructive", status: "data_ready", + live: true, }) ); - }), - http.post(`${BASE_URL}/v1/deployments/deploy-destructive/set-live`, () => { - return HttpResponse.json(createSetLiveSuccessResponse()); }) ); await deployToMain(config, resources, { - pollIntervalMs: 1, allowDestructiveOperations: true, }); @@ -283,10 +377,7 @@ describe("Deploy API", () => { }) ); - await deployToMain(config, resources, { - pollIntervalMs: 1, - check: true, - }); + await deployToMain(config, resources, { check: true }); expect(listed).toBe(false); expect(deletedIds).toEqual([]); @@ -316,12 +407,17 @@ describe("Deploy API", () => { ); setupSuccessfulDeployFlow("deploy-cleanup"); - await deployToMain(config, resources, { pollIntervalMs: 1 }); + await deployToMain(config, resources); - expect(deletedIds).toEqual(["stale-1", "stale-2", "live-1"]); + // The previous live deployment is left alone — the server removes it + // as part of the auto-promotion once the new deployment is live. + expect(deletedIds).toEqual(["stale-1", "stale-2"]); }); - it("deletes the previous live deployment after promoting the new deployment", async () => { + it("does not touch the previous live deployment client-side", async () => { + // With auto_promote=true the server flips the new deployment live AND + // deletes the previous live deployment on our behalf. The SDK should + // therefore never issue a set-live or a delete for a live deployment. const events: string[] = []; server.use( @@ -342,23 +438,23 @@ describe("Deploy API", () => { }), http.get(`${BASE_URL}/v1/deployments/new-deploy`, () => { return HttpResponse.json( - createDeploymentStatusResponse({ deploymentId: "new-deploy", status: "data_ready" }) + createDeploymentStatusResponse({ + deploymentId: "new-deploy", + status: "data_ready", + live: true, + }) ); }), - http.post(`${BASE_URL}/v1/deployments/new-deploy/set-live`, () => { - events.push("set-live"); - return HttpResponse.json(createSetLiveSuccessResponse()); - }), http.delete(`${BASE_URL}/v1/deployments/:id`, ({ params }) => { events.push(`delete:${params.id as string}`); return HttpResponse.json({ result: "success" }); }) ); - const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); + const result = await deployToMain(config, resources); expect(result.success).toBe(true); - expect(events).toEqual(["create", "set-live", "delete:previous-live"]); + expect(events).toEqual(["create"]); }); it("adds actionable guidance to Forward/Classic workspace errors", async () => { @@ -384,47 +480,77 @@ describe("Deploy API", () => { ); }); - it("handles failed deployment status", async () => { + it("tolerates transient failed status while server auto-deletes", async () => { + // When the deployment hits `failed`, the SDK should not bail immediately + // — the server usually transitions it to `deleting`/`deleted` shortly + // after. We report the failure only when that transition happens. + let pollCount = 0; server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return HttpResponse.json( - createDeploySuccessResponse({ deploymentId: "deploy-fail", status: "pending" }) + createDeploySuccessResponse({ deploymentId: "deploy-transient-fail" }) ); }), - http.get(`${BASE_URL}/v1/deployments/deploy-fail`, () => { - return HttpResponse.json( - createDeploymentStatusResponse({ deploymentId: "deploy-fail", status: "failed" }) - ); + http.get(`${BASE_URL}/v1/deployments/deploy-transient-fail`, () => { + pollCount++; + if (pollCount <= 2) { + return HttpResponse.json( + createDeploymentStatusResponse({ + deploymentId: "deploy-transient-fail", + status: "failed", + live: false, + }) + ); + } + return HttpResponse.json({ + result: "success", + deployment: { + id: "deploy-transient-fail", + status: "deleted", + live: false, + feedback: [ + { resource: null, level: "ERROR", message: "schema conflict" }, + ], + }, + }); }) ); - const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); + const result = await deployToMain(config, resources); expect(result.success).toBe(false); - expect(result.error).toContain("Deployment failed with status: failed"); + expect(result.error).toContain("deleted automatically"); + expect(result.error).toContain("schema conflict"); + // 2 failed + 1 deleted. + expect(pollCount).toBe(3); }); - it("handles set-live failure", async () => { + it("bails when the deployment is stuck in failed state", async () => { + let pollCount = 0; server.use( http.post(`${BASE_URL}/v1/deploy`, () => { return HttpResponse.json( - createDeploySuccessResponse({ deploymentId: "deploy-setlive-fail" }) + createDeploySuccessResponse({ deploymentId: "deploy-stuck" }) ); }), - http.get(`${BASE_URL}/v1/deployments/deploy-setlive-fail`, () => { + http.get(`${BASE_URL}/v1/deployments/deploy-stuck`, () => { + pollCount++; return HttpResponse.json( - createDeploymentStatusResponse({ deploymentId: "deploy-setlive-fail", status: "data_ready" }) + createDeploymentStatusResponse({ + deploymentId: "deploy-stuck", + status: "failed", + live: false, + }) ); - }), - 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 }); + const result = await deployToMain(config, resources); expect(result.success).toBe(false); - expect(result.error).toContain("Failed to set deployment as live"); + expect(result.error).toContain("didn't start deleting automatically"); + // One extra poll past the threshold trips the safety valve. + expect(pollCount).toBe(MOCK_MAX_CONSECUTIVE_FAILED_POLLS + 1); }); it("normalizes baseUrl with trailing slash", async () => { @@ -439,48 +565,22 @@ describe("Deploy API", () => { }), http.get(`${BASE_URL}/v1/deployments/deploy-slash`, () => { return HttpResponse.json( - createDeploymentStatusResponse({ deploymentId: "deploy-slash", status: "data_ready" }) + createDeploymentStatusResponse({ + deploymentId: "deploy-slash", + status: "data_ready", + live: true, + }) ); - }), - http.post(`${BASE_URL}/v1/deployments/deploy-slash/set-live`, () => { - return HttpResponse.json(createSetLiveSuccessResponse()); }) ); - await deployToMain( - { ...config, baseUrl: `${BASE_URL}/` }, - resources, - { pollIntervalMs: 1 } - ); + await deployToMain({ ...config, baseUrl: `${BASE_URL}/` }, resources); const parsed = new URL(capturedUrl ?? ""); expect(parsed.pathname).toBe("/v1/deploy"); expect(parsed.searchParams.get("from")).toBe("ts-sdk"); }); - 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"); - }); - it("includes connections in deploy form data", async () => { const resourcesWithConnections: GeneratedResources = { ...resources, @@ -503,17 +603,16 @@ describe("Deploy API", () => { }), http.get(`${BASE_URL}/v1/deployments/deploy-conn`, () => { return HttpResponse.json( - createDeploymentStatusResponse({ deploymentId: "deploy-conn", status: "data_ready" }) + createDeploymentStatusResponse({ + deploymentId: "deploy-conn", + status: "data_ready", + live: true, + }) ); - }), - http.post(`${BASE_URL}/v1/deployments/deploy-conn/set-live`, () => { - return HttpResponse.json(createSetLiveSuccessResponse()); }) ); - const result = await deployToMain(config, resourcesWithConnections, { - pollIntervalMs: 1, - }); + const result = await deployToMain(config, resourcesWithConnections); expect(result.success).toBe(true); expect(result.connectionCount).toBe(1); diff --git a/src/api/deploy.ts b/src/api/deploy.ts index 6cdcd06..74b53c2 100644 --- a/src/api/deploy.ts +++ b/src/api/deploy.ts @@ -6,6 +6,11 @@ import type { GeneratedResources } from "../generator/index.js"; import type { BuildConfig, BuildApiResult } from "./build.js"; import { tinybirdFetch } from "./fetcher.js"; +import { + MAX_CONSECUTIVE_FAILED_POLLS, + POLL_INTERVAL_MS, + sleep, +} from "./_deploy_timing.js"; const FORWARD_CLASSIC_GUIDANCE = "Use the Tinybird Classic CLI (`tb`) from a Tinybird Classic workspace for this operation."; @@ -87,8 +92,10 @@ export interface DeploymentStatusResponse { * * Uses the /v1/deploy endpoint which accepts all resources in a single * 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 + * 1. When `auto` is true (default), sends `auto_promote=true` so the + * server promotes the deployment as soon as it's ready. + * 2. When `wait` is true (default), polls until the deployment reaches + * `data_ready` (and, if `auto` is also true, until it becomes live). * * @param config - Build configuration with API URL and token * @param resources - Generated resources to deploy @@ -139,6 +146,8 @@ export interface DeploymentChanges { export interface DeployCallbacks { /** Called when deployment is created and changes are available */ onChanges?: (changes: DeploymentChanges) => void; + /** Called when deployment was submitted but wait was disabled */ + onDeploymentSubmitted?: (deploymentId: string) => void; /** Called when waiting for deployment to be ready */ onWaitingForReady?: () => void; /** Called when deployment is ready */ @@ -158,18 +167,17 @@ export async function deployToMain( resources: GeneratedResources, options?: { debug?: boolean; - pollIntervalMs?: number; - maxPollAttempts?: number; check?: boolean; allowDestructiveOperations?: boolean; + wait?: boolean; + auto?: boolean; callbacks?: DeployCallbacks; } ): Promise { const debug = options?.debug ?? !!process.env.TINYBIRD_DEBUG; - const pollIntervalMs = options?.pollIntervalMs ?? 1000; - const maxPollAttempts = options?.maxPollAttempts ?? 120; // 2 minutes max + const wait = options?.wait ?? true; + const auto = options?.auto ?? true; const baseUrl = config.baseUrl.replace(/\/$/, ""); - let previousLiveDeploymentId: string | undefined; const formData = new FormData(); @@ -231,10 +239,6 @@ export async function deployToMain( if (deploymentsResponse.ok) { const deploymentsBody = (await deploymentsResponse.json()) as DeploymentsListResponse; - const previousLiveDeployment = deploymentsBody.deployments.find( - (d) => d.live || d.status === "live" - ); - previousLiveDeploymentId = previousLiveDeployment?.id; const staleDeployments = deploymentsBody.deployments.filter( (d) => !d.live && d.status !== "live" ); @@ -264,6 +268,9 @@ export async function deployToMain( const urlParams = new URLSearchParams(); if (options?.check) { urlParams.set("check", "true"); + } else if (auto) { + // Server will auto-promote the deployment when it's ready + urlParams.set("auto_promote", "true"); } if (options?.allowDestructiveOperations) { urlParams.set("allow_destructive_operations", "true"); @@ -431,18 +438,76 @@ export async function deployToMain( }); } - // Step 2: Poll until deployment is ready + const deploymentChanges = { + pipes: { + changed: deploymentDetails.changed_pipe_names ?? [], + created: deploymentDetails.new_pipe_names ?? [], + deleted: deploymentDetails.deleted_pipe_names ?? [], + }, + datasources: { + changed: deploymentDetails.changed_datasource_names ?? [], + created: deploymentDetails.new_datasource_names ?? [], + deleted: deploymentDetails.deleted_datasource_names ?? [], + }, + }; + + // If we're not waiting, return as soon as the server accepted the deployment. + if (!wait) { + options?.callbacks?.onDeploymentSubmitted?.(deploymentId); + return { + success: true, + result: "success", + datasourceCount: resources.datasources.length, + pipeCount: resources.pipes.length, + connectionCount: resources.connections?.length ?? 0, + buildId: deploymentId, + ...deploymentChanges, + }; + } + + // Step 2: Poll until the deployment reaches a terminal state. + // + // Poll cadence and semantics match `tb deploy` (deployment_common.py): + // - No overall attempt cap: we poll forever at POLL_INTERVAL_MS. + // - `failed` is transient — count consecutive occurrences. If the server + // doesn't move the deployment to `deleting`/`deleted` within + // MAX_CONSECUTIVE_FAILED_POLLS polls (~5 minutes), bail out with a + // "stuck" error. + // - `deleting`/`deleted` means the server gave up; bail with its errors. + // - `data_ready` (+ `live`, when auto-promoting) is success. let deployment = body.deployment; - let attempts = 0; + let timesSeenFailed = 0; + let notifiedReady = false; + let notifiedWaitingForPromote = false; options?.callbacks?.onWaitingForReady?.(); - while (deployment.status !== "data_ready" && attempts < maxPollAttempts) { - await sleep(pollIntervalMs); - attempts++; + const isDone = (): boolean => { + if (deployment.status !== "data_ready") { + return false; + } + if (auto) { + // When auto-promoting we must also wait for the server to flip it live. + return deployment.live === true; + } + return true; + }; + + const buildError = (message: string): BuildApiResult => ({ + success: false, + result: "failed", + error: message, + datasourceCount: resources.datasources.length, + pipeCount: resources.pipes.length, + connectionCount: resources.connections?.length ?? 0, + buildId: deploymentId, + }); + + while (!isDone()) { + await sleep(POLL_INTERVAL_MS); if (debug) { - console.log(`[debug] Polling deployment status (attempt ${attempts})...`); + console.log(`[debug] Polling deployment status...`); } const statusUrl = `${baseUrl}/v1/deployments/${deploymentId}`; @@ -453,114 +518,72 @@ export async function deployToMain( }); 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, - }; + return buildError( + `Failed to check deployment status: ${statusResponse.status} ${statusResponse.statusText}` + ); } const statusBody = (await statusResponse.json()) as DeploymentStatusResponse; deployment = statusBody.deployment; if (debug) { - console.log(`[debug] Deployment status: ${deployment.status}`); + console.log( + `[debug] Deployment status: ${deployment.status} (live=${deployment.live ?? false})` + ); } - // 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 === "failed") { + timesSeenFailed++; + if (timesSeenFailed > MAX_CONSECUTIVE_FAILED_POLLS) { + return buildError( + "Deployment failed to create and didn't start deleting automatically after 5 minutes. " + + "You might need to delete it manually in the UI." + ); + } + continue; } - } - - if (deployment.status !== "data_ready") { - return { - success: false, - result: "failed", - error: `Deployment timed out after ${maxPollAttempts} attempts. Last status: ${deployment.status}`, - datasourceCount: resources.datasources.length, - pipeCount: resources.pipes.length, - connectionCount: resources.connections?.length ?? 0, - buildId: deploymentId, - }; - } - - options?.callbacks?.onDeploymentReady?.(); - - // Step 3: Set the deployment as live - const setLiveUrl = `${baseUrl}/v1/deployments/${deploymentId}/set-live`; - - if (debug) { - console.log(`[debug] POST ${setLiveUrl}`); - } - const setLiveResponse = await tinybirdFetch(setLiveUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${config.token}`, - }, - }); + if (deployment.status === "deleting" || deployment.status === "deleted") { + const errors = deployment.feedback + ?.filter((f) => f.level === "ERROR") + .map((f) => f.message) + .join("\n"); + const errorSuffix = errors ? `\n${errors}` : ""; + return buildError( + `Deployment failed and ${ + deployment.status === "deleting" ? "is being" : "was" + } deleted automatically.${errorSuffix}` + ); + } - 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 ( + auto && + deployment.status === "data_ready" && + !deployment.live && + !notifiedWaitingForPromote + ) { + if (!notifiedReady) { + options?.callbacks?.onDeploymentReady?.(); + notifiedReady = true; + } + options?.callbacks?.onWaitingForPromote?.(); + notifiedWaitingForPromote = true; + } } - if (debug) { - console.log(`[debug] Deployment ${deploymentId} is now live`); + if (!notifiedReady) { + options?.callbacks?.onDeploymentReady?.(); + notifiedReady = true; } - if (previousLiveDeploymentId && previousLiveDeploymentId !== deploymentId) { + if (auto) { if (debug) { - console.log(`[debug] Removing previous deployment: ${previousLiveDeploymentId}`); - } - - const deletePreviousResponse = await tinybirdFetch( - `${baseUrl}/v1/deployments/${previousLiveDeploymentId}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${config.token}`, - }, - } - ); - - if (!deletePreviousResponse.ok) { - const deletePreviousBody = await deletePreviousResponse.text(); - return { - success: false, - result: "failed", - error: `Failed to remove previous deployment: ${deletePreviousResponse.status} ${deletePreviousResponse.statusText}\n${deletePreviousBody}`, - datasourceCount: resources.datasources.length, - pipeCount: resources.pipes.length, - connectionCount: resources.connections?.length ?? 0, - buildId: deploymentId, - }; + console.log(`[debug] Deployment ${deploymentId} is now live`); } + options?.callbacks?.onDeploymentPromoted?.(); + options?.callbacks?.onDeploymentLive?.(deploymentId); } - options?.callbacks?.onDeploymentLive?.(deploymentId); - return { success: true, result: "success", @@ -568,26 +591,10 @@ export async function deployToMain( pipeCount: resources.pipes.length, connectionCount: resources.connections?.length ?? 0, buildId: deploymentId, - pipes: { - changed: deploymentDetails.changed_pipe_names ?? [], - created: deploymentDetails.new_pipe_names ?? [], - deleted: deploymentDetails.deleted_pipe_names ?? [], - }, - datasources: { - changed: deploymentDetails.changed_datasource_names ?? [], - created: deploymentDetails.new_datasource_names ?? [], - deleted: deploymentDetails.deleted_datasource_names ?? [], - }, + ...deploymentChanges, }; } -/** - * Helper function to sleep for a given number of milliseconds - */ -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - function normalizeDeployErrorMessage(message: string): string { const trimmedMessage = message.trim(); const isForwardClassicError = diff --git a/src/cli/commands/deploy.ts b/src/cli/commands/deploy.ts index 3c48899..4f9172d 100644 --- a/src/cli/commands/deploy.ts +++ b/src/cli/commands/deploy.ts @@ -19,6 +19,14 @@ export interface DeployCommandOptions { check?: boolean; /** Allow deleting existing resources in main workspace deploys */ allowDestructiveOperations?: boolean; + /** + * Wait for the deployment to finish. Defaults to true. + */ + wait?: boolean; + /** + * Auto-promote the deployment when it's ready. Defaults to true. + */ + auto?: boolean; /** Callbacks for deploy progress */ callbacks?: DeployCallbacks; } @@ -106,6 +114,8 @@ export async function runDeploy(options: DeployCommandOptions = {}): Promise { if (options.debug) { process.env.TINYBIRD_DEBUG = "1"; } + const wait = options.wait !== false; + const auto = options.auto !== false; + output.highlight("Deploying to main workspace..."); const result = await runDeploy({ dryRun: options.dryRun, check: options.check, allowDestructiveOperations: options.allowDestructiveOperations, + wait, + auto, callbacks: { onChanges: (deployChanges) => { // Show changes table immediately after deployment is created @@ -531,8 +546,11 @@ function createCli(): Command { output.showChangesTable(changes); }, + onDeploymentSubmitted: (id) => output.showDeploymentSubmitted(id, auto), onWaitingForReady: () => output.showWaitingForDeployment(), onDeploymentReady: () => output.showDeploymentReady(), + onWaitingForPromote: () => output.showWaitingForPromote(), + onDeploymentPromoted: () => output.showDeploymentPromoted(), onDeploymentLive: (id) => output.showDeploymentLive(id), onValidating: () => output.showValidatingDeployment(), }, diff --git a/src/cli/output.ts b/src/cli/output.ts index d3513b4..0f3d2d7 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -218,6 +218,30 @@ export function showDeploymentLive(deploymentId: string): void { success(`✓ Deployment #${deploymentId} is live!`); } +/** + * Show waiting for deployment promotion message + */ +export function showWaitingForPromote(): void { + info("» Waiting for deployment to be promoted..."); +} + +/** + * Show deployment submitted message (used when --no-wait is set) + */ +export function showDeploymentSubmitted(deploymentId: string, autoPromote: boolean): void { + const autoFrag = autoPromote + ? " It will be auto-promoted when ready." + : " It won't be auto-promoted when ready."; + success(`✓ Deployment #${deploymentId} submitted.${autoFrag}`); +} + +/** + * Show deployment promoted message + */ +export function showDeploymentPromoted(): void { + success("✓ Deployment promoted"); +} + /** * Show validating deployment message */ @@ -424,6 +448,9 @@ export const output = { showNoChanges, showWaitingForDeployment, showDeploymentReady, + showWaitingForPromote, + showDeploymentPromoted, + showDeploymentSubmitted, showDeploymentLive, showValidatingDeployment, showDeploySuccess, diff --git a/src/test/handlers.ts b/src/test/handlers.ts index aec07c9..e149a47 100644 --- a/src/test/handlers.ts +++ b/src/test/handlers.ts @@ -86,25 +86,21 @@ export function createDeploySuccessResponse(options?: { export function createDeploymentStatusResponse(options?: { deploymentId?: string; status?: string; + live?: boolean; }) { + const status = options?.status ?? "data_ready"; return { result: "success", deployment: { id: options?.deploymentId ?? "deploy-123", - status: options?.status ?? "data_ready", + status, + // When the deployment is `data_ready` the default assumes the server + // has already auto-promoted it, which is what most tests exercise. + live: options?.live ?? status === "data_ready", }, }; } -/** - * Create set-live success response (for /v1/deployments/:id/set-live endpoint) - */ -export function createSetLiveSuccessResponse() { - return { - result: "success", - }; -} - /** * Create deployments list response (for /v1/deployments endpoint) */ From d3e86fc6a707a68a38b60df386a8cd9cc70abac6 Mon Sep 17 00:00:00 2001 From: jordiori Date: Thu, 2 Jul 2026 13:25:33 +0200 Subject: [PATCH 2/3] improvements --- src/api/deploy.test.ts | 30 ++++++++++++------------------ src/api/deploy.ts | 34 ++++++++++++++++++++-------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/api/deploy.test.ts b/src/api/deploy.test.ts index fbdd324..75722eb 100644 --- a/src/api/deploy.test.ts +++ b/src/api/deploy.test.ts @@ -2,21 +2,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; -// Mock the timing module so tests never wait real wall-clock time. The 5s -// poll cadence and 60-poll safety valve are semantic (matched to the CLI), -// but tests only care about the sequence of calls — not the delay between -// them — so we swap sleep for a no-op and shrink the safety threshold to -// something we can drive in a single test. -const { MOCK_MAX_CONSECUTIVE_FAILED_POLLS } = vi.hoisted(() => ({ - MOCK_MAX_CONSECUTIVE_FAILED_POLLS: 3, -})); -vi.mock("./_deploy_timing.js", () => ({ - POLL_INTERVAL_MS: 0, - MAX_CONSECUTIVE_FAILED_POLLS: MOCK_MAX_CONSECUTIVE_FAILED_POLLS, - sleep: () => Promise.resolve(), -})); - -import { deployToMain } from "./deploy.js"; +import { deployToMain, MAX_CONSECUTIVE_FAILED_POLLS } from "./deploy.js"; import type { BuildConfig } from "./build.js"; import { BASE_URL, @@ -30,7 +16,12 @@ import type { GeneratedResources } from "../generator/index.js"; const server = setupServer(); -beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); + vi.stubGlobal("setTimeout", (fn: () => void) => { + Promise.resolve().then(fn); + }); +}); beforeEach(() => { // Set up default handler for deployments list (used by stale deployment cleanup) server.use( @@ -40,7 +31,10 @@ beforeEach(() => { ); }); afterEach(() => server.resetHandlers()); -afterAll(() => server.close()); +afterAll(() => { + server.close(); + vi.unstubAllGlobals(); +}); describe("Deploy API", () => { const config: BuildConfig = { @@ -550,7 +544,7 @@ describe("Deploy API", () => { expect(result.success).toBe(false); expect(result.error).toContain("didn't start deleting automatically"); // One extra poll past the threshold trips the safety valve. - expect(pollCount).toBe(MOCK_MAX_CONSECUTIVE_FAILED_POLLS + 1); + expect(pollCount).toBe(MAX_CONSECUTIVE_FAILED_POLLS + 1); }); it("normalizes baseUrl with trailing slash", async () => { diff --git a/src/api/deploy.ts b/src/api/deploy.ts index 74b53c2..ee577c5 100644 --- a/src/api/deploy.ts +++ b/src/api/deploy.ts @@ -6,15 +6,23 @@ import type { GeneratedResources } from "../generator/index.js"; import type { BuildConfig, BuildApiResult } from "./build.js"; import { tinybirdFetch } from "./fetcher.js"; -import { - MAX_CONSECUTIVE_FAILED_POLLS, - POLL_INTERVAL_MS, - sleep, -} from "./_deploy_timing.js"; const FORWARD_CLASSIC_GUIDANCE = "Use the Tinybird Classic CLI (`tb`) from a Tinybird Classic workspace for this operation."; +/** + * Poll interval used while waiting for a deployment to reach `data_ready` + * (and, when auto-promoting, `live`). Matches the Tinybird CLI (`tb deploy`). + */ +const POLL_INTERVAL_MS = 5_000; + +/** + * How many consecutive `failed` status polls to tolerate before giving up. + * With a 5s poll interval this is ~5 minutes, matching the CLI safety valve + * for deployments that fail but never auto-delete. + */ +export const MAX_CONSECUTIVE_FAILED_POLLS = 60; + /** * Feedback item from deployment response */ @@ -466,15 +474,6 @@ export async function deployToMain( } // Step 2: Poll until the deployment reaches a terminal state. - // - // Poll cadence and semantics match `tb deploy` (deployment_common.py): - // - No overall attempt cap: we poll forever at POLL_INTERVAL_MS. - // - `failed` is transient — count consecutive occurrences. If the server - // doesn't move the deployment to `deleting`/`deleted` within - // MAX_CONSECUTIVE_FAILED_POLLS polls (~5 minutes), bail out with a - // "stuck" error. - // - `deleting`/`deleted` means the server gave up; bail with its errors. - // - `data_ready` (+ `live`, when auto-promoting) is success. let deployment = body.deployment; let timesSeenFailed = 0; let notifiedReady = false; @@ -595,6 +594,13 @@ 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)); +} + function normalizeDeployErrorMessage(message: string): string { const trimmedMessage = message.trim(); const isForwardClassicError = From 69eb731994262a8ba4e26c7756db2bb2a052bb7d Mon Sep 17 00:00:00 2001 From: jordiori Date: Thu, 2 Jul 2026 16:13:12 +0200 Subject: [PATCH 3/3] bump version 0.0.81 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bbeb65f..54eae6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tinybirdco/sdk", - "version": "0.0.80", + "version": "0.0.81", "description": "TypeScript SDK for Tinybird Forward - define datasources and pipes as TypeScript", "type": "module", "main": "./dist/index.js",