@uploadista/core 0.0.13 → 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{checksum-CtOagryS.mjs → checksum-BaO9w1gC.mjs} +2 -2
- package/dist/{checksum-CtOagryS.mjs.map → checksum-BaO9w1gC.mjs.map} +1 -1
- package/dist/{checksum-jmKtZ9W8.cjs → checksum-DXCv7Avr.cjs} +1 -1
- package/dist/errors/index.cjs +1 -1
- package/dist/errors/index.d.cts +1 -1
- package/dist/errors/index.d.mts +1 -1
- package/dist/errors/index.mjs +1 -1
- package/dist/flow/index.cjs +1 -1
- package/dist/flow/index.d.cts +5 -5
- package/dist/flow/index.d.mts +5 -5
- package/dist/flow/index.mjs +1 -1
- package/dist/flow-DhuIQwjv.mjs +2 -0
- package/dist/flow-DhuIQwjv.mjs.map +1 -0
- package/dist/flow-s_AlC4r5.cjs +1 -0
- package/dist/{index-Bi9YYid8.d.mts → index-3jSHmGwH.d.mts} +2 -2
- package/dist/{index-Bi9YYid8.d.mts.map → index-3jSHmGwH.d.mts.map} +1 -1
- package/dist/{index-4VDJDcWM.d.cts → index-5K4oXy67.d.cts} +822 -169
- package/dist/index-5K4oXy67.d.cts.map +1 -0
- package/dist/{index-RgOX4psL.d.mts → index-BB1v4Ynz.d.mts} +822 -169
- package/dist/index-BB1v4Ynz.d.mts.map +1 -0
- package/dist/{index-Cbf1OPLp.d.mts → index-Bu5i-gcV.d.mts} +2 -2
- package/dist/index-Bu5i-gcV.d.mts.map +1 -0
- package/dist/{index-De4wQJwR.d.cts → index-CHGBYDtr.d.cts} +2 -2
- package/dist/{index-De4wQJwR.d.cts.map → index-CHGBYDtr.d.cts.map} +1 -1
- package/dist/{index-qZ90PVNl.d.cts → index-T6MZvUlM.d.cts} +2 -2
- package/dist/{index-Cbf1OPLp.d.mts.map → index-T6MZvUlM.d.cts.map} +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +1 -1
- package/dist/{stream-limiter-D9rrsvAT.cjs → stream-limiter-BcTJAjs-.cjs} +1 -1
- package/dist/{stream-limiter-D9KSAaoY.mjs → stream-limiter-D1-sVS5i.mjs} +2 -2
- package/dist/{stream-limiter-D9KSAaoY.mjs.map → stream-limiter-D1-sVS5i.mjs.map} +1 -1
- package/dist/streams/index.cjs +1 -1
- package/dist/streams/index.d.cts +2 -2
- package/dist/streams/index.d.mts +2 -2
- package/dist/streams/index.mjs +1 -1
- package/dist/testing/index.cjs +1 -1
- package/dist/testing/index.d.cts +4 -4
- package/dist/testing/index.d.mts +4 -4
- package/dist/testing/index.mjs +1 -1
- package/dist/types/index.cjs +1 -1
- package/dist/types/index.d.cts +4 -4
- package/dist/types/index.d.mts +4 -4
- package/dist/types/index.mjs +1 -1
- package/dist/types-B-EckCWW.cjs +1 -0
- package/dist/types-CO-R4pFG.mjs +2 -0
- package/dist/types-CO-R4pFG.mjs.map +1 -0
- package/dist/upload/index.cjs +1 -1
- package/dist/upload/index.d.cts +4 -4
- package/dist/upload/index.d.mts +4 -4
- package/dist/upload/index.mjs +1 -1
- package/dist/{upload-D-eiOIVG.cjs → upload-BwXGQQ26.cjs} +1 -1
- package/dist/upload-C_Ew1NMF.mjs +2 -0
- package/dist/{upload-Yj5lrtZo.mjs.map → upload-C_Ew1NMF.mjs.map} +1 -1
- package/dist/{uploadista-error-B-n8Kfyh.cjs → uploadista-error-Blmj3lpk.cjs} +5 -1
- package/dist/{uploadista-error-DUWw6OqS.d.mts → uploadista-error-Cpn3uBLO.d.mts} +2 -2
- package/dist/uploadista-error-Cpn3uBLO.d.mts.map +1 -0
- package/dist/{uploadista-error-BQLhNZcY.d.cts → uploadista-error-DgdQnozn.d.cts} +2 -2
- package/dist/uploadista-error-DgdQnozn.d.cts.map +1 -0
- package/dist/{uploadista-error-Buscq-FR.mjs → uploadista-error-DhNBioWq.mjs} +5 -1
- package/dist/uploadista-error-DhNBioWq.mjs.map +1 -0
- package/dist/utils/index.cjs +1 -1
- package/dist/utils/index.d.cts +2 -2
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.mjs +1 -1
- package/dist/{utils-BWiu6lqv.mjs → utils-7gziergl.mjs} +2 -2
- package/dist/{utils-BWiu6lqv.mjs.map → utils-7gziergl.mjs.map} +1 -1
- package/dist/{utils-_StwBtxT.cjs → utils-C_STf6Wl.cjs} +1 -1
- package/package.json +3 -3
- package/src/errors/uploadista-error.ts +21 -1
- package/src/flow/event.ts +28 -4
- package/src/flow/flow-server.ts +43 -12
- package/src/flow/flow.ts +92 -13
- package/src/flow/index.ts +7 -0
- package/src/flow/node-types/index.ts +85 -0
- package/src/flow/node.ts +48 -6
- package/src/flow/nodes/input-node.ts +2 -0
- package/src/flow/nodes/storage-node.ts +2 -0
- package/src/flow/type-guards.ts +293 -0
- package/src/flow/type-registry.ts +345 -0
- package/src/flow/types/flow-job.ts +22 -6
- package/src/flow/types/flow-types.ts +152 -3
- package/tests/flow/type-system.test.ts +799 -0
- package/dist/flow-ChADffZ5.cjs +0 -1
- package/dist/flow-_J9-Dm_m.mjs +0 -2
- package/dist/flow-_J9-Dm_m.mjs.map +0 -1
- package/dist/index-4VDJDcWM.d.cts.map +0 -1
- package/dist/index-RgOX4psL.d.mts.map +0 -1
- package/dist/index-qZ90PVNl.d.cts.map +0 -1
- package/dist/types-BI_KmpTc.mjs +0 -2
- package/dist/types-BI_KmpTc.mjs.map +0 -1
- package/dist/types-f08UsX4E.cjs +0 -1
- package/dist/upload-Yj5lrtZo.mjs +0 -2
- package/dist/uploadista-error-BQLhNZcY.d.cts.map +0 -1
- package/dist/uploadista-error-Buscq-FR.mjs.map +0 -1
- package/dist/uploadista-error-DUWw6OqS.d.mts.map +0 -1
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Flow Type System and Automatic Narrowing
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - TypedOutput discriminated unions
|
|
6
|
+
* - Automatic type narrowing for built-in types
|
|
7
|
+
* - Type guards for custom types
|
|
8
|
+
* - FlowJob.result with TypedOutput[]
|
|
9
|
+
* - Multi-output flow handling
|
|
10
|
+
* - Backward compatibility with untyped nodes
|
|
11
|
+
* - Type registry integration
|
|
12
|
+
* - Helper functions (filter, getSingle)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Effect } from "effect";
|
|
16
|
+
import { describe, expect, it } from "vitest";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import type { UploadFile } from "../../src/types/upload-file";
|
|
19
|
+
import { createFlow } from "../../src/flow";
|
|
20
|
+
import { createFlowNode, NodeType } from "../../src/flow/node";
|
|
21
|
+
import type { TypedOutput } from "../../src/flow/types/flow-types";
|
|
22
|
+
import {
|
|
23
|
+
createTypeGuard,
|
|
24
|
+
filterOutputsByType,
|
|
25
|
+
getSingleOutputByType,
|
|
26
|
+
isStorageOutput,
|
|
27
|
+
} from "../../src/flow/type-guards";
|
|
28
|
+
import { flowTypeRegistry } from "../../src/flow/type-registry";
|
|
29
|
+
// Import built-in type registrations
|
|
30
|
+
import "../../src/flow/node-types";
|
|
31
|
+
|
|
32
|
+
// Helper function to create valid UploadFile test data
|
|
33
|
+
function createMockUploadFile(overrides?: Partial<UploadFile>): UploadFile {
|
|
34
|
+
return {
|
|
35
|
+
id: "file-123",
|
|
36
|
+
offset: 0,
|
|
37
|
+
storage: {
|
|
38
|
+
id: "storage-1",
|
|
39
|
+
type: "s3",
|
|
40
|
+
bucket: "uploads",
|
|
41
|
+
},
|
|
42
|
+
size: 1024,
|
|
43
|
+
url: "https://example.com/file.jpg",
|
|
44
|
+
creationDate: new Date().toISOString(),
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("Type System", () => {
|
|
50
|
+
describe("TypedOutput Discriminated Unions", () => {
|
|
51
|
+
it("should automatically narrow built-in storage-output-v1", () => {
|
|
52
|
+
// Create a typed output with built-in type
|
|
53
|
+
const output: TypedOutput = {
|
|
54
|
+
nodeType: "storage-output-v1",
|
|
55
|
+
nodeId: "storage-1",
|
|
56
|
+
timestamp: new Date().toISOString(),
|
|
57
|
+
data: createMockUploadFile({
|
|
58
|
+
id: "file-123",
|
|
59
|
+
url: "https://example.com/test.jpg",
|
|
60
|
+
size: 1024,
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// TypeScript should automatically narrow in switch
|
|
65
|
+
switch (output.nodeType) {
|
|
66
|
+
case "storage-output-v1":
|
|
67
|
+
// ✅ TypeScript knows output.data is UploadFile
|
|
68
|
+
expect(output.data.url).toBe("https://example.com/test.jpg");
|
|
69
|
+
expect(output.data.size).toBe(1024);
|
|
70
|
+
expect(output.data.id).toBe("file-123");
|
|
71
|
+
break;
|
|
72
|
+
default:
|
|
73
|
+
throw new Error("Should have matched storage-output-v1");
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should automatically narrow built-in streaming-input-v1", () => {
|
|
78
|
+
const output: TypedOutput = {
|
|
79
|
+
nodeType: "streaming-input-v1",
|
|
80
|
+
nodeId: "input-1",
|
|
81
|
+
timestamp: new Date().toISOString(),
|
|
82
|
+
data: {
|
|
83
|
+
id: "file-456",
|
|
84
|
+
name: "upload.pdf",
|
|
85
|
+
size: 2048,
|
|
86
|
+
mimeType: "application/pdf",
|
|
87
|
+
url: "https://example.com/upload.pdf",
|
|
88
|
+
bucket: "inputs",
|
|
89
|
+
key: "upload.pdf",
|
|
90
|
+
storageId: "storage-1",
|
|
91
|
+
createdAt: new Date(),
|
|
92
|
+
updatedAt: new Date(),
|
|
93
|
+
} satisfies UploadFile,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
switch (output.nodeType) {
|
|
97
|
+
case "streaming-input-v1":
|
|
98
|
+
// ✅ TypeScript knows output.data is UploadFile
|
|
99
|
+
expect(output.data.name).toBe("upload.pdf");
|
|
100
|
+
expect(output.data.size).toBe(2048);
|
|
101
|
+
break;
|
|
102
|
+
default:
|
|
103
|
+
throw new Error("Should have matched streaming-input-v1");
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should handle custom types with optional nodeType", () => {
|
|
108
|
+
type ThumbnailOutput = { width: number; height: number; url: string };
|
|
109
|
+
|
|
110
|
+
const output: TypedOutput<ThumbnailOutput> = {
|
|
111
|
+
nodeType: "thumbnail-v1",
|
|
112
|
+
nodeId: "thumbnail-1",
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
data: { width: 150, height: 150, url: "https://example.com/thumb.jpg" },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Custom types require type guards (no automatic narrowing)
|
|
118
|
+
expect(output.nodeType).toBe("thumbnail-v1");
|
|
119
|
+
expect((output.data as ThumbnailOutput).width).toBe(150);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should handle untyped outputs (no nodeType)", () => {
|
|
123
|
+
const output: TypedOutput = {
|
|
124
|
+
nodeId: "untyped-1",
|
|
125
|
+
timestamp: new Date().toISOString(),
|
|
126
|
+
data: { customField: "value" },
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Untyped outputs have unknown data
|
|
130
|
+
expect(output.nodeType).toBeUndefined();
|
|
131
|
+
expect(output.data).toEqual({ customField: "value" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should support array of mixed typed outputs", () => {
|
|
135
|
+
const outputs: TypedOutput[] = [
|
|
136
|
+
{
|
|
137
|
+
nodeType: "storage-output-v1",
|
|
138
|
+
nodeId: "storage-1",
|
|
139
|
+
timestamp: new Date().toISOString(),
|
|
140
|
+
data: {
|
|
141
|
+
id: "file-1",
|
|
142
|
+
name: "file1.jpg",
|
|
143
|
+
size: 1024,
|
|
144
|
+
mimeType: "image/jpeg",
|
|
145
|
+
url: "https://example.com/file1.jpg",
|
|
146
|
+
bucket: "uploads",
|
|
147
|
+
key: "file1.jpg",
|
|
148
|
+
storageId: "storage-1",
|
|
149
|
+
createdAt: new Date(),
|
|
150
|
+
updatedAt: new Date(),
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
nodeType: "streaming-input-v1",
|
|
155
|
+
nodeId: "input-1",
|
|
156
|
+
timestamp: new Date().toISOString(),
|
|
157
|
+
data: {
|
|
158
|
+
id: "file-2",
|
|
159
|
+
name: "file2.pdf",
|
|
160
|
+
size: 2048,
|
|
161
|
+
mimeType: "application/pdf",
|
|
162
|
+
url: "https://example.com/file2.pdf",
|
|
163
|
+
bucket: "inputs",
|
|
164
|
+
key: "file2.pdf",
|
|
165
|
+
storageId: "storage-1",
|
|
166
|
+
createdAt: new Date(),
|
|
167
|
+
updatedAt: new Date(),
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
nodeType: "custom-v1",
|
|
172
|
+
nodeId: "custom-1",
|
|
173
|
+
timestamp: new Date().toISOString(),
|
|
174
|
+
data: { customData: "test" },
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
// Process mixed outputs with automatic narrowing + type guards
|
|
179
|
+
let storageCount = 0;
|
|
180
|
+
let inputCount = 0;
|
|
181
|
+
let customCount = 0;
|
|
182
|
+
|
|
183
|
+
for (const output of outputs) {
|
|
184
|
+
switch (output.nodeType) {
|
|
185
|
+
case "storage-output-v1":
|
|
186
|
+
storageCount++;
|
|
187
|
+
expect(output.data.url).toContain("https://");
|
|
188
|
+
break;
|
|
189
|
+
case "streaming-input-v1":
|
|
190
|
+
inputCount++;
|
|
191
|
+
expect(output.data.name).toBeTruthy();
|
|
192
|
+
break;
|
|
193
|
+
default:
|
|
194
|
+
customCount++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
expect(storageCount).toBe(1);
|
|
199
|
+
expect(inputCount).toBe(1);
|
|
200
|
+
expect(customCount).toBe(1);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("Type Guards", () => {
|
|
205
|
+
it("should validate storage outputs with isStorageOutput", () => {
|
|
206
|
+
const validOutput: TypedOutput = {
|
|
207
|
+
nodeType: "storage-output-v1",
|
|
208
|
+
nodeId: "storage-1",
|
|
209
|
+
timestamp: new Date().toISOString(),
|
|
210
|
+
data: createMockUploadFile({
|
|
211
|
+
id: "file-123",
|
|
212
|
+
url: "https://example.com/test.jpg",
|
|
213
|
+
}),
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const invalidOutput: TypedOutput = {
|
|
217
|
+
nodeType: "custom-v1",
|
|
218
|
+
nodeId: "custom-1",
|
|
219
|
+
timestamp: new Date().toISOString(),
|
|
220
|
+
data: { customField: "value" },
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
expect(isStorageOutput(validOutput)).toBe(true);
|
|
224
|
+
expect(isStorageOutput(invalidOutput)).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("should create custom type guards with createTypeGuard", () => {
|
|
228
|
+
// Register custom type
|
|
229
|
+
type ThumbnailOutput = { width: number; height: number; url: string };
|
|
230
|
+
const thumbnailSchema = z.object({
|
|
231
|
+
width: z.number(),
|
|
232
|
+
height: z.number(),
|
|
233
|
+
url: z.string().url(),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
flowTypeRegistry.register({
|
|
237
|
+
id: "thumbnail-test-v1",
|
|
238
|
+
name: "Thumbnail Output",
|
|
239
|
+
description: "Thumbnail metadata",
|
|
240
|
+
category: "output",
|
|
241
|
+
schema: thumbnailSchema,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const isThumbnailOutput = createTypeGuard<ThumbnailOutput>("thumbnail-test-v1");
|
|
245
|
+
|
|
246
|
+
const validOutput: TypedOutput = {
|
|
247
|
+
nodeType: "thumbnail-test-v1",
|
|
248
|
+
nodeId: "thumb-1",
|
|
249
|
+
timestamp: new Date().toISOString(),
|
|
250
|
+
data: { width: 150, height: 150, url: "https://example.com/thumb.jpg" },
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const invalidOutput: TypedOutput = {
|
|
254
|
+
nodeType: "thumbnail-test-v1",
|
|
255
|
+
nodeId: "thumb-2",
|
|
256
|
+
timestamp: new Date().toISOString(),
|
|
257
|
+
data: { invalid: "data" }, // Missing required fields
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
expect(isThumbnailOutput(validOutput)).toBe(true);
|
|
261
|
+
expect(isThumbnailOutput(invalidOutput)).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("should handle type guards with wrong nodeType", () => {
|
|
265
|
+
const output: TypedOutput = {
|
|
266
|
+
nodeType: "storage-output-v1",
|
|
267
|
+
nodeId: "storage-1",
|
|
268
|
+
timestamp: new Date().toISOString(),
|
|
269
|
+
data: {
|
|
270
|
+
id: "file-123",
|
|
271
|
+
name: "test.jpg",
|
|
272
|
+
size: 1024,
|
|
273
|
+
mimeType: "image/jpeg",
|
|
274
|
+
url: "https://example.com/test.jpg",
|
|
275
|
+
bucket: "uploads",
|
|
276
|
+
key: "test.jpg",
|
|
277
|
+
storageId: "storage-1",
|
|
278
|
+
createdAt: new Date(),
|
|
279
|
+
updatedAt: new Date(),
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// Create guard for different type
|
|
284
|
+
const isThumbnail = createTypeGuard("thumbnail-test-v1");
|
|
285
|
+
|
|
286
|
+
// Should return false for wrong nodeType
|
|
287
|
+
expect(isThumbnail(output)).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("Helper Functions", () => {
|
|
292
|
+
it("should filter outputs by type with filterOutputsByType", () => {
|
|
293
|
+
const outputs: TypedOutput[] = [
|
|
294
|
+
{
|
|
295
|
+
nodeType: "storage-output-v1",
|
|
296
|
+
nodeId: "storage-1",
|
|
297
|
+
timestamp: new Date().toISOString(),
|
|
298
|
+
data: createMockUploadFile({
|
|
299
|
+
id: "file-1",
|
|
300
|
+
url: "https://example.com/file1.jpg",
|
|
301
|
+
}),
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
nodeType: "streaming-input-v1",
|
|
305
|
+
nodeId: "input-1",
|
|
306
|
+
timestamp: new Date().toISOString(),
|
|
307
|
+
data: createMockUploadFile({
|
|
308
|
+
id: "file-2",
|
|
309
|
+
url: "https://example.com/file2.pdf",
|
|
310
|
+
size: 2048,
|
|
311
|
+
}),
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
nodeType: "storage-output-v1",
|
|
315
|
+
nodeId: "storage-2",
|
|
316
|
+
timestamp: new Date().toISOString(),
|
|
317
|
+
data: createMockUploadFile({
|
|
318
|
+
id: "file-3",
|
|
319
|
+
url: "https://example.com/file3.png",
|
|
320
|
+
size: 512,
|
|
321
|
+
}),
|
|
322
|
+
},
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
const storageOutputs = filterOutputsByType(outputs, isStorageOutput);
|
|
326
|
+
|
|
327
|
+
expect(storageOutputs).toHaveLength(2);
|
|
328
|
+
expect(storageOutputs[0]?.data.id).toBe("file-1");
|
|
329
|
+
expect(storageOutputs[1]?.data.id).toBe("file-3");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("should get single output with getSingleOutputByType", () =>
|
|
333
|
+
Effect.gen(function* () {
|
|
334
|
+
const outputs: TypedOutput[] = [
|
|
335
|
+
{
|
|
336
|
+
nodeType: "streaming-input-v1",
|
|
337
|
+
nodeId: "input-1",
|
|
338
|
+
timestamp: new Date().toISOString(),
|
|
339
|
+
data: createMockUploadFile({
|
|
340
|
+
id: "file-1",
|
|
341
|
+
url: "https://example.com/file1.pdf",
|
|
342
|
+
size: 2048,
|
|
343
|
+
}),
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
nodeType: "storage-output-v1",
|
|
347
|
+
nodeId: "storage-1",
|
|
348
|
+
timestamp: new Date().toISOString(),
|
|
349
|
+
data: createMockUploadFile({
|
|
350
|
+
id: "file-2",
|
|
351
|
+
url: "https://example.com/file2.jpg",
|
|
352
|
+
}),
|
|
353
|
+
},
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
const storageOutput = yield* getSingleOutputByType(outputs, isStorageOutput);
|
|
357
|
+
|
|
358
|
+
expect(storageOutput.nodeType).toBe("storage-output-v1");
|
|
359
|
+
expect(storageOutput.data.id).toBe("file-2");
|
|
360
|
+
}).pipe(Effect.runPromise));
|
|
361
|
+
|
|
362
|
+
it("should fail when no outputs match getSingleOutputByType", () =>
|
|
363
|
+
Effect.gen(function* () {
|
|
364
|
+
const outputs: TypedOutput[] = [
|
|
365
|
+
{
|
|
366
|
+
nodeType: "streaming-input-v1",
|
|
367
|
+
nodeId: "input-1",
|
|
368
|
+
timestamp: new Date().toISOString(),
|
|
369
|
+
data: createMockUploadFile({
|
|
370
|
+
id: "file-1",
|
|
371
|
+
url: "https://example.com/file1.pdf",
|
|
372
|
+
}),
|
|
373
|
+
},
|
|
374
|
+
];
|
|
375
|
+
|
|
376
|
+
const result = yield* Effect.either(
|
|
377
|
+
getSingleOutputByType(outputs, isStorageOutput),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
expect(result._tag).toBe("Left");
|
|
381
|
+
if (result._tag === "Left") {
|
|
382
|
+
expect(result.left.code).toBe("OUTPUT_NOT_FOUND");
|
|
383
|
+
}
|
|
384
|
+
}).pipe(Effect.runPromise));
|
|
385
|
+
|
|
386
|
+
it("should fail when multiple outputs match getSingleOutputByType", () =>
|
|
387
|
+
Effect.gen(function* () {
|
|
388
|
+
const outputs: TypedOutput[] = [
|
|
389
|
+
{
|
|
390
|
+
nodeType: "storage-output-v1",
|
|
391
|
+
nodeId: "storage-1",
|
|
392
|
+
timestamp: new Date().toISOString(),
|
|
393
|
+
data: createMockUploadFile({
|
|
394
|
+
id: "file-1",
|
|
395
|
+
url: "https://example.com/file1.jpg",
|
|
396
|
+
}),
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
nodeType: "storage-output-v1",
|
|
400
|
+
nodeId: "storage-2",
|
|
401
|
+
timestamp: new Date().toISOString(),
|
|
402
|
+
data: createMockUploadFile({
|
|
403
|
+
id: "file-2",
|
|
404
|
+
url: "https://example.com/file2.jpg",
|
|
405
|
+
size: 2048,
|
|
406
|
+
}),
|
|
407
|
+
},
|
|
408
|
+
];
|
|
409
|
+
|
|
410
|
+
const result = yield* Effect.either(
|
|
411
|
+
getSingleOutputByType(outputs, isStorageOutput),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
expect(result._tag).toBe("Left");
|
|
415
|
+
if (result._tag === "Left") {
|
|
416
|
+
expect(result.left.code).toBe("MULTIPLE_OUTPUTS_FOUND");
|
|
417
|
+
}
|
|
418
|
+
}).pipe(Effect.runPromise));
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe("Flow Integration with Typed Outputs", () => {
|
|
422
|
+
it("should collect typed outputs from single output node", () =>
|
|
423
|
+
Effect.gen(function* () {
|
|
424
|
+
const inputNode = yield* createFlowNode({
|
|
425
|
+
id: "input-1",
|
|
426
|
+
name: "Input Node",
|
|
427
|
+
description: "Test input",
|
|
428
|
+
type: NodeType.input,
|
|
429
|
+
nodeTypeId: "streaming-input-v1",
|
|
430
|
+
inputSchema: z.object({ value: z.string() }),
|
|
431
|
+
outputSchema: z.object({ value: z.string() }),
|
|
432
|
+
run: ({ data }) =>
|
|
433
|
+
Effect.succeed({ type: "complete", data: { value: data.value } }),
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const storageNode = yield* createFlowNode({
|
|
437
|
+
id: "storage-1",
|
|
438
|
+
name: "Storage Node",
|
|
439
|
+
description: "Storage output",
|
|
440
|
+
type: NodeType.output,
|
|
441
|
+
nodeTypeId: "storage-output-v1",
|
|
442
|
+
inputSchema: z.object({ value: z.string() }),
|
|
443
|
+
outputSchema: z.custom<UploadFile>(),
|
|
444
|
+
run: () =>
|
|
445
|
+
Effect.succeed({
|
|
446
|
+
type: "complete",
|
|
447
|
+
data: createMockUploadFile({
|
|
448
|
+
id: "file-123",
|
|
449
|
+
url: "https://example.com/output.jpg",
|
|
450
|
+
}),
|
|
451
|
+
}),
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const flow = yield* createFlow({
|
|
455
|
+
flowId: "typed-output-flow",
|
|
456
|
+
name: "Typed Output Flow",
|
|
457
|
+
inputSchema: z.object({ value: z.string() }),
|
|
458
|
+
outputSchema: z.custom<UploadFile>(),
|
|
459
|
+
nodes: {
|
|
460
|
+
"input-1": inputNode,
|
|
461
|
+
"storage-1": storageNode,
|
|
462
|
+
},
|
|
463
|
+
edges: [{ source: "input-1", target: "storage-1" }],
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const result = yield* flow.run({
|
|
467
|
+
inputs: { "input-1": { value: "test" } },
|
|
468
|
+
storageId: "test-storage",
|
|
469
|
+
jobId: "test-job",
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
expect(result.type).toBe("completed");
|
|
473
|
+
if (result.type === "completed") {
|
|
474
|
+
// Check typed outputs array
|
|
475
|
+
expect(result.outputs).toBeDefined();
|
|
476
|
+
expect(Array.isArray(result.outputs)).toBe(true);
|
|
477
|
+
expect(result.outputs?.length).toBe(1);
|
|
478
|
+
|
|
479
|
+
const output = result.outputs?.[0];
|
|
480
|
+
expect(output?.nodeType).toBe("storage-output-v1");
|
|
481
|
+
expect(output?.nodeId).toBe("storage-1");
|
|
482
|
+
|
|
483
|
+
// Automatic narrowing in switch
|
|
484
|
+
if (output) {
|
|
485
|
+
switch (output.nodeType) {
|
|
486
|
+
case "storage-output-v1":
|
|
487
|
+
expect(output.data.url).toBe("https://example.com/output.jpg");
|
|
488
|
+
expect(output.data.id).toBe("file-123");
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}).pipe(Effect.runPromise));
|
|
494
|
+
|
|
495
|
+
it("should collect typed outputs from multiple output nodes", () =>
|
|
496
|
+
Effect.gen(function* () {
|
|
497
|
+
const inputNode = yield* createFlowNode({
|
|
498
|
+
id: "input-1",
|
|
499
|
+
name: "Input Node",
|
|
500
|
+
description: "Test input",
|
|
501
|
+
type: NodeType.input,
|
|
502
|
+
nodeTypeId: "streaming-input-v1",
|
|
503
|
+
inputSchema: z.object({ value: z.string() }),
|
|
504
|
+
outputSchema: z.object({ value: z.string() }),
|
|
505
|
+
run: ({ data }) =>
|
|
506
|
+
Effect.succeed({ type: "complete", data: { value: data.value } }),
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
const storage1 = yield* createFlowNode({
|
|
510
|
+
id: "storage-1",
|
|
511
|
+
name: "Storage 1",
|
|
512
|
+
description: "First storage",
|
|
513
|
+
type: NodeType.output,
|
|
514
|
+
nodeTypeId: "storage-output-v1",
|
|
515
|
+
inputSchema: z.object({ value: z.string() }),
|
|
516
|
+
outputSchema: z.custom<UploadFile>(),
|
|
517
|
+
run: () =>
|
|
518
|
+
Effect.succeed({
|
|
519
|
+
type: "complete",
|
|
520
|
+
data: createMockUploadFile({
|
|
521
|
+
id: "file-1",
|
|
522
|
+
url: "https://example.com/output1.jpg",
|
|
523
|
+
}),
|
|
524
|
+
}),
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const storage2 = yield* createFlowNode({
|
|
528
|
+
id: "storage-2",
|
|
529
|
+
name: "Storage 2",
|
|
530
|
+
description: "Second storage",
|
|
531
|
+
type: NodeType.output,
|
|
532
|
+
nodeTypeId: "storage-output-v1",
|
|
533
|
+
inputSchema: z.object({ value: z.string() }),
|
|
534
|
+
outputSchema: z.custom<UploadFile>(),
|
|
535
|
+
run: () =>
|
|
536
|
+
Effect.succeed({
|
|
537
|
+
type: "complete",
|
|
538
|
+
data: createMockUploadFile({
|
|
539
|
+
id: "file-2",
|
|
540
|
+
url: "https://example.com/output2.png",
|
|
541
|
+
size: 2048,
|
|
542
|
+
}),
|
|
543
|
+
}),
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
const flow = yield* createFlow({
|
|
547
|
+
flowId: "multi-output-flow",
|
|
548
|
+
name: "Multi Output Flow",
|
|
549
|
+
inputSchema: z.object({ value: z.string() }),
|
|
550
|
+
outputSchema: z.custom<UploadFile>(),
|
|
551
|
+
nodes: {
|
|
552
|
+
"input-1": inputNode,
|
|
553
|
+
"storage-1": storage1,
|
|
554
|
+
"storage-2": storage2,
|
|
555
|
+
},
|
|
556
|
+
edges: [
|
|
557
|
+
{ source: "input-1", target: "storage-1" },
|
|
558
|
+
{ source: "input-1", target: "storage-2" },
|
|
559
|
+
],
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
const result = yield* flow.run({
|
|
563
|
+
inputs: { "input-1": { value: "test" } },
|
|
564
|
+
storageId: "test-storage",
|
|
565
|
+
jobId: "test-job",
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
expect(result.type).toBe("completed");
|
|
569
|
+
if (result.type === "completed") {
|
|
570
|
+
expect(result.outputs).toBeDefined();
|
|
571
|
+
expect(result.outputs?.length).toBe(2);
|
|
572
|
+
|
|
573
|
+
// Filter by type
|
|
574
|
+
const storageOutputs = filterOutputsByType(
|
|
575
|
+
result.outputs || [],
|
|
576
|
+
isStorageOutput,
|
|
577
|
+
);
|
|
578
|
+
expect(storageOutputs).toHaveLength(2);
|
|
579
|
+
|
|
580
|
+
// Check both outputs
|
|
581
|
+
const ids = storageOutputs.map((o) => o.data.id).sort();
|
|
582
|
+
expect(ids).toEqual(["file-1", "file-2"]);
|
|
583
|
+
}
|
|
584
|
+
}).pipe(Effect.runPromise));
|
|
585
|
+
|
|
586
|
+
it("should handle flows with mixed typed and untyped nodes", () =>
|
|
587
|
+
Effect.gen(function* () {
|
|
588
|
+
const inputNode = yield* createFlowNode({
|
|
589
|
+
id: "input-1",
|
|
590
|
+
name: "Input Node",
|
|
591
|
+
description: "Test input",
|
|
592
|
+
type: NodeType.input,
|
|
593
|
+
// No nodeTypeId - untyped
|
|
594
|
+
inputSchema: z.object({ value: z.string() }),
|
|
595
|
+
outputSchema: z.object({ value: z.string() }),
|
|
596
|
+
run: ({ data }) =>
|
|
597
|
+
Effect.succeed({ type: "complete", data: { value: data.value } }),
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const processNode = yield* createFlowNode({
|
|
601
|
+
id: "process-1",
|
|
602
|
+
name: "Process Node",
|
|
603
|
+
description: "Process data",
|
|
604
|
+
type: NodeType.process,
|
|
605
|
+
// No nodeTypeId - untyped
|
|
606
|
+
inputSchema: z.object({ value: z.string() }),
|
|
607
|
+
outputSchema: z.object({ value: z.string() }),
|
|
608
|
+
run: ({ data }) =>
|
|
609
|
+
Effect.succeed({
|
|
610
|
+
type: "complete",
|
|
611
|
+
data: { value: `processed-${data.value}` },
|
|
612
|
+
}),
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const storageNode = yield* createFlowNode({
|
|
616
|
+
id: "storage-1",
|
|
617
|
+
name: "Storage Node",
|
|
618
|
+
description: "Storage output",
|
|
619
|
+
type: NodeType.output,
|
|
620
|
+
nodeTypeId: "storage-output-v1", // Typed
|
|
621
|
+
inputSchema: z.object({ value: z.string() }),
|
|
622
|
+
outputSchema: z.custom<UploadFile>(),
|
|
623
|
+
run: () =>
|
|
624
|
+
Effect.succeed({
|
|
625
|
+
type: "complete",
|
|
626
|
+
data: createMockUploadFile({
|
|
627
|
+
id: "file-123",
|
|
628
|
+
url: "https://example.com/output.jpg",
|
|
629
|
+
}),
|
|
630
|
+
}),
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const flow = yield* createFlow({
|
|
634
|
+
flowId: "mixed-flow",
|
|
635
|
+
name: "Mixed Typed/Untyped Flow",
|
|
636
|
+
inputSchema: z.object({ value: z.string() }),
|
|
637
|
+
outputSchema: z.custom<UploadFile>(),
|
|
638
|
+
nodes: {
|
|
639
|
+
"input-1": inputNode,
|
|
640
|
+
"process-1": processNode,
|
|
641
|
+
"storage-1": storageNode,
|
|
642
|
+
},
|
|
643
|
+
edges: [
|
|
644
|
+
{ source: "input-1", target: "process-1" },
|
|
645
|
+
{ source: "process-1", target: "storage-1" },
|
|
646
|
+
],
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
const result = yield* flow.run({
|
|
650
|
+
inputs: { "input-1": { value: "test" } },
|
|
651
|
+
storageId: "test-storage",
|
|
652
|
+
jobId: "test-job",
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
expect(result.type).toBe("completed");
|
|
656
|
+
if (result.type === "completed") {
|
|
657
|
+
// Only typed output nodes should be in outputs array
|
|
658
|
+
expect(result.outputs).toBeDefined();
|
|
659
|
+
expect(result.outputs?.length).toBe(1);
|
|
660
|
+
|
|
661
|
+
const output = result.outputs?.[0];
|
|
662
|
+
expect(output?.nodeType).toBe("storage-output-v1");
|
|
663
|
+
expect(output?.nodeId).toBe("storage-1");
|
|
664
|
+
}
|
|
665
|
+
}).pipe(Effect.runPromise));
|
|
666
|
+
|
|
667
|
+
it("should handle flows with no output nodes (empty outputs)", () =>
|
|
668
|
+
Effect.gen(function* () {
|
|
669
|
+
const inputNode = yield* createFlowNode({
|
|
670
|
+
id: "input-1",
|
|
671
|
+
name: "Input Node",
|
|
672
|
+
description: "Test input",
|
|
673
|
+
type: NodeType.input,
|
|
674
|
+
inputSchema: z.object({ value: z.string() }),
|
|
675
|
+
outputSchema: z.object({ value: z.string() }),
|
|
676
|
+
run: ({ data }) =>
|
|
677
|
+
Effect.succeed({ type: "complete", data: { value: data.value } }),
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
const processNode = yield* createFlowNode({
|
|
681
|
+
id: "process-1",
|
|
682
|
+
name: "Process Node",
|
|
683
|
+
description: "Process data",
|
|
684
|
+
type: NodeType.process,
|
|
685
|
+
inputSchema: z.object({ value: z.string() }),
|
|
686
|
+
outputSchema: z.object({ value: z.string() }),
|
|
687
|
+
run: ({ data }) =>
|
|
688
|
+
Effect.succeed({
|
|
689
|
+
type: "complete",
|
|
690
|
+
data: { value: `processed-${data.value}` },
|
|
691
|
+
}),
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
const flow = yield* createFlow({
|
|
695
|
+
flowId: "no-output-flow",
|
|
696
|
+
name: "Flow with No Outputs",
|
|
697
|
+
inputSchema: z.object({ value: z.string() }),
|
|
698
|
+
outputSchema: z.object({ value: z.string() }),
|
|
699
|
+
nodes: {
|
|
700
|
+
"input-1": inputNode,
|
|
701
|
+
"process-1": processNode,
|
|
702
|
+
},
|
|
703
|
+
edges: [{ source: "input-1", target: "process-1" }],
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
const result = yield* flow.run({
|
|
707
|
+
inputs: { "input-1": { value: "test" } },
|
|
708
|
+
storageId: "test-storage",
|
|
709
|
+
jobId: "test-job",
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
expect(result.type).toBe("completed");
|
|
713
|
+
if (result.type === "completed") {
|
|
714
|
+
// No output nodes, so outputs should be empty array
|
|
715
|
+
expect(result.outputs).toBeDefined();
|
|
716
|
+
expect(result.outputs?.length).toBe(0);
|
|
717
|
+
}
|
|
718
|
+
}).pipe(Effect.runPromise));
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
describe("Backward Compatibility", () => {
|
|
722
|
+
it("should support legacy flows without nodeTypeId", () =>
|
|
723
|
+
Effect.gen(function* () {
|
|
724
|
+
const inputNode = yield* createFlowNode({
|
|
725
|
+
id: "input-1",
|
|
726
|
+
name: "Input Node",
|
|
727
|
+
description: "Legacy input",
|
|
728
|
+
type: NodeType.input,
|
|
729
|
+
inputSchema: z.object({ value: z.string() }),
|
|
730
|
+
outputSchema: z.object({ value: z.string() }),
|
|
731
|
+
run: ({ data }) =>
|
|
732
|
+
Effect.succeed({ type: "complete", data: { value: data.value } }),
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
const outputNode = yield* createFlowNode({
|
|
736
|
+
id: "output-1",
|
|
737
|
+
name: "Output Node",
|
|
738
|
+
description: "Legacy output",
|
|
739
|
+
type: NodeType.output,
|
|
740
|
+
inputSchema: z.object({ value: z.string() }),
|
|
741
|
+
outputSchema: z.object({ result: z.string() }),
|
|
742
|
+
run: ({ data }) =>
|
|
743
|
+
Effect.succeed({
|
|
744
|
+
type: "complete",
|
|
745
|
+
data: { result: `result-${data.value}` },
|
|
746
|
+
}),
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
const flow = yield* createFlow({
|
|
750
|
+
flowId: "legacy-flow",
|
|
751
|
+
name: "Legacy Flow",
|
|
752
|
+
inputSchema: z.object({ value: z.string() }),
|
|
753
|
+
outputSchema: z.object({ result: z.string() }),
|
|
754
|
+
nodes: {
|
|
755
|
+
"input-1": inputNode,
|
|
756
|
+
"output-1": outputNode,
|
|
757
|
+
},
|
|
758
|
+
edges: [{ source: "input-1", target: "output-1" }],
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
const result = yield* flow.run({
|
|
762
|
+
inputs: { "input-1": { value: "test" } },
|
|
763
|
+
storageId: "test-storage",
|
|
764
|
+
jobId: "test-job",
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
expect(result.type).toBe("completed");
|
|
768
|
+
if (result.type === "completed") {
|
|
769
|
+
// Legacy result field still works
|
|
770
|
+
expect(result.result["output-1"]).toEqual({ result: "result-test" });
|
|
771
|
+
|
|
772
|
+
// Typed outputs may be empty or undefined for legacy nodes
|
|
773
|
+
expect(result.outputs).toBeDefined();
|
|
774
|
+
}
|
|
775
|
+
}).pipe(Effect.runPromise));
|
|
776
|
+
|
|
777
|
+
it("should support existing type guards on untyped outputs", () => {
|
|
778
|
+
const untypedOutput: TypedOutput = {
|
|
779
|
+
nodeId: "untyped-1",
|
|
780
|
+
timestamp: new Date().toISOString(),
|
|
781
|
+
data: {
|
|
782
|
+
id: "file-123",
|
|
783
|
+
name: "test.jpg",
|
|
784
|
+
size: 1024,
|
|
785
|
+
mimeType: "image/jpeg",
|
|
786
|
+
url: "https://example.com/test.jpg",
|
|
787
|
+
bucket: "uploads",
|
|
788
|
+
key: "test.jpg",
|
|
789
|
+
storageId: "storage-1",
|
|
790
|
+
createdAt: new Date(),
|
|
791
|
+
updatedAt: new Date(),
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
// Type guards should work even without nodeType
|
|
796
|
+
expect(isStorageOutput(untypedOutput)).toBe(false); // No nodeType, so returns false
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
});
|