@tinybirdco/sdk 0.0.63 → 0.0.65

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.
Files changed (42) hide show
  1. package/README.md +7 -0
  2. package/dist/api/branches.d.ts +2 -0
  3. package/dist/api/branches.d.ts.map +1 -1
  4. package/dist/api/branches.js +14 -6
  5. package/dist/api/branches.js.map +1 -1
  6. package/dist/api/branches.test.js +31 -0
  7. package/dist/api/branches.test.js.map +1 -1
  8. package/dist/client/base.d.ts.map +1 -1
  9. package/dist/client/base.js +5 -1
  10. package/dist/client/base.js.map +1 -1
  11. package/dist/client/base.test.js +72 -1
  12. package/dist/client/base.test.js.map +1 -1
  13. package/dist/client/preview.d.ts +1 -0
  14. package/dist/client/preview.d.ts.map +1 -1
  15. package/dist/client/preview.js +4 -3
  16. package/dist/client/preview.js.map +1 -1
  17. package/dist/client/preview.test.js +35 -0
  18. package/dist/client/preview.test.js.map +1 -1
  19. package/dist/generator/datasource.test.js +12 -1
  20. package/dist/generator/datasource.test.js.map +1 -1
  21. package/dist/schema/project.d.ts +2 -0
  22. package/dist/schema/project.d.ts.map +1 -1
  23. package/dist/schema/project.js +7 -1
  24. package/dist/schema/project.js.map +1 -1
  25. package/dist/schema/project.test.js +67 -0
  26. package/dist/schema/project.test.js.map +1 -1
  27. package/dist/schema/types.js +2 -2
  28. package/dist/schema/types.js.map +1 -1
  29. package/dist/schema/types.test.js +9 -2
  30. package/dist/schema/types.test.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/api/branches.test.ts +39 -0
  33. package/src/api/branches.ts +18 -6
  34. package/src/client/base.test.ts +85 -1
  35. package/src/client/base.ts +5 -1
  36. package/src/client/preview.test.ts +44 -0
  37. package/src/client/preview.ts +11 -3
  38. package/src/generator/datasource.test.ts +14 -1
  39. package/src/schema/project.test.ts +79 -0
  40. package/src/schema/project.ts +10 -1
  41. package/src/schema/types.test.ts +10 -2
  42. package/src/schema/types.ts +2 -2
@@ -3,7 +3,7 @@
3
3
  * Uses the /v1/environments endpoints (Forward API)
4
4
  */
5
5
 
6
- import { tinybirdFetch } from "./fetcher.js";
6
+ import { createTinybirdFetcher } from "./fetcher.js";
7
7
 
8
8
  /**
9
9
  * Branch information from Tinybird API
@@ -35,6 +35,12 @@ export interface BranchApiConfig {
35
35
  baseUrl: string;
36
36
  /** Parent workspace token (used to create/manage branches) */
37
37
  token: string;
38
+ /** Custom fetch implementation (optional) */
39
+ fetch?: typeof fetch;
40
+ }
41
+
42
+ function getFetch(config: BranchApiConfig) {
43
+ return createTinybirdFetcher(config.fetch ?? globalThis.fetch);
38
44
  }
39
45
 
