@tinybirdco/sdk 0.0.19 → 0.0.21
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 +47 -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/api/dashboard.d.ts +74 -0
- package/dist/api/dashboard.d.ts.map +1 -0
- package/dist/api/dashboard.js +102 -0
- package/dist/api/dashboard.js.map +1 -0
- package/dist/api/dashboard.test.d.ts +2 -0
- package/dist/api/dashboard.test.d.ts.map +1 -0
- package/dist/api/dashboard.test.js +91 -0
- package/dist/api/dashboard.test.js.map +1 -0
- package/dist/cli/commands/branch.d.ts +2 -0
- package/dist/cli/commands/branch.d.ts.map +1 -1
- package/dist/cli/commands/branch.js +12 -1
- package/dist/cli/commands/branch.js.map +1 -1
- package/dist/cli/commands/build.d.ts +17 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +25 -0
- package/dist/cli/commands/build.js.map +1 -1
- package/dist/cli/commands/dev.d.ts +2 -0
- package/dist/cli/commands/dev.d.ts.map +1 -1
- package/dist/cli/commands/dev.js +19 -3
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/index.js +23 -24
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/output.d.ts +20 -0
- package/dist/cli/output.d.ts.map +1 -1
- package/dist/cli/output.js +37 -0
- package/dist/cli/output.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 +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -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/api/dashboard.test.ts +115 -0
- package/src/api/dashboard.ts +121 -0
- package/src/cli/commands/branch.ts +15 -1
- package/src/cli/commands/build.ts +46 -0
- package/src/cli/commands/dev.ts +47 -7
- package/src/cli/index.ts +25 -27
- package/src/cli/output.ts +54 -0
- package/src/client/base.ts +34 -181
- package/src/index.ts +23 -0
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;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseApiUrl,
|
|
4
|
+
getDashboardUrl,
|
|
5
|
+
getBranchDashboardUrl,
|
|
6
|
+
getLocalDashboardUrl,
|
|
7
|
+
} from "./dashboard.js";
|
|
8
|
+
|
|
9
|
+
describe("parseApiUrl", () => {
|
|
10
|
+
it("parses EU GCP region", () => {
|
|
11
|
+
const result = parseApiUrl("https://api.tinybird.co");
|
|
12
|
+
expect(result).toEqual({ provider: "gcp", region: "europe-west3" });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("parses US East GCP region", () => {
|
|
16
|
+
const result = parseApiUrl("https://api.us-east.tinybird.co");
|
|
17
|
+
expect(result).toEqual({ provider: "gcp", region: "us-east4" });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("parses EU Central AWS region", () => {
|
|
21
|
+
const result = parseApiUrl("https://api.eu-central-1.aws.tinybird.co");
|
|
22
|
+
expect(result).toEqual({ provider: "aws", region: "eu-central-1" });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("parses US East AWS region", () => {
|
|
26
|
+
const result = parseApiUrl("https://api.us-east-1.aws.tinybird.co");
|
|
27
|
+
expect(result).toEqual({ provider: "aws", region: "us-east-1" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("parses US West AWS region", () => {
|
|
31
|
+
const result = parseApiUrl("https://api.us-west-2.aws.tinybird.co");
|
|
32
|
+
expect(result).toEqual({ provider: "aws", region: "us-west-2" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns null for unknown regions", () => {
|
|
36
|
+
const result = parseApiUrl("https://api.unknown.tinybird.co");
|
|
37
|
+
expect(result).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns null for invalid URLs", () => {
|
|
41
|
+
const result = parseApiUrl("not-a-url");
|
|
42
|
+
expect(result).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns null for localhost", () => {
|
|
46
|
+
const result = parseApiUrl("http://localhost:7181");
|
|
47
|
+
expect(result).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("getDashboardUrl", () => {
|
|
52
|
+
it("generates EU GCP workspace URL", () => {
|
|
53
|
+
const result = getDashboardUrl("https://api.tinybird.co", "my_workspace");
|
|
54
|
+
expect(result).toBe("https://cloud.tinybird.co/gcp/europe-west3/my_workspace");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("generates US East GCP workspace URL", () => {
|
|
58
|
+
const result = getDashboardUrl("https://api.us-east.tinybird.co", "my_workspace");
|
|
59
|
+
expect(result).toBe("https://cloud.tinybird.co/gcp/us-east4/my_workspace");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("generates AWS workspace URL", () => {
|
|
63
|
+
const result = getDashboardUrl("https://api.us-west-2.aws.tinybird.co", "my_workspace");
|
|
64
|
+
expect(result).toBe("https://cloud.tinybird.co/aws/us-west-2/my_workspace");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns null for unknown regions", () => {
|
|
68
|
+
const result = getDashboardUrl("https://api.unknown.tinybird.co", "my_workspace");
|
|
69
|
+
expect(result).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("getBranchDashboardUrl", () => {
|
|
74
|
+
it("generates EU GCP branch URL", () => {
|
|
75
|
+
const result = getBranchDashboardUrl("https://api.tinybird.co", "my_workspace", "feature_branch");
|
|
76
|
+
expect(result).toBe("https://cloud.tinybird.co/gcp/europe-west3/my_workspace~feature_branch");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("generates US East GCP branch URL", () => {
|
|
80
|
+
const result = getBranchDashboardUrl("https://api.us-east.tinybird.co", "my_workspace", "feature_branch");
|
|
81
|
+
expect(result).toBe("https://cloud.tinybird.co/gcp/us-east4/my_workspace~feature_branch");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("generates AWS branch URL", () => {
|
|
85
|
+
const result = getBranchDashboardUrl("https://api.us-west-2.aws.tinybird.co", "my_workspace", "my_feature");
|
|
86
|
+
expect(result).toBe("https://cloud.tinybird.co/aws/us-west-2/my_workspace~my_feature");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns null for unknown regions", () => {
|
|
90
|
+
const result = getBranchDashboardUrl("https://api.unknown.tinybird.co", "my_workspace", "branch");
|
|
91
|
+
expect(result).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("handles branch names with underscores", () => {
|
|
95
|
+
const result = getBranchDashboardUrl("https://api.tinybird.co", "my_workspace", "feature_with_underscores");
|
|
96
|
+
expect(result).toBe("https://cloud.tinybird.co/gcp/europe-west3/my_workspace~feature_with_underscores");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("getLocalDashboardUrl", () => {
|
|
101
|
+
it("generates local dashboard URL with default port", () => {
|
|
102
|
+
const result = getLocalDashboardUrl("my_local_workspace");
|
|
103
|
+
expect(result).toBe("https://cloud.tinybird.co/local/7181/my_local_workspace");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("generates local dashboard URL with custom port", () => {
|
|
107
|
+
const result = getLocalDashboardUrl("my_local_workspace", 8080);
|
|
108
|
+
expect(result).toBe("https://cloud.tinybird.co/local/8080/my_local_workspace");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("handles workspace names with underscores", () => {
|
|
112
|
+
const result = getLocalDashboardUrl("dublin_feature_branch");
|
|
113
|
+
expect(result).toBe("https://cloud.tinybird.co/local/7181/dublin_feature_branch");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tinybird Dashboard URL utilities
|
|
3
|
+
*
|
|
4
|
+
* Generates dashboard links for workspaces and branches
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Region information extracted from API URL
|
|
9
|
+
*/
|
|
10
|
+
export interface RegionInfo {
|
|
11
|
+
/** Cloud provider: "gcp" or "aws" */
|
|
12
|
+
provider: string;
|
|
13
|
+
/** Region identifier (e.g., "europe-west3", "us-east4", "us-west-2") */
|
|
14
|
+
region: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Mapping of API hostnames to region information
|
|
19
|
+
*
|
|
20
|
+
* Based on https://www.tinybird.co/docs/api-reference#current-tinybird-regions
|
|
21
|
+
*/
|
|
22
|
+
const API_REGION_MAP: Record<string, RegionInfo> = {
|
|
23
|
+
// GCP Regions
|
|
24
|
+
"api.tinybird.co": { provider: "gcp", region: "europe-west3" },
|
|
25
|
+
"api.us-east.tinybird.co": { provider: "gcp", region: "us-east4" },
|
|
26
|
+
// AWS Regions
|
|
27
|
+
"api.eu-central-1.aws.tinybird.co": { provider: "aws", region: "eu-central-1" },
|
|
28
|
+
"api.us-east-1.aws.tinybird.co": { provider: "aws", region: "us-east-1" },
|
|
29
|
+
"api.us-west-2.aws.tinybird.co": { provider: "aws", region: "us-west-2" },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse an API URL to extract region information
|
|
34
|
+
*
|
|
35
|
+
* @param apiUrl - The Tinybird API base URL (e.g., "https://api.tinybird.co")
|
|
36
|
+
* @returns Region info or null if the URL doesn't match a known region
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* parseApiUrl("https://api.tinybird.co")
|
|
41
|
+
* // => { provider: "gcp", region: "europe-west3" }
|
|
42
|
+
*
|
|
43
|
+
* parseApiUrl("https://api.us-west-2.aws.tinybird.co")
|
|
44
|
+
* // => { provider: "aws", region: "us-west-2" }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function parseApiUrl(apiUrl: string): RegionInfo | null {
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(apiUrl);
|
|
50
|
+
const hostname = url.hostname;
|
|
51
|
+
return API_REGION_MAP[hostname] ?? null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate a Tinybird dashboard URL for a workspace
|
|
59
|
+
*
|
|
60
|
+
* @param apiUrl - The Tinybird API base URL
|
|
61
|
+
* @param workspaceName - The workspace name
|
|
62
|
+
* @returns Dashboard URL or null if the API URL is not recognized
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* getDashboardUrl("https://api.tinybird.co", "my_workspace")
|
|
67
|
+
* // => "https://cloud.tinybird.co/gcp/europe-west3/my_workspace"
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function getDashboardUrl(apiUrl: string, workspaceName: string): string | null {
|
|
71
|
+
const regionInfo = parseApiUrl(apiUrl);
|
|
72
|
+
if (!regionInfo) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return `https://cloud.tinybird.co/${regionInfo.provider}/${regionInfo.region}/${workspaceName}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Generate a Tinybird dashboard URL for a branch
|
|
81
|
+
*
|
|
82
|
+
* @param apiUrl - The Tinybird API base URL
|
|
83
|
+
* @param workspaceName - The workspace name
|
|
84
|
+
* @param branchName - The branch name
|
|
85
|
+
* @returns Dashboard URL or null if the API URL is not recognized
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```ts
|
|
89
|
+
* getBranchDashboardUrl("https://api.tinybird.co", "my_workspace", "feature_branch")
|
|
90
|
+
* // => "https://cloud.tinybird.co/gcp/europe-west3/my_workspace~feature_branch"
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export function getBranchDashboardUrl(
|
|
94
|
+
apiUrl: string,
|
|
95
|
+
workspaceName: string,
|
|
96
|
+
branchName: string
|
|
97
|
+
): string | null {
|
|
98
|
+
const regionInfo = parseApiUrl(apiUrl);
|
|
99
|
+
if (!regionInfo) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return `https://cloud.tinybird.co/${regionInfo.provider}/${regionInfo.region}/${workspaceName}~${branchName}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Generate a local Tinybird dashboard URL
|
|
108
|
+
*
|
|
109
|
+
* @param workspaceName - The local workspace name
|
|
110
|
+
* @param port - The local Tinybird port (default: 7181)
|
|
111
|
+
* @returns Local dashboard URL
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* getLocalDashboardUrl("my_local_workspace")
|
|
116
|
+
* // => "https://cloud.tinybird.co/local/7181/my_local_workspace"
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
export function getLocalDashboardUrl(workspaceName: string, port = 7181): string {
|
|
120
|
+
return `https://cloud.tinybird.co/local/${port}/${workspaceName}`;
|
|
121
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
removeBranch as removeCachedBranch,
|
|
17
17
|
listCachedBranches,
|
|
18
18
|
} from "../branch-store.js";
|
|
19
|
+
import { getBranchDashboardUrl } from "../../api/dashboard.js";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Branch command options
|
|
@@ -53,6 +54,8 @@ export interface BranchStatusResult {
|
|
|
53
54
|
tinybirdBranch?: TinybirdBranch;
|
|
54
55
|
/** Whether a cached token exists */
|
|
55
56
|
hasCachedToken: boolean;
|
|
57
|
+
/** Dashboard URL for the branch */
|
|
58
|
+
dashboardUrl?: string;
|
|
56
59
|
/** Error message if failed */
|
|
57
60
|
error?: string;
|
|
58
61
|
}
|
|
@@ -135,14 +138,16 @@ export async function runBranchStatus(
|
|
|
135
138
|
const tinybirdBranchName = config.tinybirdBranch; // Sanitized name
|
|
136
139
|
const isMainBranch = config.isMainBranch;
|
|
137
140
|
|
|
138
|
-
// Fetch the workspace
|
|
141
|
+
// Fetch the workspace from the API
|
|
139
142
|
let workspaceId: string;
|
|
143
|
+
let workspaceName: string;
|
|
140
144
|
try {
|
|
141
145
|
const workspace = await getWorkspace({
|
|
142
146
|
baseUrl: config.baseUrl,
|
|
143
147
|
token: config.token,
|
|
144
148
|
});
|
|
145
149
|
workspaceId = workspace.id;
|
|
150
|
+
workspaceName = workspace.name;
|
|
146
151
|
} catch (error) {
|
|
147
152
|
return {
|
|
148
153
|
success: false,
|
|
@@ -154,6 +159,11 @@ export async function runBranchStatus(
|
|
|
154
159
|
};
|
|
155
160
|
}
|
|
156
161
|
|
|
162
|
+
// Generate dashboard URL for the branch
|
|
163
|
+
const dashboardUrl = tinybirdBranchName
|
|
164
|
+
? getBranchDashboardUrl(config.baseUrl, workspaceName, tinybirdBranchName) ?? undefined
|
|
165
|
+
: undefined;
|
|
166
|
+
|
|
157
167
|
// Check for cached token (use sanitized name)
|
|
158
168
|
const cachedBranch = tinybirdBranchName ? getBranchToken(workspaceId, tinybirdBranchName) : null;
|
|
159
169
|
const hasCachedToken = cachedBranch !== null;
|
|
@@ -166,6 +176,7 @@ export async function runBranchStatus(
|
|
|
166
176
|
tinybirdBranchName,
|
|
167
177
|
isMainBranch,
|
|
168
178
|
hasCachedToken,
|
|
179
|
+
dashboardUrl,
|
|
169
180
|
};
|
|
170
181
|
}
|
|
171
182
|
|
|
@@ -186,6 +197,7 @@ export async function runBranchStatus(
|
|
|
186
197
|
isMainBranch,
|
|
187
198
|
tinybirdBranch,
|
|
188
199
|
hasCachedToken,
|
|
200
|
+
dashboardUrl,
|
|
189
201
|
};
|
|
190
202
|
} catch (error) {
|
|
191
203
|
// If 404, branch doesn't exist yet
|
|
@@ -196,6 +208,7 @@ export async function runBranchStatus(
|
|
|
196
208
|
tinybirdBranchName,
|
|
197
209
|
isMainBranch,
|
|
198
210
|
hasCachedToken,
|
|
211
|
+
dashboardUrl,
|
|
199
212
|
};
|
|
200
213
|
}
|
|
201
214
|
|
|
@@ -205,6 +218,7 @@ export async function runBranchStatus(
|
|
|
205
218
|
tinybirdBranchName,
|
|
206
219
|
isMainBranch,
|
|
207
220
|
hasCachedToken,
|
|
221
|
+
dashboardUrl,
|
|
208
222
|
error: (error as Error).message,
|
|
209
223
|
};
|
|
210
224
|
}
|