@tinybirdco/sdk 0.0.51 → 0.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
1
+ import { describe, it, expect, beforeAll, afterEach, afterAll, vi } from "vitest";
2
2
  import { setupServer } from "msw/node";
3
3
  import { http, HttpResponse } from "msw";
4
4
  import { deployToMain } from "./deploy.js";
@@ -6,25 +6,14 @@ import type { BuildConfig } from "./build.js";
6
6
  import {
7
7
  BASE_URL,
8
8
  createDeploySuccessResponse,
9
- createDeploymentStatusResponse,
10
- createSetLiveSuccessResponse,
11
9
  createBuildFailureResponse,
12
10
  createBuildMultipleErrorsResponse,
13
- createDeploymentsListResponse,
14
11
  } from "../test/handlers.js";
15
12
  import type { GeneratedResources } from "../generator/index.js";
16
13
 
17
14
  const server = setupServer();
18
15
 
19
16
  beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
20
- beforeEach(() => {
21
- // Set up default handler for deployments list (used by stale deployment cleanup)
22
- server.use(
23
- http.get(`${BASE_URL}/v1/deployments`, () => {
24
- return HttpResponse.json(createDeploymentsListResponse());
25
- })
26
- );
27
- });
28
17
  afterEach(() => server.resetHandlers());
29
18
  afterAll(() => server.close());
30
19
 