40
46
  /**
@@ -89,10 +95,12 @@ async function pollJob(
89
95
  maxAttempts = 120,
90
96
  intervalMs = 1000
91
97
  ): Promise<JobStatusResponse> {
98
+ const fetchFn = getFetch(config);
99
+
92
100
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
93
101
  const url = new URL(`/v0/jobs/${jobId}`, config.baseUrl);
94
102
 
95
- const response = await tinybirdFetch(url.toString(), {
103
+ const response = await fetchFn(url.toString(), {
96
104
  method: "GET",
97
105
  headers: {
98
106
  Authorization: `Bearer ${config.token}`,
@@ -153,6 +161,7 @@ export async function createBranch(
153
161
  name: string,
154
162
  options?: CreateBranchOptions
155
163
  ): Promise<TinybirdBranch> {
164
+ const fetchFn = getFetch(config);
156
165
  const url = new URL("/v1/environments", config.baseUrl);
157
166
  url.searchParams.set("name", name);
158
167
  if (options?.lastPartition) {
@@ -164,7 +173,7 @@ export async function createBranch(
164
173
  console.log(`[debug] POST ${url.toString()}`);
165
174
  }
166
175
 
167
- const response = await tinybirdFetch(url.toString(), {
176
+ const response = await fetchFn(url.toString(), {
168
177
  method: "POST",
169
178
  headers: {
170
179
  Authorization: `Bearer ${config.token}`,
@@ -219,9 +228,10 @@ export async function createBranch(
219
228
  export async function listBranches(
220
229
  config: BranchApiConfig
221
230
  ): Promise<TinybirdBranch[]> {
231
+ const fetchFn = getFetch(config);
222
232
  const url = new URL("/v1/environments", config.baseUrl);
223
233
 
224
- const response = await tinybirdFetch(url.toString(), {
234
+ const response = await fetchFn(url.toString(), {
225
235
  method: "GET",
226
236
  headers: {
227
237
  Authorization: `Bearer ${config.token}`,
@@ -253,10 +263,11 @@ export async function getBranch(
253
263
  config: BranchApiConfig,
254
264
  name: string
255
265
  ): Promise<TinybirdBranch> {
266
+ const fetchFn = getFetch(config);
256
267
  const url = new URL(`/v0/environments/${encodeURIComponent(name)}`, config.baseUrl);
257
268
  url.searchParams.set("with_token", "true");
258
269
 
259
- const response = await tinybirdFetch(url.toString(), {
270
+ const response = await fetchFn(url.toString(), {
260
271
  method: "GET",
261
272
  headers: {
262
273
  Authorization: `Bearer ${config.token}`,
@@ -292,10 +303,11 @@ export async function deleteBranch(
292
303
  ): Promise<void> {
293
304
  // First get the branch to find its ID
294
305
  const branch = await getBranch(config, name);
306
+ const fetchFn = getFetch(config);
295
307
 
296
308
  const url = new URL(`/v0/environments/${branch.id}`, config.baseUrl);
297
309
 
298
- const response = await tinybirdFetch(url.toString(), {
310
+ const response = await fetchFn(url.toString(), {
299
311
  method: "DELETE",
300
312
  headers: {
301
313
  Authorization: `Bearer ${config.token}`,
@@ -1,8 +1,46 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { TinybirdClient, createClient } from "./base.js";
3
3
  import type { DatasourcesNamespace } from "./types.js";
4
+ import { loadConfigAsync } from "../cli/config.js";
5
+ import { getOrCreateBranch } from "../api/branches.js";
6
+
7
+ vi.mock("../cli/config.js", () => ({
8
+ loadConfigAsync: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("../api/branches.js", () => ({
12
+ getOrCreateBranch: vi.fn(),
13
+ }));
4
14
 
5
15
  describe("TinybirdClient", () => {
16
+ const originalEnv = { ...process.env };
17
+ const mockedLoadConfigAsync = vi.mocked(loadConfigAsync);
18
+ const mockedGetOrCreateBranch = vi.mocked(getOrCreateBranch);
19
+
20
+ beforeEach(() => {
21
+ process.env = { ...originalEnv };
22
+ delete process.env.VERCEL_ENV;
23
+ delete process.env.GITHUB_HEAD_REF;
24
+ delete process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME;
25
+ delete process.env.CI;
26
+ delete process.env.TINYBIRD_PREVIEW_MODE;
27
+ delete process.env.VERCEL_GIT_COMMIT_REF;
28
+ delete process.env.GITHUB_REF_NAME;
29
+ delete process.env.CI_COMMIT_BRANCH;
30
+ delete process.env.CIRCLE_BRANCH;
31
+ delete process.env.BUILD_SOURCEBRANCHNAME;
32
+ delete process.env.BITBUCKET_BRANCH;
33
+ delete process.env.TINYBIRD_BRANCH_NAME;
34
+ delete process.env.TINYBIRD_BRANCH_TOKEN;
35
+ mockedLoadConfigAsync.mockReset();
36
+ mockedGetOrCreateBranch.mockReset();
37
+ });
38
+
39
+ afterEach(() => {
40
+ process.env = { ...originalEnv };
41
+ vi.restoreAllMocks();
42
+ });
43
+
6
44
  describe("constructor", () => {
7
45
  it("throws error when baseUrl is missing", () => {
8
46
  expect(() => new TinybirdClient({ baseUrl: "", token: "test-token" })).toThrow(
@@ -115,6 +153,52 @@ describe("TinybirdClient", () => {
115
153
  expect(context.baseUrl).toBe("https://api.us-east.tinybird.co");
116
154
  expect(context.token).toBe("us-token");
117
155
  });
156
+
157
+ it("passes custom fetch to devMode branch resolution", async () => {
158
+ const customFetch = vi.fn();
159
+
160
+ mockedLoadConfigAsync.mockResolvedValue({
161
+ include: [],
162
+ token: "workspace-token",
163
+ baseUrl: "https://api.tinybird.co",
164
+ configPath: "/tmp/tinybird.config.json",
165
+ cwd: "/tmp",
166
+ gitBranch: "feature/add-fetch",
167
+ tinybirdBranch: "feature_add_fetch",
168
+ isMainBranch: false,
169
+ devMode: "branch",
170
+ });
171
+ mockedGetOrCreateBranch.mockResolvedValue({
172
+ id: "branch-123",
173
+ name: "feature_add_fetch",
174
+ token: "branch-token",
175
+ created_at: "2024-01-01T00:00:00Z",
176
+ wasCreated: false,
177
+ });
178
+
179
+ const client = new TinybirdClient({
180
+ baseUrl: "https://api.tinybird.co",
181
+ token: "workspace-token",
182
+ fetch: customFetch as typeof fetch,
183
+ devMode: true,
184
+ });
185
+
186
+ const context = await client.getContext();
187
+
188
+ expect(mockedLoadConfigAsync).toHaveBeenCalled();
189
+ expect(mockedGetOrCreateBranch).toHaveBeenCalledWith(
190
+ {
191
+ baseUrl: "https://api.tinybird.co",
192
+ token: "workspace-token",
193
+ fetch: customFetch,
194
+ },
195
+ "feature_add_fetch"
196
+ );
197
+ expect(context.token).toBe("branch-token");
198
+ expect(context.isBranchToken).toBe(true);
199
+ expect(context.branchName).toBe("feature_add_fetch");
200
+ expect(context.gitBranch).toBe("feature/add-fetch");
201
+ });
118
202
  });
119
203
 
120
204
  describe("createClient", () => {
@@ -343,7 +343,11 @@ export class TinybirdClient {
343
343
 
344
344
  // Get or create branch (always fetch fresh to avoid stale cache issues)
345
345
  const branch = await getOrCreateBranch(
346
- { baseUrl: this.config.baseUrl, token: this.config.token },
346
+ {
347
+ baseUrl: this.config.baseUrl,
348
+ token: this.config.token,
349
+ fetch: this.config.fetch,
350
+ },
347
351
  branchName
348
352
  );
349
353
 
@@ -157,6 +157,50 @@ describe("Preview environment detection", () => {
157
157
  const token = await resolveToken({ token: "my-token" });
158
158
  expect(token).toBe("my-token");
159
159
  });
160
+
161
+ it("uses custom fetch for preview branch token resolution", async () => {
162
+ process.env.VERCEL_ENV = "preview";
163
+ process.env.VERCEL_GIT_COMMIT_REF = "feature/add-fetch";
164
+
165
+ const customFetch = vi.fn().mockResolvedValueOnce({
166
+ ok: true,
167
+ json: () =>
168
+ Promise.resolve({
169
+ id: "branch-123",
170
+ name: "tmp_ci_feature_add_fetch",
171
+ token: "branch-token",
172
+ created_at: "2024-01-01T00:00:00Z",
173
+ }),
174
+ });
175
+ const originalFetch = global.fetch;
176
+ const globalFetch = vi.fn().mockRejectedValue(
177
+ new Error("global fetch should not be called")
178
+ );
179
+ global.fetch = globalFetch as typeof fetch;
180
+
181
+ try {
182
+ const token = await resolveToken({
183
+ baseUrl: "https://api.tinybird.co",
184
+ token: "workspace-token",
185
+ fetch: customFetch as typeof fetch,
186
+ });
187
+
188
+ expect(token).toBe("branch-token");
189
+ expect(customFetch).toHaveBeenCalledTimes(1);
190
+ expect(globalFetch).not.toHaveBeenCalled();
191
+
192
+ const [url, init] = customFetch.mock.calls[0] as [string, RequestInit];
193
+ const parsed = new URL(url);
194
+ expect(parsed.pathname).toBe("/v0/environments/tmp_ci_feature_add_fetch");
195
+ expect(parsed.searchParams.get("with_token")).toBe("true");
196
+ expect(parsed.searchParams.get("from")).toBe("ts-sdk");
197
+ expect(new Headers(init.headers).get("Authorization")).toBe(
198
+ "Bearer workspace-token"
199
+ );
200
+ } finally {
201
+ global.fetch = originalFetch;
202
+ }
203
+ });
160
204
  });
161
205
 
162
206
  describe("clearTokenCache", () => {
@@ -5,7 +5,7 @@
5
5
  * Tinybird branch token for the current git branch.
6
6
  */
