@tinybirdco/sdk 0.0.47 → 0.0.49

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.
Files changed (107) hide show
  1. package/README.md +53 -3
  2. package/dist/cli/commands/migrate.d.ts.map +1 -1
  3. package/dist/cli/commands/migrate.js +32 -0
  4. package/dist/cli/commands/migrate.js.map +1 -1
  5. package/dist/cli/commands/migrate.test.js +585 -8
  6. package/dist/cli/commands/migrate.test.js.map +1 -1
  7. package/dist/generator/connection.d.ts.map +1 -1
  8. package/dist/generator/connection.js +3 -0
  9. package/dist/generator/connection.js.map +1 -1
  10. package/dist/generator/connection.test.js +8 -0
  11. package/dist/generator/connection.test.js.map +1 -1
  12. package/dist/generator/datasource.d.ts.map +1 -1
  13. package/dist/generator/datasource.js +3 -0
  14. package/dist/generator/datasource.js.map +1 -1
  15. package/dist/generator/datasource.test.js +50 -0
  16. package/dist/generator/datasource.test.js.map +1 -1
  17. package/dist/generator/pipe.d.ts.map +1 -1
  18. package/dist/generator/pipe.js +31 -1
  19. package/dist/generator/pipe.js.map +1 -1
  20. package/dist/generator/pipe.test.js +50 -1
  21. package/dist/generator/pipe.test.js.map +1 -1
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/index.test.js +3 -0
  27. package/dist/index.test.js.map +1 -1
  28. package/dist/migrate/emit-ts.d.ts.map +1 -1
  29. package/dist/migrate/emit-ts.js +159 -41
  30. package/dist/migrate/emit-ts.js.map +1 -1
  31. package/dist/migrate/parse-connection.d.ts.map +1 -1
  32. package/dist/migrate/parse-connection.js +13 -2
  33. package/dist/migrate/parse-connection.js.map +1 -1
  34. package/dist/migrate/parse-datasource.d.ts.map +1 -1
  35. package/dist/migrate/parse-datasource.js +115 -52
  36. package/dist/migrate/parse-datasource.js.map +1 -1
  37. package/dist/migrate/parse-pipe.d.ts.map +1 -1
  38. package/dist/migrate/parse-pipe.js +257 -46
  39. package/dist/migrate/parse-pipe.js.map +1 -1
  40. package/dist/migrate/parser-utils.d.ts +5 -0
  41. package/dist/migrate/parser-utils.d.ts.map +1 -1
  42. package/dist/migrate/parser-utils.js +22 -0
  43. package/dist/migrate/parser-utils.js.map +1 -1
  44. package/dist/migrate/types.d.ts +25 -3
  45. package/dist/migrate/types.d.ts.map +1 -1
  46. package/dist/schema/connection.d.ts +2 -0
  47. package/dist/schema/connection.d.ts.map +1 -1
  48. package/dist/schema/connection.js.map +1 -1
  49. package/dist/schema/datasource.d.ts +3 -1
  50. package/dist/schema/datasource.d.ts.map +1 -1
  51. package/dist/schema/datasource.js +8 -1
  52. package/dist/schema/datasource.js.map +1 -1
  53. package/dist/schema/datasource.test.js +13 -0
  54. package/dist/schema/datasource.test.js.map +1 -1
  55. package/dist/schema/engines.d.ts.map +1 -1
  56. package/dist/schema/engines.js +3 -0
  57. package/dist/schema/engines.js.map +1 -1
  58. package/dist/schema/engines.test.js +16 -0
  59. package/dist/schema/engines.test.js.map +1 -1
  60. package/dist/schema/pipe.d.ts +90 -3
  61. package/dist/schema/pipe.d.ts.map +1 -1
  62. package/dist/schema/pipe.js +84 -0
  63. package/dist/schema/pipe.js.map +1 -1
  64. package/dist/schema/pipe.test.js +70 -1
  65. package/dist/schema/pipe.test.js.map +1 -1
  66. package/dist/schema/secret.d.ts +6 -0
  67. package/dist/schema/secret.d.ts.map +1 -0
  68. package/dist/schema/secret.js +14 -0
  69. package/dist/schema/secret.js.map +1 -0
  70. package/dist/schema/secret.test.d.ts +2 -0
  71. package/dist/schema/secret.test.d.ts.map +1 -0
  72. package/dist/schema/secret.test.js +14 -0
  73. package/dist/schema/secret.test.js.map +1 -0
  74. package/dist/schema/types.d.ts +5 -0
  75. package/dist/schema/types.d.ts.map +1 -1
  76. package/dist/schema/types.js +6 -0
  77. package/dist/schema/types.js.map +1 -1
  78. package/dist/schema/types.test.js +12 -0
  79. package/dist/schema/types.test.js.map +1 -1
  80. package/package.json +1 -1
  81. package/src/cli/commands/migrate.test.ts +859 -8
  82. package/src/cli/commands/migrate.ts +35 -0
  83. package/src/generator/connection.test.ts +13 -0
  84. package/src/generator/connection.ts +4 -0
  85. package/src/generator/datasource.test.ts +60 -0
  86. package/src/generator/datasource.ts +3 -0
  87. package/src/generator/pipe.test.ts +56 -1
  88. package/src/generator/pipe.ts +41 -1
  89. package/src/index.test.ts +4 -0
  90. package/src/index.ts +12 -0
  91. package/src/migrate/emit-ts.ts +161 -48
  92. package/src/migrate/parse-connection.ts +15 -2
  93. package/src/migrate/parse-datasource.ts +134 -71
  94. package/src/migrate/parse-pipe.ts +364 -69
  95. package/src/migrate/parser-utils.ts +36 -1
  96. package/src/migrate/types.ts +28 -3
  97. package/src/schema/connection.ts +2 -0
  98. package/src/schema/datasource.test.ts +17 -0
  99. package/src/schema/datasource.ts +13 -2
  100. package/src/schema/engines.test.ts +18 -0
  101. package/src/schema/engines.ts +3 -0
  102. package/src/schema/pipe.test.ts +89 -0
  103. package/src/schema/pipe.ts +188 -4
  104. package/src/schema/secret.test.ts +19 -0
  105. package/src/schema/secret.ts +16 -0
  106. package/src/schema/types.test.ts +14 -0
  107. package/src/schema/types.ts +10 -0
