Skip to content

Commit 993b1c1

Browse files
committed
- Fix bug where Promise.all would wipe out previous variables
- Add retry for map - Steps.map - Add shared task options - Doc fixes
1 parent 9397aee commit 993b1c1

39 files changed

Lines changed: 793 additions & 156 deletions

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ TypeScript-to-ASL compiler for AWS Step Functions. Define workflows as typed asy
1111

1212
At my previous company we had dozens of Step Functions deployed with CDK. They were incredibly hard to read and maintain. The CDK approach defines a program by stringing together a tree of construct objects — `.next().next().next()` chains with raw JSONPath and `sfn.CustomState` workarounds. That's exactly the kind of work a compiler should handle.
1313

14-
SimpleSteps compiles typed async functions to ASL state machines. The compiler performs whole-program data flow analysis and derives all JSONPath expressions (`Parameters`, `ResultPath`, `InputPath`, `ResultSelector`, `OutputPath`) from variable usage. Service bindings are resolved at compile time, with CDK token substitution at synth time.
14+
SimpleSteps compiles typed async functions to ASL state machines. The compiler performs whole-program data flow analysis and derives all JSONPath expressions (`Parameters`, `ResultPath`, `InputPath`, `ResultSelector`) from variable usage. Service bindings are resolved at compile time, with CDK token substitution at synth time.
1515

1616
**Input:**
1717

@@ -135,8 +135,9 @@ npx simplesteps compile workflow.ts -o output/
135135
| `const x = { ... }` | Pass state |
136136
| `if/else`, `switch/case` | Choice state |
137137
| `while`, `do...while` | Choice + back-edge loop |
138-
| `for (const item of array)` | Map state (parallel) |
138+
| `for (const item of array)` | Map state (parallel, with closures) |
139139
| `await Steps.map(items, cb, opts?)` | Map state (results, closures, MaxConcurrency) |
140+
| `for (const item of Steps.items(arr, opts?))` | Map state (for...of + MaxConcurrency) |
140141
| `await Promise.all([...])` | Parallel state |
141142
| Deferred-await (`const p = call(); await p`) | Parallel state (auto-batched) |
142143
| `await Steps.delay({ seconds: 30 })` | Wait state |

docs/language-reference.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ Every TypeScript construct supported by SimpleSteps and its ASL mapping.
1010
| `const x = { ... }` | Pass |
1111
| `if/else`, `switch/case` | Choice |
1212
| `while`, `do...while` | Choice + back-edge loop |
13-
| `for (const item of array)` | Map (parallel) |
13+
| `for (const item of array)` | Map (parallel, with closures) |
1414
| `await Steps.map(items, callback, opts?)` | Map (with result capture, closures, MaxConcurrency) |
15+
| `for (const item of Steps.items(array, opts?))` | Map (for...of with MaxConcurrency + closures) |
1516
| `for (const item of Steps.sequential(array))` | Map (sequential, `MaxConcurrency: 1`) |
1617
| `await Promise.all([...])` | Parallel |
1718
| Deferred-await (`const p = call(); await p`) | Parallel (auto-batched) |
@@ -110,10 +111,21 @@ for (const item of input.items) {
110111
await processor.call(item);
111112
}
112113

114+
// Closures work in all iteration styles
115+
const config = await getConfig.call({ env: input.env });
116+
for (const item of input.items) {
117+
await processor.call({ item, prefix: config.prefix }); // config captured via ItemSelector
118+
}
119+
113120
// Sequential iteration
114121
for (const step of Steps.sequential(input.steps)) {
115122
await executor.call(step);
116123
}
124+
125+
// For-of with concurrency control
126+
for (const item of Steps.items(input.items, { maxConcurrency: 5 })) {
127+
await processor.call({ item });
128+
}
117129
```
118130

119131
### Steps.map()
@@ -145,7 +157,21 @@ await Steps.map(input.items, async (item) => {
145157
});
146158
```
147159

