@tinybirdco/sdk 0.0.49 → 0.0.51

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 (82) hide show
  1. package/README.md +19 -2
  2. package/dist/cli/commands/migrate.d.ts.map +1 -1
  3. package/dist/cli/commands/migrate.js +36 -1
  4. package/dist/cli/commands/migrate.js.map +1 -1
  5. package/dist/cli/commands/migrate.test.js +307 -2
  6. package/dist/cli/commands/migrate.test.js.map +1 -1
  7. package/dist/codegen/type-mapper.d.ts.map +1 -1
  8. package/dist/codegen/type-mapper.js +70 -7
  9. package/dist/codegen/type-mapper.js.map +1 -1
  10. package/dist/codegen/type-mapper.test.js +9 -0
  11. package/dist/codegen/type-mapper.test.js.map +1 -1
  12. package/dist/generator/connection.d.ts.map +1 -1
  13. package/dist/generator/connection.js +14 -1
  14. package/dist/generator/connection.js.map +1 -1
  15. package/dist/generator/connection.test.js +20 -4
  16. package/dist/generator/connection.test.js.map +1 -1
  17. package/dist/generator/datasource.d.ts.map +1 -1
  18. package/dist/generator/datasource.js +39 -10
  19. package/dist/generator/datasource.js.map +1 -1
  20. package/dist/generator/datasource.test.js +42 -1
  21. package/dist/generator/datasource.test.js.map +1 -1
  22. package/dist/generator/pipe.d.ts.map +1 -1
  23. package/dist/generator/pipe.js +92 -3
  24. package/dist/generator/pipe.js.map +1 -1
  25. package/dist/generator/pipe.test.js +19 -0
  26. package/dist/generator/pipe.test.js.map +1 -1
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/migrate/emit-ts.d.ts.map +1 -1
  32. package/dist/migrate/emit-ts.js +56 -11
  33. package/dist/migrate/emit-ts.js.map +1 -1
  34. package/dist/migrate/parse-connection.d.ts +2 -2
  35. package/dist/migrate/parse-connection.d.ts.map +1 -1
  36. package/dist/migrate/parse-connection.js +34 -4
  37. package/dist/migrate/parse-connection.js.map +1 -1
  38. package/dist/migrate/parse-datasource.d.ts.map +1 -1
  39. package/dist/migrate/parse-datasource.js +39 -2
  40. package/dist/migrate/parse-datasource.js.map +1 -1
  41. package/dist/migrate/parse-pipe.d.ts.map +1 -1
  42. package/dist/migrate/parse-pipe.js +212 -93
  43. package/dist/migrate/parse-pipe.js.map +1 -1
  44. package/dist/migrate/parser-utils.d.ts.map +1 -1
  45. package/dist/migrate/parser-utils.js +3 -1
  46. package/dist/migrate/parser-utils.js.map +1 -1
  47. package/dist/migrate/types.d.ts +22 -1
  48. package/dist/migrate/types.d.ts.map +1 -1
  49. package/dist/schema/connection.d.ts +34 -1
  50. package/dist/schema/connection.d.ts.map +1 -1
  51. package/dist/schema/connection.js +26 -0
  52. package/dist/schema/connection.js.map +1 -1
  53. package/dist/schema/connection.test.js +35 -1
  54. package/dist/schema/connection.test.js.map +1 -1
  55. package/dist/schema/datasource.d.ts +32 -1
  56. package/dist/schema/datasource.d.ts.map +1 -1
  57. package/dist/schema/datasource.js +19 -2
  58. package/dist/schema/datasource.js.map +1 -1
  59. package/dist/schema/datasource.test.js +71 -3
  60. package/dist/schema/datasource.test.js.map +1 -1
  61. package/package.json +1 -1
  62. package/src/cli/commands/migrate.test.ts +448 -2
  63. package/src/cli/commands/migrate.ts +39 -1
  64. package/src/codegen/type-mapper.test.ts +18 -0
  65. package/src/codegen/type-mapper.ts +79 -7
  66. package/src/generator/connection.test.ts +29 -4
  67. package/src/generator/connection.ts +25 -2
  68. package/src/generator/datasource.test.ts +52 -1
  69. package/src/generator/datasource.ts +47 -10
  70. package/src/generator/pipe.test.ts +21 -0
  71. package/src/generator/pipe.ts +119 -3
  72. package/src/index.ts +6 -0
  73. package/src/migrate/emit-ts.ts +67 -14
  74. package/src/migrate/parse-connection.ts +56 -6
  75. package/src/migrate/parse-datasource.ts +74 -3
  76. package/src/migrate/parse-pipe.ts +250 -111
  77. package/src/migrate/parser-utils.ts +5 -1
  78. package/src/migrate/types.ts +26 -1
  79. package/src/schema/connection.test.ts +48 -0
  80. package/src/schema/connection.ts +60 -1
  81. package/src/schema/datasource.test.ts +91 -3
  82. package/src/schema/datasource.ts +62 -3
