@tinybirdco/sdk 0.0.41 → 0.0.43

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 (95) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +29 -3
  3. package/dist/api/resources.d.ts +72 -1
  4. package/dist/api/resources.d.ts.map +1 -1
  5. package/dist/api/resources.js +197 -1
  6. package/dist/api/resources.js.map +1 -1
  7. package/dist/api/resources.test.js +82 -1
  8. package/dist/api/resources.test.js.map +1 -1
  9. package/dist/cli/commands/migrate.d.ts +11 -0
  10. package/dist/cli/commands/migrate.d.ts.map +1 -0
  11. package/dist/cli/commands/migrate.js +196 -0
  12. package/dist/cli/commands/migrate.js.map +1 -0
  13. package/dist/cli/commands/migrate.test.d.ts +2 -0
  14. package/dist/cli/commands/migrate.test.d.ts.map +1 -0
  15. package/dist/cli/commands/migrate.test.js +473 -0
  16. package/dist/cli/commands/migrate.test.js.map +1 -0
  17. package/dist/cli/commands/pull.d.ts +59 -0
  18. package/dist/cli/commands/pull.d.ts.map +1 -0
  19. package/dist/cli/commands/pull.js +104 -0
  20. package/dist/cli/commands/pull.js.map +1 -0
  21. package/dist/cli/commands/pull.test.d.ts +2 -0
  22. package/dist/cli/commands/pull.test.d.ts.map +1 -0
  23. package/dist/cli/commands/pull.test.js +140 -0
  24. package/dist/cli/commands/pull.test.js.map +1 -0
  25. package/dist/cli/config.d.ts +10 -0
  26. package/dist/cli/config.d.ts.map +1 -1
  27. package/dist/cli/config.js +22 -0
  28. package/dist/cli/config.js.map +1 -1
  29. package/dist/cli/index.js +77 -0
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/generator/client.js +2 -2
  32. package/dist/generator/client.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/migrate/discovery.d.ts +7 -0
  38. package/dist/migrate/discovery.d.ts.map +1 -0
  39. package/dist/migrate/discovery.js +125 -0
  40. package/dist/migrate/discovery.js.map +1 -0
  41. package/dist/migrate/emit-ts.d.ts +4 -0
  42. package/dist/migrate/emit-ts.d.ts.map +1 -0
  43. package/dist/migrate/emit-ts.js +387 -0
  44. package/dist/migrate/emit-ts.js.map +1 -0
  45. package/dist/migrate/parse-connection.d.ts +3 -0
  46. package/dist/migrate/parse-connection.d.ts.map +1 -0
  47. package/dist/migrate/parse-connection.js +74 -0
  48. package/dist/migrate/parse-connection.js.map +1 -0
  49. package/dist/migrate/parse-datasource.d.ts +3 -0
  50. package/dist/migrate/parse-datasource.d.ts.map +1 -0
  51. package/dist/migrate/parse-datasource.js +324 -0
  52. package/dist/migrate/parse-datasource.js.map +1 -0
  53. package/dist/migrate/parse-pipe.d.ts +3 -0
  54. package/dist/migrate/parse-pipe.d.ts.map +1 -0
  55. package/dist/migrate/parse-pipe.js +332 -0
  56. package/dist/migrate/parse-pipe.js.map +1 -0
  57. package/dist/migrate/parse.d.ts +3 -0
  58. package/dist/migrate/parse.d.ts.map +1 -0
  59. package/dist/migrate/parse.js +18 -0
  60. package/dist/migrate/parse.js.map +1 -0
  61. package/dist/migrate/parser-utils.d.ts +20 -0
  62. package/dist/migrate/parser-utils.d.ts.map +1 -0
  63. package/dist/migrate/parser-utils.js +130 -0
  64. package/dist/migrate/parser-utils.js.map +1 -0
  65. package/dist/migrate/types.d.ts +110 -0
  66. package/dist/migrate/types.d.ts.map +1 -0
  67. package/dist/migrate/types.js +2 -0
  68. package/dist/migrate/types.js.map +1 -0
  69. package/dist/schema/project.d.ts +20 -9
  70. package/dist/schema/project.d.ts.map +1 -1
  71. package/dist/schema/project.js +127 -136
  72. package/dist/schema/project.js.map +1 -1
  73. package/dist/schema/project.test.js +22 -0
  74. package/dist/schema/project.test.js.map +1 -1
  75. package/package.json +2 -1
  76. package/src/api/resources.test.ts +121 -0
  77. package/src/api/resources.ts +292 -1
  78. package/src/cli/commands/migrate.test.ts +564 -0
  79. package/src/cli/commands/migrate.ts +240 -0
  80. package/src/cli/commands/pull.test.ts +173 -0
  81. package/src/cli/commands/pull.ts +177 -0
  82. package/src/cli/config.ts +26 -0
  83. package/src/cli/index.ts +112 -0
  84. package/src/generator/client.ts +2 -2
  85. package/src/index.ts +1 -1
  86. package/src/migrate/discovery.ts +151 -0
  87. package/src/migrate/emit-ts.ts +469 -0
  88. package/src/migrate/parse-connection.ts +128 -0
  89. package/src/migrate/parse-datasource.ts +453 -0
  90. package/src/migrate/parse-pipe.ts +518 -0
  91. package/src/migrate/parse.ts +20 -0
  92. package/src/migrate/parser-utils.ts +160 -0
  93. package/src/migrate/types.ts +125 -0
  94. package/src/schema/project.test.ts +28 -0
  95. package/src/schema/project.ts +173 -181
