@tinybirdco/sdk 0.0.48 → 0.0.50
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 +71 -4
- package/dist/cli/commands/migrate.d.ts.map +1 -1
- package/dist/cli/commands/migrate.js +68 -1
- package/dist/cli/commands/migrate.js.map +1 -1
- package/dist/cli/commands/migrate.test.js +458 -1
- package/dist/cli/commands/migrate.test.js.map +1 -1
- package/dist/generator/connection.d.ts.map +1 -1
- package/dist/generator/connection.js +14 -1
- package/dist/generator/connection.js.map +1 -1
- package/dist/generator/connection.test.js +20 -4
- package/dist/generator/connection.test.js.map +1 -1
- package/dist/generator/datasource.d.ts.map +1 -1
- package/dist/generator/datasource.js +20 -10
- package/dist/generator/datasource.js.map +1 -1
- package/dist/generator/datasource.test.js +26 -1
- package/dist/generator/datasource.test.js.map +1 -1
- package/dist/generator/pipe.d.ts.map +1 -1
- package/dist/generator/pipe.js +31 -1
- package/dist/generator/pipe.js.map +1 -1
- package/dist/generator/pipe.test.js +50 -1
- package/dist/generator/pipe.test.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/migrate/emit-ts.d.ts.map +1 -1
- package/dist/migrate/emit-ts.js +95 -20
- package/dist/migrate/emit-ts.js.map +1 -1
- package/dist/migrate/parse-connection.d.ts +2 -2
- package/dist/migrate/parse-connection.d.ts.map +1 -1
- package/dist/migrate/parse-connection.js +34 -4
- package/dist/migrate/parse-connection.js.map +1 -1
- package/dist/migrate/parse-datasource.d.ts.map +1 -1
- package/dist/migrate/parse-datasource.js +79 -51
- package/dist/migrate/parse-datasource.js.map +1 -1
- package/dist/migrate/parse-pipe.d.ts.map +1 -1
- package/dist/migrate/parse-pipe.js +254 -44
- package/dist/migrate/parse-pipe.js.map +1 -1
- package/dist/migrate/parser-utils.d.ts +5 -0
- package/dist/migrate/parser-utils.d.ts.map +1 -1
- package/dist/migrate/parser-utils.js +22 -0
- package/dist/migrate/parser-utils.js.map +1 -1
- package/dist/migrate/types.d.ts +37 -4
- package/dist/migrate/types.d.ts.map +1 -1
- package/dist/schema/connection.d.ts +34 -1
- package/dist/schema/connection.d.ts.map +1 -1
- package/dist/schema/connection.js +26 -0
- package/dist/schema/connection.js.map +1 -1
- package/dist/schema/connection.test.js +35 -1
- package/dist/schema/connection.test.js.map +1 -1
- package/dist/schema/datasource.d.ts +16 -1
- package/dist/schema/datasource.d.ts.map +1 -1
- package/dist/schema/datasource.js +3 -2
- package/dist/schema/datasource.js.map +1 -1
- package/dist/schema/datasource.test.js +33 -3
- package/dist/schema/datasource.test.js.map +1 -1
- package/dist/schema/pipe.d.ts +90 -3
- package/dist/schema/pipe.d.ts.map +1 -1
- package/dist/schema/pipe.js +84 -0
- package/dist/schema/pipe.js.map +1 -1
- package/dist/schema/pipe.test.js +70 -1
- package/dist/schema/pipe.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/migrate.test.ts +671 -1
- package/src/cli/commands/migrate.ts +74 -1
- package/src/generator/connection.test.ts +29 -4
- package/src/generator/connection.ts +25 -2
- package/src/generator/datasource.test.ts +30 -1
- package/src/generator/datasource.ts +22 -10
- package/src/generator/pipe.test.ts +56 -1
- package/src/generator/pipe.ts +41 -1
- package/src/index.ts +14 -0
- package/src/migrate/emit-ts.ts +106 -24
- package/src/migrate/parse-connection.ts +56 -6
- package/src/migrate/parse-datasource.ts +84 -70
- package/src/migrate/parse-pipe.ts +359 -66
- package/src/migrate/parser-utils.ts +36 -1
- package/src/migrate/types.ts +43 -4
- package/src/schema/connection.test.ts +48 -0
- package/src/schema/connection.ts +60 -1
- package/src/schema/datasource.test.ts +39 -3
- package/src/schema/datasource.ts +24 -3
- package/src/schema/pipe.test.ts +89 -0
- package/src/schema/pipe.ts +188 -4
|
@@ -2,9 +2,11 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
defineKafkaConnection,
|
|
4
4
|
defineS3Connection,
|
|
5
|
+
defineGCSConnection,
|
|
5
6
|
isConnectionDefinition,
|
|
6
7
|
isKafkaConnectionDefinition,
|
|
7
8
|
isS3ConnectionDefinition,
|
|
9
|
+
isGCSConnectionDefinition,
|
|
8
10
|
getConnectionType,
|
|
9
11
|
} from "./connection.js";
|
|
10
12
|
|
|
@@ -153,6 +155,29 @@ describe("Connection Schema", () => {
|
|
|
153
155
|
});
|
|
154
156
|
});
|
|
155
157
|
|
|
158
|
+
describe("defineGCSConnection", () => {
|
|
159
|
+
it("creates a GCS connection with required fields", () => {
|
|
160
|
+
const conn = defineGCSConnection("my_gcs", {
|
|
161
|
+
serviceAccountCredentialsJson: '{{ tb_secret("GCS_SERVICE_ACCOUNT_CREDENTIALS_JSON") }}',
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(conn._name).toBe("my_gcs");
|
|
165
|
+
expect(conn._type).toBe("connection");
|
|
166
|
+
expect(conn._connectionType).toBe("gcs");
|
|
167
|
+
expect(conn.options.serviceAccountCredentialsJson).toBe(
|
|
168
|
+
'{{ tb_secret("GCS_SERVICE_ACCOUNT_CREDENTIALS_JSON") }}'
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("throws when credentials json is empty", () => {
|
|
173
|
+
expect(() =>
|
|
174
|
+
defineGCSConnection("my_gcs", {
|
|
175
|
+
serviceAccountCredentialsJson: " ",
|
|
176
|
+
})
|
|
177
|
+
).toThrow("GCS connection `serviceAccountCredentialsJson` is required.");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
156
181
|
describe("isConnectionDefinition", () => {
|
|
157
182
|
it("returns true for valid connection", () => {
|
|
158
183
|
const conn = defineKafkaConnection("my_kafka", {
|
|
@@ -203,6 +228,21 @@ describe("Connection Schema", () => {
|
|
|
203
228
|
});
|
|
204
229
|
});
|
|
205
230
|
|
|
231
|
+
describe("isGCSConnectionDefinition", () => {
|
|
232
|
+
it("returns true for GCS connection", () => {
|
|
233
|
+
const conn = defineGCSConnection("my_gcs", {
|
|
234
|
+
serviceAccountCredentialsJson: '{{ tb_secret("GCS_SERVICE_ACCOUNT_CREDENTIALS_JSON") }}',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(isGCSConnectionDefinition(conn)).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("returns false for non-GCS objects", () => {
|
|
241
|
+
expect(isGCSConnectionDefinition({})).toBe(false);
|
|
242
|
+
expect(isGCSConnectionDefinition(null)).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
206
246
|
describe("getConnectionType", () => {
|
|
207
247
|
it("returns the connection type", () => {
|
|
208
248
|
const conn = defineKafkaConnection("my_kafka", {
|
|
@@ -220,5 +260,13 @@ describe("Connection Schema", () => {
|
|
|
220
260
|
|
|
221
261
|
expect(getConnectionType(conn)).toBe("s3");
|
|
222
262
|
});
|
|
263
|
+
|
|
264
|
+
it("returns the gcs connection type", () => {
|
|
265
|
+
const conn = defineGCSConnection("my_gcs", {
|
|
266
|
+
serviceAccountCredentialsJson: '{{ tb_secret("GCS_SERVICE_ACCOUNT_CREDENTIALS_JSON") }}',
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(getConnectionType(conn)).toBe("gcs");
|
|
270
|
+
});
|
|
223
271
|
});
|
|
224
272
|
});
|
package/src/schema/connection.ts
CHANGED
|
@@ -81,10 +81,36 @@ export interface S3ConnectionDefinition {
|
|
|
81
81
|
readonly options: S3ConnectionOptions;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Options for defining a GCS connection
|
|
86
|
+
*/
|
|
87
|
+
export interface GCSConnectionOptions {
|
|
88
|
+
/** Service account credentials JSON */
|
|
89
|
+
serviceAccountCredentialsJson: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* GCS-specific connection definition
|
|
94
|
+
*/
|
|
95
|
+
export interface GCSConnectionDefinition {
|
|
96
|
+
readonly [CONNECTION_BRAND]: true;
|
|
97
|
+
/** Connection name */
|
|
98
|
+
readonly _name: string;
|
|
99
|
+
/** Type marker for inference */
|
|
100
|
+
readonly _type: "connection";
|
|
101
|
+
/** Connection type */
|
|
102
|
+
readonly _connectionType: "gcs";
|
|
103
|
+
/** GCS options */
|
|
104
|
+
readonly options: GCSConnectionOptions;
|
|
105
|
+
}
|
|
106
|
+
|
|
84
107
|
/**
|
|
85
108
|
* A connection definition - union of all connection types
|
|
86
109
|
*/
|
|
87
|
-
export type ConnectionDefinition =
|
|
110
|
+
export type ConnectionDefinition =
|
|
111
|
+
| KafkaConnectionDefinition
|
|
112
|
+
| S3ConnectionDefinition
|
|
113
|
+
| GCSConnectionDefinition;
|
|
88
114
|
|
|
89
115
|
function validateConnectionName(name: string): void {
|
|
90
116
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
@@ -166,6 +192,32 @@ export function defineS3Connection(
|
|
|
166
192
|
};
|
|
167
193
|
}
|
|
168
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Define a GCS connection
|
|
197
|
+
*
|
|
198
|
+
* @param name - The connection name (must be valid identifier)
|
|
199
|
+
* @param options - GCS connection configuration
|
|
200
|
+
* @returns A connection definition that can be used in a project
|
|
201
|
+
*/
|
|
202
|
+
export function defineGCSConnection(
|
|
203
|
+
name: string,
|
|
204
|
+
options: GCSConnectionOptions
|
|
205
|
+
): GCSConnectionDefinition {
|
|
206
|
+
validateConnectionName(name);
|
|
207
|
+
|
|
208
|
+
if (!options.serviceAccountCredentialsJson.trim()) {
|
|
209
|
+
throw new Error("GCS connection `serviceAccountCredentialsJson` is required.");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
[CONNECTION_BRAND]: true,
|
|
214
|
+
_name: name,
|
|
215
|
+
_type: "connection",
|
|
216
|
+
_connectionType: "gcs",
|
|
217
|
+
options,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
169
221
|
/**
|
|
170
222
|
* Check if a value is a connection definition
|
|
171
223
|
*/
|
|
@@ -192,6 +244,13 @@ export function isS3ConnectionDefinition(value: unknown): value is S3ConnectionD
|
|
|
192
244
|
return isConnectionDefinition(value) && value._connectionType === "s3";
|
|
193
245
|
}
|
|
194
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Check if a value is a GCS connection definition
|
|
249
|
+
*/
|
|
250
|
+
export function isGCSConnectionDefinition(value: unknown): value is GCSConnectionDefinition {
|
|
251
|
+
return isConnectionDefinition(value) && value._connectionType === "gcs";
|
|
252
|
+
}
|
|
253
|
+
|
|
195
254
|
/**
|
|
196
255
|
* Get the connection type from a connection definition
|
|
197
256
|
*/
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "./datasource.js";
|
|
10
10
|
import { t } from "./types.js";
|
|
11
11
|
import { engine } from "./engines.js";
|
|
12
|
-
import { defineKafkaConnection, defineS3Connection } from "./connection.js";
|
|
12
|
+
import { defineKafkaConnection, defineS3Connection, defineGCSConnection } from "./connection.js";
|
|
13
13
|
|
|
14
14
|
describe("Datasource Schema", () => {
|
|
15
15
|
describe("defineDatasource", () => {
|
|
@@ -24,6 +24,7 @@ describe("Datasource Schema", () => {
|
|
|
24
24
|
expect(ds._name).toBe("events");
|
|
25
25
|
expect(ds._type).toBe("datasource");
|
|
26
26
|
expect(ds.options.schema).toBeDefined();
|
|
27
|
+
expect(ds.options.engine).toBeUndefined();
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
it("creates a datasource with description", () => {
|
|
@@ -87,7 +88,7 @@ describe("Datasource Schema", () => {
|
|
|
87
88
|
expect(ds2._name).toBe("events_v2");
|
|
88
89
|
});
|
|
89
90
|
|
|
90
|
-
it("throws when
|
|
91
|
+
it("throws when multiple ingestion configs are configured", () => {
|
|
91
92
|
const kafkaConn = defineKafkaConnection("my_kafka", {
|
|
92
93
|
bootstrapServers: "kafka.example.com:9092",
|
|
93
94
|
});
|
|
@@ -95,6 +96,9 @@ describe("Datasource Schema", () => {
|
|
|
95
96
|
region: "us-east-1",
|
|
96
97
|
arn: "arn:aws:iam::123456789012:role/tinybird-s3-access",
|
|
97
98
|
});
|
|
99
|
+
const gcsConn = defineGCSConnection("my_gcs", {
|
|
100
|
+
serviceAccountCredentialsJson: '{{ tb_secret("GCS_SERVICE_ACCOUNT_CREDENTIALS_JSON") }}',
|
|
101
|
+
});
|
|
98
102
|
|
|
99
103
|
expect(() =>
|
|
100
104
|
defineDatasource("events", {
|
|
@@ -108,7 +112,39 @@ describe("Datasource Schema", () => {
|
|
|
108
112
|
bucketUri: "s3://my-bucket/events/*.csv",
|
|
109
113
|
},
|
|
110
114
|
})
|
|
111
|
-
).toThrow("Datasource
|
|
115
|
+
).toThrow("Datasource can only define one ingestion option: `kafka`, `s3`, or `gcs`.");
|
|
116
|
+
|
|
117
|
+
expect(() =>
|
|
118
|
+
defineDatasource("events_gcs", {
|
|
119
|
+
schema: { id: t.string() },
|
|
120
|
+
kafka: {
|
|
121
|
+
connection: kafkaConn,
|
|
122
|
+
topic: "events",
|
|
123
|
+
},
|
|
124
|
+
gcs: {
|
|
125
|
+
connection: gcsConn,
|
|
126
|
+
bucketUri: "gs://my-bucket/events/*.csv",
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
).toThrow("Datasource can only define one ingestion option: `kafka`, `s3`, or `gcs`.");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("accepts gcs ingestion configuration", () => {
|
|
133
|
+
const gcsConn = defineGCSConnection("my_gcs", {
|
|
134
|
+
serviceAccountCredentialsJson: '{{ tb_secret("GCS_SERVICE_ACCOUNT_CREDENTIALS_JSON") }}',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const ds = defineDatasource("events_gcs", {
|
|
138
|
+
schema: { id: t.string() },
|
|
139
|
+
gcs: {
|
|
140
|
+
connection: gcsConn,
|
|
141
|
+
bucketUri: "gs://my-bucket/events/*.csv",
|
|
142
|
+
schedule: "@auto",
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(ds.options.gcs?.connection._name).toBe("my_gcs");
|
|
147
|
+
expect(ds.options.gcs?.bucketUri).toBe("gs://my-bucket/events/*.csv");
|
|
112
148
|
});
|
|
113
149
|
});
|
|
114
150
|
|
package/src/schema/datasource.ts
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import { getModifiers, isTypeValidator, type AnyTypeValidator } from "./types.js";
|
|
7
7
|
import type { EngineConfig } from "./engines.js";
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
KafkaConnectionDefinition,
|
|
10
|
+
S3ConnectionDefinition,
|
|
11
|
+
GCSConnectionDefinition,
|
|
12
|
+
} from "./connection.js";
|
|
9
13
|
import type { TokenDefinition, DatasourceTokenScope } from "./token.js";
|
|
10
14
|
|
|
11
15
|
// Symbol for brand typing - use Symbol.for() for global registry
|
|
@@ -84,6 +88,20 @@ export interface S3Config {
|
|
|
84
88
|
fromTimestamp?: string;
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
/**
|
|
92
|
+
* GCS import configuration for a datasource
|
|
93
|
+
*/
|
|
94
|
+
export interface GCSConfig {
|
|
95
|
+
/** GCS connection to use */
|
|
96
|
+
connection: GCSConnectionDefinition;
|
|
97
|
+
/** GCS bucket URI, for example: gs://my-bucket/path/*.csv */
|
|
98
|
+
bucketUri: string;
|
|
99
|
+
/** Import schedule, for example: @auto or @once */
|
|
100
|
+
schedule?: string;
|
|
101
|
+
/** Incremental import lower bound timestamp expression */
|
|
102
|
+
fromTimestamp?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
/**
|
|
88
106
|
* Options for defining a datasource
|
|
89
107
|
*/
|
|
@@ -113,6 +131,8 @@ export interface DatasourceOptions<TSchema extends SchemaDefinition> {
|
|
|
113
131
|
kafka?: KafkaConfig;
|
|
114
132
|
/** S3 ingestion configuration */
|
|
115
133
|
s3?: S3Config;
|
|
134
|
+
/** GCS ingestion configuration */
|
|
135
|
+
gcs?: GCSConfig;
|
|
116
136
|
}
|
|
117
137
|
|
|
118
138
|
/**
|
|
@@ -170,8 +190,9 @@ export function defineDatasource<TSchema extends SchemaDefinition>(
|
|
|
170
190
|
);
|
|
171
191
|
}
|
|
172
192
|
|
|
173
|
-
|
|
174
|
-
|
|
193
|
+
const ingestionConfigCount = [options.kafka, options.s3, options.gcs].filter(Boolean).length;
|
|
194
|
+
if (ingestionConfigCount > 1) {
|
|
195
|
+
throw new Error("Datasource can only define one ingestion option: `kafka`, `s3`, or `gcs`.");
|
|
175
196
|
}
|
|
176
197
|
|
|
177
198
|
return {
|
package/src/schema/pipe.test.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
definePipe,
|
|
4
|
+
defineSinkPipe,
|
|
4
5
|
defineMaterializedView,
|
|
5
6
|
node,
|
|
6
7
|
isPipeDefinition,
|
|
7
8
|
getEndpointConfig,
|
|
8
9
|
getMaterializedConfig,
|
|
10
|
+
getSinkConfig,
|
|
9
11
|
isMaterializedView,
|
|
12
|
+
isSinkPipe,
|
|
10
13
|
getNodeNames,
|
|
11
14
|
getNode,
|
|
12
15
|
sql,
|
|
13
16
|
} from "./pipe.js";
|
|
14
17
|
import { defineDatasource } from "./datasource.js";
|
|
18
|
+
import { defineKafkaConnection, defineS3Connection } from "./connection.js";
|
|
15
19
|
import { t } from "./types.js";
|
|
16
20
|
import { p } from "./params.js";
|
|
17
21
|
import { engine } from "./engines.js";
|
|
@@ -154,6 +158,91 @@ describe("Pipe Schema", () => {
|
|
|
154
158
|
});
|
|
155
159
|
});
|
|
156
160
|
|
|
161
|
+
describe("Sink pipes", () => {
|
|
162
|
+
it("creates a Kafka sink pipe", () => {
|
|
163
|
+
const kafka = defineKafkaConnection("events_kafka", {
|
|
164
|
+
bootstrapServers: "localhost:9092",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const pipe = defineSinkPipe("events_sink", {
|
|
168
|
+
nodes: [node({ name: "publish", sql: "SELECT * FROM events" })],
|
|
169
|
+
sink: {
|
|
170
|
+
connection: kafka,
|
|
171
|
+
topic: "events_out",
|
|
172
|
+
schedule: "@on-demand",
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const sink = getSinkConfig(pipe);
|
|
177
|
+
expect(sink).toBeTruthy();
|
|
178
|
+
expect(sink && "topic" in sink ? sink.topic : undefined).toBe("events_out");
|
|
179
|
+
expect(isSinkPipe(pipe)).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("creates an S3 sink pipe", () => {
|
|
183
|
+
const s3 = defineS3Connection("exports_s3", {
|
|
184
|
+
region: "us-east-1",
|
|
185
|
+
arn: "arn:aws:iam::123456789012:role/tinybird-s3-access",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const pipe = defineSinkPipe("exports_sink", {
|
|
189
|
+
nodes: [node({ name: "export", sql: "SELECT * FROM events" })],
|
|
190
|
+
sink: {
|
|
191
|
+
connection: s3,
|
|
192
|
+
bucketUri: "s3://exports/events/",
|
|
193
|
+
fileTemplate: "events_{date}",
|
|
194
|
+
schedule: "@once",
|
|
195
|
+
format: "csv",
|
|
196
|
+
strategy: "create_new",
|
|
197
|
+
compression: "gzip",
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const sink = getSinkConfig(pipe);
|
|
202
|
+
expect(sink).toBeTruthy();
|
|
203
|
+
expect(sink && "bucketUri" in sink ? sink.bucketUri : undefined).toBe("s3://exports/events/");
|
|
204
|
+
expect(isSinkPipe(pipe)).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("throws when Kafka sink connection type is invalid", () => {
|
|
208
|
+
const s3 = defineS3Connection("exports_s3", {
|
|
209
|
+
region: "us-east-1",
|
|
210
|
+
arn: "arn:aws:iam::123456789012:role/tinybird-s3-access",
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(() =>
|
|
214
|
+
defineSinkPipe("bad_sink", {
|
|
215
|
+
nodes: [node({ name: "export", sql: "SELECT * FROM events" })],
|
|
216
|
+
sink: {
|
|
217
|
+
// Runtime validation rejects mismatched connection/type
|
|
218
|
+
connection: s3 as unknown as ReturnType<typeof defineKafkaConnection>,
|
|
219
|
+
topic: "events_out",
|
|
220
|
+
schedule: "@on-demand",
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
).toThrow("requires a Kafka connection");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("throws when sink configuration is passed to definePipe", () => {
|
|
227
|
+
const kafka = defineKafkaConnection("events_kafka", {
|
|
228
|
+
bootstrapServers: "localhost:9092",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(() =>
|
|
232
|
+
definePipe(
|
|
233
|
+
"bad_via_define_pipe",
|
|
234
|
+
{
|
|
235
|
+
nodes: [node({ name: "export", sql: "SELECT * FROM events" })],
|
|
236
|
+
sink: {
|
|
237
|
+
connection: kafka,
|
|
238
|
+
topic: "events_out",
|
|
239
|
+
},
|
|
240
|
+
} as unknown as Parameters<typeof definePipe>[1]
|
|
241
|
+
)
|
|
242
|
+
).toThrow("must be created with defineSinkPipe");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
157
246
|
describe("getEndpointConfig", () => {
|
|
158
247
|
it("returns null when endpoint is false", () => {
|
|
159
248
|
const pipe = definePipe("my_pipe", {
|
package/src/schema/pipe.ts
CHANGED
|
@@ -9,6 +9,8 @@ import type { DatasourceDefinition, SchemaDefinition, ColumnDefinition } from ".
|
|
|
9
9
|
import { getColumnType } from "./datasource.js";
|
|
10
10
|
import { getTinybirdType } from "./types.js";
|
|
11
11
|
import type { TokenDefinition, PipeTokenScope } from "./token.js";
|
|
12
|
+
import type { KafkaConnectionDefinition, S3ConnectionDefinition } from "./connection.js";
|
|
13
|
+
import { isKafkaConnectionDefinition, isS3ConnectionDefinition } from "./connection.js";
|
|
12
14
|
|
|
13
15
|
/** Symbol for brand typing pipes - use Symbol.for() for global registry */
|
|
14
16
|
export const PIPE_BRAND = Symbol.for("tinybird.pipe");
|
|
@@ -155,6 +157,55 @@ export interface CopyConfig<
|
|
|
155
157
|
copy_schedule?: string;
|
|
156
158
|
}
|
|
157
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Sink export strategy.
|
|
162
|
+
* - 'create_new': write new files on each run
|
|
163
|
+
* - 'replace': replace destination data on each run
|
|
164
|
+
*/
|
|
165
|
+
export type SinkStrategy = "create_new" | "replace";
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* S3 sink compression codec.
|
|
169
|
+
*/
|
|
170
|
+
export type SinkCompression = "none" | "gzip" | "snappy";
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Kafka sink configuration
|
|
174
|
+
*/
|
|
175
|
+
export interface KafkaSinkConfig {
|
|
176
|
+
/** Kafka connection used to publish records */
|
|
177
|
+
connection: KafkaConnectionDefinition;
|
|
178
|
+
/** Destination Kafka topic */
|
|
179
|
+
topic: string;
|
|
180
|
+
/** Sink schedule (for example: @on-demand, @once, cron expression) */
|
|
181
|
+
schedule: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* S3 sink configuration
|
|
186
|
+
*/
|
|
187
|
+
export interface S3SinkConfig {
|
|
188
|
+
/** S3 connection used to write exported files */
|
|
189
|
+
connection: S3ConnectionDefinition;
|
|
190
|
+
/** Destination bucket URI (for example: s3://bucket/prefix/) */
|
|
191
|
+
bucketUri: string;
|
|
192
|
+
/** Output filename template (supports Tinybird placeholders) */
|
|
193
|
+
fileTemplate: string;
|
|
194
|
+
/** Output format (for example: csv, ndjson) */
|
|
195
|
+
format: string;
|
|
196
|
+
/** Sink schedule (for example: @on-demand, @once, cron expression) */
|
|
197
|
+
schedule: string;
|
|
198
|
+
/** Export strategy */
|
|
199
|
+
strategy?: SinkStrategy;
|
|
200
|
+
/** Compression codec */
|
|
201
|
+
compression?: SinkCompression;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Sink pipe configuration (Kafka or S3 only)
|
|
206
|
+
*/
|
|
207
|
+
export type SinkConfig = KafkaSinkConfig | S3SinkConfig;
|
|
208
|
+
|
|
158
209
|
/**
|
|
159
210
|
* Inline token configuration for pipe access
|
|
160
211
|
*/
|
|
@@ -194,9 +245,9 @@ export interface PipeOptions<
|
|
|
194
245
|
nodes: readonly NodeDefinition[];
|
|
195
246
|
/** Output schema (optional for reusable pipes, required for endpoints) */
|
|
196
247
|
output?: TOutput;
|
|
197
|
-
/** Whether this pipe is an API endpoint (shorthand for { enabled: true }). Mutually exclusive with materialized and
|
|
248
|
+
/** Whether this pipe is an API endpoint (shorthand for { enabled: true }). Mutually exclusive with materialized, copy, and sink. */
|
|
198
249
|
endpoint?: boolean | EndpointConfig;
|
|
199
|
-
/** Materialized view configuration. Mutually exclusive with endpoint and
|
|
250
|
+
/** Materialized view configuration. Mutually exclusive with endpoint, copy, and sink. */
|
|
200
251
|
materialized?: MaterializedConfig;
|
|
201
252
|
/** Copy pipe configuration. Mutually exclusive with endpoint and materialized. */
|
|
202
253
|
copy?: CopyConfig;
|
|
@@ -204,6 +255,33 @@ export interface PipeOptions<
|
|
|
204
255
|
tokens?: readonly PipeTokenConfig[];
|
|
205
256
|
}
|
|
206
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Options for defining a sink pipe
|
|
260
|
+
*/
|
|
261
|
+
export interface SinkPipeOptions<TParams extends ParamsDefinition> {
|
|
262
|
+
/** Human-readable description of the sink pipe */
|
|
263
|
+
description?: string;
|
|
264
|
+
/** Parameter definitions for query inputs */
|
|
265
|
+
params?: TParams;
|
|
266
|
+
/** Nodes in the transformation pipeline */
|
|
267
|
+
nodes: readonly NodeDefinition[];
|
|
268
|
+
/** Sink export configuration */
|
|
269
|
+
sink: SinkConfig;
|
|
270
|
+
/** Sink pipes are not endpoints */
|
|
271
|
+
endpoint?: never;
|
|
272
|
+
/** Sink pipes are not materialized views */
|
|
273
|
+
materialized?: never;
|
|
274
|
+
/** Sink pipes are not copy pipes */
|
|
275
|
+
copy?: never;
|
|
276
|
+
/** Access tokens for this sink pipe */
|
|
277
|
+
tokens?: readonly PipeTokenConfig[];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
type PipeRuntimeOptions<
|
|
281
|
+
TParams extends ParamsDefinition,
|
|
282
|
+
TOutput extends OutputDefinition
|
|
283
|
+
> = (PipeOptions<TParams, TOutput> & { sink?: never }) | SinkPipeOptions<TParams>;
|
|
284
|
+
|
|
207
285
|
/**
|
|
208
286
|
* Options for defining an endpoint (API-exposed pipe)
|
|
209
287
|
*/
|
|
@@ -277,7 +355,7 @@ export interface PipeDefinition<
|
|
|
277
355
|
/** Output schema (optional for reusable pipes) */
|
|
278
356
|
readonly _output?: TOutput;
|
|
279
357
|
/** Full options */
|
|
280
|
-
readonly options:
|
|
358
|
+
readonly options: PipeRuntimeOptions<TParams, TOutput>;
|
|
281
359
|
}
|
|
282
360
|
|
|
283
361
|
/**
|
|
@@ -428,6 +506,52 @@ function validateMaterializedSchema(
|
|
|
428
506
|
}
|
|
429
507
|
}
|
|
430
508
|
|
|
509
|
+
function validateSinkConfig(pipeName: string, sink: SinkConfig): void {
|
|
510
|
+
if ("topic" in sink) {
|
|
511
|
+
if (!isKafkaConnectionDefinition(sink.connection)) {
|
|
512
|
+
throw new Error(
|
|
513
|
+
`Pipe "${pipeName}" sink with topic requires a Kafka connection.`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
if (typeof sink.topic !== "string" || !sink.topic.trim()) {
|
|
517
|
+
throw new Error(`Pipe "${pipeName}" sink topic cannot be empty.`);
|
|
518
|
+
}
|
|
519
|
+
if (typeof sink.schedule !== "string" || !sink.schedule.trim()) {
|
|
520
|
+
throw new Error(`Pipe "${pipeName}" sink schedule cannot be empty.`);
|
|
521
|
+
}
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (!isS3ConnectionDefinition(sink.connection)) {
|
|
526
|
+
throw new Error(
|
|
527
|
+
`Pipe "${pipeName}" S3 sink requires an S3 connection.`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (typeof sink.bucketUri !== "string" || !sink.bucketUri.trim()) {
|
|
531
|
+
throw new Error(`Pipe "${pipeName}" sink bucketUri cannot be empty.`);
|
|
532
|
+
}
|
|
533
|
+
if (typeof sink.fileTemplate !== "string" || !sink.fileTemplate.trim()) {
|
|
534
|
+
throw new Error(`Pipe "${pipeName}" sink fileTemplate cannot be empty.`);
|
|
535
|
+
}
|
|
536
|
+
if (typeof sink.format !== "string" || !sink.format.trim()) {
|
|
537
|
+
throw new Error(`Pipe "${pipeName}" sink format cannot be empty.`);
|
|
538
|
+
}
|
|
539
|
+
if (typeof sink.schedule !== "string" || !sink.schedule.trim()) {
|
|
540
|
+
throw new Error(`Pipe "${pipeName}" sink schedule cannot be empty.`);
|
|
541
|
+
}
|
|
542
|
+
if (sink.strategy && sink.strategy !== "create_new" && sink.strategy !== "replace") {
|
|
543
|
+
throw new Error(`Pipe "${pipeName}" sink strategy must be "create_new" or "replace".`);
|
|
544
|
+
}
|
|
545
|
+
if (
|
|
546
|
+
sink.compression &&
|
|
547
|
+
sink.compression !== "none" &&
|
|
548
|
+
sink.compression !== "gzip" &&
|
|
549
|
+
sink.compression !== "snappy"
|
|
550
|
+
) {
|
|
551
|
+
throw new Error(`Pipe "${pipeName}" sink compression must be "none", "gzip", or "snappy".`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
431
555
|
export function definePipe<
|
|
432
556
|
TParams extends ParamsDefinition,
|
|
433
557
|
TOutput extends OutputDefinition
|
|
@@ -447,6 +571,12 @@ export function definePipe<
|
|
|
447
571
|
throw new Error(`Pipe "${name}" must have at least one node.`);
|
|
448
572
|
}
|
|
449
573
|
|
|
574
|
+
if ("sink" in (options as unknown as object)) {
|
|
575
|
+
throw new Error(
|
|
576
|
+
`Pipe "${name}" sink configuration must be created with defineSinkPipe().`
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
450
580
|
// Validate output is provided for endpoints and materialized views
|
|
451
581
|
if ((options.endpoint || options.materialized) && (!options.output || Object.keys(options.output).length === 0)) {
|
|
452
582
|
throw new Error(
|
|
@@ -480,7 +610,47 @@ export function definePipe<
|
|
|
480
610
|
options: {
|
|
481
611
|
...options,
|
|
482
612
|
params,
|
|
483
|
-
},
|
|
613
|
+
} as PipeRuntimeOptions<TParams, TOutput>,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Define a Tinybird sink pipe
|
|
619
|
+
*
|
|
620
|
+
* Sink pipes export query results to external systems via Kafka or S3.
|
|
621
|
+
*
|
|
622
|
+
* @param name - The sink pipe name (must be valid identifier)
|
|
623
|
+
* @param options - Sink pipe configuration
|
|
624
|
+
* @returns A pipe definition configured as a sink pipe
|
|
625
|
+
*/
|
|
626
|
+
export function defineSinkPipe<TParams extends ParamsDefinition>(
|
|
627
|
+
name: string,
|
|
628
|
+
options: SinkPipeOptions<TParams>
|
|
629
|
+
): PipeDefinition<TParams, Record<string, never>> {
|
|
630
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
631
|
+
throw new Error(
|
|
632
|
+
`Invalid pipe name: "${name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.`
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (!options.nodes || options.nodes.length === 0) {
|
|
637
|
+
throw new Error(`Pipe "${name}" must have at least one node.`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
validateSinkConfig(name, options.sink);
|
|
641
|
+
|
|
642
|
+
const params = (options.params ?? {}) as TParams;
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
[PIPE_BRAND]: true,
|
|
646
|
+
_name: name,
|
|
647
|
+
_type: "pipe",
|
|
648
|
+
_params: params,
|
|
649
|
+
_output: undefined,
|
|
650
|
+
options: {
|
|
651
|
+
...options,
|
|
652
|
+
params,
|
|
653
|
+
} as PipeRuntimeOptions<TParams, Record<string, never>>,
|
|
484
654
|
};
|
|
485
655
|
}
|
|
486
656
|
|
|
@@ -790,6 +960,20 @@ export function isCopyPipe(pipe: PipeDefinition): boolean {
|
|
|
790
960
|
return pipe.options.copy !== undefined;
|
|
791
961
|
}
|
|
792
962
|
|
|
963
|
+
/**
|
|
964
|
+
* Get the sink configuration from a pipe
|
|
965
|
+
*/
|
|
966
|
+
export function getSinkConfig(pipe: PipeDefinition): SinkConfig | null {
|
|
967
|
+
return "sink" in pipe.options ? (pipe.options.sink ?? null) : null;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Check if a pipe is a sink pipe
|
|
972
|
+
*/
|
|
973
|
+
export function isSinkPipe(pipe: PipeDefinition): boolean {
|
|
974
|
+
return pipe.options.sink !== undefined;
|
|
975
|
+
}
|
|
976
|
+
|
|
793
977
|
/**
|
|
794
978
|
* Get all node names from a pipe
|
|
795
979
|
*/
|