airfoil 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/index.js +843 -99650
  2. package/package.json +21 -22
  3. package/src/commands/cluster/index.ts +15 -42
  4. package/src/commands/cluster/namespace/create.ts +280 -73
  5. package/src/commands/cluster/namespace/delete.ts +12 -20
  6. package/src/commands/cluster/namespace/get.ts +15 -21
  7. package/src/commands/cluster/namespace/list.ts +17 -44
  8. package/src/commands/cluster/table/create.ts +279 -0
  9. package/src/commands/cluster/table/delete.ts +54 -0
  10. package/src/commands/cluster/table/get.ts +62 -0
  11. package/src/commands/cluster/table/list.ts +61 -0
  12. package/src/commands/dev.ts +73 -172
  13. package/src/commands/sql.ts +31 -62
  14. package/src/index.ts +13 -6
  15. package/src/utils/client.ts +3 -3
  16. package/src/utils/docker.ts +137 -0
  17. package/src/utils/options.ts +2 -4
  18. package/src/utils/wings.ts +72 -64
  19. package/src/commands/cluster/data-lake/create-iceberg.ts +0 -61
  20. package/src/commands/cluster/data-lake/create-parquet.ts +0 -61
  21. package/src/commands/cluster/data-lake/create.ts +0 -16
  22. package/src/commands/cluster/data-lake/delete.ts +0 -58
  23. package/src/commands/cluster/data-lake/get.ts +0 -46
  24. package/src/commands/cluster/data-lake/list.ts +0 -72
  25. package/src/commands/cluster/object-store/create-aws.ts +0 -106
  26. package/src/commands/cluster/object-store/create-azure.ts +0 -100
  27. package/src/commands/cluster/object-store/create-google.ts +0 -102
  28. package/src/commands/cluster/object-store/create-s3.ts +0 -126
  29. package/src/commands/cluster/object-store/create.ts +0 -20
  30. package/src/commands/cluster/object-store/delete.ts +0 -58
  31. package/src/commands/cluster/object-store/get.ts +0 -51
  32. package/src/commands/cluster/object-store/list.ts +0 -72
  33. package/src/commands/cluster/tenant/create.ts +0 -46
  34. package/src/commands/cluster/tenant/delete.ts +0 -62
  35. package/src/commands/cluster/tenant/get.ts +0 -44
  36. package/src/commands/cluster/tenant/list.ts +0 -60
  37. package/src/commands/cluster/topic/create.ts +0 -317
  38. package/src/commands/cluster/topic/delete.ts +0 -67
  39. package/src/commands/cluster/topic/get.ts +0 -51
  40. package/src/commands/cluster/topic/list.ts +0 -70
  41. package/src/scripts/compile-all.ts +0 -21
  42. package/src/utils/effect.ts +0 -11