148-
Unlike `for...of`, `Steps.map()` supports closures over prior `await` results and can capture iteration results into a variable.
160+
All iteration styles (`for...of`, `Steps.map()`, `Steps.items()`, `Steps.sequential()`) support closures over prior `await` results. `Steps.map()` additionally captures iteration results into a variable.
161+
162+
### Steps.items()
163+
164+
`Steps.items()` wraps an array for use with `for...of`, adding MaxConcurrency control:
165+
166+
```typescript
167+
// for...of with concurrency limit + closures
168+
const config = await getConfig.call({ env: input.env });
169+
for (const item of Steps.items(input.items, { maxConcurrency: 5 })) {
170+
await processItem.call({ item, prefix: config.prefix });
171+
}
172+
```
173+
174+
Use `Steps.items()` when you prefer imperative `for...of` syntax but need concurrency control. Use `Steps.map()` when you need to collect iteration results.
149175

150176
### Parallel Execution
151177

@@ -241,7 +267,7 @@ One of SimpleSteps' key design goals: **you never write JSONPath or data flow fi
241267
| `ResultPath` | Variable assignment — `const x = await svc.call(...)` stores the result at `$.x` |
242268
| `InputPath` | Variable references in service call arguments — the compiler determines what data the state needs |
243269
| `ResultSelector` | Automatic `.Payload` extraction for Lambda results (so you access `result.field`, not `result.Payload.field`) |
244-
| `OutputPath` | Variable liveness analysisthe compiler prunes fields that are no longer needed downstream |
270+
| `OutputPath` | *Not yet implemented*planned optimization for payload size reduction via variable liveness analysis |
245271

246272
In CDK, you'd write `sfn.JsonPath.stringAt('$.order.total')` and manually set `outputPath: '$.Payload'`. In SimpleSteps, you write `order.total` and the compiler handles the rest.
247273

docs/limitations.md

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -115,33 +115,28 @@ Workaround for rest parameters: pass an explicit array parameter instead.
115115

116116
## Closures and Variable Capture
117117

118-
Map state iterations have isolated state — each iteration receives only its array element as input.
118+
Map state iterations have isolated state — each iteration receives only its array element as input. The compiler automatically detects outer-scope variables referenced inside a loop body and projects them into each iteration via ASL's `ItemSelector`.
119119

120-
**`Steps.map()` supports closures**prior `await` results are automatically projected into each iteration via ASL's `ItemSelector`:
120+
**All iteration styles support closures**`for...of`, `Steps.map()`, `Steps.items()`, and `Steps.sequential()` all capture outer `await` results automatically:
121121

122122
```typescript
123123
const config = await getConfig.call({ env: input.env });
124124

125-
// OK — Steps.map() captures `config` via ItemSelector
125+
// All of these capture `config` via ItemSelector:
126+
for (const item of input.items) {
127+
await processor.call({ key: config.prefix, item });
128+
}
129+
126130
await Steps.map(input.items, async (item) => {
127131
await processor.call({ key: config.prefix, item });
128132
});
129-
```
130-
131-
**`for...of` loops do NOT support closures** — each iteration cannot reference runtime variables from the outer scope:
132133

133-
```typescript
134-
const config = await getConfig.call({ env: input.env });
135-
136-
// NOT OK — for...of Map state can't access `config`
137-
for (const item of input.items) {
138-
await processor.call({ key: config.prefix, item }); // SS502
134+
for (const item of Steps.items(input.items, { maxConcurrency: 5 })) {
135+
await processor.call({ key: config.prefix, item });
139136
}
140137
```
141138

142-
Compile-time constants and service bindings are accessible in all Map iterations. Only runtime variables (service call results) are restricted in `for...of`.
143-
144-
Workaround for `for...of`: Use `Steps.map()` instead (which supports closures), use `Steps.sequential()` for sequential iteration, or restructure the data so each item carries what it needs.
139+
Compile-time constants and service bindings are also accessible in all Map iterations.
145140

146141
## Array Methods
147142

packages/core/src/compiler/cfg/cfgBuilder.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -747,16 +747,43 @@ function processForOfStatement(
747747
}
748748
}
749749