@@ -3,49 +3,40 @@ import {
3
3
  MigrationParseError,
4
4
  isBlank,
5
5
  parseDirectiveLine,
6
+ parseQuotedValue,
7
+ readDirectiveBlock,
6
8
  splitLines,
7
9
  splitTopLevelComma,
8
- stripIndent,
9
10
  } from "./parser-utils.js";
10
11
 
11
- interface BlockReadResult {
12
- lines: string[];
13
- nextIndex: number;
14
- }
15
-
16
- function readIndentedBlock(lines: string[], startIndex: number): BlockReadResult {
17
- const collected: string[] = [];
18
- let i = startIndex;
19
-
20
- while (i < lines.length) {
21
- const line = lines[i] ?? "";
22
- if (line.startsWith(" ")) {
23
- collected.push(stripIndent(line));
24
- i += 1;
25
- continue;
26
- }
27
-
28
- if (isBlank(line)) {
29
- let j = i + 1;
30
- while (j < lines.length && isBlank(lines[j] ?? "")) {
31
- j += 1;
32
- }
33
- if (j < lines.length && (lines[j] ?? "").startsWith(" ")) {
34
- collected.push("");
35
- i += 1;
36
- continue;
37
- }
38
- }
39
-
40
- break;
12
+ const PIPE_DIRECTIVES = new Set([
13
+ "DESCRIPTION",
14
+ "NODE",
15
+ "SQL",
16
+ "TYPE",
17
+ "CACHE",
18
+ "DATASOURCE",
19
+ "DEPLOYMENT_METHOD",
20
+ "TARGET_DATASOURCE",
21
+ "COPY_SCHEDULE",
22
+ "COPY_MODE",
23
+ "TOKEN",
24
+ ]);
25
+
26
+ function isPipeDirectiveLine(line: string): boolean {
27
+ if (!line) {
28
+ return false;
41
29
  }
42
-
43
- return { lines: collected, nextIndex: i };
30
+ const { key } = parseDirectiveLine(line);
31
+ return PIPE_DIRECTIVES.has(key);
44
32
  }
45
33
 
46
34
  function nextNonBlank(lines: string[], startIndex: number): number {
47
35
  let i = startIndex;
48
- while (i < lines.length && isBlank(lines[i] ?? "")) {
36
+ while (
37
+ i < lines.length &&
38
+ (isBlank(lines[i] ?? "") || (lines[i] ?? "").trim().startsWith("#"))
39
+ ) {
49
40
  i += 1;
50
41
  }
51
42
  return i;
@@ -114,11 +105,14 @@ function mapTemplateFunctionToParamType(func: string): string | null {
114
105
  return null;
115
106
  }
116
107
 
117
- function parseParamDefault(rawValue: string): string | number {
108
+ function parseParamDefault(rawValue: string): string | number | boolean {
118
109
  const trimmed = rawValue.trim();
119
110
  if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
120
111
  return Number(trimmed);
121
112
  }
113
+ if (/^(true|false)$/i.test(trimmed)) {
114
+ return trimmed.toLowerCase() === "true";
115
+ }
122
116
  if (
123
117
  (trimmed.startsWith("'") && trimmed.endsWith("'")) ||
124
118
  (trimmed.startsWith('"') && trimmed.endsWith('"'))
@@ -128,6 +122,92 @@ function parseParamDefault(rawValue: string): string | number {
128
122
  throw new Error(`Unsupported parameter default value: "${rawValue}"`);
129
123
  }
130
124
 
125
+ function parseKeywordArgument(rawArg: string): { key: string; value: string } | null {
126
+ const equalsIndex = rawArg.indexOf("=");
127
+ if (equalsIndex <= 0) {
128
+ return null;
129
+ }
130
+
131
+ const key = rawArg.slice(0, equalsIndex).trim();
132
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
133
+ return null;
134
+ }
135
+
136
+ const value = rawArg.slice(equalsIndex + 1).trim();
137
+ if (!value) {
138
+ return null;
139
+ }
140
+
141
+ return { key, value };
142
+ }
143
+
144
+ function parseRequiredFlag(rawValue: string): boolean {
145
+ const normalized = rawValue.trim().toLowerCase();
146
+ if (normalized === "true" || normalized === "1") {
147
+ return true;
148
+ }
149
+ if (normalized === "false" || normalized === "0") {
150
+ return false;
151
+ }
152
+ throw new Error(`Unsupported required value: "${rawValue}"`);
153
+ }
154
+
155
+ function parseParamOptions(rawArgs: string[]): {
156
+ defaultValue?: string | number | boolean;
157
+ required?: boolean;
158
+ description?: string;
159
+ } {
160
+ let positionalDefault: string | number | boolean | undefined;
161
+ let keywordDefault: string | number | boolean | undefined;
162
+ let required: boolean | undefined;
163
+ let description: string | undefined;
164
+
165
+ for (const rawArg of rawArgs) {
166
+ const trimmed = rawArg.trim();
167
+ if (!trimmed) {
168
+ continue;
169
+ }
170
+
171
+ const keyword = parseKeywordArgument(trimmed);
172
+ if (!keyword) {
173
+ if (positionalDefault === undefined) {
174
+ positionalDefault = parseParamDefault(trimmed);
175
+ }
176
+ continue;
177
+ }
178
+
179
+ const keyLower = keyword.key.toLowerCase();
180
+ if (keyLower === "default") {
181
+ keywordDefault = parseParamDefault(keyword.value);
182
+ continue;
183
+ }
184
+ if (keyLower === "required") {
185
+ required = parseRequiredFlag(keyword.value);
186
+ continue;
187
+ }
188
+ if (keyLower === "description") {
189
+ const parsedDescription = parseParamDefault(keyword.value);
190
+ if (typeof parsedDescription !== "string") {
191
+ throw new Error(`Unsupported description value: "${keyword.value}"`);
192
+ }
193
+ description = parsedDescription;
194
+ continue;
195
+ }
196
+ }
197
+
198
+ let defaultValue = keywordDefault ?? positionalDefault;
199
+ if (keywordDefault !== undefined && positionalDefault !== undefined) {
200
+ if (keywordDefault !== positionalDefault) {
201
+ throw new Error(
202
+ `Parameter has conflicting positional and keyword defaults: "${positionalDefault}" and "${keywordDefault}".`
203
+ );
204
+ }
205
+ defaultValue = positionalDefault;
206
+ }
207
+
208
+ return { defaultValue, required, description };
209
+ }
210
+
131
211
  function inferParamsFromSql(
132
212
  sql: string,
133
213
  filePath: string,
@@ -170,10 +250,15 @@ function inferParamsFromSql(
170
250
  );
171
251
  }
172
252
 
173
- let defaultValue: string | number | undefined;
253
+ let defaultValue: string | number | boolean | undefined;
254
+ let required: boolean | undefined;
255
+ let description: string | undefined;
174
256
  if (args.length > 1) {
175
257
  try {
176
- defaultValue = parseParamDefault(args[1] ?? "");
258
+ const parsedOptions = parseParamOptions(args.slice(1));
259
+ defaultValue = parsedOptions.defaultValue;
260
+ required = parsedOptions.required;
261
+ description = parsedOptions.description;
177
262
  } catch (error) {
178
263
  throw new MigrationParseError(
179
264
  filePath,
@@ -206,14 +291,21 @@ function inferParamsFromSql(
206
291
  }
207
292
  if (existing.defaultValue === undefined && defaultValue !== undefined) {
208
293
  existing.defaultValue = defaultValue;
209
- existing.required = false;
210
294
  }
295
+ if (existing.description === undefined && description !== undefined) {
296
+ existing.description = description;
297
+ }
298
+ const optionalInAnyUsage =
299
+ existing.required === false || required === false || defaultValue !== undefined;
300
+ existing.required = !optionalInAnyUsage;
211
301
  } else {
302
+ const isRequired = required ?? defaultValue === undefined;
212
303
  params.set(paramName, {
213
304
  name: paramName,
214
305
  type: mappedType,
215
- required: defaultValue === undefined,
306
+ required: isRequired,
216
307
  defaultValue,
308
+ description,
217
309
  });
218
310
  }
219
311
 
@@ -224,7 +316,13 @@ function inferParamsFromSql(
224
316
  }
225
317
 
226
318
  function parseToken(filePath: string, resourceName: string, value: string): PipeTokenModel {
227
- const parts = value.split(/\s+/).filter(Boolean);
319
+ const trimmed = value.trim();
320
+ const quotedMatch = trimmed.match(/^"([^"]+)"(?:\s+(READ))?$/);
321
+ if (quotedMatch) {
322
+ return { name: quotedMatch[1] ?? "", scope: "READ" };
323
+ }
324
+
325
+ const parts = trimmed.split(/\s+/).filter(Boolean);
228
326
  if (parts.length === 0) {
229
327
  throw new MigrationParseError(filePath, "pipe", resourceName, "Invalid TOKEN line.");
230
328
  }
@@ -237,7 +335,13 @@ function parseToken(filePath: string, resourceName: string, value: string): Pipe
237
335
  );
238
336
  }
239
337
 
240
- const tokenName = parts[0];
338
+ const rawTokenName = parts[0] ?? "";
339
+ const tokenName =
340
+ rawTokenName.startsWith('"') &&
341
+ rawTokenName.endsWith('"') &&
342
+ rawTokenName.length >= 2
343
+ ? rawTokenName.slice(1, -1)
344
+ : rawTokenName;
241
345
  const scope = parts[1] ?? "READ";
242
346
  if (scope !== "READ") {
243
347
  throw new MigrationParseError(
@@ -263,27 +367,27 @@ export function parsePipeFile(resource: ResourceFile): PipeModel {
263
367
  let copyTargetDatasource: string | undefined;
264
368
  let copySchedule: string | undefined;
265
369
  let copyMode: "append" | "replace" | undefined;
370
+ let exportService: "kafka" | "s3" | undefined;
371
+ let exportConnectionName: string | undefined;
372
+ let exportTopic: string | undefined;
373
+ let exportBucketUri: string | undefined;
374
+ let exportFileTemplate: string | undefined;
375
+ let exportFormat: string | undefined;
376
+ let exportSchedule: string | undefined;
377
+ let exportStrategy: "create_new" | "replace" | undefined;
378
+ let exportCompression: "none" | "gzip" | "snappy" | undefined;
266
379
 
267
380
  let i = 0;
268
381
  while (i < lines.length) {
269
382
  const line = (lines[i] ?? "").trim();
270
- if (!line) {
383
+ if (!line || line.startsWith("#")) {
271
384
  i += 1;
272
385
  continue;
273
386
  }
274
387
 
275
388
  if (line === "DESCRIPTION >") {
276
- const block = readIndentedBlock(lines, i + 1);
277
- if (block.lines.length === 0) {
278
- throw new MigrationParseError(
279
- resource.filePath,
280
- "pipe",
281
- resource.name,
282
- "DESCRIPTION block is empty."
283
- );
284
- }
285
-
286
- if (!description) {
389
+ const block = readDirectiveBlock(lines, i + 1, isPipeDirectiveLine);
390
+ if (description === undefined) {
287
391
  description = block.lines.join("\n");
288
392
  } else if (nodes.length > 0) {
289
393
  nodes[nodes.length - 1] = {
@@ -318,15 +422,7 @@ export function parsePipeFile(resource: ResourceFile): PipeModel {
318
422
 
319
423
  let nodeDescription: string | undefined;
320
424
  if ((lines[i] ?? "").trim() === "DESCRIPTION >") {
321
- const descriptionBlock = readIndentedBlock(lines, i + 1);
322
- if (descriptionBlock.lines.length === 0) {
323
- throw new MigrationParseError(
324
- resource.filePath,
325
- "pipe",
326
- resource.name,
327
- `Node "${nodeName}" has an empty DESCRIPTION block.`
328
- );
329
- }
425
+ const descriptionBlock = readDirectiveBlock(lines, i + 1, isPipeDirectiveLine);
330
426
  nodeDescription = descriptionBlock.lines.join("\n");
331
427
  i = descriptionBlock.nextIndex;
332
428
  i = nextNonBlank(lines, i);
@@ -340,7 +436,7 @@ export function parsePipeFile(resource: ResourceFile): PipeModel {
340
436
  `Node "${nodeName}" is missing SQL > block.`
341
437
  );
342
438
  }
343
- const sqlBlock = readIndentedBlock(lines, i + 1);
439
+ const sqlBlock = readDirectiveBlock(lines, i + 1, isPipeDirectiveLine);
344
440
  if (sqlBlock.lines.length === 0) {
345
441
  throw new MigrationParseError(
346
442
  resource.filePath,
@@ -374,22 +470,26 @@ export function parsePipeFile(resource: ResourceFile): PipeModel {
374
470
 
375
471
  const { key, value } = parseDirectiveLine(line);
376
472
  switch (key) {
377
- case "TYPE":
378
- if (value === "endpoint") {
473
+ case "TYPE": {
474
+ const normalizedType = parseQuotedValue(value).toLowerCase();
475
+ if (normalizedType === "endpoint") {
379
476
  pipeType = "endpoint";
380
- } else if (value === "MATERIALIZED") {
477
+ } else if (normalizedType === "materialized") {
381
478
  pipeType = "materialized";
382
- } else if (value === "COPY") {
479
+ } else if (normalizedType === "copy") {
383
480
  pipeType = "copy";
481
+ } else if (normalizedType === "sink") {
482
+ pipeType = "sink";
384
483
  } else {
385
484
  throw new MigrationParseError(
386
485
  resource.filePath,
387
486
  "pipe",
388
487
  resource.name,
389
- `Unsupported TYPE value in strict mode: "${value}"`
488
+ `Unsupported TYPE value in strict mode: "${parseQuotedValue(value)}"`
390
489
  );
391
490
  }
392
491
  break;
492
+ }
393
493
  case "CACHE": {
394
494
  const ttl = Number(value);
395
495
  if (!Number.isFinite(ttl) || ttl < 0) {
@@ -434,6 +534,63 @@ export function parsePipeFile(resource: ResourceFile): PipeModel {
434
534
  }
435
535
  copyMode = value;
436
536
  break;
537
+ case "EXPORT_SERVICE": {
538
+ const normalized = parseQuotedValue(value).toLowerCase();
539
+ if (normalized !== "kafka" && normalized !== "s3") {
540
+ throw new MigrationParseError(
541
+ resource.filePath,
542
+ "pipe",
543
+ resource.name,
544
+ `Unsupported EXPORT_SERVICE in strict mode: "${value}"`
545
+ );
546
+ }
547
+ exportService = normalized;
548
+ break;
549
+ }
550
+ case "EXPORT_CONNECTION_NAME":
551
+ exportConnectionName = parseQuotedValue(value);
552
+ break;
553
+ case "EXPORT_KAFKA_TOPIC":
554
+ exportTopic = parseQuotedValue(value);
555
+ break;
556
+ case "EXPORT_BUCKET_URI":
557
+ exportBucketUri = parseQuotedValue(value);
558
+ break;
559
+ case "EXPORT_FILE_TEMPLATE":
560
+ exportFileTemplate = parseQuotedValue(value);
561
+ break;
562
+ case "EXPORT_FORMAT":
563
+ exportFormat = parseQuotedValue(value);
564
+ break;
565
+ case "EXPORT_SCHEDULE":
566
+ exportSchedule = parseQuotedValue(value);
567
+ break;
568
+ case "EXPORT_STRATEGY": {
569
+ const normalized = parseQuotedValue(value).toLowerCase();
570
+ if (normalized !== "create_new" && normalized !== "replace") {
571
+ throw new MigrationParseError(
572
+ resource.filePath,
573
+ "pipe",
574
+ resource.name,
575
+ `Unsupported EXPORT_STRATEGY in strict mode: "${value}"`
576
+ );
577
+ }
578
+ exportStrategy = normalized;
579
+ break;
580
+ }
581
+ case "EXPORT_COMPRESSION": {
582
+ const normalized = parseQuotedValue(value).toLowerCase();
583
+ if (normalized !== "none" && normalized !== "gzip" && normalized !== "snappy") {
584
+ throw new MigrationParseError(
585
+ resource.filePath,
586
+ "pipe",
587
+ resource.name,
588
+ `Unsupported EXPORT_COMPRESSION in strict mode: "${value}"`
589
+ );
590
+ }
591
+ exportCompression = normalized;
592
+ break;
593
+ }
437
594
  case "TOKEN":
438
595
  tokens.push(parseToken(resource.filePath, resource.name, value));
439
596
  break;
@@ -485,6 +642,144 @@ export function parsePipeFile(resource: ResourceFile): PipeModel {
485
642
  );
486
643
  }
487
644
 
645
+ const hasSinkDirectives =
646
+ exportService !== undefined ||
647
+ exportConnectionName !== undefined ||
648
+ exportTopic !== undefined ||
649
+ exportBucketUri !== undefined ||
650
+ exportFileTemplate !== undefined ||
651
+ exportFormat !== undefined ||
652
+ exportSchedule !== undefined ||
653
+ exportStrategy !== undefined ||
654
+ exportCompression !== undefined;
655
+
656
+ if (pipeType !== "sink" && hasSinkDirectives) {
657
+ throw new MigrationParseError(
658
+ resource.filePath,
659
+ "pipe",
660
+ resource.name,
661
+ "EXPORT_* directives are only supported for TYPE sink."
662
+ );
663
+ }
664
+
665
+ let sink: PipeModel["sink"];
666
+ if (pipeType === "sink") {
667
+ if (!exportConnectionName) {
668
+ throw new MigrationParseError(
669
+ resource.filePath,
670
+ "pipe",
671
+ resource.name,
672
+ "EXPORT_CONNECTION_NAME is required for TYPE sink."
673
+ );
674
+ }
675
+
676
+ const hasKafkaDirectives = exportTopic !== undefined;
677
+ const hasS3Directives =
678
+ exportBucketUri !== undefined ||
679
+ exportFileTemplate !== undefined ||
680
+ exportFormat !== undefined ||
681
+ exportCompression !== undefined;
682
+
683
+ if (hasKafkaDirectives && hasS3Directives) {
684
+ throw new MigrationParseError(
685
+ resource.filePath,
686
+ "pipe",
687
+ resource.name,
688
+ "Sink pipe cannot mix Kafka and S3 export directives."
689
+ );
690
+ }
691
+
692
+ const inferredService =
693
+ exportService ?? (hasKafkaDirectives ? "kafka" : hasS3Directives ? "s3" : undefined);
694
+
695
+ if (!inferredService) {
696
+ throw new MigrationParseError(
697
+ resource.filePath,
698
+ "pipe",
699
+ resource.name,
700
+ "Sink pipe must define EXPORT_SERVICE or include service-specific export directives."
701
+ );
702
+ }
703
+
704
+ if (inferredService === "kafka") {
705
+ if (hasS3Directives) {
706
+ throw new MigrationParseError(
707
+ resource.filePath,
708
+ "pipe",
709
+ resource.name,
710
+ "S3 export directives are not valid for Kafka sinks."
711
+ );
712
+ }
713
+ if (!exportTopic) {
714
+ throw new MigrationParseError(
715
+ resource.filePath,
716
+ "pipe",
717
+ resource.name,
718
+ "EXPORT_KAFKA_TOPIC is required for Kafka sinks."
719
+ );
720
+ }
721
+ if (!exportSchedule) {
722
+ throw new MigrationParseError(
723
+ resource.filePath,
724
+ "pipe",
725
+ resource.name,
726
+ "EXPORT_SCHEDULE is required for Kafka sinks."
727
+ );
728
+ }
729
+ if (exportStrategy !== undefined) {
730
+ throw new MigrationParseError(
731
+ resource.filePath,
732
+ "pipe",
733
+ resource.name,
734
+ "EXPORT_STRATEGY is only valid for S3 sinks."
735
+ );
736
+ }
737
+ if (exportCompression !== undefined) {
738
+ throw new MigrationParseError(
739
+ resource.filePath,
740
+ "pipe",
741
+ resource.name,
742
+ "EXPORT_COMPRESSION is only valid for S3 sinks."
743
+ );
744
+ }
745
+
746
+ sink = {
747
+ service: "kafka",
748
+ connectionName: exportConnectionName,
749
+ topic: exportTopic,
750
+ schedule: exportSchedule,
751
+ };
752
+ } else {
753
+ if (hasKafkaDirectives) {
754
+ throw new MigrationParseError(
755
+ resource.filePath,
756
+ "pipe",
757
+ resource.name,
758
+ "Kafka export directives are not valid for S3 sinks."
759
+ );
760
+ }
761
+ if (!exportBucketUri || !exportFileTemplate || !exportFormat || !exportSchedule) {
762
+ throw new MigrationParseError(
763
+ resource.filePath,
764
+ "pipe",
765
+ resource.name,
766
+ "S3 sinks require EXPORT_BUCKET_URI, EXPORT_FILE_TEMPLATE, EXPORT_FORMAT, and EXPORT_SCHEDULE."
767
+ );
768
+ }
769
+
770
+ sink = {
771
+ service: "s3",
772
+ connectionName: exportConnectionName,
773
+ bucketUri: exportBucketUri,
774
+ fileTemplate: exportFileTemplate,
775
+ format: exportFormat,
776
+ schedule: exportSchedule,
777
+ strategy: exportStrategy,
778
+ compression: exportCompression,
779
+ };
780
+ }
781
+ }
782
+
488
783
  const params =
489
784
  pipeType === "materialized" || pipeType === "copy"
490
785
  ? []
@@ -510,9 +805,9 @@ export function parsePipeFile(resource: ResourceFile): PipeModel {
510
805
  copyTargetDatasource,
511
806
  copySchedule,
512
807
  copyMode,
808
+ sink,
513
809
  tokens,
514
810
  params,
515
811
  inferredOutputColumns,
516
812
  };
517
813
  }
518
-
@@ -27,6 +27,42 @@ export function stripIndent(line: string): string {
27
27
  return line.trimStart();
28
28
  }
29
29
 
30
+ export interface BlockReadResult {
31
+ lines: string[];
32
+ nextIndex: number;
33
+ }
34
+
35
+ export function readDirectiveBlock(
36
+ lines: string[],
37
+ startIndex: number,
38
+ isDirectiveLine: (line: string) => boolean
39
+ ): BlockReadResult {
40
+ const collected: string[] = [];
41
+ let i = startIndex;
42
+
43
+ while (i < lines.length) {
44
+ const line = (lines[i] ?? "").trim();
45
+ if (isDirectiveLine(line)) {
46
+ break;
47
+ }
48
+ collected.push(line);
49
+ i += 1;
50
+ }
51
+
52
+ let first = 0;
53
+ while (first < collected.length && collected[first] === "") {
54
+ first += 1;
55
+ }
56
+
57
+ let last = collected.length - 1;
58
+ while (last >= first && collected[last] === "") {
59
+ last -= 1;
60
+ }
61
+
62
+ const normalized = first <= last ? collected.slice(first, last + 1) : [];
63
+ return { lines: normalized, nextIndex: i };
64
+ }
65
+
30
66
  export function splitCommaSeparated(input: string): string[] {
31
67
  return input
32
68
  .split(",")
@@ -157,4 +193,3 @@ export function splitTopLevelComma(input: string): string[] {
157
193
 
158
194
  return parts;
159
195
  }
160
-
@@ -30,6 +30,7 @@ export interface DatasourceEngineModel {
30
30
  primaryKey?: string[];
31
31
  ttl?: string;
32
32
  ver?: string;
33
+ isDeleted?: string;
33
34
  sign?: string;
34
35
  version?: string;
35
36
  summingColumns?: string[];
@@ -41,6 +42,7 @@ export interface DatasourceKafkaModel {
41
42
  topic: string;
42
43
  groupId?: string;
43
44
  autoOffsetReset?: "earliest" | "latest";
45
+ storeRawValue?: boolean;
44
46
  }
45
47
 
46
48
  export interface DatasourceS3Model {
@@ -61,7 +63,7 @@ export interface DatasourceModel {
61
63
  filePath: string;
62
64
  description?: string;
63
65
  columns: DatasourceColumnModel[];
64
- engine: DatasourceEngineModel;
66
+ engine?: DatasourceEngineModel;
65
67
  kafka?: DatasourceKafkaModel;
66
68
  s3?: DatasourceS3Model;
67
69
  forwardQuery?: string;
@@ -80,13 +82,34 @@ export interface PipeTokenModel {
80
82
  scope: "READ";
81
83
  }
82
84
 
83
- export type PipeTypeModel = "pipe" | "endpoint" | "materialized" | "copy";
85
+ export type PipeTypeModel = "pipe" | "endpoint" | "materialized" | "copy" | "sink";
86
+
87
+ export interface PipeKafkaSinkModel {
88
+ service: "kafka";
89
+ connectionName: string;
90
+ topic: string;
91
+ schedule: string;
92
+ }
93
+
94
+ export interface PipeS3SinkModel {
95
+ service: "s3";
96
+ connectionName: string;
97
+ bucketUri: string;
98
+ fileTemplate: string;
99
+ format: string;
100
+ schedule: string;
101
+ strategy?: "create_new" | "replace";
102
+ compression?: "none" | "gzip" | "snappy";
103
+ }
104
+
105
+ export type PipeSinkModel = PipeKafkaSinkModel | PipeS3SinkModel;
84
106
 
85
107
  export interface PipeParamModel {
86
108
  name: string;
87
109
  type: string;
88
110
  required: boolean;
89
- defaultValue?: string | number;
111
+ defaultValue?: string | number | boolean;
112
+ description?: string;
90
113
  }
91
114
 
92
115
  export interface PipeModel {
@@ -102,6 +125,7 @@ export interface PipeModel {
102
125
  copyTargetDatasource?: string;
103
126
  copySchedule?: string;
104
127
  copyMode?: "append" | "replace";
128
+ sink?: PipeSinkModel;
105
129
  tokens: PipeTokenModel[];
106
130
  params: PipeParamModel[];
107
131
  inferredOutputColumns: string[];
@@ -117,6 +141,7 @@ export interface KafkaConnectionModel {
117
141
  saslMechanism?: "PLAIN" | "SCRAM-SHA-256" | "SCRAM-SHA-512" | "OAUTHBEARER";
118
142
  key?: string;
119
143
  secret?: string;
144
+ schemaRegistryUrl?: string;
120
145
  sslCaPem?: string;
121
146
  }
122
147
 
@@ -31,6 +31,8 @@ export interface KafkaConnectionOptions {
31
31
  key?: string;
32
32
  /** Kafka secret/password - can use {{ tb_secret(...) }} */
33
33
  secret?: string;
34
+ /** Schema Registry URL (optionally with embedded auth credentials) */
35
+ schemaRegistryUrl?: string;
34
36
  /** SSL CA certificate PEM - for private CA certs */
35
37
  sslCaPem?: string;
36
38
  }