diff --git a/.gitignore b/.gitignore index dbf7979..393241c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Dependencies node_modules/ +sync-backend/node_modules/ # Build output dist/ @@ -19,6 +20,10 @@ Thumbs.db .env .env.local .env.*.local +sync-backend/.env + +# Local sync backend data +sync-backend/data/ # Logs *.log diff --git a/README.md b/README.md index a46b79f..173aa46 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ ## What is PrepTrack? -PrepTrack is a **free, ad-free, offline-first Progressive Web App** for managing your emergency supplies, pantry, and stockpile. Scan barcodes, track expiry dates, receive local notifications — all data stays on your device. No cloud. No accounts. No tracking. +PrepTrack is a **free, ad-free, offline-first Progressive Web App** for managing your emergency supplies, pantry, and stockpile. Scan barcodes, track expiry dates, receive local notifications — all data stays on your device by default. No accounts. No tracking. > **Built for preppers, self-sufficiency enthusiasts, and anyone who wants to keep their supplies organized.** @@ -82,6 +82,7 @@ PrepTrack supports **6 languages** with full translations for every screen, noti | **Consumption Log** | Mark products as consumed, expired, or damaged. View statistics. | | **Data Export** | JSON backup (complete) and CSV export (Excel/Google Sheets compatible with proper encoding). | | **Data Import** | Restore from backup with automatic duplicate detection. | +| **Optional LAN Sync** | Pair devices with a shared sync code and sync via your own Docker-hosted backend. | | **Offline-First** | Fully functional offline. All data in IndexedDB. Service Worker caches assets, fonts, and API responses. | | **Installable PWA** | Install as native app on Android, iOS, and Desktop. | | **Dark & Light Mode** | Dark theme by default. Toggle to light theme anytime. Accessible color contrast in both modes. | @@ -172,6 +173,7 @@ PrepTrack supports **6 languages** with full translations for every screen, noti | **Icons** | Lucide React | Consistent, lightweight SVG icons | | **Animation** | Framer Motion | Smooth UI transitions | | **API** | Open Food Facts | Free product database with images | +| **Optional Backend** | Fastify + SQLite (Docker) | LAN device synchronization | | **CI/CD** | GitHub Actions | Auto-deploy to GitHub Pages | | **Testing** | Vitest | Unit tests (59 tests) | @@ -205,10 +207,20 @@ The app is available at `http://localhost:5173`. ```bash npm run build # Production build (tsc + vite) npm run preview # Preview production build locally -npm run test # Run all tests (Vitest) +npm run test # Frontend + sync backend tests (Vitest) npx tsc --noEmit # Type check without building +npm --prefix sync-backend run test # Sync backend integration tests only ``` +### Optional LAN Sync Backend + +```bash +docker compose -f docker-compose.sync.yml up -d --build +curl http://localhost:8787/health +``` + +Open Settings in the app, enter your backend URL (for example `http://192.168.0.20:8787`), set a shared sync code, and pair each device. + ### Deploy (GitHub Pages) 1. Push to your GitHub repository @@ -252,7 +264,7 @@ npx tsc --noEmit # Type check without building PrepTrack takes your privacy seriously: -- **All data stored locally** on your device (IndexedDB). No cloud. No servers. No accounts. +- **Default mode is local-only** on your device (IndexedDB). Optional self-hosted LAN sync can be enabled in Settings. - **No tracking.** No analytics. No cookies. No ads. No data shared with third parties. - **External service:** Only the Open Food Facts API is contacted during barcode scans (open-source, non-profit). Only the barcode is transmitted. - **Notifications** are generated locally. No push tokens sent to external servers. @@ -383,7 +395,7 @@ See [LICENSE](LICENSE) for the full license text. ## Was ist PrepTrack? -PrepTrack ist eine **kostenlose, werbefreie, Offline-first Progressive Web App** zur Verwaltung von Vorräten. Produkte scannen, Mindesthaltbarkeitsdaten tracken, Benachrichtigungen erhalten — alle Daten bleiben auf deinem Gerät. Keine Cloud. Keine Accounts. Kein Tracking. +PrepTrack ist eine **kostenlose, werbefreie, Offline-first Progressive Web App** zur Verwaltung von Vorräten. Produkte scannen, Mindesthaltbarkeitsdaten tracken, Benachrichtigungen erhalten — standardmäßig bleiben alle Daten auf deinem Gerät. Keine Accounts. Kein Tracking. > **Entwickelt für Prepper, Selbstversorger und alle, die ihren Vorrat im Griff haben wollen.** @@ -565,7 +577,7 @@ npx tsc --noEmit # Type-Check ohne Build ## 🔒 Datenschutz -- **Alle Daten lokal** auf deinem Gerät (IndexedDB). Keine Cloud. Keine Server. Keine Accounts. +- **Standardmodus ist lokal** auf deinem Gerät (IndexedDB). Optional kann ein selbst gehosteter LAN-Sync in den Einstellungen aktiviert werden. - **Kein Tracking.** Keine Analytics. Keine Cookies. Keine Werbung. - **Externer Dienst:** Nur die Open Food Facts API wird beim Barcode-Scan kontaktiert (Open Source, gemeinnützig). Es wird nur der Barcode übermittelt. - **Benachrichtigungen** werden lokal erzeugt. Keine Push-Tokens an externe Server. diff --git a/docker-compose.sync.yml b/docker-compose.sync.yml new file mode 100644 index 0000000..1bc3377 --- /dev/null +++ b/docker-compose.sync.yml @@ -0,0 +1,16 @@ +services: + preptrack-sync: + build: + context: ./sync-backend + container_name: preptrack-sync + restart: unless-stopped + ports: + - "8787:8787" + environment: + PORT: "8787" + DB_PATH: "/data/sync.db" + volumes: + - preptrack_sync_data:/data + +volumes: + preptrack_sync_data: diff --git a/package-lock.json b/package-lock.json index 077a807..d27aeb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "preptrack", "version": "1.88_Build_9426", + "license": "Apache-2.0", "dependencies": { "@zxing/browser": "^0.1.5", "@zxing/library": "^0.21.3", @@ -22,12 +23,16 @@ "zustand": "^5.0.3" }, "devDependencies": { + "@fastify/cors": "^11.2.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "better-sqlite3": "^12.6.2", + "fake-indexeddb": "^6.2.5", + "fastify": "^5.8.2", "jsdom": "^25.0.1", "postcss": "^8.4.49", "sharp": "^0.34.5", @@ -2221,6 +2226,144 @@ "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -2820,6 +2963,13 @@ "node": ">= 8" } }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3601,6 +3751,13 @@ "license": "(Unlicense OR Apache-2.0)", "optional": 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==", + "dev": true, + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3641,6 +3798,24 @@ "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3787,6 +3962,16 @@ "node": ">= 4.0.0" } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -3840,6 +4025,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "dev": true, + "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/babel-plugin-polyfill-corejs2": { "version": "0.4.16", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz", @@ -3892,6 +4098,27 @@ "node": "18 || 20 || >=22" } }, + "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==", + "dev": true, + "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/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -3905,6 +4132,21 @@ "node": ">=6.0.0" } }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "dev": true, + "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" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3918,6 +4160,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", @@ -3978,6 +4242,31 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "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/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4141,6 +4430,13 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4181,6 +4477,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-js-compat": { "version": "3.48.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", @@ -4360,6 +4670,22 @@ "dev": true, "license": "MIT" }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -4370,6 +4696,16 @@ "node": ">=6" } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -4523,6 +4859,16 @@ "dev": true, "license": "ISC" }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -4748,6 +5094,16 @@ "node": ">=0.10.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==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -4758,14 +5114,31 @@ "node": ">=12.0.0" } }, - "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==", + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", @@ -4802,6 +5175,41 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -4819,6 +5227,70 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastify": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz", + "integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==", + "dev": true, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastify/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -4829,6 +5301,13 @@ "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==", + "dev": true, + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -4882,6 +5361,21 @@ "node": ">=8" } }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -4973,6 +5467,13 @@ } } }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -5129,6 +5630,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -5398,6 +5906,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "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/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -5408,6 +5937,20 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "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==", + "dev": true, + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5423,6 +5966,16 @@ "node": ">= 0.4" } }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6000,6 +6553,26 @@ "dev": true, "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "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==", + "dev": true, + "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", @@ -6053,6 +6626,45 @@ "node": ">=6" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -6210,6 +6822,19 @@ "node": ">= 0.6" } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -6236,6 +6861,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -6246,6 +6881,13 @@ "node": ">=16 || 14 >=14.17" } }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/motion-dom": { "version": "11.18.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", @@ -6299,6 +6941,39 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.88.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz", + "integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -6387,6 +7062,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -6516,6 +7211,46 @@ "node": ">=0.10.0" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -6699,6 +7434,34 @@ "dev": true, "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.", + "dev": true, + "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/pretty-bytes": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", @@ -6728,6 +7491,34 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6759,6 +7550,13 @@ ], "license": "MIT" }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -6769,6 +7567,22 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "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/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -6849,6 +7663,21 @@ "pify": "^2.3.0" } }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -6862,6 +7691,16 @@ "node": ">=8.10.0" } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7009,6 +7848,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -7020,6 +7869,13 @@ "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==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -7172,6 +8028,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -7201,6 +8087,23 @@ "loose-envify": "^1.1.0" } }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -7221,6 +8124,13 @@ "randombytes": "^2.1.0" } }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7447,6 +8357,53 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "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/smob": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz", @@ -7457,6 +8414,16 @@ "node": ">=20.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==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -7539,6 +8506,16 @@ "dev": true, "license": "MIT" }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -7567,6 +8544,16 @@ "node": ">= 0.4" } }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -7692,6 +8679,16 @@ "node": ">=8" } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -7773,6 +8770,36 @@ "node": ">=14.0.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==", + "dev": true, + "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==", + "dev": true, + "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/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -7851,6 +8878,19 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -7976,6 +9016,16 @@ "node": ">=8.0" } }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", @@ -8024,6 +9074,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-fest": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", @@ -10024,6 +11087,13 @@ "workbox-core": "7.4.0" } }, + "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==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", diff --git a/package.json b/package.json index 8eaadbc..36fbb43 100644 --- a/package.json +++ b/package.json @@ -28,12 +28,16 @@ "zustand": "^5.0.3" }, "devDependencies": { + "@fastify/cors": "^11.2.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "better-sqlite3": "^12.6.2", + "fake-indexeddb": "^6.2.5", + "fastify": "^5.8.2", "jsdom": "^25.0.1", "postcss": "^8.4.49", "sharp": "^0.34.5", diff --git a/src/App.tsx b/src/App.tsx index afaa2ad..135df27 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, lazy, Suspense } from 'react'; import { useAppStore } from './store/useAppStore'; import { seedDefaults } from './lib/db'; import { startNotificationChecker } from './lib/notifications'; +import { startSyncEngine } from './lib/sync'; import { ErrorBoundary } from './components/ErrorBoundary'; import { OfflineBanner } from './components/OfflineBanner'; import { PWAInstallPrompt } from './components/PWAInstallPrompt'; @@ -69,7 +70,11 @@ export default function App() { ); const interval = startNotificationChecker(); - return () => clearInterval(interval); + const stopSync = startSyncEngine(); + return () => { + clearInterval(interval); + stopSync(); + }; }, []); return ( diff --git a/src/components/ProductList.tsx b/src/components/ProductList.tsx index 60f3c76..e01b270 100644 --- a/src/components/ProductList.tsx +++ b/src/components/ProductList.tsx @@ -33,7 +33,7 @@ import { Plus, } from 'lucide-react'; import { useState, useEffect, useCallback, useMemo } from 'react'; -import { archiveProduct, deleteProduct, logConsumption } from '../lib/db'; +import { archiveProduct, deleteProduct, logConsumption, updateProduct } from '../lib/db'; const STATUS_COLORS: Record = { expired: 'bg-red-500', @@ -108,7 +108,7 @@ export function ProductList() { await archiveProduct(productId); showToast(t('consume.toastConsumedAndArchived', { name: product.name, amount, unit: product.unit })); } else { - await db.products.update(productId, { quantity: newQuantity, updatedAt: new Date().toISOString() }); + await updateProduct(productId, { quantity: newQuantity }); showToast(t('consume.toastConsumed', { name: product.name, amount, unit: product.unit, remaining: newQuantity })); } setConsumeProduct(null); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 26110b0..f98437c 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { version as appVersion } from '../../package.json'; import { useLiveQuery } from 'dexie-react-hooks'; @@ -8,6 +8,13 @@ import { useDarkMode } from '../hooks/useDarkMode'; import { usePWAInstall } from '../hooks/usePWAInstall'; import { useAppStore } from '../store/useAppStore'; import { downloadFile } from '../lib/utils'; +import { getSyncConfig, saveSyncConfig, clearSyncPairing } from '../lib/syncConfig'; +import { + getSyncRuntimeState, + pairSyncDevice, + runSyncNow, + subscribeSyncRuntime, +} from '../lib/sync'; import { Bell, BellOff, @@ -28,6 +35,8 @@ import { ChevronUp, Info, Globe, + Cloud, + RefreshCw, } from 'lucide-react'; const LANGUAGES = [ @@ -39,6 +48,13 @@ const LANGUAGES = [ { code: 'fr', label: 'Français', flag: '🇫🇷' }, ]; +function formatSyncTime(value?: string): string { + if (!value) return '—'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleString(); +} + export function Settings() { const [isDark, toggleDark] = useDarkMode(); const { notificationsEnabled, setNotificationsEnabled } = useAppStore(); @@ -47,6 +63,14 @@ export function Settings() { const allProducts = useLiveQuery(() => db.products.toArray()) ?? []; const [newLocation, setNewLocation] = useState(''); const [importStatus, setImportStatus] = useState<{ message: string; type: 'success' | 'warning' | 'error' } | null>(null); + const [syncConfigState, setSyncConfigState] = useState(() => getSyncConfig()); + const [syncServerUrl, setSyncServerUrl] = useState(syncConfigState.serverUrl); + const [syncDeviceName, setSyncDeviceName] = useState(syncConfigState.deviceName || ''); + const [syncCode, setSyncCode] = useState(''); + const [showRepair, setShowRepair] = useState(false); + const [syncBusy, setSyncBusy] = useState(false); + const [syncNotice, setSyncNotice] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + const [syncRuntime, setSyncRuntime] = useState(() => getSyncRuntimeState()); const [showImpressum, setShowImpressum] = useState(false); const [showDatenschutz, setShowDatenschutz] = useState(false); const [showAGB, setShowAGB] = useState(false); @@ -110,7 +134,88 @@ export function Settings() { i18n.changeLanguage(langCode); } + useEffect(() => { + return subscribeSyncRuntime((next) => { + setSyncRuntime(next); + }); + }, []); + + function handleSaveSyncSettings() { + const next = saveSyncConfig({ + ...syncConfigState, + serverUrl: syncServerUrl.trim(), + deviceName: syncDeviceName.trim(), + }); + setSyncConfigState(next); + setSyncNotice({ type: 'success', message: 'Sync-Einstellungen gespeichert.' }); + } + + async function handlePairSyncDevice() { + if (!syncServerUrl.trim() || !syncCode.trim() || !syncDeviceName.trim()) { + setSyncNotice({ + type: 'error', + message: 'Server URL, Sync-Code und Gerätename sind erforderlich.', + }); + return; + } + + setSyncBusy(true); + setSyncNotice(null); + try { + await pairSyncDevice({ + serverUrl: syncServerUrl.trim(), + syncCode: syncCode.trim(), + deviceName: syncDeviceName.trim(), + }); + setSyncCode(''); + setShowRepair(false); + const next = getSyncConfig(); + setSyncConfigState(next); + setSyncNotice({ type: 'success', message: 'Gerät erfolgreich gekoppelt.' }); + await runSyncNow('pairing'); + } catch (err) { + setSyncNotice({ + type: 'error', + message: err instanceof Error ? err.message : 'Koppeln fehlgeschlagen.', + }); + } finally { + setSyncBusy(false); + } + } + + function handleToggleSyncEnabled(enabled: boolean) { + const next = saveSyncConfig({ + ...syncConfigState, + enabled, + serverUrl: syncServerUrl.trim(), + deviceName: syncDeviceName.trim(), + }); + setSyncConfigState(next); + setSyncNotice(null); + void runSyncNow(enabled ? 'enable' : 'disable').catch(() => undefined); + } + + async function handleSyncNowClick() { + setSyncBusy(true); + setSyncNotice(null); + try { + await runSyncNow('manual'); + setSyncNotice({ type: 'success', message: 'Sync abgeschlossen.' }); + } catch (err) { + setSyncNotice({ + type: 'error', + message: err instanceof Error ? err.message : 'Sync fehlgeschlagen.', + }); + } finally { + setSyncBusy(false); + } + } + const notifStatus = getNotificationPermissionStatus(); + const syncIsPaired = + syncConfigState.householdId.length > 0 && + syncConfigState.deviceId.length > 0 && + syncConfigState.deviceToken.length > 0; return (
@@ -377,6 +482,152 @@ export function Settings() {
+ {/* Sync */} +
+

+ + LAN Sync (optional) +

+ +

+ Daten bleiben lokal nutzbar. Sync ist optional und wird nur mit deinem eigenen Backend verwendet. +

+ +
+
+ + setSyncServerUrl(e.target.value)} + placeholder="http://192.168.0.20:8787" + className="w-full rounded-lg border border-primary-600 bg-primary-900 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-sky-500 focus:outline-none" + /> +
+ +
+ + setSyncDeviceName(e.target.value)} + placeholder="z. B. iPhone Küche" + className="w-full rounded-lg border border-primary-600 bg-primary-900 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-sky-500 focus:outline-none" + /> +
+ + {(!syncIsPaired || showRepair) && ( +
+ + setSyncCode(e.target.value)} + placeholder="gemeinsamer Haushalt-Code" + className="w-full rounded-lg border border-primary-600 bg-primary-900 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-sky-500 focus:outline-none" + /> +
+ )} + +
+ + + {!syncIsPaired || showRepair ? ( + + ) : ( + + )} +
+ + {syncIsPaired && !showRepair && ( + + )} + {showRepair && ( + + )} + + {syncIsPaired && ( + + )} + +
+