@@ -0,0 +1,518 @@
1
+ import type { PipeModel, PipeParamModel, PipeTokenModel, ResourceFile } from "./types.js";
2
+ import {
3
+ MigrationParseError,
4
+ isBlank,
5
+ parseDirectiveLine,
6
+ splitLines,
7
+ splitTopLevelComma,
8
+ stripIndent,
9
+ } from "./parser-utils.js";
10
+
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;
41
+ }
42
+
43
+ return { lines: collected, nextIndex: i };
44
+ }
45
+
46
+ function nextNonBlank(lines: string[], startIndex: number): number {
47
+ let i = startIndex;
48
+ while (i < lines.length && isBlank(lines[i] ?? "")) {
49
+ i += 1;
50
+ }
51
+ return i;
52
+ }
53
+
54
+ function inferOutputColumnsFromSql(sql: string): string[] {
55
+ const match = sql.match(/select\s+([\s\S]+?)\s+from\s/iu);
56
+ if (!match) {
57
+ return ["result"];
58
+ }
59
+
60
+ const selectClause = match[1] ?? "";
61
+ const expressions = splitTopLevelComma(selectClause);
62
+ const columns: string[] = [];
63
+
64
+ for (const expression of expressions) {
65
+ const aliasMatch = expression.match(/\s+AS\s+`?([a-zA-Z_][a-zA-Z0-9_]*)`?\s*$/iu);
66
+ if (aliasMatch?.[1]) {
67
+ columns.push(aliasMatch[1]);
68
+ continue;
69
+ }
70
+
71
+ const simpleMatch = expression.match(/(?:^|\.)`?([a-zA-Z_][a-zA-Z0-9_]*)`?\s*$/u);
72
+ if (simpleMatch?.[1]) {
73
+ columns.push(simpleMatch[1]);
74
+ continue;
75
+ }
76
+ }
77
+
78
+ return Array.from(new Set(columns.length > 0 ? columns : ["result"]));
79
+ }
80
+
81
+ function mapTemplateFunctionToParamType(func: string): string | null {
82
+ const known = new Set([
83
+ "String",
84
+ "UUID",
85
+ "Int8",
86
+ "Int16",
87
+ "Int32",
88
+ "Int64",
89
+ "UInt8",
90
+ "UInt16",
91
+ "UInt32",
92
+ "UInt64",
93
+ "Float32",
94
+ "Float64",
95
+ "Boolean",
96
+ "Bool",
97
+ "Date",
98
+ "DateTime",
99
+ "DateTime64",
100
+ "Array",
101
+ ]);
102
+
103
+ if (known.has(func)) {
104
+ return func;
105
+ }
106
+
107
+ if (func.startsWith("DateTime64")) {
108
+ return "DateTime64";
109
+ }
110
+ if (func.startsWith("DateTime")) {
111
+ return "DateTime";
112
+ }
113
+
114
+ return null;
115
+ }
116
+
117
+ function parseParamDefault(rawValue: string): string | number {
118
+ const trimmed = rawValue.trim();
119
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
120
+ return Number(trimmed);
121
+ }
122
+ if (
123
+ (trimmed.startsWith("'") && trimmed.endsWith("'")) ||
124
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))
125
+ ) {
126
+ return trimmed.slice(1, -1);
127
+ }
128
+ throw new Error(`Unsupported parameter default value: "${rawValue}"`);
129
+ }
130
+
131
+ function inferParamsFromSql(
132
+ sql: string,
133
+ filePath: string,
134
+ resourceName: string
135
+ ): PipeParamModel[] {
136
+ const regex = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\(([^{}]*)\)\s*\}\}/g;
137
+ const params = new Map<string, PipeParamModel>();
138
+ let match: RegExpExecArray | null = regex.exec(sql);
139
+
140
+ while (match) {
141
+ const templateFunction = match[1] ?? "";
142
+ const argsRaw = match[2] ?? "";
143
+ const args = splitTopLevelComma(argsRaw);
144
+ if (args.length === 0) {
145
+ throw new MigrationParseError(
146
+ filePath,
147
+ "pipe",
148
+ resourceName,
149
+ `Invalid template placeholder: "${match[0]}"`
150
+ );
151
+ }
152
+
153
+ const paramName = args[0]?.trim();
154
+ if (!paramName || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(paramName)) {
155
+ throw new MigrationParseError(
156
+ filePath,
157
+ "pipe",
158
+ resourceName,
159
+ `Unsupported parameter name in placeholder: "${match[0]}"`
160
+ );
161
+ }
162
+
163
+ const mappedType = mapTemplateFunctionToParamType(templateFunction);
164
+ if (!mappedType) {
165
+ throw new MigrationParseError(
166
+ filePath,
167
+ "pipe",
168
+ resourceName,
169
+ `Unsupported placeholder function in strict mode: "${templateFunction}"`
170
+ );
171
+ }
172
+
173
+ let defaultValue: string | number | undefined;
174
+ if (args.length > 1) {
175
+ try {
176
+ defaultValue = parseParamDefault(args[1] ?? "");
177
+ } catch (error) {
178
+ throw new MigrationParseError(
179
+ filePath,
180
+ "pipe",
181
+ resourceName,
182
+ (error as Error).message
183
+ );
184
+ }
185
+ }
186
+
187
+ const existing = params.get(paramName);
188
+ if (existing) {
189
+ if (existing.type !== mappedType) {
190
+ throw new MigrationParseError(
191
+ filePath,
192
+ "pipe",
193
+ resourceName,
194
+ `Parameter "${paramName}" is used with multiple types: "${existing.type}" and "${mappedType}".`
195
+ );
196
+ }
197
+ if (existing.defaultValue !== undefined && defaultValue !== undefined) {
198
+ if (existing.defaultValue !== defaultValue) {
199
+ throw new MigrationParseError(
200
+ filePath,
201
+ "pipe",
202
+ resourceName,
203
+ `Parameter "${paramName}" uses multiple defaults: "${existing.defaultValue}" and "${defaultValue}".`
204
+ );
205
+ }
206
+ }
207
+ if (existing.defaultValue === undefined && defaultValue !== undefined) {
208
+ existing.defaultValue = defaultValue;
209
+ existing.required = false;
210
+ }
211
+ } else {
212
+ params.set(paramName, {
213
+ name: paramName,
214
+ type: mappedType,
215
+ required: defaultValue === undefined,
216
+ defaultValue,
217
+ });
218
+ }
219
+
220
+ match = regex.exec(sql);
221
+ }
222
+
223
+ return Array.from(params.values()).sort((a, b) => a.name.localeCompare(b.name));
224
+ }
225
+
226
+ function parseToken(filePath: string, resourceName: string, value: string): PipeTokenModel {
227
+ const parts = value.split(/\s+/).filter(Boolean);
228
+ if (parts.length === 0) {
229
+ throw new MigrationParseError(filePath, "pipe", resourceName, "Invalid TOKEN line.");
230
+ }
231
+ if (parts.length > 2) {
232
+ throw new MigrationParseError(
233
+ filePath,
234
+ "pipe",
235
+ resourceName,
236
+ `Unsupported TOKEN syntax in strict mode: "${value}"`
237
+ );
238
+ }
239
+
240
+ const tokenName = parts[0];
241
+ const scope = parts[1] ?? "READ";
242
+ if (scope !== "READ") {
243
+ throw new MigrationParseError(
244
+ filePath,
245
+ "pipe",
246
+ resourceName,
247
+ `Unsupported pipe token scope: "${scope}"`
248
+ );
249
+ }
250
+
251
+ return { name: tokenName, scope: "READ" };
252
+ }
253
+
254
+ export function parsePipeFile(resource: ResourceFile): PipeModel {
255
+ const lines = splitLines(resource.content);
256
+ const nodes: PipeModel["nodes"] = [];
257
+ const tokens: PipeTokenModel[] = [];
258
+ let description: string | undefined;
259
+ let pipeType: PipeModel["type"] = "pipe";
260
+ let cacheTtl: number | undefined;
261
+ let materializedDatasource: string | undefined;
262
+ let deploymentMethod: "alter" | undefined;
263
+ let copyTargetDatasource: string | undefined;
264
+ let copySchedule: string | undefined;
265
+ let copyMode: "append" | "replace" | undefined;
266
+
267
+ let i = 0;
268
+ while (i < lines.length) {
269
+ const line = (lines[i] ?? "").trim();
270
+ if (!line) {
271
+ i += 1;
272
+ continue;
273
+ }
274
+
275
+ 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) {
287
+ description = block.lines.join("\n");
288
+ } else if (nodes.length > 0) {
289
+ nodes[nodes.length - 1] = {
290
+ ...nodes[nodes.length - 1]!,
291
+ description: block.lines.join("\n"),
292
+ };
293
+ } else {
294
+ throw new MigrationParseError(
295
+ resource.filePath,
296
+ "pipe",
297
+ resource.name,
298
+ "DESCRIPTION block is not attached to a node or pipe header."
299
+ );
300
+ }
301
+ i = block.nextIndex;
302
+ continue;
303
+ }
304
+
305
+ if (line.startsWith("NODE ")) {
306
+ const nodeName = line.slice("NODE ".length).trim();
307
+ if (!nodeName) {
308
+ throw new MigrationParseError(
309
+ resource.filePath,
310
+ "pipe",
311
+ resource.name,
312
+ "NODE directive requires a name."
313
+ );
314
+ }
315
+
316
+ i += 1;
317
+ i = nextNonBlank(lines, i);
318
+
319
+ let nodeDescription: string | undefined;
320
+ 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
+ }
330
+ nodeDescription = descriptionBlock.lines.join("\n");
331
+ i = descriptionBlock.nextIndex;
332
+ i = nextNonBlank(lines, i);
333
+ }
334
+
335
+ if ((lines[i] ?? "").trim() !== "SQL >") {
336
+ throw new MigrationParseError(
337
+ resource.filePath,
338
+ "pipe",
339
+ resource.name,
340
+ `Node "${nodeName}" is missing SQL > block.`
341
+ );
342
+ }
343
+ const sqlBlock = readIndentedBlock(lines, i + 1);
344
+ if (sqlBlock.lines.length === 0) {
345
+ throw new MigrationParseError(
346
+ resource.filePath,
347
+ "pipe",
348
+ resource.name,
349
+ `Node "${nodeName}" has an empty SQL block.`
350
+ );
351
+ }
352
+
353
+ const normalizedSqlLines =
354
+ sqlBlock.lines[0] === "%" ? sqlBlock.lines.slice(1) : sqlBlock.lines;
355
+ const sql = normalizedSqlLines.join("\n").trim();
356
+ if (!sql) {
357
+ throw new MigrationParseError(
358
+ resource.filePath,
359
+ "pipe",
360
+ resource.name,
361
+ `Node "${nodeName}" has SQL marker '%' but no SQL body.`
362
+ );
363
+ }
364
+
365
+ nodes.push({
366
+ name: nodeName,
367
+ description: nodeDescription,
368
+ sql,
369
+ });
370
+
371
+ i = sqlBlock.nextIndex;
372
+ continue;
373
+ }
374
+
375
+ const { key, value } = parseDirectiveLine(line);
376
+ switch (key) {
377
+ case "TYPE":
378
+ if (value === "endpoint") {
379
+ pipeType = "endpoint";
380
+ } else if (value === "MATERIALIZED") {
381
+ pipeType = "materialized";
382
+ } else if (value === "COPY") {
383
+ pipeType = "copy";
384
+ } else {
385
+ throw new MigrationParseError(
386
+ resource.filePath,
387
+ "pipe",
388
+ resource.name,
389
+ `Unsupported TYPE value in strict mode: "${value}"`
390
+ );
391
+ }
392
+ break;
393
+ case "CACHE": {
394
+ const ttl = Number(value);
395
+ if (!Number.isFinite(ttl) || ttl < 0) {
396
+ throw new MigrationParseError(
397
+ resource.filePath,
398
+ "pipe",
399
+ resource.name,
400
+ `Invalid CACHE value: "${value}"`
401
+ );
402
+ }
403
+ cacheTtl = ttl;
404
+ break;
405
+ }
406
+ case "DATASOURCE":
407
+ materializedDatasource = value.trim();
408
+ break;
409
+ case "DEPLOYMENT_METHOD":
410
+ if (value !== "alter") {
411
+ throw new MigrationParseError(
412
+ resource.filePath,
413
+ "pipe",
414
+ resource.name,
415
+ `Unsupported DEPLOYMENT_METHOD: "${value}"`
416
+ );
417
+ }
418
+ deploymentMethod = "alter";
419
+ break;
420
+ case "TARGET_DATASOURCE":
421
+ copyTargetDatasource = value.trim();
422
+ break;
423
+ case "COPY_SCHEDULE":
424
+ copySchedule = value;
425
+ break;
426
+ case "COPY_MODE":
427
+ if (value !== "append" && value !== "replace") {
428
+ throw new MigrationParseError(
429
+ resource.filePath,
430
+ "pipe",
431
+ resource.name,
432
+ `Unsupported COPY_MODE: "${value}"`
433
+ );
434
+ }
435
+ copyMode = value;
436
+ break;
437
+ case "TOKEN":
438
+ tokens.push(parseToken(resource.filePath, resource.name, value));
439
+ break;
440
+ default:
441
+ throw new MigrationParseError(
442
+ resource.filePath,
443
+ "pipe",
444
+ resource.name,
445
+ `Unsupported pipe directive in strict mode: "${line}"`
446
+ );
447
+ }
448
+
449
+ i += 1;
450
+ }
451
+
452
+ if (nodes.length === 0) {
453
+ throw new MigrationParseError(
454
+ resource.filePath,
455
+ "pipe",
456
+ resource.name,
457
+ "At least one NODE is required."
458
+ );
459
+ }
460
+
461
+ if (pipeType !== "endpoint" && cacheTtl !== undefined) {
462
+ throw new MigrationParseError(
463
+ resource.filePath,
464
+ "pipe",
465
+ resource.name,
466
+ "CACHE is only supported for TYPE endpoint."
467
+ );
468
+ }
469
+
470
+ if (pipeType === "materialized" && !materializedDatasource) {
471
+ throw new MigrationParseError(
472
+ resource.filePath,
473
+ "pipe",
474
+ resource.name,
475
+ "DATASOURCE is required for TYPE MATERIALIZED."
476
+ );
477
+ }
478
+
479
+ if (pipeType === "copy" && !copyTargetDatasource) {
480
+ throw new MigrationParseError(
481
+ resource.filePath,
482
+ "pipe",
483
+ resource.name,
484
+ "TARGET_DATASOURCE is required for TYPE COPY."
485
+ );
486
+ }
487
+
488
+ const params =
489
+ pipeType === "materialized" || pipeType === "copy"
490
+ ? []
491
+ : inferParamsFromSql(
492
+ nodes.map((node) => node.sql).join("\n"),
493
+ resource.filePath,
494
+ resource.name
495
+ );
496
+
497
+ const inferredOutputColumns =
498
+ pipeType === "endpoint" ? inferOutputColumnsFromSql(nodes[nodes.length - 1]!.sql) : [];
499
+
500
+ return {
501
+ kind: "pipe",
502
+ name: resource.name,
503
+ filePath: resource.filePath,
504
+ description,
505
+ type: pipeType,
506
+ nodes,
507
+ cacheTtl,
508
+ materializedDatasource,
509
+ deploymentMethod,
510
+ copyTargetDatasource,
511
+ copySchedule,
512
+ copyMode,
513
+ tokens,
514
+ params,
515
+ inferredOutputColumns,
516
+ };
517
+ }
518
+
@@ -0,0 +1,20 @@
1
+ import type { ParsedResource, ResourceFile } from "./types.js";
2
+ import { parseDatasourceFile } from "./parse-datasource.js";
3
+ import { parsePipeFile } from "./parse-pipe.js";
4
+ import { parseConnectionFile } from "./parse-connection.js";
5
+
6
+ export function parseResourceFile(resource: ResourceFile): ParsedResource {
7
+ switch (resource.kind) {
8
+ case "datasource":
9
+ return parseDatasourceFile(resource);
10
+ case "pipe":
11
+ return parsePipeFile(resource);
12
+ case "connection":
13
+ return parseConnectionFile(resource);
14
+ default: {
15
+ const exhaustive: never = resource.kind;
16
+ throw new Error(`Unsupported resource kind: ${String(exhaustive)}`);
17
+ }
18
+ }
19
+ }
20
+
@@ -0,0 +1,160 @@
1
+ import type { ResourceKind } from "./types.js";
2
+
3
+ export class MigrationParseError extends Error {
4
+ constructor(
5
+ public readonly filePath: string,
6
+ public readonly resourceKind: ResourceKind,
7
+ public readonly resourceName: string,
8
+ message: string
9
+ ) {
10
+ super(message);
11
+ this.name = "MigrationParseError";
12
+ }
13
+ }
14
+
15
+ export function splitLines(content: string): string[] {
16
+ return content.replace(/\r\n/g, "\n").split("\n");
17
+ }
18
+
19
+ export function isBlank(line: string): boolean {
20
+ return line.trim().length === 0;
21
+ }
22
+
23
+ export function stripIndent(line: string): string {
24
+ if (line.startsWith(" ")) {
25
+ return line.slice(4);
26
+ }
27
+ return line.trimStart();
28
+ }
29
+
30
+ export function splitCommaSeparated(input: string): string[] {
31
+ return input
32
+ .split(",")
33
+ .map((part) => part.trim())
34
+ .filter((part) => part.length > 0);
35
+ }
36
+
37
+ export function parseQuotedValue(input: string): string {
38
+ const trimmed = input.trim();
39
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
40
+ return trimmed.slice(1, -1);
41
+ }
42
+ return trimmed;
43
+ }
44
+
45
+ export function parseLiteralFromDatafile(
46
+ value: string
47
+ ): string | number | boolean | null | Record<string, unknown> | unknown[] {
48
+ const trimmed = value.trim();
49
+
50
+ if (trimmed === "NULL") {
51
+ return null;
52
+ }
53
+
54
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
55
+ return Number(trimmed);
56
+ }
57
+
58
+ if (trimmed === "1") {
59
+ return true;
60
+ }
61
+
62
+ if (trimmed === "0") {
63
+ return false;
64
+ }
65
+
66
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
67
+ return trimmed.slice(1, -1).replace(/\\'/g, "'");
68
+ }
69
+
70
+ if (
71
+ (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
72
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))
73
+ ) {
74
+ return JSON.parse(trimmed) as Record<string, unknown> | unknown[];
75
+ }
76
+
77
+ throw new Error(`Unsupported literal value: ${value}`);
78
+ }
79
+
80
+ export function toTsLiteral(
81
+ value: string | number | boolean | null | Record<string, unknown> | unknown[]
82
+ ): string {
83
+ if (value === null) {
84
+ return "null";
85
+ }
86
+ if (typeof value === "number" || typeof value === "boolean") {
87
+ return String(value);
88
+ }
89
+ if (typeof value === "string") {
90
+ return JSON.stringify(value);
91
+ }
92
+ return JSON.stringify(value);
93
+ }
94
+
95
+ export function parseDirectiveLine(line: string): { key: string; value: string } {
96
+ const firstSpace = line.indexOf(" ");
97
+ if (firstSpace === -1) {
98
+ return { key: line.trim(), value: "" };
99
+ }
100
+ return {
101
+ key: line.slice(0, firstSpace).trim(),
102
+ value: line.slice(firstSpace + 1).trim(),
103
+ };
104
+ }
105
+
106
+ export function splitTopLevelComma(input: string): string[] {
107
+ const parts: string[] = [];
108
+ let current = "";
109
+ let depth = 0;
110
+ let inSingleQuote = false;
111
+ let inDoubleQuote = false;
112
+
113
+ for (let i = 0; i < input.length; i += 1) {
114
+ const char = input[i];
115
+ const prev = i > 0 ? input[i - 1] : "";
116
+
117
+ if (char === "'" && !inDoubleQuote && prev !== "\\") {
118
+ inSingleQuote = !inSingleQuote;
119
+ current += char;
120
+ continue;
121
+ }
122
+
123
+ if (char === '"' && !inSingleQuote && prev !== "\\") {
124
+ inDoubleQuote = !inDoubleQuote;
125
+ current += char;
126
+ continue;
127
+ }
128
+
129
+ if (!inSingleQuote && !inDoubleQuote) {
130
+ if (char === "(") {
131
+ depth += 1;
132
+ current += char;
133
+ continue;
134
+ }
135
+ if (char === ")") {
136
+ depth -= 1;
137
+ current += char;
138
+ continue;
139
+ }
140
+ if (char === "," && depth === 0) {
141
+ const trimmed = current.trim();
142
+ if (trimmed.length > 0) {
143
+ parts.push(trimmed);
144
+ }
145
+ current = "";
146
+ continue;
147
+ }
148
+ }
149
+
150
+ current += char;
151
+ }
152
+
153
+ const trimmed = current.trim();
154
+ if (trimmed.length > 0) {
155
+ parts.push(trimmed);
156
+ }
157
+
158
+ return parts;
159
+ }
160
+