@@ -1,317 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as p from "@clack/prompts";
3
- import type { FieldConfig } from "@useairfoil/wings";
4
- import { WingsClusterMetadata } from "@useairfoil/wings";
5
- import { printTable } from "console-table-printer";
6
- import { Effect, Option } from "effect";
7
- import { Command, Flag } from "effect/unstable/cli";
8
- import { makeClusterMetadataLayer } from "../../../utils/client.js";
9
- import { handleCliError } from "../../../utils/effect.js";
10
- import { hostOption, portOption } from "../../../utils/options.js";
11
-
12
- // Supported simple types (no config required)
13
- const SUPPORTED_INLINE_TYPES = [
14
- "Int8",
15
- "Int16",
16
- "Int32",
17
- "Int64",
18
- "Uint8",
19
- "Uint16",
20
- "Uint32",
21
- "Uint64",
22
- "Bool",
23
- "Utf8",
24
- "Binary",
25
- "Null",
26
- "Float16",
27
- "Float32",
28
- "Float64",
29
- "DateDay",
30
- "DateMillisecond",
31
- "TimestampSecond",
32
- "TimestampMillisecond",
33
- "TimestampMicrosecond",
34
- "TimestampNanosecond",
35
- "DurationSecond",
36
- "DurationMillisecond",
37
- "DurationMicrosecond",
38
- "DurationNanosecond",
39
- ] as const;
40
-
41
- type SupportedInlineType = (typeof SUPPORTED_INLINE_TYPES)[number];
42
-
43
- const parentOption = Flag.string("parent").pipe(
44
- Flag.withDescription(
45
- "Parent namespace in format: tenants/{tenant}/namespaces/{namespace}",
46
- ),
47
- );
48
-
49
- const topicIdOption = Flag.string("topic-id").pipe(
50
- Flag.withDescription("Unique identifier for the topic"),
51
- );
52
-
53
- const descriptionOption = Flag.string("description").pipe(
54
- Flag.withDescription("Topic description"),
55
- Flag.optional,
56
- );
57
-
58
- const fieldsOption = Flag.string("fields").pipe(
59
- Flag.withDescription(
60
- 'Field definitions in format "name:Type" or "name:Type?" for nullable',
61
- ),
62
- Flag.atLeast(0),
63
- );
64
-
65
- const schemaFileOption = Flag.string("schema-file").pipe(
66
- Flag.withDescription(
67
- "Path to JSON file containing FieldConfig[] (for complex types)",
68
- ),
69
- Flag.optional,
70
- );
71
-
72
- const partitionKeyOption = Flag.string("partition-key").pipe(
73
- Flag.withDescription("Name of the field used for partitioning"),
74
- Flag.optional,
75
- );
76
-
77
- const freshnessSecondsOption = Flag.integer("freshness-seconds").pipe(
78
- Flag.withDescription("How often to compact the topic (seconds)"),
79
- Flag.withDefault(60),
80
- );
81
-
82
- const ttlSecondsOption = Flag.integer("ttl-seconds").pipe(
83
- Flag.withDescription("How long to keep topic data (seconds)"),
84
- Flag.optional,
85
- );
86
-
87
- const targetFileSizeBytesOption = Flag.integer("target-file-size-bytes").pipe(
88
- Flag.withDescription("Target file size for compaction (bytes)"),
89
- Flag.withDefault(1024 * 1024),
90
- );
91
-
92
- /**
93
- * Parse a field string in format "name:Type" or "name:Type?" (nullable)
94
- */
95
- function parseFieldString(fieldStr: string, index: number): FieldConfig {
96
- const match = fieldStr.match(/^([^:]+):([^?]+)(\?)?$/);
97
- if (!match) {
98
- throw new Error(
99
- `Invalid field format: "${fieldStr}". Expected format: "name:Type" or "name:Type?"`,
100
- );
101
- }
102
-
103
- const name = match[1];
104
- const dataType = match[2];
105
- const nullableMarker = match[3];
106
-
107
- if (!name || !dataType) {
108
- throw new Error(
109
- `Invalid field format: "${fieldStr}". Expected format: "name:Type" or "name:Type?"`,
110
- );
111
- }
112
-
113
- const nullable = nullableMarker === "?";
114
-
115
- if (!SUPPORTED_INLINE_TYPES.includes(dataType as SupportedInlineType)) {
116
- throw new Error(
117
- `Unsupported type: "${dataType}". Supported types: ${SUPPORTED_INLINE_TYPES.join(", ")}. For complex types, use --schema-file.`,
118
- );
119
- }
120
-
121
- return {
122
- name: name.trim(),
123
- dataType: dataType as SupportedInlineType,
124
- nullable,
125
- id: BigInt(index + 1),
126
- };
127
- }
128
-
129
- /**
130
- * Parse multiple field strings into FieldConfig array
131
- */
132
- function parseFieldsFromArgs(fields: ReadonlyArray<string>): FieldConfig[] {
133
- return fields.map((field, index) => parseFieldString(field, index));
134
- }
135
-
136
- /**
137
- * Load fields from a JSON schema file with basic validation
138
- */
139
- function loadFieldsFromFile(filePath: string): FieldConfig[] {
140
- if (!fs.existsSync(filePath)) {
141
- throw new Error(`Schema file not found: "${filePath}"`);
142
- }
143
-
144
- const content = fs.readFileSync(filePath, "utf-8");
145
-
146
- let parsed: unknown;
147
- try {
148
- parsed = JSON.parse(content);
149
- } catch {
150
- throw new Error(`Invalid JSON in schema file: "${filePath}"`);
151
- }
152
-
153
- if (!Array.isArray(parsed)) {
154
- throw new Error(
155
- `Schema file must contain an array of fields. Got: ${typeof parsed}`,
156
- );
157
- }
158
-
159
- if (parsed.length === 0) {
160
- throw new Error(
161
- "Schema file contains an empty array. At least one field is required.",
162
- );
163
- }
164
-
165
- return (parsed as FieldConfig[]).map((field, index) => {
166
- const fieldId = (field as { id?: bigint | number }).id;
167
- return {
168
- ...field,
169
- id: typeof fieldId === "bigint" ? fieldId : BigInt(fieldId ?? index + 1),
170
- };
171
- });
172
- }
173
-
174
- export const createTopicCommand = Command.make(
175
- "create-topic",
176
- {
177
- parent: parentOption,
178
- topicId: topicIdOption,
179
- description: descriptionOption,
180
- fields: fieldsOption,
181
- schemaFile: schemaFileOption,
182
- partitionKey: partitionKeyOption,
183
- freshnessSeconds: freshnessSecondsOption,
184
- ttlSeconds: ttlSecondsOption,
185
- targetFileSizeBytes: targetFileSizeBytesOption,
186
- host: hostOption,
187
- port: portOption,
188
- },
189
- ({
190
- parent,
191
- topicId,
192
- description,
193
- fields,
194
- schemaFile,
195
- partitionKey,
196
- freshnessSeconds,
197
- ttlSeconds,
198
- targetFileSizeBytes,
199
- host,
200
- port,
201
- }) =>
202
- Effect.gen(function* () {
203
- p.intro("📋 Create Topic");
204
-
205
- const schemaFilePath = Option.getOrUndefined(schemaFile);
206
- const hasFields = fields.length > 0;
207
-
208
- if (!hasFields && !schemaFilePath) {
209
- return yield* Effect.fail(
210
- new Error("Either --fields or --schema-file must be provided"),
211
- );
212
- }
213
-
214
- if (hasFields && schemaFilePath) {
215
- return yield* Effect.fail(
216
- new Error("Cannot use both --fields and --schema-file. Choose one."),
217
- );
218
- }
219
-
220
- const topicFields = yield* Effect.try({
221
- try: () =>
222
- schemaFilePath
223
- ? loadFieldsFromFile(schemaFilePath)
224
- : parseFieldsFromArgs(fields),
225
- catch: (error) =>
226
- error instanceof Error ? error : new Error("Failed to parse fields"),
227
- });
228
-
229
- if (topicFields.length === 0) {
230
- return yield* Effect.fail(new Error("At least one field is required"));
231
- }
232
-
233
- const partitionKeyName = Option.getOrUndefined(partitionKey);
234
- let partitionKeyId: bigint | undefined;
235
- if (partitionKeyName) {
236
- const index = topicFields.findIndex(
237
- (field) => field.name === partitionKeyName,
238
- );
239
- if (index === -1) {
240
- return yield* Effect.fail(
241
- new Error(
242
- `Partition key field "${partitionKeyName}" not found in fields. Available fields: ${topicFields.map((field) => field.name).join(", ")}`,
243
- ),
244
- );
245
- }
246
- const partitionField = topicFields[index];
247
- if (!partitionField) {
248
- return yield* Effect.fail(
249
- new Error(
250
- `Partition key field "${partitionKeyName}" not found in fields. Available fields: ${topicFields.map((field) => field.name).join(", ")}`,
251
- ),
252
- );
253
- }
254
- partitionKeyId = partitionField.id;
255
- }
256
-
257
- const layer = makeClusterMetadataLayer(host, port);
258
-
259
- const s = p.spinner();
260
- s.start("Creating topic...");
261
-
262
- const topic = yield* WingsClusterMetadata.createTopic({
263
- parent,
264
- topicId,
265
- description: Option.getOrUndefined(description),
266
- fields: topicFields,
267
- partitionKey: partitionKeyId,
268
- compaction: {
269
- freshnessSeconds: BigInt(freshnessSeconds),
270
- ttlSeconds: Option.getOrUndefined(
271
- Option.map(ttlSeconds, (ttl) => BigInt(ttl)),
272
- ),
273
- targetFileSizeBytes: BigInt(targetFileSizeBytes),
274
- },
275
- }).pipe(
276
- Effect.provide(layer),
277
- Effect.tapError(() =>
278
- Effect.sync(() => s.stop("Failed to create topic")),
279
- ),
280
- );
281
-
282
- s.stop("Topic created successfully");
283
-
284
- yield* Effect.sync(() => {
285
- printTable([
286
- {
287
- name: topic.name,
288
- description: topic.description || "-",
289
- partition_key:
290
- topic.partitionKey !== undefined
291
- ? topicFields.find((field) => field.id === topic.partitionKey)
292
- ?.name || topic.partitionKey.toString()
293
- : "-",
294
- freshness_seconds: topic.compaction.freshnessSeconds.toString(),
295
- ttl_seconds: topic.compaction.ttlSeconds?.toString() || "-",
296
- target_file_size_bytes:
297
- topic.compaction.targetFileSizeBytes.toString(),
298
- fields_count: topic.schema.fields.length.toString(),
299
- },
300
- ]);
301
-
302
- if (topic.schema.fields.length > 0) {
303
- console.log("\nFields:");
304
- printTable(
305
- topic.schema.fields.map((field, index) => ({
306
- index: index.toString(),
307
- name: field.name,
308
- type: field.arrowType?._tag ?? "unknown",
309
- nullable: field.nullable ? "yes" : "no",
310
- })),
311
- );
312
- }
313
-
314
- p.outro("✓ Done");
315
- });
316
- }).pipe(Effect.catch(handleCliError("Failed to create topic"))),
317
- ).pipe(Command.withDescription("Create a new topic belonging to a namespace"));
@@ -1,67 +0,0 @@
1
- import * as p from "@clack/prompts";
2
- import { WingsClusterMetadata } from "@useairfoil/wings";
3
- import { Effect } from "effect";
4
- import { Command, Flag } from "effect/unstable/cli";
5
- import { makeClusterMetadataLayer } from "../../../utils/client.js";
6
- import { handleCliError } from "../../../utils/effect.js";
7
- import { forceOption, hostOption, portOption } from "../../../utils/options.js";
8
-
9
- const nameOption = Flag.string("name").pipe(
10
- Flag.withDescription(
11
- "Topic name in format: tenants/{tenant}/namespaces/{namespace}/topics/{topic}",
12
- ),
13
- );
14
-
15
- const forceDeleteOption = Flag.boolean("force-delete").pipe(
16
- Flag.withDescription("Also delete data associated with the topic"),
17
- Flag.withDefault(false),
18
- );
19
-
20
- export const deleteTopicCommand = Command.make(
21
- "delete-topic",
22
- {
23
- name: nameOption,
24
- forceDelete: forceDeleteOption,
25
- force: forceOption,
26
- host: hostOption,
27
- port: portOption,
28
- },
29
- ({ name, forceDelete, force, host, port }) =>
30
- Effect.gen(function* () {
31
- p.intro("🗑️ Delete Topic");
32
-
33
- if (!force) {
34
- const confirm = yield* Effect.tryPromise({
35
- try: () =>
36
- p.confirm({
37
- message: `Are you sure you want to delete topic ${name}?${forceDelete ? " (including all data)" : ""}`,
38
- initialValue: false,
39
- }),
40
- catch: () => new Error("Failed to confirm deletion"),
41
- });
42
-
43
- if (p.isCancel(confirm) || !confirm) {
44
- p.cancel("Deletion cancelled");
45
- process.exit(0);
46
- }
47
- }
48
-
49
- const layer = makeClusterMetadataLayer(host, port);
50
-
51
- const s = p.spinner();
52
- s.start("Deleting topic...");
53
-
54
- yield* WingsClusterMetadata.deleteTopic({
55
- name,
56
- force: forceDelete,
57
- }).pipe(
58
- Effect.provide(layer),
59
- Effect.tapError(() =>
60
- Effect.sync(() => s.stop("Failed to delete topic")),
61
- ),
62
- );
63
-
64
- s.stop("Topic deleted successfully");
65
- p.outro("✓ Done");
66
- }).pipe(Effect.catch(handleCliError("Failed to delete topic"))),
67
- ).pipe(Command.withDescription("Delete a topic from the cluster"));
@@ -1,51 +0,0 @@
1
- import * as p from "@clack/prompts";
2
- import { WingsClusterMetadata } from "@useairfoil/wings";
3
- import { printTable } from "console-table-printer";
4
- import { Effect } from "effect";
5
- import { Command, Flag } from "effect/unstable/cli";
6
- import { makeClusterMetadataLayer } from "../../../utils/client.js";
7
- import { handleCliError } from "../../../utils/effect.js";
8
- import { hostOption, portOption } from "../../../utils/options.js";
9
-
10
- const nameOption = Flag.string("name").pipe(
11
- Flag.withDescription(
12
- "Topic name in format: tenants/{tenant}/namespaces/{namespace}/topics/{topic}",
13
- ),
14
- );
15
-
16
- export const getTopicCommand = Command.make(
17
- "get-topic",
18
- {
19
- name: nameOption,
20
- host: hostOption,
21
- port: portOption,
22
- },
23
- ({ name, host, port }) =>
24
- Effect.gen(function* () {
25
- const layer = makeClusterMetadataLayer(host, port);
26
-
27
- const s = p.spinner();
28
- s.start("Fetching topic...");
29
-
30
- const topic = yield* WingsClusterMetadata.getTopic({ name }).pipe(
31
- Effect.provide(layer),
32
- Effect.tapError(() => Effect.sync(() => s.stop("Failed to get topic"))),
33
- );
34
-
35
- s.stop("Topic retrieved");
36
-
37
- yield* Effect.sync(() => {
38
- printTable([
39
- {
40
- name: topic.name,
41
- description: topic.description || "-",
42
- partition_key: topic.partitionKey?.toString() || "-",
43
- freshness_seconds: topic.compaction.freshnessSeconds.toString(),
44
- ttl_seconds: topic.compaction.ttlSeconds?.toString() || "-",
45
- },
46
- ]);
47
-
48
- p.outro("✓ Done");
49
- });
50
- }).pipe(Effect.catch(handleCliError("Failed to get topic"))),
51
- ).pipe(Command.withDescription("Get details of a specific topic"));
@@ -1,70 +0,0 @@
1
- import * as p from "@clack/prompts";
2
- import { WingsClusterMetadata } from "@useairfoil/wings";
3
- import { printTable } from "console-table-printer";
4
- import { Effect, Option } from "effect";
5
- import { Command, Flag } from "effect/unstable/cli";
6
- import { makeClusterMetadataLayer } from "../../../utils/client.js";
7
- import { handleCliError } from "../../../utils/effect.js";
8
- import {
9
- hostOption,
10
- pageSizeOption,
11
- pageTokenOption,
12
- portOption,
13
- } from "../../../utils/options.js";
14
-
15
- const parentOption = Flag.string("parent").pipe(
16
- Flag.withDescription(
17
- "Parent namespace in format: tenants/{tenant}/namespaces/{namespace}",
18
- ),
19
- );
20
-
21
- export const listTopicsCommand = Command.make(
22
- "list-topics",
23
- {
24
- parent: parentOption,
25
- pageSize: pageSizeOption,
26
- pageToken: pageTokenOption,
27
- host: hostOption,
28
- port: portOption,
29
- },
30
- ({ parent, pageSize, pageToken, host, port }) =>
31
- Effect.gen(function* () {
32
- const layer = makeClusterMetadataLayer(host, port);
33
-
34
- const s = p.spinner();
35
- s.start("Fetching topics...");
36
-
37
- const response = yield* WingsClusterMetadata.listTopics({
38
- parent,
39
- pageSize,
40
- pageToken: Option.getOrUndefined(pageToken),
41
- }).pipe(
42
- Effect.provide(layer),
43
- Effect.tapError(() =>
44
- Effect.sync(() => s.stop("Failed to list topics")),
45
- ),
46
- );
47
-
48
- s.stop(`Found ${response.topics.length} topic(s)`);
49
-
50
- yield* Effect.sync(() => {
51
- if (response.topics.length === 0) {
52
- p.log.warn("No topics found");
53
- } else {
54
- printTable(
55
- response.topics.map((topic) => ({
56
- name: topic.name,
57
- description: topic.description || "-",
58
- partition_key: topic.partitionKey?.toString() || "-",
59
- })),
60
- );
61
- }
62
-
63
- if (response.nextPageToken) {
64
- p.log.info(`Next page token: ${response.nextPageToken}`);
65
- }
66
-
67
- p.outro("✓ Done");
68
- });
69
- }).pipe(Effect.catch(handleCliError("Failed to list topics"))),
70
- ).pipe(Command.withDescription("List all topics belonging to a namespace"));
@@ -1,21 +0,0 @@
1
- import { $ } from "bun";
2
-
3
- const targets = [
4
- { platform: "linux", arch: "x64" },
5
- { platform: "linux", arch: "arm64" },
6
- { platform: "darwin", arch: "x64" },
7
- { platform: "darwin", arch: "arm64" },
8
- { platform: "windows", arch: "x64" },
9
- ];
10
-
11
- for (const { platform, arch } of targets) {
12
- const outfile = `dist/airfoil-${platform}-${arch}${platform === "windows" ? ".exe" : ""}`;
13
-
14
- console.log(`Compiling for ${platform}-${arch}...`);
15
-
16
- await $`bun build src/index.ts --compile --target=bun-${platform}-${arch} --outfile ${outfile}`;
17
-
18
- console.log(`✓ Created ${outfile}`);
19
- }
20
-
21
- console.log("\n✓ All binaries compiled successfully!");
@@ -1,11 +0,0 @@
1
- import * as p from "@clack/prompts";
2
- import { Effect } from "effect";
3
-
4
- export const handleCliError = (fallbackMessage: string) => (error: unknown) =>
5
- Effect.sync(() => {
6
- p.cancel(error instanceof Error ? error.message : fallbackMessage);
7
- }).pipe(
8
- Effect.andThen(
9
- Effect.fail(error instanceof Error ? error : new Error(fallbackMessage)),
10
- ),
11
- );