diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a99e28f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.0.78] - 2026-06-15 + +### Changed + +- Updated Tinybird CLI behavior to match the `4.6.1` branch-management changes. +- Branch data config handling now uses `branch_data_mode`; legacy `branch_data_on_create` now triggers an explicit migration error. +- `branch_data_mode` now only accepts `last_partition` as a user-facing value. +- In local development mode, branch data mode warnings are now shown only when `branch_data_mode` is explicitly set in `tinybird.config.json`. +- `tinybird branch create` and `tinybird branch clear` now show a deprecation warning (instead of failing) when `--ignore-datasource` is passed, then continue by ignoring that flag. diff --git a/package.json b/package.json index 57a499f..775cc38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tinybirdco/sdk", - "version": "0.0.77", + "version": "0.0.78", "description": "TypeScript SDK for Tinybird Forward - define datasources and pipes as TypeScript", "type": "module", "main": "./dist/index.js", diff --git a/src/api/branches.test.ts b/src/api/branches.test.ts index 7027101..fa239d4 100644 --- a/src/api/branches.test.ts +++ b/src/api/branches.test.ts @@ -189,6 +189,40 @@ describe("Branch API client", () => { ); }); + it("uses data=last_partition wire format when branch_data_mode is set", async () => { + const mockBranch = { + id: "branch-123", + name: "my-feature", + token: "p.branch-token", + created_at: "2024-01-01T00:00:00Z", + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + job: { id: "job-123", status: "waiting" }, + workspace: { id: "ws-123" }, + }), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: "job-123", status: "done" }), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockBranch), + }); + + await createBranch(config, "my-feature", { + branch_data_mode: "last_partition", + }); + + const [createUrl] = mockFetch.mock.calls[0]; + const createParsed = expectFromParam(createUrl); + expect(createParsed.searchParams.get("name")).toBe("my-feature"); + expect(createParsed.searchParams.get("data")).toBe("last_partition"); + }); + it("uses custom fetch when provided", async () => { const customFetch = vi .fn() @@ -494,7 +528,9 @@ describe("Branch API client", () => { json: () => Promise.resolve(newBranch), }); - const result = await clearBranch(config, "my-feature"); + const result = await clearBranch(config, "my-feature", { + branch_data_mode: "last_partition", + }); expect(mockFetch).toHaveBeenCalledTimes(5); @@ -515,6 +551,7 @@ describe("Branch API client", () => { const createParsed = expectFromParam(createUrl); expect(createParsed.pathname).toBe("/v1/environments"); expect(createParsed.searchParams.get("name")).toBe("my-feature"); + expect(createParsed.searchParams.get("data")).toBe("last_partition"); expect(createInit.method).toBe("POST"); expect(result).toEqual(newBranch); diff --git a/src/api/branches.ts b/src/api/branches.ts index 439e382..ecfff37 100644 --- a/src/api/branches.ts +++ b/src/api/branches.ts @@ -3,6 +3,7 @@ * Uses the /v1/environments endpoints (Forward API) */ +import type { BranchDataMode } from "../cli/config-types.js"; import { createTinybirdFetcher } from "./fetcher.js"; /** @@ -152,8 +153,8 @@ async function pollJob( * @returns The created branch with token */ export interface CreateBranchOptions { - /** Copy the last partition of production data into the branch */ - lastPartition?: boolean; + /** Data mode applied when creating the branch */ + branch_data_mode?: BranchDataMode; } export async function createBranch( @@ -164,8 +165,8 @@ export async function createBranch( const fetchFn = getFetch(config); const url = new URL("/v1/environments", config.baseUrl); url.searchParams.set("name", name); - if (options?.lastPartition) { - url.searchParams.set("last_partition", "1"); + if (options?.branch_data_mode) { + url.searchParams.set("data", options.branch_data_mode); } const debug = !!process.env.TINYBIRD_DEBUG; @@ -377,13 +378,14 @@ export async function getOrCreateBranch( */ export async function clearBranch( config: BranchApiConfig, - name: string + name: string, + options?: CreateBranchOptions ): Promise { // Delete the branch await deleteBranch(config, name); // Recreate the branch - const branch = await createBranch(config, name); + const branch = await createBranch(config, name, options); return branch; } diff --git a/src/cli/commands/build.test.ts b/src/cli/commands/build.test.ts index 5e1e957..48e95f4 100644 --- a/src/cli/commands/build.test.ts +++ b/src/cli/commands/build.test.ts @@ -64,6 +64,7 @@ describe("Build Command", () => { gitBranch: "feature-test", tinybirdBranch: "feature_test", isMainBranch: false, + branchDataMode: null, }); vi.mocked(buildFromInclude).mockResolvedValue({ @@ -156,6 +157,7 @@ describe("Build Command", () => { gitBranch: "feature-test", tinybirdBranch: "feature_test", isMainBranch: false, + branchDataMode: null, }); vi.mocked(buildFromInclude).mockResolvedValue({ @@ -242,6 +244,7 @@ describe("Build Command", () => { gitBranch: "feature-test", tinybirdBranch: "feature_test", isMainBranch: false, + branchDataMode: null, }); vi.mocked(buildFromInclude).mockResolvedValue({ @@ -307,4 +310,177 @@ describe("Build Command", () => { expect(getLocalTokens).toHaveBeenCalled(); }); }); + + describe("branch_data_mode wiring", () => { + it("uses config-only last_partition when flag is absent", async () => { + const { loadConfigAsync } = await import("../config.js"); + const { buildFromInclude } = await import("../../generator/index.js"); + const { getOrCreateBranch } = await import("../../api/branches.js"); + const { buildToTinybird } = await import("../../api/build.js"); + const { getWorkspace } = await import("../../api/workspaces.js"); + const { getBranchDashboardUrl } = await import("../../api/dashboard.js"); + + vi.mocked(loadConfigAsync).mockResolvedValue({ + include: ["test.ts"], + token: "p.test-token", + baseUrl: "https://api.tinybird.co", + configPath: "/test/tinybird.config.json", + devMode: "branch", + cwd: "/test", + gitBranch: "feature-test", + tinybirdBranch: "feature_test", + isMainBranch: false, + branchDataMode: "last_partition", + }); + vi.mocked(buildFromInclude).mockResolvedValue({ + resources: { datasources: [], pipes: [], connections: [] }, + entities: { datasources: {}, pipes: {}, connections: {}, rawDatasources: [], rawPipes: [], sourceFiles: [] }, + stats: { datasourceCount: 0, pipeCount: 0, connectionCount: 0 }, + }); + vi.mocked(getOrCreateBranch).mockResolvedValue({ + id: "branch-id", + name: "feature_test", + token: "branch-token", + wasCreated: false, + created_at: "2024-01-01", + }); + vi.mocked(getWorkspace).mockResolvedValue({ + id: "ws-id", + name: "test-workspace", + user_id: "user-id", + user_email: "user@test.com", + scope: "USER", + main: null, + }); + vi.mocked(getBranchDashboardUrl).mockReturnValue("https://app.tinybird.co/dashboard"); + vi.mocked(buildToTinybird).mockResolvedValue({ + success: true, + result: "success", + datasourceCount: 0, + pipeCount: 0, + connectionCount: 0, + }); + + await runBuild(); + expect(getOrCreateBranch).toHaveBeenCalledWith( + expect.any(Object), + "feature_test", + { branch_data_mode: "last_partition" } + ); + }); + + it("keeps CLI --last-partition precedence over config", async () => { + const { loadConfigAsync } = await import("../config.js"); + const { buildFromInclude } = await import("../../generator/index.js"); + const { getOrCreateBranch } = await import("../../api/branches.js"); + const { buildToTinybird } = await import("../../api/build.js"); + const { getWorkspace } = await import("../../api/workspaces.js"); + const { getBranchDashboardUrl } = await import("../../api/dashboard.js"); + + vi.mocked(loadConfigAsync).mockResolvedValue({ + include: ["test.ts"], + token: "p.test-token", + baseUrl: "https://api.tinybird.co", + configPath: "/test/tinybird.config.json", + devMode: "branch", + cwd: "/test", + gitBranch: "feature-test", + tinybirdBranch: "feature_test", + isMainBranch: false, + branchDataMode: null, + }); + vi.mocked(buildFromInclude).mockResolvedValue({ + resources: { datasources: [], pipes: [], connections: [] }, + entities: { datasources: {}, pipes: {}, connections: {}, rawDatasources: [], rawPipes: [], sourceFiles: [] }, + stats: { datasourceCount: 0, pipeCount: 0, connectionCount: 0 }, + }); + vi.mocked(getOrCreateBranch).mockResolvedValue({ + id: "branch-id", + name: "feature_test", + token: "branch-token", + wasCreated: false, + created_at: "2024-01-01", + }); + vi.mocked(getWorkspace).mockResolvedValue({ + id: "ws-id", + name: "test-workspace", + user_id: "user-id", + user_email: "user@test.com", + scope: "USER", + main: null, + }); + vi.mocked(getBranchDashboardUrl).mockReturnValue("https://app.tinybird.co/dashboard"); + vi.mocked(buildToTinybird).mockResolvedValue({ + success: true, + result: "success", + datasourceCount: 0, + pipeCount: 0, + connectionCount: 0, + }); + + await runBuild({ lastPartition: true }); + expect(getOrCreateBranch).toHaveBeenCalledWith( + expect.any(Object), + "feature_test", + { branch_data_mode: "last_partition" } + ); + }); + + it("ignores config branch_data_mode in local mode", async () => { + const { loadConfigAsync } = await import("../config.js"); + const { buildFromInclude } = await import("../../generator/index.js"); + const { getOrCreateBranch } = await import("../../api/branches.js"); + const { getLocalTokens, getOrCreateLocalWorkspace, getLocalWorkspaceName } = await import("../../api/local.js"); + const { buildToTinybird } = await import("../../api/build.js"); + const { getWorkspace } = await import("../../api/workspaces.js"); + const { getLocalDashboardUrl } = await import("../../api/dashboard.js"); + + vi.mocked(loadConfigAsync).mockResolvedValue({ + include: ["test.ts"], + token: "p.test-token", + baseUrl: "https://api.tinybird.co", + configPath: "/test/tinybird.config.json", + devMode: "local", + cwd: "/test", + gitBranch: "feature-test", + tinybirdBranch: "feature_test", + isMainBranch: false, + branchDataMode: "last_partition", + }); + vi.mocked(buildFromInclude).mockResolvedValue({ + resources: { datasources: [], pipes: [], connections: [] }, + entities: { datasources: {}, pipes: {}, connections: {}, rawDatasources: [], rawPipes: [], sourceFiles: [] }, + stats: { datasourceCount: 0, pipeCount: 0, connectionCount: 0 }, + }); + vi.mocked(getLocalTokens).mockResolvedValue({ + admin_token: "admin-token", + user_token: "user-token", + workspace_admin_token: "workspace-admin-token", + }); + vi.mocked(getWorkspace).mockResolvedValue({ + id: "ws-id", + name: "test-workspace", + user_id: "user-id", + user_email: "user@test.com", + scope: "USER", + main: null, + }); + vi.mocked(getLocalWorkspaceName).mockReturnValue("feature_test_workspace"); + vi.mocked(getOrCreateLocalWorkspace).mockResolvedValue({ + workspace: { id: "local-ws-id", name: "feature_test_workspace", token: "local-token" }, + wasCreated: false, + }); + vi.mocked(getLocalDashboardUrl).mockReturnValue("http://localhost:7181/dashboard"); + vi.mocked(buildToTinybird).mockResolvedValue({ + success: true, + result: "success", + datasourceCount: 0, + pipeCount: 0, + connectionCount: 0, + }); + + await runBuild(); + expect(getOrCreateBranch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/cli/commands/build.ts b/src/cli/commands/build.ts index 842123f..a23414b 100644 --- a/src/cli/commands/build.ts +++ b/src/cli/commands/build.ts @@ -2,7 +2,7 @@ * Build command - generates and pushes resources to Tinybird branches */ -import { loadConfigAsync, LOCAL_BASE_URL, type ResolvedConfig, type DevMode } from "../config.js"; +import { loadConfigAsync, LOCAL_BASE_URL, type ResolvedConfig, type DevMode, type BranchDataMode } from "../config.js"; import { buildFromInclude, type BuildFromIncludeResult } from "../../generator/index.js"; import { buildToTinybird, type BuildApiResult } from "../../api/build.js"; import { getOrCreateBranch } from "../../api/branches.js"; @@ -225,13 +225,20 @@ export async function runBuild(options: BuildCommandOptions = {}): Promise { }); // Clear the branch (delete and recreate) + const branchOptions: CreateBranchOptions | undefined = + config.devMode !== "local" && config.branchDataMode === "last_partition" + ? { branch_data_mode: "last_partition" } + : undefined; + const newBranch = await clearBranch( { baseUrl: config.baseUrl, token: config.token, }, - branchName + branchName, + branchOptions ); // Update the cached token with the new branch token diff --git a/src/cli/commands/deploy.test.ts b/src/cli/commands/deploy.test.ts index fc54a7c..9f9f390 100644 --- a/src/cli/commands/deploy.test.ts +++ b/src/cli/commands/deploy.test.ts @@ -37,6 +37,7 @@ describe("Deploy command", () => { tinybirdBranch: "feature_pro_610", isMainBranch: false, devMode: "branch", + branchDataMode: null, }); vi.mocked(buildFromInclude).mockResolvedValue({ diff --git a/src/cli/commands/dev.ts b/src/cli/commands/dev.ts index b07d82e..493a0d8 100644 --- a/src/cli/commands/dev.ts +++ b/src/cli/commands/dev.ts @@ -13,6 +13,7 @@ import { LOCAL_BASE_URL, type ResolvedConfig, type DevMode, + type BranchDataMode, } from "../config.js"; import { runBuild, type BuildCommandResult } from "./build.js"; import { getOrCreateBranch, type TinybirdBranch } from "../../api/branches.js"; @@ -239,6 +240,13 @@ export async function runDev( // Use tinybirdBranch (sanitized name) for Tinybird API, gitBranch for display if (config.tinybirdBranch) { const branchName = config.tinybirdBranch; // Sanitized name for Tinybird + const branchDataMode: BranchDataMode | undefined = + options.lastPartition || config.branchDataMode === "last_partition" + ? "last_partition" + : undefined; + const branchOptions = branchDataMode + ? { branch_data_mode: branchDataMode } + : undefined; // Always fetch fresh from API to avoid stale cache issues const tinybirdBranch = await getOrCreateBranch( @@ -247,7 +255,7 @@ export async function runDev( token: config.token, }, branchName, - { lastPartition: options.lastPartition } + branchOptions ); if (!tinybirdBranch.token) { diff --git a/src/cli/commands/generate.test.ts b/src/cli/commands/generate.test.ts index c0e0918..ece239c 100644 --- a/src/cli/commands/generate.test.ts +++ b/src/cli/commands/generate.test.ts @@ -35,6 +35,7 @@ describe("Generate command", () => { tinybirdBranch: "feature_x", isMainBranch: false, devMode: "branch", + branchDataMode: null, }); vi.mocked(buildFromInclude).mockResolvedValue({ @@ -108,6 +109,7 @@ describe("Generate command", () => { tinybirdBranch: "feature_x", isMainBranch: false, devMode: "branch", + branchDataMode: null, }); vi.mocked(buildFromInclude).mockResolvedValue({ @@ -167,6 +169,7 @@ describe("Generate command", () => { tinybirdBranch: "feature_x", isMainBranch: false, devMode: "branch", + branchDataMode: null, }); vi.mocked(buildFromInclude).mockRejectedValue( new Error("generator failed") diff --git a/src/cli/commands/preview.test.ts b/src/cli/commands/preview.test.ts index e2f072e..12deaae 100644 --- a/src/cli/commands/preview.test.ts +++ b/src/cli/commands/preview.test.ts @@ -1,7 +1,45 @@ -import { describe, it, expect } from "vitest"; -import { generatePreviewBranchName } from "./preview.js"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { generatePreviewBranchName, runPreview } from "./preview.js"; + +vi.mock("../config.js", () => ({ + loadConfigAsync: vi.fn(), + LOCAL_BASE_URL: "http://localhost:7181", +})); + +vi.mock("../../generator/index.js", () => ({ + buildFromInclude: vi.fn(), +})); + +vi.mock("../../api/branches.js", () => ({ + createBranch: vi.fn(), + deleteBranch: vi.fn(), + getBranch: vi.fn(), +})); + +vi.mock("../../api/deploy.js", () => ({ + deployToMain: vi.fn(), +})); + +vi.mock("../../api/build.js", () => ({ + buildToTinybird: vi.fn(), +})); + +vi.mock("../../api/local.js", () => ({ + getLocalTokens: vi.fn(), + getOrCreateLocalWorkspace: vi.fn(), + LocalNotRunningError: class LocalNotRunningError extends Error {}, +})); + +vi.mock("../git.js", () => ({ + sanitizeBranchName: (value: string) => value.replace(/[^a-zA-Z0-9]/g, "_"), + getCurrentGitBranch: vi.fn(() => "feature/test"), +})); describe("Preview command", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe("generatePreviewBranchName", () => { it("generates name with tmp_ci prefix", () => { const result = generatePreviewBranchName("feature-branch"); @@ -34,4 +72,97 @@ describe("Preview command", () => { expect(result1).toBe(result2); }); }); + + describe("branch_data_mode wiring", () => { + it("uses config-only last_partition when creating cloud preview branch", async () => { + const { loadConfigAsync } = await import("../config.js"); + const { buildFromInclude } = await import("../../generator/index.js"); + const { getBranch, createBranch } = await import("../../api/branches.js"); + const { deployToMain } = await import("../../api/deploy.js"); + + vi.mocked(loadConfigAsync).mockResolvedValue({ + include: ["test.ts"], + token: "p.test-token", + baseUrl: "https://api.tinybird.co", + configPath: "/test/tinybird.config.json", + devMode: "branch", + cwd: "/test", + gitBranch: "feature-test", + tinybirdBranch: "feature_test", + isMainBranch: false, + branchDataMode: "last_partition", + }); + vi.mocked(buildFromInclude).mockResolvedValue({ + resources: { datasources: [], pipes: [], connections: [] }, + entities: { datasources: {}, pipes: {}, connections: {}, rawDatasources: [], rawPipes: [], sourceFiles: [] }, + stats: { datasourceCount: 0, pipeCount: 0, connectionCount: 0 }, + }); + vi.mocked(getBranch).mockRejectedValue(new Error("not found")); + vi.mocked(createBranch).mockResolvedValue({ + id: "b1", + name: "tmp_ci_feature_test", + token: "p.branch", + created_at: "2024-01-01T00:00:00Z", + }); + vi.mocked(deployToMain).mockResolvedValue({ + success: true, + result: "success", + datasourceCount: 0, + pipeCount: 0, + connectionCount: 0, + }); + + await runPreview(); + expect(createBranch).toHaveBeenCalledWith( + expect.any(Object), + "tmp_ci_feature_test", + { branch_data_mode: "last_partition" } + ); + }); + + it("ignores config branch_data_mode in local mode", async () => { + const { loadConfigAsync } = await import("../config.js"); + const { buildFromInclude } = await import("../../generator/index.js"); + const { createBranch } = await import("../../api/branches.js"); + const { getLocalTokens, getOrCreateLocalWorkspace } = await import("../../api/local.js"); + const { buildToTinybird } = await import("../../api/build.js"); + + vi.mocked(loadConfigAsync).mockResolvedValue({ + include: ["test.ts"], + token: "p.test-token", + baseUrl: "https://api.tinybird.co", + configPath: "/test/tinybird.config.json", + devMode: "local", + cwd: "/test", + gitBranch: "feature-test", + tinybirdBranch: "feature_test", + isMainBranch: false, + branchDataMode: "last_partition", + }); + vi.mocked(buildFromInclude).mockResolvedValue({ + resources: { datasources: [], pipes: [], connections: [] }, + entities: { datasources: {}, pipes: {}, connections: {}, rawDatasources: [], rawPipes: [], sourceFiles: [] }, + stats: { datasourceCount: 0, pipeCount: 0, connectionCount: 0 }, + }); + vi.mocked(getLocalTokens).mockResolvedValue({ + admin_token: "admin-token", + user_token: "user-token", + workspace_admin_token: "workspace-admin-token", + }); + vi.mocked(getOrCreateLocalWorkspace).mockResolvedValue({ + workspace: { id: "lw1", name: "tmp_ci_feature_test", token: "local-token" }, + wasCreated: true, + }); + vi.mocked(buildToTinybird).mockResolvedValue({ + success: true, + result: "success", + datasourceCount: 0, + pipeCount: 0, + connectionCount: 0, + }); + + await runPreview(); + expect(createBranch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/cli/commands/preview.ts b/src/cli/commands/preview.ts index 5eda94a..42c0f6f 100644 --- a/src/cli/commands/preview.ts +++ b/src/cli/commands/preview.ts @@ -4,7 +4,7 @@ import { loadConfigAsync, LOCAL_BASE_URL, type ResolvedConfig, type DevMode } from "../config.js"; import { buildFromInclude, type BuildFromIncludeResult } from "../../generator/index.js"; -import { createBranch, deleteBranch, getBranch, type TinybirdBranch } from "../../api/branches.js"; +import { createBranch, deleteBranch, getBranch, type CreateBranchOptions, type TinybirdBranch } from "../../api/branches.js"; import { deployToMain } from "../../api/deploy.js"; import { buildToTinybird } from "../../api/build.js"; import { @@ -226,6 +226,10 @@ export async function runPreview(options: PreviewCommandOptions = {}): Promise

