@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.
Files changed (97) hide show
  1. package/dist/{checksum-CtOagryS.mjs → checksum-BaO9w1gC.mjs} +2 -2
  2. package/dist/{checksum-CtOagryS.mjs.map → checksum-BaO9w1gC.mjs.map} +1 -1
  3. package/dist/{checksum-jmKtZ9W8.cjs → checksum-DXCv7Avr.cjs} +1 -1
  4. package/dist/errors/index.cjs +1 -1
  5. package/dist/errors/index.d.cts +1 -1
  6. package/dist/errors/index.d.mts +1 -1
  7. package/dist/errors/index.mjs +1 -1
  8. package/dist/flow/index.cjs +1 -1
  9. package/dist/flow/index.d.cts +5 -5
  10. package/dist/flow/index.d.mts +5 -5
  11. package/dist/flow/index.mjs +1 -1
  12. package/dist/flow-DhuIQwjv.mjs +2 -0
  13. package/dist/flow-DhuIQwjv.mjs.map +1 -0
  14. package/dist/flow-s_AlC4r5.cjs +1 -0
  15. package/dist/{index-Bi9YYid8.d.mts → index-3jSHmGwH.d.mts} +2 -2
  16. package/dist/{index-Bi9YYid8.d.mts.map → index-3jSHmGwH.d.mts.map} +1 -1
  17. package/dist/{index-4VDJDcWM.d.cts → index-5K4oXy67.d.cts} +822 -169
  18. package/dist/index-5K4oXy67.d.cts.map +1 -0
  19. package/dist/{index-RgOX4psL.d.mts → index-BB1v4Ynz.d.mts} +822 -169
  20. package/dist/index-BB1v4Ynz.d.mts.map +1 -0
  21. package/dist/{index-Cbf1OPLp.d.mts → index-Bu5i-gcV.d.mts} +2 -2
  22. package/dist/index-Bu5i-gcV.d.mts.map +1 -0
  23. package/dist/{index-De4wQJwR.d.cts → index-CHGBYDtr.d.cts} +2 -2
  24. package/dist/{index-De4wQJwR.d.cts.map → index-CHGBYDtr.d.cts.map} +1 -1
  25. package/dist/{index-qZ90PVNl.d.cts → index-T6MZvUlM.d.cts} +2 -2
  26. package/dist/{index-Cbf1OPLp.d.mts.map → index-T6MZvUlM.d.cts.map} +1 -1
  27. package/dist/index.cjs +1 -1
  28. package/dist/index.d.cts +5 -5
  29. package/dist/index.d.mts +5 -5
  30. package/dist/index.mjs +1 -1
  31. package/dist/{stream-limiter-D9rrsvAT.cjs → stream-limiter-BcTJAjs-.cjs} +1 -1
  32. package/dist/{stream-limiter-D9KSAaoY.mjs → stream-limiter-D1-sVS5i.mjs} +2 -2
  33. package/dist/{stream-limiter-D9KSAaoY.mjs.map → stream-limiter-D1-sVS5i.mjs.map} +1 -1
  34. package/dist/streams/index.cjs +1 -1
  35. package/dist/streams/index.d.cts +2 -2
  36. package/dist/streams/index.d.mts +2 -2
  37. package/dist/streams/index.mjs +1 -1
  38. package/dist/testing/index.cjs +1 -1
  39. package/dist/testing/index.d.cts +4 -4
  40. package/dist/testing/index.d.mts +4 -4
  41. package/dist/testing/index.mjs +1 -1
  42. package/dist/types/index.cjs +1 -1
  43. package/dist/types/index.d.cts +4 -4
  44. package/dist/types/index.d.mts +4 -4
  45. package/dist/types/index.mjs +1 -1
  46. package/dist/types-B-EckCWW.cjs +1 -0
  47. package/dist/types-CO-R4pFG.mjs +2 -0
  48. package/dist/types-CO-R4pFG.mjs.map +1 -0
  49. package/dist/upload/index.cjs +1 -1
  50. package/dist/upload/index.d.cts +4 -4
  51. package/dist/upload/index.d.mts +4 -4
  52. package/dist/upload/index.mjs +1 -1
  53. package/dist/{upload-D-eiOIVG.cjs → upload-BwXGQQ26.cjs} +1 -1
  54. package/dist/upload-C_Ew1NMF.mjs +2 -0
  55. package/dist/{upload-Yj5lrtZo.mjs.map → upload-C_Ew1NMF.mjs.map} +1 -1
  56. package/dist/{uploadista-error-B-n8Kfyh.cjs → uploadista-error-Blmj3lpk.cjs} +5 -1
  57. package/dist/{uploadista-error-DUWw6OqS.d.mts → uploadista-error-Cpn3uBLO.d.mts} +2 -2
  58. package/dist/uploadista-error-Cpn3uBLO.d.mts.map +1 -0
  59. package/dist/{uploadista-error-BQLhNZcY.d.cts → uploadista-error-DgdQnozn.d.cts} +2 -2
  60. package/dist/uploadista-error-DgdQnozn.d.cts.map +1 -0
  61. package/dist/{uploadista-error-Buscq-FR.mjs → uploadista-error-DhNBioWq.mjs} +5 -1
  62. package/dist/uploadista-error-DhNBioWq.mjs.map +1 -0
  63. package/dist/utils/index.cjs +1 -1
  64. package/dist/utils/index.d.cts +2 -2
  65. package/dist/utils/index.d.mts +2 -2
  66. package/dist/utils/index.mjs +1 -1
  67. package/dist/{utils-BWiu6lqv.mjs → utils-7gziergl.mjs} +2 -2
  68. package/dist/{utils-BWiu6lqv.mjs.map → utils-7gziergl.mjs.map} +1 -1
  69. package/dist/{utils-_StwBtxT.cjs → utils-C_STf6Wl.cjs} +1 -1
  70. package/package.json +3 -3
  71. package/src/errors/uploadista-error.ts +21 -1
  72. package/src/flow/event.ts +28 -4
  73. package/src/flow/flow-server.ts +43 -12
  74. package/src/flow/flow.ts +92 -13
  75. package/src/flow/index.ts +7 -0
  76. package/src/flow/node-types/index.ts +85 -0
  77. package/src/flow/node.ts +48 -6
  78. package/src/flow/nodes/input-node.ts +2 -0
  79. package/src/flow/nodes/storage-node.ts +2 -0
  80. package/src/flow/type-guards.ts +293 -0
  81. package/src/flow/type-registry.ts +345 -0
  82. package/src/flow/types/flow-job.ts +22 -6
  83. package/src/flow/types/flow-types.ts +152 -3
  84. package/tests/flow/type-system.test.ts +799 -0
  85. package/dist/flow-ChADffZ5.cjs +0 -1
  86. package/dist/flow-_J9-Dm_m.mjs +0 -2
  87. package/dist/flow-_J9-Dm_m.mjs.map +0 -1
  88. package/dist/index-4VDJDcWM.d.cts.map +0 -1
  89. package/dist/index-RgOX4psL.d.mts.map +0 -1
  90. package/dist/index-qZ90PVNl.d.cts.map +0 -1
  91. package/dist/types-BI_KmpTc.mjs +0 -2
  92. package/dist/types-BI_KmpTc.mjs.map +0 -1
  93. package/dist/types-f08UsX4E.cjs +0 -1
  94. package/dist/upload-Yj5lrtZo.mjs +0 -2
  95. package/dist/uploadista-error-BQLhNZcY.d.cts.map +0 -1
  96. package/dist/uploadista-error-Buscq-FR.mjs.map +0 -1
  97. 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
+ });