@ductape/sdk 0.1.151 → 0.1.153

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.
@@ -1666,7 +1666,7 @@ function createStepOutputProxy(pathPrefix = '') {
1666
1666
  * - Default delimiter is "" so template literals like `receipts/${ctx.input.orderId}.txt` become receipts/ORD-001.txt not receipts/ ORD-001 .txt.
1667
1667
  * - Recurses into plain objects and arrays; leaves other primitives unchanged.
1668
1668
  */
1669
- const OPERATOR_REF_IN_STRING_REGEX = /(\$Input\{[^}]+\}|\$Sequence\{main\}\{[^}]+\}\{[^}]*\}|\$Now)/g;
1669
+ const OPERATOR_REF_IN_STRING_REGEX = /(\$Input\{[^}]+\}|\$Sequence\{main\}\{[^}]+\}\{[^}]*\}|\$Now|\$Uuid)/g;
1670
1670
  function convertStringToConcatIfOperatorRefs(str, delimiter = '') {
1671
1671
  OPERATOR_REF_IN_STRING_REGEX.lastIndex = 0;
1672
1672
  const tokens = str.split(OPERATOR_REF_IN_STRING_REGEX);
@@ -1674,7 +1674,7 @@ function convertStringToConcatIfOperatorRefs(str, delimiter = '') {
1674
1674
  for (const t of tokens) {
1675
1675
  if (!t)
1676
1676
  continue;
1677
- const isOp = t.startsWith('$Input{') || t.startsWith('$Sequence{') || t === '$Now';
1677
+ const isOp = t.startsWith('$Input{') || t.startsWith('$Sequence{') || t === '$Now' || t === '$Uuid';
1678
1678
  if (isOp) {
1679
1679
  parts.push(t);
1680
1680
  }
@@ -1717,6 +1717,9 @@ function convertToOperatorInput(value, inputProxy, schemaKeys) {
1717
1717
  return out;
1718
1718
  }
1719
1719
  if (typeof value === 'string') {
1720
+ if (/^\$(?:Concat|Substring|Trim|Uppercase|Lowercase|Dateformat|Replace|Split|Join|Pick|Filter|Find|Size|Length|Add|Subtract)\s*[({]/.test(value)) {
1721
+ return value;
1722
+ }
1720
1723
  return convertStringToConcatIfOperatorRefs(value);
1721
1724
  }
1722
1725
  return value;
@@ -1725,19 +1728,28 @@ function convertToOperatorInput(value, inputProxy, schemaKeys) {
1725
1728
  * Recording context that captures step definitions during handler execution.
1726
1729
  * Uses operator strings ($Input{field}, $Sequence{main}{stepTag}{field}) per Ductape operator docs.
1727
1730
  */
1728
- /** Build a step condition from a branch override so the step only runs when the overridden step's output matches (e.g. $Step{validate}{valid} == true). */
1731
+ /** Build a condition matching the concrete output used to record a branch. */
1729
1732
  function buildConditionFromOverride(stepTag, override) {
1730
- if (override === null || override === undefined)
1731
- return '';
1732
- if (typeof override !== 'object' || Array.isArray(override)) {
1733
- return `$Step{${stepTag}}{value} == ${JSON.stringify(override)}`;
1734
- }
1735
1733
  const parts = [];
1736
- for (const [key, val] of Object.entries(override)) {
1737
- if (typeof val !== 'object' || val === null || Array.isArray(val)) {
1738
- parts.push(`$Step{${stepTag}}{${key}} == ${JSON.stringify(val)}`);
1734
+ const visit = (value, path) => {
1735
+ const reference = `$Step{${stepTag}}{${path || 'value'}}`;
1736
+ if (Array.isArray(value)) {
1737
+ parts.push(`$Step{${stepTag}}{${path ? `${path}.length` : 'length'}} == ${value.length}`);
1738
+ return;
1739
1739
  }
1740
- }
1740
+ if (value === null || value === undefined) {
1741
+ parts.push(`${reference} == null`);
1742
+ return;
1743
+ }
1744
+ if (typeof value !== 'object') {
1745
+ parts.push(`${reference} == ${JSON.stringify(value)}`);
1746
+ return;
1747
+ }
1748
+ for (const [key, nested] of Object.entries(value)) {
1749
+ visit(nested, path ? `${path}.${key}` : key);
1750
+ }
1751
+ };
1752
+ visit(override, '');
1741
1753
  return parts.length === 0 ? '' : parts.join(' && ');
1742
1754
  }
1743
1755
  /** Build a step condition from a scenario (recordInput) so the step only runs when feature input matches (e.g. $Input{type} == 'a'). */
@@ -1767,6 +1779,8 @@ class RecordingContext {
1767
1779
  /** When set (from recordScenarios), every step we push gets this condition so it only runs when input matches. */
1768
1780
  this._scenarioCondition = null;
1769
1781
  this._recordInput = null;
1782
+ /** Explicit portable control-flow condition inherited by steps in a branch. */
1783
+ this._portableCondition = null;
1770
1784
  this._featureTag = featureTag;
1771
1785
  this._inputSchemaKeys = featureInputSchema ? Object.keys(featureInputSchema) : [];
1772
1786
  this._stepResultOverrides = stepResultOverrides !== null && stepResultOverrides !== void 0 ? stepResultOverrides : {};
@@ -1818,10 +1832,68 @@ class RecordingContext {
1818
1832
  get now() {
1819
1833
  return '$Now';
1820
1834
  }
1835
+ conditionOperand(value) {
1836
+ const converted = convertToOperatorInput(value, this._inputProxy, this._inputSchemaKeys);
1837
+ if (typeof converted === 'string' && converted.startsWith('$'))
1838
+ return converted;
1839
+ return JSON.stringify(converted);
1840
+ }
1841
+ comparison(left, operator, inverseOperator, right) {
1842
+ const lhs = this.conditionOperand(left);
1843
+ const rhs = this.conditionOperand(right);
1844
+ return { expression: `${lhs} ${operator} ${rhs}`, inverse: `${lhs} ${inverseOperator} ${rhs}` };
1845
+ }
1846
+ get when() {
1847
+ return {
1848
+ eq: (left, right) => this.comparison(left, '==', '!=', right),
1849
+ ne: (left, right) => this.comparison(left, '!=', '==', right),
1850
+ gt: (left, right) => this.comparison(left, '>', '<=', right),
1851
+ gte: (left, right) => this.comparison(left, '>=', '<', right),
1852
+ lt: (left, right) => this.comparison(left, '<', '>=', right),
1853
+ lte: (left, right) => this.comparison(left, '<=', '>', right),
1854
+ truthy: (value) => this.comparison(value, '==', '!=', true),
1855
+ falsy: (value) => this.comparison(value, '==', '!=', false),
1856
+ and: (...conditions) => ({
1857
+ expression: conditions.map((condition) => `(${condition.expression})`).join(' && '),
1858
+ inverse: conditions.map((condition) => `(${condition.inverse})`).join(' || '),
1859
+ }),
1860
+ or: (...conditions) => ({
1861
+ expression: conditions.map((condition) => `(${condition.expression})`).join(' || '),
1862
+ inverse: conditions.map((condition) => `(${condition.inverse})`).join(' && '),
1863
+ }),
1864
+ };
1865
+ }
1866
+ async branch(condition, handlers) {
1867
+ if (!(condition === null || condition === void 0 ? void 0 : condition.expression) || !(condition === null || condition === void 0 ? void 0 : condition.inverse)) {
1868
+ throw new FeatureCompilationError(this._featureTag, '(branch)', 'ctx.branch requires a condition created by ctx.when.');
1869
+ }
1870
+ const parent = this._portableCondition;
1871
+ const scoped = (expression) => parent ? `(${parent}) && (${expression})` : expression;
1872
+ try {
1873
+ this._portableCondition = scoped(condition.expression);
1874
+ await handlers.then();
1875
+ if (handlers.else) {
1876
+ this._portableCondition = scoped(condition.inverse);
1877
+ await handlers.else();
1878
+ }
1879
+ }
1880
+ finally {
1881
+ this._portableCondition = parent;
1882
+ }
1883
+ }
1884
+ async each(items, handler) {
1885
+ if (!Array.isArray(items)) {
1886
+ throw new FeatureCompilationError(this._featureTag, '(loop)', 'ctx.each requires a concrete ctx.sampleInput array during compilation. Use ctx.functions for runtime-sized collection logic.');
1887
+ }
1888
+ for (let index = 0; index < items.length; index += 1) {
1889
+ await handler(items[index], index);
1890
+ }
1891
+ }
1821
1892
  /**
1822
1893
  * Record a step definition
1823
1894
  */
1824
1895
  async step(tag, handler, rollback, options) {
1896
+ var _a;
1825
1897
  this._currentStepTag = tag;
1826
1898
  this._stepOrder++;
1827
1899
  this._stepResultDependencies = new Set();
@@ -1880,6 +1952,16 @@ class RecordingContext {
1880
1952
  const condParts = [];
1881
1953
  if (this._scenarioCondition)
1882
1954
  condParts.push(`(${this._scenarioCondition})`);
1955
+ if (this._portableCondition) {
1956
+ condParts.push(`(${this._portableCondition})`);
1957
+ const conditionDependencies = [
1958
+ ...Array.from(this._portableCondition.matchAll(/\$Step\{([^}]+)\}/g), (match) => match[1]),
1959
+ ...Array.from(this._portableCondition.matchAll(/\$Sequence\{main\}\{([^}]+)\}/g), (match) => match[1]),
1960
+ ];
1961
+ if (conditionDependencies.length > 0) {
1962
+ step.depends_on = Array.from(new Set([...((_a = step.depends_on) !== null && _a !== void 0 ? _a : []), ...conditionDependencies]));
1963
+ }
1964
+ }
1883
1965
  if (this._pendingConditionFromOverride) {
1884
1966
  condParts.push(`(${this._pendingConditionFromOverride})`);
1885
1967
  this._pendingConditionFromOverride = null;
@@ -2212,18 +2294,27 @@ class RecordingContext {
2212
2294
  };
2213
2295
  }
2214
2296
  get transform() {
2297
+ const operator = (value) => {
2298
+ const converted = convertToOperatorInput(value, this._inputProxy, this._inputSchemaKeys);
2299
+ return typeof converted === 'string' && converted.startsWith('$') ? converted : null;
2300
+ };
2301
+ const operand = (value) => { var _a; return (_a = operator(value)) !== null && _a !== void 0 ? _a : JSON.stringify(value); };
2215
2302
  return {
2303
+ concat: (...parts) => `$Concat([${parts.map(operand).join(', ')}], "")`,
2216
2304
  size: (obj) => Object.keys(obj).length,
2217
2305
  length: (arr) => arr.length,
2218
2306
  parseJson: (str) => JSON.parse(str),
2219
2307
  stringify: (obj) => JSON.stringify(obj),
2220
- upper: (str) => str.toUpperCase(),
2221
- lower: (str) => str.toLowerCase(),
2222
- trim: (str) => str.trim(),
2308
+ upper: (str) => operator(str) ? `$Uppercase(${operand(str)})` : str.toUpperCase(),
2309
+ lower: (str) => operator(str) ? `$Lowercase(${operand(str)})` : str.toLowerCase(),
2310
+ trim: (str) => operator(str) ? `$Trim(${operand(str)})` : str.trim(),
2223
2311
  split: (str, separator) => str.split(separator),
2224
2312
  join: (arr, separator) => arr.join(separator),
2225
- now: () => Date.now(),
2226
- formatDate: (date, format) => new Date(date).toISOString(),
2313
+ now: () => '$Now',
2314
+ uuid: () => '$Uuid',
2315
+ replace: (str, search, replacement) => operator(str) ? `$Replace(${operand(str)}, ${JSON.stringify(search)}, ${JSON.stringify(replacement)})` : str.split(search).join(replacement),
2316
+ substring: (str, start, end) => operator(str) ? `$Substring(${operand(str)}, ${start}, ${end})` : str.substring(start, end),
2317
+ formatDate: (date, format) => operator(date) ? `$Dateformat(${operand(date)}, ${JSON.stringify(format)})` : new Date(date).toISOString(),
2227
2318
  };
2228
2319
  }
2229
2320
  // ==================== DATA REFERENCES ====================
@@ -2415,6 +2506,19 @@ class FeatureCompiler {
2415
2506
  constructor(options) {
2416
2507
  this.options = options;
2417
2508
  }
2509
+ validatePortableControlFlow() {
2510
+ const source = this.options.handler.toString();
2511
+ if (/\bDate\s*\.\s*now\s*\(/.test(source) || /\bMath\s*\.\s*random\s*\(/.test(source)) {
2512
+ throw new FeatureCompilationError(this.options.tag, '(handler)', 'Date.now() and Math.random() run while the Feature is being compiled and would become frozen literals. ' +
2513
+ 'Use ctx.transform.now() for the current runtime timestamp. Generate random or unique values through a portable function or reusable action.');
2514
+ }
2515
+ const containsNullishBranch = /\?\?/.test(source) ||
2516
+ /!==\s*null\s*&&[\s\S]{0,300}!==\s*void\s+0/.test(source);
2517
+ if (containsNullishBranch && !this.options.branchOverrides) {
2518
+ throw new FeatureCompilationError(this.options.tag, '(handler)', 'Nullish branching on a step result cannot be inferred safely. Provide branchOverrides for the step output that records this branch, ' +
2519
+ 'or use an explicit portable branch primitive. The Feature was not persisted.');
2520
+ }
2521
+ }
2418
2522
  /**
2419
2523
  * Compile the feature definition to JSON schema
2420
2524
  * Executes the handler with a recording context to capture step definitions
@@ -2469,6 +2573,7 @@ class FeatureCompiler {
2469
2573
  */
2470
2574
  async compileAsync() {
2471
2575
  var _a, _b, _c, _d, _e;
2576
+ this.validatePortableControlFlow();
2472
2577
  const scenarios = this.options.recordScenarios && this.options.recordScenarios.length > 0
2473
2578
  ? this.options.recordScenarios
2474
2579
  : [(_a = this.options.recordInput) !== null && _a !== void 0 ? _a : {}];