@tinybirdco/sdk 0.0.18 → 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.
@@ -0,0 +1,295 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
+ import { setupServer } from "msw/node";
3
+ import { http, HttpResponse } from "msw";
4
+ import { createTinybirdApi } from "./api.js";
5
+ import { TINYBIRD_FROM_PARAM } from "./fetcher.js";
6
+ import { BASE_URL } from "../test/handlers.js";
7
+
8
+ const server = setupServer();
9
+
10
+ beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
11
+ afterEach(() => server.resetHandlers());
12
+ afterAll(() => server.close());
13
+
14
+ describe("TinybirdApi", () => {
15
+ it("sends authorization header and from=ts-sdk param", async () => {
16
+ let authorizationHeader: string | null = null;
17
+ let fromParam: string | null = null;
18
+
19
+ server.use(
20
+ http.get(`${BASE_URL}/v1/workspace`, ({ request }) => {
21
+ authorizationHeader = request.headers.get("Authorization");
22
+ const url = new URL(request.url);
23
+ fromParam = url.searchParams.get("from");
24
+ return HttpResponse.json({ ok: true });
25
+ })
26
+ );
27
+
28
+ const api = createTinybirdApi({
29
+ baseUrl: BASE_URL,
30
+ token: "p.default-token",
31
+ });
32
+
33
+ await api.request("/v1/workspace");
34
+
35
+ expect(authorizationHeader).toBe("Bearer p.default-token");
36
+ expect(fromParam).toBe(TINYBIRD_FROM_PARAM);
37
+ });
38
+
39
+ it("resolves relative paths and preserves query params", async () => {
40
+ let fooParam: string | null = null;
41
+ let fromParam: string | null = null;
42
+
43
+ server.use(
44
+ http.get(`${BASE_URL}/v1/build`, ({ request }) => {
45
+ const url = new URL(request.url);
46
+ fooParam = url.searchParams.get("foo");
47
+ fromParam = url.searchParams.get("from");
48
+ return HttpResponse.json({ ok: true });
49
+ })
50
+ );
51
+
52
+ const api = createTinybirdApi({
53
+ baseUrl: `${BASE_URL}/`,
54
+ token: "p.default-token",
55
+ });
56
+
57
+ await api.request("v1/build?foo=bar");
58
+
59
+ expect(fooParam).toBe("bar");
60
+ expect(fromParam).toBe(TINYBIRD_FROM_PARAM);
61
+ });
62
+
63
+ it("allows per-request token override", async () => {
64
+ let authorizationHeader: string | null = null;
65
+
66
+ server.use(
67
+ http.get(`${BASE_URL}/v1/workspace`, ({ request }) => {
68
+ authorizationHeader = request.headers.get("Authorization");
69
+ return HttpResponse.json({ ok: true });
70
+ })
71
+ );
72
+
73
+ const api = createTinybirdApi({
74
+ baseUrl: BASE_URL,
75
+ token: "p.default-token",
76
+ });
77
+
78
+ await api.request("/v1/workspace", { token: "p.override-token" });
79
+
80
+ expect(authorizationHeader).toBe("Bearer p.override-token");
81
+ });
82
+
83
+ it("queries endpoint params via tinybirdApi.query", async () => {
84
+ let fromParam: string | null = null;
85
+ let startDateParam: string | null = null;
86
+ let limitParam: string | null = null;
87
+ let tagsParams: string[] = [];
88
+
89
+ server.use(
90
+ http.get(`${BASE_URL}/v0/pipes/top_pages.json`, ({ request }) => {
91
+ const url = new URL(request.url);
92
+ fromParam = url.searchParams.get("from");
93
+ startDateParam = url.searchParams.get("start_date");
94
+ limitParam = url.searchParams.get("limit");
95
+ tagsParams = url.searchParams.getAll("tags");
96
+
97
+ return HttpResponse.json({
98
+ data: [{ pathname: "/", views: 1 }],
99
+ meta: [
100
+ { name: "pathname", type: "String" },
101
+ { name: "views", type: "UInt64" },
102
+ ],
103
+ rows: 1,
104
+ statistics: {
105
+ elapsed: 0.001,
106
+ rows_read: 1,
107
+ bytes_read: 10,
108
+ },
109
+ });
110
+ })
111
+ );
112
+
113
+ const api = createTinybirdApi({
114
+ baseUrl: BASE_URL,
115
+ token: "p.default-token",
116
+ });
117
+
118
+ const result = await api.query<{ pathname: string; views: number }>("top_pages", {
119
+ start_date: new Date("2024-01-01T00:00:00.000Z"),
120
+ limit: 5,
121
+ tags: ["a", "b"],
122
+ });
123
+
124
+ expect(result.rows).toBe(1);
125
+ expect(result.data[0]).toEqual({ pathname: "/", views: 1 });
126
+ expect(fromParam).toBe(TINYBIRD_FROM_PARAM);
127
+ expect(startDateParam).toBe("2024-01-01T00:00:00.000Z");
128
+ expect(limitParam).toBe("5");
129
+ expect(tagsParams).toEqual(["a", "b"]);
130
+ });
131
+
132
+ it("ingests rows via tinybirdApi.ingest", async () => {
133
+ let datasourceName: string | null = null;
134
+ let waitParam: string | null = null;
135
+ let fromParam: string | null = null;
136
+ let contentType: string | null = null;
137
+ let parsedBody: Record<string, unknown> | null = null;
138
+
139
+ server.use(
140
+ http.post(`${BASE_URL}/v0/events`, async ({ request }) => {
141
+ const url = new URL(request.url);
142
+ datasourceName = url.searchParams.get("name");
143
+ waitParam = url.searchParams.get("wait");
144
+ fromParam = url.searchParams.get("from");
145
+ contentType = request.headers.get("content-type");
146
+
147
+ const rawBody = await request.text();
148
+ parsedBody = JSON.parse(rawBody);
149
+
150
+ return HttpResponse.json({
151
+ successful_rows: 1,
152
+ quarantined_rows: 0,
153
+ });
154
+ })
155
+ );
156
+
157
+ const api = createTinybirdApi({
158
+ baseUrl: BASE_URL,
159
+ token: "p.default-token",
160
+ });
161
+
162
+ const result = await api.ingest("events", {
163
+ timestamp: new Date("2024-01-01T00:00:00.000Z"),
164
+ count: 10n,
165
+ payload: new Map([["k", "v"]]),
166
+ nested: {
167
+ when: new Date("2024-01-02T00:00:00.000Z"),
168
+ },
169
+ });
170
+
171
+ expect(result).toEqual({ successful_rows: 1, quarantined_rows: 0 });
172
+ expect(datasourceName).toBe("events");
173
+ expect(waitParam).toBe("true");
174
+ expect(fromParam).toBe(TINYBIRD_FROM_PARAM);
175
+ expect(contentType).toBe("application/x-ndjson");
176
+ expect(parsedBody).toEqual({
177
+ timestamp: "2024-01-01T00:00:00.000Z",
178
+ count: "10",
179
+ payload: { k: "v" },
180
+ nested: { when: "2024-01-02T00:00:00.000Z" },
181
+ });
182
+ });
183
+
184
+ it("executes raw SQL via tinybirdApi.sql", async () => {
185
+ let rawSql: string | null = null;
186
+ let contentType: string | null = null;
187
+
188
+ server.use(
189
+ http.post(`${BASE_URL}/v0/sql`, async ({ request }) => {
190
+ contentType = request.headers.get("content-type");
191
+ rawSql = await request.text();
192
+
193
+ return HttpResponse.json({
194
+ data: [{ value: 1 }],
195
+ meta: [{ name: "value", type: "UInt8" }],
196
+ rows: 1,
197
+ statistics: {
198
+ elapsed: 0.001,
199
+ rows_read: 1,
200
+ bytes_read: 1,
201
+ },
202
+ });
203
+ })
204
+ );
205
+
206
+ const api = createTinybirdApi({
207
+ baseUrl: BASE_URL,
208
+ token: "p.default-token",
209
+ });
210
+
211
+ const result = await api.sql<{ value: number }>("SELECT 1 AS value");
212
+
213
+ expect(result.data[0]?.value).toBe(1);
214
+ expect(contentType).toBe("text/plain");
215
+ expect(rawSql).toBe("SELECT 1 AS value");
216
+ });
217
+
218
+ it("returns zero counts for empty ingest batches", async () => {
219
+ const api = createTinybirdApi({
220
+ baseUrl: BASE_URL,
221
+ token: "p.default-token",
222
+ });
223
+
224
+ const result = await api.ingestBatch("events", []);
225
+
226
+ expect(result).toEqual({ successful_rows: 0, quarantined_rows: 0 });
227
+ });
228
+
229
+ it("parses JSON responses", async () => {
230
+ server.use(
231
+ http.get(`${BASE_URL}/v1/workspace`, () => {
232
+ return HttpResponse.json({ id: "ws_123", name: "main" });
233
+ })
234
+ );
235
+
236
+ const api = createTinybirdApi({
237
+ baseUrl: BASE_URL,
238
+ token: "p.default-token",
239
+ });
240
+
241
+ const result = await api.requestJson<{ id: string; name: string }>("/v1/workspace");
242
+
243
+ expect(result.id).toBe("ws_123");
244
+ expect(result.name).toBe("main");
245
+ });
246
+
247
+ it("throws TinybirdApiError for non-OK responses", async () => {
248
+ server.use(
249
+ http.get(`${BASE_URL}/v1/workspace`, () => {
250
+ return new HttpResponse("Unauthorized", { status: 401 });
251
+ })
252
+ );
253
+
254
+ const api = createTinybirdApi({
255
+ baseUrl: BASE_URL,
256
+ token: "p.default-token",
257
+ });
258
+
259
+ await expect(api.requestJson("/v1/workspace")).rejects.toMatchObject({
260
+ name: "TinybirdApiError",
261
+ statusCode: 401,
262
+ responseBody: "Unauthorized",
263
+ });
264
+ });
265
+
266
+ it("throws TinybirdApiError with parsed API details", async () => {
267
+ server.use(
268
+ http.get(`${BASE_URL}/v0/pipes/broken.json`, () => {
269
+ return HttpResponse.json(
270
+ {
271
+ error: "Invalid query",
272
+ status: 400,
273
+ documentation: "https://www.tinybird.co/docs",
274
+ },
275
+ { status: 400 }
276
+ );
277
+ })
278
+ );
279
+
280
+ const api = createTinybirdApi({
281
+ baseUrl: BASE_URL,
282
+ token: "p.default-token",
283
+ });
284
+
285
+ await expect(api.query("broken")).rejects.toMatchObject({
286
+ name: "TinybirdApiError",
287
+ statusCode: 400,
288
+ message: "Invalid query",
289
+ response: {
290
+ error: "Invalid query",
291
+ status: 400,
292
+ },
293
+ });
294
+ });
295
+ });
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;
package/src/cli/index.ts CHANGED
@@ -231,34 +231,32 @@ function createCli(): Command {
231
231
  if (deploy.result === "no_changes") {
232
232
  output.showNoChanges();
233
233
  } else {
234
- // Collect all changes for table display
235
- const changes: ResourceChange[] = [];
236
-
234
+ // Show datasource changes
237
235
  if (deploy.datasources) {
238
236
  for (const name of deploy.datasources.created) {
239
- changes.push({ status: "new", name, type: "datasource" });
237
+ output.showResourceChange(`${name}.datasource`, "created");
240
238
  }
241
239
  for (const name of deploy.datasources.changed) {
242
- changes.push({ status: "modified", name, type: "datasource" });
240
+ output.showResourceChange(`${name}.datasource`, "changed");
243
241
  }
244
242
  for (const name of deploy.datasources.deleted) {
245
- changes.push({ status: "deleted", name, type: "datasource" });
243
+ output.showResourceChange(`${name}.datasource`, "deleted");
246
244
  }
247
245
  }
248
246
 
247
+ // Show pipe changes
249
248
  if (deploy.pipes) {
250
249
  for (const name of deploy.pipes.created) {
251
- changes.push({ status: "new", name, type: "pipe" });
250
+ output.showResourceChange(`${name}.pipe`, "created");
252
251
  }
253
252
  for (const name of deploy.pipes.changed) {
254
- changes.push({ status: "modified", name, type: "pipe" });
253
+ output.showResourceChange(`${name}.pipe`, "changed");
255
254
  }
256
255
  for (const name of deploy.pipes.deleted) {
257
- changes.push({ status: "deleted", name, type: "pipe" });
256
+ output.showResourceChange(`${name}.pipe`, "deleted");
258
257
  }
259
258
  }
260
259
 
261
- output.showChangesTable(changes);
262
260
  output.showBuildSuccess(result.durationMs);
263
261
  }
264
262
  }