Status: {syncRuntime.status}

+

Ausstehende Änderungen: {syncRuntime.pendingChanges}

+

Letzter Erfolg: {formatSyncTime(syncRuntime.lastSuccessAt)}

+ {syncRuntime.lastError && ( +

Fehler: {syncRuntime.lastError}

+ )} + {syncIsPaired && ( +

+ Haushalt: {syncConfigState.householdId} +

+ )} +
+ + {syncNotice && ( +

+ {syncNotice.message} +

+ )} +
+
+ {/* Spenden */}

diff --git a/src/lib/db.import.test.ts b/src/lib/db.import.test.ts new file mode 100644 index 0000000..e362e97 --- /dev/null +++ b/src/lib/db.import.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { indexedDB, IDBKeyRange } from 'fake-indexeddb'; + +class MemoryStorage implements Storage { + private store = new Map(); + + get length(): number { + return this.store.size; + } + + clear(): void { + this.store.clear(); + } + + getItem(key: string): string | null { + return this.store.get(key) ?? null; + } + + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null; + } + + removeItem(key: string): void { + this.store.delete(key); + } + + setItem(key: string, value: string): void { + this.store.set(key, value); + } +} + +describe('importData sync linkage', () => { + beforeEach(async () => { + vi.resetModules(); + Object.defineProperty(globalThis, 'indexedDB', { + value: indexedDB, + configurable: true, + }); + Object.defineProperty(globalThis, 'IDBKeyRange', { + value: IDBKeyRange, + configurable: true, + }); + Object.defineProperty(globalThis, 'localStorage', { + value: new MemoryStorage(), + configurable: true, + }); + Object.defineProperty(globalThis, 'sessionStorage', { + value: new MemoryStorage(), + configurable: true, + }); + }); + + it('derives productSyncId for imported legacy logs before queueing sync changes', async () => { + const { saveSyncConfig } = await import('./syncConfig'); + saveSyncConfig({ + enabled: true, + serverUrl: 'http://localhost:8787', + householdId: 'house-1', + deviceId: 'device-1', + deviceToken: 'token-1', + deviceName: 'Desktop', + }); + + const { db, importData } = await import('./db'); + await db.delete(); + await db.open(); + + const payload = { + version: 'legacy', + exportedAt: '2026-03-12T10:00:00.000Z', + products: [ + { + id: 42, + name: 'Reis', + category: 'lebensmittel', + storageLocation: 'Keller', + quantity: 3, + unit: 'Stück', + expiryDate: '2027-01-01T00:00:00.000Z', + expiryPrecision: 'day', + archived: false, + createdAt: '2026-03-10T10:00:00.000Z', + updatedAt: '2026-03-10T10:00:00.000Z', + }, + ], + storageLocations: [], + consumptionLogs: [ + { + id: 9, + productId: 42, + productName: 'Reis', + quantity: 1, + unit: 'Stück', + consumedAt: '2026-03-11T09:00:00.000Z', + reason: 'verbraucht', + }, + ], + }; + + await importData(JSON.stringify(payload)); + + const logs = await db.consumptionLogs.toArray(); + expect(logs).toHaveLength(1); + expect(logs[0].productSyncId).toBeTypeOf('string'); + expect(logs[0].productSyncId?.length).toBeGreaterThan(0); + expect(typeof logs[0].productId).toBe('number'); + + const queueRows = await db.syncQueue.toArray(); + const logSyncRow = queueRows.find( + (row) => row.entityType === 'consumptionLog' && row.op === 'upsert' + ); + expect(logSyncRow).toBeDefined(); + expect(logSyncRow?.payload?.productSyncId).toBe(logs[0].productSyncId); + expect(logSyncRow?.payload?.productId).toBe(logs[0].productId); + }); +}); diff --git a/src/lib/db.ts b/src/lib/db.ts index e16475c..4591a70 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -2,18 +2,96 @@ import Dexie, { type Table } from 'dexie'; import { version as appVersion } from '../../package.json'; import i18n from '../i18n/i18n'; import { getLocale } from './utils'; +import { isSyncEnabled } from './syncConfig'; import type { Product, StorageLocation, ConsumptionLog, NotificationSchedule, + SyncEntityType, + SyncOperation, } from '../types'; +export interface SyncQueueRow { + id?: number; + entityType: SyncEntityType; + entitySyncId: string; + op: SyncOperation; + updatedAt: string; + payload?: Record; +} + +export interface SyncMetaRow { + key: string; + value: string; +} + +const VALID_REASONS: ConsumptionLog['reason'][] = [ + 'verbraucht', + 'abgelaufen', + 'beschadigt', + 'sonstiges', +]; + +let syncQueueSuppressionDepth = 0; + +function createSyncId(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +function shouldQueueSyncChange(): boolean { + return syncQueueSuppressionDepth === 0 && isSyncEnabled(); +} + +function toSyncProductPayload(product: Omit): Record { + return { + ...product, + // Explicit null for optional fields so JSON serialization preserves intentional clears. + // Receivers distinguish null (cleared) from absent key (unknown/fallback). + barcode: product.barcode ?? null, + photo: product.photo ?? null, + minStock: product.minStock ?? null, + notes: product.notes ?? null, + }; +} + +function toSyncLocationPayload(location: Omit): Record { + return { ...location }; +} + +function toSyncLogPayload(log: Omit): Record { + return { ...log }; +} + +function toIso(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback; + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) return fallback; + return dt.toISOString(); +} + +function normalizeNumber(value: unknown, fallback: number): number { + if (typeof value === 'number' && Number.isFinite(value)) return value; + return fallback; +} + +function normalizeReason(value: unknown, fallback: ConsumptionLog['reason']): ConsumptionLog['reason'] { + if (typeof value === 'string' && VALID_REASONS.includes(value as ConsumptionLog['reason'])) { + return value as ConsumptionLog['reason']; + } + return fallback; +} + export class PrepTrackDB extends Dexie { products!: Table; storageLocations!: Table; consumptionLogs!: Table; notificationSchedules!: Table; + syncQueue!: Table; + syncMeta!: Table; constructor() { super('PrepTrackDB'); @@ -33,56 +111,272 @@ export class PrepTrackDB extends Dexie { consumptionLogs: '++id, productId, consumedAt', notificationSchedules: '++id, productId, notifyAt, sent, [productId+daysBefore]', }); + + this.version(3) + .stores({ + products: + '++id, &syncId, name, barcode, category, storageLocation, expiryDate, archived, createdAt, updatedAt', + storageLocations: '++id, &syncId, name, createdAt, updatedAt', + consumptionLogs: '++id, &syncId, productId, productSyncId, consumedAt, updatedAt', + notificationSchedules: '++id, productId, notifyAt, sent, [productId+daysBefore]', + syncQueue: '++id, [entityType+entitySyncId], updatedAt', + syncMeta: '&key', + }) + .upgrade(async (tx) => { + const now = new Date().toISOString(); + const productsTable = tx.table('products') as Table; + const storageTable = tx.table('storageLocations') as Table; + const logsTable = tx.table('consumptionLogs') as Table; + const syncMetaTable = tx.table('syncMeta') as Table; + + const products = await productsTable.toArray(); + const productSyncById = new Map(); + for (const product of products) { + const next: Product = { + ...product, + syncId: product.syncId ?? createSyncId(), + updatedAt: product.updatedAt ?? product.createdAt ?? now, + }; + if (product.id !== undefined) { + productSyncById.set(product.id, next.syncId!); + await productsTable.put(next); + } + } + + const locations = await storageTable.toArray(); + for (const location of locations) { + const next: StorageLocation = { + ...location, + syncId: location.syncId ?? createSyncId(), + updatedAt: location.updatedAt ?? location.createdAt ?? now, + }; + await storageTable.put(next); + } + + const logs = await logsTable.toArray(); + for (const log of logs) { + const next: ConsumptionLog = { + ...log, + syncId: log.syncId ?? createSyncId(), + productSyncId: + log.productSyncId ?? + (typeof log.productId === 'number' ? productSyncById.get(log.productId) : undefined), + updatedAt: log.updatedAt ?? log.consumedAt ?? now, + }; + await logsTable.put(next); + } + + await syncMetaTable.put({ key: 'sync_cursor', value: '0' }); + await syncMetaTable.put({ key: 'sync_full_snapshot_pending', value: '0' }); + }); } } export const db = new PrepTrackDB(); +async function enqueueSyncChange(row: Omit): Promise { + if (!shouldQueueSyncChange()) return; + await db.syncQueue.add(row); +} + +export async function withSyncQueueSuppressed(fn: () => Promise): Promise { + syncQueueSuppressionDepth += 1; + try { + return await fn(); + } finally { + syncQueueSuppressionDepth -= 1; + } +} + +export async function getSyncMetaValue(key: string): Promise { + const row = await db.syncMeta.get(key); + return row?.value; +} + +export async function setSyncMetaValue(key: string, value: string): Promise { + await db.syncMeta.put({ key, value }); +} + +export async function getQueuedSyncChanges(limit = 500): Promise { + return db.syncQueue.orderBy('id').limit(limit).toArray(); +} + +export async function removeQueuedSyncChanges(ids: number[]): Promise { + if (ids.length === 0) return; + await db.syncQueue.bulkDelete(ids); +} + +export async function getSyncQueueCount(): Promise { + return db.syncQueue.count(); +} + +export async function queueFullSnapshotForSync(resetQueue = false): Promise { + if (!isSyncEnabled()) return; + if (resetQueue) { + await db.syncQueue.clear(); + } + + const [products, locations, logs] = await Promise.all([ + db.products.toArray(), + db.storageLocations.toArray(), + db.consumptionLogs.toArray(), + ]); + + const batch: Omit[] = []; + + for (const product of products) { + if (!product.syncId) continue; + const { id: _id, ...payload } = product; + batch.push({ + entityType: 'product', + entitySyncId: product.syncId, + op: 'upsert', + updatedAt: product.updatedAt, + payload: toSyncProductPayload(payload), + }); + } + + for (const location of locations) { + if (!location.syncId) continue; + const { id: _id, ...payload } = location; + batch.push({ + entityType: 'storageLocation', + entitySyncId: location.syncId, + op: 'upsert', + updatedAt: location.updatedAt ?? location.createdAt, + payload: toSyncLocationPayload(payload), + }); + } + + for (const log of logs) { + if (!log.syncId) continue; + const { id: _id, ...payload } = log; + batch.push({ + entityType: 'consumptionLog', + entitySyncId: log.syncId, + op: 'upsert', + updatedAt: log.updatedAt ?? log.consumedAt, + payload: toSyncLogPayload(payload), + }); + } + + if (batch.length > 0) { + await db.syncQueue.bulkAdd(batch); + } +} + // Seed default storage locations on first run export async function seedDefaults(): Promise { const count = await db.storageLocations.count(); if (count === 0) { const now = new Date().toISOString(); await db.storageLocations.bulkAdd([ - { name: 'Keller', createdAt: now }, - { name: 'Garage', createdAt: now }, - { name: 'Küche', createdAt: now }, - { name: 'Dachboden', createdAt: now }, - { name: 'Vorratsraum', createdAt: now }, - { name: 'Bunker', createdAt: now }, - { name: 'Auto', createdAt: now }, - { name: 'Gartenhaus', createdAt: now }, + { syncId: createSyncId(), name: 'Keller', createdAt: now, updatedAt: now }, + { syncId: createSyncId(), name: 'Garage', createdAt: now, updatedAt: now }, + { syncId: createSyncId(), name: 'Küche', createdAt: now, updatedAt: now }, + { syncId: createSyncId(), name: 'Dachboden', createdAt: now, updatedAt: now }, + { syncId: createSyncId(), name: 'Vorratsraum', createdAt: now, updatedAt: now }, + { syncId: createSyncId(), name: 'Bunker', createdAt: now, updatedAt: now }, + { syncId: createSyncId(), name: 'Auto', createdAt: now, updatedAt: now }, + { syncId: createSyncId(), name: 'Gartenhaus', createdAt: now, updatedAt: now }, ]); } } // Product CRUD export async function addProduct(product: Omit): Promise { - return db.products.add(product); + const now = new Date().toISOString(); + const next: Omit = { + ...product, + syncId: product.syncId ?? createSyncId(), + createdAt: product.createdAt ?? now, + updatedAt: product.updatedAt ?? now, + }; + + const id = await db.products.add(next); + await enqueueSyncChange({ + entityType: 'product', + entitySyncId: next.syncId!, + op: 'upsert', + updatedAt: next.updatedAt, + payload: toSyncProductPayload(next), + }); + return id; } export async function updateProduct( id: number, changes: Partial ): Promise { - return db.products.update(id, { + const existing = await db.products.get(id); + if (!existing) return 0; + + const now = new Date().toISOString(); + const next: Omit = { + ...existing, ...changes, - updatedAt: new Date().toISOString(), - }); + syncId: existing.syncId ?? createSyncId(), + updatedAt: changes.updatedAt ?? now, + }; + + const updated = await db.products.update(id, next); + if (updated) { + await enqueueSyncChange({ + entityType: 'product', + entitySyncId: next.syncId!, + op: 'upsert', + updatedAt: next.updatedAt, + payload: toSyncProductPayload(next), + }); + } + return updated; } export async function deleteProduct(id: number): Promise { - await db.transaction('rw', db.products, db.consumptionLogs, db.notificationSchedules, async () => { - await db.products.delete(id); - await db.consumptionLogs.where('productId').equals(id).delete(); - await db.notificationSchedules.where('productId').equals(id).delete(); - }); + const product = await db.products.get(id); + if (!product) return; + + const relatedLogs = await db.consumptionLogs.where('productId').equals(id).toArray(); + const now = new Date().toISOString(); + + await db.transaction( + 'rw', + db.products, + db.consumptionLogs, + db.notificationSchedules, + async () => { + await db.products.delete(id); + await db.consumptionLogs.where('productId').equals(id).delete(); + await db.notificationSchedules.where('productId').equals(id).delete(); + } + ); + + if (product.syncId) { + await enqueueSyncChange({ + entityType: 'product', + entitySyncId: product.syncId, + op: 'delete', + updatedAt: now, + }); + } + + const logDeletes = relatedLogs + .filter((log) => typeof log.syncId === 'string' && log.syncId.length > 0) + .map((log) => + enqueueSyncChange({ + entityType: 'consumptionLog', + entitySyncId: log.syncId!, + op: 'delete', + updatedAt: now, + }) + ); + + await Promise.all(logDeletes); } export async function archiveProduct(id: number): Promise { - await db.products.update(id, { + await updateProduct(id, { archived: true, - updatedAt: new Date().toISOString(), }); } @@ -96,21 +390,234 @@ export async function getArchivedProducts(): Promise { // Storage Location CRUD export async function addStorageLocation(name: string): Promise { - return db.storageLocations.add({ + const now = new Date().toISOString(); + const next: Omit = { + syncId: createSyncId(), name, - createdAt: new Date().toISOString(), + createdAt: now, + updatedAt: now, + }; + const id = await db.storageLocations.add(next); + await enqueueSyncChange({ + entityType: 'storageLocation', + entitySyncId: next.syncId!, + op: 'upsert', + updatedAt: next.updatedAt!, + payload: toSyncLocationPayload(next), }); + return id; } export async function deleteStorageLocation(id: number): Promise { + const location = await db.storageLocations.get(id); + if (!location) return; await db.storageLocations.delete(id); + if (location.syncId) { + await enqueueSyncChange({ + entityType: 'storageLocation', + entitySyncId: location.syncId, + op: 'delete', + updatedAt: new Date().toISOString(), + }); + } } // Consumption Log export async function logConsumption( log: Omit ): Promise { - return db.consumptionLogs.add(log); + const now = new Date().toISOString(); + let productSyncId = log.productSyncId; + if (!productSyncId && typeof log.productId === 'number') { + const product = await db.products.get(log.productId); + productSyncId = product?.syncId; + } + + const next: Omit = { + ...log, + syncId: log.syncId ?? createSyncId(), + productSyncId, + updatedAt: log.updatedAt ?? now, + }; + + const id = await db.consumptionLogs.add(next); + await enqueueSyncChange({ + entityType: 'consumptionLog', + entitySyncId: next.syncId!, + op: 'upsert', + updatedAt: next.updatedAt!, + payload: toSyncLogPayload(next), + }); + return id; +} + +// Remote sync apply helpers +export async function applyProductUpsertFromSync( + payload: Record +): Promise { + const syncId = typeof payload.syncId === 'string' ? payload.syncId : ''; + if (!syncId) return; + + const existing = await db.products.where('syncId').equals(syncId).first(); + const now = new Date().toISOString(); + + const next: Omit = { + syncId, + name: typeof payload.name === 'string' ? payload.name : existing?.name ?? 'Produkt', + // For optional fields: null means explicitly cleared; absent key falls back to existing value. + barcode: 'barcode' in payload ? (typeof payload.barcode === 'string' ? payload.barcode : undefined) : existing?.barcode, + category: + typeof payload.category === 'string' + ? (payload.category as Product['category']) + : existing?.category ?? 'sonstiges', + storageLocation: + typeof payload.storageLocation === 'string' + ? payload.storageLocation + : existing?.storageLocation ?? 'Keller', + quantity: normalizeNumber(payload.quantity, existing?.quantity ?? 1), + unit: typeof payload.unit === 'string' ? payload.unit : existing?.unit ?? 'Stück', + expiryDate: + typeof payload.expiryDate === 'string' + ? payload.expiryDate + : existing?.expiryDate ?? now, + expiryPrecision: + payload.expiryPrecision === 'month' || payload.expiryPrecision === 'year' + ? (payload.expiryPrecision as Product['expiryPrecision']) + : payload.expiryPrecision === 'day' + ? 'day' + : existing?.expiryPrecision ?? 'day', + photo: 'photo' in payload ? (typeof payload.photo === 'string' ? payload.photo : undefined) : existing?.photo, + minStock: 'minStock' in payload + ? (typeof payload.minStock === 'number' && Number.isFinite(payload.minStock) ? payload.minStock : undefined) + : existing?.minStock, + notes: 'notes' in payload ? (typeof payload.notes === 'string' ? payload.notes : undefined) : existing?.notes, + archived: typeof payload.archived === 'boolean' ? payload.archived : existing?.archived ?? false, + createdAt: toIso(payload.createdAt, existing?.createdAt ?? now), + updatedAt: toIso(payload.updatedAt, existing?.updatedAt ?? now), + }; + + if (existing?.id !== undefined) { + await db.products.update(existing.id, next); + return; + } + + await db.products.add(next); +} + +export async function applyProductDeleteFromSync(syncId: string): Promise { + const product = await db.products.where('syncId').equals(syncId).first(); + const productLogs = await db.consumptionLogs.where('productSyncId').equals(syncId).toArray(); + + await db.transaction( + 'rw', + db.products, + db.consumptionLogs, + db.notificationSchedules, + async () => { + if (product?.id !== undefined) { + await db.products.delete(product.id); + await db.consumptionLogs.where('productId').equals(product.id).delete(); + await db.notificationSchedules.where('productId').equals(product.id).delete(); + } + + if (productLogs.length > 0) { + await db.consumptionLogs.bulkDelete( + productLogs.map((log) => log.id).filter((id): id is number => typeof id === 'number') + ); + } + } + ); +} + +export async function applyStorageLocationUpsertFromSync( + payload: Record +): Promise { + const syncId = typeof payload.syncId === 'string' ? payload.syncId : ''; + if (!syncId) return; + const now = new Date().toISOString(); + const incomingName = typeof payload.name === 'string' ? payload.name : ''; + if (!incomingName) return; + + const bySyncId = await db.storageLocations.where('syncId').equals(syncId).first(); + let existing = bySyncId; + if (!existing) { + const byName = await db.storageLocations.where('name').equals(incomingName).first(); + existing = byName; + } + + const next: Omit = { + syncId, + name: incomingName, + icon: typeof payload.icon === 'string' ? payload.icon : existing?.icon, + createdAt: toIso(payload.createdAt, existing?.createdAt ?? now), + updatedAt: toIso(payload.updatedAt, existing?.updatedAt ?? now), + }; + + if (existing?.id !== undefined) { + await db.storageLocations.update(existing.id, next); + return; + } + + await db.storageLocations.add(next); +} + +export async function applyStorageLocationDeleteFromSync(syncId: string): Promise { + const location = await db.storageLocations.where('syncId').equals(syncId).first(); + if (location?.id !== undefined) { + await db.storageLocations.delete(location.id); + } +} + +export async function applyConsumptionLogUpsertFromSync( + payload: Record +): Promise { + const syncId = typeof payload.syncId === 'string' ? payload.syncId : ''; + if (!syncId) return; + const now = new Date().toISOString(); + const existing = await db.consumptionLogs.where('syncId').equals(syncId).first(); + + const productSyncId = + typeof payload.productSyncId === 'string' + ? payload.productSyncId + : existing?.productSyncId; + + let productId = typeof payload.productId === 'number' ? payload.productId : existing?.productId; + if (productSyncId) { + const product = await db.products.where('syncId').equals(productSyncId).first(); + if (product?.id !== undefined) { + productId = product.id; + } + } + + const fallbackReason: ConsumptionLog['reason'] = existing?.reason ?? 'sonstiges'; + const next: Omit = { + syncId, + productId, + productSyncId, + productName: + typeof payload.productName === 'string' + ? payload.productName + : existing?.productName ?? 'Produkt', + quantity: normalizeNumber(payload.quantity, existing?.quantity ?? 1), + unit: typeof payload.unit === 'string' ? payload.unit : existing?.unit ?? 'Stück', + consumedAt: toIso(payload.consumedAt, existing?.consumedAt ?? now), + updatedAt: toIso(payload.updatedAt, existing?.updatedAt ?? now), + reason: normalizeReason(payload.reason, fallbackReason), + }; + + if (existing?.id !== undefined) { + await db.consumptionLogs.update(existing.id, next); + return; + } + + await db.consumptionLogs.add(next); +} + +export async function applyConsumptionLogDeleteFromSync(syncId: string): Promise { + const log = await db.consumptionLogs.where('syncId').equals(syncId).first(); + if (log?.id !== undefined) { + await db.consumptionLogs.delete(log.id); + } } // Export/Import @@ -189,7 +696,13 @@ export async function exportCSV(): Promise { escCsv(p.quantity), escCsv(t(`units.${p.unit}`)), escCsv(fmtDate(p.expiryDate)), - escCsv(p.expiryPrecision === 'day' ? t('form.precisionDay') : p.expiryPrecision === 'month' ? t('form.precisionMonth') : t('form.precisionYear')), + escCsv( + p.expiryPrecision === 'day' + ? t('form.precisionDay') + : p.expiryPrecision === 'month' + ? t('form.precisionMonth') + : t('form.precisionYear') + ), escCsv(p.minStock ?? ''), escCsv(p.notes), escCsv(p.archived ? t('common.yes') : t('common.no')), @@ -216,6 +729,9 @@ export async function importData(jsonString: string): Promise { const products = data.products as Record[]; const storageLocations = (data.storageLocations ?? []) as Record[]; const consumptionLogs = (data.consumptionLogs ?? []) as Record[]; + const queuedSyncChanges: Omit[] = []; + const importedProductSyncIdByLegacyId = new Map(); + const localProductIdBySyncId = new Map(); let imported = 0; let skipped = 0; @@ -234,21 +750,33 @@ export async function importData(jsonString: string): Promise { .equals(loc.name) .first(); if (!existing) { - await db.storageLocations.add({ + const now = new Date().toISOString(); + const next: Omit = { + syncId: typeof loc.syncId === 'string' ? loc.syncId : createSyncId(), name: loc.name, - createdAt: (loc.createdAt as string) || new Date().toISOString(), + createdAt: (loc.createdAt as string) || now, + updatedAt: (loc.updatedAt as string) || (loc.createdAt as string) || now, + }; + await db.storageLocations.add(next); + queuedSyncChanges.push({ + entityType: 'storageLocation', + entitySyncId: next.syncId!, + op: 'upsert', + updatedAt: next.updatedAt!, + payload: toSyncLocationPayload(next), }); } } // Import products (skip duplicates based on name + expiryDate + storageLocation) for (const product of products) { + const legacyProductId = typeof product.id === 'number' ? product.id : undefined; if ( !product.name || typeof product.name !== 'string' || !product.expiryDate || typeof product.expiryDate !== 'string' || - isNaN(new Date(String(product.expiryDate)).getTime()) + Number.isNaN(new Date(String(product.expiryDate)).getTime()) ) { skipped++; continue; @@ -259,51 +787,131 @@ export async function importData(jsonString: string): Promise { .where('name') .equals(product.name as string) .toArray(); - const isDuplicate = existingProducts.some( + const duplicateProduct = existingProducts.find( (p) => p.expiryDate === product.expiryDate && p.storageLocation === product.storageLocation ); + const isDuplicate = Boolean(duplicateProduct); if (isDuplicate) { skipped++; + if (legacyProductId !== undefined && duplicateProduct?.syncId) { + importedProductSyncIdByLegacyId.set(legacyProductId, duplicateProduct.syncId); + if (typeof duplicateProduct.id === 'number') { + localProductIdBySyncId.set(duplicateProduct.syncId, duplicateProduct.id); + } + } continue; } // Clean up photo field - don't import placeholder markers const rawPhoto = product.photo; - const photo = rawPhoto && rawPhoto !== '[FOTO]' && typeof rawPhoto === 'string' ? rawPhoto : undefined; + const photo = + rawPhoto && rawPhoto !== '[FOTO]' && typeof rawPhoto === 'string' + ? rawPhoto + : undefined; const now = new Date().toISOString(); // Only import known fields to prevent injection of unexpected data - await db.products.add({ + const next: Omit = { + syncId: typeof product.syncId === 'string' ? product.syncId : createSyncId(), name: String(product.name), barcode: typeof product.barcode === 'string' ? product.barcode : undefined, - category: typeof product.category === 'string' ? product.category as Product['category'] : 'sonstiges', - storageLocation: typeof product.storageLocation === 'string' ? product.storageLocation : 'Keller', + category: + typeof product.category === 'string' + ? (product.category as Product['category']) + : 'sonstiges', + storageLocation: + typeof product.storageLocation === 'string' + ? product.storageLocation + : 'Keller', quantity: typeof product.quantity === 'number' ? product.quantity : 1, unit: typeof product.unit === 'string' ? product.unit : 'Stück', expiryDate: String(product.expiryDate), - expiryPrecision: ['day', 'month', 'year'].includes(product.expiryPrecision as string) ? product.expiryPrecision as Product['expiryPrecision'] : 'day', + expiryPrecision: ['day', 'month', 'year'].includes(product.expiryPrecision as string) + ? (product.expiryPrecision as Product['expiryPrecision']) + : 'day', photo, minStock: typeof product.minStock === 'number' ? product.minStock : undefined, notes: typeof product.notes === 'string' ? product.notes : undefined, archived: product.archived === true || product.archived === 1, createdAt: typeof product.createdAt === 'string' ? product.createdAt : now, updatedAt: typeof product.updatedAt === 'string' ? product.updatedAt : now, - }); + }; + const insertedProductId = await db.products.add(next); + if (legacyProductId !== undefined) { + importedProductSyncIdByLegacyId.set(legacyProductId, next.syncId!); + } + if (typeof insertedProductId === 'number') { + localProductIdBySyncId.set(next.syncId!, insertedProductId); + } imported++; + queuedSyncChanges.push({ + entityType: 'product', + entitySyncId: next.syncId!, + op: 'upsert', + updatedAt: next.updatedAt, + payload: toSyncProductPayload(next), + }); } // Import consumption logs for (const log of consumptionLogs) { - if (!log.productId || !log.consumedAt) continue; - const { id: _id, ...logData } = log; - await db.consumptionLogs.add(logData as Omit); + if (!log.consumedAt) continue; + const now = new Date().toISOString(); + const legacyLogProductId = + typeof log.productId === 'number' ? log.productId : undefined; + let resolvedProductSyncId = + typeof log.productSyncId === 'string' ? log.productSyncId : undefined; + if (!resolvedProductSyncId && legacyLogProductId !== undefined) { + resolvedProductSyncId = importedProductSyncIdByLegacyId.get(legacyLogProductId); + } + + let resolvedLocalProductId: number | undefined; + if (resolvedProductSyncId) { + const mappedLocalId = localProductIdBySyncId.get(resolvedProductSyncId); + if (mappedLocalId !== undefined) { + resolvedLocalProductId = mappedLocalId; + } else { + const existingProduct = await db.products + .where('syncId') + .equals(resolvedProductSyncId) + .first(); + if (typeof existingProduct?.id === 'number') { + resolvedLocalProductId = existingProduct.id; + localProductIdBySyncId.set(resolvedProductSyncId, existingProduct.id); + } + } + } + + const next: Omit = { + syncId: typeof log.syncId === 'string' ? log.syncId : createSyncId(), + productId: resolvedLocalProductId, + productSyncId: resolvedProductSyncId, + productName: typeof log.productName === 'string' ? log.productName : 'Produkt', + quantity: typeof log.quantity === 'number' ? log.quantity : 1, + unit: typeof log.unit === 'string' ? log.unit : 'Stück', + consumedAt: typeof log.consumedAt === 'string' ? log.consumedAt : now, + updatedAt: typeof log.updatedAt === 'string' ? log.updatedAt : now, + reason: normalizeReason(log.reason, 'sonstiges'), + }; + await db.consumptionLogs.add(next); + queuedSyncChanges.push({ + entityType: 'consumptionLog', + entitySyncId: next.syncId!, + op: 'upsert', + updatedAt: next.updatedAt!, + payload: toSyncLogPayload(next), + }); } } ); + if (shouldQueueSyncChange() && queuedSyncChanges.length > 0) { + await db.syncQueue.bulkAdd(queuedSyncChanges); + } + if (skipped > 0) { throw new ImportResult(imported, skipped); } diff --git a/src/lib/sync.test.ts b/src/lib/sync.test.ts new file mode 100644 index 0000000..0f3f4f2 --- /dev/null +++ b/src/lib/sync.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const dbMock = vi.hoisted(() => ({ + applyConsumptionLogDeleteFromSync: vi.fn(), + applyConsumptionLogUpsertFromSync: vi.fn(), + applyProductDeleteFromSync: vi.fn(), + applyProductUpsertFromSync: vi.fn(), + applyStorageLocationDeleteFromSync: vi.fn(), + applyStorageLocationUpsertFromSync: vi.fn(), + getQueuedSyncChanges: vi.fn(), + getSyncMetaValue: vi.fn(), + getSyncQueueCount: vi.fn(), + queueFullSnapshotForSync: vi.fn(), + removeQueuedSyncChanges: vi.fn(), + setSyncMetaValue: vi.fn(), + withSyncQueueSuppressed: vi.fn(async (fn: () => Promise) => fn()), +})); + +const syncConfigMock = vi.hoisted(() => ({ + getSyncConfig: vi.fn(), + saveSyncConfig: vi.fn(), +})); + +vi.mock('./db', () => dbMock); +vi.mock('./syncConfig', () => syncConfigMock); + +function mockFetchResponse(payload: unknown, ok = true, status = 200): Response { + return { + ok, + status, + json: async () => payload, + } as Response; +} + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + + Object.defineProperty(globalThis, 'navigator', { + value: { onLine: true }, + configurable: true, + }); + + globalThis.fetch = vi.fn() as typeof fetch; + + dbMock.getQueuedSyncChanges.mockResolvedValue([]); + dbMock.getSyncQueueCount.mockResolvedValue(0); + dbMock.getSyncMetaValue.mockImplementation(async (key: string) => { + if (key === 'sync_cursor') return '0'; + if (key === 'sync_full_snapshot_pending') return '0'; + return undefined; + }); + syncConfigMock.getSyncConfig.mockReturnValue({ + enabled: true, + serverUrl: 'http://localhost:8787', + householdId: 'house-1', + deviceId: 'device-1', + deviceToken: 'token-1', + deviceName: 'Desktop', + intervalMs: 120000, + }); + syncConfigMock.saveSyncConfig.mockImplementation((value: unknown) => value); +}); + +describe('sync runtime', () => { + it('pairs a device and stores credentials', async () => { + (globalThis.fetch as ReturnType).mockResolvedValueOnce( + mockFetchResponse({ + householdId: 'house-abc', + deviceId: 'device-xyz', + deviceToken: 'token-xyz', + }) + ); + + const { pairSyncDevice } = await import('./sync'); + + await pairSyncDevice({ + serverUrl: 'http://192.168.0.20:8787/', + syncCode: 'HOUSE-1234', + deviceName: 'Phone', + }); + + expect(syncConfigMock.saveSyncConfig).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + serverUrl: 'http://192.168.0.20:8787', + householdId: 'house-abc', + deviceId: 'device-xyz', + deviceToken: 'token-xyz', + deviceName: 'Phone', + }) + ); + expect(dbMock.setSyncMetaValue).toHaveBeenCalledWith('sync_cursor', '0'); + expect(dbMock.setSyncMetaValue).toHaveBeenCalledWith('sync_full_snapshot_pending', '1'); + }); + + it('runs pull/push/pull cycle and deduplicates queued changes', async () => { + (globalThis.fetch as ReturnType) + .mockResolvedValueOnce( + mockFetchResponse({ + cursor: 3, + changes: [ + { + seq: 3, + entityType: 'product', + entityId: 'prod-1', + op: 'upsert', + payload: { syncId: 'prod-1', name: 'Reis', quantity: 1 }, + updatedAt: '2026-03-10T12:00:00.000Z', + updatedByDeviceId: 'device-remote', + }, + ], + }) + ) + .mockResolvedValueOnce(mockFetchResponse({ received: 1, applied: 1 })) + .mockResolvedValueOnce(mockFetchResponse({ cursor: 3, changes: [] })); + + dbMock.getQueuedSyncChanges + .mockResolvedValueOnce([ + { + id: 1, + entityType: 'product', + entitySyncId: 'prod-1', + op: 'upsert', + updatedAt: '2026-03-10T12:00:00.000Z', + payload: { syncId: 'prod-1', quantity: 1 }, + }, + { + id: 2, + entityType: 'product', + entitySyncId: 'prod-1', + op: 'upsert', + updatedAt: '2026-03-10T12:01:00.000Z', + payload: { syncId: 'prod-1', quantity: 2 }, + }, + ]) + .mockResolvedValueOnce([]); + + const { runSyncNow, subscribeSyncRuntime, getSyncRuntimeState } = await import('./sync'); + const observedStatuses: string[] = []; + const unsubscribe = subscribeSyncRuntime((state) => { + observedStatuses.push(state.status); + }); + + await runSyncNow('test'); + unsubscribe(); + + expect(dbMock.applyProductUpsertFromSync).toHaveBeenCalledWith( + expect.objectContaining({ syncId: 'prod-1' }) + ); + expect(dbMock.removeQueuedSyncChanges).toHaveBeenCalledWith([1, 2]); + + const pushCall = (globalThis.fetch as ReturnType).mock.calls.find( + ([url, init]) => String(url).includes('/v1/sync/push') && (init as RequestInit).method === 'POST' + ); + expect(pushCall).toBeDefined(); + const pushBody = JSON.parse((pushCall![1] as RequestInit).body as string); + expect(pushBody.changes).toHaveLength(1); + expect(pushBody.changes[0].updatedAt).toBe('2026-03-10T12:01:00.000Z'); + expect(pushBody.changes[0].payload.quantity).toBe(2); + + expect(dbMock.setSyncMetaValue).toHaveBeenCalledWith('sync_cursor', '3'); + expect(observedStatuses).toContain('syncing'); + expect(getSyncRuntimeState().status).toBe('idle'); + }); + + it('does not sync while offline and keeps pending count', async () => { + Object.defineProperty(globalThis, 'navigator', { + value: { onLine: false }, + configurable: true, + }); + dbMock.getSyncQueueCount.mockResolvedValue(7); + + const { runSyncNow, getSyncRuntimeState } = await import('./sync'); + await runSyncNow('offline-check'); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(getSyncRuntimeState().status).toBe('idle'); + expect(getSyncRuntimeState().pendingChanges).toBe(7); + }); +}); diff --git a/src/lib/sync.ts b/src/lib/sync.ts new file mode 100644 index 0000000..632bdd9 --- /dev/null +++ b/src/lib/sync.ts @@ -0,0 +1,414 @@ +import { + applyConsumptionLogDeleteFromSync, + applyConsumptionLogUpsertFromSync, + applyProductDeleteFromSync, + applyProductUpsertFromSync, + applyStorageLocationDeleteFromSync, + applyStorageLocationUpsertFromSync, + getQueuedSyncChanges, + getSyncMetaValue, + getSyncQueueCount, + queueFullSnapshotForSync, + removeQueuedSyncChanges, + setSyncMetaValue, + withSyncQueueSuppressed, +} from './db'; +import { getSyncConfig, saveSyncConfig } from './syncConfig'; +import type { SyncEntityType, SyncOperation } from '../types'; + +interface PairResponse { + householdId: string; + deviceId: string; + deviceToken: string; +} + +interface PushRequestChange { + entityType: SyncEntityType; + entityId: string; + op: SyncOperation; + updatedAt: string; + payload?: Record; +} + +interface PullResponseChange { + seq: number; + entityType: SyncEntityType; + entityId: string; + op: SyncOperation; + updatedAt: string; + updatedByDeviceId: string; + payload?: Record | null; +} + +interface PullResponse { + cursor: number; + changes: PullResponseChange[]; +} + +export interface SyncRuntimeState { + status: 'disabled' | 'idle' | 'syncing' | 'error'; + lastAttemptAt?: string; + lastSuccessAt?: string; + lastError?: string; + pendingChanges: number; +} + +const runtimeState: SyncRuntimeState = { + status: 'disabled', + pendingChanges: 0, +}; + +const listeners = new Set<(state: SyncRuntimeState) => void>(); +let syncInterval: ReturnType | null = null; +let onlineHandler: (() => void) | null = null; +let visibilityHandler: (() => void) | null = null; +let inFlightSync: Promise | null = null; + +function emitRuntimeState(partial: Partial): void { + Object.assign(runtimeState, partial); + const snapshot = { ...runtimeState }; + for (const listener of listeners) { + listener(snapshot); + } +} + +function buildAuthHeaders(cfg: ReturnType): Record { + return { + Authorization: `Bearer ${cfg.deviceToken}`, + 'x-household-id': cfg.householdId, + 'Content-Type': 'application/json', + }; +} + +function dedupeQueueChanges( + rows: Awaited> +): { changes: PushRequestChange[]; consumedIds: number[] } { + const byKey = new Map< + string, + { rowId: number; change: PushRequestChange; consumedIds: number[] } + >(); + + for (const row of rows) { + if (row.id === undefined) continue; + const key = `${row.entityType}:${row.entitySyncId}`; + const change: PushRequestChange = { + entityType: row.entityType, + entityId: row.entitySyncId, + op: row.op, + updatedAt: row.updatedAt, + payload: row.payload, + }; + + const existing = byKey.get(key); + if (!existing) { + byKey.set(key, { + rowId: row.id, + change, + consumedIds: [row.id], + }); + continue; + } + + existing.consumedIds.push(row.id); + if (row.id > existing.rowId) { + existing.rowId = row.id; + existing.change = change; + } + } + + const entries = Array.from(byKey.values()).sort((a, b) => a.rowId - b.rowId); + return { + changes: entries.map((entry) => entry.change), + consumedIds: entries.flatMap((entry) => entry.consumedIds), + }; +} + +async function applyPulledChanges(changes: PullResponseChange[]): Promise { + await withSyncQueueSuppressed(async () => { + for (const change of changes) { + const entityId = change.entityId; + if (!entityId) continue; + + if (change.entityType === 'product') { + if (change.op === 'delete') { + await applyProductDeleteFromSync(entityId); + } else if (change.payload && typeof change.payload === 'object') { + await applyProductUpsertFromSync(change.payload); + } + continue; + } + + if (change.entityType === 'storageLocation') { + if (change.op === 'delete') { + await applyStorageLocationDeleteFromSync(entityId); + } else if (change.payload && typeof change.payload === 'object') { + await applyStorageLocationUpsertFromSync(change.payload); + } + continue; + } + + if (change.entityType === 'consumptionLog') { + if (change.op === 'delete') { + await applyConsumptionLogDeleteFromSync(entityId); + } else if (change.payload && typeof change.payload === 'object') { + await applyConsumptionLogUpsertFromSync(change.payload); + } + } + } + }); +} + +async function pullChanges(cursor: number): Promise { + const cfg = getSyncConfig(); + const res = await fetch( + `${cfg.serverUrl}/v1/sync/pull?cursor=${encodeURIComponent(String(cursor))}`, + { + method: 'GET', + headers: buildAuthHeaders(cfg), + } + ); + + if (!res.ok) { + throw new Error(`Pull fehlgeschlagen (${res.status})`); + } + + const payload = (await res.json()) as Partial; + return { + cursor: + typeof payload.cursor === 'number' && Number.isFinite(payload.cursor) + ? payload.cursor + : cursor, + changes: Array.isArray(payload.changes) + ? payload.changes.filter( + (item): item is PullResponseChange => + Boolean(item) && + typeof item === 'object' && + typeof item.entityType === 'string' && + typeof item.entityId === 'string' && + typeof item.op === 'string' + ) + : [], + }; +} + +async function pushChanges(changes: PushRequestChange[]): Promise { + if (changes.length === 0) return; + const cfg = getSyncConfig(); + const res = await fetch(`${cfg.serverUrl}/v1/sync/push`, { + method: 'POST', + headers: buildAuthHeaders(cfg), + body: JSON.stringify({ changes }), + }); + if (!res.ok) { + throw new Error(`Push fehlgeschlagen (${res.status})`); + } +} + +export async function pairSyncDevice(params: { + serverUrl: string; + syncCode: string; + deviceName: string; +}): Promise { + const serverUrl = params.serverUrl.trim().replace(/\/+$/, ''); + if (!serverUrl) { + throw new Error('Server URL fehlt.'); + } + if (!params.syncCode.trim()) { + throw new Error('Sync-Code fehlt.'); + } + if (!params.deviceName.trim()) { + throw new Error('Gerätename fehlt.'); + } + + const res = await fetch(`${serverUrl}/v1/pair`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + syncCode: params.syncCode.trim(), + deviceName: params.deviceName.trim(), + }), + }); + + if (!res.ok) { + throw new Error(`Pairing fehlgeschlagen (${res.status})`); + } + + const payload = (await res.json()) as Partial; + if (!payload.deviceToken || !payload.deviceId || !payload.householdId) { + throw new Error('Ungültige Pairing-Antwort vom Server.'); + } + + saveSyncConfig({ + enabled: true, + serverUrl, + householdId: payload.householdId, + deviceId: payload.deviceId, + deviceToken: payload.deviceToken, + deviceName: params.deviceName.trim(), + }); + + await setSyncMetaValue('sync_cursor', '0'); + await setSyncMetaValue('sync_full_snapshot_pending', '1'); + + emitRuntimeState({ + status: 'idle', + lastError: undefined, + }); +} + +export async function runSyncNow(reason = 'manual'): Promise { + if (inFlightSync) { + return inFlightSync; + } + + const cfg = getSyncConfig(); + if (!cfg.enabled) { + emitRuntimeState({ status: 'disabled', pendingChanges: 0 }); + return; + } + + if (!cfg.serverUrl || !cfg.deviceToken || !cfg.householdId) { + emitRuntimeState({ + status: 'error', + lastError: 'Sync ist aktiviert, aber unvollständig konfiguriert.', + }); + return; + } + + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + const pending = await getSyncQueueCount(); + emitRuntimeState({ + status: 'idle', + pendingChanges: pending, + }); + return; + } + + inFlightSync = (async () => { + const startedAt = new Date().toISOString(); + emitRuntimeState({ + status: 'syncing', + lastAttemptAt: startedAt, + }); + + try { + let cursorRaw = await getSyncMetaValue('sync_cursor'); + let cursor = Number.parseInt(cursorRaw ?? '0', 10); + if (!Number.isFinite(cursor) || cursor < 0) cursor = 0; + + const firstPull = await pullChanges(cursor); + if (firstPull.changes.length > 0) { + await applyPulledChanges(firstPull.changes); + } + cursor = firstPull.cursor; + await setSyncMetaValue('sync_cursor', String(cursor)); + + const fullSnapshotPending = (await getSyncMetaValue('sync_full_snapshot_pending')) === '1'; + if (fullSnapshotPending) { + await queueFullSnapshotForSync(true); + await setSyncMetaValue('sync_full_snapshot_pending', '0'); + } + + while (true) { + const queued = await getQueuedSyncChanges(500); + if (queued.length === 0) break; + + const deduped = dedupeQueueChanges(queued); + if (deduped.changes.length === 0) { + await removeQueuedSyncChanges(deduped.consumedIds); + continue; + } + + await pushChanges(deduped.changes); + await removeQueuedSyncChanges(deduped.consumedIds); + } + + const secondPull = await pullChanges(cursor); + if (secondPull.changes.length > 0) { + await applyPulledChanges(secondPull.changes); + } + cursor = secondPull.cursor; + await setSyncMetaValue('sync_cursor', String(cursor)); + + const pending = await getSyncQueueCount(); + emitRuntimeState({ + status: 'idle', + pendingChanges: pending, + lastSuccessAt: new Date().toISOString(), + lastError: undefined, + }); + } catch (error) { + const pending = await getSyncQueueCount(); + const message = + error instanceof Error ? error.message : `Sync fehlgeschlagen (${reason})`; + emitRuntimeState({ + status: 'error', + pendingChanges: pending, + lastError: message, + }); + throw error; + } finally { + inFlightSync = null; + } + })(); + + return inFlightSync; +} + +export function getSyncRuntimeState(): SyncRuntimeState { + return { ...runtimeState }; +} + +export function subscribeSyncRuntime( + listener: (state: SyncRuntimeState) => void +): () => void { + listeners.add(listener); + listener({ ...runtimeState }); + return () => { + listeners.delete(listener); + }; +} + +export function startSyncEngine(): () => void { + if (syncInterval) { + return () => undefined; + } + + const cfg = getSyncConfig(); + emitRuntimeState({ + status: cfg.enabled ? 'idle' : 'disabled', + }); + + void runSyncNow('startup').catch(() => undefined); + + syncInterval = setInterval(() => { + void runSyncNow('interval').catch(() => undefined); + }, cfg.intervalMs); + + onlineHandler = () => { + void runSyncNow('online').catch(() => undefined); + }; + window.addEventListener('online', onlineHandler); + + visibilityHandler = () => { + if (document.visibilityState === 'visible') { + void runSyncNow('visibility').catch(() => undefined); + } + }; + document.addEventListener('visibilitychange', visibilityHandler); + + return () => { + if (syncInterval) { + clearInterval(syncInterval); + syncInterval = null; + } + if (onlineHandler) { + window.removeEventListener('online', onlineHandler); + onlineHandler = null; + } + if (visibilityHandler) { + document.removeEventListener('visibilitychange', visibilityHandler); + visibilityHandler = null; + } + }; +} diff --git a/src/lib/syncConfig.test.ts b/src/lib/syncConfig.test.ts new file mode 100644 index 0000000..1d743fd --- /dev/null +++ b/src/lib/syncConfig.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + clearSyncPairing, + getSyncConfig, + isSyncEnabled, + saveSyncConfig, +} from './syncConfig'; + +class MemoryStorage implements Storage { + private store = new Map(); + + get length(): number { + return this.store.size; + } + + clear(): void { + this.store.clear(); + } + + getItem(key: string): string | null { + return this.store.get(key) ?? null; + } + + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null; + } + + removeItem(key: string): void { + this.store.delete(key); + } + + setItem(key: string, value: string): void { + this.store.set(key, value); + } +} + +beforeEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + value: new MemoryStorage(), + configurable: true, + }); +}); + +describe('syncConfig', () => { + it('returns safe defaults when no config exists', () => { + const cfg = getSyncConfig(); + expect(cfg.enabled).toBe(false); + expect(cfg.serverUrl).toBe(''); + expect(cfg.intervalMs).toBe(120000); + }); + + it('normalizes and persists settings', () => { + const cfg = saveSyncConfig({ + enabled: true, + serverUrl: 'http://192.168.0.20:8787/', + householdId: 'house-1', + deviceId: 'device-1', + deviceToken: 'token-1', + deviceName: 'Phone', + intervalMs: 60000, + }); + + expect(cfg.serverUrl).toBe('http://192.168.0.20:8787'); + expect(getSyncConfig().serverUrl).toBe('http://192.168.0.20:8787'); + }); + + it('marks sync as enabled only when full credentials exist', () => { + saveSyncConfig({ + enabled: true, + serverUrl: 'http://localhost:8787', + householdId: 'house-1', + deviceId: 'device-1', + deviceToken: 'token-1', + deviceName: 'Desktop', + }); + expect(isSyncEnabled()).toBe(true); + + saveSyncConfig({ deviceToken: '' }); + expect(isSyncEnabled()).toBe(false); + }); + + it('clears pairing credentials', () => { + saveSyncConfig({ + enabled: true, + serverUrl: 'http://localhost:8787', + householdId: 'house-1', + deviceId: 'device-1', + deviceToken: 'token-1', + deviceName: 'Desktop', + }); + + const cleared = clearSyncPairing(); + expect(cleared.enabled).toBe(false); + expect(cleared.householdId).toBe(''); + expect(cleared.deviceId).toBe(''); + expect(cleared.deviceToken).toBe(''); + }); +}); diff --git a/src/lib/syncConfig.ts b/src/lib/syncConfig.ts new file mode 100644 index 0000000..5c45cbc --- /dev/null +++ b/src/lib/syncConfig.ts @@ -0,0 +1,87 @@ +const SYNC_CONFIG_KEY = 'preptrack-sync-config'; + +export interface SyncConfig { + enabled: boolean; + serverUrl: string; + householdId: string; + deviceId: string; + deviceToken: string; + deviceName: string; + intervalMs: number; +} + +const DEFAULT_SYNC_CONFIG: SyncConfig = { + enabled: false, + serverUrl: '', + householdId: '', + deviceId: '', + deviceToken: '', + deviceName: '', + intervalMs: 2 * 60 * 1000, +}; + +function normalizeServerUrl(value: string): string { + return value.trim().replace(/\/+$/, ''); +} + +function readRawConfig(): Partial { + try { + const raw = localStorage.getItem(SYNC_CONFIG_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Partial; + return parsed ?? {}; + } catch { + return {}; + } +} + +export function getSyncConfig(): SyncConfig { + const raw = readRawConfig(); + return { + enabled: raw.enabled === true, + serverUrl: normalizeServerUrl(raw.serverUrl ?? ''), + householdId: String(raw.householdId ?? ''), + deviceId: String(raw.deviceId ?? ''), + deviceToken: String(raw.deviceToken ?? ''), + deviceName: String(raw.deviceName ?? ''), + intervalMs: + typeof raw.intervalMs === 'number' && Number.isFinite(raw.intervalMs) && raw.intervalMs >= 10_000 + ? raw.intervalMs + : DEFAULT_SYNC_CONFIG.intervalMs, + }; +} + +export function saveSyncConfig(next: Partial): SyncConfig { + const merged = { + ...getSyncConfig(), + ...next, + }; + + const normalized: SyncConfig = { + ...merged, + serverUrl: normalizeServerUrl(merged.serverUrl), + }; + + localStorage.setItem(SYNC_CONFIG_KEY, JSON.stringify(normalized)); + return normalized; +} + +export function clearSyncPairing(): SyncConfig { + return saveSyncConfig({ + enabled: false, + householdId: '', + deviceId: '', + deviceToken: '', + }); +} + +export function isSyncEnabled(): boolean { + const cfg = getSyncConfig(); + return ( + cfg.enabled && + cfg.serverUrl.length > 0 && + cfg.householdId.length > 0 && + cfg.deviceId.length > 0 && + cfg.deviceToken.length > 0 + ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 86cf35e..ccc074b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,6 +2,7 @@ export type ExpiryStatus = 'expired' | 'critical' | 'warning' | 'soon' | 'good'; export interface Product { id?: number; + syncId?: string; name: string; barcode?: string; category: ProductCategory; @@ -71,9 +72,11 @@ export const DEFAULT_UNITS = [ export interface StorageLocation { id?: number; + syncId?: string; name: string; icon?: string; createdAt: string; + updatedAt?: string; } export const DEFAULT_LOCATIONS = [ @@ -89,14 +92,20 @@ export const DEFAULT_LOCATIONS = [ export interface ConsumptionLog { id?: number; - productId: number; + syncId?: string; + productId?: number; + productSyncId?: string; productName: string; quantity: number; unit: string; consumedAt: string; + updatedAt?: string; reason: 'verbraucht' | 'abgelaufen' | 'beschadigt' | 'sonstiges'; } +export type SyncEntityType = 'product' | 'storageLocation' | 'consumptionLog'; +export type SyncOperation = 'upsert' | 'delete'; + export interface NotificationSchedule { id?: number; productId: number; diff --git a/sync-backend/.dockerignore b/sync-backend/.dockerignore new file mode 100644 index 0000000..1a48b0e --- /dev/null +++ b/sync-backend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +npm-debug.log* +.env diff --git a/sync-backend/Dockerfile b/sync-backend/Dockerfile new file mode 100644 index 0000000..c93f1d8 --- /dev/null +++ b/sync-backend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY src ./src + +ENV PORT=8787 +ENV DB_PATH=/data/sync.db + +EXPOSE 8787 +VOLUME ["/data"] + +CMD ["npm", "start"] diff --git a/sync-backend/README.md b/sync-backend/README.md new file mode 100644 index 0000000..dbe1e60 --- /dev/null +++ b/sync-backend/README.md @@ -0,0 +1,35 @@ +# PrepTrack Sync Backend + +Minimal LAN-first sync backend for PrepTrack. + +## Run with Docker Compose + +From repo root: + +```bash +docker compose -f docker-compose.sync.yml up -d --build +``` + +Health check: + +```bash +curl http://localhost:8787/health +``` + +## Environment Variables + +- `PORT` (default `8787`) +- `DB_PATH` (default `/data/sync.db`) +- `CORS_ORIGINS` optional comma-separated allowlist (if unset, all origins are allowed) + +## API + +- `POST /v1/pair` `{ syncCode, deviceName }` +- `POST /v1/sync/push` (auth required via `Authorization: Bearer ` + `x-household-id`) +- `GET /v1/sync/pull?cursor=` (same auth headers) + +## Tests + +```bash +npm run test +``` diff --git a/sync-backend/package-lock.json b/sync-backend/package-lock.json new file mode 100644 index 0000000..d3e4b64 --- /dev/null +++ b/sync-backend/package-lock.json @@ -0,0 +1,2506 @@ +{ + "name": "preptrack-sync-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "preptrack-sync-backend", + "version": "1.0.0", + "dependencies": { + "@fastify/cors": "^11.1.0", + "better-sqlite3": "^11.9.1", + "fastify": "^5.6.1" + }, + "devDependencies": { + "vitest": "^2.1.8" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "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/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "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/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "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/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "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==", + "dev": true, + "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-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.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/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "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.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "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.2", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz", + "integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==", + "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.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "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.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "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/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/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "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.88.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz", + "integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==", + "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/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "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/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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/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/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "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.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "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.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "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.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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/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/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "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" + } + } +} diff --git a/sync-backend/package.json b/sync-backend/package.json new file mode 100644 index 0000000..070e6eb --- /dev/null +++ b/sync-backend/package.json @@ -0,0 +1,18 @@ +{ + "name": "preptrack-sync-backend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/server.js", + "test": "vitest run src/server.test.js" + }, + "dependencies": { + "@fastify/cors": "^11.1.0", + "better-sqlite3": "^11.9.1", + "fastify": "^5.6.1" + }, + "devDependencies": { + "vitest": "^2.1.8" + } +} diff --git a/sync-backend/src/server.js b/sync-backend/src/server.js new file mode 100644 index 0000000..ee37860 --- /dev/null +++ b/sync-backend/src/server.js @@ -0,0 +1,379 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import Database from 'better-sqlite3'; + +const ENTITY_TYPES = new Set(['product', 'storageLocation', 'consumptionLog']); +const OPERATIONS = new Set(['upsert', 'delete']); + +function nowIso() { + return new Date().toISOString(); +} + +function hashValue(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function randomToken() { + return crypto.randomBytes(32).toString('base64url'); +} + +function parseDateScore(value) { + const ms = new Date(value).getTime(); + if (Number.isNaN(ms)) return 0; + return ms; +} + +export function shouldApplyChange(current, incomingUpdatedAt, incomingDeviceId) { + if (!current) return true; + const currentScore = parseDateScore(current.updated_at); + const incomingScore = parseDateScore(incomingUpdatedAt); + if (incomingScore > currentScore) return true; + if (incomingScore < currentScore) return false; + return String(incomingDeviceId) > String(current.updated_by_device_id); +} + +function resolveDbPath(dbPath) { + let resolvedDbPath = dbPath; + try { + fs.mkdirSync(path.dirname(resolvedDbPath), { recursive: true }); + } catch { + resolvedDbPath = path.resolve(process.cwd(), 'data/sync.db'); + fs.mkdirSync(path.dirname(resolvedDbPath), { recursive: true }); + } + return resolvedDbPath; +} + +function initDb(dbPath) { + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.exec(` +CREATE TABLE IF NOT EXISTS households ( + id TEXT PRIMARY KEY, + sync_code_hash TEXT UNIQUE NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, + household_id TEXT NOT NULL, + device_name TEXT NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS entities ( + household_id TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + payload_json TEXT, + updated_at TEXT NOT NULL, + deleted_at TEXT, + updated_by_device_id TEXT NOT NULL, + PRIMARY KEY (household_id, entity_type, entity_id) +); + +CREATE TABLE IF NOT EXISTS changes ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + household_id TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + op TEXT NOT NULL, + payload_json TEXT, + updated_at TEXT NOT NULL, + updated_by_device_id TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_devices_household ON devices(household_id); +CREATE INDEX IF NOT EXISTS idx_entities_household_updated ON entities(household_id, updated_at); +CREATE INDEX IF NOT EXISTS idx_changes_household_seq ON changes(household_id, seq); +`); + return db; +} + +function parseCorsOrigins(value) { + return String(value || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +export async function createServer(options = {}) { + const port = Number.parseInt(String(options.port ?? process.env.PORT ?? '8787'), 10); + const requestedDbPath = String(options.dbPath ?? process.env.DB_PATH ?? '/data/sync.db'); + const corsOrigins = Array.isArray(options.corsOrigins) + ? options.corsOrigins + : parseCorsOrigins(options.corsOrigins ?? process.env.CORS_ORIGINS ?? ''); + const logger = options.logger === true; + + const resolvedDbPath = resolveDbPath(requestedDbPath); + const db = initDb(resolvedDbPath); + + const findHouseholdByCode = db.prepare( + 'SELECT id FROM households WHERE sync_code_hash = ? LIMIT 1' + ); + const insertHousehold = db.prepare( + 'INSERT INTO households (id, sync_code_hash, created_at) VALUES (?, ?, ?)' + ); + const insertDevice = db.prepare( + `INSERT INTO devices (id, household_id, device_name, token_hash, created_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?)` + ); + const findDeviceByToken = db.prepare( + `SELECT id, household_id, device_name + FROM devices + WHERE token_hash = ? AND household_id = ? + LIMIT 1` + ); + const touchDevice = db.prepare('UPDATE devices SET last_seen_at = ? WHERE id = ?'); + const readEntity = db.prepare( + `SELECT updated_at, updated_by_device_id + FROM entities + WHERE household_id = ? AND entity_type = ? AND entity_id = ?` + ); + const upsertEntity = db.prepare( + `INSERT INTO entities ( + household_id, entity_type, entity_id, payload_json, updated_at, deleted_at, updated_by_device_id + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(household_id, entity_type, entity_id) + DO UPDATE SET + payload_json = excluded.payload_json, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at, + updated_by_device_id = excluded.updated_by_device_id` + ); + const appendChange = db.prepare( + `INSERT INTO changes ( + household_id, entity_type, entity_id, op, payload_json, updated_at, updated_by_device_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ); + const readChangesSince = db.prepare( + `SELECT seq, entity_type, entity_id, op, payload_json, updated_at, updated_by_device_id + FROM changes + WHERE household_id = ? AND seq > ? + ORDER BY seq ASC + LIMIT ?` + ); + + const app = Fastify({ + logger, + bodyLimit: 15 * 1024 * 1024, + }); + + app.decorateRequest('syncContext', null); + + await app.register(cors, { + origin: (origin, cb) => { + if (!origin) { + cb(null, true); + return; + } + if (corsOrigins.length === 0) { + cb(null, true); + return; + } + cb(null, corsOrigins.includes(origin)); + }, + }); + + app.get('/health', async () => { + return { + ok: true, + time: nowIso(), + dbPath: resolvedDbPath, + }; + }); + + app.post('/v1/pair', async (request, reply) => { + const body = request.body && typeof request.body === 'object' ? request.body : {}; + const syncCode = typeof body.syncCode === 'string' ? body.syncCode.trim() : ''; + const deviceName = typeof body.deviceName === 'string' ? body.deviceName.trim() : ''; + + if (syncCode.length < 4 || deviceName.length < 2) { + reply.code(400); + return { error: 'syncCode und deviceName sind erforderlich.' }; + } + + const syncCodeHash = hashValue(syncCode); + let household = findHouseholdByCode.get(syncCodeHash); + if (!household) { + household = { id: crypto.randomUUID() }; + insertHousehold.run(household.id, syncCodeHash, nowIso()); + } + + const deviceId = crypto.randomUUID(); + const token = randomToken(); + const tokenHash = hashValue(token); + const now = nowIso(); + + insertDevice.run(deviceId, household.id, deviceName, tokenHash, now, now); + + return { + householdId: household.id, + deviceId, + deviceToken: token, + }; + }); + + app.addHook('preHandler', async (request, reply) => { + if (!request.url.startsWith('/v1/sync/')) { + return; + } + + const authHeader = request.headers.authorization || ''; + const householdId = String(request.headers['x-household-id'] || '').trim(); + + if (!authHeader.startsWith('Bearer ') || !householdId) { + reply.code(401); + throw new Error('Nicht autorisiert.'); + } + + const token = authHeader.slice('Bearer '.length).trim(); + const tokenHash = hashValue(token); + const device = findDeviceByToken.get(tokenHash, householdId); + + if (!device) { + reply.code(401); + throw new Error('Ungültiges Gerätetoken.'); + } + + touchDevice.run(nowIso(), device.id); + request.syncContext = { + deviceId: device.id, + householdId: device.household_id, + }; + }); + + app.post('/v1/sync/push', async (request, reply) => { + const body = request.body && typeof request.body === 'object' ? request.body : {}; + const incomingChanges = Array.isArray(body.changes) ? body.changes : []; + if (incomingChanges.length > 1000) { + reply.code(400); + return { error: 'Zu viele Änderungen in einem Request.' }; + } + + const { householdId, deviceId } = request.syncContext; + let applied = 0; + + const applyTransaction = db.transaction((changes) => { + for (const rawChange of changes) { + if (!rawChange || typeof rawChange !== 'object') continue; + const entityType = String(rawChange.entityType || ''); + const entityId = String(rawChange.entityId || ''); + const op = String(rawChange.op || ''); + const updatedAt = String(rawChange.updatedAt || ''); + + if (!ENTITY_TYPES.has(entityType)) continue; + if (!OPERATIONS.has(op)) continue; + if (!entityId || !updatedAt) continue; + + const payload = + op === 'upsert' && rawChange.payload && typeof rawChange.payload === 'object' + ? JSON.stringify(rawChange.payload) + : null; + + const current = readEntity.get(householdId, entityType, entityId); + if (!shouldApplyChange(current, updatedAt, deviceId)) { + continue; + } + + const deletedAt = op === 'delete' ? updatedAt : null; + upsertEntity.run( + householdId, + entityType, + entityId, + payload, + updatedAt, + deletedAt, + deviceId + ); + appendChange.run( + householdId, + entityType, + entityId, + op, + payload, + updatedAt, + deviceId, + nowIso() + ); + applied += 1; + } + }); + + applyTransaction(incomingChanges); + + return { + received: incomingChanges.length, + applied, + }; + }); + + app.get('/v1/sync/pull', async (request) => { + const query = request.query && typeof request.query === 'object' ? request.query : {}; + const rawCursor = Number.parseInt(String(query.cursor || '0'), 10); + const cursor = Number.isFinite(rawCursor) && rawCursor >= 0 ? rawCursor : 0; + const { householdId } = request.syncContext; + + const rows = readChangesSince.all(householdId, cursor, 1000); + let nextCursor = cursor; + + const changes = rows.map((row) => { + nextCursor = Math.max(nextCursor, row.seq); + return { + seq: row.seq, + entityType: row.entity_type, + entityId: row.entity_id, + op: row.op, + updatedAt: row.updated_at, + updatedByDeviceId: row.updated_by_device_id, + payload: row.payload_json ? JSON.parse(row.payload_json) : null, + }; + }); + + return { + cursor: nextCursor, + changes, + }; + }); + + app.setErrorHandler((error, _request, reply) => { + if (!reply.sent) { + reply.code(reply.statusCode >= 400 ? reply.statusCode : 500).send({ + error: error.message || 'Serverfehler', + }); + } + }); + + app.addHook('onClose', async () => { + db.close(); + }); + + return { + app, + port, + resolvedDbPath, + }; +} + +const isMainModule = + typeof process.argv[1] === 'string' && + import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMainModule) { + const { app, port } = await createServer({ + port: process.env.PORT, + dbPath: process.env.DB_PATH, + corsOrigins: process.env.CORS_ORIGINS, + logger: true, + }); + app.listen({ port, host: '0.0.0.0' }).catch((err) => { + app.log.error(err); + process.exit(1); + }); +} diff --git a/sync-backend/src/server.test.js b/sync-backend/src/server.test.js new file mode 100644 index 0000000..52395f1 --- /dev/null +++ b/sync-backend/src/server.test.js @@ -0,0 +1,258 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createServer, shouldApplyChange } from './server.js'; + +async function setupServer() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'preptrack-sync-test-')); + const dbPath = path.join(tempDir, 'sync.db'); + const { app } = await createServer({ + dbPath, + logger: false, + }); + return { app, tempDir }; +} + +async function pairDevice(app, syncCode, deviceName) { + const res = await app.inject({ + method: 'POST', + url: '/v1/pair', + payload: { syncCode, deviceName }, + }); + expect(res.statusCode).toBe(200); + return res.json(); +} + +function authHeaders(deviceToken, householdId) { + return { + authorization: `Bearer ${deviceToken}`, + 'x-household-id': householdId, + }; +} + +const openServers = []; + +afterEach(async () => { + while (openServers.length > 0) { + const { app, tempDir } = openServers.pop(); + await app.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('shouldApplyChange', () => { + it('accepts newer updates and rejects older ones', () => { + const current = { + updated_at: '2026-03-10T10:00:00.000Z', + updated_by_device_id: 'device-a', + }; + + expect(shouldApplyChange(current, '2026-03-10T10:00:01.000Z', 'device-b')).toBe(true); + expect(shouldApplyChange(current, '2026-03-10T09:59:59.000Z', 'device-b')).toBe(false); + }); + + it('uses device id as deterministic tie-breaker', () => { + const current = { + updated_at: '2026-03-10T10:00:00.000Z', + updated_by_device_id: 'device-b', + }; + + expect(shouldApplyChange(current, '2026-03-10T10:00:00.000Z', 'device-a')).toBe(false); + expect(shouldApplyChange(current, '2026-03-10T10:00:00.000Z', 'device-c')).toBe(true); + }); +}); + +describe('sync backend integration', () => { + it('pairs multiple devices into the same household for the same sync code', async () => { + const ctx = await setupServer(); + openServers.push(ctx); + + const first = await pairDevice(ctx.app, 'HOUSEHOLD-1234', 'Desktop'); + const second = await pairDevice(ctx.app, 'HOUSEHOLD-1234', 'Phone'); + + expect(first.householdId).toBe(second.householdId); + expect(first.deviceId).not.toBe(second.deviceId); + expect(first.deviceToken).not.toBe(second.deviceToken); + }); + + it('requires authentication for sync endpoints', async () => { + const ctx = await setupServer(); + openServers.push(ctx); + + const res = await ctx.app.inject({ + method: 'GET', + url: '/v1/sync/pull?cursor=0', + }); + expect(res.statusCode).toBe(401); + }); + + it('supports push and pull with last-write-wins behavior', async () => { + const ctx = await setupServer(); + openServers.push(ctx); + + const desktop = await pairDevice(ctx.app, 'SYNC-CODE-1', 'Desktop'); + const phone = await pairDevice(ctx.app, 'SYNC-CODE-1', 'Phone'); + + const firstPush = await ctx.app.inject({ + method: 'POST', + url: '/v1/sync/push', + headers: authHeaders(desktop.deviceToken, desktop.householdId), + payload: { + changes: [ + { + entityType: 'product', + entityId: 'prod-1', + op: 'upsert', + updatedAt: '2026-03-10T12:00:00.000Z', + payload: { syncId: 'prod-1', name: 'Reis', quantity: 1 }, + }, + ], + }, + }); + expect(firstPush.statusCode).toBe(200); + expect(firstPush.json().applied).toBe(1); + + const initialPull = await ctx.app.inject({ + method: 'GET', + url: '/v1/sync/pull?cursor=0', + headers: authHeaders(phone.deviceToken, phone.householdId), + }); + expect(initialPull.statusCode).toBe(200); + const initialPayload = initialPull.json(); + expect(initialPayload.changes).toHaveLength(1); + expect(initialPayload.changes[0].payload.quantity).toBe(1); + + const staleUpdate = await ctx.app.inject({ + method: 'POST', + url: '/v1/sync/push', + headers: authHeaders(phone.deviceToken, phone.householdId), + payload: { + changes: [ + { + entityType: 'product', + entityId: 'prod-1', + op: 'upsert', + updatedAt: '2026-03-10T11:59:59.000Z', + payload: { syncId: 'prod-1', name: 'Reis', quantity: 99 }, + }, + ], + }, + }); + expect(staleUpdate.statusCode).toBe(200); + expect(staleUpdate.json().applied).toBe(0); + + const newerUpdate = await ctx.app.inject({ + method: 'POST', + url: '/v1/sync/push', + headers: authHeaders(phone.deviceToken, phone.householdId), + payload: { + changes: [ + { + entityType: 'product', + entityId: 'prod-1', + op: 'upsert', + updatedAt: '2026-03-10T12:05:00.000Z', + payload: { syncId: 'prod-1', name: 'Reis', quantity: 2 }, + }, + ], + }, + }); + expect(newerUpdate.statusCode).toBe(200); + expect(newerUpdate.json().applied).toBe(1); + + const secondPull = await ctx.app.inject({ + method: 'GET', + url: `/v1/sync/pull?cursor=${initialPayload.cursor}`, + headers: authHeaders(desktop.deviceToken, desktop.householdId), + }); + expect(secondPull.statusCode).toBe(200); + const secondPayload = secondPull.json(); + expect(secondPayload.changes).toHaveLength(1); + expect(secondPayload.changes[0].payload.quantity).toBe(2); + }); + + it('propagates deletes as tombstone changes', async () => { + const ctx = await setupServer(); + openServers.push(ctx); + + const deviceA = await pairDevice(ctx.app, 'SYNC-CODE-DELETE', 'Desktop'); + const deviceB = await pairDevice(ctx.app, 'SYNC-CODE-DELETE', 'Phone'); + + await ctx.app.inject({ + method: 'POST', + url: '/v1/sync/push', + headers: authHeaders(deviceA.deviceToken, deviceA.householdId), + payload: { + changes: [ + { + entityType: 'product', + entityId: 'prod-delete', + op: 'upsert', + updatedAt: '2026-03-10T10:00:00.000Z', + payload: { syncId: 'prod-delete', name: 'Wasser', quantity: 4 }, + }, + ], + }, + }); + + const firstPull = await ctx.app.inject({ + method: 'GET', + url: '/v1/sync/pull?cursor=0', + headers: authHeaders(deviceB.deviceToken, deviceB.householdId), + }); + const firstCursor = firstPull.json().cursor; + + const deletePush = await ctx.app.inject({ + method: 'POST', + url: '/v1/sync/push', + headers: authHeaders(deviceA.deviceToken, deviceA.householdId), + payload: { + changes: [ + { + entityType: 'product', + entityId: 'prod-delete', + op: 'delete', + updatedAt: '2026-03-10T11:00:00.000Z', + }, + ], + }, + }); + expect(deletePush.statusCode).toBe(200); + expect(deletePush.json().applied).toBe(1); + + const afterDeletePull = await ctx.app.inject({ + method: 'GET', + url: `/v1/sync/pull?cursor=${firstCursor}`, + headers: authHeaders(deviceB.deviceToken, deviceB.householdId), + }); + expect(afterDeletePull.statusCode).toBe(200); + const payload = afterDeletePull.json(); + expect(payload.changes).toHaveLength(1); + expect(payload.changes[0].op).toBe('delete'); + expect(payload.changes[0].payload).toBeNull(); + }); + + it('rejects oversized push batches', async () => { + const ctx = await setupServer(); + openServers.push(ctx); + + const paired = await pairDevice(ctx.app, 'SYNC-CODE-LIMIT', 'Desktop'); + const changes = Array.from({ length: 1001 }, (_, i) => ({ + entityType: 'product', + entityId: `prod-${i}`, + op: 'upsert', + updatedAt: '2026-03-10T12:00:00.000Z', + payload: { syncId: `prod-${i}`, name: `Item ${i}`, quantity: 1 }, + })); + + const res = await ctx.app.inject({ + method: 'POST', + url: '/v1/sync/push', + headers: authHeaders(paired.deviceToken, paired.householdId), + payload: { changes }, + }); + + expect(res.statusCode).toBe(400); + }); +});