@@ -107,7 +107,7 @@ export const eventsEndpoint = definePipe("events_endpoint", {
107
107
  SELECT event_id, user_id, payload
108
108
  FROM events
109
109
  WHERE user_id = {{UInt64(user_id)}}
110
- AND env = {{String(env, 'prod')}}
110
+ AND env = {{ String(env) }}
111
111
  \`,
112
112
  }),
113
113
  node({
@@ -168,7 +168,7 @@ GROUP BY user_id
168
168
  sql: \`
169
169
  SELECT user_id, total
170
170
  FROM agg
171
- WHERE total > {{UInt32(min_total, 10)}}
171
+ WHERE total > {{ UInt32(min_total) }}
172
172
  \`,
173
173
  }),
174
174
  ],
@@ -616,6 +616,144 @@ IMPORT_FROM_TIMESTAMP 2024-01-01T00:00:00Z
616
616
  expect(output).toContain('fromTimestamp: "2024-01-01T00:00:00Z"');
617
617
  });
618
618
 
619
+ it("migrates gcs connection and import datasource directives", async () => {
620
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
621
+ tempDirs.push(tempDir);
622
+
623
+ writeFile(
624
+ tempDir,
625
+ "gcsample.connection",
626
+ `TYPE gcs
627
+ GCS_SERVICE_ACCOUNT_CREDENTIALS_JSON {{ tb_secret("GCS_SERVICE_ACCOUNT_CREDENTIALS_JSON") }}
628
+ `
629
+ );
630
+
631
+ writeFile(
632
+ tempDir,
633
+ "events_gcs_landing.datasource",
634
+ `SCHEMA >
635
+ timestamp DateTime,
636
+ session_id String
637
+
638
+ ENGINE "MergeTree"
639
+ ENGINE_SORTING_KEY "timestamp"
640
+ IMPORT_CONNECTION_NAME gcsample
641
+ IMPORT_BUCKET_URI gs://my-gcs-bucket/events/*.csv
642
+ IMPORT_SCHEDULE @auto
643
+ IMPORT_FROM_TIMESTAMP 2024-01-01T00:00:00Z
644
+ `
645
+ );
646
+
647
+ const result = await runMigrate({
648
+ cwd: tempDir,
649
+ patterns: ["."],
650
+ strict: true,
651
+ });
652
+
653
+ expect(result.success).toBe(true);
654
+ expect(result.errors).toHaveLength(0);
655
+ expect(result.migrated.filter((resource) => resource.kind === "connection")).toHaveLength(1);
656
+ expect(result.migrated.filter((resource) => resource.kind === "datasource")).toHaveLength(1);
657
+
658
+ const output = fs.readFileSync(result.outputPath, "utf-8");
659
+ expect(output).toContain("defineGCSConnection");
660
+ expect(output).toContain('export const gcsample = defineGCSConnection("gcsample", {');
661
+ expect(output).toContain(
662
+ 'serviceAccountCredentialsJson: secret("GCS_SERVICE_ACCOUNT_CREDENTIALS_JSON"),'
663
+ );
664
+ expect(output).toContain("gcs: {");
665
+ expect(output).toContain("connection: gcsample");
666
+ expect(output).toContain('bucketUri: "gs://my-gcs-bucket/events/*.csv"');
667
+ expect(output).toContain('schedule: "@auto"');
668
+ expect(output).toContain('fromTimestamp: "2024-01-01T00:00:00Z"');
669
+ });
670
+
671
+ it("migrates single-quoted gcs import directives", async () => {
672
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
673
+ tempDirs.push(tempDir);
674
+
675
+ writeFile(
676
+ tempDir,
677
+ "gcp_billing_reports.connection",
678
+ `TYPE gcs
679
+ GCS_SERVICE_ACCOUNT_CREDENTIALS_JSON {{tb_secret("GCP_SERVICE_ACCOUNT_CREDENTIALS_JSON")}}
680
+ `
681
+ );
682
+
683
+ writeFile(
684
+ tempDir,
685
+ "gcp_production_billing_account_landing.datasource",
686
+ `SCHEMA >
687
+ usage_start_time DateTime64(3)
688
+
689
+ ENGINE "MergeTree"
690
+ ENGINE_SORTING_KEY "usage_start_time"
691
+ IMPORT_CONNECTION_NAME 'gcp_billing_reports'
692
+ IMPORT_BUCKET_URI 'gs://tinybird-oa-sot-fwd/gcp_production_billing_reports/billing_*.csv.gz'
693
+ IMPORT_SCHEDULE '@on-demand'
694
+ `
695
+ );
696
+
697
+ const result = await runMigrate({
698
+ cwd: tempDir,
699
+ patterns: ["."],
700
+ strict: true,
701
+ });
702
+
703
+ expect(result.success).toBe(true);
704
+ expect(result.errors).toHaveLength(0);
705
+
706
+ const output = fs.readFileSync(result.outputPath, "utf-8");
707
+ expect(output).toContain(
708
+ 'export const gcpBillingReports = defineGCSConnection("gcp_billing_reports", {'
709
+ );
710
+ expect(output).toContain("gcs: {");
711
+ expect(output).toContain("connection: gcpBillingReports");
712
+ expect(output).toContain(
713
+ 'bucketUri: "gs://tinybird-oa-sot-fwd/gcp_production_billing_reports/billing_*.csv.gz"'
714
+ );
715
+ expect(output).toContain('schedule: "@on-demand"');
716
+ });
717
+
718
+ it("reports an error when import directives use a non-bucket connection type", async () => {
719
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
720
+ tempDirs.push(tempDir);
721
+
722
+ writeFile(
723
+ tempDir,
724
+ "stream.connection",
725
+ `TYPE kafka
726
+ KAFKA_BOOTSTRAP_SERVERS localhost:9092
727
+ `
728
+ );
729
+
730
+ writeFile(
731
+ tempDir,
732
+ "events_landing.datasource",
733
+ `SCHEMA >
734
+ id String
735
+
736
+ ENGINE "MergeTree"
737
+ ENGINE_SORTING_KEY "id"
738
+ IMPORT_CONNECTION_NAME stream
739
+ IMPORT_BUCKET_URI gs://my-gcs-bucket/events/*.csv
740
+ `
741
+ );
742
+
743
+ const result = await runMigrate({
744
+ cwd: tempDir,
745
+ patterns: ["."],
746
+ strict: true,
747
+ });
748
+
749
+ expect(result.success).toBe(false);
750
+ expect(result.errors.map((error) => error.message)).toEqual(
751
+ expect.arrayContaining([
752
+ 'Datasource import directives require an s3 or gcs connection, found "kafka".',
753
+ ])
754
+ );
755
+ });
756
+
619
757
 
620
758
  it("migrates KAFKA_STORE_RAW_VALUE datasource directive", async () => {
621
759
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
@@ -659,6 +797,44 @@ KAFKA_STORE_RAW_VALUE True
659
797
  expect(output).toContain("storeRawValue: true");
660
798
  });
661
799
 
800
+ it("migrates INDEXES datasource block", async () => {
801
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
802
+ tempDirs.push(tempDir);
803
+
804
+ writeFile(
805
+ tempDir,
806
+ "events.datasource",
807
+ `SCHEMA >
808
+ id String,
809
+ pipe_name String
810
+
811
+ ENGINE "MergeTree"
812
+ ENGINE_SORTING_KEY "id"
813
+ INDEXES >
814
+ pipe_name_set pipe_name TYPE set(100) GRANULARITY 1
815
+ id_bf lower(id) TYPE bloom_filter(0.001) GRANULARITY 4
816
+ `
817
+ );
818
+
819
+ const result = await runMigrate({
820
+ cwd: tempDir,
821
+ patterns: ["."],
822
+ strict: true,
823
+ });
824
+
825
+ expect(result.success).toBe(true);
826
+ expect(result.errors).toHaveLength(0);
827
+
828
+ const output = fs.readFileSync(result.outputPath, "utf-8");
829
+ expect(output).toContain("indexes: [");
830
+ expect(output).toContain(
831
+ '{ name: "pipe_name_set", expr: "pipe_name", type: "set(100)", granularity: 1 }'
832
+ );
833
+ expect(output).toContain(
834
+ '{ name: "id_bf", expr: "lower(id)", type: "bloom_filter(0.001)", granularity: 4 }'
835
+ );
836
+ });
837
+
662
838
  it("migrates kafka schema registry and engine is deleted directives", async () => {
663
839
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
664
840
  tempDirs.push(tempDir);
@@ -809,6 +985,201 @@ TYPE ENDPOINT
809
985
  expect(output).toContain("endpoint: true,");
810
986
  });
811
987
 
988
+ it("supports Int/Integer aliases and column placeholder params", async () => {
989
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
990
+ tempDirs.push(tempDir);
991
+
992
+ writeFile(
993
+ tempDir,
994
+ "placeholder_aliases.pipe",
995
+ `NODE endpoint
996
+ SQL >
997
+ SELECT event_id AS event_id
998
+ FROM events
999
+ WHERE score >= {{int(min_score, 10)}}
1000
+ ORDER BY {{column(sort_col)}} DESC
1001
+ LIMIT {{ Integer(limit, 50) }}
1002
+ TYPE endpoint
1003
+ `
1004
+ );
1005
+
1006
+ const result = await runMigrate({
1007
+ cwd: tempDir,
1008
+ patterns: ["."],
1009
+ strict: true,
1010
+ });
1011
+
1012
+ expect(result.success).toBe(true);
1013
+ expect(result.errors).toHaveLength(0);
1014
+
1015
+ const output = fs.readFileSync(result.outputPath, "utf-8");
1016
+ expect(output).toContain('export const placeholderAliases = definePipe("placeholder_aliases", {');
1017
+ expect(output).toContain("params: {");
1018
+ expect(output).toContain("limit: p.int32().optional(50),");
1019
+ expect(output).toContain("min_score: p.int32().optional(10),");
1020
+ expect(output).toContain("sort_col: p.column(),");
1021
+ });
1022
+
1023
+ it("supports error() placeholder helper without treating it as a param", async () => {
1024
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
1025
+ tempDirs.push(tempDir);
1026
+
1027
+ writeFile(
1028
+ tempDir,
1029
+ "placeholder_error.pipe",
1030
+ `NODE endpoint
1031
+ SQL >
1032
+ %
1033
+ {% if not defined(start_date) and not defined(end_date) %}
1034
+ {{ error('start_date and end_date are required parameters') }}
1035
+ {% end %}
1036
+ SELECT 1 AS id
1037
+ WHERE now() >= {{DateTime(start_date, '2025-01-01 00:00:00')}}
1038
+ TYPE endpoint
1039
+ `
1040
+ );
1041
+
1042
+ const result = await runMigrate({
1043
+ cwd: tempDir,
1044
+ patterns: ["."],
1045
+ strict: true,
1046
+ });
1047
+
1048
+ expect(result.success).toBe(true);
1049
+ expect(result.errors).toHaveLength(0);
1050
+
1051
+ const output = fs.readFileSync(result.outputPath, "utf-8");
1052
+ expect(output).toContain('export const placeholderError = definePipe("placeholder_error", {');
1053
+ expect(output).toContain("params: {");
1054
+ expect(output).toContain('start_date: p.dateTime().optional("2025-01-01 00:00:00"),');
1055
+ expect(output).not.toContain("error:");
1056
+ });
1057
+
1058
+ it("uses the last explicit default and strips inline defaults from SQL", async () => {
1059
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
1060
+ tempDirs.push(tempDir);
1061
+
1062
+ writeFile(
1063
+ tempDir,
1064
+ "last_default.pipe",
1065
+ `NODE endpoint
1066
+ SQL >
1067
+ SELECT 1
1068
+ WHERE d >= {{ String(start_date, '2025-03-01') }}
1069
+ AND d <= {{ String(start_date, '2025-04-01') }}
1070
+ TYPE endpoint
1071
+ `
1072
+ );
1073
+
1074
+ const result = await runMigrate({
1075
+ cwd: tempDir,
1076
+ patterns: ["."],
1077
+ strict: true,
1078
+ });
1079
+
1080
+ expect(result.success).toBe(true);
1081
+ expect(result.errors).toHaveLength(0);
1082
+
1083
+ const output = fs.readFileSync(result.outputPath, "utf-8");
1084
+ expect(output).toContain('start_date: p.string().optional("2025-04-01"),');
1085
+ expect(output).toContain("{{ String(start_date) }}");
1086
+ expect(output).not.toContain("{{ String(start_date, '2025-03-01') }}");
1087
+ expect(output).not.toContain("{{ String(start_date, '2025-04-01') }}");
1088
+ });
1089
+
1090
+ it("keeps the previous truthy default when a later duplicate uses a falsy default", async () => {
1091
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
1092
+ tempDirs.push(tempDir);
1093
+
1094
+ writeFile(
1095
+ tempDir,
1096
+ "truthy_default_precedence.pipe",
1097
+ `NODE endpoint
1098
+ SQL >
1099
+ SELECT 1
1100
+ WHERE d >= {{ String(start_date, '2025-03-01') }}
1101
+ AND d <= {{ String(start_date, '') }}
1102
+ TYPE endpoint
1103
+ `
1104
+ );
1105
+
1106
+ const result = await runMigrate({
1107
+ cwd: tempDir,
1108
+ patterns: ["."],
1109
+ strict: true,
1110
+ });
1111
+
1112
+ expect(result.success).toBe(true);
1113
+ expect(result.errors).toHaveLength(0);
1114
+
1115
+ const output = fs.readFileSync(result.outputPath, "utf-8");
1116
+ expect(output).toContain('start_date: p.string().optional("2025-03-01"),');
1117
+ expect(output).toContain("{{ String(start_date) }}");
1118
+ });
1119
+
1120
+ it("extracts params from placeholder expressions with multiple function calls", async () => {
1121
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
1122
+ tempDirs.push(tempDir);
1123
+
1124
+ writeFile(
1125
+ tempDir,
1126
+ "expression_params.pipe",
1127
+ `NODE endpoint
1128
+ SQL >
1129
+ SELECT 1
1130
+ LIMIT {{ Int32(limit, 20) }}
1131
+ OFFSET {{ Int32(page, 0) * Int32(limit, 20) }}
1132
+ TYPE endpoint
1133
+ `
1134
+ );
1135
+
1136
+ const result = await runMigrate({
1137
+ cwd: tempDir,
1138
+ patterns: ["."],
1139
+ strict: true,
1140
+ });
1141
+
1142
+ expect(result.success).toBe(true);
1143
+ expect(result.errors).toHaveLength(0);
1144
+
1145
+ const output = fs.readFileSync(result.outputPath, "utf-8");
1146
+ expect(output).toContain("limit: p.int32().optional(20),");
1147
+ expect(output).toContain("page: p.int32().optional(0),");
1148
+ expect(output).toContain("{{ Int32(limit) }}");
1149
+ expect(output).toContain("{{ Int32(page) * Int32(limit) }}");
1150
+ });
1151
+
1152
+ it("uses the last explicit type when a param appears with mixed placeholder types", async () => {
1153
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
1154
+ tempDirs.push(tempDir);
1155
+
1156
+ writeFile(
1157
+ tempDir,
1158
+ "last_type.pipe",
1159
+ `NODE endpoint
1160
+ SQL >
1161
+ SELECT 1
1162
+ WHERE d >= {{ Date(start_date) }}
1163
+ AND d <= {{ String(start_date, '2025-04-01') }}
1164
+ TYPE endpoint
1165
+ `
1166
+ );
1167
+
1168
+ const result = await runMigrate({
1169
+ cwd: tempDir,
1170
+ patterns: ["."],
1171
+ strict: true,
1172
+ });
1173
+
1174
+ expect(result.success).toBe(true);
1175
+ expect(result.errors).toHaveLength(0);
1176
+
1177
+ const output = fs.readFileSync(result.outputPath, "utf-8");
1178
+ expect(output).toContain('start_date: p.string().optional("2025-04-01"),');
1179
+ expect(output).toContain("{{ Date(start_date) }}");
1180
+ expect(output).toContain("{{ String(start_date) }}");
1181
+ });
1182
+
812
1183
  it("parses multiline datasource blocks with flexible indentation", async () => {
813
1184
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
814
1185
  tempDirs.push(tempDir);
@@ -1107,6 +1478,38 @@ TYPE endpoint
1107
1478
  );
1108
1479
  });
1109
1480
 
1481
+ it("ignores function-like text inside quoted descriptions when parsing placeholders", async () => {
1482
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
1483
+ tempDirs.push(tempDir);
1484
+
1485
+ writeFile(
1486
+ tempDir,
1487
+ "description_parentheses.pipe",
1488
+ `NODE endpoint
1489
+ SQL >
1490
+ %
1491
+ SELECT 1 AS id
1492
+ WHERE ts >= now() - interval {{Int32(days, 1, description="Number of days to analyze (defaults to 1 day)")}} day
1493
+ TYPE endpoint
1494
+ `
1495
+ );
1496
+
1497
+ const result = await runMigrate({
1498
+ cwd: tempDir,
1499
+ patterns: ["."],
1500
+ strict: true,
1501
+ });
1502
+
1503
+ expect(result.success).toBe(true);
1504
+ expect(result.errors).toHaveLength(0);
1505
+
1506
+ const output = fs.readFileSync(result.outputPath, "utf-8");
1507
+ expect(output).toContain(
1508
+ 'days: p.int32().optional(1).describe("Number of days to analyze (defaults to 1 day)"),'
1509
+ );
1510
+ expect(output).toContain("{{ Int32(days) }}");
1511
+ });
1512
+
1110
1513
  it("migrates datasource with mixed explicit and default json paths", async () => {
1111
1514
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
1112
1515
  tempDirs.push(tempDir);
@@ -1358,6 +1761,49 @@ EXPORT_COMPRESSION gzip
1358
1761
  expect(output).toContain('compression: "gzip"');
1359
1762
  });
1360
1763
 
1764
+ it("migrates legacy EXPORT_WRITE_STRATEGY sink directives", async () => {
1765
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
1766
+ tempDirs.push(tempDir);
1767
+
1768
+ writeFile(
1769
+ tempDir,
1770
+ "exports_s3.connection",
1771
+ `TYPE s3
1772
+ S3_REGION "us-east-1"
1773
+ S3_ARN "arn:aws:iam::123456789012:role/tinybird-s3-access"
1774
+ `
1775
+ );
1776
+
1777
+ writeFile(
1778
+ tempDir,
1779
+ "events_s3_sink.pipe",
1780
+ `NODE export
1781
+ SQL >
1782
+ SELECT * FROM events
1783
+ TYPE sink
1784
+ EXPORT_CONNECTION_NAME exports_s3
1785
+ EXPORT_BUCKET_URI s3://exports/events/
1786
+ EXPORT_FILE_TEMPLATE events_{date}
1787
+ EXPORT_SCHEDULE @once
1788
+ EXPORT_FORMAT ndjson
1789
+ EXPORT_WRITE_STRATEGY truncate
1790
+ `
1791
+ );
1792
+
1793
+ const result = await runMigrate({
1794
+ cwd: tempDir,
1795
+ patterns: ["."],
1796
+ strict: true,
1797
+ });
1798
+
1799
+ expect(result.success).toBe(true);
1800
+ expect(result.errors).toHaveLength(0);
1801
+
1802
+ const output = fs.readFileSync(result.outputPath, "utf-8");
1803
+ expect(output).toContain('export const eventsS3Sink = defineSinkPipe("events_s3_sink", {');
1804
+ expect(output).toContain('strategy: "replace"');
1805
+ });
1806
+
1361
1807
  it("reports an error when sink pipe references a missing connection", async () => {
1362
1808
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinybird-migrate-"));
1363
1809
  tempDirs.push(tempDir);
@@ -133,7 +133,9 @@ export async function runMigrate(
133
133
 
134
134
  for (const datasource of parsedDatasources) {
135
135
  const referencedConnectionName =
136
- datasource.kafka?.connectionName ?? datasource.s3?.connectionName;
136
+ datasource.kafka?.connectionName ??
137
+ datasource.s3?.connectionName ??
138
+ datasource.gcs?.connectionName;
137
139
 
138
140
  if (
139
141
  referencedConnectionName &&
@@ -148,6 +150,42 @@ export async function runMigrate(
148
150
  continue;
149
151
  }
150
152
 
153
+ if (datasource.kafka) {
154
+ const kafkaConnectionType = parsedConnectionTypeByName.get(datasource.kafka.connectionName);
155
+ if (kafkaConnectionType !== "kafka") {
156
+ errors.push({
157
+ filePath: datasource.filePath,
158
+ resourceName: datasource.name,
159
+ resourceKind: datasource.kind,
160
+ message: `Datasource kafka ingestion requires a kafka connection, found "${kafkaConnectionType ?? "(none)"}".`,
161
+ });
162
+ continue;
163
+ }
164
+ }
165
+
166
+ const importConfig = datasource.s3 ?? datasource.gcs;
167
+ if (importConfig) {
168
+ const importConnectionType = parsedConnectionTypeByName.get(importConfig.connectionName);
169
+ if (importConnectionType !== "s3" && importConnectionType !== "gcs") {
170
+ errors.push({
171
+ filePath: datasource.filePath,
172
+ resourceName: datasource.name,
173
+ resourceKind: datasource.kind,
174
+ message:
175
+ `Datasource import directives require an s3 or gcs connection, found "${importConnectionType ?? "(none)"}".`,
176
+ });
177
+ continue;
178
+ }
179
+
180
+ if (importConnectionType === "gcs") {
181
+ datasource.gcs = { ...importConfig };
182
+ datasource.s3 = undefined;
183
+ } else {
184
+ datasource.s3 = { ...importConfig };
185
+ datasource.gcs = undefined;
186
+ }
187
+ }
188
+
151
189
  try {
152
190
  validateResourceForEmission(datasource);
153
191
  migrated.push(datasource);
@@ -132,6 +132,18 @@ describe("clickhouseTypeToValidator", () => {
132
132
  "t.map(t.string(), t.int32())"
133
133
  );
134
134
  });
135
+
136
+ it("handles Tuple(T1, T2, ...)", () => {
137
+ expect(clickhouseTypeToValidator("Tuple(String, Float64, String)")).toBe(
138
+ "t.tuple(t.string(), t.float64(), t.string())"
139
+ );
140
+ });
141
+
142
+ it("handles Array(Tuple(...))", () => {
143
+ expect(clickhouseTypeToValidator("Array(Tuple(String, Float64, String))")).toBe(
144
+ "t.array(t.tuple(t.string(), t.float64(), t.string()))"
145
+ );
146
+ });
135
147
  });
136
148
 
137
149
  describe("enum types", () => {
@@ -160,6 +172,12 @@ describe("clickhouseTypeToValidator", () => {
160
172
  't.aggregateFunction("uniq", t.string())'
161
173
  );
162
174
  });
175
+
176
+ it("handles AggregateFunction(count) without explicit state type", () => {
177
+ expect(clickhouseTypeToValidator("AggregateFunction(count)")).toBe(
178
+ 't.aggregateFunction("count", t.uint64())'
179
+ );
180
+ });
163
181
  });
164
182
 
165
183
  describe("unknown types", () => {
@@ -16,6 +16,61 @@ function parseEnumValues(enumContent: string): string[] {
16
16
  return values;
17
17
  }
18
18
 
19
+ function splitTopLevelComma(input: string): string[] {
20
+ const parts: string[] = [];
21
+ let current = "";
22
+ let depth = 0;
23
+ let inSingleQuote = false;
24
+ let inDoubleQuote = false;
25
+
26
+ for (let i = 0; i < input.length; i += 1) {
27
+ const char = input[i];
28
+ const prev = i > 0 ? input[i - 1] : "";
29
+
30
+ if (char === "'" && !inDoubleQuote && prev !== "\\") {
31
+ inSingleQuote = !inSingleQuote;
32
+ current += char;
33
+ continue;
34
+ }
35
+
36
+ if (char === '"' && !inSingleQuote && prev !== "\\") {
37
+ inDoubleQuote = !inDoubleQuote;
38
+ current += char;
39
+ continue;
40
+ }
41
+
42
+ if (!inSingleQuote && !inDoubleQuote) {
43
+ if (char === "(") {
44
+ depth += 1;
45
+ current += char;
46
+ continue;
47
+ }
48
+ if (char === ")") {
49
+ depth -= 1;
50
+ current += char;
51
+ continue;
52
+ }
53
+ if (char === "," && depth === 0) {
54
+ const trimmed = current.trim();
55
+ if (trimmed.length > 0) {
56
+ parts.push(trimmed);
57
+ }
58
+ current = "";
59
+ continue;
60
+ }
61
+ }
62
+
63
+ current += char;
64
+ }
65
+
66
+ const trimmed = current.trim();
67
+ if (trimmed.length > 0) {
68
+ parts.push(trimmed);
69
+ }
70
+
71
+ return parts;
72
+ }
73
+
19
74
  /**
20
75
  * Map a ClickHouse type to a t.* validator call
21
76
  *
@@ -138,19 +193,26 @@ export function clickhouseTypeToValidator(chType: string): string {
138
193
  return `t.array(${innerType})`;
139
194
  }
140
195
 
141
- // Tuple(T1, T2, ...) - simplified handling
196
+ // Tuple(T1, T2, ...)
142
197
  const tupleMatch = chType.match(/^Tuple\((.+)\)$/);
143
198
  if (tupleMatch) {
144
- // For complex tuples, we just use a JSON type
145
- // TODO: Could parse and generate t.tuple() for simple cases
146
- return `t.json() /* Tuple: ${chType} */`;
199
+ const tupleArgs = splitTopLevelComma(tupleMatch[1]);
200
+ if (tupleArgs.length === 0) {
201
+ return `t.string() /* TODO: Unknown type: ${chType} */`;
202
+ }
203
+ const tupleTypes = tupleArgs.map((arg) => clickhouseTypeToValidator(arg));
204
+ return `t.tuple(${tupleTypes.join(", ")})`;
147
205
  }
148
206
 
149
207
  // Map(K, V)
150
- const mapMatch = chType.match(/^Map\(([^,]+),\s*(.+)\)$/);
208
+ const mapMatch = chType.match(/^Map\((.+)\)$/);
151
209
  if (mapMatch) {
152
- const keyType = clickhouseTypeToValidator(mapMatch[1]);
153
- const valueType = clickhouseTypeToValidator(mapMatch[2]);
210
+ const mapArgs = splitTopLevelComma(mapMatch[1]);
211
+ if (mapArgs.length !== 2) {
212
+ return `t.string() /* TODO: Unknown type: ${chType} */`;
213
+ }
214
+ const keyType = clickhouseTypeToValidator(mapArgs[0]);
215
+ const valueType = clickhouseTypeToValidator(mapArgs[1]);
154
216
  return `t.map(${keyType}, ${valueType})`;
155
217
  }
156
218
 
@@ -190,6 +252,16 @@ export function clickhouseTypeToValidator(chType: string): string {
190
252
  return `t.aggregateFunction("${func}", ${innerType})`;
191
253
  }
192
254
 
255
+ // AggregateFunction(count)
256
+ const aggNoArgMatch = chType.match(/^AggregateFunction\((\w+)\)$/);
257
+ if (aggNoArgMatch) {
258
+ const func = aggNoArgMatch[1];
259
+ if (func === "count") {
260
+ return 't.aggregateFunction("count", t.uint64())';
261
+ }
262
+ return `t.string() /* TODO: Unknown type: ${chType} */`;
263
+ }
264
+
193
265
  // Nested - treat as JSON
194
266
  if (chType.startsWith("Nested(")) {
195
267
  return `t.json() /* ${chType} */`;