@tinybirdco/sdk 0.0.36 → 0.0.37

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.
@@ -292,4 +292,226 @@ describe("TinybirdApi", () => {
292
292
  },
293
293
  });
294
294
  });
295
+
296
+ describe("appendDatasource", () => {
297
+ it("appends data from URL", async () => {
298
+ let datasourceName: string | null = null;
299
+ let modeParam: string | null = null;
300
+ let formatParam: string | null = null;
301
+ let contentType: string | null = null;
302
+ let requestBody: string | null = null;
303
+
304
+ server.use(
305
+ http.post(`${BASE_URL}/v0/datasources`, async ({ request }) => {
306
+ const url = new URL(request.url);
307
+ datasourceName = url.searchParams.get("name");
308
+ modeParam = url.searchParams.get("mode");
309
+ formatParam = url.searchParams.get("format");
310
+ contentType = request.headers.get("content-type");
311
+ requestBody = await request.text();
312
+
313
+ return HttpResponse.json({
314
+ successful_rows: 100,
315
+ quarantined_rows: 0,
316
+ import_id: "import_123",
317
+ });
318
+ })
319
+ );
320
+
321
+ const api = createTinybirdApi({
322
+ baseUrl: BASE_URL,
323
+ token: "p.default-token",
324
+ });
325
+
326
+ const result = await api.appendDatasource("events", {
327
+ url: "https://example.com/data.csv",
328
+ });
329
+
330
+ expect(result).toEqual({
331
+ successful_rows: 100,
332
+ quarantined_rows: 0,
333
+ import_id: "import_123",
334
+ });
335
+ expect(datasourceName).toBe("events");
336
+ expect(modeParam).toBe("append");
337
+ expect(formatParam).toBe("csv");
338
+ expect(contentType).toBe("application/x-www-form-urlencoded");
339
+ expect(requestBody).toBe("url=https%3A%2F%2Fexample.com%2Fdata.csv");
340
+ });
341
+
342
+ it("detects ndjson format from URL extension", async () => {
343
+ let formatParam: string | null = null;
344
+
345
+ server.use(
346
+ http.post(`${BASE_URL}/v0/datasources`, async ({ request }) => {
347
+ const url = new URL(request.url);
348
+ formatParam = url.searchParams.get("format");
349
+ return HttpResponse.json({ successful_rows: 1, quarantined_rows: 0 });
350
+ })
351
+ );
352
+
353
+ const api = createTinybirdApi({
354
+ baseUrl: BASE_URL,
355
+ token: "p.default-token",
356
+ });
357
+
358
+ await api.appendDatasource("events", {
359
+ url: "https://example.com/data.ndjson",
360
+ });
361
+
362
+ expect(formatParam).toBe("ndjson");
363
+ });
364
+
365
+ it("detects jsonl as ndjson format", async () => {
366
+ let formatParam: string | null = null;
367
+
368
+ server.use(
369
+ http.post(`${BASE_URL}/v0/datasources`, async ({ request }) => {
370
+ const url = new URL(request.url);
371
+ formatParam = url.searchParams.get("format");
372
+ return HttpResponse.json({ successful_rows: 1, quarantined_rows: 0 });
373
+ })
374
+ );
375
+
376
+ const api = createTinybirdApi({
377
+ baseUrl: BASE_URL,
378
+ token: "p.default-token",
379
+ });
380
+
381
+ await api.appendDatasource("events", {
382
+ url: "https://example.com/data.jsonl",
383
+ });
384
+
385
+ expect(formatParam).toBe("ndjson");
386
+ });
387
+
388
+ it("detects parquet format from URL extension", async () => {
389
+ let formatParam: string | null = null;
390
+
391
+ server.use(
392
+ http.post(`${BASE_URL}/v0/datasources`, async ({ request }) => {
393
+ const url = new URL(request.url);
394
+ formatParam = url.searchParams.get("format");
395
+ return HttpResponse.json({ successful_rows: 1, quarantined_rows: 0 });
396
+ })
397
+ );
398
+
399
+ const api = createTinybirdApi({
400
+ baseUrl: BASE_URL,
401
+ token: "p.default-token",
402
+ });
403
+
404
+ await api.appendDatasource("events", {
405
+ url: "https://example.com/data.parquet",
406
+ });
407
+
408
+ expect(formatParam).toBe("parquet");
409
+ });
410
+
411
+ it("strips query string when detecting format", async () => {
412
+ let formatParam: string | null = null;
413
+
414
+ server.use(
415
+ http.post(`${BASE_URL}/v0/datasources`, async ({ request }) => {
416
+ const url = new URL(request.url);
417
+ formatParam = url.searchParams.get("format");
418
+ return HttpResponse.json({ successful_rows: 1, quarantined_rows: 0 });
419
+ })
420
+ );
421
+
422
+ const api = createTinybirdApi({
423
+ baseUrl: BASE_URL,
424
+ token: "p.default-token",
425
+ });
426
+
427
+ await api.appendDatasource("events", {
428
+ url: "https://example.com/data.csv?token=abc",
429
+ });
430
+
431
+ expect(formatParam).toBe("csv");
432
+ });
433
+
434
+ it("includes CSV dialect options", async () => {
435
+ let delimiterParam: string | null = null;
436
+ let newLineParam: string | null = null;
437
+ let escapeCharParam: string | null = null;
438
+
439
+ server.use(
440
+ http.post(`${BASE_URL}/v0/datasources`, async ({ request }) => {
441
+ const url = new URL(request.url);
442
+ delimiterParam = url.searchParams.get("dialect_delimiter");
443
+ newLineParam = url.searchParams.get("dialect_new_line");
444
+ escapeCharParam = url.searchParams.get("dialect_escapechar");
445
+ return HttpResponse.json({ successful_rows: 1, quarantined_rows: 0 });
446
+ })
447
+ );
448
+
449
+ const api = createTinybirdApi({
450
+ baseUrl: BASE_URL,
451
+ token: "p.default-token",
452
+ });
453
+
454
+ await api.appendDatasource("events", {
455
+ url: "https://example.com/data.csv",
456
+ csvDialect: {
457
+ delimiter: ";",
458
+ newLine: "\r\n",
459
+ escapeChar: "\\",
460
+ },
461
+ });
462
+
463
+ expect(delimiterParam).toBe(";");
464
+ expect(newLineParam).toBe("\r\n");
465
+ expect(escapeCharParam).toBe("\\");
466
+ });
467
+
468
+ it("throws error when neither url nor file is provided", async () => {
469
+ const api = createTinybirdApi({
470
+ baseUrl: BASE_URL,
471
+ token: "p.default-token",
472
+ });
473
+
474
+ await expect(api.appendDatasource("events", {})).rejects.toThrow(
475
+ "Either 'url' or 'file' must be provided in options"
476
+ );
477
+ });
478
+
479
+ it("throws error when both url and file are provided", async () => {
480
+ const api = createTinybirdApi({
481
+ baseUrl: BASE_URL,
482
+ token: "p.default-token",
483
+ });
484
+
485
+ await expect(
486
+ api.appendDatasource("events", {
487
+ url: "https://example.com/data.csv",
488
+ file: "./data.csv",
489
+ })
490
+ ).rejects.toThrow("Only one of 'url' or 'file' can be provided, not both");
491
+ });
492
+
493
+ it("allows per-request token override", async () => {
494
+ let authorizationHeader: string | null = null;
495
+
496
+ server.use(
497
+ http.post(`${BASE_URL}/v0/datasources`, async ({ request }) => {
498
+ authorizationHeader = request.headers.get("Authorization");
499
+ return HttpResponse.json({ successful_rows: 1, quarantined_rows: 0 });
500
+ })
501
+ );
502
+
503
+ const api = createTinybirdApi({
504
+ baseUrl: BASE_URL,
505
+ token: "p.default-token",
506
+ });
507
+
508
+ await api.appendDatasource(
509
+ "events",
510
+ { url: "https://example.com/data.csv" },
511
+ { token: "p.override-token" }
512
+ );
513
+
514
+ expect(authorizationHeader).toBe("Bearer p.override-token");
515
+ });
516
+ });
295
517
  });