750+
// Detect Steps.sequential() / Steps.items() wrappers and unwrap to the real items expression
751+
let itemsExpression: ts.Expression = stmt.expression;
752+
let maxConcurrency: number | undefined;
753+
let retryExpression: ts.Expression | undefined;
754+
755+
if (ts.isCallExpression(stmt.expression)) {
756+
if (isStepsCall(stmt.expression, 'sequential')) {
757+
itemsExpression = stmt.expression.arguments[0];
758+
maxConcurrency = 1;
759+
} else if (isStepsCall(stmt.expression, 'items')) {
760+
itemsExpression = stmt.expression.arguments[0];
761+
const optionsArg = stmt.expression.arguments[1];
762+
if (optionsArg && ts.isObjectLiteralExpression(optionsArg)) {
763+
for (const prop of optionsArg.properties) {
764+
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue;
765+
if (prop.name.text === 'maxConcurrency' && ts.isNumericLiteral(prop.initializer)) {
766+
maxConcurrency = Number(prop.initializer.text);
767+
} else if (prop.name.text === 'retry') {
768+
retryExpression = prop.initializer;
769+
}
770+
}
771+
}
772+
}
773+
}
774+
750775
// Finalize current block with mapState terminator
751776
state.addBlock(currentBlockId, accumulated, {
752777
kind: 'mapState',
753778
expression: stmt,
754-
itemsExpression: stmt.expression,
779+
itemsExpression,
755780
iterVarName,
756781
iterVarSymbol,
757782
bodyBlock: bodyBlockId,
758783
exitBlock: exitBlockId,
759784
collectResults,
785+
...(maxConcurrency != null && { maxConcurrency }),
786+
...(retryExpression && { retryExpression }),
760787
});
761788

762789
// Process body — break/continue targets loop exit/body start
@@ -1404,13 +1431,16 @@ function tryExtractStepsMap(
14041431
return undefined;
14051432
}
14061433

1407-
// Extract maxConcurrency from options
1434+
// Extract maxConcurrency and retry from options
14081435
let maxConcurrency: number | undefined;
1436+
let retryExpression: ts.Expression | undefined;
14091437
if (optionsArg && ts.isObjectLiteralExpression(optionsArg)) {
14101438
for (const prop of optionsArg.properties) {
1411-
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) &&
1412-
prop.name.text === 'maxConcurrency' && ts.isNumericLiteral(prop.initializer)) {
1439+
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue;
1440+
if (prop.name.text === 'maxConcurrency' && ts.isNumericLiteral(prop.initializer)) {
14131441
maxConcurrency = Number(prop.initializer.text);
1442+
} else if (prop.name.text === 'retry') {
1443+
retryExpression = prop.initializer;
14141444
}
14151445
}
14161446
}
@@ -1438,6 +1468,7 @@ function tryExtractStepsMap(
14381468
maxConcurrency,
14391469
...(resultBindingName && { resultBindingName }),
14401470
...(resultSymbol && { resultSymbol }),
1471+
...(retryExpression && { retryExpression }),
14411472
});
14421473

14431474
// Process the callback body
@@ -1466,6 +1497,15 @@ function isStepsMapCall(call: ts.CallExpression): boolean {
14661497
prop.name.text === 'map';
14671498
}
14681499

1500+
/** Check if an expression is a call to Steps.<method>() */
1501+
function isStepsCall(expr: ts.Expression, method: string): boolean {
1502+
if (!ts.isCallExpression(expr)) return false;
1503+
if (!ts.isPropertyAccessExpression(expr.expression)) return false;
1504+
const prop = expr.expression;
1505+
return ts.isIdentifier(prop.expression) && prop.expression.text === 'Steps' &&
1506+
prop.name.text === method;
1507+
}
1508+
14691509
// ---------------------------------------------------------------------------
14701510
// Parallel branch classification
14711511
// ---------------------------------------------------------------------------

packages/core/src/compiler/cfg/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export interface MapStateTerminator {
6161
readonly resultBindingName?: string;
6262
/** Symbol of the result variable for registration in the variable resolver. */
6363
readonly resultSymbol?: ts.Symbol;
64+
/** Retry policy extracted from options (for Steps.map/items). */
65+
readonly retryExpression?: ts.Expression;
6466
}
6567

6668
export interface ReturnTerminator {

0 commit comments

Comments
 (0)