From fe39d661aa2da9d2c5f8f6f4bf7c44e59245b9dd Mon Sep 17 00:00:00 2001 From: bl4ckh4nd Date: Thu, 12 Mar 2026 17:26:19 +0100 Subject: [PATCH 1/2] feat(sync-backend): initialize backend with Fastify, SQLite, and CORS support - Add package.json with dependencies and scripts for starting the server and running tests. - Implement server.js with endpoints for device pairing, sync push/pull, and health check. - Create SQLite database schema for households, devices, entities, and changes. - Implement logic for handling changes with last-write-wins strategy and tombstone deletes. - Add server tests for pairing devices, authentication, sync operations, and error handling. --- .gitignore | 5 + README.md | 22 +- docker-compose.sync.yml | 16 + package-lock.json | 1084 ++++++++++++- package.json | 4 + src/App.tsx | 7 +- src/components/ProductList.tsx | 4 +- src/components/Settings.tsx | 228 ++- src/lib/db.import.test.ts | 116 ++ src/lib/db.ts | 672 ++++++++- src/lib/sync.test.ts | 181 +++ src/lib/sync.ts | 414 +++++ src/lib/syncConfig.test.ts | 98 ++ src/lib/syncConfig.ts | 87 ++ src/types/index.ts | 11 +- sync-backend/.dockerignore | 3 + sync-backend/Dockerfile | 16 + sync-backend/README.md | 35 + sync-backend/package-lock.json | 2506 +++++++++++++++++++++++++++++++ sync-backend/package.json | 18 + sync-backend/src/server.js | 379 +++++ sync-backend/src/server.test.js | 258 ++++ 22 files changed, 6111 insertions(+), 53 deletions(-) create mode 100644 docker-compose.sync.yml create mode 100644 src/lib/db.import.test.ts create mode 100644 src/lib/sync.test.ts create mode 100644 src/lib/sync.ts create mode 100644 src/lib/syncConfig.test.ts create mode 100644 src/lib/syncConfig.ts create mode 100644 sync-backend/.dockerignore create mode 100644 sync-backend/Dockerfile create mode 100644 sync-backend/README.md create mode 100644 sync-backend/package-lock.json create mode 100644 sync-backend/package.json create mode 100644 sync-backend/src/server.js create mode 100644 sync-backend/src/server.test.js 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..60f1d45 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 } 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,13 @@ 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 [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 +133,87 @@ 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(''); + 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 +480,129 @@ 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 && ( +
+ + 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 ? ( + + ) : ( + + )} +
+ + {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..c5dcb50 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -2,18 +2,88 @@ 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 }; +} + +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 +103,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 +382,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', + barcode: typeof payload.barcode === 'string' ? payload.barcode : 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: typeof payload.photo === 'string' ? payload.photo : existing?.photo, + minStock: + typeof payload.minStock === 'number' && Number.isFinite(payload.minStock) + ? payload.minStock + : existing?.minStock, + notes: typeof payload.notes === 'string' ? payload.notes : 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 +688,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 +721,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 +742,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 +779,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); + }); +}); From d0e63dedc40f8fffe86e0b89a9ebe4db817c8865 Mon Sep 17 00:00:00 2001 From: bl4ckh4nd Date: Thu, 12 Mar 2026 19:53:25 +0100 Subject: [PATCH 2/2] Fix field-clear propagation in product sync and allow re-pairing P1: toSyncProductPayload now serializes optional fields (barcode, photo, minStock, notes) as explicit null instead of omitting them via JSON.stringify. applyProductUpsertFromSync uses 'key in payload' to distinguish an intentional clear (null) from an absent key (fallback to existing), so clearing a field on one device correctly propagates to peers. P2: Add a 'Neu koppeln' path in Settings that clears stored pairing credentials (householdId/deviceToken) and shows the sync-code input again, allowing users to re-pair after switching servers or resetting backend data. --- src/components/Settings.tsx | 33 +++++++++++++++++++++++++++++---- src/lib/db.ts | 24 ++++++++++++++++-------- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 60f1d45..f98437c 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -8,7 +8,7 @@ import { useDarkMode } from '../hooks/useDarkMode'; import { usePWAInstall } from '../hooks/usePWAInstall'; import { useAppStore } from '../store/useAppStore'; import { downloadFile } from '../lib/utils'; -import { getSyncConfig, saveSyncConfig } from '../lib/syncConfig'; +import { getSyncConfig, saveSyncConfig, clearSyncPairing } from '../lib/syncConfig'; import { getSyncRuntimeState, pairSyncDevice, @@ -67,6 +67,7 @@ export function Settings() { 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()); @@ -167,6 +168,7 @@ export function Settings() { deviceName: syncDeviceName.trim(), }); setSyncCode(''); + setShowRepair(false); const next = getSyncConfig(); setSyncConfigState(next); setSyncNotice({ type: 'success', message: 'Gerät erfolgreich gekoppelt.' }); @@ -514,7 +516,7 @@ export function Settings() { /> - {!syncIsPaired && ( + {(!syncIsPaired || showRepair) && (
- {!syncIsPaired ? ( + {!syncIsPaired || showRepair ? ( ) : ( + )} + {showRepair && ( + + )} + {syncIsPaired && (