commit 7890669eda43951819f61cede36173f37b7901df Author: Harvey Zuccon Date: Sat May 23 10:39:41 2026 +0200 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d368dbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/node_modules +/data +/dist +.env \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..93c9900 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1834 @@ +{ + "name": "vending-machine", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vending-machine", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@fastify/cors": "^11.2.0", + "@types/debug": "^4.1.13", + "@xo-cash/engine": "file:../../engine", + "better-sqlite3": "^12.10.0", + "debug": "^4.4.3", + "fastify": "^5.8.5", + "kysely": "^0.29.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^25.9.1", + "prettier": "^3.8.3", + "tsx": "^4.22.3", + "typescript": "^6.0.3" + } + }, + "../../engine": { + "name": "@xo-cash/engine", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8", + "@electrum-cash/application": "^0.2.3-development.13447192992", + "@electrum-cash/network": "^4.2.2", + "@electrum-cash/protocol": "^2.3.1", + "@electrum-cash/servers": "^3.1.0", + "@xo-cash/crypto": "0.0.1", + "@xo-cash/primitives": "file:../primitives", + "@xo-cash/state": "file:../state", + "@xo-cash/templates": "0.0.1", + "@xo-cash/types": "0.0.1", + "@xo-cash/utils": "0.0.1", + "eventemitter3": "^5.0.1" + }, + "devDependencies": { + "@chalp/eslint-airbnb": "^1.3.0", + "@generalprotocols/cspell-dictionary": "^1.0.1", + "@stylistic/eslint-plugin": "^5.7.0", + "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/parser": "^8.53.1", + "@vitest/coverage-v8": "^4.0.17", + "@viz-kit/esbuild-analyzer": "^1.0.0", + "@xo-cash/eslint-config": "1.0.1", + "cspell": "^9.6.0", + "eslint": "^9.39.2", + "prettier": "^3.6.2", + "tsdown": "^0.20.0-beta.4", + "typedoc": "^0.28.16", + "typedoc-plugin-coverage": "^4.0.2", + "typescript": "^5.3.2", + "typescript-eslint": "^8.53.1", + "vitest": "^4.0.17" + } + }, + "../engine": { + "name": "@xo-cash/engine", + "version": "0.0.1", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8", + "@electrum-cash/application": "^0.2.3-development.13447192992", + "@electrum-cash/network": "^4.2.2", + "@electrum-cash/protocol": "^2.3.1", + "@electrum-cash/servers": "^3.1.0", + "@xo-cash/crypto": "0.0.1", + "@xo-cash/primitives": "file:../primitives", + "@xo-cash/state": "file:../state", + "@xo-cash/templates": "0.0.1", + "@xo-cash/types": "0.0.1", + "@xo-cash/utils": "0.0.1", + "eventemitter3": "^5.0.1" + }, + "devDependencies": { + "@chalp/eslint-airbnb": "^1.3.0", + "@generalprotocols/cspell-dictionary": "^1.0.1", + "@stylistic/eslint-plugin": "^5.7.0", + "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/parser": "^8.53.1", + "@vitest/coverage-v8": "^4.0.17", + "@viz-kit/esbuild-analyzer": "^1.0.0", + "@xo-cash/eslint-config": "1.0.1", + "cspell": "^9.6.0", + "eslint": "^9.39.2", + "prettier": "^3.6.2", + "tsdown": "^0.20.0-beta.4", + "typedoc": "^0.28.16", + "typedoc-plugin-coverage": "^4.0.2", + "typescript": "^5.3.2", + "typescript-eslint": "^8.53.1", + "vitest": "^4.0.17" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@xo-cash/engine": { + "resolved": "../../engine", + "link": true + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", + "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/kysely": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.2.tgz", + "integrity": "sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba29b41 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "vending-machine", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^25.9.1", + "prettier": "^3.8.3", + "tsx": "^4.22.3", + "typescript": "^6.0.3" + }, + "dependencies": { + "@fastify/cors": "^11.2.0", + "@types/debug": "^4.1.13", + "@xo-cash/engine": "file:../../engine", + "better-sqlite3": "^12.10.0", + "debug": "^4.4.3", + "fastify": "^5.8.5", + "kysely": "^0.29.2", + "zod": "^4.4.3" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c9f4ad0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,47 @@ +import Debug from "debug"; +import { Engine } from "@xo-cash/engine"; + +import { Config } from "./services/config.js"; + +import { Database } from "./services/database/database.js"; + +import { ItemsRoute } from "./routes/items.js"; +import { OrdersRoute } from "./routes/orders.js"; +import { HTTPService } from "./services/http-router.js"; + +type VendingMachineDeps = { + config: Config; + httpService: HTTPService; + database: Database; + engine: Engine; +} + +export class VendingMachine { + static async from(config: Config) { + const debug = Debug("vending-machine"); + + const engine = await Engine.create(config.engine.mnemonic, { databasePath: config.engine.database.path }); + const database = new Database({ config: config.database, debug }); + + // Create the routes + const routes = [ + new ItemsRoute({ database: database, engine: engine, debug }), + new OrdersRoute({ database: database, engine: engine, debug }), + ]; + + // Create the HTTP service, passing in the routes and config. + const httpService = new HTTPService({ routes: [], config: config.server, debug }); + + return new VendingMachine({ config, httpService, database, engine }); + } + + private constructor(private readonly deps: VendingMachineDeps) {} + + public async start() { + await this.deps.httpService.start(); + } +} + +VendingMachine.from(Config.fromEnv()).then((vendingMachine) => { + vendingMachine.start(); +}); diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..4a43a25 --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,2 @@ +export * from './items.js'; +export * from './orders.js'; \ No newline at end of file diff --git a/src/routes/items.ts b/src/routes/items.ts new file mode 100644 index 0000000..345a1a1 --- /dev/null +++ b/src/routes/items.ts @@ -0,0 +1,73 @@ +import type { Debugger as Debug } from 'debug'; +import type { RouteOptions, FastifyRequest, FastifyReply } from 'fastify'; +import type { Engine } from '@xo-cash/engine' +import type { Database } from '../services/database/database.js' + +import { z } from 'zod'; + +export type ItemsRouteDeps = { + database: Database; + engine: Engine; + debug: Debug; +} + +export class ItemsRoute { + public constructor(private readonly deps: ItemsRouteDeps) {} + + public async getRoutes(): Promise> { + return [ + { + method: 'GET', + url: '/items', + handler: this.getItems.bind(this), + }, + { + method: 'GET', + url: '/items/:id', + handler: this.getItem.bind(this), + }, + ] + } + + /** + * Get all items from the database + * @param request + * @param reply + * @returns + */ + private async getItems(request: FastifyRequest, reply: FastifyReply) { + // Get all items from the database. + const items = await this.deps.database.db.selectFrom('items').selectAll().execute(); + + // Return the items. + return reply.send(items); + } + + /** + * Get an item from the database by id + * @param request + * @param reply + * @returns + */ + private async getItem(request: FastifyRequest, reply: FastifyReply) { + // Parse the request parameters. + const { id } = ItemsRoute.getItemSchema.parse(request.params); + + // Get the item from the database. + const item = await this.deps.database.db.selectFrom('items').where('id', '=', id).selectAll().executeTakeFirst(); + + // If the item is not found, return a 404 error. + if (!item) { + return reply.status(404).send({ + error: 'Item not found' + }); + } + + // Return the item. + return reply.send(item); + } + + static getItemSchema = z.object({ + id: z.string(), + }); +} \ No newline at end of file diff --git a/src/routes/orders.ts b/src/routes/orders.ts new file mode 100644 index 0000000..c22d9d0 --- /dev/null +++ b/src/routes/orders.ts @@ -0,0 +1,88 @@ +import type { Debugger as Debug } from 'debug'; +import type { RouteOptions, FastifyRequest, FastifyReply } from 'fastify'; +import type { Engine } from '@xo-cash/engine' +import type { Database } from '../services/database/database.js' + +import { z } from 'zod'; + +export type OrdersRouteDeps = { + database: Database; + engine: Engine + debug: Debug; +} + +export class OrdersRoute { + public constructor(private readonly deps: OrdersRouteDeps) {} + + public async getRoutes(): Promise> { + return [ + { + method: 'GET', + url: '/orders', + handler: this.getOrders.bind(this), + }, + { + method: 'POST', + url: '/orders', + handler: this.createOrder.bind(this), + }, + ] + } + + private async getOrders(request: FastifyRequest, reply: FastifyReply) { + // Get all orders from the database. + const orders = await this.deps.database.db.selectFrom('orders').selectAll().execute(); + + // Return the orders. + return reply.send(orders); + } + + private async createOrder(request: FastifyRequest, reply: FastifyReply) { + // Parse the request body. + const { items: itemsInput } = OrdersRoute.createOrderSchema.parse(request.body); + + // Get the items from the database. + const items = await this.deps.database.db.selectFrom('items').where('id', 'in', itemsInput.map((item) => item.id)).selectAll().execute(); + + // If the items are not found, return a 404 error. + if (items.length !== items.length) { + return reply.status(404).send({ + error: 'Items not found' + }); + } + + // TODO: Create an XO Engine Invitation with the relavent data in it so we can pass it back to the client. + + // Create the order in the database. + const order = await this.deps.database.db.insertInto('orders').values({ + // user_id: request.user.id, + status: 'pending', + total_price: 0, + total_quantity: 0, + items: JSON.stringify(items.map((item) => ({ + id: item.id, + quantity: item.quantity, + }))), + }).execute(); + + // If the order is not created, return a 500 error. + if (!order) { + return reply.status(500).send({ + error: 'Failed to create order' + }); + } + + // Return the order. + return reply.send(order); + } + + /** + * Schema for creating an order. + */ + static createOrderSchema = z.object({ + items: z.array(z.object({ + id: z.string(), + quantity: z.number(), + })), + }); +} \ No newline at end of file diff --git a/src/services/config.ts b/src/services/config.ts new file mode 100644 index 0000000..242353d --- /dev/null +++ b/src/services/config.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +const configSchema = z.object({ + engine: z.object({ + mnemonic: z.string(), + database: z.object({ + path: z.string().default("data/engine"), + }), + }), + syncServer: z.object({ + url: z.string().default("http://localhost:3000"), + }), + database: z.object({ + path: z.string().default("data.db"), + }), + server: z.object({ + port: z.number().default(3000), + host: z.string().default("0.0.0.0"), + cors: z + .object({ + origin: z.string().default("*"), + methods: z + .array(z.string()) + .default(["GET", "POST", "PUT", "DELETE", "OPTIONS"]), + allowedHeaders: z + .array(z.string()) + .default(["Content-Type", "Authorization"]), + }) + .partial() + .prefault({}), + }), +}); + +type ConfigInput = z.input; +type ConfigSchema = z.output; + +/** + * Converts an object's keys to camelCase. + * @param obj - The object to convert to camelCase. + * @returns The camelCase object. + */ +const toCamelCaseObject = (obj: Record): Record => { + return Object.fromEntries(Object.entries(obj).map(([key, value]) => { + const camelCaseKey = key.toLowerCase().replace(/([-_][a-z])/g, (group) => group.toUpperCase().replace("-", "").replace("_", "")); + return [camelCaseKey, value]; + })); +} + +/** + * The Config class is used to load and parse the configuration for the vending machine. + */ +export class Config { + static fromEnv(): Config { + // Parse through process.env, and convert the upperCase keys to camelCase. + const envConfig = toCamelCaseObject(Object(process.env)); + + // Parse the environment config. + return this.from(configSchema.parse(envConfig)); + } + + static from(config: ConfigInput): Config { + return new Config(configSchema.parse(config)); + } + + public get syncServer() { + return this.config.syncServer; + } + + public get engine() { + return this.config.engine; + } + + public get database() { + return this.config.database; + } + + public get server() { + return this.config.server; + } + + private constructor(private readonly config: ConfigSchema) {} +} diff --git a/src/services/database/database.ts b/src/services/database/database.ts new file mode 100644 index 0000000..894ef98 --- /dev/null +++ b/src/services/database/database.ts @@ -0,0 +1,84 @@ +import { type Debugger } from "debug"; + +import DatabaseConstructor from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import type { Database as DatabaseTables } from "./tables.js"; + +import { z } from "zod"; +import { debuggerSchema } from "../../types.js"; + +export const databaseOptionsSchema = z.object({ + config: z.object({ + path: z.string(), + }), + + debug: debuggerSchema, +}); + +type DatabaseOptionsInput = z.input; + +/** + * Database service that owns SQLite + Kysely connections. + * + * @remarks + * This service is intentionally connection-only: + * it manages lifecycle and exposes a typed Kysely client. + */ +export class Database { + // Debugger instance used for logging. + private readonly debug: Debugger; + + // SQLite connection instance. + private readonly sqlite: DatabaseConstructor.Database; + + // Kysely database client instance. + private readonly kysely: Kysely; + + public constructor(options: DatabaseOptionsInput) { + // Parse the options with the zod schema. + const { config, debug } = databaseOptionsSchema.parse(options); + + // Extend the debug instance. + this.debug = debug.extend("database"); + + // Create the SQLite database instance. + this.sqlite = new DatabaseConstructor(config.path); + + // Configure the SQLite database. + this.configurePragmas(); + + // Create the Kysely database client. + this.kysely = new Kysely({ dialect: new SqliteDialect({ database: this.sqlite }) }); + } + + /** + * Accessor for the typed Kysely database client. + */ + public get db(): Kysely { + return this.kysely; + } + + /** + * Gracefully closes all storage resources. + */ + public async destroy(): Promise { + this.debug("Destroying storage resources"); + await this.kysely.destroy(); + } + + /** + * Applies required SQLite pragmas for correctness and performance. + */ + private configurePragmas(): void { + this.debug("Configuring SQLite pragmas"); + /** + * WAL improves concurrent read behavior, useful for streaming-heavy APIs. + */ + this.sqlite.pragma("journal_mode = WAL"); + + /** + * Foreign keys are disabled by default in SQLite; enable explicitly. + */ + this.sqlite.pragma("foreign_keys = ON"); + } +} diff --git a/src/services/database/migrate.ts b/src/services/database/migrate.ts new file mode 100644 index 0000000..69fae8e --- /dev/null +++ b/src/services/database/migrate.ts @@ -0,0 +1,50 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { FileMigrationProvider, Migrator } from "kysely/migration"; + +import type { Database } from "./database.js"; + +/** + * Handles migration execution for the storage layer. + */ +export class MigrationService { + private readonly migrator: Migrator; + + public constructor(database: Database) { + const currentFilePath = fileURLToPath(import.meta.url); + const currentDirectory = path.dirname(currentFilePath); + const migrationsPath = path.join(currentDirectory, "migrations"); + + this.migrator = new Migrator({ + db: database.db, + provider: new FileMigrationProvider({ + fs, + path, + migrationFolder: migrationsPath, + }), + }); + } + + /** + * Runs all pending migrations. + * + * @throws Error when one or more migrations fail. + */ + public async migrateToLatest(): Promise { + const { error, results } = await this.migrator.migrateToLatest(); + + for (const result of results ?? []) { + if (result.status === "Success") { + console.info(`[migration] Applied: ${result.migrationName}`); + } else if (result.status === "Error") { + console.error(`[migration] Failed: ${result.migrationName}`); + } + } + + if (error) { + throw error; + } + } +} diff --git a/src/services/database/migrations/001-initial-schema.ts b/src/services/database/migrations/001-initial-schema.ts new file mode 100644 index 0000000..8ac6918 --- /dev/null +++ b/src/services/database/migrations/001-initial-schema.ts @@ -0,0 +1,153 @@ +import { Kysely, sql } from "kysely"; + +import type { Database } from "../tables.js"; + +/** + * UUID v4 default for primary key columns. + * + * @remarks + * SQLite has no native UUID type, so we generate v4 strings in SQL. + */ +const uuid4Default = sql`(lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1,1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))))`; + +/** + * Millisecond timestamp for created_at/updated_at column defaults. + * + * @remarks + * unixepoch('subsec') returns seconds with a fractional part; multiplying by + * 1000 and casting to INTEGER gives a millisecond epoch value. + */ +const millisecondTime = sql`(CAST(unixepoch('subsec') * 1000 AS INTEGER))`; + +/** + * Same expression as {@link millisecondTime}, but as a raw SQL string. + * + * @remarks + * SQLite triggers cannot use Kysely's `sql` tagged templates directly, so we + * keep a plain string copy for trigger bodies. + */ +const millisecondTimeRaw = `(CAST(unixepoch('subsec') * 1000 AS INTEGER))`; + +/** + * Tables that receive an automatic updated_at trigger on row modification. + */ +const UPDATED_AT_TRIGGER_TABLES = ["users", "items", "orders"] as const; + +/** + * Initial schema for the vending machine. + * + * @remarks + * Creates users, catalog items, and orders. Orders store a JSON snapshot of + * line items in the `items` column rather than a normalized join table. + */ +export async function up(db: Kysely): Promise { + // ------------------------------------------------------------------------- + // Users + // ------------------------------------------------------------------------- + // Authentication credentials for vending machine operators / customers. + await db.schema + .createTable("users") + .addColumn("id", "text", (col) => col.primaryKey().defaultTo(uuid4Default)) + .addColumn("username", "text", (col) => col.notNull().unique()) + .addColumn("password", "text", (col) => col.notNull()) + .addColumn("salt", "text", (col) => col.notNull()) + .addColumn("created_at", "integer", (col) => col.notNull().defaultTo(millisecondTime)) + .addColumn("updated_at", "integer", (col) => col.notNull().defaultTo(millisecondTime)) + .execute(); + + // ------------------------------------------------------------------------- + // Items + // ------------------------------------------------------------------------- + // Vending machine catalog entries available for purchase. + await db.schema + .createTable("items") + .addColumn("id", "text", (col) => col.primaryKey().defaultTo(uuid4Default)) + .addColumn("name", "text", (col) => col.notNull()) + .addColumn("description", "text", (col) => col.notNull().defaultTo("")) + // Price stored as an integer in the smallest currency unit (e.g. sats). + .addColumn("price", "integer", (col) => col.notNull()) + // Current stock level for this slot/product. + .addColumn("quantity", "integer", (col) => col.notNull().defaultTo(0)) + // URL or path to the product image shown in the UI. + .addColumn("image", "text", (col) => col.notNull().defaultTo("")) + .addColumn("created_at", "integer", (col) => col.notNull().defaultTo(millisecondTime)) + .addColumn("updated_at", "integer", (col) => col.notNull().defaultTo(millisecondTime)) + .addCheckConstraint("items_price_check", sql`price >= 0`) + .addCheckConstraint("items_quantity_check", sql`quantity >= 0`) + .execute(); + + // ------------------------------------------------------------------------- + // Orders + // ------------------------------------------------------------------------- + // A purchase attempt. Line items are denormalized into JSON at creation time. + await db.schema + .createTable("orders") + .addColumn("id", "text", (col) => col.primaryKey().defaultTo(uuid4Default)) + .addColumn("status", "text", (col) => col.notNull().defaultTo("pending")) + .addColumn("total_price", "integer", (col) => col.notNull().defaultTo(0)) + .addColumn("total_quantity", "integer", (col) => col.notNull().defaultTo(0)) + // JSON array of { id, quantity } objects; serialized by application code. + .addColumn("items", "text", (col) => col.notNull().defaultTo("[]")) + .addColumn("created_at", "integer", (col) => col.notNull().defaultTo(millisecondTime)) + .addColumn("updated_at", "integer", (col) => col.notNull().defaultTo(millisecondTime)) + .addCheckConstraint( + "orders_status_check", + sql`status in ('pending', 'paid', 'completed', 'cancelled')`, + ) + .addCheckConstraint("orders_total_price_check", sql`total_price >= 0`) + .addCheckConstraint("orders_total_quantity_check", sql`total_quantity >= 0`) + .execute(); + + // ------------------------------------------------------------------------- + // Indexes + // ------------------------------------------------------------------------- + // Look up catalog entries by display name. + await db.schema.createIndex("idx_items_name").on("items").column("name").execute(); + + // Filter orders by lifecycle state (e.g. pending payments). + await db.schema + .createIndex("idx_orders_status") + .on("orders") + .column("status") + .execute(); + + // List recent orders chronologically. + await db.schema + .createIndex("idx_orders_created_at") + .on("orders") + .columns(["created_at"]) + .execute(); + + // ------------------------------------------------------------------------- + // Updated-at triggers + // ------------------------------------------------------------------------- + // Keep updated_at in sync without requiring every query to set it explicitly. + for (const tableName of UPDATED_AT_TRIGGER_TABLES) { + await sql + .raw(` + CREATE TRIGGER trg_${tableName}_updated_at + AFTER UPDATE ON ${tableName} + FOR EACH ROW + BEGIN + UPDATE ${tableName} + SET updated_at = ${millisecondTimeRaw} + WHERE id = NEW.id; + END; + `) + .execute(db); + } +} + +/** + * Drops the full schema in reverse dependency order. + */ +export async function down(db: Kysely): Promise { + // Remove triggers before dropping the tables they reference. + for (const tableName of UPDATED_AT_TRIGGER_TABLES) { + await sql.raw(`DROP TRIGGER IF EXISTS trg_${tableName}_updated_at`).execute(db); + } + + await db.schema.dropTable("orders").ifExists().execute(); + await db.schema.dropTable("items").ifExists().execute(); + await db.schema.dropTable("users").ifExists().execute(); +} diff --git a/src/services/database/tables.ts b/src/services/database/tables.ts new file mode 100644 index 0000000..c29c15d --- /dev/null +++ b/src/services/database/tables.ts @@ -0,0 +1,71 @@ +import type { ColumnType, Generated } from "kysely"; + +/** + * SQLite timestamp column represented as INTEGER. + * + * @remarks + * The application stores timestamps as numbers and may provide explicit + * values or rely on database defaults. + */ +export type Timestamp = ColumnType; + +/** + * SQLite JSON column represented as TEXT. + * + * @remarks + * Serialize values with JSON.stringify and parse with JSON.parse in app code. + */ +export type JsonText = ColumnType; + +/** + * SQLite boolean emulation represented as INTEGER (0/1). + */ +export type SqliteBoolean = ColumnType; + +/** + * Users table. + */ +export interface UsersTable { + id: Generated; + username: string; + password: string; + salt: string; + created_at: Generated; + updated_at: Generated; +} + +/** + * Items table. + */ +export interface ItemsTable { + id: Generated; + name: string; + description: string; + price: number; + quantity: number; + image: string; + created_at: Generated; + updated_at: Generated; +} + +/** + * Orders table. + */ +export interface OrdersTable { + id: Generated; + status: string; + total_price: number; + total_quantity: number; + items: JsonText; + created_at: Generated; + updated_at: Generated; +} + +/** + * Kysely database schema. + */ +export interface Database { + users: UsersTable; + items: ItemsTable; + orders: OrdersTable; +} diff --git a/src/services/http-router.ts b/src/services/http-router.ts new file mode 100644 index 0000000..5cf1d0c --- /dev/null +++ b/src/services/http-router.ts @@ -0,0 +1,153 @@ +import Debug from "debug"; +import { z } from "zod"; + +import fastify, { type FastifyInstance, type RouteOptions } from "fastify"; +import cors from "@fastify/cors"; + +import { debuggerSchema } from "../types.js"; + +// Interface to add to our route classes so that we can register them. +// NOTE: I hate this pattern. But ExpressJS is odd in that it is structured as a singleton that still needs registration. +export interface APIRoutes { + getRoutes(): Promise>; +} + +// Zod schema for the API routes. +export const apiRoutesSchema = z.array(z.custom()); + +// Zod schema for the server config. +export const serverConfigSchema = z.object({ + port: z.number().default(3000), + host: z.string().default("0.0.0.0"), + cors: z.object({ + origin: z.string().default("*"), + methods: z.array(z.string()).default(["GET", "POST", "PUT", "DELETE", "OPTIONS"]), + allowedHeaders: z.array(z.string()).default(["Content-Type", "Authorization"]), + }).prefault({}), +}); + +// Zod schema for the server debug instance. +export const serverDebugSchema = debuggerSchema.optional().default(Debug("vending-machine")); + +// Zod schema for the HTTP service options. +export const HTTPOptions = z.object({ + routes: apiRoutesSchema, + config: serverConfigSchema, + debug: serverDebugSchema, +}); + +// Types. +type HTTPServiceOptionsInput = z.input; +type HTTPServiceOptions = z.output; + +export class HTTPService { + static schema() { + return z.object({ + routes: apiRoutesSchema, + config: serverConfigSchema, + debug: serverDebugSchema, + }); + } + + // Public properties. + private server: FastifyInstance; + + // Private properties. + private debug: debug.Debugger; + private config: HTTPServiceOptions["config"]; + private routes: HTTPServiceOptions["routes"]; + + constructor(options: HTTPServiceOptionsInput) { + const { routes, config, debug } = HTTPOptions.parse(options); + + // Extend the debug instance. + this.debug = debug.extend("http-router"); + + // Set the config. + this.config = config; + + // Set the routes. + this.routes = routes; + + // Create the server. + this.server = fastify({ + logger: false, + }); + } + + async start(): Promise { + // Debug the server starting. + this.debug( + `Starting HTTP server on http://${this.config.host}:${this.config.port}`, + ); + + // Setup Error Handling (to give more verbose Zod errors) + this.handleErrors(); + + // Allow CORS requests. This allows requests from any origin/domain. + // TODO: Set this to a meaningful value. For now, we allow all origins since we dont know what the origin will bes. + await this.server.register(cors); + + // Register your routes here before starting the server + this.server.get("/health", async () => { + return { status: "ok" }; + }); + + // Register each route. + for (const routes of this.routes) { + for (const routeOptions of await routes.getRoutes()) { + this.server.route(routeOptions); + } + } + + // Ready the server. + await this.server.ready(); + + // Listen on the configured port and host. + await this.server.listen({ + port: this.config.port, + host: this.config.host, + }); + + // Debug the server started. + this.debug( + `Started HTTP server on http://${this.config.host}:${this.config.port}`, + ); + } + + // Helper method to access the server instance + getInstance(): FastifyInstance { + return this.server; + } + + private handleErrors() { + // Customize our error handler to give better errors. + // NOTE: This will nicely format the Zod validation errors. + this.server.setErrorHandler((error, _request, reply) => { + // If the error is a Zod error, format the errors and send them to the client. + if (error instanceof z.ZodError) { + // Format the errors. + const formattedErrors = error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })); + + // Debug the validation error. + this.debug(`Validation Error: ${error}`); + + // Send the validation error to the client. + return reply.status(400).send({ + statusCode: 400, + error: "Validation Error", + details: formattedErrors, + }); + } + + // Debug the internal server error. + this.debug(`Internal Server Error: ${error}`); + + // Handle other types of errors + reply.status(500).send({ error: "Internal Server Error" }); + }); + } +} diff --git a/src/services/sse-broadcaster.ts b/src/services/sse-broadcaster.ts new file mode 100644 index 0000000..db82bbb --- /dev/null +++ b/src/services/sse-broadcaster.ts @@ -0,0 +1,270 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; + +import debug, { type Debugger } from "debug"; + +/** + * Represents an event stored in the history buffer. + * Used for replaying missed events to reconnecting clients. + */ +interface HistoricalEvent { + /** The event topic/type (e.g., 'invitation-created', 'invitation-updated') */ + topic: string; + /** The event payload data */ + data: unknown; + /** Unix timestamp in milliseconds when the event was created */ + timestamp: number; +} + +/** + * Options for configuring the SSE service. + */ +interface SSEOptions { + /** Maximum age of events to keep in history (in milliseconds). Default: 5 minutes */ + maxHistoryAge?: number; + /** Maximum number of events to keep per user. Default: 1000 */ + maxHistorySize?: number; +} + +/** + * Server-Sent Events broadcaster with event history support. + * + * Maintains a per-user event history buffer that allows clients to replay + * missed events when reconnecting. This makes reconnections robust against + * network interruptions. + */ +export class SSEBroadcaster { + /** + * Factory method to create and start an SSE broadcaster. + * @param options - Configuration options for the SSE service + * @returns A started SSE instance + */ + static async from(options?: SSEOptions) { + const broadcaster = new SSEBroadcaster(options); + await broadcaster.start(); + return broadcaster; + } + + /** Map of Invitation IDs to their connected SSE response streams */ + private clients: Map<(string), Set> = new Map(); + + /** Map of Invitation IDs to their event history buffers */ + private eventHistory: Map = new Map(); + + /** Maximum age of events to keep in history (in milliseconds) */ + private maxHistoryAge: number; + + /** Maximum number of events to keep per user */ + private maxHistorySize: number; + + private debug: Debugger; + + constructor(options?: SSEOptions) { + this.clients = new Map(); + this.eventHistory = new Map(); + this.maxHistoryAge = options?.maxHistoryAge ?? 20 * 60 * 1000; // 20 minutes default + this.maxHistorySize = options?.maxHistorySize ?? 1000; // 1000 events default + this.debug = debug('xo:sse'); + } + + /** + * Starts the SSE broadcaster. + * @returns The SSE instance for chaining + */ + start() { + this.debug('SSE broadcaster is running (maxHistoryAge: %dms, maxHistorySize: %d)', + this.maxHistoryAge, this.maxHistorySize); + return this; + } + + /** + * Sends an event to a client. + * + * @param client - The client to send the event to + * @param topic - The event topic/type + * @param data - The event payload data + */ + static sendEvent(client: FastifyReply, topic: string, data: unknown) { + const timestamp = Date.now(); + client.raw.write(`id: ${timestamp}\n`); + client.raw.write(`event: ${topic}\n`); + client.raw.write(`data: ${JSON.stringify(data)}\n\n`); + } + + /** + * Sends an event to a client. + * + * @param client - The client to send the event to + * @param topic - The event topic/type + * @param data - The event payload data + */ + sendEvent(client: FastifyReply, topic: string, data: unknown) { + try { + SSEBroadcaster.sendEvent(client, topic, data); + } catch (error) { + this.debug('Error sending event to client', error); + } + } + + /** + * Broadcasts an event to all connected clients for a user and stores it in history. + * + * @param clientId - The user ID to broadcast to + * @param topic - The event topic/type + * @param data - The event payload data + */ + async broadcast(clientId: string, topic: string, data: unknown) { + const timestamp = Date.now(); + + // Store the event in history for potential replay + this.storeEvent(clientId, topic, data, timestamp); + + // Broadcast to all connected clients + this.clients.get(clientId)?.forEach((client: FastifyReply) => { + try { + this.sendEvent(client, topic, data); + } catch (error) { + this.debug('Error sending event to client', error); + } + }); + + this.debug('SSE broadcasted message', topic, data); + } + + /** + * Subscribes a client to receive SSE events. + * + * If lastEventTime is provided, all events that occurred after that timestamp + * will be replayed to the client before starting the live stream. + * + * @param req - The authenticated request containing the user ID + * @param res - The Express response object to use for SSE streaming + * @param lastEventTime - Optional timestamp to replay events from (in milliseconds) + */ + async subscribe(req: FastifyRequest, res: FastifyReply, lastEventTime?: number) { + // Get the invitation ID from the request + const { invitationIdentifier } = req.query as { invitationIdentifier?: string }; + if (!invitationIdentifier) { + throw new Error('Invitation Identifier is required'); + } + + // Initialize client set for this user if needed + if (!this.clients.has(invitationIdentifier)) { + this.clients.set(invitationIdentifier, new Set()); + } + + // Manually include the CORS header since `writeHead` bypasses the auto-injection by the @fastify/cors plugin. + // This statement grabs the CORS header that the CORS plugin would have added to the response, configured in the HTTP service. + const corsHeader = res.getHeader("access-control-allow-origin"); + + // Disable timeout for the connection. Without this, the connection will drop and the client will have to reconnect. + req.raw.socket.setTimeout(0); + + // Set up SSE headers + // Set headers for SSE + res.raw.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": corsHeader, + }); + + // Force the headers to be dispatched to the client. + // NOTE: This is very important: A `fetch` call will NOT resolve until it has received the headers. + // And Fastify, unless otherwise specified, will not send the headers until it sends the body. + res.raw.flushHeaders(); + + // Set retry interval for automatic reconnection + res.raw.write('retry: 3000\n\n'); + + // Replay missed events if a lastEventTime was provided + if (lastEventTime !== undefined) { + const missedEvents = this.getEventsAfter(invitationIdentifier, lastEventTime); + this.debug('SSE replaying %d missed events for invitation %s (since %d)', + missedEvents.length, invitationIdentifier, lastEventTime); + + for (const event of missedEvents) { + try { + await this.sendEvent(res, event.topic, event.data); + } catch (error) { + this.debug('Error sending event to client', error); + } + } + } + + // Add client to the set for live updates + this.clients.get(invitationIdentifier)?.add(res); + + this.debug('SSE subscribed to client (invitationId: %s, lastEventTime: %s)', + invitationIdentifier, lastEventTime ?? 'none'); + + // Clean up when client disconnects + res.raw.on('close', () => { + this.clients.get(invitationIdentifier)?.delete(res); + this.debug('SSE client disconnected (invitationIdentifier: %s)', invitationIdentifier); + }); + } + + /** + * Stores an event in the user's history buffer. + * Automatically prunes old events based on age and size limits. + * + * @param invitationId - The invitation ID to store the event for + * @param topic - The event topic/type + * @param data - The event payload data + * @param timestamp - The event timestamp + */ + private storeEvent(invitationId: string, topic: string, data: unknown, timestamp: number) { + // Initialize history array for this user if needed + if (!this.eventHistory.has(invitationId)) { + this.eventHistory.set(invitationId, []); + } + + const history = this.eventHistory.get(invitationId)!; + + // Add the new event + history.push({ topic, data, timestamp }); + + // Prune old events + this.pruneHistory(invitationId, timestamp); + } + + /** + * Removes old events from a invitation's history based on age and size limits. + * + * @param invitationId - The invitation ID whose history to prune + * @param currentTime - The current timestamp for age calculations + */ + private pruneHistory(invitationId: string, currentTime: number) { + const history = this.eventHistory.get(invitationId); + if (!history) return; + + const cutoffTime = currentTime - this.maxHistoryAge; + + // Remove events older than maxHistoryAge + const prunedByAge = history.filter(event => event.timestamp > cutoffTime); + + // If still over size limit, remove oldest events + const prunedBySize = prunedByAge.length > this.maxHistorySize + ? prunedByAge.slice(-this.maxHistorySize) + : prunedByAge; + + this.eventHistory.set(invitationId, prunedBySize); + } + + /** + * Retrieves all events for a user that occurred after a given timestamp. + * + * @param invitationId - The invitation ID to get events for + * @param afterTimestamp - The timestamp to get events after (exclusive) + * @returns Array of events that occurred after the timestamp + */ + private getEventsAfter(invitationId: string, afterTimestamp: number): HistoricalEvent[] { + const history = this.eventHistory.get(invitationId); + if (!history) return []; + + // First prune old events to ensure we don't return stale data + this.pruneHistory(invitationId, Date.now()); + + return history.filter(event => event.timestamp > afterTimestamp); + } +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..cc89351 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,18 @@ +import { type Debugger } from "debug"; +import { z } from "zod"; + +export const isDebugger = (value: unknown): value is Debugger => { + if (typeof value !== "function") return false; + + const candidate = value as Partial; + + return ( + typeof candidate.namespace === "string" && + typeof candidate.extend === "function" && + typeof candidate.enabled === "boolean" + ); +}; + +export const debuggerSchema = z.custom(isDebugger, { + message: "Expected a debug.Debugger instance", +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e68bd0b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,40 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + // File Layout + "rootDir": "./src", + "outDir": "./dist", + + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "target": "esnext", + "types": ["node"], + + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + // Style Options + // "noImplicitReturns": true, + // "noImplicitOverride": true, + // "noUnusedLocals": true, + // "noUnusedParameters": true, + // "noFallthroughCasesInSwitch": true, + // "noPropertyAccessFromIndexSignature": true, + + // Recommended Options + "strict": true, + "jsx": "react-jsx", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + } +}