From ca190b2832442f03e8c7123366706abcc4098fd0 Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 12 Jul 2026 20:39:01 +0200 Subject: [PATCH] composite & deleted --- .gitignore | 3 +- package-lock.json | 357 +++++++++++++++++++++++++ package.json | 3 +- productSync.md | 63 +++++ server.js | 10 + src/endpoints/cimage.js | 2 +- src/endpoints/deleted-entity.js | 14 + src/endpoints/index.js | 4 +- src/endpoints/init.js | 12 +- src/endpoints/pimage.js | 2 +- src/endpoints/productcomposite.js | 14 + src/{endpoints => }/image-handler.js | 6 +- src/queries/composite-product-count.js | 18 ++ src/queries/composite-product-list.js | 30 +++ src/queries/deleted-entity-count.js | 16 ++ src/queries/deleted-entity-list.js | 26 ++ src/queries/product-list.js | 2 + 17 files changed, 571 insertions(+), 11 deletions(-) create mode 100644 productSync.md create mode 100644 src/endpoints/deleted-entity.js create mode 100644 src/endpoints/productcomposite.js rename src/{endpoints => }/image-handler.js (77%) create mode 100644 src/queries/composite-product-count.js create mode 100644 src/queries/composite-product-list.js create mode 100644 src/queries/deleted-entity-count.js create mode 100644 src/queries/deleted-entity-list.js diff --git a/.gitignore b/.gitignore index b9213ac..656342c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/ .env certs/ logs/ -capturedDataReference \ No newline at end of file +capturedDataReference +decompiledReference \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8d3dcf0..69b9860 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "0BSD", "dependencies": { + "better-sqlite3": "^12.11.1", "chalk": "^5.6.2", "dotenv": "^17.4.2", "mssql": "^12.7.0", @@ -903,6 +904,29 @@ ], "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "6.1.6", "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", @@ -972,6 +996,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "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/commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -998,6 +1028,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -1068,6 +1122,15 @@ "safe-buffer": "^5.0.1" } }, + "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/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -1086,6 +1149,33 @@ "node": ">=0.8.x" } }, + "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/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/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/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/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -1154,6 +1244,12 @@ "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/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -1293,6 +1389,33 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1318,12 +1441,39 @@ "node": ">=18.19.0" } }, + "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/native-duplexpair": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "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/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -1342,6 +1492,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -1351,6 +1528,31 @@ "node": ">= 0.6.0" } }, + "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/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": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -1466,6 +1668,51 @@ } } }, + "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/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -1481,6 +1728,92 @@ "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.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "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/tar-stream/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/tar-stream/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/tar-stream/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/tarn": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.1.0.tgz", @@ -1517,12 +1850,36 @@ "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==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", diff --git a/package.json b/package.json index c05bec3..545949e 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,10 @@ "test:client": "node test-client.js" }, "engines": { - "node": ">=18" + "node": ">=22" }, "dependencies": { + "better-sqlite3": "^12.11.1", "chalk": "^5.6.2", "dotenv": "^17.4.2", "mssql": "^12.7.0", diff --git a/productSync.md b/productSync.md new file mode 100644 index 0000000..c2a7dc7 --- /dev/null +++ b/productSync.md @@ -0,0 +1,63 @@ +# Product Sync Protocol + +## The Sync Flow + +1. **The POS client polls `/api/v1/init`** with its current `lastChangedProduct` cursor (e.g. `24599`). The server counts how many products have a row version greater than that cursor and returns the count. + +2. **If `product_count > 0`**, the client fetches `/api/v1/product` with the same cursor and a `limit` (e.g. 20). The server returns the changed products ordered by row version ascending. + +3. **The client updates its local cursor** to the highest `lastChanged` value it received in that batch (e.g. `24609`), so the next poll only fetches newer changes. + +### Example from the log + +``` +init with lastChangedProduct=24599 -> product_count=0 (no changes) +init with lastChangedProduct=24599 -> product_count=1 (a new product appeared) +product fetch lastChangedProduct=24599 -> returns product with lastChanged=24609 +init with lastChangedProduct=24609 -> product_count=0 (caught up) +``` + +## What is `lastChangedProduct`? + +It's the **MSSQL `bRowversion`** value -- an auto-incrementing binary counter that SQL Server bumps whenever a row is modified. The queries in `product-count.js` and `product-list.js` filter with: + +```sql +WHERE CONVERT(BIGINT, a.bRowversion) > @cursor +``` + +The client simply persists the highest `lastChanged` value it received so it only gets new/changed rows on subsequent polls. + +## How it's kept track + +**Server side**: The cursor is stateless -- the client sends its last-known value every time. The server just runs the SQL query filtering rows above that value. + +**Client side**: The POS client stores its `lastChangedProduct` (and `lastChangedCategory`, `lastChangedCustomerGroup`, etc.) persistently so it can resume syncing after a restart without re-downloading everything. + +## Other entity types + +The same pattern applies to: + +- **Categories** (`lastChangedCategory` / `bRowversion` on `tKategorie`) +- **Customer groups** (`lastChangedCustomerGroup` / `bRowversion` on `tKundenGruppe`) +- **Composite products** (`lastChangedCompositeProduct` / `bRowversion` on `tArtikelBaugruppe`) +- **Deleted entities** (`lastChangedDeletedEntity` / `bLastChanged` on `Pos.vDeletedEntity`) + +## Deleted entity sync + +When entities are deleted in JTL-Wawi, they appear in the `Pos.vDeletedEntity` view with their entity type: + +| nEntityType | Entity | +|---|---| +| 1 | Artikel (Product) | +| 2 | Kategorie (Category) | +| 7 | Stückliste (Composite Product) | + +The client polls `/v1/init` with `lastChangedDeletedEntity` cursor, and if `deletedEntity_count > 0`, fetches `/v1/deletedentity` to get the list of deleted entities. The response format is: + +```json +[ + { "entityId": "123", "entityType": "1", "lastChanged": "208980" } +] +``` + +The client then removes these entities from its local database. \ No newline at end of file diff --git a/server.js b/server.js index 04de48b..722ce9d 100644 --- a/server.js +++ b/server.js @@ -36,6 +36,14 @@ function truncateUrl(url) { } const lastInitState = { url: '', result: '', timestamp: 0 }; +let suppressedCount = 0; + +setInterval(() => { + if (suppressedCount > 0) { + logger.info(`Suppressed ${suppressedCount} init log(s) in the last hour`); + suppressedCount = 0; + } +}, 60 * 60 * 1000); function shouldLogInit(url, statusCode, durationMs, responseBody) { if (statusCode !== 200) return true; @@ -45,6 +53,8 @@ function shouldLogInit(url, statusCode, durationMs, responseBody) { const timeSinceLast = now - lastInitState.timestamp; if (timeSinceLast < 30_000 && url === lastInitState.url && responseBody === lastInitState.result) { + lastInitState.timestamp = now; + suppressedCount++; return false; } diff --git a/src/endpoints/cimage.js b/src/endpoints/cimage.js index b56287b..c4c9fc2 100644 --- a/src/endpoints/cimage.js +++ b/src/endpoints/cimage.js @@ -1,4 +1,4 @@ -import { createImageHandler } from './image-handler.js'; +import { createImageHandler } from '../image-handler.js'; export const method = 'GET'; export const path = '/v1/cimage'; diff --git a/src/endpoints/deleted-entity.js b/src/endpoints/deleted-entity.js new file mode 100644 index 0000000..0b4cf8b --- /dev/null +++ b/src/endpoints/deleted-entity.js @@ -0,0 +1,14 @@ +import { sendJson } from '../http.js'; +import { getDeletedEntityList } from '../queries/deleted-entity-list.js'; + +export const method = 'GET'; +export const path = '/v1/deletedentity'; + +export async function handle(_req, res, { url }) { + const cursor = Number(url.searchParams.get('lastChangedDeletedEntity')) || 0; + const limit = Number(url.searchParams.get('limit')) || 600; + + const deletedEntities = await getDeletedEntityList({ cursor, limit }); + + return sendJson(res, 200, deletedEntities); +} diff --git a/src/endpoints/index.js b/src/endpoints/index.js index b3a39f3..bfcd4e5 100644 --- a/src/endpoints/index.js +++ b/src/endpoints/index.js @@ -2,9 +2,11 @@ import * as category from './category.js'; import * as cimage from './cimage.js'; import * as client from './client.js'; import * as customergroup from './customergroup.js'; +import * as deletedEntity from './deleted-entity.js'; import * as init from './init.js'; import * as order from './order.js'; import * as pimage from './pimage.js'; import * as product from './product.js'; +import * as productcomposite from './productcomposite.js'; -export const endpoints = [client, init, category, product, pimage, cimage, customergroup, order]; +export const endpoints = [client, init, category, product, productcomposite, deletedEntity, pimage, cimage, customergroup, order]; diff --git a/src/endpoints/init.js b/src/endpoints/init.js index 8662d4c..f75da26 100644 --- a/src/endpoints/init.js +++ b/src/endpoints/init.js @@ -1,7 +1,9 @@ import { sendJson } from '../http.js'; import { getMaxExternalId } from '../order-log.js'; import { getCategoryCount } from '../queries/category-count.js'; +import { getCompositeProductCount } from '../queries/composite-product-count.js'; import { getCustomerGroupCount } from '../queries/customer-groups.js'; +import { getDeletedEntityCount } from '../queries/deleted-entity-count.js'; import { getProductCount } from '../queries/product-count.js'; export const method = 'GET'; @@ -11,11 +13,15 @@ export async function handle(_req, res, { url }) { const productCursor = Number(url.searchParams.get('lastChangedProduct')) || 0; const categoryCursor = Number(url.searchParams.get('lastChangedCategory')) || 0; const customerGroupCursor = Number(url.searchParams.get('lastChangedCustomerGroup')) || 0; + const compositeProductCursor = Number(url.searchParams.get('lastChangedCompositeProduct')) || 0; + const deletedEntityCursor = Number(url.searchParams.get('lastChangedDeletedEntity')) || 0; - const [productCount, categoryCount, customerGroupCount] = await Promise.all([ + const [productCount, categoryCount, customerGroupCount, compositeProductCount, deletedEntityCount] = await Promise.all([ getProductCount({ cursor: productCursor }), getCategoryCount({ cursor: categoryCursor }), getCustomerGroupCount({ cursor: customerGroupCursor }), + getCompositeProductCount({ cursor: compositeProductCursor }), + getDeletedEntityCount({ cursor: deletedEntityCursor }), ]); return sendJson(res, 200, { @@ -24,10 +30,10 @@ export async function handle(_req, res, { url }) { category_count: String(categoryCount), customer_count: '0', customerGroup_count: String(customerGroupCount), - compositeProduct_count: '0', + compositeProduct_count: String(compositeProductCount), configurationGroup_count: '0', configurationItem_count: '0', - deletedEntity_count: '0', + deletedEntity_count: String(deletedEntityCount), max_orderId_count: getMaxExternalId(), }); } diff --git a/src/endpoints/pimage.js b/src/endpoints/pimage.js index 8242542..857b544 100644 --- a/src/endpoints/pimage.js +++ b/src/endpoints/pimage.js @@ -1,4 +1,4 @@ -import { createImageHandler } from './image-handler.js'; +import { createImageHandler } from '../image-handler.js'; export const method = 'GET'; export const path = '/v1/pimage'; diff --git a/src/endpoints/productcomposite.js b/src/endpoints/productcomposite.js new file mode 100644 index 0000000..6a314fd --- /dev/null +++ b/src/endpoints/productcomposite.js @@ -0,0 +1,14 @@ +import { sendJson } from '../http.js'; +import { getCompositeProductList } from '../queries/composite-product-list.js'; + +export const method = 'GET'; +export const path = '/v1/productcomposite'; + +export async function handle(_req, res, { url }) { + const cursor = Number(url.searchParams.get('lastChangedCompositeProduct')) || 0; + const limit = Number(url.searchParams.get('limit')) || 100; + + const composites = await getCompositeProductList({ cursor, limit }); + + return sendJson(res, 200, composites); +} diff --git a/src/endpoints/image-handler.js b/src/image-handler.js similarity index 77% rename from src/endpoints/image-handler.js rename to src/image-handler.js index 5c92f60..3e98946 100644 --- a/src/endpoints/image-handler.js +++ b/src/image-handler.js @@ -1,6 +1,6 @@ -import { sendBinary, sendJson } from '../http.js'; -import { resizeImage } from '../image-resize.js'; -import { getImageByHash } from '../queries/image.js'; +import { sendBinary, sendJson } from './http.js'; +import { resizeImage } from './image-resize.js'; +import { getImageByHash } from './queries/image.js'; export function createImageHandler() { return async function handle(_req, res, { url }) { diff --git a/src/queries/composite-product-count.js b/src/queries/composite-product-count.js new file mode 100644 index 0000000..34fb32b --- /dev/null +++ b/src/queries/composite-product-count.js @@ -0,0 +1,18 @@ +import sql from 'mssql'; +import { getPool } from '../db.js'; + +const COMPOSITE_PRODUCT_COUNT_SQL = ` +SELECT COUNT(DISTINCT a.kArtikel) AS CompositeProductCount +FROM dbo.tArtikel a +INNER JOIN dbo.tStueckliste s ON s.kStueckliste = a.kStueckliste +WHERE a.kStueckliste <> 0 + AND CONVERT(BIGINT, a.bRowversion) > @cursor; +`; + +export async function getCompositeProductCount({ cursor = 0 } = {}) { + const result = await getPool() + .request() + .input('cursor', sql.BigInt, cursor) + .query(COMPOSITE_PRODUCT_COUNT_SQL); + return result.recordset[0]?.CompositeProductCount ?? 0; +} diff --git a/src/queries/composite-product-list.js b/src/queries/composite-product-list.js new file mode 100644 index 0000000..d3334ce --- /dev/null +++ b/src/queries/composite-product-list.js @@ -0,0 +1,30 @@ +import sql from 'mssql'; +import { getPool } from '../db.js'; + +const COMPOSITE_PRODUCT_LIST_SQL = ` +SELECT TOP (@limit) + s.kVaterArtikel AS productId, + s.kArtikel AS productIdComponent, + CONVERT(VARCHAR(20), s.fAnzahl, 2) AS quantity, + CONVERT(BIGINT, a.bRowversion) AS lastChanged +FROM dbo.tStueckliste s +INNER JOIN dbo.tArtikel a ON a.kArtikel = s.kVaterArtikel +WHERE a.kStueckliste <> 0 + AND CONVERT(BIGINT, a.bRowversion) > @cursor +ORDER BY lastChanged ASC; +`; + +export async function getCompositeProductList({ cursor = 0, limit = 100 } = {}) { + const result = await getPool() + .request() + .input('cursor', sql.BigInt, cursor) + .input('limit', sql.Int, limit) + .query(COMPOSITE_PRODUCT_LIST_SQL); + + return result.recordset.map((row) => ({ + productId: String(row.productId), + productIdComponent: String(row.productIdComponent), + quantity: row.quantity, + lastChanged: String(row.lastChanged), + })); +} diff --git a/src/queries/deleted-entity-count.js b/src/queries/deleted-entity-count.js new file mode 100644 index 0000000..ab2f9c1 --- /dev/null +++ b/src/queries/deleted-entity-count.js @@ -0,0 +1,16 @@ +import sql from 'mssql'; +import { getPool } from '../db.js'; + +const DELETED_ENTITY_COUNT_SQL = ` +SELECT COUNT(*) AS DeletedEntityCount +FROM Pos.vDeletedEntity +WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > @cursor; +`; + +export async function getDeletedEntityCount({ cursor = 0 } = {}) { + const result = await getPool() + .request() + .input('cursor', sql.BigInt, cursor) + .query(DELETED_ENTITY_COUNT_SQL); + return result.recordset[0]?.DeletedEntityCount ?? 0; +} diff --git a/src/queries/deleted-entity-list.js b/src/queries/deleted-entity-list.js new file mode 100644 index 0000000..071259b --- /dev/null +++ b/src/queries/deleted-entity-list.js @@ -0,0 +1,26 @@ +import sql from 'mssql'; +import { getPool } from '../db.js'; + +const DELETED_ENTITY_LIST_SQL = ` +SELECT TOP (@limit) + vDeletedEntity.kEntityId, + vDeletedEntity.nEntityType, + CONVERT(BIGINT, vDeletedEntity.bLastChanged) AS lastChanged +FROM Pos.vDeletedEntity +WHERE CONVERT(BIGINT, vDeletedEntity.bLastChanged) > @cursor +ORDER BY lastChanged ASC; +`; + +export async function getDeletedEntityList({ cursor = 0, limit = 600 } = {}) { + const result = await getPool() + .request() + .input('cursor', sql.BigInt, cursor) + .input('limit', sql.Int, limit) + .query(DELETED_ENTITY_LIST_SQL); + + return result.recordset.map((row) => ({ + entityId: String(row.kEntityId), + entityType: String(row.nEntityType), + lastChanged: String(row.lastChanged), + })); +} diff --git a/src/queries/product-list.js b/src/queries/product-list.js index 3adf775..b91e498 100644 --- a/src/queries/product-list.js +++ b/src/queries/product-list.js @@ -38,6 +38,7 @@ SELECT TOP (@limit) ) AS categoryIds, a.nIstVater AS isParent, a.kVaterArtikel AS parentArticleId, + CASE WHEN a.kStueckliste <> 0 THEN '1' ELSE '0' END AS isCompositeProduct, ( SELECT TOP 1 pv.cVariantName FROM Pos.vProductVariant pv @@ -144,6 +145,7 @@ export async function getProductList({ cursor = 0, limit = 20 } = {}) { is_parent: product.isParent ? '1' : '0', parent: product.parentArticleId > 0 ? String(product.parentArticleId) : '0', variants: product.variantName ?? '', + isCompositeProduct: product.isCompositeProduct, attributes: articleAttributes?.attributes ?? [], ...(articleAttributes?.deposit ?? {}), };