diff --git a/.gitignore b/.gitignore index 21fd20c..35d5ed2 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ coverage/ *.temp .direnv .env + +# Embedded repo (nested clone โ€” not a submodule) +jam-nodes/ diff --git a/README.md b/README.md index 7409186..4ccb0bf 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ npm install @jam-nodes/core @jam-nodes/nodes zod ## Quick Start ```typescript -import { NodeRegistry, defineNode, ExecutionContext } from '@jam-nodes/core'; -import { conditionalNode, endNode, builtInNodes } from '@jam-nodes/nodes'; -import { z } from 'zod'; +import { NodeRegistry, defineNode, ExecutionContext } from "@jam-nodes/core"; +import { conditionalNode, endNode, builtInNodes } from "@jam-nodes/nodes"; +import { z } from "zod"; // Create a registry and register built-in nodes const registry = new NodeRegistry(); @@ -29,10 +29,10 @@ registry.registerAll(builtInNodes); // Define a custom node const greetNode = defineNode({ - type: 'greet', - name: 'Greet', - description: 'Generate a greeting message', - category: 'action', + type: "greet", + name: "Greet", + description: "Generate a greeting message", + category: "action", inputSchema: z.object({ name: z.string(), }), @@ -49,11 +49,11 @@ const greetNode = defineNode({ registry.register(greetNode); // Execute a node -const context = new ExecutionContext({ userName: 'World' }); -const executor = registry.getExecutor('greet'); +const context = new ExecutionContext({ userName: "World" }); +const executor = registry.getExecutor("greet"); const result = await executor( - { name: context.interpolate('{{userName}}') }, - context.toNodeContext('user-123', 'workflow-456') + { name: context.interpolate("{{userName}}") }, + context.toNodeContext("user-123", "workflow-456"), ); console.log(result.output?.message); // "Hello, World!" @@ -62,14 +62,14 @@ console.log(result.output?.message); // "Hello, World!" ## Creating Custom Nodes ```typescript -import { defineNode } from '@jam-nodes/core'; -import { z } from 'zod'; +import { defineNode } from "@jam-nodes/core"; +import { z } from "zod"; export const myNode = defineNode({ - type: 'my_custom_node', - name: 'My Custom Node', - description: 'Does something awesome', - category: 'action', // 'action' | 'logic' | 'integration' | 'transform' + type: "my_custom_node", + name: "My Custom Node", + description: "Does something awesome", + category: "action", // 'action' | 'logic' | 'integration' | 'transform' inputSchema: z.object({ input1: z.string(), @@ -87,7 +87,7 @@ export const myNode = defineNode({ executor: async (input, context) => { // Access workflow variables - const previousData = context.resolveNestedPath('someNode.output'); + const previousData = context.resolveNestedPath("someNode.output"); // Your logic here const result = `Processed: ${input.input1}`; @@ -97,8 +97,8 @@ export const myNode = defineNode({ output: { result }, // Optional: send notification notification: { - title: 'Node Complete', - message: 'Processing finished', + title: "Node Complete", + message: "Processing finished", }, }; }, @@ -108,15 +108,18 @@ export const myNode = defineNode({ ## Built-in Nodes ### Logic + - **conditional** - Branch workflow based on conditions - **end** - Mark end of workflow branch - **delay** - Wait for specified duration ### Transform + - **map** - Extract property from array items - **filter** - Filter array based on conditions ### Examples + - **http_request** - Make HTTP requests to external APIs ## Variable Interpolation @@ -125,22 +128,20 @@ The `ExecutionContext` supports powerful variable interpolation: ```typescript const ctx = new ExecutionContext({ - user: { name: 'Alice', email: 'alice@example.com' }, + user: { name: "Alice", email: "alice@example.com" }, items: [1, 2, 3], }); // Simple interpolation -ctx.interpolate('Hello {{user.name}}'); // "Hello Alice" +ctx.interpolate("Hello {{user.name}}"); // "Hello Alice" // Direct value (returns actual type) -ctx.interpolate('{{items}}'); // [1, 2, 3] +ctx.interpolate("{{items}}"); // [1, 2, 3] // JSONPath -ctx.evaluateJsonPath('$.user.email'); // "alice@example.com" +ctx.evaluateJsonPath("$.user.email"); // "alice@example.com" ``` ## License -also we have a github with 668 contributions which we will open source more of in the coming days - jia and mal - MIT diff --git a/package-lock.json b/package-lock.json index 36b2561..888c928 100644 --- a/package-lock.json +++ b/package-lock.json @@ -925,6 +925,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1233,6 +1234,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1908,6 +1910,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2247,6 +2250,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -2589,6 +2593,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2613,6 +2618,7 @@ "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 10.16.0" } @@ -3208,6 +3214,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3429,6 +3436,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3441,6 +3449,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3454,6 +3463,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -4015,6 +4025,7 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -4121,6 +4132,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4364,6 +4376,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -4379,7 +4392,7 @@ }, "packages/core": { "name": "@jam-nodes/core", - "version": "0.2.4", + "version": "0.2.5", "license": "MIT", "dependencies": { "jsonpath-plus": "^10.0.0" @@ -4433,7 +4446,7 @@ }, "packages/nodes": { "name": "@jam-nodes/nodes", - "version": "0.2.4", + "version": "0.2.5", "license": "MIT", "dependencies": { "@jam-nodes/core": "^0.2.1" @@ -4448,7 +4461,7 @@ }, "packages/playground": { "name": "@jam-nodes/playground", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { "@jam-nodes/core": "*", diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..4ccb0bf --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,147 @@ +# jam-nodes + +Extensible workflow node framework for building automation pipelines. Define, register, and execute typed nodes with Zod validation. + +๐Ÿ“š **[Documentation](https://docs.spreadjam.com)** ยท ๐ŸŽฎ **[Playground](https://docs.spreadjam.com/playground/overview)** + +## Packages + +- **[@jam-nodes/core](./packages/core)** - Core framework with types, registry, and execution context +- **[@jam-nodes/nodes](./packages/nodes)** - Built-in nodes (conditional, delay, filter, map, http-request) +- **[@jam-nodes/playground](./packages/playground)** - CLI tool for testing nodes interactively + +## Installation + +```bash +npm install @jam-nodes/core @jam-nodes/nodes zod +``` + +## Quick Start + +```typescript +import { NodeRegistry, defineNode, ExecutionContext } from "@jam-nodes/core"; +import { conditionalNode, endNode, builtInNodes } from "@jam-nodes/nodes"; +import { z } from "zod"; + +// Create a registry and register built-in nodes +const registry = new NodeRegistry(); +registry.registerAll(builtInNodes); + +// Define a custom node +const greetNode = defineNode({ + type: "greet", + name: "Greet", + description: "Generate a greeting message", + category: "action", + inputSchema: z.object({ + name: z.string(), + }), + outputSchema: z.object({ + message: z.string(), + }), + executor: async (input) => ({ + success: true, + output: { message: `Hello, ${input.name}!` }, + }), +}); + +// Register custom node +registry.register(greetNode); + +// Execute a node +const context = new ExecutionContext({ userName: "World" }); +const executor = registry.getExecutor("greet"); +const result = await executor( + { name: context.interpolate("{{userName}}") }, + context.toNodeContext("user-123", "workflow-456"), +); + +console.log(result.output?.message); // "Hello, World!" +``` + +## Creating Custom Nodes + +```typescript +import { defineNode } from "@jam-nodes/core"; +import { z } from "zod"; + +export const myNode = defineNode({ + type: "my_custom_node", + name: "My Custom Node", + description: "Does something awesome", + category: "action", // 'action' | 'logic' | 'integration' | 'transform' + + inputSchema: z.object({ + input1: z.string(), + input2: z.number().optional(), + }), + + outputSchema: z.object({ + result: z.string(), + }), + + capabilities: { + supportsRerun: true, + supportsCancel: true, + }, + + executor: async (input, context) => { + // Access workflow variables + const previousData = context.resolveNestedPath("someNode.output"); + + // Your logic here + const result = `Processed: ${input.input1}`; + + return { + success: true, + output: { result }, + // Optional: send notification + notification: { + title: "Node Complete", + message: "Processing finished", + }, + }; + }, +}); +``` + +## Built-in Nodes + +### Logic + +- **conditional** - Branch workflow based on conditions +- **end** - Mark end of workflow branch +- **delay** - Wait for specified duration + +### Transform + +- **map** - Extract property from array items +- **filter** - Filter array based on conditions + +### Examples + +- **http_request** - Make HTTP requests to external APIs + +## Variable Interpolation + +The `ExecutionContext` supports powerful variable interpolation: + +```typescript +const ctx = new ExecutionContext({ + user: { name: "Alice", email: "alice@example.com" }, + items: [1, 2, 3], +}); + +// Simple interpolation +ctx.interpolate("Hello {{user.name}}"); // "Hello Alice" + +// Direct value (returns actual type) +ctx.interpolate("{{items}}"); // [1, 2, 3] + +// JSONPath +ctx.evaluateJsonPath("$.user.email"); // "alice@example.com" +``` + +## License + +MIT diff --git a/packages/core/package.json b/packages/core/package.json index f9cf384..f0ae542 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -3,6 +3,7 @@ "version": "0.2.5", "description": "Core framework for building workflow nodes with type-safe executors and Zod validation", "type": "module", + "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/packages/nodes/README.md b/packages/nodes/README.md new file mode 100644 index 0000000..4ccb0bf --- /dev/null +++ b/packages/nodes/README.md @@ -0,0 +1,147 @@ +# jam-nodes + +Extensible workflow node framework for building automation pipelines. Define, register, and execute typed nodes with Zod validation. + +๐Ÿ“š **[Documentation](https://docs.spreadjam.com)** ยท ๐ŸŽฎ **[Playground](https://docs.spreadjam.com/playground/overview)** + +## Packages + +- **[@jam-nodes/core](./packages/core)** - Core framework with types, registry, and execution context +- **[@jam-nodes/nodes](./packages/nodes)** - Built-in nodes (conditional, delay, filter, map, http-request) +- **[@jam-nodes/playground](./packages/playground)** - CLI tool for testing nodes interactively + +## Installation + +```bash +npm install @jam-nodes/core @jam-nodes/nodes zod +``` + +## Quick Start + +```typescript +import { NodeRegistry, defineNode, ExecutionContext } from "@jam-nodes/core"; +import { conditionalNode, endNode, builtInNodes } from "@jam-nodes/nodes"; +import { z } from "zod"; + +// Create a registry and register built-in nodes +const registry = new NodeRegistry(); +registry.registerAll(builtInNodes); + +// Define a custom node +const greetNode = defineNode({ + type: "greet", + name: "Greet", + description: "Generate a greeting message", + category: "action", + inputSchema: z.object({ + name: z.string(), + }), + outputSchema: z.object({ + message: z.string(), + }), + executor: async (input) => ({ + success: true, + output: { message: `Hello, ${input.name}!` }, + }), +}); + +// Register custom node +registry.register(greetNode); + +// Execute a node +const context = new ExecutionContext({ userName: "World" }); +const executor = registry.getExecutor("greet"); +const result = await executor( + { name: context.interpolate("{{userName}}") }, + context.toNodeContext("user-123", "workflow-456"), +); + +console.log(result.output?.message); // "Hello, World!" +``` + +## Creating Custom Nodes + +```typescript +import { defineNode } from "@jam-nodes/core"; +import { z } from "zod"; + +export const myNode = defineNode({ + type: "my_custom_node", + name: "My Custom Node", + description: "Does something awesome", + category: "action", // 'action' | 'logic' | 'integration' | 'transform' + + inputSchema: z.object({ + input1: z.string(), + input2: z.number().optional(), + }), + + outputSchema: z.object({ + result: z.string(), + }), + + capabilities: { + supportsRerun: true, + supportsCancel: true, + }, + + executor: async (input, context) => { + // Access workflow variables + const previousData = context.resolveNestedPath("someNode.output"); + + // Your logic here + const result = `Processed: ${input.input1}`; + + return { + success: true, + output: { result }, + // Optional: send notification + notification: { + title: "Node Complete", + message: "Processing finished", + }, + }; + }, +}); +``` + +## Built-in Nodes + +### Logic + +- **conditional** - Branch workflow based on conditions +- **end** - Mark end of workflow branch +- **delay** - Wait for specified duration + +### Transform + +- **map** - Extract property from array items +- **filter** - Filter array based on conditions + +### Examples + +- **http_request** - Make HTTP requests to external APIs + +## Variable Interpolation + +The `ExecutionContext` supports powerful variable interpolation: + +```typescript +const ctx = new ExecutionContext({ + user: { name: "Alice", email: "alice@example.com" }, + items: [1, 2, 3], +}); + +// Simple interpolation +ctx.interpolate("Hello {{user.name}}"); // "Hello Alice" + +// Direct value (returns actual type) +ctx.interpolate("{{items}}"); // [1, 2, 3] + +// JSONPath +ctx.evaluateJsonPath("$.user.email"); // "alice@example.com" +``` + +## License + +MIT diff --git a/packages/nodes/package.json b/packages/nodes/package.json index 30acf69..fddccc6 100644 --- a/packages/nodes/package.json +++ b/packages/nodes/package.json @@ -3,6 +3,7 @@ "version": "0.2.5", "description": "Built-in workflow nodes for jam-nodes framework", "type": "module", + "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/packages/nodes/src/integrations/dataforseo/keyword-research.ts b/packages/nodes/src/integrations/dataforseo/keyword-research.ts index 2bb2059..1707b73 100644 --- a/packages/nodes/src/integrations/dataforseo/keyword-research.ts +++ b/packages/nodes/src/integrations/dataforseo/keyword-research.ts @@ -2,15 +2,10 @@ import { z } from 'zod'; import { defineNode } from '@jam-nodes/core'; import { fetchWithRetry } from '../../utils/http.js'; -// ============================================================================= // Constants -// ============================================================================= - const DATAFORSEO_BASE_URL = 'https://api.dataforseo.com/v3'; -// ============================================================================= // Types -// ============================================================================= interface DataForSEOKeywordIdea { keyword: string; @@ -36,9 +31,7 @@ interface DataForSEOResponse { }>; } -// ============================================================================= // Schemas -// ============================================================================= export const SeoKeywordResearchInputSchema = z.object({ /** Seed keywords to research */ @@ -58,7 +51,7 @@ export const SeoKeywordResearchOutputSchema = z.object({ keyword: z.string(), searchVolume: z.number(), keywordDifficulty: z.number(), - cpc: z.string(), + cpc: z.number(), searchIntent: z.enum(['informational', 'commercial', 'navigational', 'transactional']), })), totalResearched: z.number(), @@ -130,9 +123,7 @@ function normalizeSearchIntent(intent: string | undefined): 'informational' | 'c return 'informational'; } -// ============================================================================= // Node Definition -// ============================================================================= /** * SEO Keyword Research Node @@ -197,7 +188,7 @@ export const seoKeywordResearchNode = defineNode({ keyword: kw.keyword, searchVolume: kw.keyword_info?.search_volume || 0, keywordDifficulty: kw.keyword_properties?.keyword_difficulty || 0, - cpc: (kw.keyword_info?.cpc || 0).toString(), + cpc: kw.keyword_info?.cpc || 0, searchIntent: normalizeSearchIntent(kw.search_intent_info?.main_intent), }); } diff --git a/packages/nodes/src/integrations/dataforseo/seo-audit.ts b/packages/nodes/src/integrations/dataforseo/seo-audit.ts index d00a899..7611757 100644 --- a/packages/nodes/src/integrations/dataforseo/seo-audit.ts +++ b/packages/nodes/src/integrations/dataforseo/seo-audit.ts @@ -339,9 +339,12 @@ export const seoAuditNode = defineNode({ let isPassed: boolean; if (POSITIVE_CHECKS.has(checkId)) { + // Positive checks: passed=true means the good thing IS present (e.g. is_https) isPassed = checkData.passed; } else { - isPassed = checkData.passed; + // Normal checks: passed=true means the problem is ABSENT (i.e. the page is clean) + // DataForSEO sets passed=true to mean "no issue found", so invert for our tracking + isPassed = !checkData.passed; } if (isPassed) { diff --git a/packages/nodes/src/transform/filter.ts b/packages/nodes/src/transform/filter.ts index cb3e7aa..24ae9a1 100644 --- a/packages/nodes/src/transform/filter.ts +++ b/packages/nodes/src/transform/filter.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { defineNode } from '@jam-nodes/core'; +import { resolvePath } from '../utils/resolve-path.js'; /** * Filter operator schema @@ -44,28 +45,6 @@ export const FilterOutputSchema = z.object({ export type FilterOutput = z.infer; -/** - * Resolve a nested path on an object - */ -function resolvePath(obj: unknown, path: string): unknown { - // Empty path means use the item itself - if (!path) { - return obj; - } - - const parts = path.split('.'); - let current: unknown = obj; - - for (const part of parts) { - if (current === null || current === undefined) { - return undefined; - } - current = (current as Record)[part]; - } - - return current; -} - /** * Evaluate filter condition */ diff --git a/packages/nodes/src/transform/map.ts b/packages/nodes/src/transform/map.ts index c169fad..37592ef 100644 --- a/packages/nodes/src/transform/map.ts +++ b/packages/nodes/src/transform/map.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { defineNode } from '@jam-nodes/core'; +import { resolvePath } from '../utils/resolve-path.js'; /** * Input schema for map node @@ -23,34 +24,6 @@ export const MapOutputSchema = z.object({ export type MapOutput = z.infer; -/** - * Resolve a nested path on an object - */ -function resolvePath(obj: unknown, path: string): unknown { - const parts = path.split('.'); - let current: unknown = obj; - - for (const part of parts) { - if (current === null || current === undefined) { - return undefined; - } - - // Handle array access like "[0]" - const arrayMatch = part.match(/^\[(\d+)\]$/); - if (arrayMatch) { - if (Array.isArray(current)) { - current = current[parseInt(arrayMatch[1]!, 10)]; - } else { - return undefined; - } - } else { - current = (current as Record)[part]; - } - } - - return current; -} - /** * Map node - extract a property from each item in an array. * diff --git a/packages/nodes/src/utils/http.ts b/packages/nodes/src/utils/http.ts index 702dcb6..e389c07 100644 --- a/packages/nodes/src/utils/http.ts +++ b/packages/nodes/src/utils/http.ts @@ -105,6 +105,14 @@ export async function fetchWithRetry( await sleep(delayMs); continue; } + + // All retries exhausted โ€” throw instead of returning the 429 response + const errorText = await response.text(); + throw new FetchRetryError( + `Rate limit exceeded after ${maxRetries} attempts`, + 429, + errorText + ); } // Retry on server errors diff --git a/packages/nodes/src/utils/index.ts b/packages/nodes/src/utils/index.ts index 3064450..9aa470e 100644 --- a/packages/nodes/src/utils/index.ts +++ b/packages/nodes/src/utils/index.ts @@ -5,3 +5,5 @@ export { FetchRetryError, type FetchWithRetryOptions, } from './http.js'; + +export { resolvePath } from './resolve-path.js'; diff --git a/packages/nodes/src/utils/resolve-path.ts b/packages/nodes/src/utils/resolve-path.ts new file mode 100644 index 0000000..b4b4d7c --- /dev/null +++ b/packages/nodes/src/utils/resolve-path.ts @@ -0,0 +1,61 @@ +/** + * Shared path resolution utility for transform nodes. + * + * Resolves a dot-notation path string against an arbitrary object. + * This mirrors the same logic as `ExecutionContext.resolveNestedPath`, + * but operates on any object rather than the workflow variable store. + * + * Supported syntax: + * - Dot notation: "contact.email" + * - Keyed array access: "contacts[0].name" + * - Standalone array index: "[0].name" (when current value is an array) + * - Empty path returns the object itself + * + * @example + * resolvePath({ a: { b: [1, 2] } }, 'a.b[1]') // โ†’ 2 + * resolvePath([{ id: 1 }, { id: 2 }], '[0].id') // โ†’ 1 + * resolvePath({ score: 42 }, '') // โ†’ { score: 42 } + */ +export function resolvePath(obj: unknown, path: string): unknown { + // Empty path means use the value itself + if (!path) { + return obj; + } + + const parts = path.split('.'); + let current: unknown = obj; + + for (const part of parts) { + if (current === null || current === undefined) { + return undefined; + } + + // Handle keyed array access like "contacts[0]" + const keyedArrayMatch = part.match(/^(\w+)\[(\d+)\]$/); + if (keyedArrayMatch) { + const [, key, index] = keyedArrayMatch; + current = (current as Record)[key!]; + if (Array.isArray(current)) { + current = current[parseInt(index!, 10)]; + } else { + return undefined; + } + continue; + } + + // Handle standalone array index like "[0]" + const standaloneIndexMatch = part.match(/^\[(\d+)\]$/); + if (standaloneIndexMatch) { + if (Array.isArray(current)) { + current = current[parseInt(standaloneIndexMatch[1]!, 10)]; + } else { + return undefined; + } + continue; + } + + current = (current as Record)[part]; + } + + return current; +}