@tinybirdco/sdk 0.0.1
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 +518 -0
- package/bin/tinybird.js +7 -0
- package/dist/api/branches.d.ts +98 -0
- package/dist/api/branches.d.ts.map +1 -0
- package/dist/api/branches.js +203 -0
- package/dist/api/branches.js.map +1 -0
- package/dist/api/branches.test.d.ts +2 -0
- package/dist/api/branches.test.d.ts.map +1 -0
- package/dist/api/branches.test.js +286 -0
- package/dist/api/branches.test.js.map +1 -0
- package/dist/api/build.d.ts +130 -0
- package/dist/api/build.d.ts.map +1 -0
- package/dist/api/build.js +143 -0
- package/dist/api/build.js.map +1 -0
- package/dist/api/build.test.d.ts +2 -0
- package/dist/api/build.test.d.ts.map +1 -0
- package/dist/api/build.test.js +138 -0
- package/dist/api/build.test.js.map +1 -0
- package/dist/api/deploy.d.ts +39 -0
- package/dist/api/deploy.d.ts.map +1 -0
- package/dist/api/deploy.js +135 -0
- package/dist/api/deploy.js.map +1 -0
- package/dist/api/deploy.test.d.ts +2 -0
- package/dist/api/deploy.test.d.ts.map +1 -0
- package/dist/api/deploy.test.js +118 -0
- package/dist/api/deploy.test.js.map +1 -0
- package/dist/api/workspaces.d.ts +46 -0
- package/dist/api/workspaces.d.ts.map +1 -0
- package/dist/api/workspaces.js +39 -0
- package/dist/api/workspaces.js.map +1 -0
- package/dist/api/workspaces.test.d.ts +2 -0
- package/dist/api/workspaces.test.d.ts.map +1 -0
- package/dist/api/workspaces.test.js +65 -0
- package/dist/api/workspaces.test.js.map +1 -0
- package/dist/cli/auth.d.ts +86 -0
- package/dist/cli/auth.d.ts.map +1 -0
- package/dist/cli/auth.js +284 -0
- package/dist/cli/auth.js.map +1 -0
- package/dist/cli/branch-store.d.ts +53 -0
- package/dist/cli/branch-store.d.ts.map +1 -0
- package/dist/cli/branch-store.js +91 -0
- package/dist/cli/branch-store.js.map +1 -0
- package/dist/cli/branch-store.test.d.ts +2 -0
- package/dist/cli/branch-store.test.d.ts.map +1 -0
- package/dist/cli/branch-store.test.js +115 -0
- package/dist/cli/branch-store.test.js.map +1 -0
- package/dist/cli/commands/branch.d.ts +82 -0
- package/dist/cli/commands/branch.d.ts.map +1 -0
- package/dist/cli/commands/branch.js +215 -0
- package/dist/cli/commands/branch.js.map +1 -0
- package/dist/cli/commands/build.d.ts +43 -0
- package/dist/cli/commands/build.d.ts.map +1 -0
- package/dist/cli/commands/build.js +138 -0
- package/dist/cli/commands/build.js.map +1 -0
- package/dist/cli/commands/dev.d.ts +78 -0
- package/dist/cli/commands/dev.d.ts.map +1 -0
- package/dist/cli/commands/dev.js +226 -0
- package/dist/cli/commands/dev.js.map +1 -0
- package/dist/cli/commands/init.d.ts +45 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +277 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/init.test.d.ts +2 -0
- package/dist/cli/commands/init.test.d.ts.map +1 -0
- package/dist/cli/commands/init.test.js +158 -0
- package/dist/cli/commands/init.test.js.map +1 -0
- package/dist/cli/commands/login.d.ts +37 -0
- package/dist/cli/commands/login.d.ts.map +1 -0
- package/dist/cli/commands/login.js +64 -0
- package/dist/cli/commands/login.js.map +1 -0
- package/dist/cli/config.d.ts +114 -0
- package/dist/cli/config.d.ts.map +1 -0
- package/dist/cli/config.js +258 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/config.test.d.ts +2 -0
- package/dist/cli/config.test.d.ts.map +1 -0
- package/dist/cli/config.test.js +243 -0
- package/dist/cli/config.test.js.map +1 -0
- package/dist/cli/env.d.ts +29 -0
- package/dist/cli/env.d.ts.map +1 -0
- package/dist/cli/env.js +66 -0
- package/dist/cli/env.js.map +1 -0
- package/dist/cli/git.d.ts +29 -0
- package/dist/cli/git.d.ts.map +1 -0
- package/dist/cli/git.js +114 -0
- package/dist/cli/git.js.map +1 -0
- package/dist/cli/git.test.d.ts +2 -0
- package/dist/cli/git.test.d.ts.map +1 -0
- package/dist/cli/git.test.js +125 -0
- package/dist/cli/git.test.js.map +1 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +337 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/utils/schema-validation.d.ts +95 -0
- package/dist/cli/utils/schema-validation.d.ts.map +1 -0
- package/dist/cli/utils/schema-validation.js +175 -0
- package/dist/cli/utils/schema-validation.js.map +1 -0
- package/dist/cli/utils/schema-validation.test.d.ts +5 -0
- package/dist/cli/utils/schema-validation.test.d.ts.map +1 -0
- package/dist/cli/utils/schema-validation.test.js +173 -0
- package/dist/cli/utils/schema-validation.test.js.map +1 -0
- package/dist/client/base.d.ts +116 -0
- package/dist/client/base.d.ts.map +1 -0
- package/dist/client/base.js +328 -0
- package/dist/client/base.js.map +1 -0
- package/dist/client/types.d.ts +137 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +43 -0
- package/dist/client/types.js.map +1 -0
- package/dist/generator/client.d.ts +44 -0
- package/dist/generator/client.d.ts.map +1 -0
- package/dist/generator/client.js +144 -0
- package/dist/generator/client.js.map +1 -0
- package/dist/generator/datasource.d.ts +57 -0
- package/dist/generator/datasource.d.ts.map +1 -0
- package/dist/generator/datasource.js +169 -0
- package/dist/generator/datasource.js.map +1 -0
- package/dist/generator/datasource.test.d.ts +2 -0
- package/dist/generator/datasource.test.d.ts.map +1 -0
- package/dist/generator/datasource.test.js +254 -0
- package/dist/generator/datasource.test.js.map +1 -0
- package/dist/generator/index.d.ts +131 -0
- package/dist/generator/index.d.ts.map +1 -0
- package/dist/generator/index.js +121 -0
- package/dist/generator/index.js.map +1 -0
- package/dist/generator/index.test.d.ts +2 -0
- package/dist/generator/index.test.d.ts.map +1 -0
- package/dist/generator/index.test.js +175 -0
- package/dist/generator/index.test.js.map +1 -0
- package/dist/generator/loader.d.ts +156 -0
- package/dist/generator/loader.d.ts.map +1 -0
- package/dist/generator/loader.js +295 -0
- package/dist/generator/loader.js.map +1 -0
- package/dist/generator/pipe.d.ts +72 -0
- package/dist/generator/pipe.d.ts.map +1 -0
- package/dist/generator/pipe.js +174 -0
- package/dist/generator/pipe.js.map +1 -0
- package/dist/generator/pipe.test.d.ts +2 -0
- package/dist/generator/pipe.test.d.ts.map +1 -0
- package/dist/generator/pipe.test.js +393 -0
- package/dist/generator/pipe.test.js.map +1 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/dist/infer/index.d.ts +202 -0
- package/dist/infer/index.d.ts.map +1 -0
- package/dist/infer/index.js +5 -0
- package/dist/infer/index.js.map +1 -0
- package/dist/schema/datasource.d.ts +135 -0
- package/dist/schema/datasource.d.ts.map +1 -0
- package/dist/schema/datasource.js +105 -0
- package/dist/schema/datasource.js.map +1 -0
- package/dist/schema/datasource.test.d.ts +2 -0
- package/dist/schema/datasource.test.d.ts.map +1 -0
- package/dist/schema/datasource.test.js +142 -0
- package/dist/schema/datasource.test.js.map +1 -0
- package/dist/schema/engines.d.ts +157 -0
- package/dist/schema/engines.d.ts.map +1 -0
- package/dist/schema/engines.js +155 -0
- package/dist/schema/engines.js.map +1 -0
- package/dist/schema/engines.test.d.ts +2 -0
- package/dist/schema/engines.test.d.ts.map +1 -0
- package/dist/schema/engines.test.js +221 -0
- package/dist/schema/engines.test.js.map +1 -0
- package/dist/schema/params.d.ts +106 -0
- package/dist/schema/params.d.ts.map +1 -0
- package/dist/schema/params.js +138 -0
- package/dist/schema/params.js.map +1 -0
- package/dist/schema/params.test.d.ts +2 -0
- package/dist/schema/params.test.d.ts.map +1 -0
- package/dist/schema/params.test.js +175 -0
- package/dist/schema/params.test.js.map +1 -0
- package/dist/schema/pipe.d.ts +436 -0
- package/dist/schema/pipe.d.ts.map +1 -0
- package/dist/schema/pipe.js +484 -0
- package/dist/schema/pipe.js.map +1 -0
- package/dist/schema/pipe.test.d.ts +2 -0
- package/dist/schema/pipe.test.d.ts.map +1 -0
- package/dist/schema/pipe.test.js +488 -0
- package/dist/schema/pipe.test.js.map +1 -0
- package/dist/schema/project.d.ts +202 -0
- package/dist/schema/project.d.ts.map +1 -0
- package/dist/schema/project.js +188 -0
- package/dist/schema/project.js.map +1 -0
- package/dist/schema/project.test.d.ts +2 -0
- package/dist/schema/project.test.d.ts.map +1 -0
- package/dist/schema/project.test.js +180 -0
- package/dist/schema/project.test.js.map +1 -0
- package/dist/schema/types.d.ts +140 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/schema/types.js +174 -0
- package/dist/schema/types.js.map +1 -0
- package/dist/schema/types.test.d.ts +2 -0
- package/dist/schema/types.test.d.ts.map +1 -0
- package/dist/schema/types.test.js +176 -0
- package/dist/schema/types.test.js.map +1 -0
- package/dist/test/handlers.d.ts +58 -0
- package/dist/test/handlers.d.ts.map +1 -0
- package/dist/test/handlers.js +62 -0
- package/dist/test/handlers.js.map +1 -0
- package/dist/test/setup.d.ts +5 -0
- package/dist/test/setup.d.ts.map +1 -0
- package/dist/test/setup.js +11 -0
- package/dist/test/setup.js.map +1 -0
- package/package.json +57 -0
- package/src/api/branches.test.ts +377 -0
- package/src/api/branches.ts +334 -0
- package/src/api/build.test.ts +216 -0
- package/src/api/build.ts +266 -0
- package/src/api/deploy.test.ts +193 -0
- package/src/api/deploy.ts +163 -0
- package/src/api/workspaces.test.ts +81 -0
- package/src/api/workspaces.ts +77 -0
- package/src/cli/auth.ts +358 -0
- package/src/cli/branch-store.test.ts +139 -0
- package/src/cli/branch-store.ts +137 -0
- package/src/cli/commands/branch.ts +306 -0
- package/src/cli/commands/build.ts +183 -0
- package/src/cli/commands/dev.ts +334 -0
- package/src/cli/commands/init.test.ts +249 -0
- package/src/cli/commands/init.ts +323 -0
- package/src/cli/commands/login.ts +98 -0
- package/src/cli/config.test.ts +359 -0
- package/src/cli/config.ts +335 -0
- package/src/cli/env.ts +86 -0
- package/src/cli/git.test.ts +147 -0
- package/src/cli/git.ts +125 -0
- package/src/cli/index.ts +382 -0
- package/src/cli/utils/schema-validation.test.ts +222 -0
- package/src/cli/utils/schema-validation.ts +272 -0
- package/src/client/base.ts +414 -0
- package/src/client/types.ts +165 -0
- package/src/generator/client.ts +194 -0
- package/src/generator/datasource.test.ts +297 -0
- package/src/generator/datasource.ts +217 -0
- package/src/generator/index.test.ts +209 -0
- package/src/generator/index.ts +203 -0
- package/src/generator/loader.ts +406 -0
- package/src/generator/pipe.test.ts +441 -0
- package/src/generator/pipe.ts +220 -0
- package/src/index.ts +191 -0
- package/src/infer/index.ts +247 -0
- package/src/schema/datasource.test.ts +187 -0
- package/src/schema/datasource.ts +195 -0
- package/src/schema/engines.test.ts +247 -0
- package/src/schema/engines.ts +271 -0
- package/src/schema/params.test.ts +208 -0
- package/src/schema/params.ts +249 -0
- package/src/schema/pipe.test.ts +588 -0
- package/src/schema/pipe.ts +832 -0
- package/src/schema/project.test.ts +236 -0
- package/src/schema/project.ts +394 -0
- package/src/schema/types.test.ts +212 -0
- package/src/schema/types.ts +366 -0
- package/src/test/handlers.ts +79 -0
- package/src/test/setup.ts +13 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema validation for pipe output
|
|
3
|
+
* Validates that query responses match the declared output schema
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { TinybirdClient } from "../../client/base.js";
|
|
7
|
+
import type { ProjectDefinition, PipesDefinition } from "../../schema/project.js";
|
|
8
|
+
import type { PipeDefinition, OutputDefinition } from "../../schema/pipe.js";
|
|
9
|
+
import type { ColumnMeta } from "../../client/types.js";
|
|
10
|
+
import type { LoadedEntities } from "../../generator/loader.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Options for schema validation
|
|
14
|
+
*/
|
|
15
|
+
export interface SchemaValidationOptions {
|
|
16
|
+
/** The project definition containing pipe schemas (legacy) */
|
|
17
|
+
project?: ProjectDefinition;
|
|
18
|
+
/** The loaded entities containing pipe schemas (new) */
|
|
19
|
+
entities?: LoadedEntities;
|
|
20
|
+
/** Names of pipes to validate */
|
|
21
|
+
pipeNames: string[];
|
|
22
|
+
/** Tinybird API base URL */
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
/** API token for authentication */
|
|
25
|
+
token: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A single validation issue
|
|
30
|
+
*/
|
|
31
|
+
export interface ValidationIssue {
|
|
32
|
+
/** Name of the pipe with the issue */
|
|
33
|
+
pipeName: string;
|
|
34
|
+
/** Issue severity */
|
|
35
|
+
type: "error" | "warning";
|
|
36
|
+
/** Human-readable description of the issue */
|
|
37
|
+
message: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Result of schema validation
|
|
42
|
+
*/
|
|
43
|
+
export interface SchemaValidationResult {
|
|
44
|
+
/** Whether all validations passed (no errors) */
|
|
45
|
+
valid: boolean;
|
|
46
|
+
/** List of validation issues found */
|
|
47
|
+
issues: ValidationIssue[];
|
|
48
|
+
/** Names of pipes that were successfully validated */
|
|
49
|
+
pipesValidated: string[];
|
|
50
|
+
/** Names of pipes that were skipped (e.g., require params) */
|
|
51
|
+
pipesSkipped: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Internal result of validating a single pipe's output schema
|
|
56
|
+
*/
|
|
57
|
+
interface ColumnValidation {
|
|
58
|
+
valid: boolean;
|
|
59
|
+
missingColumns: { name: string; expectedType: string }[];
|
|
60
|
+
extraColumns: { name: string; actualType: string }[];
|
|
61
|
+
typeMismatches: { name: string; expectedType: string; actualType: string }[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validate pipe schemas by querying them and comparing response to output definition
|
|
66
|
+
*
|
|
67
|
+
* @param options - Validation options
|
|
68
|
+
* @returns Validation result with issues found
|
|
69
|
+
*/
|
|
70
|
+
export async function validatePipeSchemas(
|
|
71
|
+
options: SchemaValidationOptions
|
|
72
|
+
): Promise<SchemaValidationResult> {
|
|
73
|
+
const client = new TinybirdClient({
|
|
74
|
+
baseUrl: options.baseUrl,
|
|
75
|
+
token: options.token,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const result: SchemaValidationResult = {
|
|
79
|
+
valid: true,
|
|
80
|
+
issues: [],
|
|
81
|
+
pipesValidated: [],
|
|
82
|
+
pipesSkipped: [],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Get pipes from either project or entities
|
|
86
|
+
const pipes: PipesDefinition = options.entities
|
|
87
|
+
? Object.fromEntries(
|
|
88
|
+
Object.entries(options.entities.pipes).map(([name, { definition }]) => [name, definition])
|
|
89
|
+
)
|
|
90
|
+
: options.project?.pipes ?? {};
|
|
91
|
+
|
|
92
|
+
// Only validate the specified pipes
|
|
93
|
+
for (const pipeName of options.pipeNames) {
|
|
94
|
+
// Find pipe by name
|
|
95
|
+
const pipe = Object.values(pipes).find(
|
|
96
|
+
(p) => p._name === pipeName
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (!pipe) {
|
|
100
|
+
// Pipe exists in Tinybird but not in local schema (could be deleted or renamed)
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Skip if pipe has required params without defaults
|
|
105
|
+
if (hasRequiredParams(pipe)) {
|
|
106
|
+
result.pipesSkipped.push(pipeName);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Skip if pipe has no output schema (reusable pipes)
|
|
111
|
+
if (!pipe._output) {
|
|
112
|
+
result.pipesSkipped.push(pipeName);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Build params using defaults
|
|
117
|
+
const params = buildDefaultParams(pipe);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const response = await client.query(pipeName, params);
|
|
121
|
+
const validation = validateOutputSchema(response.meta, pipe._output);
|
|
122
|
+
|
|
123
|
+
if (!validation.valid) {
|
|
124
|
+
result.valid = false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Add missing column errors
|
|
128
|
+
for (const missing of validation.missingColumns) {
|
|
129
|
+
result.issues.push({
|
|
130
|
+
pipeName,
|
|
131
|
+
type: "error",
|
|
132
|
+
message: `Missing column '${missing.name}' (expected: ${missing.expectedType})`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Add type mismatch errors
|
|
137
|
+
for (const mismatch of validation.typeMismatches) {
|
|
138
|
+
result.issues.push({
|
|
139
|
+
pipeName,
|
|
140
|
+
type: "error",
|
|
141
|
+
message: `Type mismatch '${mismatch.name}': expected ${mismatch.expectedType}, got ${mismatch.actualType}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Add extra column warnings
|
|
146
|
+
for (const extra of validation.extraColumns) {
|
|
147
|
+
result.issues.push({
|
|
148
|
+
pipeName,
|
|
149
|
+
type: "warning",
|
|
150
|
+
message: `Extra column '${extra.name}' (${extra.actualType}) not in output schema`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
result.pipesValidated.push(pipeName);
|
|
155
|
+
} catch {
|
|
156
|
+
// Query failed - skip validation for this pipe
|
|
157
|
+
// This could happen if the pipe doesn't exist yet, network issues, etc.
|
|
158
|
+
result.pipesSkipped.push(pipeName);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check if a pipe has any required parameters without defaults
|
|
167
|
+
*/
|
|
168
|
+
function hasRequiredParams(pipe: PipeDefinition): boolean {
|
|
169
|
+
if (!pipe._params) return false;
|
|
170
|
+
|
|
171
|
+
for (const param of Object.values(pipe._params)) {
|
|
172
|
+
if (param._required && param._default === undefined) {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build a params object using default values from the pipe definition
|
|
181
|
+
*/
|
|
182
|
+
function buildDefaultParams(pipe: PipeDefinition): Record<string, unknown> {
|
|
183
|
+
const params: Record<string, unknown> = {};
|
|
184
|
+
|
|
185
|
+
if (!pipe._params) return params;
|
|
186
|
+
|
|
187
|
+
for (const [name, param] of Object.entries(pipe._params)) {
|
|
188
|
+
if (param._default !== undefined) {
|
|
189
|
+
params[name] = param._default;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return params;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Validate response metadata against the expected output schema
|
|
198
|
+
*/
|
|
199
|
+
function validateOutputSchema(
|
|
200
|
+
responseMeta: ColumnMeta[],
|
|
201
|
+
outputSchema: OutputDefinition
|
|
202
|
+
): ColumnValidation {
|
|
203
|
+
const result: ColumnValidation = {
|
|
204
|
+
valid: true,
|
|
205
|
+
missingColumns: [],
|
|
206
|
+
extraColumns: [],
|
|
207
|
+
typeMismatches: [],
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Build a map of response columns for lookup
|
|
211
|
+
const responseColumns = new Map(
|
|
212
|
+
responseMeta.map((col) => [col.name, col.type])
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// Check each expected column from the schema
|
|
216
|
+
for (const [name, validator] of Object.entries(outputSchema)) {
|
|
217
|
+
const expectedType = validator._tinybirdType;
|
|
218
|
+
const actualType = responseColumns.get(name);
|
|
219
|
+
|
|
220
|
+
if (!actualType) {
|
|
221
|
+
// Column missing from response
|
|
222
|
+
result.missingColumns.push({ name, expectedType });
|
|
223
|
+
result.valid = false;
|
|
224
|
+
} else if (!typesAreCompatible(actualType, expectedType)) {
|
|
225
|
+
// Column exists but type doesn't match
|
|
226
|
+
result.typeMismatches.push({ name, expectedType, actualType });
|
|
227
|
+
result.valid = false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Remove from map so we can find extra columns
|
|
231
|
+
responseColumns.delete(name);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Remaining columns are extras (warnings, not errors)
|
|
235
|
+
for (const [name, actualType] of responseColumns) {
|
|
236
|
+
result.extraColumns.push({ name, actualType });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Check if two ClickHouse types are compatible
|
|
244
|
+
* Handles Nullable, LowCardinality, and timezone variations
|
|
245
|
+
*/
|
|
246
|
+
function typesAreCompatible(actual: string, expected: string): boolean {
|
|
247
|
+
const normalize = (t: string): string => {
|
|
248
|
+
let normalized = t;
|
|
249
|
+
// Remove LowCardinality(Nullable(...)) to just the inner type (must be before individual removals)
|
|
250
|
+
normalized = normalized.replace(
|
|
251
|
+
/^LowCardinality\(Nullable\((.+)\)\)$/,
|
|
252
|
+
"$1"
|
|
253
|
+
);
|
|
254
|
+
// Remove Nullable wrapper
|
|
255
|
+
normalized = normalized.replace(/^Nullable\((.+)\)$/, "$1");
|
|
256
|
+
// Remove LowCardinality wrapper
|
|
257
|
+
normalized = normalized.replace(/^LowCardinality\((.+)\)$/, "$1");
|
|
258
|
+
// Remove timezone from DateTime
|
|
259
|
+
normalized = normalized.replace(/^DateTime\('.+'\)$/, "DateTime");
|
|
260
|
+
// Remove precision and timezone from DateTime64
|
|
261
|
+
normalized = normalized.replace(/^DateTime64\(\d+(, '.+')?\)$/, "DateTime64");
|
|
262
|
+
return normalized;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return normalize(actual) === normalize(expected);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Export internal functions for testing
|
|
269
|
+
export { typesAreCompatible as _typesAreCompatible };
|
|
270
|
+
export { validateOutputSchema as _validateOutputSchema };
|
|
271
|
+
export { hasRequiredParams as _hasRequiredParams };
|
|
272
|
+
export { buildDefaultParams as _buildDefaultParams };
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tinybird client for querying pipes and ingesting events
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ClientConfig,
|
|
7
|
+
QueryResult,
|
|
8
|
+
IngestResult,
|
|
9
|
+
QueryOptions,
|
|
10
|
+
IngestOptions,
|
|
11
|
+
TinybirdErrorResponse,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
import { TinybirdError } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default timeout for requests (30 seconds)
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_TIMEOUT = 30000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolved token info from dev mode
|
|
22
|
+
*/
|
|
23
|
+
interface ResolvedTokenInfo {
|
|
24
|
+
token: string;
|
|
25
|
+
isBranchToken: boolean;
|
|
26
|
+
branchName?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tinybird API client
|
|
31
|
+
*
|
|
32
|
+
* Provides methods for querying pipe endpoints and ingesting events to datasources.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { TinybirdClient } from '@tinybirdco/sdk';
|
|
37
|
+
*
|
|
38
|
+
* const client = new TinybirdClient({
|
|
39
|
+
* baseUrl: 'https://api.tinybird.co',
|
|
40
|
+
* token: process.env.TINYBIRD_TOKEN,
|
|
41
|
+
* });
|
|
42
|
+
*
|
|
43
|
+
* // Query a pipe
|
|
44
|
+
* const result = await client.query('top_events', {
|
|
45
|
+
* start_date: '2024-01-01',
|
|
46
|
+
* end_date: '2024-01-31',
|
|
47
|
+
* });
|
|
48
|
+
*
|
|
49
|
+
* // Ingest an event
|
|
50
|
+
* await client.ingest('events', {
|
|
51
|
+
* timestamp: new Date().toISOString(),
|
|
52
|
+
* event_type: 'page_view',
|
|
53
|
+
* user_id: 'user_123',
|
|
54
|
+
* });
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export class TinybirdClient {
|
|
58
|
+
private readonly config: ClientConfig;
|
|
59
|
+
private readonly fetchFn: typeof fetch;
|
|
60
|
+
private tokenPromise: Promise<ResolvedTokenInfo> | null = null;
|
|
61
|
+
private resolvedToken: string | null = null;
|
|
62
|
+
|
|
63
|
+
constructor(config: ClientConfig) {
|
|
64
|
+
// Validate required config
|
|
65
|
+
if (!config.baseUrl) {
|
|
66
|
+
throw new Error("baseUrl is required");
|
|
67
|
+
}
|
|
68
|
+
if (!config.token) {
|
|
69
|
+
throw new Error("token is required");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Normalize base URL (remove trailing slash)
|
|
73
|
+
this.config = {
|
|
74
|
+
...config,
|
|
75
|
+
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
this.fetchFn = config.fetch ?? globalThis.fetch;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get the effective token, resolving branch token in dev mode if needed
|
|
83
|
+
*/
|
|
84
|
+
private async getToken(): Promise<string> {
|
|
85
|
+
// If already resolved, return it
|
|
86
|
+
if (this.resolvedToken) {
|
|
87
|
+
return this.resolvedToken;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// If not in dev mode, use the configured token
|
|
91
|
+
if (!this.config.devMode) {
|
|
92
|
+
this.resolvedToken = this.config.token;
|
|
93
|
+
return this.resolvedToken;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// In dev mode, lazily resolve the branch token
|
|
97
|
+
if (!this.tokenPromise) {
|
|
98
|
+
this.tokenPromise = this.resolveBranchToken();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const resolved = await this.tokenPromise;
|
|
102
|
+
this.resolvedToken = resolved.token;
|
|
103
|
+
return this.resolvedToken;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the branch token in dev mode
|
|
108
|
+
*/
|
|
109
|
+
private async resolveBranchToken(): Promise<ResolvedTokenInfo> {
|
|
110
|
+
try {
|
|
111
|
+
// Dynamic import to avoid circular dependencies and to keep CLI code
|
|
112
|
+
// out of the client bundle when not using dev mode
|
|
113
|
+
const { loadConfig } = await import("../cli/config.js");
|
|
114
|
+
const { getOrCreateBranch } = await import("../api/branches.js");
|
|
115
|
+
|
|
116
|
+
const config = loadConfig();
|
|
117
|
+
|
|
118
|
+
// If on main branch, use the workspace token
|
|
119
|
+
if (config.isMainBranch || !config.tinybirdBranch) {
|
|
120
|
+
return { token: this.config.token, isBranchToken: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const branchName = config.tinybirdBranch;
|
|
124
|
+
|
|
125
|
+
// Get or create branch (always fetch fresh to avoid stale cache issues)
|
|
126
|
+
const branch = await getOrCreateBranch(
|
|
127
|
+
{ baseUrl: this.config.baseUrl, token: this.config.token },
|
|
128
|
+
branchName
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (!branch.token) {
|
|
132
|
+
// Fall back to workspace token if no branch token
|
|
133
|
+
return { token: this.config.token, isBranchToken: false };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
token: branch.token,
|
|
138
|
+
isBranchToken: true,
|
|
139
|
+
branchName,
|
|
140
|
+
};
|
|
141
|
+
} catch {
|
|
142
|
+
// If anything fails, fall back to the workspace token
|
|
143
|
+
return { token: this.config.token, isBranchToken: false };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Query a pipe endpoint
|
|
149
|
+
*
|
|
150
|
+
* @param pipeName - Name of the pipe to query
|
|
151
|
+
* @param params - Query parameters
|
|
152
|
+
* @param options - Additional request options
|
|
153
|
+
* @returns Query result with typed data
|
|
154
|
+
*/
|
|
155
|
+
async query<T = unknown>(
|
|
156
|
+
pipeName: string,
|
|
157
|
+
params: Record<string, unknown> = {},
|
|
158
|
+
options: QueryOptions = {}
|
|
159
|
+
): Promise<QueryResult<T>> {
|
|
160
|
+
const token = await this.getToken();
|
|
161
|
+
const url = new URL(`/v0/pipes/${pipeName}.json`, this.config.baseUrl);
|
|
162
|
+
|
|
163
|
+
// Add parameters to query string
|
|
164
|
+
for (const [key, value] of Object.entries(params)) {
|
|
165
|
+
if (value !== undefined && value !== null) {
|
|
166
|
+
if (Array.isArray(value)) {
|
|
167
|
+
// Handle array parameters
|
|
168
|
+
for (const item of value) {
|
|
169
|
+
url.searchParams.append(key, String(item));
|
|
170
|
+
}
|
|
171
|
+
} else if (value instanceof Date) {
|
|
172
|
+
// Handle Date objects
|
|
173
|
+
url.searchParams.set(key, value.toISOString());
|
|
174
|
+
} else {
|
|
175
|
+
url.searchParams.set(key, String(value));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const response = await this.fetch(url.toString(), {
|
|
181
|
+
method: "GET",
|
|
182
|
+
headers: {
|
|
183
|
+
Authorization: `Bearer ${token}`,
|
|
184
|
+
},
|
|
185
|
+
signal: this.createAbortSignal(options.timeout, options.signal),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
await this.handleErrorResponse(response);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const result = (await response.json()) as QueryResult<T>;
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Ingest a single event to a datasource
|
|
198
|
+
*
|
|
199
|
+
* @param datasourceName - Name of the datasource
|
|
200
|
+
* @param event - Event data to ingest
|
|
201
|
+
* @param options - Additional request options
|
|
202
|
+
* @returns Ingest result
|
|
203
|
+
*/
|
|
204
|
+
async ingest<T extends Record<string, unknown>>(
|
|
205
|
+
datasourceName: string,
|
|
206
|
+
event: T,
|
|
207
|
+
options: IngestOptions = {}
|
|
208
|
+
): Promise<IngestResult> {
|
|
209
|
+
return this.ingestBatch(datasourceName, [event], options);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Ingest multiple events to a datasource
|
|
214
|
+
*
|
|
215
|
+
* @param datasourceName - Name of the datasource
|
|
216
|
+
* @param events - Array of events to ingest
|
|
217
|
+
* @param options - Additional request options
|
|
218
|
+
* @returns Ingest result
|
|
219
|
+
*/
|
|
220
|
+
async ingestBatch<T extends Record<string, unknown>>(
|
|
221
|
+
datasourceName: string,
|
|
222
|
+
events: T[],
|
|
223
|
+
options: IngestOptions = {}
|
|
224
|
+
): Promise<IngestResult> {
|
|
225
|
+
if (events.length === 0) {
|
|
226
|
+
return { successful_rows: 0, quarantined_rows: 0 };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const token = await this.getToken();
|
|
230
|
+
const url = new URL("/v0/events", this.config.baseUrl);
|
|
231
|
+
url.searchParams.set("name", datasourceName);
|
|
232
|
+
|
|
233
|
+
if (options.wait !== false) {
|
|
234
|
+
url.searchParams.set("wait", "true");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Convert events to NDJSON format
|
|
238
|
+
const ndjson = events
|
|
239
|
+
.map((event) => JSON.stringify(this.serializeEvent(event)))
|
|
240
|
+
.join("\n");
|
|
241
|
+
|
|
242
|
+
const response = await this.fetch(url.toString(), {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: {
|
|
245
|
+
Authorization: `Bearer ${token}`,
|
|
246
|
+
"Content-Type": "application/x-ndjson",
|
|
247
|
+
},
|
|
248
|
+
body: ndjson,
|
|
249
|
+
signal: this.createAbortSignal(options.timeout, options.signal),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
if (!response.ok) {
|
|
253
|
+
await this.handleErrorResponse(response);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const result = (await response.json()) as IngestResult;
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Execute a raw SQL query
|
|
262
|
+
*
|
|
263
|
+
* @param sql - SQL query to execute
|
|
264
|
+
* @param options - Additional request options
|
|
265
|
+
* @returns Query result
|
|
266
|
+
*/
|
|
267
|
+
async sql<T = unknown>(
|
|
268
|
+
sql: string,
|
|
269
|
+
options: QueryOptions = {}
|
|
270
|
+
): Promise<QueryResult<T>> {
|
|
271
|
+
const token = await this.getToken();
|
|
272
|
+
const url = new URL("/v0/sql", this.config.baseUrl);
|
|
273
|
+
|
|
274
|
+
const response = await this.fetch(url.toString(), {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: {
|
|
277
|
+
Authorization: `Bearer ${token}`,
|
|
278
|
+
"Content-Type": "text/plain",
|
|
279
|
+
},
|
|
280
|
+
body: sql,
|
|
281
|
+
signal: this.createAbortSignal(options.timeout, options.signal),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
if (!response.ok) {
|
|
285
|
+
await this.handleErrorResponse(response);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const result = (await response.json()) as QueryResult<T>;
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Serialize an event for ingestion, handling Date objects and other special types
|
|
294
|
+
*/
|
|
295
|
+
private serializeEvent<T extends Record<string, unknown>>(
|
|
296
|
+
event: T
|
|
297
|
+
): Record<string, unknown> {
|
|
298
|
+
const serialized: Record<string, unknown> = {};
|
|
299
|
+
|
|
300
|
+
for (const [key, value] of Object.entries(event)) {
|
|
301
|
+
if (value instanceof Date) {
|
|
302
|
+
// Convert Date to ISO string
|
|
303
|
+
serialized[key] = value.toISOString();
|
|
304
|
+
} else if (value instanceof Map) {
|
|
305
|
+
// Convert Map to object
|
|
306
|
+
serialized[key] = Object.fromEntries(value);
|
|
307
|
+
} else if (typeof value === "bigint") {
|
|
308
|
+
// Convert BigInt to string (ClickHouse will parse it)
|
|
309
|
+
serialized[key] = value.toString();
|
|
310
|
+
} else if (Array.isArray(value)) {
|
|
311
|
+
// Recursively serialize array elements
|
|
312
|
+
serialized[key] = value.map((item) =>
|
|
313
|
+
typeof item === "object" && item !== null
|
|
314
|
+
? this.serializeEvent(item as Record<string, unknown>)
|
|
315
|
+
: item instanceof Date
|
|
316
|
+
? item.toISOString()
|
|
317
|
+
: item
|
|
318
|
+
);
|
|
319
|
+
} else if (typeof value === "object" && value !== null) {
|
|
320
|
+
// Recursively serialize nested objects
|
|
321
|
+
serialized[key] = this.serializeEvent(value as Record<string, unknown>);
|
|
322
|
+
} else {
|
|
323
|
+
serialized[key] = value;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return serialized;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Create an AbortSignal with timeout
|
|
332
|
+
*/
|
|
333
|
+
private createAbortSignal(
|
|
334
|
+
timeout?: number,
|
|
335
|
+
existingSignal?: AbortSignal
|
|
336
|
+
): AbortSignal | undefined {
|
|
337
|
+
const timeoutMs = timeout ?? this.config.timeout ?? DEFAULT_TIMEOUT;
|
|
338
|
+
|
|
339
|
+
// If no timeout and no existing signal, return undefined
|
|
340
|
+
if (!timeoutMs && !existingSignal) {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// If only existing signal, return it
|
|
345
|
+
if (!timeoutMs && existingSignal) {
|
|
346
|
+
return existingSignal;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Create timeout signal
|
|
350
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
351
|
+
|
|
352
|
+
// If only timeout, return timeout signal
|
|
353
|
+
if (!existingSignal) {
|
|
354
|
+
return timeoutSignal;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Combine both signals
|
|
358
|
+
return AbortSignal.any([timeoutSignal, existingSignal]);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Handle error responses from the API
|
|
363
|
+
*/
|
|
364
|
+
private async handleErrorResponse(response: Response): Promise<never> {
|
|
365
|
+
let errorResponse: TinybirdErrorResponse | undefined;
|
|
366
|
+
let rawBody: string | undefined;
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
rawBody = await response.text();
|
|
370
|
+
errorResponse = JSON.parse(rawBody) as TinybirdErrorResponse;
|
|
371
|
+
} catch {
|
|
372
|
+
// Failed to parse error response - include raw body in message
|
|
373
|
+
if (rawBody) {
|
|
374
|
+
throw new TinybirdError(
|
|
375
|
+
`Request failed with status ${response.status}: ${rawBody}`,
|
|
376
|
+
response.status,
|
|
377
|
+
undefined
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const message =
|
|
383
|
+
errorResponse?.error ?? `Request failed with status ${response.status}`;
|
|
384
|
+
|
|
385
|
+
throw new TinybirdError(message, response.status, errorResponse);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Internal fetch wrapper
|
|
390
|
+
*/
|
|
391
|
+
private fetch(url: string, init?: RequestInit): Promise<Response> {
|
|
392
|
+
return this.fetchFn(url, init);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Create a Tinybird client
|
|
398
|
+
*
|
|
399
|
+
* @param config - Client configuration
|
|
400
|
+
* @returns Configured Tinybird client
|
|
401
|
+
*
|
|
402
|
+
* @example
|
|
403
|
+
* ```ts
|
|
404
|
+
* import { createClient } from '@tinybirdco/sdk';
|
|
405
|
+
*
|
|
406
|
+
* const client = createClient({
|
|
407
|
+
* baseUrl: process.env.TINYBIRD_URL,
|
|
408
|
+
* token: process.env.TINYBIRD_TOKEN,
|
|
409
|
+
* });
|
|
410
|
+
* ```
|
|
411
|
+
*/
|
|
412
|
+
export function createClient(config: ClientConfig): TinybirdClient {
|
|
413
|
+
return new TinybirdClient(config);
|
|
414
|
+
}
|