@tinybirdco/sdk 0.0.79 → 0.0.81

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.
package/src/api/deploy.ts CHANGED
@@ -10,6 +10,19 @@ import { tinybirdFetch } from "./fetcher.js";
10
10
  const FORWARD_CLASSIC_GUIDANCE =
11
11
  "Use the Tinybird Classic CLI (`tb`) from a Tinybird Classic workspace for this operation.";
12
12
 
13
+ /**
14
+ * Poll interval used while waiting for a deployment to reach `data_ready`
15
+ * (and, when auto-promoting, `live`). Matches the Tinybird CLI (`tb deploy`).
16
+ */
17
+ const POLL_INTERVAL_MS = 5_000;
18
+
19
+ /**
20
+ * How many consecutive `failed` status polls to tolerate before giving up.
21
+ * With a 5s poll interval this is ~5 minutes, matching the CLI safety valve
22
+ * for deployments that fail but never auto-delete.
23
+ */
24
+ export const MAX_CONSECUTIVE_FAILED_POLLS = 60;
25
+
13
26
  /**
14
27
  * Feedback item from deployment response
15
28
  */
@@ -87,8 +100,10 @@ export interface DeploymentStatusResponse {
87
100
  *
88
101
  * Uses the /v1/deploy endpoint which accepts all resources in a single
89
102
  * multipart form request. After creating the deployment, this function:
90
- * 1. Polls until the deployment is ready (status === 'data_ready')
91
- * 2. Sets the deployment as live via /v1/deployments/{id}/set-live
103
+ * 1. When `auto` is true (default), sends `auto_promote=true` so the
104
+ * server promotes the deployment as soon as it's ready.
105
+ * 2. When `wait` is true (default), polls until the deployment reaches
106
+ * `data_ready` (and, if `auto` is also true, until it becomes live).
92
107
  *
93
108
  * @param config - Build configuration with API URL and token
94
109
  * @param resources - Generated resources to deploy
@@ -139,6 +154,8 @@ export interface DeploymentChanges {
139
154
  export interface DeployCallbacks {
140
155
  /** Called when deployment is created and changes are available */
141
156
  onChanges?: (changes: DeploymentChanges) => void;
157
+ /** Called when deployment was submitted but wait was disabled */
158
+ onDeploymentSubmitted?: (deploymentId: string) => void;
142
159
  /** Called when waiting for deployment to be ready */
143
160
  onWaitingForReady?: () => void;
144
161
  /** Called when deployment is ready */
@@ -158,18 +175,17 @@ export async function deployToMain(
158
175
  resources: GeneratedResources,
159
176
  options?: {
160
177
  debug?: boolean;
161
- pollIntervalMs?: number;
162
- maxPollAttempts?: number;
163
178
  check?: boolean;
164
179
  allowDestructiveOperations?: boolean;
180
+ wait?: boolean;
181
+ auto?: boolean;
165
182
  callbacks?: DeployCallbacks;
166
183
  }
167
184
  ): Promise<BuildApiResult> {
168
185
  const debug = options?.debug ?? !!process.env.TINYBIRD_DEBUG;
169
- const pollIntervalMs = options?.pollIntervalMs ?? 1000;
170
- const maxPollAttempts = options?.maxPollAttempts ?? 120; // 2 minutes max
186
+ const wait = options?.wait ?? true;
187
+ const auto = options?.auto ?? true;
171
188
  const baseUrl = config.baseUrl.replace(/\/$/, "");
172
- let previousLiveDeploymentId: string | undefined;
173
189
 
174
190
  const formData = new FormData();
175
191
 
@@ -231,10 +247,6 @@ export async function deployToMain(
231
247
 
232
248
  if (deploymentsResponse.ok) {
233
249
  const deploymentsBody = (await deploymentsResponse.json()) as DeploymentsListResponse;
234
- const previousLiveDeployment = deploymentsBody.deployments.find(
235
- (d) => d.live || d.status === "live"
236
- );
237
- previousLiveDeploymentId = previousLiveDeployment?.id;
238
250
  const staleDeployments = deploymentsBody.deployments.filter(
239
251
  (d) => !d.live && d.status !== "live"
240
252
  );
@@ -264,6 +276,9 @@ export async function deployToMain(
264
276
  const urlParams = new URLSearchParams();
265
277
  if (options?.check) {
266
278
  urlParams.set("check", "true");
279
+ } else if (auto) {
280
+ // Server will auto-promote the deployment when it's ready
281
+ urlParams.set("auto_promote", "true");
267
282
  }
268
283
  if (options?.allowDestructiveOperations) {
269
284
  urlParams.set("allow_destructive_operations", "true");
@@ -431,18 +446,67 @@ export async function deployToMain(
431
446
  });
432
447
  }
433
448
 
434
- // Step 2: Poll until deployment is ready
449
+ const deploymentChanges = {
450
+ pipes: {
451
+ changed: deploymentDetails.changed_pipe_names ?? [],
452
+ created: deploymentDetails.new_pipe_names ?? [],
453
+ deleted: deploymentDetails.deleted_pipe_names ?? [],
454
+ },
455
+ datasources: {
456
+ changed: deploymentDetails.changed_datasource_names ?? [],
457
+ created: deploymentDetails.new_datasource_names ?? [],
458
+ deleted: deploymentDetails.deleted_datasource_names ?? [],
459
+ },
460
+ };
461
+
462
+ // If we're not waiting, return as soon as the server accepted the deployment.
463
+ if (!wait) {
464
+ options?.callbacks?.onDeploymentSubmitted?.(deploymentId);
465
+ return {
466
+ success: true,
467
+ result: "success",
468
+ datasourceCount: resources.datasources.length,
469
+ pipeCount: resources.pipes.length,
470
+ connectionCount: resources.connections?.length ?? 0,
471
+ buildId: deploymentId,
472
+ ...deploymentChanges,
473
+ };
474
+ }
475
+
476
+ // Step 2: Poll until the deployment reaches a terminal state.
435
477
  let deployment = body.deployment;
436
- let attempts = 0;
478
+ let timesSeenFailed = 0;
479
+ let notifiedReady = false;
480
+ let notifiedWaitingForPromote = false;
437
481
 
438
482
  options?.callbacks?.onWaitingForReady?.();
439
483
 
440
- while (deployment.status !== "data_ready" && attempts < maxPollAttempts) {
441
- await sleep(pollIntervalMs);
442
- attempts++;
484
+ const isDone = (): boolean => {
485
+ if (deployment.status !== "data_ready") {
486
+ return false;
487
+ }
488
+ if (auto) {
489
+ // When auto-promoting we must also wait for the server to flip it live.
490
+ return deployment.live === true;
491
+ }
492
+ return true;
493
+ };
494
+
495
+ const buildError = (message: string): BuildApiResult => ({
496
+ success: false,
497
+ result: "failed",
498
+ error: message,
499
+ datasourceCount: resources.datasources.length,
500
+ pipeCount: resources.pipes.length,
501
+ connectionCount: resources.connections?.length ?? 0,
502
+ buildId: deploymentId,
503
+ });
504
+
505
+ while (!isDone()) {
506
+ await sleep(POLL_INTERVAL_MS);
443
507
 
444
508
  if (debug) {
445
- console.log(`[debug] Polling deployment status (attempt ${attempts})...`);
509
+ console.log(`[debug] Polling deployment status...`);
446
510
  }
447
511
 
448
512
  const statusUrl = `${baseUrl}/v1/deployments/${deploymentId}`;
@@ -453,114 +517,72 @@ export async function deployToMain(
453
517
  });
454
518
 
455
519
  if (!statusResponse.ok) {
456
- return {
457
- success: false,
458
- result: "failed",
459
- error: `Failed to check deployment status: ${statusResponse.status} ${statusResponse.statusText}`,
460
- datasourceCount: resources.datasources.length,
461
- pipeCount: resources.pipes.length,
462
- connectionCount: resources.connections?.length ?? 0,
463
- buildId: deploymentId,
464
- };
520
+ return buildError(
521
+ `Failed to check deployment status: ${statusResponse.status} ${statusResponse.statusText}`
522
+ );
465
523
  }
466
524
 
467
525
  const statusBody = (await statusResponse.json()) as DeploymentStatusResponse;
468
526
  deployment = statusBody.deployment;
469
527
 
470
528
  if (debug) {
471
- console.log(`[debug] Deployment status: ${deployment.status}`);
529
+ console.log(
530
+ `[debug] Deployment status: ${deployment.status} (live=${deployment.live ?? false})`
531
+ );
472
532
  }
473
533
 
474
- // Check for failed status
475
- if (deployment.status === "failed" || deployment.status === "error") {
476
- return {
477
- success: false,
478
- result: "failed",
479
- error: `Deployment failed with status: ${deployment.status}`,
480
- datasourceCount: resources.datasources.length,
481
- pipeCount: resources.pipes.length,
482
- connectionCount: resources.connections?.length ?? 0,
483
- buildId: deploymentId,
484
- };
534
+ if (deployment.status === "failed") {
535
+ timesSeenFailed++;
536
+ if (timesSeenFailed > MAX_CONSECUTIVE_FAILED_POLLS) {
537
+ return buildError(
538
+ "Deployment failed to create and didn't start deleting automatically after 5 minutes. " +
539
+ "You might need to delete it manually in the UI."
540
+ );
541
+ }
542
+ continue;
485
543
  }
486
- }
487
-
488
- if (deployment.status !== "data_ready") {
489
- return {
490
- success: false,
491
- result: "failed",
492
- error: `Deployment timed out after ${maxPollAttempts} attempts. Last status: ${deployment.status}`,
493
- datasourceCount: resources.datasources.length,
494
- pipeCount: resources.pipes.length,
495
- connectionCount: resources.connections?.length ?? 0,
496
- buildId: deploymentId,
497
- };
498
- }
499
544
 
500
- options?.callbacks?.onDeploymentReady?.();
501
-
502
- // Step 3: Set the deployment as live
503
- const setLiveUrl = `${baseUrl}/v1/deployments/${deploymentId}/set-live`;
504
-
505
- if (debug) {
506
- console.log(`[debug] POST ${setLiveUrl}`);
507
- }
508
-
509
- const setLiveResponse = await tinybirdFetch(setLiveUrl, {
510
- method: "POST",
511
- headers: {
512
- Authorization: `Bearer ${config.token}`,
513
- },
514
- });
545
+ if (deployment.status === "deleting" || deployment.status === "deleted") {
546
+ const errors = deployment.feedback
547
+ ?.filter((f) => f.level === "ERROR")
548
+ .map((f) => f.message)
549
+ .join("\n");
550
+ const errorSuffix = errors ? `\n${errors}` : "";
551
+ return buildError(
552
+ `Deployment failed and ${
553
+ deployment.status === "deleting" ? "is being" : "was"
554
+ } deleted automatically.${errorSuffix}`
555
+ );
556
+ }
515
557
 
516
- if (!setLiveResponse.ok) {
517
- const setLiveBody = await setLiveResponse.text();
518
- return {
519
- success: false,
520
- result: "failed",
521
- error: `Failed to set deployment as live: ${setLiveResponse.status} ${setLiveResponse.statusText}\n${setLiveBody}`,
522
- datasourceCount: resources.datasources.length,
523
- pipeCount: resources.pipes.length,
524
- connectionCount: resources.connections?.length ?? 0,
525
- buildId: deploymentId,
526
- };
558
+ if (
559
+ auto &&
560
+ deployment.status === "data_ready" &&
561
+ !deployment.live &&
562
+ !notifiedWaitingForPromote
563
+ ) {
564
+ if (!notifiedReady) {
565
+ options?.callbacks?.onDeploymentReady?.();
566
+ notifiedReady = true;
567
+ }
568
+ options?.callbacks?.onWaitingForPromote?.();
569
+ notifiedWaitingForPromote = true;
570
+ }
527
571
  }
528
572
 
529
- if (debug) {
530
- console.log(`[debug] Deployment ${deploymentId} is now live`);
573
+ if (!notifiedReady) {
574
+ options?.callbacks?.onDeploymentReady?.();
575
+ notifiedReady = true;
531
576
  }
532
577
 
533
- if (previousLiveDeploymentId && previousLiveDeploymentId !== deploymentId) {
578
+ if (auto) {
534
579
  if (debug) {
535
- console.log(`[debug] Removing previous deployment: ${previousLiveDeploymentId}`);
536
- }
537
-
538
- const deletePreviousResponse = await tinybirdFetch(
539
- `${baseUrl}/v1/deployments/${previousLiveDeploymentId}`,
540
- {
541
- method: "DELETE",
542
- headers: {
543
- Authorization: `Bearer ${config.token}`,
544
- },
545
- }
546
- );
547
-
548
- if (!deletePreviousResponse.ok) {
549
- const deletePreviousBody = await deletePreviousResponse.text();
550
- return {
551
- success: false,
552
- result: "failed",
553
- error: `Failed to remove previous deployment: ${deletePreviousResponse.status} ${deletePreviousResponse.statusText}\n${deletePreviousBody}`,
554
- datasourceCount: resources.datasources.length,
555
- pipeCount: resources.pipes.length,
556
- connectionCount: resources.connections?.length ?? 0,
557
- buildId: deploymentId,
558
- };
580
+ console.log(`[debug] Deployment ${deploymentId} is now live`);
559
581
  }
582
+ options?.callbacks?.onDeploymentPromoted?.();
583
+ options?.callbacks?.onDeploymentLive?.(deploymentId);
560
584
  }
561
585
 
562
- options?.callbacks?.onDeploymentLive?.(deploymentId);
563
-
564
586
  return {
565
587
  success: true,
566
588
  result: "success",
@@ -568,16 +590,7 @@ export async function deployToMain(
568
590
  pipeCount: resources.pipes.length,
569
591
  connectionCount: resources.connections?.length ?? 0,
570
592
  buildId: deploymentId,
571
- pipes: {
572
- changed: deploymentDetails.changed_pipe_names ?? [],
573
- created: deploymentDetails.new_pipe_names ?? [],
574
- deleted: deploymentDetails.deleted_pipe_names ?? [],
575
- },
576
- datasources: {
577
- changed: deploymentDetails.changed_datasource_names ?? [],
578
- created: deploymentDetails.new_datasource_names ?? [],
579
- deleted: deploymentDetails.deleted_datasource_names ?? [],
580
- },
593
+ ...deploymentChanges,
581
594
  };
582
595
  }
583
596
 
@@ -19,6 +19,14 @@ export interface DeployCommandOptions {
19
19
  check?: boolean;
20
20
  /** Allow deleting existing resources in main workspace deploys */
21
21
  allowDestructiveOperations?: boolean;
22
+ /**
23
+ * Wait for the deployment to finish. Defaults to true.
24
+ */
25
+ wait?: boolean;
26
+ /**
27
+ * Auto-promote the deployment when it's ready. Defaults to true.
28
+ */
29
+ auto?: boolean;
22
30
  /** Callbacks for deploy progress */
23
31
  callbacks?: DeployCallbacks;
24
32
  }
@@ -106,6 +114,8 @@ export async function runDeploy(options: DeployCommandOptions = {}): Promise<Dep
106
114
  {
107
115
  check: options.check,
108
116
  allowDestructiveOperations: options.allowDestructiveOperations,
117
+ wait: options.wait,
118
+ auto: options.auto,
109
119
  callbacks: options.callbacks,
110
120
  }
111
121
  );
package/src/cli/index.ts CHANGED
@@ -482,18 +482,33 @@ function createCli(): Command {
482
482
  "--allow-destructive-operations",
483
483
  "Allow deploys that delete existing datasources, pipes, or connections"
484
484
  )
485
+ .option("--wait", "Wait for the deployment to finish (default)")
486
+ .option("--no-wait", "Return as soon as the deployment is submitted")
487
+ .option(
488
+ "--auto",
489
+ "Auto-promote the deployment when it's ready (default)"
490
+ )
491
+ .option(
492
+ "--no-auto",
493
+ "Do not auto-promote the deployment; leave it staged for a manual promote"
494
+ )
485
495
  .option("--debug", "Show debug output including API requests/responses")
486
496
  .action(async (options) => {
487
497
  if (options.debug) {
488
498
  process.env.TINYBIRD_DEBUG = "1";
489
499
  }
490
500
 
501
+ const wait = options.wait !== false;
502
+ const auto = options.auto !== false;
503
+
491
504
  output.highlight("Deploying to main workspace...");
492
505
 
493
506
  const result = await runDeploy({
494
507
  dryRun: options.dryRun,
495
508
  check: options.check,
496
509
  allowDestructiveOperations: options.allowDestructiveOperations,
510
+ wait,
511
+ auto,
497
512
  callbacks: {
498
513
  onChanges: (deployChanges) => {
499
514
  // Show changes table immediately after deployment is created
@@ -531,8 +546,11 @@ function createCli(): Command {
531
546
 
532
547
  output.showChangesTable(changes);
533
548
  },
549
+ onDeploymentSubmitted: (id) => output.showDeploymentSubmitted(id, auto),
534
550
  onWaitingForReady: () => output.showWaitingForDeployment(),
535
551
  onDeploymentReady: () => output.showDeploymentReady(),
552
+ onWaitingForPromote: () => output.showWaitingForPromote(),
553
+ onDeploymentPromoted: () => output.showDeploymentPromoted(),
536
554
  onDeploymentLive: (id) => output.showDeploymentLive(id),
537
555
  onValidating: () => output.showValidatingDeployment(),
538
556
  },
package/src/cli/output.ts CHANGED
@@ -218,6 +218,30 @@ export function showDeploymentLive(deploymentId: string): void {
218
218
  success(`✓ Deployment #${deploymentId} is live!`);
219
219
  }
220
220
 
221
+ /**
222
+ * Show waiting for deployment promotion message
223
+ */
224
+ export function showWaitingForPromote(): void {
225
+ info("» Waiting for deployment to be promoted...");
226
+ }
227
+
228
+ /**
229
+ * Show deployment submitted message (used when --no-wait is set)
230
+ */
231
+ export function showDeploymentSubmitted(deploymentId: string, autoPromote: boolean): void {
232
+ const autoFrag = autoPromote
233
+ ? " It will be auto-promoted when ready."
234
+ : " It won't be auto-promoted when ready.";
235
+ success(`✓ Deployment #${deploymentId} submitted.${autoFrag}`);
236
+ }
237
+
238
+ /**
239
+ * Show deployment promoted message
240
+ */
241
+ export function showDeploymentPromoted(): void {
242
+ success("✓ Deployment promoted");
243
+ }
244
+
221
245
  /**
222
246
  * Show validating deployment message
223
247
  */
@@ -424,6 +448,9 @@ export const output = {
424
448
  showNoChanges,
425
449
  showWaitingForDeployment,
426
450
  showDeploymentReady,
451
+ showWaitingForPromote,
452
+ showDeploymentPromoted,
453
+ showDeploymentSubmitted,
427
454
  showDeploymentLive,
428
455
  showValidatingDeployment,
429
456
  showDeploySuccess,
@@ -86,25 +86,21 @@ export function createDeploySuccessResponse(options?: {
86
86
  export function createDeploymentStatusResponse(options?: {
87
87
  deploymentId?: string;
88
88
  status?: string;
89
+ live?: boolean;
89
90
  }) {
91
+ const status = options?.status ?? "data_ready";
90
92
  return {
91
93
  result: "success",
92
94
  deployment: {
93
95
  id: options?.deploymentId ?? "deploy-123",
94
- status: options?.status ?? "data_ready",
96
+ status,
97
+ // When the deployment is `data_ready` the default assumes the server
98
+ // has already auto-promoted it, which is what most tests exercise.
99
+ live: options?.live ?? status === "data_ready",
95
100
  },
96
101
  };
97
102
  }
98
103
 
99
- /**
100
- * Create set-live success response (for /v1/deployments/:id/set-live endpoint)
101
- */
102
- export function createSetLiveSuccessResponse() {
103
- return {
104
- result: "success",
105
- };
106
- }
107
-
108
104
  /**
109
105
  * Create deployments list response (for /v1/deployments endpoint)
110
106
  */