7
7
 
8
- import { tinybirdFetch } from "../api/fetcher.js";
8
+ import { createTinybirdFetcher } from "../api/fetcher.js";
9
9
 
10
10
  /**
11
11
  * Branch information with token
@@ -110,13 +110,15 @@ function sanitizeBranchName(branchName: string): string {
110
110
  async function fetchBranchToken(
111
111
  baseUrl: string,
112
112
  workspaceToken: string,
113
- branchName: string
113
+ branchName: string,
114
+ fetchFn?: typeof fetch
114
115
  ): Promise<string | null> {
115
116
  const sanitizedName = sanitizeBranchName(branchName);
116
117
  // Look for the preview branch with tmp_ci_ prefix (matches what tinybird preview creates)
117
118
  const previewBranchName = `tmp_ci_${sanitizedName}`;
118
119
  const url = new URL(`/v0/environments/${encodeURIComponent(previewBranchName)}`, baseUrl);
119
120
  url.searchParams.set("with_token", "true");
121
+ const tinybirdFetch = createTinybirdFetcher(fetchFn ?? globalThis.fetch);
120
122
 
121
123
  try {
122
124
  const response = await tinybirdFetch(url.toString(), {
@@ -153,6 +155,7 @@ async function fetchBranchToken(
153
155
  export async function resolveToken(options?: {
154
156
  baseUrl?: string;
155
157
  token?: string;
158
+ fetch?: typeof fetch;
156
159
  }): Promise<string> {
157
160
  // 1. Check for explicit branch token override
158
161
  if (process.env.TINYBIRD_BRANCH_TOKEN) {
@@ -181,7 +184,12 @@ export async function resolveToken(options?: {
181
184
  const baseUrl = options?.baseUrl ?? process.env.TINYBIRD_URL ?? "https://api.tinybird.co";
182
185
 
183
186
  // Fetch branch token
184
- const branchToken = await fetchBranchToken(baseUrl, configuredToken, branchName);
187
+ const branchToken = await fetchBranchToken(
188
+ baseUrl,
189
+ configuredToken,
190
+ branchName,
191
+ options?.fetch
192
+ );
185
193
 
186
194
  if (branchToken) {
187
195
  // Cache for subsequent calls
@@ -142,7 +142,7 @@ describe('Datasource Generator', () => {
142
142
  expect(result.content).toContain('country LowCardinality(String)');
143
143
  });
144
144
 
145
- it('formats LowCardinality(Nullable) correctly', () => {
145
+ it('formats LowCardinality(Nullable) correctly with .lowCardinality().nullable()', () => {
146
146
  const ds = defineDatasource('test_ds', {
147
147
  schema: {
148
148
  country: t.string().lowCardinality().nullable(),
@@ -151,6 +151,19 @@ describe('Datasource Generator', () => {
151
151
 
152
152
  const result = generateDatasource(ds);
153
153
  expect(result.content).toContain('country LowCardinality(Nullable(String))');
154
+ expect(result.content).not.toContain('Nullable(LowCardinality');
155
+ });
156
+
157
+ it('formats LowCardinality(Nullable) correctly with .nullable().lowCardinality()', () => {
158
+ const ds = defineDatasource('test_ds', {
159
+ schema: {
160
+ country: t.string().nullable().lowCardinality(),
161
+ },
162
+ });
163
+
164
+ const result = generateDatasource(ds);
165
+ expect(result.content).toContain('country LowCardinality(Nullable(String))');
166
+ expect(result.content).not.toContain('Nullable(LowCardinality');
154
167
  });
155
168
 
156
169
  it('includes default values', () => {
@@ -13,6 +13,29 @@ import { definePipe, node } from "./pipe.js";
13
13
  import { t } from "./types.js";
14
14
 
15
15
  describe("Project Schema", () => {
16
+ const originalEnv = { ...process.env };
17
+
18
+ beforeEach(() => {
19
+ process.env = { ...originalEnv };
20
+ delete process.env.VERCEL_ENV;
21
+ delete process.env.GITHUB_HEAD_REF;
22
+ delete process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME;
23
+ delete process.env.CI;
24
+ delete process.env.TINYBIRD_PREVIEW_MODE;
25
+ delete process.env.VERCEL_GIT_COMMIT_REF;
26
+ delete process.env.GITHUB_REF_NAME;
27
+ delete process.env.CI_COMMIT_BRANCH;
28
+ delete process.env.CIRCLE_BRANCH;
29
+ delete process.env.BUILD_SOURCEBRANCHNAME;
30
+ delete process.env.BITBUCKET_BRANCH;
31
+ delete process.env.TINYBIRD_BRANCH_NAME;
32
+ delete process.env.TINYBIRD_BRANCH_TOKEN;
33
+ });
34
+
35
+ afterEach(() => {
36
+ process.env = { ...originalEnv };
37
+ });
38
+
16
39
  describe("defineProject", () => {
17
40
  it("creates a project with empty config", () => {
18
41
  const project = defineProject({});
@@ -390,6 +413,7 @@ describe("Project Schema", () => {
390
413
  const events = defineDatasource("events", {
391
414
  schema: { id: t.string() },
392
415
  });
416
+ const customFetch = vi.fn();
393
417
 
394
418
  // Should accept all options without throwing
395
419
  const client = createTinybirdClient({
@@ -397,6 +421,7 @@ describe("Project Schema", () => {
397
421
  pipes: {},
398
422
  baseUrl: "https://custom.tinybird.co",
399
423
  token: "test-token",
424
+ fetch: customFetch as typeof fetch,
400
425
  configDir: "/custom/config/dir",
401
426
  devMode: true,
402
427
  });
@@ -440,5 +465,59 @@ describe("Project Schema", () => {
440
465
  expect(client._options).toBeDefined();
441
466
  expect(() => client.client).toThrow("Client not initialized");
442
467
  });
468
+
469
+ it("uses custom fetch for typed endpoint queries", async () => {
470
+ const topEvents = definePipe("top_events", {
471
+ nodes: [node({ name: "endpoint", sql: "SELECT 1" })],
472
+ output: { count: t.int64() },
473
+ endpoint: true,
474
+ });
475
+ const customFetch = vi.fn().mockResolvedValueOnce({
476
+ ok: true,
477
+ json: () =>
478
+ Promise.resolve({
479
+ data: [],
480
+ meta: [],
481
+ rows: 0,
482
+ statistics: {
483
+ elapsed: 0,
484
+ rows_read: 0,
485
+ bytes_read: 0,
486
+ },
487
+ }),
488
+ });
489
+ const originalFetch = global.fetch;
490
+ const globalFetch = vi.fn().mockRejectedValue(
491
+ new Error("global fetch should not be called")
492
+ );
493
+ global.fetch = globalFetch as typeof fetch;
494
+
495
+ try {
496
+ const client = createTinybirdClient({
497
+ datasources: {},
498
+ pipes: { topEvents },
499
+ baseUrl: "https://api.tinybird.co",
500
+ token: "test-token",
501
+ fetch: customFetch as typeof fetch,
502
+ devMode: false,
503
+ });
504
+
505
+ const result = await client.topEvents.query({});
506
+
507
+ expect(result.rows).toBe(0);
508
+ expect(customFetch).toHaveBeenCalledTimes(1);
509
+ expect(globalFetch).not.toHaveBeenCalled();
510
+
511
+ const [url, init] = customFetch.mock.calls[0] as [string, RequestInit];
512
+ const parsed = new URL(url);
513
+ expect(parsed.pathname).toBe("/v0/pipes/top_events.json");
514
+ expect(parsed.searchParams.get("from")).toBe("ts-sdk");
515
+ expect(new Headers(init.headers).get("Authorization")).toBe(
516
+ "Bearer test-token"
517
+ );
518
+ } finally {
519
+ global.fetch = originalFetch;
520
+ }
521
+ });
443
522
  });
444
523
  });
@@ -130,6 +130,8 @@ export interface TinybirdClientConfig<
130
130
  baseUrl?: string;
131
131
  /** Tinybird API token (defaults to TINYBIRD_TOKEN env var) */