@@ -35,17 +24,12 @@ describe("Deploy API", () => {
35
24
  };
36
25
 
37
26
  const resources: GeneratedResources = {
38
- datasources: [
39
- { name: "events", content: "SCHEMA > timestamp DateTime" },
40
- ],
41
- pipes: [
42
- { name: "top_events", content: "NODE main\nSQL > SELECT * FROM events" },
43
- ],
27
+ datasources: [{ name: "events", content: "SCHEMA > timestamp DateTime" }],
28
+ pipes: [{ name: "top_events", content: "NODE main\nSQL > SELECT * FROM events" }],
44
29
  connections: [],
45
30
  };
46
31
 
47
- // Helper to set up successful deploy flow
48
- function setupSuccessfulDeployFlow(deploymentId = "deploy-abc") {
32
+ function setupAutoPromoteSuccessFlow(deploymentId = "deploy-abc") {
49
33
  server.use(
50
34
  http.post(`${BASE_URL}/v1/deploy`, () => {
51
35
  return HttpResponse.json(
@@ -53,64 +37,42 @@ describe("Deploy API", () => {
53
37
  );
54
38
  }),
55
39
  http.get(`${BASE_URL}/v1/deployments/${deploymentId}`, () => {
56
- return HttpResponse.json(
57
- createDeploymentStatusResponse({ deploymentId, status: "data_ready" })
58
- );
59
- }),
60
- http.post(`${BASE_URL}/v1/deployments/${deploymentId}/set-live`, () => {
61
- return HttpResponse.json(createSetLiveSuccessResponse());
40
+ return HttpResponse.json({
41
+ result: "success",
42
+ deployment: {
43
+ id: deploymentId,
44
+ status: "data_ready",
45
+ live: true,
46
+ },
47
+ });
62
48
  })
63
49
  );
64
50
  }
65
51
 
66
52
  describe("deployToMain", () => {
67
- it("successfully deploys resources with full flow", async () => {
68
- setupSuccessfulDeployFlow("deploy-abc");
53
+ it("successfully deploys resources with auto-promote flow", async () => {
54
+ setupAutoPromoteSuccessFlow("deploy-abc");
69
55
 
70
- const result = await deployToMain(config, resources, { pollIntervalMs: 1 });
56
+ const onDeploymentLive = vi.fn();
57
+ const result = await deployToMain(config, resources, {
58
+ pollIntervalMs: 1,
59
+ callbacks: { onDeploymentLive },
60
+ });
71
61
 
72
62
  expect(result.success).toBe(true);
73
63
  expect(result.result).toBe("success");
74
64
  expect(result.buildId).toBe("deploy-abc");
75
65
  expect(result.datasourceCount).toBe(1);
76
66
  expect(result.pipeCount).toBe(1);
77
- });
78
-
79
- it("polls until deployment is ready", async () => {
80
- let pollCount = 0;
81
-
82
- server.use(
83
- http.post(`${BASE_URL}/v1/deploy`, () => {
84
- return HttpResponse.json(
85
- createDeploySuccessResponse({ deploymentId: "deploy-poll", status: "pending" })
86
- );
87
- }),
88
- http.get(`${BASE_URL}/v1/deployments/deploy-poll`, () => {
89
- pollCount++;
90
- // Return pending for first 2 polls, then data_ready
91
- const status = pollCount < 3 ? "pending" : "data_ready";
92
- return HttpResponse.json(
93
- createDeploymentStatusResponse({ deploymentId: "deploy-poll", status })
94
- );
95
- }),
96
- http.post(`${BASE_URL}/v1/deployments/deploy-poll/set-live`, () => {
97
- return HttpResponse.json(createSetLiveSuccessResponse());
98
- })
99
- );
100
-
101
- const result = await deployToMain(config, resources, { pollIntervalMs: 1 });
102
-
103
- expect(result.success).toBe(true);
104
- expect(pollCount).toBe(3);
67
+ expect(onDeploymentLive).toHaveBeenCalledWith("deploy-abc");
105
68
  });
106
69
 
107
70
  it("handles deploy failure with single error", async () => {
108
71
  server.use(
109
72
  http.post(`${BASE_URL}/v1/deploy`, () => {
110
- return HttpResponse.json(
111
- createBuildFailureResponse("Permission denied"),
112
- { status: 200 }
113
- );
73
+ return HttpResponse.json(createBuildFailureResponse("Permission denied"), {
74
+ status: 200,
75
+ });
114
76
  })
115
77
  );
116
78
 
@@ -144,10 +106,7 @@ describe("Deploy API", () => {
144
106
  it("handles HTTP error responses", async () => {
145
107
  server.use(
146
108
  http.post(`${BASE_URL}/v1/deploy`, () => {
147
- return HttpResponse.json(
148
- { result: "failed", error: "Forbidden" },
149
- { status: 403 }
150
- );
109
+ return HttpResponse.json({ result: "failed", error: "Forbidden" }, { status: 403 });
151
110
  })
152
111
  );
153
112
 
@@ -167,28 +126,24 @@ describe("Deploy API", () => {
167
126
  })
168
127
  );
169
128
 
170
- await expect(deployToMain(config, resources)).rejects.toThrow(
171
- "Failed to parse response"
172
- );
129
+ await expect(deployToMain(config, resources)).rejects.toThrow("Failed to parse response");
173
130
  });
174
131
 
175
- it("uses /v1/deploy endpoint (not /v1/build)", async () => {
132
+ it("uses /v1/deploy endpoint and sends auto_promote by default", async () => {
176
133
  let capturedUrl: string | null = null;
177
134
 
178
135
  server.use(
179
136
  http.post(`${BASE_URL}/v1/deploy`, ({ request }) => {
180
137
  capturedUrl = request.url;
181
138
  return HttpResponse.json(
182
- createDeploySuccessResponse({ deploymentId: "deploy-url-test" })
139
+ createDeploySuccessResponse({ deploymentId: "deploy-url-test", status: "pending" })
183
140
  );
184
141
  }),
185
142
  http.get(`${BASE_URL}/v1/deployments/deploy-url-test`, () => {
186
- return HttpResponse.json(
187
- createDeploymentStatusResponse({ deploymentId: "deploy-url-test", status: "data_ready" })
188
- );
189
- }),
190
- http.post(`${BASE_URL}/v1/deployments/deploy-url-test/set-live`, () => {
191
- return HttpResponse.json(createSetLiveSuccessResponse());
143
+ return HttpResponse.json({
144
+ result: "success",
145
+ deployment: { id: "deploy-url-test", status: "data_ready", live: true },
146
+ });
192
147
  })
193
148
  );
194
149
 
@@ -197,49 +152,76 @@ describe("Deploy API", () => {
197
152
  const parsed = new URL(capturedUrl ?? "");
198
153
  expect(parsed.pathname).toBe("/v1/deploy");
199
154
  expect(parsed.searchParams.get("from")).toBe("ts-sdk");
155
+ expect(parsed.searchParams.get("auto_promote")).toBe("true");
200
156
  });
201
157
 
202
- it("handles failed deployment status", async () => {
158
+ it("passes allow_destructive_operations when explicitly enabled", async () => {
159
+ let capturedUrl: string | null = null;
160
+
203
161
  server.use(
204
- http.post(`${BASE_URL}/v1/deploy`, () => {
162
+ http.post(`${BASE_URL}/v1/deploy`, ({ request }) => {
163
+ capturedUrl = request.url;
205
164
  return HttpResponse.json(
206
- createDeploySuccessResponse({ deploymentId: "deploy-fail", status: "pending" })
165
+ createDeploySuccessResponse({ deploymentId: "deploy-destructive", status: "pending" })
207
166
  );
208
167
  }),
209
- http.get(`${BASE_URL}/v1/deployments/deploy-fail`, () => {
210
- return HttpResponse.json(
211
- createDeploymentStatusResponse({ deploymentId: "deploy-fail", status: "failed" })
212
- );
168
+ http.get(`${BASE_URL}/v1/deployments/deploy-destructive`, () => {
169
+ return HttpResponse.json({
170
+ result: "success",
171
+ deployment: { id: "deploy-destructive", status: "data_ready", live: true },
172
+ });
213
173
  })
214
174
  );
215
175
 
216
- const result = await deployToMain(config, resources, { pollIntervalMs: 1 });
176
+ await deployToMain(config, resources, {
177
+ pollIntervalMs: 1,
178
+ allowDestructiveOperations: true,
179
+ });
217
180
 
218
- expect(result.success).toBe(false);
219
- expect(result.error).toContain("Deployment failed with status: failed");
181
+ const parsed = new URL(capturedUrl ?? "");
182
+ expect(parsed.searchParams.get("allow_destructive_operations")).toBe("true");
183
+ expect(parsed.searchParams.get("auto_promote")).toBe("true");
184
+ });
185
+
186
+ it("does not send auto_promote in check mode", async () => {
187
+ let capturedUrl: string | null = null;
188
+
189
+ server.use(
190
+ http.post(`${BASE_URL}/v1/deploy`, ({ request }) => {
191
+ capturedUrl = request.url;
192
+ return HttpResponse.json({ result: "success" });
193
+ })
194
+ );
195
+
196
+ const result = await deployToMain(config, resources, { check: true });
197
+
198
+ expect(result.success).toBe(true);
199
+ const parsed = new URL(capturedUrl ?? "");
200
+ expect(parsed.searchParams.get("check")).toBe("true");
201
+ expect(parsed.searchParams.get("auto_promote")).toBeNull();
220
202
  });
221
203
 
222
- it("handles set-live failure", async () => {
204
+ it("adds actionable guidance to Forward/Classic workspace errors", async () => {
223
205
  server.use(
224
206
  http.post(`${BASE_URL}/v1/deploy`, () => {
225
207
  return HttpResponse.json(
226
- createDeploySuccessResponse({ deploymentId: "deploy-setlive-fail" })
208
+ {
209
+ result: "failed",
210
+ error:
211
+ "This is a Tinybird Forward workspace, and this operation is only available for Tinybird Classic workspaces.",
212
+ },
213
+ { status: 400 }
227
214
  );
228
- }),
229
- http.get(`${BASE_URL}/v1/deployments/deploy-setlive-fail`, () => {
230
- return HttpResponse.json(
231
- createDeploymentStatusResponse({ deploymentId: "deploy-setlive-fail", status: "data_ready" })
232
- );
233
- }),
234
- http.post(`${BASE_URL}/v1/deployments/deploy-setlive-fail/set-live`, () => {
235
- return HttpResponse.json({ error: "Set live failed" }, { status: 500 });
236
215
  })
237
216
  );
238
217
 
239
- const result = await deployToMain(config, resources, { pollIntervalMs: 1 });
218
+ const result = await deployToMain(config, resources);
240
219
 
241
220
  expect(result.success).toBe(false);
242
- expect(result.error).toContain("Failed to set deployment as live");
221
+ expect(result.error).toContain("Tinybird Forward workspace");
222
+ expect(result.error).toContain(
223
+ "Use the Tinybird Classic CLI (`tb`) from a Tinybird Classic workspace for this operation."
224
+ );
243
225
  });
244
226
 
245
227
  it("normalizes baseUrl with trailing slash", async () => {
@@ -249,16 +231,14 @@ describe("Deploy API", () => {
249
231
  http.post(`${BASE_URL}/v1/deploy`, ({ request }) => {
250
232
  capturedUrl = request.url;
251
233
  return HttpResponse.json(
252
- createDeploySuccessResponse({ deploymentId: "deploy-slash" })
234
+ createDeploySuccessResponse({ deploymentId: "deploy-slash", status: "pending" })
253
235
  );
254
236
  }),
255
237
  http.get(`${BASE_URL}/v1/deployments/deploy-slash`, () => {
256
- return HttpResponse.json(
257
- createDeploymentStatusResponse({ deploymentId: "deploy-slash", status: "data_ready" })
258
- );
259
- }),
260
- http.post(`${BASE_URL}/v1/deployments/deploy-slash/set-live`, () => {
261
- return HttpResponse.json(createSetLiveSuccessResponse());
238
+ return HttpResponse.json({
239
+ result: "success",
240
+ deployment: { id: "deploy-slash", status: "data_ready", live: true },
241
+ });
262
242
  })
263
243
  );
264
244
 
@@ -271,29 +251,7 @@ describe("Deploy API", () => {
271
251
  const parsed = new URL(capturedUrl ?? "");
272
252
  expect(parsed.pathname).toBe("/v1/deploy");
273
253
  expect(parsed.searchParams.get("from")).toBe("ts-sdk");
274
- });
275
-
276
- it("times out when deployment never becomes ready", async () => {
277
- server.use(
278
- http.post(`${BASE_URL}/v1/deploy`, () => {
279
- return HttpResponse.json(
280
- createDeploySuccessResponse({ deploymentId: "deploy-timeout", status: "pending" })
281
- );
282
- }),
283
- http.get(`${BASE_URL}/v1/deployments/deploy-timeout`, () => {
284
- return HttpResponse.json(
285
- createDeploymentStatusResponse({ deploymentId: "deploy-timeout", status: "pending" })
286
- );
287
- })
288
- );
289
-
290
- const result = await deployToMain(config, resources, {
291
- pollIntervalMs: 1,
292
- maxPollAttempts: 3,
293
- });
294
-
295
- expect(result.success).toBe(false);
296
- expect(result.error).toContain("Deployment timed out");
254
+ expect(parsed.searchParams.get("auto_promote")).toBe("true");
297
255
  });
298
256
  });
299
257
  });
package/src/api/deploy.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * Deploy resources to Tinybird main workspace
3
- * Uses the /v1/deploy endpoint to create a deployment, then sets it live
3
+ * Uses the /v1/deploy endpoint with auto-promotion enabled
4
4
  */
5
5
 
6
6
  import type { GeneratedResources } from "../generator/index.js";
7
7
  import type { BuildConfig, BuildApiResult } from "./build.js";
8
8
  import { tinybirdFetch } from "./fetcher.js";
9
9
 
10
+ const FORWARD_CLASSIC_GUIDANCE =
11
+ "Use the Tinybird Classic CLI (`tb`) from a Tinybird Classic workspace for this operation.";
12
+
10
13
  /**
11
14
  * Feedback item from deployment response
12
15
  */
@@ -28,13 +31,6 @@ export interface Deployment {
28
31
  feedback?: DeploymentFeedback[];
29
32
  }
30
33
 
31
- /**
32
- * Response from /v1/deployments list endpoint
33
- */
34
- export interface DeploymentsListResponse {
35
- deployments: Deployment[];
36
- }
37
-
38
34
  /**
39
35
  * Response from /v1/deploy endpoint
40
36
  */
@@ -45,6 +41,14 @@ export interface DeployResponse {
45
41
  errors?: Array<{ filename?: string; error: string }>;
46
42
  }
47
43
 
44
+ /**
45
+ * Response from /v1/deployments/{id} endpoint
46
+ */
47
+ export interface DeploymentStatusResponse {
48
+ result: string;
49
+ deployment: Deployment;
50
+ }
51
+
48
52
  /**
49
53
  * Detailed deployment information with resource changes
50
54
  */
@@ -71,21 +75,11 @@ export interface DeploymentDetails extends Deployment {
71
75
  errors?: Array<{ filename?: string; error: string }>;
72
76
  }
73
77
 
74
- /**
75
- * Response from /v1/deployments/{id} endpoint
76
- */
77
- export interface DeploymentStatusResponse {
78
- result: string;
79
- deployment: Deployment;
80
- }
81
-
82
78
  /**
83
79
  * Deploy generated resources to Tinybird main workspace
84
80
  *
85
81
  * Uses the /v1/deploy endpoint which accepts all resources in a single
86
- * multipart form request. After creating the deployment, this function:
87
- * 1. Polls until the deployment is ready (status === 'data_ready')
88
- * 2. Sets the deployment as live via /v1/deployments/{id}/set-live
82
+ * multipart form request.
89
83
  *
90
84
  * @param config - Build configuration with API URL and token
91
85
  * @param resources - Generated resources to deploy
@@ -158,13 +152,14 @@ export async function deployToMain(
158
152
  pollIntervalMs?: number;
159
153
  maxPollAttempts?: number;
160
154
  check?: boolean;
155
+ autoPromote?: boolean;
161
156
  allowDestructiveOperations?: boolean;
162
157
  callbacks?: DeployCallbacks;
163
158
  }
164
159
  ): Promise<BuildApiResult> {
165
160
  const debug = options?.debug ?? !!process.env.TINYBIRD_DEBUG;
166
161
  const pollIntervalMs = options?.pollIntervalMs ?? 1000;
167
- const maxPollAttempts = options?.maxPollAttempts ?? 120; // 2 minutes max
162
+ const maxPollAttempts = options?.maxPollAttempts ?? 120;
168
163
  const baseUrl = config.baseUrl.replace(/\/$/, "");
169
164
 
170
165
  const formData = new FormData();
@@ -199,46 +194,17 @@ export async function deployToMain(
199
194
  );
200
195
  }
201
196
 
202
- // Step 0: Clean up any stale non-live deployments that might block the new deployment
203
- try {
204
- const deploymentsUrl = `${baseUrl}/v1/deployments`;
205
- const deploymentsResponse = await tinybirdFetch(deploymentsUrl, {
206
- headers: {
207
- Authorization: `Bearer ${config.token}`,
208
- },
209
- });
210
-
211
- if (deploymentsResponse.ok) {
212
- const deploymentsBody = (await deploymentsResponse.json()) as DeploymentsListResponse;
213
- const staleDeployments = deploymentsBody.deployments.filter(
214
- (d) => !d.live && d.status !== "live"
215
- );
216
-
217
- for (const stale of staleDeployments) {
218
- if (debug) {
219
- console.log(`[debug] Cleaning up stale deployment: ${stale.id} (status: ${stale.status})`);
220
- }
221
- await tinybirdFetch(`${baseUrl}/v1/deployments/${stale.id}`, {
222
- method: "DELETE",
223
- headers: {
224
- Authorization: `Bearer ${config.token}`,
225
- },
226
- });
227
- }
228
- }
229
- } catch (e) {
230
- // Ignore errors during cleanup - we'll try to deploy anyway
231
- if (debug) {
232
- console.log(`[debug] Failed to clean up stale deployments: ${e}`);
233
- }
234
- }
235
-
236
- // Step 1: Create deployment via /v1/deploy
197
+ // Create deployment via /v1/deploy.
198
+ // `auto_promote=true` makes the API promote the deployment automatically.
237
199
  const deployUrlBase = `${baseUrl}/v1/deploy`;
238
200
  const urlParams = new URLSearchParams();
239
201
  if (options?.check) {
240
202
  urlParams.set("check", "true");
241
203
  }
204
+ const autoPromote = options?.autoPromote ?? !options?.check;
205
+ if (autoPromote) {
206
+ urlParams.set("auto_promote", "true");
207
+ }
242
208
  if (options?.allowDestructiveOperations) {
243
209
  urlParams.set("allow_destructive_operations", "true");
244
210
  }
@@ -280,7 +246,7 @@ export async function deployToMain(
280
246
  .map((f) => {
281
247
  // Extract just the filename from "Datasource events.datasource" format
282
248
  const resourceName = f.resource.split(" ").pop() ?? f.resource;
283
- return `${resourceName}: ${f.message}`;
249
+ return `${resourceName}: ${normalizeDeployErrorMessage(f.message)}`;
284
250
  })
285
251
  .join("\n");
286
252
  };
@@ -298,12 +264,12 @@ export async function deployToMain(
298
264
  return body.errors
299
265
  .map((e) => {
300
266
  const prefix = e.filename ? `[${e.filename}] ` : "";
301
- return `${prefix}${e.error}`;
267
+ return `${prefix}${normalizeDeployErrorMessage(e.error)}`;
302
268
  })
303
269
  .join("\n");
304
270
  }
305
271
  if (body.error) {
306
- return body.error;
272
+ return normalizeDeployErrorMessage(body.error);
307
273
  }
308
274
  // Include raw response body for debugging when no structured error is available
309
275
  return `HTTP ${response.status}: ${response.statusText}\nResponse: ${rawBody}`;
@@ -405,18 +371,22 @@ export async function deployToMain(
405
371
  });
406
372
  }
407
373
 
408
- // Step 2: Poll until deployment is ready
409
- let deployment = body.deployment;
410
- let attempts = 0;
374
+ let deployment = deploymentDetails;
375
+ let statusAttempts = 0;
411
376
 
412
377
  options?.callbacks?.onWaitingForReady?.();
413
378
 
414
- while (deployment.status !== "data_ready" && attempts < maxPollAttempts) {
379
+ while (
380
+ deployment.status !== "data_ready" &&
381
+ deployment.status !== "failed" &&
382
+ deployment.status !== "error" &&
383
+ statusAttempts < maxPollAttempts
384
+ ) {
415
385
  await sleep(pollIntervalMs);
416
- attempts++;
386
+ statusAttempts++;
417
387
 
418
388
  if (debug) {
419
- console.log(`[debug] Polling deployment status (attempt ${attempts})...`);
389
+ console.log(`[debug] Polling deployment status (attempt ${statusAttempts})...`);
420
390
  }
421
391
 
422
392
  const statusUrl = `${baseUrl}/v1/deployments/${deploymentId}`;
@@ -440,30 +410,13 @@ export async function deployToMain(
440
410
 
441
411
  const statusBody = (await statusResponse.json()) as DeploymentStatusResponse;
442
412
  deployment = statusBody.deployment;
443
-
444
- if (debug) {
445
- console.log(`[debug] Deployment status: ${deployment.status}`);
446
- }
447
-
448
- // Check for failed status
449
- if (deployment.status === "failed" || deployment.status === "error") {
450
- return {
451
- success: false,
452
- result: "failed",
453
- error: `Deployment failed with status: ${deployment.status}`,
454
- datasourceCount: resources.datasources.length,
455
- pipeCount: resources.pipes.length,
456
- connectionCount: resources.connections?.length ?? 0,
457
- buildId: deploymentId,
458
- };
459
- }
460
413
  }
461
414
 
462
- if (deployment.status !== "data_ready") {
415
+ if (deployment.status === "failed" || deployment.status === "error") {
463
416
  return {
464
417
  success: false,
465
418
  result: "failed",
466
- error: `Deployment timed out after ${maxPollAttempts} attempts. Last status: ${deployment.status}`,
419
+ error: `Deployment failed with status: ${deployment.status}`,
467
420
  datasourceCount: resources.datasources.length,
468
421
  pipeCount: resources.pipes.length,
469
422
  connectionCount: resources.connections?.length ?? 0,
@@ -471,28 +424,11 @@ export async function deployToMain(
471
424
  };
472
425
  }
473
426
 
474
- options?.callbacks?.onDeploymentReady?.();
475
-
476
- // Step 3: Set the deployment as live
477
- const setLiveUrl = `${baseUrl}/v1/deployments/${deploymentId}/set-live`;
478
-
479
- if (debug) {
480
- console.log(`[debug] POST ${setLiveUrl}`);
481
- }
482
-
483
- const setLiveResponse = await tinybirdFetch(setLiveUrl, {
484
- method: "POST",
485
- headers: {
486
- Authorization: `Bearer ${config.token}`,
487
- },
488
- });
489
-
490
- if (!setLiveResponse.ok) {
491
- const setLiveBody = await setLiveResponse.text();
427
+ if (deployment.status !== "data_ready") {
492
428
  return {
493
429
  success: false,
494
430
  result: "failed",
495
- error: `Failed to set deployment as live: ${setLiveResponse.status} ${setLiveResponse.statusText}\n${setLiveBody}`,
431
+ error: `Deployment timed out after ${maxPollAttempts} attempts. Last status: ${deployment.status}`,
496
432
  datasourceCount: resources.datasources.length,
497
433
  pipeCount: resources.pipes.length,
498
434
  connectionCount: resources.connections?.length ?? 0,
@@ -500,11 +436,66 @@ export async function deployToMain(
500
436
  };
501
437
  }
502
438
 
439
+ options?.callbacks?.onDeploymentReady?.();
440
+
441
+ if (autoPromote && !deployment.live) {
442
+ let promotionAttempts = 0;
443
+ options?.callbacks?.onWaitingForPromote?.();
444
+
445
+ while (!deployment.live && promotionAttempts < maxPollAttempts) {
446
+ await sleep(pollIntervalMs);
447
+ promotionAttempts++;
448
+
449
+ if (debug) {
450
+ console.log(`[debug] Polling auto-promote status (attempt ${promotionAttempts})...`);
451
+ }
452
+
453
+ const statusUrl = `${baseUrl}/v1/deployments/${deploymentId}`;
454
+ const statusResponse = await tinybirdFetch(statusUrl, {
455
+ headers: {
456
+ Authorization: `Bearer ${config.token}`,
457
+ },
458
+ });
459
+
460
+ if (!statusResponse.ok) {
461
+ return {
462
+ success: false,
463
+ result: "failed",
464
+ error: `Failed to check deployment status: ${statusResponse.status} ${statusResponse.statusText}`,
465
+ datasourceCount: resources.datasources.length,
466
+ pipeCount: resources.pipes.length,
467
+ connectionCount: resources.connections?.length ?? 0,
468
+ buildId: deploymentId,
469
+ };
470
+ }
471
+
472
+ const statusBody = (await statusResponse.json()) as DeploymentStatusResponse;
473
+ deployment = statusBody.deployment;
474
+ }
475
+
476
+ if (!deployment.live) {
477
+ return {
478
+ success: false,
479
+ result: "failed",
480
+ error: `Deployment reached data_ready but auto-promote did not complete after ${maxPollAttempts} attempts`,
481
+ datasourceCount: resources.datasources.length,
482
+ pipeCount: resources.pipes.length,
483
+ connectionCount: resources.connections?.length ?? 0,
484
+ buildId: deploymentId,
485
+ };
486
+ }
487
+
488
+ options?.callbacks?.onDeploymentPromoted?.();
489
+ }
490
+
503
491
  if (debug) {
504
- console.log(`[debug] Deployment ${deploymentId} is now live`);
492
+ const stateLabel = deployment.live ? "live" : "ready";
493
+ console.log(`[debug] Deployment ${deploymentId} is now ${stateLabel}`);
505
494
  }
506
495
 
507
- options?.callbacks?.onDeploymentLive?.(deploymentId);
496
+ if (deployment.live) {
497
+ options?.callbacks?.onDeploymentLive?.(deploymentId);
498
+ }
508
499
 
509
500
  return {
510
501
  success: true,
@@ -526,9 +517,19 @@ export async function deployToMain(
526
517
  };
527
518
  }
528
519
 
529
- /**
530
- * Helper function to sleep for a given number of milliseconds
531
- */
532
520
  function sleep(ms: number): Promise<void> {
533
521
  return new Promise((resolve) => setTimeout(resolve, ms));
534
522
  }
523
+
524
+ function normalizeDeployErrorMessage(message: string): string {
525
+ const trimmedMessage = message.trim();
526
+ const isForwardClassicError =
527
+ trimmedMessage.includes("Tinybird Forward workspace") &&
528
+ trimmedMessage.includes("Tinybird Classic workspaces");
529
+
530
+ if (!isForwardClassicError || trimmedMessage.includes(FORWARD_CLASSIC_GUIDANCE)) {
531
+ return message;
532
+ }
533
+
534
+ return `${trimmedMessage}\n${FORWARD_CLASSIC_GUIDANCE}`;
535
+ }