{"_id":"@brokenrubik/netsuite-sdk","_rev":"10-1f9c232352bd768196c116a31f2ed290","name":"@brokenrubik/netsuite-sdk","dist-tags":{"latest":"0.6.0"},"versions":{"0.1.0":{"name":"@brokenrubik/netsuite-sdk","version":"0.1.0","keywords":["netsuite","suitetalk","rest","api","sdk","oauth2","m2m","suiteql","restlet","erp"],"author":{"name":"Leandro","email":"leandro@brokenrubik.co"},"license":"ISC","_id":"@brokenrubik/netsuite-sdk@0.1.0","maintainers":[{"name":"brokenrubik","email":"team@brokenrubik.co"}],"dist":{"shasum":"c2d4e67e7a3c3999ca34bb713f419b90367babf1","tarball":"https://registry.npmjs.org/@brokenrubik/netsuite-sdk/-/netsuite-sdk-0.1.0.tgz","fileCount":8,"integrity":"sha512-+HXl9Auh12hQH7EIgLtQ+5Zo+hNJdXytAvLwnpWjkjRjmMdmZLiYj/ldWZnNv5ZeoU51J8OLaxDIV2bXrx4OPg==","signatures":[{"sig":"MEUCIQCbNslHmfk3o2XVLkUMNDwTtXNi/5XjuXOgKUt/SmBA3AIgJ7mtA/9IKnBi1x1/pYNOcOvPUA2lNSgeg2Zdy7gKy9c=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":358607},"main":"dist/cjs/index.js","type":"module","types":"dist/cjs/index.d.ts","module":"dist/esm/index.js","engines":{"node":">=18.0.0"},"exports":{".":{"import":{"types":"./dist/esm/index.d.ts","default":"./dist/esm/index.js"},"require":{"types":"./dist/cjs/index.d.ts","default":"./dist/cjs/index.js"}}},"gitHead":"15f30594f42aaf068a3fac41d4084f1e2c6bea34","scripts":{"lint":"eslint src/ test/","test":"vitest run","build":"tsup","test:unit":"vitest run test/unit","test:watch":"vitest","publish:major":"npm version major && npm publish --access public","publish:minor":"npm version minor && npm publish --access public","publish:patch":"npm version patch && npm publish --access public","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build","test:integration":"vitest run test/integration"},"_npmUser":{"name":"brokenrubik","email":"team@brokenrubik.co"},"_npmVersion":"11.9.0","description":"Production-ready NetSuite REST API client SDK with OAuth 2.0 M2M authentication, retry logic, RESTlet support, and SuiteQL.","directories":{},"_nodeVersion":"24.14.0","dependencies":{"jsonwebtoken":"^9.0.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","vitest":"^3.0.0","typescript":"^5.0.0","@types/node":"^20.0.0","@types/jsonwebtoken":"^9.0.0","@vitest/coverage-v8":"^3.2.4"},"_npmOperationalInternal":{"tmp":"tmp/netsuite-sdk_0.1.0_1775415544856_0.5924926700909834","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@brokenrubik/netsuite-sdk","version":"0.1.1","keywords":["netsuite","suitetalk","rest","api","sdk","oauth2","m2m","suiteql","restlet","erp"],"author":{"name":"Leandro","email":"leandro@brokenrubik.co"},"license":"ISC","_id":"@brokenrubik/netsuite-sdk@0.1.1","maintainers":[{"name":"brokenrubik","email":"team@brokenrubik.co"}],"dist":{"shasum":"20f9d323b732c5d165215b1f814da6d447514b9e","tarball":"https://registry.npmjs.org/@brokenrubik/netsuite-sdk/-/netsuite-sdk-0.1.1.tgz","fileCount":8,"integrity":"sha512-FPHDe7QT54L2c2NmaKwwiCKYH0+ve1tAuvBgDOxFWAsHU0pPLB1TqxCdUjSZkWPVm08i8hbLvdLV61bLji+IzQ==","signatures":[{"sig":"MEYCIQDQPXhyAmZAEeXJpGl68pIQML2qsbAWhbAwxUfjpQbIaQIhAM46PaIr6JAHsc/XCw5jkWHBd5Fvd7P3Sy9h81d55wMF","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":359232},"main":"dist/cjs/index.js","type":"module","types":"dist/cjs/index.d.ts","module":"dist/esm/index.js","engines":{"node":">=18.0.0"},"exports":{".":{"import":{"types":"./dist/esm/index.d.ts","default":"./dist/esm/index.js"},"require":{"types":"./dist/cjs/index.d.ts","default":"./dist/cjs/index.js"}}},"gitHead":"3b0edca1d9f61b85adbbd3114ac669108407fd28","scripts":{"lint":"eslint src/ test/","test":"vitest run","build":"tsup","test:unit":"vitest run test/unit","test:watch":"vitest","publish:major":"npm version major && npm publish --access public","publish:minor":"npm version minor && npm publish --access public","publish:patch":"npm version patch && npm publish --access public","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build","test:integration":"vitest run test/integration"},"_npmUser":{"name":"brokenrubik","email":"team@brokenrubik.co"},"_npmVersion":"11.9.0","description":"Production-ready NetSuite REST API client SDK with OAuth 2.0 M2M authentication, retry logic, RESTlet support, and SuiteQL.","directories":{},"_nodeVersion":"24.14.0","dependencies":{"jsonwebtoken":"^9.0.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","vitest":"^3.0.0","typescript":"^5.0.0","@types/node":"^20.0.0","@types/jsonwebtoken":"^9.0.0","@vitest/coverage-v8":"^3.2.4"},"_npmOperationalInternal":{"tmp":"tmp/netsuite-sdk_0.1.1_1775419649370_0.17549257043816313","host":"s3://npm-registry-packages-npm-production"}},"0.1.2":{"name":"@brokenrubik/netsuite-sdk","version":"0.1.2","keywords":["netsuite","suitetalk","rest","api","sdk","oauth2","m2m","suiteql","restlet","erp"],"author":{"name":"Leandro","email":"leandro@brokenrubik.co"},"license":"ISC","_id":"@brokenrubik/netsuite-sdk@0.1.2","maintainers":[{"name":"brokenrubik","email":"team@brokenrubik.co"}],"dist":{"shasum":"9f3ac52417c013aa472db5420f8e9cd2adbc479c","tarball":"https://registry.npmjs.org/@brokenrubik/netsuite-sdk/-/netsuite-sdk-0.1.2.tgz","fileCount":8,"integrity":"sha512-PmZ8ZGTsQE0RiRIcUO9iCIakTLTsvU6WsF4zv4UfJNMtoIzVSV5vlKnH6iuy0xYWvojcx0jm9wjIyZjXI3j9yA==","signatures":[{"sig":"MEYCIQD4C0b6YiDlGOEvVxB5BN2CNzLH4efUchSoelLKlsX0oAIhALxIQyZcxF1LdkAHiZRm7P4b5RKF4fcRiMLjPIwXFZ6l","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":360864},"main":"dist/cjs/index.js","type":"module","types":"dist/cjs/index.d.ts","module":"dist/esm/index.js","engines":{"node":">=18.0.0"},"exports":{".":{"import":{"types":"./dist/esm/index.d.ts","default":"./dist/esm/index.js"},"require":{"types":"./dist/cjs/index.d.ts","default":"./dist/cjs/index.js"}}},"gitHead":"caf5f2e341730f53ea0d27a6016164d873ae82a7","scripts":{"lint":"eslint src/ test/","test":"vitest run","build":"tsup","test:unit":"vitest run test/unit","test:watch":"vitest","publish:major":"npm version major && npm publish --access public","publish:minor":"npm version minor && npm publish --access public","publish:patch":"npm version patch && npm publish --access public","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build","test:integration":"vitest run test/integration"},"_npmUser":{"name":"brokenrubik","email":"team@brokenrubik.co"},"_npmVersion":"11.9.0","description":"Production-ready NetSuite REST API client SDK with OAuth 2.0 M2M authentication, retry logic, RESTlet support, and SuiteQL.","directories":{},"_nodeVersion":"24.14.0","dependencies":{"jsonwebtoken":"^9.0.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","vitest":"^3.0.0","typescript":"^5.0.0","@types/node":"^20.0.0","@types/jsonwebtoken":"^9.0.0","@vitest/coverage-v8":"^3.2.4"},"_npmOperationalInternal":{"tmp":"tmp/netsuite-sdk_0.1.2_1775427313527_0.12145567307101812","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@brokenrubik/netsuite-sdk","version":"0.2.0","keywords":["netsuite","suitetalk","rest","api","sdk","oauth2","m2m","suiteql","restlet","erp"],"author":{"name":"Leandro","email":"leandro@brokenrubik.co"},"license":"ISC","_id":"@brokenrubik/netsuite-sdk@0.2.0","maintainers":[{"name":"brokenrubik","email":"team@brokenrubik.co"}],"dist":{"shasum":"3a797330e02e719a1ff40044f76963178a5a1b3c","tarball":"https://registry.npmjs.org/@brokenrubik/netsuite-sdk/-/netsuite-sdk-0.2.0.tgz","fileCount":12,"integrity":"sha512-anB8W5Y5xKJFZiZ144aHxqTvBCwzZ8oLbKnp9epE/3NhnQX9+b+Pjruzwylje7vijv0OWi7NSNI3suDl076GXg==","signatures":[{"sig":"MEUCIQDSMzG7jMNyLNr2EA4Igou0txNu229r6toy9wH782T5AAIgfel5v9y+/om27ZwiVJhGAgYctmIKsLx141PYUxZ6meE=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":378241},"main":"dist/cjs/index.js","type":"module","_from":"file:brokenrubik-netsuite-sdk-0.2.0.tgz","types":"dist/esm/index.d.ts","module":"dist/esm/index.js","engines":{"node":">=18.0.0"},"exports":{".":{"import":{"types":"./dist/esm/index.d.ts","default":"./dist/esm/index.js"},"require":{"types":"./dist/cjs/index.d.cts","default":"./dist/cjs/index.cjs"}}},"scripts":{"lint":"eslint src/ test/","test":"vitest run","build":"tsup","test:unit":"vitest run test/unit","test:watch":"vitest","publish:major":"npm version major && npm publish --access public","publish:minor":"npm version minor && npm publish --access public","publish:patch":"npm version patch && npm publish --access public","test:coverage":"vitest run --coverage","test:integration":"vitest run test/integration"},"_npmUser":{"name":"brokenrubik","email":"team@brokenrubik.co"},"_resolved":"/home/k/.tmp/533b99eef4be3c726349eac31ed9abb7/brokenrubik-netsuite-sdk-0.2.0.tgz","_integrity":"sha512-anB8W5Y5xKJFZiZ144aHxqTvBCwzZ8oLbKnp9epE/3NhnQX9+b+Pjruzwylje7vijv0OWi7NSNI3suDl076GXg==","_npmVersion":"10.9.3","description":"Production-ready NetSuite REST API client SDK with OAuth 2.0 M2M authentication, retry logic, RESTlet support, and SuiteQL.","directories":{},"_nodeVersion":"22.20.0","dependencies":{"jsonwebtoken":"^9.0.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","vitest":"^3.0.0","typescript":"^5.0.0","@types/node":"^20.0.0","@types/jsonwebtoken":"^9.0.0","@vitest/coverage-v8":"^3.2.4"},"_npmOperationalInternal":{"tmp":"tmp/netsuite-sdk_0.2.0_1775579707409_0.42693670386641847","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"name":"@brokenrubik/netsuite-sdk","version":"0.2.1","keywords":["netsuite","suitetalk","rest","api","sdk","oauth2","m2m","suiteql","restlet","erp"],"author":{"name":"Leandro","email":"leandro@brokenrubik.co"},"license":"ISC","_id":"@brokenrubik/netsuite-sdk@0.2.1","maintainers":[{"name":"brokenrubik","email":"team@brokenrubik.co"}],"dist":{"shasum":"21ba68760977b7d53c6367b6800b23c52a4540f8","tarball":"https://registry.npmjs.org/@brokenrubik/netsuite-sdk/-/netsuite-sdk-0.2.1.tgz","fileCount":8,"integrity":"sha512-/g1YMz482r3FaNjyggpqnSzwH5giZMf8y14Yg100c9l/eTElyldTjp4Zudo4zqEcYOzk0pw6L4hMp2RSuQcOFg==","signatures":[{"sig":"MEYCIQDU0r6rrA6mqO474w+ngSYAjRAzmeWMX5lzth6B8kEgiQIhAIVINoNk0x73qG4L+7JXbucpuUK9chFPD4nJoVPjTOt+","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":365328},"main":"dist/cjs/index.js","type":"module","types":"dist/esm/index.d.ts","module":"dist/esm/index.js","engines":{"node":">=18.0.0"},"exports":{".":{"import":{"types":"./dist/esm/index.d.ts","default":"./dist/esm/index.js"},"require":{"types":"./dist/cjs/index.d.cts","default":"./dist/cjs/index.cjs"}}},"gitHead":"b4629537a49bbb925f4559643b46921c75618125","scripts":{"lint":"eslint src/ test/","test":"vitest run","build":"tsup","test:unit":"vitest run test/unit","test:watch":"vitest","publish:major":"npm version major && npm publish --access public","publish:minor":"npm version minor && npm publish --access public","publish:patch":"npm version patch && npm publish --access public","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build","test:integration":"vitest run test/integration"},"_npmUser":{"name":"brokenrubik","email":"team@brokenrubik.co"},"_npmVersion":"11.9.0","description":"Production-ready NetSuite REST API client SDK with OAuth 2.0 M2M authentication, retry logic, RESTlet support, and SuiteQL.","directories":{},"_nodeVersion":"24.14.0","dependencies":{"jsonwebtoken":"^9.0.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","vitest":"^3.0.0","typescript":"^5.0.0","@types/node":"^20.0.0","@types/jsonwebtoken":"^9.0.0","@vitest/coverage-v8":"^3.2.4"},"_npmOperationalInternal":{"tmp":"tmp/netsuite-sdk_0.2.1_1776996247171_0.6027061476097553","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@brokenrubik/netsuite-sdk","version":"0.3.0","keywords":["netsuite","suitetalk","rest","api","sdk","oauth2","m2m","suiteql","restlet","erp"],"author":{"name":"Leandro","email":"leandro@brokenrubik.co"},"license":"ISC","_id":"@brokenrubik/netsuite-sdk@0.3.0","maintainers":[{"name":"brokenrubik","email":"team@brokenrubik.co"}],"dist":{"shasum":"46defebfa95cdeea60a19d98f17327d490ee8fa1","tarball":"https://registry.npmjs.org/@brokenrubik/netsuite-sdk/-/netsuite-sdk-0.3.0.tgz","fileCount":8,"integrity":"sha512-1I4/DpUznYaWvuwUIUvaN/dJvNKP09KKEKA3AT/jdqj9bMFNOxWz6j9z4oFTWrGv7aB/WwQJuwH4te/NCM1PcA==","signatures":[{"sig":"MEQCIBI17kfIqpNE41BpHm7yOci2u0JHJj3f7IEICGm2nCLwAiAQrcciS60Em2sCX2RGbWcSI0c4sMZYkRzxHxRNIjwebQ==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":366350},"main":"dist/cjs/index.js","type":"module","types":"dist/esm/index.d.ts","module":"dist/esm/index.js","engines":{"node":">=18.0.0"},"exports":{".":{"import":{"types":"./dist/esm/index.d.ts","default":"./dist/esm/index.js"},"require":{"types":"./dist/cjs/index.d.cts","default":"./dist/cjs/index.cjs"}}},"gitHead":"ff1d205e62a01185c464ec0902d4eb0aa417ce82","scripts":{"lint":"eslint src/ test/","test":"vitest run","build":"tsup","test:unit":"vitest run test/unit","test:watch":"vitest","publish:major":"npm version major && npm publish --access public","publish:minor":"npm version minor && npm publish --access public","publish:patch":"npm version patch && npm publish --access public","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build","test:integration":"vitest run test/integration"},"_npmUser":{"name":"brokenrubik","email":"team@brokenrubik.co"},"_npmVersion":"11.12.1","description":"Production-ready NetSuite REST API client SDK with OAuth 2.0 M2M authentication, retry logic, RESTlet support, and SuiteQL.","directories":{},"_nodeVersion":"24.15.0","dependencies":{"jsonwebtoken":"^9.0.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","vitest":"^3.0.0","typescript":"^5.0.0","@types/node":"^20.0.0","@types/jsonwebtoken":"^9.0.0","@vitest/coverage-v8":"^3.2.4"},"_npmOperationalInternal":{"tmp":"tmp/netsuite-sdk_0.3.0_1778524227624_0.7396296600720988","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"name":"@brokenrubik/netsuite-sdk","version":"0.4.0","keywords":["netsuite","suitetalk","rest","api","sdk","oauth2","m2m","suiteql","restlet","erp"],"author":{"name":"Leandro","email":"leandro@brokenrubik.co"},"license":"ISC","_id":"@brokenrubik/netsuite-sdk@0.4.0","maintainers":[{"name":"brokenrubik","email":"team@brokenrubik.co"}],"dist":{"shasum":"939689899072f119e5373f0c5be2cdb947d14401","tarball":"https://registry.npmjs.org/@brokenrubik/netsuite-sdk/-/netsuite-sdk-0.4.0.tgz","fileCount":8,"integrity":"sha512-oTkt3mcu7jgS4eUtjZIkrkHIxf/mvsJMcb9E+9mYOV5ojXL41GuCjQJA97GkXPWVfS4uLgK7e8i4QrljK1T7pw==","signatures":[{"sig":"MEUCIDdP/HoortAtX24vmsjkFt4yMilOIHI6Nmj70fNTFwwAAiEAhcWrRAVLcWzdT4pXCuKMWzU5xzOZyqfqhfKTJPkdLyQ=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":391086},"main":"dist/cjs/index.cjs","type":"module","types":"dist/esm/index.d.ts","module":"dist/esm/index.js","engines":{"node":">=18.0.0"},"exports":{".":{"import":{"types":"./dist/esm/index.d.ts","default":"./dist/esm/index.js"},"require":{"types":"./dist/cjs/index.d.cts","default":"./dist/cjs/index.cjs"}}},"gitHead":"bda1831346e3d07abfad911fa92d5feccb1ba6d3","scripts":{"lint":"eslint src/ test/","test":"vitest run","build":"tsup","test:unit":"vitest run test/unit","test:watch":"vitest","publish:major":"npm version major && npm publish --access public","publish:minor":"npm version minor && npm publish --access public","publish:patch":"npm version patch && npm publish --access public","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build","test:integration":"vitest run test/integration"},"_npmUser":{"name":"brokenrubik","email":"team@brokenrubik.co"},"_npmVersion":"11.16.0","description":"Production-ready NetSuite REST API client SDK with OAuth 2.0 M2M authentication, retry logic, RESTlet support, and SuiteQL.","directories":{},"_nodeVersion":"24.18.0","dependencies":{"jsonwebtoken":"^9.0.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","vitest":"^3.0.0","typescript":"^5.0.0","@types/node":"^20.0.0","@types/jsonwebtoken":"^9.0.0","@vitest/coverage-v8":"^3.2.4"},"_npmOperationalInternal":{"tmp":"tmp/netsuite-sdk_0.4.0_1783443358738_0.13864990846330638","host":"s3://npm-registry-packages-npm-production"}},"0.5.0":{"name":"@brokenrubik/netsuite-sdk","version":"0.5.0","keywords":["netsuite","suitetalk","rest","api","sdk","oauth2","m2m","suiteql","restlet","erp"],"author":{"name":"Leandro","email":"leandro@brokenrubik.co"},"license":"ISC","_id":"@brokenrubik/netsuite-sdk@0.5.0","maintainers":[{"name":"brokenrubik","email":"team@brokenrubik.co"}],"dist":{"shasum":"904517433ae9c641132d3b289c7d1eb33689d2d0","tarball":"https://registry.npmjs.org/@brokenrubik/netsuite-sdk/-/netsuite-sdk-0.5.0.tgz","fileCount":8,"integrity":"sha512-yE2rDAZTBoiI6fq7JUpWiLLTdVtHBxzolWVfu657br+a5/P8uuI3YrenLXzjNRVyoByRIYbCqzaZruI1WoFPwA==","signatures":[{"sig":"MEUCIQCaku+ocQSiAnxu6+cTAng1Wi2YIFMM6mJXUzz0Kh7TJAIgLdFmgVkPsBD0W4cQ55hIlMZ8/yyS4Hm9hUjVoGrUBwc=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":537206},"main":"dist/cjs/index.cjs","type":"module","types":"dist/esm/index.d.ts","module":"dist/esm/index.js","engines":{"node":">=18.0.0"},"exports":{".":{"import":{"types":"./dist/esm/index.d.ts","default":"./dist/esm/index.js"},"require":{"types":"./dist/cjs/index.d.cts","default":"./dist/cjs/index.cjs"}}},"gitHead":"a1a089b09823021a0548fdb7007bed91e2169868","scripts":{"lint":"eslint src/ test/","test":"vitest run","build":"tsup","test:unit":"vitest run test/unit","test:watch":"vitest","publish:major":"npm version major && npm publish --access public","publish:minor":"npm version minor && npm publish --access public","publish:patch":"npm version patch && npm publish --access public","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build","test:integration":"vitest run test/integration"},"_npmUser":{"name":"brokenrubik","email":"team@brokenrubik.co"},"_npmVersion":"11.16.0","description":"Production-ready NetSuite REST API client SDK with OAuth 2.0 M2M authentication, retry logic, RESTlet support, and SuiteQL.","directories":{},"_nodeVersion":"24.18.0","dependencies":{"jsonwebtoken":"^9.0.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","vitest":"^3.0.0","typescript":"^5.0.0","@types/node":"^20.0.0","@types/jsonwebtoken":"^9.0.0","@vitest/coverage-v8":"^3.2.4"},"_npmOperationalInternal":{"tmp":"tmp/netsuite-sdk_0.5.0_1787170771171_0.5548073707780434","host":"s3://npm-registry-packages-npm-production"}},"0.5.1":{"name":"@brokenrubik/netsuite-sdk","version":"0.5.1","keywords":["netsuite","suitetalk","rest","api","sdk","oauth2","m2m","suiteql","restlet","erp"],"author":{"name":"Leandro","email":"leandro@brokenrubik.co"},"license":"ISC","_id":"@brokenrubik/netsuite-sdk@0.5.1","maintainers":[{"name":"brokenrubik","email":"team@brokenrubik.co"}],"dist":{"shasum":"4aaeaa9a4fe3ffba043fd2ca07b2036c22520810","tarball":"https://registry.npmjs.org/@brokenrubik/netsuite-sdk/-/netsuite-sdk-0.5.1.tgz","fileCount":8,"integrity":"sha512-Y0c8xbqjdeMOc/IKSh8PDX7OLvwDD9YKkF113+Xf5J6J476V+cjZVl6/15CDFoDvuFpZOxYkPfrv9okcIFWUrA==","signatures":[{"sig":"MEUCIQCpNOmwkrurAz13HQUe67b2NsikhOZCJwun+0Kd44OprwIgVfI8N66um6ucM+rIkEesBkXTPyZ2DHYWtnH8InWFETc=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":544606},"main":"dist/cjs/index.cjs","type":"module","types":"dist/esm/index.d.ts","module":"dist/esm/index.js","engines":{"node":">=18.0.0"},"exports":{".":{"import":{"types":"./dist/esm/index.d.ts","default":"./dist/esm/index.js"},"require":{"types":"./dist/cjs/index.d.cts","default":"./dist/cjs/index.cjs"}}},"gitHead":"008d785d0450f1eb3fc2fd339ef9f4ccb80878c0","scripts":{"lint":"eslint src/ test/","test":"vitest run","build":"tsup","test:unit":"vitest run test/unit","test:watch":"vitest","publish:major":"npm version major && npm publish --access public","publish:minor":"npm version minor && npm publish --access public","publish:patch":"npm version patch && npm publish --access public","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build","test:integration":"vitest run test/integration"},"_npmUser":{"name":"brokenrubik","email":"team@brokenrubik.co"},"_npmVersion":"11.16.0","description":"Production-ready NetSuite REST API client SDK with OAuth 2.0 M2M authentication, retry logic, RESTlet support, and SuiteQL.","directories":{},"_nodeVersion":"24.18.0","dependencies":{"jsonwebtoken":"^9.0.0"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","vitest":"^3.0.0","typescript":"^5.0.0","@types/node":"^20.0.0","@types/jsonwebtoken":"^9.0.0","@vitest/coverage-v8":"^3.2.4"},"_npmOperationalInternal":{"tmp":"tmp/netsuite-sdk_0.5.1_1787173045834_0.9574910772279661","host":"s3://npm-registry-packages-npm-production"}},"0.6.0":{"name":"@brokenrubik/netsuite-sdk","version":"0.6.0","type":"module","description":"Production-ready NetSuite REST API client SDK with OAuth 2.0 M2M authentication, retry logic, RESTlet support, and SuiteQL.","main":"dist/cjs/index.cjs","module":"dist/esm/index.js","types":"dist/esm/index.d.ts","exports":{".":{"import":{"types":"./dist/esm/index.d.ts","default":"./dist/esm/index.js"},"require":{"types":"./dist/cjs/index.d.cts","default":"./dist/cjs/index.cjs"}}},"scripts":{"build":"tsup","test":"vitest run","test:unit":"vitest run test/unit","test:integration":"vitest run test/integration","test:watch":"vitest","test:coverage":"vitest run --coverage","lint":"eslint src/ test/","prepublishOnly":"npm run build","publish:patch":"npm version patch && npm publish --access public","publish:minor":"npm version minor && npm publish --access public","publish:major":"npm version major && npm publish --access public"},"keywords":["netsuite","suitetalk","rest","api","sdk","oauth2","m2m","suiteql","restlet","erp"],"author":{"name":"Leandro","email":"leandro@brokenrubik.co"},"license":"ISC","engines":{"node":">=18.0.0"},"dependencies":{"jsonwebtoken":"^9.0.0"},"devDependencies":{"@types/jsonwebtoken":"^9.0.0","@types/node":"^20.0.0","@vitest/coverage-v8":"^3.2.4","tsup":"^8.0.0","typescript":"^5.0.0","vitest":"^3.0.0"},"gitHead":"7905511bd8216f9ee58fd7dac8cb8e6b8cde24cb","_id":"@brokenrubik/netsuite-sdk@0.6.0","_nodeVersion":"24.18.0","_npmVersion":"11.16.0","dist":{"integrity":"sha512-U6cuWMTcHOaN1hM6Hf/GnOs2N5dv6IENtmBHvdWxLx4NWyGXZr29Isko2BLQ/OuKUyZZKLFVMazr9Us8q7TXHA==","shasum":"3df1ca99ab90c0a06209af6c28cdbaf367cb8787","tarball":"https://registry.npmjs.org/@brokenrubik/netsuite-sdk/-/netsuite-sdk-0.6.0.tgz","fileCount":8,"unpackedSize":564276,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIG3guyd/0e0ShvYbfL4cOC8GWuAECYOyO0+tLdafxkePAiEAjBQ+Y6sN1MlPLT01FvUe7ED51RDmH7Jvk28RtrGikCM="}]},"_npmUser":{"name":"brokenrubik","email":"team@brokenrubik.co"},"directories":{},"maintainers":[{"name":"brokenrubik","email":"team@brokenrubik.co"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/netsuite-sdk_0.6.0_1787235024415_0.46070644212928813"},"_hasShrinkwrap":false}},"time":{"created":"2026-04-05T18:59:04.784Z","modified":"2026-08-20T14:10:24.751Z","0.1.0":"2026-04-05T18:59:05.039Z","0.1.1":"2026-04-05T20:07:29.521Z","0.1.2":"2026-04-05T22:15:13.685Z","0.2.0":"2026-04-07T16:35:07.608Z","0.2.1":"2026-04-24T02:04:07.348Z","0.3.0":"2026-05-11T18:30:27.777Z","0.4.0":"2026-07-07T16:55:58.870Z","0.5.0":"2026-08-19T20:19:31.323Z","0.5.1":"2026-08-19T20:57:26.030Z","0.6.0":"2026-08-20T14:10:24.572Z"},"author":{"name":"Leandro","email":"leandro@brokenrubik.co"},"license":"ISC","keywords":["netsuite","suitetalk","rest","api","sdk","oauth2","m2m","suiteql","restlet","erp"],"description":"Production-ready NetSuite REST API client SDK with OAuth 2.0 M2M authentication, retry logic, RESTlet support, and SuiteQL.","maintainers":[{"name":"brokenrubik","email":"team@brokenrubik.co"}],"readme":"# @brokenrubik/netsuite-sdk\n\n[![CI](https://github.com/BrokenRubik/ns-suitetalk-m2m-rest/actions/workflows/ci.yml/badge.svg)](https://github.com/BrokenRubik/ns-suitetalk-m2m-rest/actions/workflows/ci.yml)\n\nProduction-ready TypeScript SDK for the NetSuite REST API with OAuth 2.0 M2M authentication.\n\n## Features\n\n- OAuth 2.0 M2M authentication with certificate-based JWT (PS256)\n- Full CRUD operations for NetSuite records + upsert via external IDs\n- SuiteQL query execution with auto-pagination\n- RESTlet support (custom scripts via `restlets.api.netsuite.com`)\n- Metadata catalog access (JSON, OpenAPI 3.0, JSON Schema)\n- Async batch operations — up to 100 records per request, with job polling, parallel mode, and idempotency keys\n- Automatic retry with exponential backoff + jitter (429, 502, 503, 504)\n- Structured error hierarchy with parsed `o:errorDetails`\n- Per-scope token caching (SuiteTalk, RESTlets, Analytics)\n- TypeScript-first with full generics support\n- Dual CJS + ESM output\n- Zero dependencies beyond `jsonwebtoken`\n\n## Table of Contents\n\n- [Installation](#installation)\n- [Prerequisites](#prerequisites)\n- [Quick Start](#quick-start)\n- [Configuration](#configuration)\n- [Common Use Cases](#common-use-cases)\n  - [Working with Records](#working-with-records)\n  - [SuiteQL Queries](#suiteql-queries)\n  - [RESTlets](#restlets)\n  - [Metadata Catalog](#metadata-catalog)\n  - [Batch Operations (Async)](#batch-operations-async)\n- [Error Handling](#error-handling)\n- [Logging](#logging)\n- [Advanced Usage](#advanced-usage)\n- [API Reference](#api-reference)\n- [Running Tests](#running-tests)\n- [Official NetSuite Documentation](#official-netsuite-documentation)\n- [License](#license)\n\n## Installation\n\n```bash\nnpm install @brokenrubik/netsuite-sdk\n```\n\n**Requires Node.js >= 18.0.0** (uses native `fetch`)\n\n## Prerequisites\n\nBefore using this SDK, you need to set up OAuth 2.0 M2M authentication in your NetSuite account. This requires four credentials:\n\n| Credential | Where to find it |\n|---|---|\n| **Account ID** | Setup > Company > Company Information (e.g., `TSTDRV1234` or `TSTDRV1234_SB1` for sandbox) |\n| **Client ID** | Setup > Integration > Manage Integrations > your integration record |\n| **Certificate ID** | Setup > Integration > OAuth 2.0 Client Credentials > Manage Certificates |\n| **Private Key** | The PEM file you generated when creating the certificate |\n\n### Step-by-step setup\n\n1. **Generate a certificate and private key:**\n   ```bash\n   openssl req -x509 -newkey rsa:4096 -sha256 -keyout private.pem -out public.pem -nodes -days 730\n   ```\n   This generates a self-signed certificate (`public.pem`) and private key (`private.pem`) in one step. The `-nodes` flag skips passphrase encryption, and `-days 730` sets a 2-year validity.\n\n2. **Create an Integration Record** in NetSuite:\n   - Navigate to Setup > Integration > Manage Integrations > New\n   - Enable **Token-Based Authentication** and **OAuth 2.0 Client Credentials (M2M)**\n   - Save and note the **Client ID**\n\n3. **Upload the public certificate** to NetSuite:\n   - Navigate to Setup > Integration > OAuth 2.0 Client Credentials (M2M) > Create New\n   - Select the integration record, upload `public.pem`\n   - Assign a role (e.g., Administrator or a custom role)\n   - Save and note the **Certificate ID**\n\n4. **Store your private key** securely. There are three common approaches:\n\n   **Option A: Inline in `.env` with escaped newlines**\n   ```bash\n   NETSUITE_PRIVATE_KEY=\"-----BEGIN PRIVATE KEY-----\\nMIIEv...base64...\\n-----END PRIVATE KEY-----\"\n   ```\n   ```typescript\n   privateKey: process.env.NETSUITE_PRIVATE_KEY!.replace(/\\\\n/g, '\\n'),\n   ```\n\n   **Option B: Read from file at runtime**\n   ```typescript\n   import { readFileSync } from 'node:fs';\n   privateKey: readFileSync('./private.pem', 'utf-8'),\n   ```\n\n   **Option C: Point to the file path via an env variable**\n   ```bash\n   NETSUITE_PRIVATE_KEY_FILE=./private.pem\n   ```\n   ```typescript\n   import { readFileSync } from 'node:fs';\n   privateKey: readFileSync(process.env.NETSUITE_PRIVATE_KEY_FILE!, 'utf-8'),\n   ```\n   This keeps the key out of your environment variables entirely while still being configurable per environment.\n\nFor more details, see the [official NetSuite OAuth 2.0 setup guide](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_162686838198.html).\n\n## Quick Start\n\n```typescript\nimport { NetSuiteClient } from '@brokenrubik/netsuite-sdk';\nimport { readFileSync } from 'node:fs';\n\nconst client = new NetSuiteClient({\n  accountId: process.env.NETSUITE_ACCOUNT_ID!,\n  clientId: process.env.NETSUITE_CLIENT_ID!,\n  certificateId: process.env.NETSUITE_CERTIFICATE_ID!,\n  privateKey: readFileSync('./private.pem', 'utf-8'),\n});\n\n// Create a customer\nconst { id } = await client.createRecord('customer', {\n  companyName: 'Acme Corporation',\n  email: 'contact@acme.com',\n  subsidiary: { id: '1' },\n});\nconsole.log(`Created customer: ${id}`);\n\n// Fetch it back\nconst customer = await client.getRecord('customer', id);\nconsole.log(customer);\n\n// Run a SuiteQL query\nconst results = await client.suiteql<{ id: string; companyname: string }>(\n  'SELECT id, companyname FROM customer WHERE ROWNUM <= 10',\n);\nconsole.log(`Found ${results.totalResults} customers`);\n```\n\n## Configuration\n\n```typescript\nconst client = new NetSuiteClient({\n  // Required\n  accountId: 'TSTDRV1234_SB1',  // Supports underscores — auto-formatted to hyphens for API URLs\n  clientId: 'abc123...',          // From Integration Record\n  certificateId: 'cert-id...',   // From OAuth 2.0 Client Credentials setup\n  privateKey: '-----BEGIN...',   // PEM-formatted private key string\n\n  // Optional — all have sensible defaults\n  requestTimeout: 30_000,        // Per-request timeout in ms (default: 30s)\n  tokenExpiryMargin: 60_000,     // Refresh token this many ms before expiry (default: 60s)\n  maxRetries: 3,                 // Retry attempts for transient errors (default: 3)\n  initialRetryDelay: 1_000,      // First retry delay in ms (default: 1s)\n  maxRetryDelay: 60_000,         // Retry delay cap in ms (default: 60s)\n  backoffMultiplier: 2,          // Exponential backoff factor (default: 2)\n  logger: new ConsoleLogger(),   // Optional ILogger — see Logging section\n});\n```\n\n## Common Use Cases\n\n### Working with Records\n\n#### Built-in record types\n\nThe SDK ships with type definitions for common record types, generated from the NetSuite metadata catalog. Use them directly or extend with your custom fields:\n\n```typescript\nimport type { Customer, SalesOrder, NsRef } from '@brokenrubik/netsuite-sdk';\n\n// Use built-in types directly\nconst customer = await client.getRecord<Customer>('customer', '123');\nconsole.log(customer.companyName); // typed as string | null\nconsole.log(customer.subsidiary);  // typed as NsRef ({ id, refName? })\n\n// Extend with your custom fields\ninterface MyCustomer extends Customer {\n  custentity_loyalty_tier: NsRef | null;\n  custentity_signup_source: string | null;\n}\n\nconst myCustomer = await client.getRecord<MyCustomer>('customer', '123');\nconsole.log(myCustomer.custentity_loyalty_tier); // typed!\n```\n\nAvailable types: `Customer`, `SalesOrder`, `Invoice`, `Vendor`, `Employee`, `InventoryItem`, `Contact`, `Subsidiary`, `VendorBill`, `PurchaseOrder`, `Department`, `Classification`, `Location`, `Account`, `Currency`.\n\nBuilding blocks: `NsRef` (reference field), `NsSublist` (sublist), `NsRecord` (base record), `NsLink` (HATEOAS link).\n\n#### Record field loading\n\nNetSuite has three loading levels that affect what you get back:\n\n| Load level | Reference fields (e.g. `entity`) | Sublists (e.g. `item`) |\n|---|---|---|\n| `getRecord(type, id)` | `{ id, refName, links }` — always a ref, never the full record | `{ links }` — collapsed, no items |\n| `getRecord(type, id, { expandSubResources: true })` | `{ id, refName, links }` — **same** | `{ links, items: [...], totalResults }` — items loaded |\n| `getRecord(type, id, { fields: '...' })` | `{ id, refName, links }` — if requested | **Not returned** (only body fields) |\n\nReference fields are **never** fully loaded inline. To get the full referenced record, follow the link:\n\n```typescript\nimport type { SalesOrder, SalesOrderItem, Customer } from '@brokenrubik/netsuite-sdk';\n\n// Load a sales order with expanded sublists\nconst order = await client.getRecord<SalesOrder>('salesOrder', '456', {\n  expandSubResources: true,\n});\n\n// entity is always { id, refName, links } — never the full customer\nconsole.log(order.entity.refName);  // \"Acme Corp\"\nconsole.log(order.entity.id);       // \"123\"\n\n// To load the full customer, make a separate call:\nconst customer = await client.getRecord<Customer>('customer', order.entity.id);\n\n// item sublist is expanded (because expandSubResources: true)\nfor (const line of order.item.items ?? []) {\n  console.log(`${line.item.refName}: ${line.quantity} x ${line.rate} = ${line.amount}`);\n}\n```\n\n#### Create a record\n\n```typescript\nconst result = await client.createRecord('customer', {\n  companyName: 'Acme Corporation',\n  email: 'billing@acme.com',\n  subsidiary: { id: '1' },\n});\n\nconsole.log(result);\n// {\n//   id: '12345',\n//   location: 'https://tstdrv1234.suitetalk.api.netsuite.com/.../customer/12345',\n//   success: true,\n//   operationId: '...',   // NetSuite operation tracking ID\n// }\n```\n\n#### Get a record by ID\n\n```typescript\n// Simple get\nconst customer = await client.getRecord<Customer>('customer', '12345');\n\n// With sublists and subrecords expanded\nconst full = await client.getRecord<Customer>('customer', '12345', {\n  expandSubResources: true,\n});\n\n// Only specific fields (reduces payload size)\nconst partial = await client.getRecord<Pick<Customer, 'id' | 'email'>>('customer', '12345', {\n  fields: 'id,email',\n});\n```\n\n> **Note:** You cannot combine `expandSubResources` and `fields` — NetSuite returns an `INVALID_PARAMETER` error.\n\n#### List records with filtering\n\n> **NetSuite collection endpoints return references only** — each item is a\n> `RecordRef` (`{ id, links }`). There is no flag to hydrate full bodies on\n> list endpoints (`expandSubResources` only expands sublists inside a\n> single-record `getRecord`). To load fields, either call `getRecord` per id,\n> or use SuiteQL with the columns you need.\n\n```typescript\n// Single page with query filter — items are { id, links }\nconst page = await client.listRecords('customer', {\n  limit: 50,\n  offset: 0,\n  q: 'email CONTAINS \"@acme.com\"',\n});\n\nconsole.log(`Showing ${page.count} of ${page.totalResults} total`);\nfor (const ref of page.items) {\n  const customer = await client.getRecord<Customer>('customer', ref.id);\n  console.log(customer.companyName);\n}\n\n// Or — and usually better — use SuiteQL to get fields in one round trip:\nconst rows = await client.suiteqlAll<{ id: string; companyname: string }>(\n  `SELECT id, companyname FROM customer WHERE email LIKE '%@acme.com' ORDER BY id`,\n);\n```\n\n#### Fetch all record references (auto-pagination)\n\n```typescript\n// Automatically fetches all pages (1000 refs per page)\nconst allRefs = await client.listAllRecords('customer', {\n  q: 'isinactive IS false',\n});\n\nconsole.log(`Total active customer refs: ${allRefs.length}`);\n```\n\n#### Update a record\n\n```typescript\nconst result = await client.updateRecord('customer', '12345', {\n  email: 'new-billing@acme.com',\n  phone: '555-0100',\n});\n\nconsole.log(result.success); // true\n```\n\nSublist lines are **merged** by default. To replace them instead, pass the\n`replace` option (comma-separated sublist names — maps to the `replace`\nquery parameter; also available on `createRecord` and `upsertRecord`):\n\n```typescript\n// Replace all sales order lines with the ones in the body\nawait client.updateRecord('salesorder', '42', { item: { items: [...] } }, { replace: 'item' });\n\n// Treat body fields as selected fields to replace (replaceSelectedFields=true)\nawait client.updateRecord('customer', '42', { email: null }, { replaceSelectedFields: true });\n```\n\n#### Delete a record\n\n```typescript\nconst result = await client.deleteRecord('customer', '12345');\nconsole.log(result.success); // true\n```\n\n#### Upsert a record (create or update by external ID)\n\n```typescript\n// If customer with external ID 'CID-002' exists, update it; otherwise, create it\nconst result = await client.upsertRecord('customer', 'CID-002', {\n  companyName: 'Updated Corp',\n  email: 'hello@updated.com',\n  subsidiary: { id: '1' },\n});\n\n// The 'eid:' prefix is added automatically — these are equivalent:\nawait client.upsertRecord('customer', 'CID-002', data);\nawait client.upsertRecord('customer', 'eid:CID-002', data);\n```\n\n#### Working with custom records\n\n```typescript\n// Custom records use their scriptId as the record type\nconst result = await client.createRecord('customrecord_my_record', {\n  name: 'Test Entry',\n  custrecord_field1: 'value1',\n});\n\nconst record = await client.getRecord('customrecord_my_record', result.id);\n```\n\n### SuiteQL Queries\n\nSuiteQL lets you query NetSuite data using SQL-like syntax. It's the most powerful way to retrieve data — supports JOINs, aggregation, and ordering (which the Record API doesn't).\n\n#### Basic query\n\n```typescript\nconst result = await client.suiteql<{ id: string; companyname: string }>(\n  'SELECT id, companyname FROM customer WHERE companyname LIKE \\'%Acme%\\' ORDER BY companyname',\n  { limit: 100 },\n);\n\nfor (const row of result.items) {\n  console.log(`${row.id}: ${row.companyname}`);\n}\n```\n\n#### Pagination\n\n```typescript\n// Manual pagination\nlet offset = 0;\nconst pageSize = 1000;\n\nwhile (true) {\n  const page = await client.suiteql<{ id: string }>(\n    'SELECT id FROM transaction WHERE type = \\'SalesOrd\\'',\n    { limit: pageSize, offset },\n  );\n\n  console.log(`Page at offset ${offset}: ${page.items.length} rows`);\n\n  if (!page.hasMore) break;\n  offset += page.items.length;\n}\n```\n\n#### Auto-paginate all results\n\n```typescript\n// Automatically fetches all pages (up to 100,000 rows, NetSuite limit)\nconst allOrders = await client.suiteqlAll<{\n  id: string;\n  tranid: string;\n  total: string;\n}>('SELECT id, tranid, total FROM transaction WHERE type = \\'SalesOrd\\' ORDER BY tranid');\n\nconsole.log(`Total orders: ${allOrders.length}`);\n```\n\n#### Async queries\n\nSuiteQL can also run as an async job — the submit returns immediately with the same `AsyncJob` handle used by [batch operations](#batch-operations-async). Worth it for slow, heavy queries you don't want to block on, or for firing several queries at once; for a quick query, plain `suiteql()` is simpler:\n\n```typescript\nconst job = await client.suiteqlAsync('SELECT id, companyname FROM customer', { limit: 1000 });\n\n// ...later (or right away)...\nconst { results } = await job.wait<PaginatedResponse<{ id: string; companyname: string }>>();\nconst page = results[0].result!; // { items, count, hasMore, links }\n```\n\n- One query per job (NetSuite has no multi-statement submit) — for a \"batch\" of queries, call `suiteqlAsync` once per query; each submit is fast and the jobs run server-side.\n- The job has a single task whose result is the **first page only** (up to `limit`, max 1000 rows). The result's `next` link points at the *synchronous* SuiteQL endpoint — fetch further pages with `suiteql()` and an `offset`.\n- The SDK sends the required `Prefer: respond-async, transient` on submit and `Prefer: transient` on the result fetch. (Both are mandatory: a submit without `transient` is accepted by NetSuite but its result is permanently unfetchable.)\n\n#### JOINs and aggregation\n\n```typescript\n// Count orders per customer\nconst stats = await client.suiteql<{\n  companyname: string;\n  order_count: string;\n}>(`\n  SELECT c.companyname, COUNT(t.id) as order_count\n  FROM customer c\n  INNER JOIN transaction t ON t.entity = c.id\n  WHERE t.type = 'SalesOrd'\n  GROUP BY c.companyname\n  ORDER BY order_count DESC\n`);\n\nfor (const row of stats.items) {\n  console.log(`${row.companyname}: ${row.order_count} orders`);\n}\n```\n\n### RESTlets\n\nRESTlets are custom SuiteScript endpoints deployed in NetSuite. They use a different hostname (`restlets.api.netsuite.com`) and OAuth scope (`restlets`), which the SDK handles automatically.\n\n```typescript\n// GET request to a RESTlet\nconst searchResults = await client.callRestlet({\n  scriptId: '1234',\n  deployId: '1',\n  method: 'GET',\n  queryParams: { action: 'search', term: 'widget' },\n});\n\n// POST request with a body\nconst created = await client.callRestlet({\n  scriptId: '1234',\n  deployId: '1',\n  method: 'POST',\n  body: {\n    recordType: 'inventoryitem',\n    values: { itemid: 'WIDGET-001', displayname: 'Widget' },\n  },\n});\n\n// Custom headers\nconst result = await client.callRestlet({\n  scriptId: '1234',\n  deployId: '1',\n  method: 'POST',\n  body: { data: 'value' },\n  headers: { 'X-Custom-Header': 'my-value' },\n});\n```\n\n> **Tip:** The SDK automatically uses the `restlets` OAuth scope and the `restlets.api.netsuite.com` hostname. Token caching is per-scope, so SuiteTalk and RESTlet tokens are managed independently.\n\n### Metadata Catalog\n\nThe metadata catalog describes the structure of all available record types, including custom records and fields.\n\n```typescript\n// Fetch the full catalog\nconst catalog = await client.getMetadata();\n\n// Fetch metadata for a specific record type\nconst customerMeta = await client.getMetadata({ select: 'customer' });\n\n// Get OpenAPI 3.0 (Swagger) definition\nconst swagger = await client.getMetadata({\n  select: 'customer',\n  format: 'openapi3',\n});\n\n// Get JSON Schema definition\nconst schema = await client.getMetadata({\n  select: 'salesorder',\n  format: 'jsonschema',\n});\n```\n\nAvailable formats:\n\n| Format | Accept header | Use case |\n|---|---|---|\n| `'json'` (default) | `application/json` | Browse record structure |\n| `'openapi3'` | `application/swagger+json` | Generate API clients, import into Postman |\n| `'jsonschema'` | `application/schema+json` | Data validation |\n\n### Batch Operations (Async)\n\nNetSuite can process up to **100 records per request** asynchronously: the submit returns immediately (HTTP 202) with a job reference, and results are collected by polling.\n\n#### When batch helps — and when it doesn't\n\n**Reach for batch when you're writing many records and nothing is waiting on each individual result** — bulk imports, nightly syncs, mass updates, cleanup jobs. One request hands NetSuite up to 100 records and your process is free immediately; the work happens on their side. For scale: 50 individual `createRecord` calls cost ~25–40s of your process's time and compete for your account's ~5 concurrent-request slots, while one `batchCreate` costs ~2 seconds and one slot.\n\n**Stick with the plain calls (`createRecord`, `updateRecord`, ...) when:**\n\n- It's a handful of records — the job bookkeeping outweighs the savings.\n- The very next line of code needs the result (create a customer, then immediately use its `id`) — a sync call gives you that in one step; batch gives you *eventual* completion.\n- You must confirm success before replying to a user or committing a transaction elsewhere.\n\n**Reading data:** for bulk reads, SuiteQL is usually the better tool — one query, exactly the columns you need. `batchGet` earns its place when you already have a list of ids and need **full record bodies** (sublists included) without making N sequential GETs.\n\n**Async SuiteQL (`suiteqlAsync`)** helps with slow, heavy queries (large JOINs/aggregations) you don't want to block on, or for firing several independent queries at once and collecting later. For a quick query, plain `suiteql()` is simpler — same rows, no job to track.\n\nThe SDK wraps the whole submit → poll → collect dance in an `AsyncJob` handle:\n\n```typescript\n// Submit — returns as soon as NetSuite accepts the job\nconst job = await client.batchCreate('customer', [\n  { companyName: 'A Corp', subsidiary: { id: '1' } },\n  { companyName: 'B Corp', subsidiary: { id: '1' } },\n]);\n\nconsole.log(job.id);       // '1234'\nconsole.log(job.location); // https://...netsuite.com/services/rest/async/v1/job/1234\n\n// Poll until every item completes and collect all results\nconst { succeeded, failed, results } = await job.wait();\n\nfor (const item of results) {\n  if (item.success) {\n    console.log(`Created record ${item.recordId}`);\n  } else {\n    console.error(`Item failed: ${item.error?.message}`);\n  }\n}\n```\n\n> **Partial success is normal.** `wait()` never throws for individual item failures — failed items are reported in `results` with their `error`. It throws only `NetSuiteTimeoutError` (overall `timeout` exceeded) or your `signal`'s abort reason.\n\n#### Choosing how to wait\n\nThe submit alone is enough to make the records happen — NetSuite processes the job server-side whether or not you ever look at it. What you pay for is **collecting results**, because NetSuite has no bulk-results endpoint: each item's outcome is a separate task with its own status and result URLs. Three strategies:\n\n| Strategy | How | Requests it costs |\n|---|---|---|\n| **Block until done** | `await job.wait()` | ~2 per item (status + result), sequential — a 50-item job is ~100 round trips (~1s per item of wall clock), *even if NetSuite finished the job in seconds* |\n| **Fire-and-forget** | persist `job.id`, later `attachJob(id).status()` | 1 request to learn the whole job finished; pay the per-item cost only if/when you call `wait()` |\n| **Selective** | `tasks()` + `taskStatus()`/`taskResult()` | ~2 per item *you care about* — fetch only specific results |\n\nRule of thumb: if you don't need the per-record outcomes right away (or at all), don't call `wait()` — submit, store `job.id`, and check `status()` later.\n\n> **`status()` answers \"is it done?\", not \"did everything succeed?\"** The job-level `progress` reports `'succeeded'` once the job ran to completion — **even when some or all items failed** (verified against the real API). Per-item outcomes only exist in the task results, i.e. `wait()`. For batch *creations*, a cheap alternative: give every item an `externalId` and verify with a single SuiteQL count —\n>\n> ```typescript\n> const eids = items.map((i) => `'${i.externalId}'`).join(',');\n> const { items: [row] } = await client.suiteql<{ n: string }>(\n>   `SELECT COUNT(*) AS n FROM customer WHERE externalid IN (${eids})`,\n> );\n> const allCreated = Number(row.n) === items.length; // one request\n> ```\n\n#### Batch methods\n\nAll submit methods return an `AsyncJob`:\n\n```typescript\n// Create (POST)\nconst job = await client.batchCreate('customer', items);\n\n// Upsert by external ID (PUT) — each item carries id or externalId in its body\nawait client.batchUpsert('customer', [\n  { externalId: 'CID-001', companyName: 'A Corp' },\n]);\n\n// Update (PATCH) — each item carries id or externalId (internal id wins)\nawait client.batchUpdate('customer', [\n  { id: '42', comments: 'updated in batch' },\n]);\n\n// Delete — internal ids and/or eid:-prefixed external ids\nawait client.batchDelete('customer', ['1', '2', 'eid:CID-001']);\n\n// Get — always sends expandRecords=true; record bodies arrive in wait() results\nconst getJob = await client.batchGet('customer', ['1', '2'], {\n  fields: 'id,companyName', // or expandSubResources: true (not both)\n});\nconst customers = await getJob.wait<Customer>();\nconsole.log(customers.results[0].result?.companyName);\n```\n\n#### Sequential vs parallel\n\nItems are processed **sequentially** by default — safe for dependent records (parent-child relationships, inventory operations). For independent items, opt into parallel processing:\n\n```typescript\nawait client.batchCreate('customer', items, { parallel: true });\n```\n\n#### Idempotency keys\n\nGuard against double submission (e.g. a retry after a network failure) with an idempotency key. A reused key throws `NetSuiteIdempotencyError` carrying the original job's `jobId`/`location` — re-attach with `attachJob()`:\n\n```typescript\nimport { randomUUID } from 'node:crypto';\n\nconst idempotencyKey = randomUUID();\n\nlet job;\ntry {\n  job = await client.batchCreate('customer', items, { idempotencyKey });\n} catch (e) {\n  if (e instanceof NetSuiteIdempotencyError) {\n    job = client.attachJob(e.location); // resume the already-submitted job\n  } else {\n    throw e;\n  }\n}\n```\n\n`attachJob()` also lets you resume polling across processes — persist `job.id` (or `job.location`) and re-attach later:\n\n```typescript\nconst job = client.attachJob('1234'); // bare job id or full Location URL\nconst outcome = await job.wait();\n```\n\n#### Fire-and-forget (don't wait at all)\n\n`wait()` is optional — the submit alone hands the work to NetSuite. Persist the job id, move on, and check back later with **one cheap request**:\n\n```typescript\nconst job = await client.batchCreate('customer', items); // ~2s, done\nawait saveJobId(job.id); // your own persistence\n\n// ... later, anywhere ...\nconst job = client.attachJob(savedJobId);\nconst status = await job.status(); // single GET: { completed, progress, startTime, endTime }\nif (status.completed) {\n  // Only if you need per-record outcomes — this still fetches every item's\n  // result (~2 requests per item); it just skips the poll sleeps.\n  const outcome = await job.wait();\n}\n```\n\n#### Fine-grained polling\n\nThe lower-level pieces are exposed too:\n\n```typescript\nconst status = await job.status();        // job-level: { completed, progress, ... }\nconst tasks = await job.tasks();          // one task per submitted item\nconst tStatus = await job.taskStatus('1'); // per-task: { completed, progress, startTime, endTime }\nconst result = await job.taskResult('1'); // throws typed errors for failed tasks\n```\n\n`wait()` options:\n\n| Option | Default | Description |\n|---|---|---|\n| `pollInterval` | `2000` | Delay between polling cycles (ms) |\n| `timeout` | `300000` | Max total wait before `NetSuiteTimeoutError` (ms) |\n| `signal` | — | `AbortSignal` to cancel polling early |\n\n> **Cost note:** `wait()`'s wall-clock time is usually dominated by its own HTTP round trips (~2 per item, sequential), not by NetSuite's processing — batch jobs typically finish server-side within seconds. Prefer `status()` when you only need to know *whether* the job is done.\n\n## Error Handling\n\nThe SDK provides a structured error hierarchy that maps to NetSuite's error response format:\n\n```\nNetSuiteError (base)\n  ├── NetSuiteAuthError          — 401/403 authentication failures\n  ├── NetSuiteValidationError    — 400 with parsed o:errorDetails\n  ├── NetSuiteNotFoundError      — 404 record/type not found\n  ├── NetSuiteRateLimitError     — 429 concurrency limit exceeded\n  ├── NetSuiteTimeoutError       — request timeout\n  └── NetSuiteIdempotencyError   — 409 duplicate batch idempotency key (carries jobId + location)\n```\n\n### Catching specific errors\n\n```typescript\nimport {\n  NetSuiteError,\n  NetSuiteAuthError,\n  NetSuiteValidationError,\n  NetSuiteNotFoundError,\n  NetSuiteRateLimitError,\n  NetSuiteTimeoutError,\n} from '@brokenrubik/netsuite-sdk';\n\ntry {\n  await client.createRecord('customer', { /* missing required fields */ });\n} catch (error) {\n  if (error instanceof NetSuiteValidationError) {\n    // 400 Bad Request — NetSuite tells you exactly what's wrong\n    console.error('Validation failed:', error.message);\n    console.error('Error code:', error.errorCode);   // e.g., 'REQUIRED_FIELD'\n    console.error('Error path:', error.errorPath);    // e.g., '/companyname'\n\n    // Full list of validation issues\n    for (const detail of error.errorDetails) {\n      console.error(`  - ${detail.errorCode}: ${detail.detail} (${detail.errorPath})`);\n    }\n  } else if (error instanceof NetSuiteNotFoundError) {\n    // 404 — record or record type doesn't exist\n    console.error('Not found:', error.message);\n  } else if (error instanceof NetSuiteAuthError) {\n    // 401/403 — credentials are wrong or expired\n    console.error('Authentication failed:', error.message);\n    console.error('Check your clientId, certificateId, and privateKey');\n  } else if (error instanceof NetSuiteRateLimitError) {\n    // 429 — too many concurrent requests\n    // Note: The SDK already retries these automatically (maxRetries times)\n    // This only fires if all retries are exhausted\n    console.error('Concurrency limit exceeded after all retries');\n  } else if (error instanceof NetSuiteTimeoutError) {\n    // Request took longer than requestTimeout\n    console.error('Request timed out');\n  } else if (error instanceof NetSuiteError) {\n    // Any other NetSuite API error (500, etc.)\n    console.error(`API Error ${error.status}: ${error.message}`);\n    console.error('Response body:', error.body);\n  }\n}\n```\n\n### Error properties\n\nAll errors extend `NetSuiteError` and include:\n\n| Property | Type | Description |\n|---|---|---|\n| `message` | `string` | Human-readable error message |\n| `status` | `number` | HTTP status code (0 for timeouts) |\n| `statusCode` | `number` | Alias for `status` |\n| `body` | `Record<string, unknown>` | Raw response body (if JSON) |\n\n`NetSuiteValidationError` adds:\n\n| Property | Type | Description |\n|---|---|---|\n| `errorDetails` | `NetSuiteErrorDetail[]` | Parsed list of validation errors |\n| `errorCode` | `string` | First error's code (e.g., `REQUIRED_FIELD`) |\n| `errorPath` | `string` | First error's path (e.g., `/companyname`) |\n\n### Automatic retries\n\nThe SDK automatically retries requests that fail with transient errors:\n\n- **429** — Rate limit / concurrency limit exceeded\n- **502** — Bad gateway\n- **503** — Service unavailable\n- **504** — Gateway timeout\n- **Network errors** — `ECONNRESET`, `ETIMEDOUT`, `ECONNREFUSED`\n\nRetries use exponential backoff with jitter (e.g., 1s, 2s, 4s with ±10% randomization). **500 errors are NOT retried** — per NetSuite docs, these indicate server-side issues that require contacting support.\n\n## Logging\n\n### Built-in console logger\n\n```typescript\nimport { NetSuiteClient, ConsoleLogger } from '@brokenrubik/netsuite-sdk';\n\nconst client = new NetSuiteClient({\n  ...credentials,\n  logger: new ConsoleLogger('MyApp'), // Prefix for log messages\n});\n\n// Output:\n// [MyApp] Requesting new access token { scope: 'rest_webservices' }\n// [MyApp] Access token obtained { scope: 'rest_webservices', expiresIn: 3600 }\n// [MyApp] Creating record { recordType: 'customer' }\n```\n\n### Custom logger (pino, winston, etc.)\n\nImplement the `ILogger` interface:\n\n```typescript\nimport type { ILogger } from '@brokenrubik/netsuite-sdk';\nimport pino from 'pino';\n\nconst pinoInstance = pino();\n\nconst logger: ILogger = {\n  debug: (msg, data) => pinoInstance.debug(data, msg),\n  info: (msg, data) => pinoInstance.info(data, msg),\n  warn: (msg, data) => pinoInstance.warn(data, msg),\n  error: (msg, err) => pinoInstance.error(err, msg),\n};\n\nconst client = new NetSuiteClient({\n  ...credentials,\n  logger,\n});\n```\n\n### Silent mode (default)\n\nIf you don't pass a `logger`, the SDK uses a `NoopLogger` internally — no output at all.\n\n## Advanced Usage\n\n### Low-level requests\n\nFor endpoints not covered by the SDK methods, use `request()` directly:\n\n```typescript\n// Hit any SuiteTalk endpoint\nconst result = await client.request(\n  '/services/rest/record/v1/invoice/123',\n  'GET',\n  { headers: { Accept: 'application/schema+json' } },\n);\n\n// Hit a restlets endpoint\nconst restletResult = await client.request(\n  '/app/site/hosting/restlet.nl?script=100&deploy=1',\n  'POST',\n  {\n    body: { action: 'custom' },\n    baseHost: 'restlets',\n    scope: 'restlets',\n  },\n);\n```\n\nWhen you need the HTTP status or response headers (not just the body), use\n`requestRaw()` — or `callRestletRaw()` for RESTlets. Same auth/retry/error\nbehavior; non-2xx responses still throw:\n\n```typescript\nconst res = await client.requestRaw('/services/rest/record/v1/customer', 'POST', {\n  body: { companyName: 'Acme' },\n});\nconsole.log(res.status);              // 204\nconsole.log(res.headers['location']); // https://...\nconsole.log(res.body);                // {} for empty responses\n```\n\n### Token management\n\n```typescript\n// Check if a valid token is cached\nclient.hasValidToken();              // rest_webservices scope (default)\nclient.hasValidToken('restlets');    // restlets scope\n\n// Get token expiration timestamp (ms since epoch, or null)\nconst exp = client.getTokenExpiration();\nif (exp) {\n  console.log(`Token expires at: ${new Date(exp).toISOString()}`);\n}\n\n// Force token refresh on next request\nclient.clearTokenCache();            // Clear all scopes\nclient.clearTokenCache('restlets');  // Clear specific scope\n```\n\n### Bulk operations with SuiteQL\n\n```typescript\n// Export all active inventory items\nconst items = await client.suiteqlAll<{\n  id: string;\n  itemid: string;\n  displayname: string;\n  salesdescription: string;\n}>(`\n  SELECT id, itemid, displayname, salesdescription\n  FROM item\n  WHERE isinactive = 'F'\n    AND itemtype = 'InvtPart'\n  ORDER BY itemid\n`);\n\nconsole.log(`Exported ${items.length} inventory items`);\n```\n\n> **Note:** `quantityavailable` is not a column on the `item` table in SuiteQL. To query stock levels by location, join with `inventoryitemlocations`:\n> ```sql\n> SELECT i.itemid, i.displayname, l.quantityavailable, l.location\n> FROM item i\n> JOIN inventoryitemlocations l ON l.item = i.id\n> WHERE i.isinactive = 'F' AND i.itemtype = 'InvtPart'\n> ORDER BY i.itemid\n> ```\n\n### Sync pattern: find-or-create\n\n```typescript\nasync function findOrCreateCustomer(\n  client: NetSuiteClient,\n  externalId: string,\n  data: Record<string, unknown>,\n) {\n  // Check if customer exists via SuiteQL\n  const existing = await client.suiteql<{ id: string }>(\n    `SELECT id FROM customer WHERE externalid = '${externalId}'`,\n    { limit: 1 },\n  );\n\n  if (existing.items.length > 0) {\n    // Update existing\n    return client.updateRecord('customer', existing.items[0].id, data);\n  } else {\n    // Create new\n    return client.createRecord('customer', { ...data, externalid: externalId });\n  }\n}\n```\n\nOr, use the built-in upsert which does this atomically:\n\n```typescript\nconst result = await client.upsertRecord('customer', externalId, data);\n```\n\n## API Reference\n\n### `NetSuiteClient`\n\n| Method | Signature | Description |\n|---|---|---|\n| `getRecord` | `<T>(type, id, options?) => Promise<T>` | Get a record by internal ID |\n| `listRecords` | `(type, options?) => Promise<PaginatedResponse<RecordRef>>` | List record refs (single page) |\n| `listAllRecords` | `(type, options?) => Promise<RecordRef[]>` | List all record refs (auto-paginate) |\n| `createRecord` | `(type, data, options?) => Promise<MutationResult>` | Create a new record |\n| `updateRecord` | `(type, id, data, options?) => Promise<MutationResult>` | Update a record by ID |\n| `deleteRecord` | `(type, id) => Promise<MutationResult>` | Delete a record by ID |\n| `upsertRecord` | `(type, externalId, data, options?) => Promise<MutationResult>` | Create or update by external ID |\n| `suiteql` | `<T>(query, options?) => Promise<PaginatedResponse<T>>` | Run a SuiteQL query (single page) |\n| `suiteqlAll` | `<T>(query) => Promise<T[]>` | Run a SuiteQL query (auto-paginate) |\n| `suiteqlAsync` | `(query, options?) => Promise<AsyncJob>` | Run a SuiteQL query as an async job |\n| `callRestlet` | `<T>(options) => Promise<T>` | Call a RESTlet script |\n| `callRestletRaw` | `<T>(options) => Promise<FullResponse<T>>` | Call a RESTlet, return full response envelope |\n| `getMetadata` | `<T>(options?) => Promise<T>` | Fetch metadata catalog |\n| `batchCreate` | `(type, items, options?) => Promise<AsyncJob>` | Create up to 100 records asynchronously |\n| `batchUpsert` | `(type, items, options?) => Promise<AsyncJob>` | Upsert up to 100 records by external ID |\n| `batchUpdate` | `(type, items, options?) => Promise<AsyncJob>` | Update up to 100 records asynchronously |\n| `batchDelete` | `(type, ids, options?) => Promise<AsyncJob>` | Delete up to 100 records asynchronously |\n| `batchGet` | `(type, ids, options?) => Promise<AsyncJob>` | Fetch up to 100 records asynchronously |\n| `attachJob` | `(jobIdOrLocation) => AsyncJob` | Re-attach a handle to an existing async job |\n| `request` | `<T>(endpoint, method, options?) => Promise<T>` | Low-level request |\n| `requestRaw` | `<T>(endpoint, method, options?) => Promise<FullResponse<T>>` | Low-level request, full response envelope |\n| `hasValidToken` | `(scope?) => boolean` | Check if a cached token is valid |\n| `clearTokenCache` | `(scope?) => void` | Clear cached tokens |\n| `getTokenExpiration` | `(scope?) => number \\| null` | Get token expiration timestamp |\n\n### Exported Types\n\n```typescript\nimport type {\n  // Client\n  NetSuiteClientOptions,\n  ILogger,\n\n  // Records — options & results\n  GetRecordOptions,     // { expandSubResources?, simpleEnumFormat?, fields? }\n  ListRecordsOptions,   // { limit?, offset?, q? }\n  CreateRecordOptions,  // { replace? }\n  UpdateRecordOptions,  // { replace?, replaceSelectedFields? }\n  UpsertRecordOptions,  // { replace? }\n  RecordRef,            // { id: string; links: Link[] } — shape of list items\n  MutationResult,       // { id, location, success, operationId?, jobId?, propertyValidation? }\n  FullResponse,         // { status, statusCode, statusText, headers, body } — requestRaw/callRestletRaw\n\n  // Records — type definitions (from metadata catalog)\n  NsRef,                // { id: string; refName?: string } — reference field\n  NsSublist,            // { links, items?, totalResults?, ... } — sublist\n  NsRecord,             // Base: { id, externalId?, links, refName }\n  NsLink,               // { rel: string; href: string }\n  NsRefLink,            // NsRef + links — actual shape of reference fields\n  Customer,\n  SalesOrder,\n  Invoice,\n  Vendor,\n  Employee,\n  InventoryItem,\n  Contact,\n  Subsidiary,\n  VendorBill,\n  PurchaseOrder,\n  Department,\n  Classification,       // \"Class\" in NetSuite UI\n  Location,\n  Account,\n  Currency,\n\n  // SuiteQL\n  SuiteQLOptions,       // { limit?, offset? }\n\n  // RESTlets\n  RestletOptions,       // { scriptId, deployId, method, body?, queryParams?, headers? }\n\n  // Metadata\n  MetadataFormat,       // 'json' | 'openapi3' | 'jsonschema'\n  MetadataOptions,      // { select?, format? }\n\n  // Batch / Async\n  BatchSubmitOptions,   // { parallel?, idempotencyKey? }\n  BatchGetOptions,      // BatchSubmitOptions + { expandSubResources?, fields? }\n  BatchMutationItem,    // { id?, externalId?, ...fields } — update/upsert items\n  BatchWaitOptions,     // { pollInterval?, timeout?, signal? }\n  AsyncJobStatus,       // { id, completed, progress, startTime?, endTime? }\n  AsyncTaskRef,         // { id?, links? } — task id lives in the link href in practice\n  AsyncTaskList,        // { count, items, links? }\n  AsyncTaskStatus,      // { id, completed, progress, startTime?, endTime? }\n  BatchItemResult,      // { taskId, success, progress, result?, recordId?, location?, error? }\n  BatchWaitResult,      // { jobId, succeeded, failed, results }\n\n  // Common\n  HttpMethod,           // 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' | 'HEAD' | 'OPTIONS'\n  AuthScope,            // 'rest_webservices' | 'restlets' | 'suite_analytics'\n  PaginatedResponse,    // { items, count, hasMore, offset, totalResults, links? }\n  Link,                 // { rel, href }\n  RequestOptions,       // { headers?, baseHost?, scope? }\n\n  // Errors\n  NetSuiteErrorDetail,  // { detail, errorCode, errorPath?, urlPath? }\n} from '@brokenrubik/netsuite-sdk';\n```\n\n### Exported Classes\n\n```typescript\nimport {\n  NetSuiteClient,\n\n  // Batch job handle (returned by batch methods / attachJob)\n  AsyncJob,\n\n  // Errors\n  NetSuiteError,\n  NetSuiteAuthError,\n  NetSuiteValidationError,\n  NetSuiteNotFoundError,\n  NetSuiteRateLimitError,\n  NetSuiteTimeoutError,\n  NetSuiteIdempotencyError,\n\n  // Logger utilities\n  ConsoleLogger,\n  NoopLogger,\n} from '@brokenrubik/netsuite-sdk';\n```\n\n## Running Tests\n\n### Unit tests\n\n```bash\nnpm test             # Run all tests\nnpm run test:unit    # Unit tests only\nnpm run test:coverage # With coverage report (100% stmt/line/func, 94% branch)\n```\n\n### Integration tests\n\nIntegration tests run against a real NetSuite sandbox. Copy the env template and fill in your credentials:\n\n```bash\ncp .env.template .env\n```\n\n```env\nNETSUITE_ACCOUNT_ID=TSTDRV1234_SB1\nNETSUITE_CLIENT_ID=your-client-id\nNETSUITE_CERTIFICATE_ID=your-cert-id\nNETSUITE_PRIVATE_KEY=\"-----BEGIN PRIVATE KEY-----\\nMIIEv...\\n-----END PRIVATE KEY-----\"\n\n# Optional: RESTlet testing\nNETSUITE_RESTLET_SCRIPT_ID=1234\nNETSUITE_RESTLET_DEPLOY_ID=1\n```\n\nThen run:\n\n```bash\nnpm run test:integration\n```\n\nIntegration tests cover:\n- Full CRUD lifecycle (create, get, get+fields, get+expand, update, list, delete)\n- Reading multiple record types (subsidiary, currency, account, department, item)\n- Pagination with offset and `next` links\n- Record filtering with `q` parameter\n- SuiteQL: SELECT, WHERE, ORDER BY, COUNT, GROUP BY, JOIN, LIKE, pagination, empty results\n- Metadata catalog in JSON, OpenAPI 3.0, and JSON Schema formats\n- Error handling for non-existent records, invalid record types, and invalid SQL\n\nTests skip automatically if credentials are not set.\n\n### CI\n\nGitHub Actions runs on every PR and push to `main`:\n\n| Job | What it does | Required to merge |\n|---|---|---|\n| **Unit Tests** | Runs on Node 18, 20, 22 | Yes |\n| **Coverage** | Enforces coverage thresholds | Yes |\n| **Integration Tests** | Runs against real NetSuite sandbox (via secrets) | No (informational) |\n\nBranch protection on `main` requires Unit Tests and Coverage to pass.\n\n## Official NetSuite Documentation\n\n- [SuiteTalk REST Web Services Overview](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/chapter_1540391670.html)\n- [OAuth 2.0 Setup](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_162686838198.html)\n- [REST API Browser](https://system.netsuite.com/help/helpcenter/en_US/APIs/REST_API_Browser/record/v1/2025.2/index.html)\n- [SuiteQL](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_156257770590.html)\n- [Batch Operations](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_0127092747.html)\n- [Record Collection Filtering](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1545222128.html)\n- [Working with Resource Metadata](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/chapter_1540810168.html)\n- [REST API Practical Advice (Tim Dietrich)](https://timdietrich.me/blog/netsuite-suitetalk-rest-overview-issues-advice/)\n\n## License\n\nISC\n","readmeFilename":"README.md"}