132
132
  token?: string;
133
+ /** Custom fetch implementation (optional, defaults to global fetch) */
134
+ fetch?: typeof fetch;
133
135
  /**
134
136
  * Directory to use as the starting point when searching for tinybird.json config.
135
137
  * In monorepo setups, this should be set to the directory containing tinybird.json
@@ -295,6 +297,7 @@ export const Tinybird: TinybirdConstructor = class Tinybird<
295
297
  readonly #options: {
296
298
  baseUrl?: string;
297
299
  token?: string;
300
+ fetch?: typeof fetch;
298
301
  configDir?: string;
299
302
  devMode?: boolean;
300
303
  };
@@ -303,6 +306,7 @@ export const Tinybird: TinybirdConstructor = class Tinybird<
303
306
  this.#options = {
304
307
  baseUrl: config.baseUrl,
305
308
  token: config.token,
309
+ fetch: config.fetch,
306
310
  configDir: config.configDir,
307
311
  devMode: config.devMode,
308
312
  };
@@ -392,11 +396,16 @@ export const Tinybird: TinybirdConstructor = class Tinybird<
392
396
 
393
397
  const baseUrl =
394
398
  this.#options.baseUrl ?? process.env.TINYBIRD_URL ?? "https://api.tinybird.co";
395
- const token = await resolveToken({ baseUrl, token: this.#options.token });
399
+ const token = await resolveToken({
400
+ baseUrl,
401
+ token: this.#options.token,
402
+ fetch: this.#options.fetch,
403
+ });
396
404
 
397
405
  this.#client = createClient({
398
406
  baseUrl,
399
407
  token,
408
+ fetch: this.#options.fetch,
400
409
  devMode: this.#options.devMode ?? process.env.NODE_ENV === "development",
401
410
  configDir: this.#options.configDir,
402
411
  });
@@ -87,10 +87,18 @@ describe("Type Validators (t.*)", () => {
87
87
  expect(type._tinybirdType).toBe("LowCardinality(Nullable(String))");
88
88
  });
89
89
 
90
- it("preserves both modifiers when chained", () => {
90
+ it("preserves lowCardinality modifier and omits nullable when combined (nullable is in the type string)", () => {
91
91
  const type = t.string().lowCardinality().nullable();
92
92
  expect(type._modifiers.lowCardinality).toBe(true);
93
- expect(type._modifiers.nullable).toBe(true);
93
+ expect(type._modifiers.nullable).toBeUndefined();
94
+ expect(type._tinybirdType).toBe("LowCardinality(Nullable(String))");
95
+ });
96
+
97
+ it("omits nullable modifier when nullable().lowCardinality() is chained", () => {
98
+ const type = t.string().nullable().lowCardinality();
99
+ expect(type._modifiers.lowCardinality).toBe(true);
100
+ expect(type._modifiers.nullable).toBeUndefined();
101
+ expect(type._tinybirdType).toBe("LowCardinality(Nullable(String))");
94
102
  });
95
103
  });
96
104
 
@@ -106,7 +106,6 @@ function createValidator<TType, TTinybirdType extends string>(
106
106
  `LowCardinality(Nullable(${string}))`
107
107
  >(newType as `LowCardinality(Nullable(${string}))`, {
108
108
  ...modifiers,
109
- nullable: true,
110
109
  }) as unknown as TypeValidator<
111
110
  TType | null,
112
111
  `Nullable(${TTinybirdType})`,
@@ -129,9 +128,10 @@ function createValidator<TType, TTinybirdType extends string>(
129
128
  // Extract base type from Nullable(X) and wrap as LowCardinality(Nullable(X))
130
129
  const baseType = tinybirdType.replace(/^Nullable\((.+)\)$/, "$1");
131
130
  const newType = `LowCardinality(Nullable(${baseType}))`;
131
+ const { nullable: _, ...rest } = modifiers;
132
132
  return createValidator<TType, `LowCardinality(Nullable(${string}))`>(
133
133
  newType as `LowCardinality(Nullable(${string}))`,
134
- { ...modifiers, lowCardinality: true },
134
+ { ...rest, lowCardinality: true },
135
135
  ) as unknown as TypeValidator<
136
136
  TType,
137
137
  `LowCardinality(${TTinybirdType})`,