@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.
@@ -1,13 +1,13 @@
1
- import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
1
+ import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from "vitest";
2
2
  import { setupServer } from "msw/node";
3
3
  import { http, HttpResponse } from "msw";
4
- import { deployToMain } from "./deploy.js";
4
+
5
+ import { deployToMain, MAX_CONSECUTIVE_FAILED_POLLS } from "./deploy.js";
5
6
  import type { BuildConfig } from "./build.js";
6
7
  import {
7
8
  BASE_URL,
8
9
  createDeploySuccessResponse,
9
10
  createDeploymentStatusResponse,
10
- createSetLiveSuccessResponse,
11
11
  createBuildFailureResponse,
12
12
  createBuildMultipleErrorsResponse,
13
13
  createDeploymentsListResponse,
@@ -16,7 +16,12 @@ import type { GeneratedResources } from "../generator/index.js";
16
16
 
17
17
  const server = setupServer();
18
18
 
19
- beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
19
+ beforeAll(() => {
20
+ server.listen({ onUnhandledRequest: "error" });
21
+ vi.stubGlobal("setTimeout", (fn: () => void) => {
22
+ Promise.resolve().then(fn);
23
+ });
24
+ });
20
25
  beforeEach(() => {
21
26
  // Set up default handler for deployments list (used by stale deployment cleanup)
22
27
  server.use(
@@ -26,7 +31,10 @@ beforeEach(() => {
26
31
  );
27
32
  });
28
33
  afterEach(() => server.resetHandlers());
29
- afterAll(() => server.close());
34
+ afterAll(() => {
35
+ server.close();
36
+ vi.unstubAllGlobals();
37
+ });
30
38
 
31
39
  describe("Deploy API", () => {
32
40
  const config: BuildConfig = {
@@ -44,7 +52,9 @@ describe("Deploy API", () => {
44
52
  connections: [],
45
53
  };
46
54
 
47
- // Helper to set up successful deploy flow
55
+ // Helper to set up successful deploy flow. By default the deployment shows
56
+ // up as live on the very first status poll, which matches the server-side
57
+ // auto_promote=true behavior the SDK now relies on.
48
58
  function setupSuccessfulDeployFlow(deploymentId = "deploy-abc") {
49
59
  server.use(
50
60
  http.post(`${BASE_URL}/v1/deploy`, () => {
@@ -54,11 +64,12 @@ describe("Deploy API", () => {
54
64
  }),
55
65
  http.get(`${BASE_URL}/v1/deployments/${deploymentId}`, () => {
56
66
  return HttpResponse.json(
57
- createDeploymentStatusResponse({ deploymentId, status: "data_ready" })
67
+ createDeploymentStatusResponse({
68
+ deploymentId,
69
+ status: "data_ready",
70
+ live: true,
71
+ })
58
72
  );
59
- }),
60
- http.post(`${BASE_URL}/v1/deployments/${deploymentId}/set-live`, () => {
61
- return HttpResponse.json(createSetLiveSuccessResponse());
62
73
  })
63
74
  );
64
75
  }
@@ -67,7 +78,7 @@ describe("Deploy API", () => {
67
78
  it("successfully deploys resources with full flow", async () => {
68
79
  setupSuccessfulDeployFlow("deploy-abc");
69
80
 
70
- const result = await deployToMain(config, resources, { pollIntervalMs: 1 });
81
+ const result = await deployToMain(config, resources);
71
82
 
72
83
  expect(result.success).toBe(true);
73
84
  expect(result.result).toBe("success");
@@ -76,7 +87,7 @@ describe("Deploy API", () => {
76
87
  expect(result.pipeCount).toBe(1);
77
88
  });
78
89
 
79
- it("polls until deployment is ready", async () => {
90
+ it("polls until deployment is ready and live", async () => {
80
91
  let pollCount = 0;
81
92
 
82
93
  server.use(
@@ -87,21 +98,39 @@ describe("Deploy API", () => {
87
98
  }),
88
99
  http.get(`${BASE_URL}/v1/deployments/deploy-poll`, () => {
89
100
  pollCount++;
90
- // Return pending for first 2 polls, then data_ready
91
- const status = pollCount < 3 ? "pending" : "data_ready";
101
+ // pending data_ready (not live yet) data_ready + live.
102
+ if (pollCount < 3) {
103
+ return HttpResponse.json(
104
+ createDeploymentStatusResponse({
105
+ deploymentId: "deploy-poll",
106
+ status: "pending",
107
+ live: false,
108
+ })
109
+ );
110
+ }
111
+ if (pollCount === 3) {
112
+ return HttpResponse.json(
113
+ createDeploymentStatusResponse({
114
+ deploymentId: "deploy-poll",
115
+ status: "data_ready",
116
+ live: false,
117
+ })
118
+ );
119
+ }
92
120
  return HttpResponse.json(
93
- createDeploymentStatusResponse({ deploymentId: "deploy-poll", status })
121
+ createDeploymentStatusResponse({
122
+ deploymentId: "deploy-poll",
123
+ status: "data_ready",
124
+ live: true,
125
+ })
94
126
  );
95
- }),
96
- http.post(`${BASE_URL}/v1/deployments/deploy-poll/set-live`, () => {
97
- return HttpResponse.json(createSetLiveSuccessResponse());
98
127
  })
99
128
  );
100
129
 
101
- const result = await deployToMain(config, resources, { pollIntervalMs: 1 });
130
+ const result = await deployToMain(config, resources);
102
131
 
103
132
  expect(result.success).toBe(true);
104
- expect(pollCount).toBe(3);
133
+ expect(pollCount).toBe(4);
105
134
  });
106
135
 
107
136
  it("handles deploy failure with single error", async () => {
@@ -196,7 +225,7 @@ describe("Deploy API", () => {
196
225
  );
197
226
  });
198
227
 
199
- it("uses /v1/deploy endpoint (not /v1/build)", async () => {
228
+ it("uses /v1/deploy endpoint with auto_promote by default", async () => {
200
229
  let capturedUrl: string | null = null;
201
230
 
202
231
  server.use(
@@ -208,19 +237,81 @@ describe("Deploy API", () => {
208
237
  }),
209
238
  http.get(`${BASE_URL}/v1/deployments/deploy-url-test`, () => {
210
239
  return HttpResponse.json(
211
- createDeploymentStatusResponse({ deploymentId: "deploy-url-test", status: "data_ready" })
240
+ createDeploymentStatusResponse({
241
+ deploymentId: "deploy-url-test",
242
+ status: "data_ready",
243
+ live: true,
244
+ })
212
245
  );
213
- }),
214
- http.post(`${BASE_URL}/v1/deployments/deploy-url-test/set-live`, () => {
215
- return HttpResponse.json(createSetLiveSuccessResponse());
216
246
  })
217
247
  );
218
248
 
219
- await deployToMain(config, resources, { pollIntervalMs: 1 });
249
+ await deployToMain(config, resources);
220
250
 
221
251
  const parsed = new URL(capturedUrl ?? "");
222
252
  expect(parsed.pathname).toBe("/v1/deploy");
223
253
  expect(parsed.searchParams.get("from")).toBe("ts-sdk");
254
+ expect(parsed.searchParams.get("auto_promote")).toBe("true");
255
+ });
256
+
257
+ it("omits auto_promote when auto is false", async () => {
258
+ let capturedUrl: string | null = null;
259
+
260
+ server.use(
261
+ http.post(`${BASE_URL}/v1/deploy`, ({ request }) => {
262
+ capturedUrl = request.url;
263
+ return HttpResponse.json(
264
+ createDeploySuccessResponse({ deploymentId: "deploy-no-auto" })
265
+ );
266
+ }),
267
+ http.get(`${BASE_URL}/v1/deployments/deploy-no-auto`, () => {
268
+ return HttpResponse.json(
269
+ createDeploymentStatusResponse({
270
+ deploymentId: "deploy-no-auto",
271
+ status: "data_ready",
272
+ live: false,
273
+ })
274
+ );
275
+ })
276
+ );
277
+
278
+ const result = await deployToMain(config, resources, { auto: false });
279
+
280
+ const parsed = new URL(capturedUrl ?? "");
281
+ expect(parsed.searchParams.get("auto_promote")).toBeNull();
282
+ // When !auto, we return success as soon as the deployment is data_ready,
283
+ // even though `live` is still false (user must promote it separately).
284
+ expect(result.success).toBe(true);
285
+ expect(result.buildId).toBe("deploy-no-auto");
286
+ });
287
+
288
+ it("returns immediately when wait is false", async () => {
289
+ let statusPolls = 0;
290
+
291
+ server.use(
292
+ http.post(`${BASE_URL}/v1/deploy`, () => {
293
+ return HttpResponse.json(
294
+ createDeploySuccessResponse({ deploymentId: "deploy-no-wait" })
295
+ );
296
+ }),
297
+ http.get(`${BASE_URL}/v1/deployments/deploy-no-wait`, () => {
298
+ statusPolls++;
299
+ return HttpResponse.json(
300
+ createDeploymentStatusResponse({
301
+ deploymentId: "deploy-no-wait",
302
+ status: "pending",
303
+ live: false,
304
+ })
305
+ );
306
+ })
307
+ );
308
+
309
+ const result = await deployToMain(config, resources, { wait: false });
310
+
311
+ expect(result.success).toBe(true);
312
+ expect(result.buildId).toBe("deploy-no-wait");
313
+ // No polling should have happened.
314
+ expect(statusPolls).toBe(0);
224
315
  });
225
316
 
226
317
  it("passes allow_destructive_operations when explicitly enabled", async () => {
@@ -238,16 +329,13 @@ describe("Deploy API", () => {
238
329
  createDeploymentStatusResponse({
239
330
  deploymentId: "deploy-destructive",
240
331
  status: "data_ready",
332
+ live: true,
241
333
  })
242
334
  );
243
- }),
244
- http.post(`${BASE_URL}/v1/deployments/deploy-destructive/set-live`, () => {
245
- return HttpResponse.json(createSetLiveSuccessResponse());
246
335
  })
247
336
  );
248
337
 
249
338
  await deployToMain(config, resources, {
250
- pollIntervalMs: 1,
251
339
  allowDestructiveOperations: true,
252
340
  });
253
341
 
@@ -283,10 +371,7 @@ describe("Deploy API", () => {
283
371
  })
284
372
  );
285
373
 
286
- await deployToMain(config, resources, {
287
- pollIntervalMs: 1,
288
- check: true,
289
- });
374
+ await deployToMain(config, resources, { check: true });
290
375
 
291
376
  expect(listed).toBe(false);
292
377
  expect(deletedIds).toEqual([]);
@@ -316,12 +401,17 @@ describe("Deploy API", () => {
316
401
  );
317
402
  setupSuccessfulDeployFlow("deploy-cleanup");
318
403
 
319
- await deployToMain(config, resources, { pollIntervalMs: 1 });
404
+ await deployToMain(config, resources);
320
405
 
321
- expect(deletedIds).toEqual(["stale-1", "stale-2", "live-1"]);
406
+ // The previous live deployment is left alone — the server removes it
407
+ // as part of the auto-promotion once the new deployment is live.
408
+ expect(deletedIds).toEqual(["stale-1", "stale-2"]);
322
409
  });
323
410
 
324
- it("deletes the previous live deployment after promoting the new deployment", async () => {
411
+ it("does not touch the previous live deployment client-side", async () => {
412
+ // With auto_promote=true the server flips the new deployment live AND
413
+ // deletes the previous live deployment on our behalf. The SDK should
414
+ // therefore never issue a set-live or a delete for a live deployment.
325
415
  const events: string[] = [];
326
416
 
327
417
  server.use(
@@ -342,23 +432,23 @@ describe("Deploy API", () => {
342
432
  }),
343
433
  http.get(`${BASE_URL}/v1/deployments/new-deploy`, () => {
344
434
  return HttpResponse.json(
345
- createDeploymentStatusResponse({ deploymentId: "new-deploy", status: "data_ready" })
435
+ createDeploymentStatusResponse({
436
+ deploymentId: "new-deploy",
437
+ status: "data_ready",
438
+ live: true,
439
+ })
346
440
  );
347
441
  }),
348
- http.post(`${BASE_URL}/v1/deployments/new-deploy/set-live`, () => {
349
- events.push("set-live");
350
- return HttpResponse.json(createSetLiveSuccessResponse());
351
- }),
352
442
  http.delete(`${BASE_URL}/v1/deployments/:id`, ({ params }) => {
353
443
  events.push(`delete:${params.id as string}`);
354
444
  return HttpResponse.json({ result: "success" });
355
445
  })
356
446
  );
357
447
 
358
- const result = await deployToMain(config, resources, { pollIntervalMs: 1 });
448
+ const result = await deployToMain(config, resources);
359
449
 
360
450
  expect(result.success).toBe(true);
361
- expect(events).toEqual(["create", "set-live", "delete:previous-live"]);
451
+ expect(events).toEqual(["create"]);
362
452
  });
363
453
 
364
454
  it("adds actionable guidance to Forward/Classic workspace errors", async () => {
@@ -384,47 +474,77 @@ describe("Deploy API", () => {
384
474
  );
385
475
  });
386
476
 
387
- it("handles failed deployment status", async () => {
477
+ it("tolerates transient failed status while server auto-deletes", async () => {
478
+ // When the deployment hits `failed`, the SDK should not bail immediately
479
+ // — the server usually transitions it to `deleting`/`deleted` shortly
480
+ // after. We report the failure only when that transition happens.
481
+ let pollCount = 0;
388
482
  server.use(
389
483
  http.post(`${BASE_URL}/v1/deploy`, () => {
390
484
  return HttpResponse.json(
391
- createDeploySuccessResponse({ deploymentId: "deploy-fail", status: "pending" })
485
+ createDeploySuccessResponse({ deploymentId: "deploy-transient-fail" })
392
486
  );
393
487
  }),
394
- http.get(`${BASE_URL}/v1/deployments/deploy-fail`, () => {
395
- return HttpResponse.json(
396
- createDeploymentStatusResponse({ deploymentId: "deploy-fail", status: "failed" })
397
- );
488
+ http.get(`${BASE_URL}/v1/deployments/deploy-transient-fail`, () => {
489
+ pollCount++;
490
+ if (pollCount <= 2) {
491
+ return HttpResponse.json(
492
+ createDeploymentStatusResponse({
493
+ deploymentId: "deploy-transient-fail",
494
+ status: "failed",
495
+ live: false,
496
+ })
497
+ );
498
+ }
499
+ return HttpResponse.json({
500
+ result: "success",
501
+ deployment: {
502
+ id: "deploy-transient-fail",
503
+ status: "deleted",
504
+ live: false,
505
+ feedback: [
506
+ { resource: null, level: "ERROR", message: "schema conflict" },
507
+ ],
508
+ },
509
+ });
398
510
  })
399
511
  );
400
512
 
401
- const result = await deployToMain(config, resources, { pollIntervalMs: 1 });
513
+ const result = await deployToMain(config, resources);
402
514
 
403
515
  expect(result.success).toBe(false);
404
- expect(result.error).toContain("Deployment failed with status: failed");
516
+ expect(result.error).toContain("deleted automatically");
517
+ expect(result.error).toContain("schema conflict");
518
+ // 2 failed + 1 deleted.
519
+ expect(pollCount).toBe(3);
405
520
  });
406
521
 
407
- it("handles set-live failure", async () => {
522
+ it("bails when the deployment is stuck in failed state", async () => {
523
+ let pollCount = 0;
408
524
  server.use(
409
525
  http.post(`${BASE_URL}/v1/deploy`, () => {
410
526
  return HttpResponse.json(
411
- createDeploySuccessResponse({ deploymentId: "deploy-setlive-fail" })
527
+ createDeploySuccessResponse({ deploymentId: "deploy-stuck" })
412
528
  );
413
529
  }),
414
- http.get(`${BASE_URL}/v1/deployments/deploy-setlive-fail`, () => {
530
+ http.get(`${BASE_URL}/v1/deployments/deploy-stuck`, () => {
531
+ pollCount++;
415
532
  return HttpResponse.json(
416
- createDeploymentStatusResponse({ deploymentId: "deploy-setlive-fail", status: "data_ready" })
533
+ createDeploymentStatusResponse({
534
+ deploymentId: "deploy-stuck",
535
+ status: "failed",
536
+ live: false,
537
+ })
417
538
  );
418
- }),
419
- http.post(`${BASE_URL}/v1/deployments/deploy-setlive-fail/set-live`, () => {
420
- return HttpResponse.json({ error: "Set live failed" }, { status: 500 });
421
539
  })
422
540
  );
423
541
 
424
- const result = await deployToMain(config, resources, { pollIntervalMs: 1 });
542
+ const result = await deployToMain(config, resources);
425
543
 
426
544
  expect(result.success).toBe(false);
427
- expect(result.error).toContain("Failed to set deployment as live");
545
+ expect(result.error).toContain("didn't start deleting automatically");
546
+ // One extra poll past the threshold trips the safety valve.
547
+ expect(pollCount).toBe(MAX_CONSECUTIVE_FAILED_POLLS + 1);
428
548
  });
429
549
 
430
550
  it("normalizes baseUrl with trailing slash", async () => {
@@ -439,48 +559,22 @@ describe("Deploy API", () => {
439
559
  }),
440
560
  http.get(`${BASE_URL}/v1/deployments/deploy-slash`, () => {
441
561
  return HttpResponse.json(
442
- createDeploymentStatusResponse({ deploymentId: "deploy-slash", status: "data_ready" })
562
+ createDeploymentStatusResponse({
563
+ deploymentId: "deploy-slash",
564
+ status: "data_ready",
565
+ live: true,
566
+ })
443
567
  );
444
- }),
445
- http.post(`${BASE_URL}/v1/deployments/deploy-slash/set-live`, () => {
446
- return HttpResponse.json(createSetLiveSuccessResponse());
447
568
  })
448
569
  );
449
570
 
450
- await deployToMain(
451
- { ...config, baseUrl: `${BASE_URL}/` },
452
- resources,
453
- { pollIntervalMs: 1 }
454
- );
571
+ await deployToMain({ ...config, baseUrl: `${BASE_URL}/` }, resources);
455
572
 
456
573
  const parsed = new URL(capturedUrl ?? "");
457
574
  expect(parsed.pathname).toBe("/v1/deploy");
458
575
  expect(parsed.searchParams.get("from")).toBe("ts-sdk");
459
576
  });
460
577
 
461
- it("times out when deployment never becomes ready", async () => {
462
- server.use(
463
- http.post(`${BASE_URL}/v1/deploy`, () => {
464
- return HttpResponse.json(
465
- createDeploySuccessResponse({ deploymentId: "deploy-timeout", status: "pending" })
466
- );
467
- }),
468
- http.get(`${BASE_URL}/v1/deployments/deploy-timeout`, () => {
469
- return HttpResponse.json(
470
- createDeploymentStatusResponse({ deploymentId: "deploy-timeout", status: "pending" })
471
- );
472
- })
473
- );
474
-
475
- const result = await deployToMain(config, resources, {
476
- pollIntervalMs: 1,
477
- maxPollAttempts: 3,
478
- });
479
-
480
- expect(result.success).toBe(false);
481
- expect(result.error).toContain("Deployment timed out");
482
- });
483
-
484
578
  it("includes connections in deploy form data", async () => {
485
579
  const resourcesWithConnections: GeneratedResources = {
486
580
  ...resources,
@@ -503,17 +597,16 @@ describe("Deploy API", () => {
503
597
  }),
504
598
  http.get(`${BASE_URL}/v1/deployments/deploy-conn`, () => {
505
599
  return HttpResponse.json(
506
- createDeploymentStatusResponse({ deploymentId: "deploy-conn", status: "data_ready" })
600
+ createDeploymentStatusResponse({
601
+ deploymentId: "deploy-conn",
602
+ status: "data_ready",
603
+ live: true,
604
+ })
507
605
  );
508
- }),
509
- http.post(`${BASE_URL}/v1/deployments/deploy-conn/set-live`, () => {
510
- return HttpResponse.json(createSetLiveSuccessResponse());
511
606
  })
512
607
  );
513
608
 
514
- const result = await deployToMain(config, resourcesWithConnections, {
515
- pollIntervalMs: 1,
516
- });
609
+ const result = await deployToMain(config, resourcesWithConnections);
517
610
 
518
611
  expect(result.success).toBe(true);
519
612
  expect(result.connectionCount).toBe(1);