diff --git a/README.md b/README.md index 821c9fa..a6d9588 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,13 @@ export const tinybird = new Tinybird({ pipes: { topPages }, }); +// Optional: customize fetch for framework-specific cache or runtime behavior +export const tinybirdNoStore = new Tinybird({ + datasources: { pageViews }, + pipes: { topPages }, + fetch: (url, init) => fetch(url, { ...init, cache: "no-store" }), +}); + // Re-export types for convenience export type { PageViewsRow, TopPagesParams, TopPagesOutput }; export { pageViews, topPages }; diff --git a/package.json b/package.json index 0795923..c147a7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tinybirdco/sdk", - "version": "0.0.64", + "version": "0.0.65", "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 9d0b17e..7027101 100644 --- a/src/api/branches.test.ts +++ b/src/api/branches.test.ts @@ -188,6 +188,45 @@ describe("Branch API client", () => { BranchApiError ); }); + + it("uses custom fetch when provided", async () => { + const customFetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + job: { id: "job-123", status: "waiting" }, + workspace: { id: "ws-123" }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: "job-123", status: "done" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: "branch-123", + name: "my-feature", + token: "p.branch-token", + created_at: "2024-01-01T00:00:00Z", + }), + }); + + const result = await createBranch( + { + ...config, + fetch: customFetch as typeof fetch, + }, + "my-feature" + ); + + expect(customFetch).toHaveBeenCalledTimes(3); + expect(mockFetch).not.toHaveBeenCalled(); + expect(result.token).toBe("p.branch-token"); + }); }); describe("listBranches", () => { diff --git a/src/api/branches.ts b/src/api/branches.ts index 774d12c..439e382 100644 --- a/src/api/branches.ts +++ b/src/api/branches.ts @@ -3,7 +3,7 @@ * Uses the /v1/environments endpoints (Forward API) */ -import { tinybirdFetch } from "./fetcher.js"; +import { createTinybirdFetcher } from "./fetcher.js"; /** * Branch information from Tinybird API @@ -35,6 +35,12 @@ export interface BranchApiConfig { baseUrl: string; /** Parent workspace token (used to create/manage branches) */ token: string; + /** Custom fetch implementation (optional) */ + fetch?: typeof fetch; +} + +function getFetch(config: BranchApiConfig) { + return createTinybirdFetcher(config.fetch ?? globalThis.fetch); } /** @@ -89,10 +95,12 @@ async function pollJob( maxAttempts = 120, intervalMs = 1000 ): Promise { + const fetchFn = getFetch(config); + for (let attempt = 0; attempt < maxAttempts; attempt++) { const url = new URL(`/v0/jobs/${jobId}`, config.baseUrl); - const response = await tinybirdFetch(url.toString(), { + const response = await fetchFn(url.toString(), { method: "GET", headers: { Authorization: `Bearer ${config.token}`, @@ -153,6 +161,7 @@ export async function createBranch( name: string, options?: CreateBranchOptions ): Promise { + const fetchFn = getFetch(config); const url = new URL("/v1/environments", config.baseUrl); url.searchParams.set("name", name); if (options?.lastPartition) { @@ -164,7 +173,7 @@ export async function createBranch( console.log(`[debug] POST ${url.toString()}`); } - const response = await tinybirdFetch(url.toString(), { + const response = await fetchFn(url.toString(), { method: "POST", headers: { Authorization: `Bearer ${config.token}`, @@ -219,9 +228,10 @@ export async function createBranch( export async function listBranches( config: BranchApiConfig ): Promise { + const fetchFn = getFetch(config); const url = new URL("/v1/environments", config.baseUrl); - const response = await tinybirdFetch(url.toString(), { + const response = await fetchFn(url.toString(), { method: "GET", headers: { Authorization: `Bearer ${config.token}`, @@ -253,10 +263,11 @@ export async function getBranch( config: BranchApiConfig, name: string ): Promise { + const fetchFn = getFetch(config); const url = new URL(`/v0/environments/${encodeURIComponent(name)}`, config.baseUrl); url.searchParams.set("with_token", "true"); - const response = await tinybirdFetch(url.toString(), { + const response = await fetchFn(url.toString(), { method: "GET", headers: { Authorization: `Bearer ${config.token}`, @@ -292,10 +303,11 @@ export async function deleteBranch( ): Promise { // First get the branch to find its ID const branch = await getBranch(config, name); + const fetchFn = getFetch(config); const url = new URL(`/v0/environments/${branch.id}`, config.baseUrl); - const response = await tinybirdFetch(url.toString(), { + const response = await fetchFn(url.toString(), { method: "DELETE", headers: { Authorization: `Bearer ${config.token}`, diff --git a/src/client/base.test.ts b/src/client/base.test.ts index 8cd19d9..00cc1b2 100644 --- a/src/client/base.test.ts +++ b/src/client/base.test.ts @@ -1,8 +1,46 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { TinybirdClient, createClient } from "./base.js"; import type { DatasourcesNamespace } from "./types.js"; +import { loadConfigAsync } from "../cli/config.js"; +import { getOrCreateBranch } from "../api/branches.js"; + +vi.mock("../cli/config.js", () => ({ + loadConfigAsync: vi.fn(), +})); + +vi.mock("../api/branches.js", () => ({ + getOrCreateBranch: vi.fn(), +})); describe("TinybirdClient", () => { + const originalEnv = { ...process.env }; + const mockedLoadConfigAsync = vi.mocked(loadConfigAsync); + const mockedGetOrCreateBranch = vi.mocked(getOrCreateBranch); + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.VERCEL_ENV; + delete process.env.GITHUB_HEAD_REF; + delete process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME; + delete process.env.CI; + delete process.env.TINYBIRD_PREVIEW_MODE; + delete process.env.VERCEL_GIT_COMMIT_REF; + delete process.env.GITHUB_REF_NAME; + delete process.env.CI_COMMIT_BRANCH; + delete process.env.CIRCLE_BRANCH; + delete process.env.BUILD_SOURCEBRANCHNAME; + delete process.env.BITBUCKET_BRANCH; + delete process.env.TINYBIRD_BRANCH_NAME; + delete process.env.TINYBIRD_BRANCH_TOKEN; + mockedLoadConfigAsync.mockReset(); + mockedGetOrCreateBranch.mockReset(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + describe("constructor", () => { it("throws error when baseUrl is missing", () => { expect(() => new TinybirdClient({ baseUrl: "", token: "test-token" })).toThrow( @@ -115,6 +153,52 @@ describe("TinybirdClient", () => { expect(context.baseUrl).toBe("https://api.us-east.tinybird.co"); expect(context.token).toBe("us-token"); }); + + it("passes custom fetch to devMode branch resolution", async () => { + const customFetch = vi.fn(); + + mockedLoadConfigAsync.mockResolvedValue({ + include: [], + token: "workspace-token", + baseUrl: "https://api.tinybird.co", + configPath: "/tmp/tinybird.config.json", + cwd: "/tmp", + gitBranch: "feature/add-fetch", + tinybirdBranch: "feature_add_fetch", + isMainBranch: false, + devMode: "branch", + }); + mockedGetOrCreateBranch.mockResolvedValue({ + id: "branch-123", + name: "feature_add_fetch", + token: "branch-token", + created_at: "2024-01-01T00:00:00Z", + wasCreated: false, + }); + + const client = new TinybirdClient({ + baseUrl: "https://api.tinybird.co", + token: "workspace-token", + fetch: customFetch as typeof fetch, + devMode: true, + }); + + const context = await client.getContext(); + + expect(mockedLoadConfigAsync).toHaveBeenCalled(); + expect(mockedGetOrCreateBranch).toHaveBeenCalledWith( + { + baseUrl: "https://api.tinybird.co", + token: "workspace-token", + fetch: customFetch, + }, + "feature_add_fetch" + ); + expect(context.token).toBe("branch-token"); + expect(context.isBranchToken).toBe(true); + expect(context.branchName).toBe("feature_add_fetch"); + expect(context.gitBranch).toBe("feature/add-fetch"); + }); }); describe("createClient", () => { diff --git a/src/client/base.ts b/src/client/base.ts index 1fd0908..5bb8e76 100644 --- a/src/client/base.ts +++ b/src/client/base.ts @@ -343,7 +343,11 @@ export class TinybirdClient { // Get or create branch (always fetch fresh to avoid stale cache issues) const branch = await getOrCreateBranch( - { baseUrl: this.config.baseUrl, token: this.config.token }, + { + baseUrl: this.config.baseUrl, + token: this.config.token, + fetch: this.config.fetch, + }, branchName ); diff --git a/src/client/preview.test.ts b/src/client/preview.test.ts index 25605a3..66e6377 100644 --- a/src/client/preview.test.ts +++ b/src/client/preview.test.ts @@ -157,6 +157,50 @@ describe("Preview environment detection", () => { const token = await resolveToken({ token: "my-token" }); expect(token).toBe("my-token"); }); + + it("uses custom fetch for preview branch token resolution", async () => { + process.env.VERCEL_ENV = "preview"; + process.env.VERCEL_GIT_COMMIT_REF = "feature/add-fetch"; + + const customFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: "branch-123", + name: "tmp_ci_feature_add_fetch", + token: "branch-token", + created_at: "2024-01-01T00:00:00Z", + }), + }); + const originalFetch = global.fetch; + const globalFetch = vi.fn().mockRejectedValue( + new Error("global fetch should not be called") + ); + global.fetch = globalFetch as typeof fetch; + + try { + const token = await resolveToken({ + baseUrl: "https://api.tinybird.co", + token: "workspace-token", + fetch: customFetch as typeof fetch, + }); + + expect(token).toBe("branch-token"); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(globalFetch).not.toHaveBeenCalled(); + + const [url, init] = customFetch.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/v0/environments/tmp_ci_feature_add_fetch"); + expect(parsed.searchParams.get("with_token")).toBe("true"); + expect(parsed.searchParams.get("from")).toBe("ts-sdk"); + expect(new Headers(init.headers).get("Authorization")).toBe( + "Bearer workspace-token" + ); + } finally { + global.fetch = originalFetch; + } + }); }); describe("clearTokenCache", () => { diff --git a/src/client/preview.ts b/src/client/preview.ts index 9fb22ad..048956e 100644 --- a/src/client/preview.ts +++ b/src/client/preview.ts @@ -5,7 +5,7 @@ * Tinybird branch token for the current git branch. */ -import { tinybirdFetch } from "../api/fetcher.js"; +import { createTinybirdFetcher } from "../api/fetcher.js"; /** * Branch information with token @@ -110,13 +110,15 @@ function sanitizeBranchName(branchName: string): string { async function fetchBranchToken( baseUrl: string, workspaceToken: string, - branchName: string + branchName: string, + fetchFn?: typeof fetch ): Promise { const sanitizedName = sanitizeBranchName(branchName); // Look for the preview branch with tmp_ci_ prefix (matches what tinybird preview creates) const previewBranchName = `tmp_ci_${sanitizedName}`; const url = new URL(`/v0/environments/${encodeURIComponent(previewBranchName)}`, baseUrl); url.searchParams.set("with_token", "true"); + const tinybirdFetch = createTinybirdFetcher(fetchFn ?? globalThis.fetch); try { const response = await tinybirdFetch(url.toString(), { @@ -153,6 +155,7 @@ async function fetchBranchToken( export async function resolveToken(options?: { baseUrl?: string; token?: string; + fetch?: typeof fetch; }): Promise { // 1. Check for explicit branch token override if (process.env.TINYBIRD_BRANCH_TOKEN) { @@ -181,7 +184,12 @@ export async function resolveToken(options?: { const baseUrl = options?.baseUrl ?? process.env.TINYBIRD_URL ?? "https://api.tinybird.co"; // Fetch branch token - const branchToken = await fetchBranchToken(baseUrl, configuredToken, branchName); + const branchToken = await fetchBranchToken( + baseUrl, + configuredToken, + branchName, + options?.fetch + ); if (branchToken) { // Cache for subsequent calls diff --git a/src/schema/project.test.ts b/src/schema/project.test.ts index f8deba6..67a3e61 100644 --- a/src/schema/project.test.ts +++ b/src/schema/project.test.ts @@ -13,6 +13,29 @@ import { definePipe, node } from "./pipe.js"; import { t } from "./types.js"; describe("Project Schema", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.VERCEL_ENV; + delete process.env.GITHUB_HEAD_REF; + delete process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME; + delete process.env.CI; + delete process.env.TINYBIRD_PREVIEW_MODE; + delete process.env.VERCEL_GIT_COMMIT_REF; + delete process.env.GITHUB_REF_NAME; + delete process.env.CI_COMMIT_BRANCH; + delete process.env.CIRCLE_BRANCH; + delete process.env.BUILD_SOURCEBRANCHNAME; + delete process.env.BITBUCKET_BRANCH; + delete process.env.TINYBIRD_BRANCH_NAME; + delete process.env.TINYBIRD_BRANCH_TOKEN; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + describe("defineProject", () => { it("creates a project with empty config", () => { const project = defineProject({}); @@ -390,6 +413,7 @@ describe("Project Schema", () => { const events = defineDatasource("events", { schema: { id: t.string() }, }); + const customFetch = vi.fn(); // Should accept all options without throwing const client = createTinybirdClient({ @@ -397,6 +421,7 @@ describe("Project Schema", () => { pipes: {}, baseUrl: "https://custom.tinybird.co", token: "test-token", + fetch: customFetch as typeof fetch, configDir: "/custom/config/dir", devMode: true, }); @@ -440,5 +465,59 @@ describe("Project Schema", () => { expect(client._options).toBeDefined(); expect(() => client.client).toThrow("Client not initialized"); }); + + it("uses custom fetch for typed endpoint queries", async () => { + const topEvents = definePipe("top_events", { + nodes: [node({ name: "endpoint", sql: "SELECT 1" })], + output: { count: t.int64() }, + endpoint: true, + }); + const customFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [], + meta: [], + rows: 0, + statistics: { + elapsed: 0, + rows_read: 0, + bytes_read: 0, + }, + }), + }); + const originalFetch = global.fetch; + const globalFetch = vi.fn().mockRejectedValue( + new Error("global fetch should not be called") + ); + global.fetch = globalFetch as typeof fetch; + + try { + const client = createTinybirdClient({ + datasources: {}, + pipes: { topEvents }, + baseUrl: "https://api.tinybird.co", + token: "test-token", + fetch: customFetch as typeof fetch, + devMode: false, + }); + + const result = await client.topEvents.query({}); + + expect(result.rows).toBe(0); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(globalFetch).not.toHaveBeenCalled(); + + const [url, init] = customFetch.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/v0/pipes/top_events.json"); + expect(parsed.searchParams.get("from")).toBe("ts-sdk"); + expect(new Headers(init.headers).get("Authorization")).toBe( + "Bearer test-token" + ); + } finally { + global.fetch = originalFetch; + } + }); }); }); diff --git a/src/schema/project.ts b/src/schema/project.ts index 994ff33..494e64a 100644 --- a/src/schema/project.ts +++ b/src/schema/project.ts @@ -130,6 +130,8 @@ export interface TinybirdClientConfig< baseUrl?: string; /** Tinybird API token (defaults to TINYBIRD_TOKEN env var) */ token?: string; + /** Custom fetch implementation (optional, defaults to global fetch) */ + fetch?: typeof fetch; /** * Directory to use as the starting point when searching for tinybird.json config. * In monorepo setups, this should be set to the directory containing tinybird.json @@ -295,6 +297,7 @@ export const Tinybird: TinybirdConstructor = class Tinybird< readonly #options: { baseUrl?: string; token?: string; + fetch?: typeof fetch; configDir?: string; devMode?: boolean; }; @@ -303,6 +306,7 @@ export const Tinybird: TinybirdConstructor = class Tinybird< this.#options = { baseUrl: config.baseUrl, token: config.token, + fetch: config.fetch, configDir: config.configDir, devMode: config.devMode, }; @@ -392,11 +396,16 @@ export const Tinybird: TinybirdConstructor = class Tinybird< const baseUrl = this.#options.baseUrl ?? process.env.TINYBIRD_URL ?? "https://api.tinybird.co"; - const token = await resolveToken({ baseUrl, token: this.#options.token }); + const token = await resolveToken({ + baseUrl, + token: this.#options.token, + fetch: this.#options.fetch, + }); this.#client = createClient({ baseUrl, token, + fetch: this.#options.fetch, devMode: this.#options.devMode ?? process.env.NODE_ENV === "development", configDir: this.#options.configDir, });