{ expect(result.devMode).toBe("local"); }); + + it("resolves branch_data_mode as last_partition", () => { + const config = { + include: ["lib/datasources.ts"], + token: "test-token", + branch_data_mode: "last_partition", + }; + fs.writeFileSync( + path.join(tempDir, "tinybird.json"), + JSON.stringify(config) + ); + + const result = loadConfig(tempDir); + expect(result.branchDataMode).toBe("last_partition"); + }); + + it("defaults branch_data_mode to last_partition when missing", () => { + const config = { + include: ["lib/datasources.ts"], + token: "test-token", + }; + fs.writeFileSync( + path.join(tempDir, "tinybird.json"), + JSON.stringify(config) + ); + + const result = loadConfig(tempDir); + expect(result.branchDataMode).toBe("last_partition"); + }); + + it("defaults empty branch_data_mode to last_partition", () => { + const config = { + include: ["lib/datasources.ts"], + token: "test-token", + branch_data_mode: " ", + }; + fs.writeFileSync( + path.join(tempDir, "tinybird.json"), + JSON.stringify(config) + ); + + const result = loadConfig(tempDir); + expect(result.branchDataMode).toBe("last_partition"); + }); + + it("throws when branch_data_mode is all_partitions", () => { + const config = { + include: ["lib/datasources.ts"], + token: "test-token", + branch_data_mode: "all_partitions", + }; + fs.writeFileSync( + path.join(tempDir, "tinybird.json"), + JSON.stringify(config) + ); + + expect(() => loadConfig(tempDir)).toThrow("Invalid branch_data_mode"); + }); + + it("throws when branch_data_mode is invalid", () => { + const config = { + include: ["lib/datasources.ts"], + token: "test-token", + branch_data_mode: "invalid", + }; + fs.writeFileSync( + path.join(tempDir, "tinybird.json"), + JSON.stringify(config) + ); + + expect(() => loadConfig(tempDir)).toThrow("Invalid branch_data_mode"); + }); + + it("warns when branch_data_mode is set with devMode local", () => { + const config = { + include: ["lib/datasources.ts"], + token: "test-token", + devMode: "local", + branch_data_mode: "last_partition", + }; + fs.writeFileSync( + path.join(tempDir, "tinybird.json"), + JSON.stringify(config) + ); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + loadConfig(tempDir); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("branch_data_mode is set") + ); + }); + + it("does not warn when branch_data_mode is implicit in local mode", () => { + const config = { + include: ["lib/datasources.ts"], + token: "test-token", + devMode: "local", + }; + fs.writeFileSync( + path.join(tempDir, "tinybird.json"), + JSON.stringify(config) + ); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + loadConfig(tempDir); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("throws when legacy branch_data_on_create key is used", () => { + const config = { + include: ["lib/datasources.ts"], + token: "test-token", + branch_data_on_create: "last_partition", + }; + fs.writeFileSync( + path.join(tempDir, "tinybird.json"), + JSON.stringify(config) + ); + + expect(() => loadConfig(tempDir)).toThrow("renamed to `branch_data_mode`"); + }); }); describe("loadConfigAsync", () => { diff --git a/src/cli/config.ts b/src/cli/config.ts index 569a08c..0f1e2ad 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -7,9 +7,17 @@ import * as path from "path"; import { config as loadDotenv } from "dotenv"; import { getCurrentGitBranch, isMainBranch, getTinybirdBranchName } from "./git.js"; -// Re-export types from config-types.ts (separate file to avoid bundling esbuild) -export type { DevMode, TinybirdConfig } from "./config-types.js"; -import type { DevMode, TinybirdConfig } from "./config-types.js"; +// Re-export config types/constants from config-types.ts (separate file to avoid bundling esbuild) +export { + BRANCH_DATA_MODE_VALUES, + type BranchDataMode, + type DevMode, + type TinybirdConfig, +} from "./config-types.js"; +import { BRANCH_DATA_MODE_VALUES } from "./config-types.js"; +import type { BranchDataMode, DevMode, TinybirdConfig } from "./config-types.js"; + +const DEFAULT_BRANCH_DATA_MODE: BranchDataMode = "last_partition"; /** * Resolved configuration with all values expanded @@ -33,6 +41,8 @@ export interface ResolvedConfig { isMainBranch: boolean; /** Development mode: "branch" or "local" */ devMode: DevMode; + /** Branch data mode configured in tinybird.config.json */ + branchDataMode?: BranchDataMode | null; } /** @@ -196,6 +206,24 @@ export function findConfigFile(startDir: string): ConfigFileResult | null { // Import the universal config loader import { loadConfigFile } from "./config-loader.js"; +function resolveBranchDataMode(raw: Record): { mode: BranchDataMode | null; explicit: boolean } { + if (raw["branch_data_on_create"] !== undefined) { + throw new Error("`branch_data_on_create` has been renamed to `branch_data_mode`."); + } + + const value = raw["branch_data_mode"]; + if (value === undefined || value === null) return { mode: DEFAULT_BRANCH_DATA_MODE, explicit: false }; + if (typeof value !== "string") throw new Error("branch_data_mode must be a string."); + const mode = value.trim().toLowerCase(); + if (!mode) return { mode: DEFAULT_BRANCH_DATA_MODE, explicit: false }; + if (!BRANCH_DATA_MODE_VALUES.includes(mode as BranchDataMode)) { + throw new Error( + `Invalid branch_data_mode '${value}'. Allowed values are: ${BRANCH_DATA_MODE_VALUES.join(", ")}.` + ); + } + return { mode: mode as BranchDataMode, explicit: true }; +} + /** * Resolve a TinybirdConfig to a ResolvedConfig */ @@ -255,6 +283,15 @@ function resolveConfig(config: TinybirdConfig, configPath: string): ResolvedConf // Resolve devMode (default to "branch") const devMode: DevMode = config.devMode ?? "branch"; + const { mode: branchDataMode, explicit: branchDataModeExplicit } = resolveBranchDataMode( + config as unknown as Record + ); + if (branchDataModeExplicit && branchDataMode && devMode === "local") { + console.warn( + "branch_data_mode is set in tinybird.config.json but dev_mode='local'. " + + "Branch data settings only apply to cloud branches." + ); + } return { include, @@ -266,6 +303,7 @@ function resolveConfig(config: TinybirdConfig, configPath: string): ResolvedConf tinybirdBranch, isMainBranch: isMainBranch(), devMode, + branchDataMode, }; } diff --git a/src/cli/index.ts b/src/cli/index.ts index fa5a24a..6d0f6f3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -270,7 +270,10 @@ function createCli(): Command { .option("--debug", "Show debug output including API requests/responses") .option("--local", "Use local Tinybird container") .option("--branch", "Use Tinybird cloud with branches") - .option("--last-partition", "Copy the last partition of production data when creating a branch") + .option( + "--last-partition", + '[DEPRECATED] Use `branch_data_mode: "last_partition"` in tinybird.config.json. Copy the last partition of production data when creating a cloud branch.' + ) .action(async (options) => { if (options.debug) { process.env.TINYBIRD_DEBUG = "1"; @@ -679,7 +682,10 @@ function createCli(): Command { .description("Watch for changes and sync with Tinybird") .option("--local", "Use local Tinybird container") .option("--branch", "Use Tinybird cloud with branches") - .option("--last-partition", "Copy the last partition of production data when creating a branch") + .option( + "--last-partition", + '[DEPRECATED] Use `branch_data_mode: "last_partition"` in tinybird.config.json. Copy the last partition of production data when creating a cloud branch.' + ) .action(async (options) => { // Determine devMode override let devModeOverride: DevMode | undefined; diff --git a/src/client/base.test.ts b/src/client/base.test.ts index 00cc1b2..697aca9 100644 --- a/src/client/base.test.ts +++ b/src/client/base.test.ts @@ -167,6 +167,7 @@ describe("TinybirdClient", () => { tinybirdBranch: "feature_add_fetch", isMainBranch: false, devMode: "branch", + branchDataMode: null, }); mockedGetOrCreateBranch.mockResolvedValue({ id: "branch-123", @@ -192,7 +193,8 @@ describe("TinybirdClient", () => { token: "workspace-token", fetch: customFetch, }, - "feature_add_fetch" + "feature_add_fetch", + undefined ); expect(context.token).toBe("branch-token"); expect(context.isBranchToken).toBe(true); diff --git a/src/client/base.ts b/src/client/base.ts index 5bb8e76..e453338 100644 --- a/src/client/base.ts +++ b/src/client/base.ts @@ -19,6 +19,7 @@ import type { } from "./types.js"; import { TinybirdError } from "./types.js"; import { TinybirdApi, TinybirdApiError } from "../api/api.js"; +import type { CreateBranchOptions } from "../api/branches.js"; import { TokensNamespace } from "./tokens.js"; /** @@ -340,6 +341,10 @@ export class TinybirdClient { } const branchName = config.tinybirdBranch; + const branchOptions: CreateBranchOptions | undefined = + config.devMode !== "local" && config.branchDataMode === "last_partition" + ? { branch_data_mode: "last_partition" } + : undefined; // Get or create branch (always fetch fresh to avoid stale cache issues) const branch = await getOrCreateBranch( @@ -348,7 +353,8 @@ export class TinybirdClient { token: this.config.token, fetch: this.config.fetch, }, - branchName + branchName, + branchOptions ); if (!branch.token) { diff --git a/src/index.ts b/src/index.ts index 0e9ce9a..8c7e200 100644 --- a/src/index.ts +++ b/src/index.ts @@ -312,4 +312,4 @@ export type { // ============ Config Types ============ // Import from config-types.ts to avoid bundling esbuild in client code -export type { TinybirdConfig, DevMode } from "./cli/config-types.js"; +export type { TinybirdConfig, DevMode, BranchDataMode } from "./cli/config-types.js";