package/src/api/api.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { createTinybirdFetcher, type TinybirdFetch } from "./fetcher.js";
2
2
  import type {
3
+ AppendOptions,
4
+ AppendResult,
3
5
  IngestOptions,
4
6
  IngestResult,
5
7
  QueryOptions,
@@ -41,6 +43,11 @@ export interface TinybirdApiIngestOptions extends IngestOptions {
41
43
  token?: string;
42
44
  }
43
45
 
46
+ export interface TinybirdApiAppendOptions extends Omit<AppendOptions, 'url' | 'file'> {
47
+ /** Optional token override for this request */
48
+ token?: string;
49
+ }
50
+
44
51
  /**
45
52
  * Error thrown by TinybirdApi when a response is not OK
46
53
  */
@@ -242,6 +249,116 @@ export class TinybirdApi {
242
249
  return (await response.json()) as QueryResult<T>;
243
250
  }
244
251
 
252
+ /**
253
+ * Append data to a datasource from a URL or local file
254
+ */
255
+ async appendDatasource(
256
+ datasourceName: string,
257
+ options: AppendOptions,
258
+ apiOptions: TinybirdApiAppendOptions = {}
259
+ ): Promise<AppendResult> {
260
+ const { url: sourceUrl, file: filePath } = options;
261
+
262
+ if (!sourceUrl && !filePath) {
263
+ throw new Error("Either 'url' or 'file' must be provided in options");
264
+ }
265
+
266
+ if (sourceUrl && filePath) {
267
+ throw new Error("Only one of 'url' or 'file' can be provided, not both");
268
+ }
269
+
270
+ const url = new URL("/v0/datasources", `${this.baseUrl}/`);
271
+ url.searchParams.set("name", datasourceName);
272
+ url.searchParams.set("mode", "append");
273
+
274
+ // Auto-detect format from file/url extension
275
+ const format = this.detectFormat(sourceUrl ?? filePath!);
276
+ if (format) {
277
+ url.searchParams.set("format", format);
278
+ }
279
+
280
+ // Add CSV dialect options if applicable
281
+ if (options.csvDialect) {
282
+ if (options.csvDialect.delimiter) {
283
+ url.searchParams.set("dialect_delimiter", options.csvDialect.delimiter);
284
+ }
285
+ if (options.csvDialect.newLine) {
286
+ url.searchParams.set("dialect_new_line", options.csvDialect.newLine);
287
+ }
288
+ if (options.csvDialect.escapeChar) {
289
+ url.searchParams.set("dialect_escapechar", options.csvDialect.escapeChar);
290
+ }
291
+ }
292
+
293
+ let response: Response;
294
+
295
+ if (sourceUrl) {
296
+ // Remote URL: send as form-urlencoded with url parameter
297
+ response = await this.request(url.toString(), {
298
+ method: "POST",
299
+ token: apiOptions.token,
300
+ headers: {
301
+ "Content-Type": "application/x-www-form-urlencoded",
302
+ },
303
+ body: `url=${encodeURIComponent(sourceUrl)}`,
304
+ signal: this.createAbortSignal(options.timeout ?? apiOptions.timeout, options.signal ?? apiOptions.signal),
305
+ });
306
+ } else {
307
+ // Local file: send as multipart form data
308
+ const formData = await this.createFileFormData(filePath!);
309
+ response = await this.request(url.toString(), {
310
+ method: "POST",
311
+ token: apiOptions.token,
312
+ body: formData,
313
+ signal: this.createAbortSignal(options.timeout ?? apiOptions.timeout, options.signal ?? apiOptions.signal),
314
+ });
315
+ }
316
+
317
+ if (!response.ok) {
318
+ await this.handleErrorResponse(response);
319
+ }
320
+
321
+ return (await response.json()) as AppendResult;
322
+ }
323
+
324
+ /**
325
+ * Detect format from file path or URL extension
326
+ */
327
+ private detectFormat(source: string): "csv" | "ndjson" | "parquet" | undefined {
328
+ // Remove query string if present
329
+ const pathOnly = source.split("?")[0];
330
+ const extension = pathOnly.split(".").pop()?.toLowerCase();
331
+
332
+ switch (extension) {
333
+ case "csv":
334
+ return "csv";
335
+ case "ndjson":
336
+ case "jsonl":
337
+ return "ndjson";
338
+ case "parquet":
339
+ return "parquet";
340
+ default:
341
+ return undefined;
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Create FormData for file upload
347
+ */
348
+ private async createFileFormData(filePath: string): Promise<FormData> {
349
+ // Dynamic import for Node.js fs module (browser-safe)
350
+ const fs = await import("node:fs");
351
+ const path = await import("node:path");
352
+
353
+ const fileContent = await fs.promises.readFile(filePath);
354
+ const fileName = path.basename(filePath);
355
+
356
+ const formData = new FormData();
357
+ formData.append("csv", new Blob([fileContent]), fileName);
358
+
359
+ return formData;
360
+ }
361
+
245
362
  private createAbortSignal(
246
363
  timeout?: number,
247
364
  existingSignal?: AbortSignal
@@ -22,7 +22,6 @@ import { getGitRoot } from "../git.js";
22
22
  import { fetchAllResources } from "../../api/resources.js";
23
23
  import { generateCombinedFile } from "../../codegen/index.js";
24
24
  import { execSync } from "child_process";
25
- import { setTimeout as sleep } from "node:timers/promises";
26
25
  import {
27
26
  detectPackageManager,
28
27
  getPackageManagerAddCmd,
@@ -313,6 +312,8 @@ export interface InitOptions {
313
312
  includeCdWorkflow?: boolean;
314
313
  /** Git provider for workflow templates */
315
314
  workflowProvider?: "github" | "gitlab";
315
+ /** Skip auto-installing @tinybirdco/sdk dependency */
316
+ skipDependencyInstall?: boolean;
316
317
  }
317
318
 
318
319
  /**
@@ -415,6 +416,8 @@ export async function runInit(options: InitOptions = {}): Promise<InitResult> {
415
416
  const cwd = options.cwd ?? process.cwd();
416
417
  const force = options.force ?? false;
417
418
  const skipLogin = options.skipLogin ?? false;
419
+ const skipDependencyInstall =
420
+ options.skipDependencyInstall ?? Boolean(process.env.VITEST);
418
421
 
419
422
  const created: string[] = [];
420
423
  const skipped: string[] = [];
@@ -739,14 +742,13 @@ export async function runInit(options: InitOptions = {}): Promise<InitResult> {
739
742
  }
740
743
 
741
744
  // Install @tinybirdco/sdk if not already installed
742
- if (!hasTinybirdSdkDependency(cwd)) {
745
+ if (!skipDependencyInstall && !hasTinybirdSdkDependency(cwd)) {
743
746
  const s = p.spinner();
744
747
  s.start("Installing dependencies");
745
748
  const packageManager = detectPackageManager(cwd);
746
749
  const addCmd = getPackageManagerAddCmd(packageManager);
747
750
  try {
748
751
  execSync(`${addCmd} @tinybirdco/sdk`, { cwd, stdio: "pipe" });
749
- await sleep(1000);
750
752
  s.stop("Installed dependencies");
751
753
  created.push("@tinybirdco/sdk");
752
754
  } catch (error) {
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { TinybirdClient, createClient } from "./base.js";
3
+ import type { DatasourcesNamespace } from "./types.js";
3
4
 
4
5
  describe("TinybirdClient", () => {
5
6
  describe("constructor", () => {
@@ -137,4 +138,35 @@ describe("TinybirdClient", () => {
137
138
  expect(context.baseUrl).toBe("https://api.tinybird.co");
138
139
  });
139
140
  });
141
+
142
+ describe("datasources", () => {
143
+ it("exposes datasources namespace", () => {
144
+ const client = createClient({
145
+ baseUrl: "https://api.tinybird.co",
146
+ token: "test-token",
147
+ });
148
+
149
+ expect(client.datasources).toBeDefined();
150
+ });
151
+
152
+ it("datasources namespace has append method", () => {
153
+ const client = createClient({
154
+ baseUrl: "https://api.tinybird.co",
155
+ token: "test-token",
156
+ });
157
+
158
+ expect(typeof client.datasources.append).toBe("function");
159
+ });
160
+
161
+ it("datasources conforms to DatasourcesNamespace interface", () => {
162
+ const client = createClient({
163
+ baseUrl: "https://api.tinybird.co",
164
+ token: "test-token",
165
+ });
166
+
167
+ const datasources: DatasourcesNamespace = client.datasources;
168
+ expect(datasources).toBeDefined();
169
+ expect(typeof datasources.append).toBe("function");
170
+ });
171
+ });
140
172
  });
@@ -3,8 +3,11 @@
3
3
  */
4
4
 
5
5
  import type {
6
+ AppendOptions,
7
+ AppendResult,
6
8
  ClientConfig,
7
9
  ClientContext,
10
+ DatasourcesNamespace,
8
11
  QueryResult,
9
12
  IngestResult,
10
13
  QueryOptions,
@@ -57,6 +60,11 @@ export class TinybirdClient {
57
60
  private contextPromise: Promise<ClientContext> | null = null;
58
61
  private resolvedContext: ClientContext | null = null;
59
62
 
63
+ /**
64
+ * Datasources namespace for import operations
65
+ */
66
+ readonly datasources: DatasourcesNamespace;
67
+
60
68
  constructor(config: ClientConfig) {
61
69
  // Validate required config
62
70
  if (!config.baseUrl) {
@@ -71,6 +79,46 @@ export class TinybirdClient {
71
79
  ...config,
72
80
  baseUrl: config.baseUrl.replace(/\/$/, ""),
73
81
  };
82
+
83
+ // Initialize datasources namespace
84
+ this.datasources = {
85
+ append: (datasourceName: string, options: AppendOptions): Promise<AppendResult> => {
86
+ return this.appendDatasource(datasourceName, options);
87
+ },
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Append data to a datasource from a URL or local file
93
+ *
94
+ * @param datasourceName - Name of the datasource
95
+ * @param options - Append options including url or file source
96
+ * @returns Append result
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * // Append from URL
101
+ * await client.datasources.append('events', {
102
+ * url: 'https://example.com/data.csv',
103
+ * });
104
+ *
105
+ * // Append from local file
106
+ * await client.datasources.append('events', {
107
+ * file: './data/events.ndjson',
108
+ * });
109
+ * ```
110
+ */
111
+ private async appendDatasource(
112
+ datasourceName: string,
113
+ options: AppendOptions
114
+ ): Promise<AppendResult> {
115
+ const token = await this.getToken();
116
+
117
+ try {
118
+ return await this.getApi(token).appendDatasource(datasourceName, options);
119
+ } catch (error) {
120
+ this.rethrowApiError(error);
121
+ }
74
122
  }
75
123
 
76
124
  /**
@@ -189,3 +189,57 @@ export interface TypedDatasourceIngest<TRow> {
189
189
  /** Send multiple events in a batch */
190
190
  sendBatch(events: TRow[], options?: IngestOptions): Promise<IngestResult>;
191
191
  }
192
+
193
+ /**
194
+ * Data format for append operations
195
+ */
196
+ export type AppendFormat = "csv" | "ndjson" | "parquet";
197
+
198
+ /**
199
+ * CSV dialect options for customizing CSV parsing
200
+ */
201
+ export interface CsvDialectOptions {
202
+ /** Field delimiter character (default: ',') */
203
+ delimiter?: string;
204
+ /** New line character(s) (default: '\n') */
205
+ newLine?: string;
206
+ /** Escape character for special characters */
207
+ escapeChar?: string;
208
+ }
209
+
210
+ /**
211
+ * Options for append operation
212
+ * Exactly one of `url` or `file` must be provided
213
+ */
214
+ export interface AppendOptions {
215
+ /** Remote URL to import data from */
216
+ url?: string;
217
+ /** Local file path to import data from */
218
+ file?: string;
219
+ /** CSV dialect options (only applicable for csv format) */
220
+ csvDialect?: CsvDialectOptions;
221
+ /** Request timeout in milliseconds */
222
+ timeout?: number;
223
+ /** AbortController signal for cancellation */
224
+ signal?: AbortSignal;
225
+ }
226
+
227
+ /**
228
+ * Result of an append operation
229
+ */
230
+ export interface AppendResult {
231
+ /** Number of rows successfully appended */
232
+ successful_rows: number;
233
+ /** Number of rows that failed to append (quarantined) */
234
+ quarantined_rows: number;
235
+ /** Import ID for tracking */
236
+ import_id?: string;
237
+ }
238
+
239
+ /**
240
+ * Datasources namespace interface for raw client
241
+ */
242
+ export interface DatasourcesNamespace {
243
+ /** Append data to a datasource from a URL or file */
244
+ append(datasourceName: string, options: AppendOptions): Promise<AppendResult>;
245
+ }
package/src/index.ts CHANGED
@@ -202,8 +202,12 @@ export type {
202
202
  export { TinybirdClient, createClient } from "./client/base.js";
203
203
  export { TinybirdError } from "./client/types.js";
204
204
  export type {
205
+ AppendOptions,
206
+ AppendResult,
205
207
  ClientConfig,
206
208
  ClientContext,
209
+ CsvDialectOptions,
210
+ DatasourcesNamespace,
207
211
  QueryResult,
208
212
  IngestResult,
209
213
  QueryOptions,
@@ -226,6 +230,7 @@ export type {
226
230
  TinybirdApiConfig,
227
231
  TinybirdApiQueryOptions,
228
232
  TinybirdApiIngestOptions,
233
+ TinybirdApiAppendOptions,
229
234
  TinybirdApiRequestInit,
230
235
  } from "./api/api.js";
231
236
 
@@ -93,6 +93,37 @@ describe("Project Schema", () => {
93
93
  expect(typeof project.tinybird.ingest.eventsBatch).toBe("function");
94
94
  });
95
95
 
96
+ it("creates datasource accessors with append method", () => {
97
+ const events = defineDatasource("events", {
98
+ schema: { timestamp: t.dateTime() },
99
+ });
100
+
101
+ const project = defineProject({
102
+ datasources: { events },
103
+ });
104
+
105
+ expect(project.tinybird.events).toBeDefined();
106
+ expect(typeof project.tinybird.events.append).toBe("function");
107
+ });
108
+
109
+ it("creates multiple datasource accessors", () => {
110
+ const events = defineDatasource("events", {
111
+ schema: { timestamp: t.dateTime() },
112
+ });
113
+ const pageViews = defineDatasource("page_views", {
114
+ schema: { pathname: t.string() },
115
+ });
116
+
117
+ const project = defineProject({
118
+ datasources: { events, pageViews },
119
+ });
120
+
121
+ expect(project.tinybird.events).toBeDefined();
122
+ expect(project.tinybird.pageViews).toBeDefined();
123
+ expect(typeof project.tinybird.events.append).toBe("function");
124
+ expect(typeof project.tinybird.pageViews.append).toBe("function");
125
+ });
126
+
96
127
  it("throws error when accessing client before initialization", () => {
97
128
  const project = defineProject({});
98
129
 
@@ -269,6 +300,39 @@ describe("Project Schema", () => {
269
300
  expect(typeof client.ingest.eventsBatch).toBe("function");
270
301
  });
271
302
 
303
+ it("creates datasource accessors with append method", () => {
304
+ const events = defineDatasource("events", {
305
+ schema: { id: t.string() },
306
+ });
307
+
308
+ const client = createTinybirdClient({
309
+ datasources: { events },
310
+ pipes: {},
311
+ });
312
+
313
+ expect(client.events).toBeDefined();
314
+ expect(typeof client.events.append).toBe("function");
315
+ });
316
+
317
+ it("creates multiple datasource accessors", () => {
318
+ const events = defineDatasource("events", {
319
+ schema: { id: t.string() },
320
+ });
321
+ const pageViews = defineDatasource("page_views", {
322
+ schema: { pathname: t.string() },
323
+ });
324
+
325
+ const client = createTinybirdClient({
326
+ datasources: { events, pageViews },
327
+ pipes: {},
328
+ });
329
+
330
+ expect(client.events).toBeDefined();
331
+ expect(client.pageViews).toBeDefined();
332
+ expect(typeof client.events.append).toBe("function");
333
+ expect(typeof client.pageViews.append).toBe("function");
334
+ });
335
+
272
336
  it("accepts devMode option", () => {
273
337
  const events = defineDatasource("events", {
274
338
  schema: { id: t.string() },