@tinybirdco/sdk 0.0.19 → 0.0.20

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/api.ts ADDED
@@ -0,0 +1,345 @@
1
+ import { createTinybirdFetcher, type TinybirdFetch } from "./fetcher.js";
2
+ import type {
3
+ IngestOptions,
4
+ IngestResult,
5
+ QueryOptions,
6
+ QueryResult,
7
+ TinybirdErrorResponse,
8
+ } from "../client/types.js";
9
+
10
+ const DEFAULT_TIMEOUT = 30000;
11
+
12
+ /**
13
+ * Public, decoupled Tinybird API wrapper configuration
14
+ */
15
+ export interface TinybirdApiConfig {
16
+ /** Tinybird API base URL (e.g. https://api.tinybird.co) */
17
+ baseUrl: string;
18
+ /** Tinybird token used for Authorization bearer header */
19
+ token: string;
20
+ /** Custom fetch implementation (optional) */
21
+ fetch?: typeof fetch;
22
+ /** Default timeout in milliseconds (optional) */
23
+ timeout?: number;
24
+ }
25
+
26
+ /**
27
+ * Request options for the API layer
28
+ */
29
+ export interface TinybirdApiRequestInit extends RequestInit {
30
+ /** Optional token override for a specific request */
31
+ token?: string;
32
+ }
33
+
34
+ export interface TinybirdApiQueryOptions extends QueryOptions {
35
+ /** Optional token override for this request */
36
+ token?: string;
37
+ }
38
+
39
+ export interface TinybirdApiIngestOptions extends IngestOptions {
40
+ /** Optional token override for this request */
41
+ token?: string;
42
+ }
43
+
44
+ /**
45
+ * Error thrown by TinybirdApi when a response is not OK
46
+ */
47
+ export class TinybirdApiError extends Error {
48
+ readonly statusCode: number;
49
+ readonly responseBody?: string;
50
+ readonly response?: TinybirdErrorResponse;
51
+
52
+ constructor(
53
+ message: string,
54
+ statusCode: number,
55
+ responseBody?: string,
56
+ response?: TinybirdErrorResponse
57
+ ) {
58
+ super(message);
59
+ this.name = "TinybirdApiError";
60
+ this.statusCode = statusCode;
61
+ this.responseBody = responseBody;
62
+ this.response = response;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Low-level Tinybird API wrapper.
68
+ *
69
+ * This layer is intentionally decoupled from the typed TinybirdClient layer
70
+ * so it can be used standalone with just baseUrl + token.
71
+ */
72
+ export class TinybirdApi {
73
+ private readonly config: TinybirdApiConfig;
74
+ private readonly baseUrl: string;
75
+ private readonly defaultToken: string;
76
+ private readonly fetchFn: TinybirdFetch;
77
+
78
+ constructor(config: TinybirdApiConfig) {
79
+ if (!config.baseUrl) {
80
+ throw new Error("baseUrl is required");
81
+ }
82
+
83
+ if (!config.token) {
84
+ throw new Error("token is required");
85
+ }
86
+
87
+ this.config = config;
88
+ this.baseUrl = config.baseUrl.replace(/\/$/, "");
89
+ this.defaultToken = config.token;
90
+ this.fetchFn = createTinybirdFetcher(config.fetch ?? globalThis.fetch);
91
+ }
92
+
93
+ /**
94
+ * Execute a request against Tinybird API.
95
+ */
96
+ request(path: string, init: TinybirdApiRequestInit = {}): Promise<Response> {
97
+ const { token, headers, ...requestInit } = init;
98
+ const authToken = token ?? this.defaultToken;
99
+ const requestHeaders = new Headers(headers);
100
+
101
+ if (!requestHeaders.has("Authorization")) {
102
+ requestHeaders.set("Authorization", `Bearer ${authToken}`);
103
+ }
104
+
105
+ return this.fetchFn(this.resolveUrl(path), {
106
+ ...requestInit,
107
+ headers: requestHeaders,
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Execute a request and parse JSON response.
113
+ */
114
+ async requestJson<T = unknown>(
115
+ path: string,
116
+ init: TinybirdApiRequestInit = {}
117
+ ): Promise<T> {
118
+ const response = await this.request(path, init);
119
+
120
+ if (!response.ok) {
121
+ await this.handleErrorResponse(response);
122
+ }
123
+
124
+ return (await response.json()) as T;
125
+ }
126
+
127
+ /**
128
+ * Query a Tinybird endpoint
129
+ */
130
+ async query<T = unknown, P extends Record<string, unknown> = Record<string, unknown>>(
131
+ endpointName: string,
132
+ params: P = {} as P,
133
+ options: TinybirdApiQueryOptions = {}
134
+ ): Promise<QueryResult<T>> {
135
+ const url = new URL(`/v0/pipes/${endpointName}.json`, `${this.baseUrl}/`);
136
+
137
+ for (const [key, value] of Object.entries(params)) {
138
+ if (value === undefined || value === null) {
139
+ continue;
140
+ }
141
+
142
+ if (Array.isArray(value)) {
143
+ for (const item of value) {
144
+ url.searchParams.append(key, String(item));
145
+ }
146
+ continue;
147
+ }
148
+
149
+ if (value instanceof Date) {
150
+ url.searchParams.set(key, value.toISOString());
151
+ continue;
152
+ }
153
+
154
+ url.searchParams.set(key, String(value));
155
+ }
156
+
157
+ const response = await this.request(url.toString(), {
158
+ method: "GET",
159
+ token: options.token,
160
+ signal: this.createAbortSignal(options.timeout, options.signal),
161
+ });
162
+
163
+ if (!response.ok) {
164
+ await this.handleErrorResponse(response);
165
+ }
166
+
167
+ return (await response.json()) as QueryResult<T>;
168
+ }
169
+
170
+ /**
171
+ * Ingest a single row into a datasource
172
+ */
173
+ async ingest<T extends Record<string, unknown>>(
174
+ datasourceName: string,
175
+ event: T,
176
+ options: TinybirdApiIngestOptions = {}
177
+ ): Promise<IngestResult> {
178
+ return this.ingestBatch(datasourceName, [event], options);
179
+ }
180
+
181
+ /**
182
+ * Ingest a batch of rows into a datasource
183
+ */
184
+ async ingestBatch<T extends Record<string, unknown>>(
185
+ datasourceName: string,
186
+ events: T[],
187
+ options: TinybirdApiIngestOptions = {}
188
+ ): Promise<IngestResult> {
189
+ if (events.length === 0) {
190
+ return { successful_rows: 0, quarantined_rows: 0 };
191
+ }
192
+
193
+ const url = new URL("/v0/events", `${this.baseUrl}/`);
194
+ url.searchParams.set("name", datasourceName);
195
+
196
+ if (options.wait !== false) {
197
+ url.searchParams.set("wait", "true");
198
+ }
199
+
200
+ const ndjson = events
201
+ .map((event) => JSON.stringify(this.serializeEvent(event)))
202
+ .join("\n");
203
+
204
+ const response = await this.request(url.toString(), {
205
+ method: "POST",
206
+ token: options.token,
207
+ headers: {
208
+ "Content-Type": "application/x-ndjson",
209
+ },
210
+ body: ndjson,
211
+ signal: this.createAbortSignal(options.timeout, options.signal),
212
+ });
213
+
214
+ if (!response.ok) {
215
+ await this.handleErrorResponse(response);
216
+ }
217
+
218
+ return (await response.json()) as IngestResult;
219
+ }
220
+
221
+ /**
222
+ * Execute raw SQL against Tinybird
223
+ */
224
+ async sql<T = unknown>(
225
+ sql: string,
226
+ options: TinybirdApiQueryOptions = {}
227
+ ): Promise<QueryResult<T>> {
228
+ const response = await this.request("/v0/sql", {
229
+ method: "POST",
230
+ token: options.token,
231
+ headers: {
232
+ "Content-Type": "text/plain",
233
+ },
234
+ body: sql,
235
+ signal: this.createAbortSignal(options.timeout, options.signal),
236
+ });
237
+
238
+ if (!response.ok) {
239
+ await this.handleErrorResponse(response);
240
+ }
241
+
242
+ return (await response.json()) as QueryResult<T>;
243
+ }
244
+
245
+ private createAbortSignal(
246
+ timeout?: number,
247
+ existingSignal?: AbortSignal
248
+ ): AbortSignal | undefined {
249
+ const timeoutMs = timeout ?? this.config.timeout ?? DEFAULT_TIMEOUT;
250
+
251
+ if (!timeoutMs && !existingSignal) {
252
+ return undefined;
253
+ }
254
+
255
+ if (!timeoutMs && existingSignal) {
256
+ return existingSignal;
257
+ }
258
+
259
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
260
+
261
+ if (!existingSignal) {
262
+ return timeoutSignal;
263
+ }
264
+
265
+ return AbortSignal.any([timeoutSignal, existingSignal]);
266
+ }
267
+
268
+ private serializeEvent(
269
+ event: Record<string, unknown>
270
+ ): Record<string, unknown> {
271
+ const serialized: Record<string, unknown> = {};
272
+
273
+ for (const [key, value] of Object.entries(event)) {
274
+ serialized[key] = this.serializeValue(value);
275
+ }
276
+
277
+ return serialized;
278
+ }
279
+
280
+ private serializeValue(value: unknown): unknown {
281
+ if (value instanceof Date) {
282
+ return value.toISOString();
283
+ }
284
+
285
+ if (value instanceof Map) {
286
+ return Object.fromEntries(value);
287
+ }
288
+
289
+ if (typeof value === "bigint") {
290
+ return value.toString();
291
+ }
292
+
293
+ if (Array.isArray(value)) {
294
+ return value.map((item) => this.serializeValue(item));
295
+ }
296
+
297
+ if (typeof value === "object" && value !== null) {
298
+ return this.serializeEvent(value as Record<string, unknown>);
299
+ }
300
+
301
+ return value;
302
+ }
303
+
304
+ private async handleErrorResponse(response: Response): Promise<never> {
305
+ const rawBody = await response.text();
306
+ let errorResponse: TinybirdErrorResponse | undefined;
307
+
308
+ try {
309
+ errorResponse = JSON.parse(rawBody) as TinybirdErrorResponse;
310
+ } catch {
311
+ // ignore parse error and keep raw body
312
+ }
313
+
314
+ const message =
315
+ errorResponse?.error ??
316
+ (rawBody
317
+ ? `Request failed with status ${response.status}: ${rawBody}`
318
+ : `Request failed with status ${response.status}`);
319
+
320
+ throw new TinybirdApiError(
321
+ message,
322
+ response.status,
323
+ rawBody || undefined,
324
+ errorResponse
325
+ );
326
+ }
327
+
328
+ private resolveUrl(path: string): string {
329
+ return new URL(path, `${this.baseUrl}/`).toString();
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Create a decoupled Tinybird API wrapper.
335
+ */
336
+ export function createTinybirdApi(
337
+ config: TinybirdApiConfig
338
+ ): TinybirdApi {
339
+ return new TinybirdApi(config);
340
+ }
341
+
342
+ /**
343
+ * Alias for teams that prefer "wrapper" naming.
344
+ */
345
+ export const createTinybirdApiWrapper = createTinybirdApi;
@@ -8,15 +8,9 @@ import type {
8
8
  IngestResult,
9
9
  QueryOptions,
10
10
  IngestOptions,
11
- TinybirdErrorResponse,
12
11
  } from "./types.js";
13
12
  import { TinybirdError } from "./types.js";
14
- import { createTinybirdFetcher, type TinybirdFetch } from "../api/fetcher.js";
15
-
16
- /**
17
- * Default timeout for requests (30 seconds)
18
- */
19
- const DEFAULT_TIMEOUT = 30000;
13
+ import { TinybirdApi, TinybirdApiError } from "../api/api.js";
20
14
 
21
15
  /**
22
16
  * Resolved token info from dev mode
@@ -57,7 +51,7 @@ interface ResolvedTokenInfo {
57
51
  */
58
52
  export class TinybirdClient {
59
53
  private readonly config: ClientConfig;
60
- private readonly fetchFn: TinybirdFetch;
54
+ private readonly apisByToken = new Map<string, TinybirdApi>();
61
55
  private tokenPromise: Promise<ResolvedTokenInfo> | null = null;
62
56
  private resolvedToken: string | null = null;
63
57
 
@@ -75,8 +69,6 @@ export class TinybirdClient {
75
69
  ...config,
76
70
  baseUrl: config.baseUrl.replace(/\/$/, ""),
77
71
  };
78
-
79
- this.fetchFn = createTinybirdFetcher(config.fetch ?? globalThis.fetch);
80
72
  }
81
73
 
82
74
  /**
@@ -159,39 +151,12 @@ export class TinybirdClient {
159
151
  options: QueryOptions = {}
160
152
  ): Promise<QueryResult<T>> {
161
153
  const token = await this.getToken();
162
- const url = new URL(`/v0/pipes/${pipeName}.json`, this.config.baseUrl);
163
-
164
- // Add parameters to query string
165
- for (const [key, value] of Object.entries(params)) {
166
- if (value !== undefined && value !== null) {
167
- if (Array.isArray(value)) {
168
- // Handle array parameters
169
- for (const item of value) {
170
- url.searchParams.append(key, String(item));
171
- }
172
- } else if (value instanceof Date) {
173
- // Handle Date objects
174
- url.searchParams.set(key, value.toISOString());
175
- } else {
176
- url.searchParams.set(key, String(value));
177
- }
178
- }
179
- }
180
154
 
181
- const response = await this.fetch(url.toString(), {
182
- method: "GET",
183
- headers: {
184
- Authorization: `Bearer ${token}`,
185
- },
186
- signal: this.createAbortSignal(options.timeout, options.signal),
187
- });
188
-
189
- if (!response.ok) {
190
- await this.handleErrorResponse(response);
155
+ try {
156
+ return await this.getApi(token).query<T>(pipeName, params, options);
157
+ } catch (error) {
158
+ this.rethrowApiError(error);
191
159
  }
192
-
193
- const result = (await response.json()) as QueryResult<T>;
194
- return result;
195
160
  }
196
161
 
197
162
  /**
@@ -223,39 +188,13 @@ export class TinybirdClient {
223
188
  events: T[],
224
189
  options: IngestOptions = {}
225
190
  ): Promise<IngestResult> {
226
- if (events.length === 0) {
227
- return { successful_rows: 0, quarantined_rows: 0 };
228
- }
229
-
230
191
  const token = await this.getToken();
231
- const url = new URL("/v0/events", this.config.baseUrl);
232
- url.searchParams.set("name", datasourceName);
233
192
 
234
- if (options.wait !== false) {
235
- url.searchParams.set("wait", "true");
236
- }
237
-
238
- // Convert events to NDJSON format
239
- const ndjson = events
240
- .map((event) => JSON.stringify(this.serializeEvent(event)))
241
- .join("\n");
242
-
243
- const response = await this.fetch(url.toString(), {
244
- method: "POST",
245
- headers: {
246
- Authorization: `Bearer ${token}`,
247
- "Content-Type": "application/x-ndjson",
248
- },
249
- body: ndjson,
250
- signal: this.createAbortSignal(options.timeout, options.signal),
251
- });
252
-
253
- if (!response.ok) {
254
- await this.handleErrorResponse(response);
193
+ try {
194
+ return await this.getApi(token).ingestBatch(datasourceName, events, options);
195
+ } catch (error) {
196
+ this.rethrowApiError(error);
255
197
  }
256
-
257
- const result = (await response.json()) as IngestResult;
258
- return result;
259
198
  }
260
199
 
261
200
  /**
@@ -270,127 +209,41 @@ export class TinybirdClient {
270
209
  options: QueryOptions = {}
271
210
  ): Promise<QueryResult<T>> {
272
211
  const token = await this.getToken();
273
- const url = new URL("/v0/sql", this.config.baseUrl);
274
-
275
- const response = await this.fetch(url.toString(), {
276
- method: "POST",
277
- headers: {
278
- Authorization: `Bearer ${token}`,
279
- "Content-Type": "text/plain",
280
- },
281
- body: sql,
282
- signal: this.createAbortSignal(options.timeout, options.signal),
283
- });
284
-
285
- if (!response.ok) {
286
- await this.handleErrorResponse(response);
287
- }
288
-
289
- const result = (await response.json()) as QueryResult<T>;
290
- return result;
291
- }
292
212
 
293
- /**
294
- * Serialize an event for ingestion, handling Date objects and other special types
295
- */
296
- private serializeEvent<T extends Record<string, unknown>>(
297
- event: T
298
- ): Record<string, unknown> {
299
- const serialized: Record<string, unknown> = {};
300
-
301
- for (const [key, value] of Object.entries(event)) {
302
- if (value instanceof Date) {
303
- // Convert Date to ISO string
304
- serialized[key] = value.toISOString();
305
- } else if (value instanceof Map) {
306
- // Convert Map to object
307
- serialized[key] = Object.fromEntries(value);
308
- } else if (typeof value === "bigint") {
309
- // Convert BigInt to string (ClickHouse will parse it)
310
- serialized[key] = value.toString();
311
- } else if (Array.isArray(value)) {
312
- // Recursively serialize array elements
313
- serialized[key] = value.map((item) =>
314
- typeof item === "object" && item !== null
315
- ? this.serializeEvent(item as Record<string, unknown>)
316
- : item instanceof Date
317
- ? item.toISOString()
318
- : item
319
- );
320
- } else if (typeof value === "object" && value !== null) {
321
- // Recursively serialize nested objects
322
- serialized[key] = this.serializeEvent(value as Record<string, unknown>);
323
- } else {
324
- serialized[key] = value;
325
- }
213
+ try {
214
+ return await this.getApi(token).sql<T>(sql, options);
215
+ } catch (error) {
216
+ this.rethrowApiError(error);
326
217
  }
327
-
328
- return serialized;
329
218
  }
330
219
 
331
- /**
332
- * Create an AbortSignal with timeout
333
- */
334
- private createAbortSignal(
335
- timeout?: number,
336
- existingSignal?: AbortSignal
337
- ): AbortSignal | undefined {
338
- const timeoutMs = timeout ?? this.config.timeout ?? DEFAULT_TIMEOUT;
339
-
340
- // If no timeout and no existing signal, return undefined
341
- if (!timeoutMs && !existingSignal) {
342
- return undefined;
343
- }
344
-
345
- // If only existing signal, return it
346
- if (!timeoutMs && existingSignal) {
347
- return existingSignal;
220
+ private getApi(token: string): TinybirdApi {
221
+ const existing = this.apisByToken.get(token);
222
+ if (existing) {
223
+ return existing;
348
224
  }
349
225
 
350
- // Create timeout signal
351
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
352
-
353
- // If only timeout, return timeout signal
354
- if (!existingSignal) {
355
- return timeoutSignal;
356
- }
226
+ const api = new TinybirdApi({
227
+ baseUrl: this.config.baseUrl,
228
+ token,
229
+ fetch: this.config.fetch,
230
+ timeout: this.config.timeout,
231
+ });
357
232
 
358
- // Combine both signals
359
- return AbortSignal.any([timeoutSignal, existingSignal]);
233
+ this.apisByToken.set(token, api);
234
+ return api;
360
235
  }
361
236
 
362
- /**
363
- * Handle error responses from the API
364
- */
365
- private async handleErrorResponse(response: Response): Promise<never> {
366
- let errorResponse: TinybirdErrorResponse | undefined;
367
- let rawBody: string | undefined;
368
-
369
- try {
370
- rawBody = await response.text();
371
- errorResponse = JSON.parse(rawBody) as TinybirdErrorResponse;
372
- } catch {
373
- // Failed to parse error response - include raw body in message
374
- if (rawBody) {
375
- throw new TinybirdError(
376
- `Request failed with status ${response.status}: ${rawBody}`,
377
- response.status,
378
- undefined
379
- );
380
- }
237
+ private rethrowApiError(error: unknown): never {
238
+ if (error instanceof TinybirdApiError) {
239
+ throw new TinybirdError(
240
+ error.message,
241
+ error.statusCode,
242
+ error.response
243
+ );
381
244
  }
382
245
 
383
- const message =
384
- errorResponse?.error ?? `Request failed with status ${response.status}`;
385
-
386
- throw new TinybirdError(message, response.status, errorResponse);
387
- }
388
-
389
- /**
390
- * Internal fetch wrapper
391
- */
392
- private fetch(url: string, init?: RequestInit): Promise<Response> {
393
- return this.fetchFn(url, init);
246
+ throw error;
394
247
  }
395
248
  }
396
249
 
package/src/index.ts CHANGED
@@ -214,6 +214,20 @@ export type {
214
214
  TypedDatasourceIngest,
215
215
  } from "./client/types.js";
216
216
 
217
+ // ============ Public Tinybird API ============
218
+ export {
219
+ TinybirdApi,
220
+ TinybirdApiError,
221
+ createTinybirdApi,
222
+ createTinybirdApiWrapper,
223
+ } from "./api/api.js";
224
+ export type {
225
+ TinybirdApiConfig,
226
+ TinybirdApiQueryOptions,
227
+ TinybirdApiIngestOptions,
228
+ TinybirdApiRequestInit,
229
+ } from "./api/api.js";
230
+
217
231
  // ============ Preview Environment ============
218
232
  export {
219
233
  isPreviewEnvironment,