@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.
- package/README.md +66 -0
- package/dist/api/api.d.ts +89 -0
- package/dist/api/api.d.ts.map +1 -0
- package/dist/api/api.js +218 -0
- package/dist/api/api.js.map +1 -0
- package/dist/api/api.test.d.ts +2 -0
- package/dist/api/api.test.d.ts.map +1 -0
- package/dist/api/api.test.js +226 -0
- package/dist/api/api.test.js.map +1 -0
- package/dist/cli/index.js +8 -9
- package/dist/cli/index.js.map +1 -1
- package/dist/client/base.d.ts +3 -17
- package/dist/client/base.d.ts.map +1 -1
- package/dist/client/base.js +31 -153
- package/dist/client/base.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +7 -2
- package/src/api/api.test.ts +295 -0
- package/src/api/api.ts +345 -0
- package/src/cli/index.ts +8 -10
- package/src/client/base.ts +34 -181
- package/src/index.ts +14 -0
|
@@ -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
|
-
//
|
|
235
|
-
const changes: ResourceChange[] = [];
|
|
236
|
-
|
|
234
|
+
// Show datasource changes
|
|
237
235
|
if (deploy.datasources) {
|
|
238
236
|
for (const name of deploy.datasources.created) {
|
|
239
|
-
|
|
237
|
+
output.showResourceChange(`${name}.datasource`, "created");
|
|
240
238
|
}
|
|
241
239
|
for (const name of deploy.datasources.changed) {
|
|
242
|
-
|
|
240
|
+
output.showResourceChange(`${name}.datasource`, "changed");
|
|
243
241
|
}
|
|
244
242
|
for (const name of deploy.datasources.deleted) {
|
|
245
|
-
|
|
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
|
-
|
|
250
|
+
output.showResourceChange(`${name}.pipe`, "created");
|
|
252
251
|
}
|
|
253
252
|
for (const name of deploy.pipes.changed) {
|
|
254
|
-
|
|
253
|
+
output.showResourceChange(`${name}.pipe`, "changed");
|
|
255
254
|
}
|
|
256
255
|
for (const name of deploy.pipes.deleted) {
|
|
257
|
-
|
|
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
|
}
|