From 72aeafe2c2a98d99d1cecdf0de2aaddfec9c9b72 Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Mon, 10 Aug 2026 12:50:31 +0200 Subject: [PATCH 01/20] chore: add vitest and typecheck scripts The repository had no frontend test runner and no way to run tsc as a check, so type errors first surfaced during the release build. Adds vitest plus `typecheck`, `test` and `test:watch` scripts. --- package.json | 8 +- yarn.lock | 440 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 445 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index d5d9f0a1..b0d18cd4 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "format": "biome format --write .", "lint": "biome lint .", "check": "biome check --write .", - "check:ci": "biome check ." + "check:ci": "biome check .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@glideapps/glide-data-grid": "^6.0.3", @@ -54,6 +57,7 @@ "tailwindcss": "^4.1.13", "tw-animate-css": "^1.3.3", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "^3.2.4" } } diff --git a/yarn.lock b/yarn.lock index 6a69c3c4..932faa00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -412,131 +412,261 @@ resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz#bef96351f16520055c947aba28802eede3c9e9a9" integrity sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA== +"@esbuild/aix-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc" + integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ== + "@esbuild/android-arm64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz#d2e70be7d51a529425422091e0dcb90374c1546c" integrity sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg== +"@esbuild/android-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504" + integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A== + "@esbuild/android-arm@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz#d2a753fe2a4c73b79437d0ba1480e2d760097419" integrity sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ== +"@esbuild/android-arm@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381" + integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg== + "@esbuild/android-x64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz#5278836e3c7ae75761626962f902a0d55352e683" integrity sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw== +"@esbuild/android-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801" + integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q== + "@esbuild/darwin-arm64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz" integrity sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg== +"@esbuild/darwin-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc" + integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw== + "@esbuild/darwin-x64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz#e27dbc3b507b3a1cea3b9280a04b8b6b725f82be" integrity sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ== +"@esbuild/darwin-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de" + integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw== + "@esbuild/freebsd-arm64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz#364e3e5b7a1fd45d92be08c6cc5d890ca75908ca" integrity sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q== +"@esbuild/freebsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e" + integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw== + "@esbuild/freebsd-x64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz#7c869b45faeb3df668e19ace07335a0711ec56ab" integrity sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg== +"@esbuild/freebsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7" + integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg== + "@esbuild/linux-arm64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz#48d42861758c940b61abea43ba9a29b186d6cb8b" integrity sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw== +"@esbuild/linux-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d" + integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug== + "@esbuild/linux-arm@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz#6ce4b9cabf148274101701d112b89dc67cc52f37" integrity sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw== +"@esbuild/linux-arm@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753" + integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w== + "@esbuild/linux-ia32@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz#207e54899b79cac9c26c323fc1caa32e3143f1c4" integrity sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A== +"@esbuild/linux-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b" + integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ== + "@esbuild/linux-loong64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz#0ba48a127159a8f6abb5827f21198b999ffd1fc0" integrity sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ== +"@esbuild/linux-loong64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98" + integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ== + "@esbuild/linux-mips64el@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz#a4d4cc693d185f66a6afde94f772b38ce5d64eb5" integrity sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA== +"@esbuild/linux-mips64el@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca" + integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA== + "@esbuild/linux-ppc64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz#0f5805c1c6d6435a1dafdc043cb07a19050357db" integrity sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w== +"@esbuild/linux-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2" + integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ== + "@esbuild/linux-riscv64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz#6776edece0f8fca79f3386398b5183ff2a827547" integrity sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg== +"@esbuild/linux-riscv64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27" + integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA== + "@esbuild/linux-s390x@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz#3f6f29ef036938447c2218d309dc875225861830" integrity sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA== +"@esbuild/linux-s390x@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb" + integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg== + "@esbuild/linux-x64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz#831fe0b0e1a80a8b8391224ea2377d5520e1527f" integrity sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg== +"@esbuild/linux-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485" + integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ== + "@esbuild/netbsd-arm64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz#06f99d7eebe035fbbe43de01c9d7e98d2a0aa548" integrity sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q== +"@esbuild/netbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1" + integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw== + "@esbuild/netbsd-x64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz#db99858e6bed6e73911f92a88e4edd3a8c429a52" integrity sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g== +"@esbuild/netbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de" + integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw== + "@esbuild/openbsd-arm64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz#afb886c867e36f9d86bb21e878e1185f5d5a0935" integrity sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ== +"@esbuild/openbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef" + integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ== + "@esbuild/openbsd-x64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz#30855c9f8381fac6a0ef5b5f31ac6e7108a66ecf" integrity sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA== +"@esbuild/openbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz#febed2402d6088225e91f20fb4ce2522ad0a4efd" + integrity sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw== + "@esbuild/openharmony-arm64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz#2f2144af31e67adc2a8e3705c20c2bd97bd88314" integrity sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg== +"@esbuild/openharmony-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce" + integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q== + "@esbuild/sunos-x64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz#69b99a9b5bd226c9eb9c6a73f990fddd497d732e" integrity sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw== +"@esbuild/sunos-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4" + integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g== + "@esbuild/win32-arm64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz#d789330a712af916c88325f4ffe465f885719c6b" integrity sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ== +"@esbuild/win32-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532" + integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ== + "@esbuild/win32-ia32@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz#52fc735406bd49688253e74e4e837ac2ba0789e3" integrity sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww== +"@esbuild/win32-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7" + integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA== + "@esbuild/win32-x64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f" integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ== +"@esbuild/win32-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87" + integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g== + "@glideapps/glide-data-grid@^6.0.3": version "6.0.3" resolved "https://registry.npmjs.org/@glideapps/glide-data-grid/-/glide-data-grid-6.0.3.tgz" @@ -1173,11 +1303,29 @@ dependencies: "@babel/types" "^7.28.2" +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + "@types/estree@1.0.8": version "1.0.8" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== +"@types/estree@^1.0.0": + version "1.0.9" + resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== + "@types/geojson@*": version "7946.0.16" resolved "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz" @@ -1219,6 +1367,67 @@ "@types/babel__core" "^7.20.5" react-refresh "^0.17.0" +"@vitest/expect@3.2.7": + version "3.2.7" + resolved "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz#70a34158383d008c3bf5d802e2643317f09df6d8" + integrity sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/spy" "3.2.7" + "@vitest/utils" "3.2.7" + chai "^5.2.0" + tinyrainbow "^2.0.0" + +"@vitest/mocker@3.2.7": + version "3.2.7" + resolved "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz#331be944cb783c642dd42bd743411aca24ea0466" + integrity sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA== + dependencies: + "@vitest/spy" "3.2.7" + estree-walker "^3.0.3" + magic-string "^0.30.17" + +"@vitest/pretty-format@3.2.7", "@vitest/pretty-format@^3.2.7": + version "3.2.7" + resolved "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz#2a7b593f8e007e9d8ef7e7343aa30ec73fdeaf29" + integrity sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA== + dependencies: + tinyrainbow "^2.0.0" + +"@vitest/runner@3.2.7": + version "3.2.7" + resolved "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz#c0c080228189f1fa6cda40f59be09d746b0aca51" + integrity sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA== + dependencies: + "@vitest/utils" "3.2.7" + pathe "^2.0.3" + strip-literal "^3.0.0" + +"@vitest/snapshot@3.2.7": + version "3.2.7" + resolved "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz#a3a7e1950ce99ec4cf02395e20ddca403b6c818e" + integrity sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g== + dependencies: + "@vitest/pretty-format" "3.2.7" + magic-string "^0.30.17" + pathe "^2.0.3" + +"@vitest/spy@3.2.7": + version "3.2.7" + resolved "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz#ca7fbee44019523ca450395d9a2284ce9ece1f31" + integrity sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ== + dependencies: + tinyspy "^4.0.3" + +"@vitest/utils@3.2.7": + version "3.2.7" + resolved "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz#302c8126211ac4dfea87b3b5085c098d6d22e89e" + integrity sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw== + dependencies: + "@vitest/pretty-format" "3.2.7" + loupe "^3.1.4" + tinyrainbow "^2.0.0" + "@xterm/addon-fit@^0.11.0": version "0.11.0" resolved "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz" @@ -1253,6 +1462,11 @@ aria-hidden@^1.2.4: dependencies: tslib "^2.0.0" +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + babel-merge@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/babel-merge/-/babel-merge-3.0.0.tgz" @@ -1283,6 +1497,11 @@ browserslist@^4.24.0: node-releases "^2.0.19" update-browserslist-db "^1.1.3" +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + caniuse-lite@^1.0.30001737: version "1.0.30001741" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz" @@ -1293,6 +1512,22 @@ canvas-hypertxt@^1.0.3: resolved "https://registry.npmjs.org/canvas-hypertxt/-/canvas-hypertxt-1.0.3.tgz" integrity sha512-+VsMpRr64jYgKq2IeFUNel3vCZH/IzS+iXSHxmUV3IUH5dXlC9xHz4AwtPZisDxZ5MWcuK0V+TXgPKFPiZnxzg== +chai@^5.2.0: + version "5.3.3" + resolved "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" + integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + +check-error@^2.1.1: + version "2.1.3" + resolved "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz#2427361117b70cca8dc89680ead32b157019caf5" + integrity sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA== + chownr@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz" @@ -1347,13 +1582,18 @@ debug@^4.1.0, debug@^4.3.1: dependencies: ms "^2.1.3" -debug@^4.1.1: +debug@^4.1.1, debug@^4.4.1: version "4.4.3" resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + deepmerge@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz" @@ -1395,6 +1635,11 @@ enhanced-resolve@^5.18.3: graceful-fs "^4.2.4" tapable "^2.2.0" +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + esbuild@^0.25.0: version "0.25.9" resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz" @@ -1427,11 +1672,55 @@ esbuild@^0.25.0: "@esbuild/win32-ia32" "0.25.9" "@esbuild/win32-x64" "0.25.9" +"esbuild@^0.27.0 || ^0.28.0": + version "0.28.2" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816" + integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA== + optionalDependencies: + "@esbuild/aix-ppc64" "0.28.2" + "@esbuild/android-arm" "0.28.2" + "@esbuild/android-arm64" "0.28.2" + "@esbuild/android-x64" "0.28.2" + "@esbuild/darwin-arm64" "0.28.2" + "@esbuild/darwin-x64" "0.28.2" + "@esbuild/freebsd-arm64" "0.28.2" + "@esbuild/freebsd-x64" "0.28.2" + "@esbuild/linux-arm" "0.28.2" + "@esbuild/linux-arm64" "0.28.2" + "@esbuild/linux-ia32" "0.28.2" + "@esbuild/linux-loong64" "0.28.2" + "@esbuild/linux-mips64el" "0.28.2" + "@esbuild/linux-ppc64" "0.28.2" + "@esbuild/linux-riscv64" "0.28.2" + "@esbuild/linux-s390x" "0.28.2" + "@esbuild/linux-x64" "0.28.2" + "@esbuild/netbsd-arm64" "0.28.2" + "@esbuild/netbsd-x64" "0.28.2" + "@esbuild/openbsd-arm64" "0.28.2" + "@esbuild/openbsd-x64" "0.28.2" + "@esbuild/openharmony-arm64" "0.28.2" + "@esbuild/sunos-x64" "0.28.2" + "@esbuild/win32-arm64" "0.28.2" + "@esbuild/win32-ia32" "0.28.2" + "@esbuild/win32-x64" "0.28.2" + escalade@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +expect-type@^1.2.1: + version "1.4.0" + resolved "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" + integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== + fdir@^6.5.0: version "6.5.0" resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz" @@ -1504,6 +1793,11 @@ jiti@^2.5.1: resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +js-tokens@^9.0.1: + version "9.0.1" + resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" + integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== + jsesc@^3.0.2: version "3.1.0" resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz" @@ -1606,6 +1900,11 @@ loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" +loupe@^3.1.0, loupe@^3.1.4: + version "3.2.1" + resolved "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" + integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz" @@ -1618,6 +1917,13 @@ lucide-react@^0.454.0: resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz" integrity sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ== +magic-string@^0.30.17: + version "0.30.21" + resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + magic-string@^0.30.18: version "0.30.19" resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz" @@ -1729,11 +2035,26 @@ path-exists@^4.0.0: resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== + picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== +picomatch@^4.0.2, picomatch@^4.0.4: + version "4.0.5" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" + integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== + picomatch@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" @@ -1892,6 +2213,11 @@ semver@^6.3.1: resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + sonner@^2.0.7: version "2.0.7" resolved "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz" @@ -1910,11 +2236,28 @@ sql-formatter@^15.7.2: argparse "^2.0.1" nearley "^2.20.1" +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + state-local@^1.0.6: version "1.0.7" resolved "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz" integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w== +std-env@^3.9.0: + version "3.10.0" + resolved "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== + +strip-literal@^3.0.0: + version "3.1.0" + resolved "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz#222b243dd2d49c0bcd0de8906adbd84177196032" + integrity sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg== + dependencies: + js-tokens "^9.0.1" + tailwind-merge@^3.3.1: version "3.3.1" resolved "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz" @@ -1947,6 +2290,24 @@ tar@^7.4.3: mkdirp "^3.0.1" yallist "^5.0.0" +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== + +tinyglobby@^0.2.14: + version "0.2.17" + resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" + integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + tinyglobby@^0.2.15: version "0.2.15" resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz" @@ -1955,6 +2316,21 @@ tinyglobby@^0.2.15: fdir "^6.5.0" picomatch "^4.0.3" +tinypool@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== + +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^4.0.3: + version "4.0.4" + resolved "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz#d77a002fb53a88aa1429b419c1c92492e0c81f78" + integrity sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q== + ts-invariant@^0.10.3: version "0.10.3" resolved "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.3.tgz" @@ -2000,6 +2376,31 @@ use-sidecar@^1.1.3: detect-node-es "^1.1.0" tslib "^2.0.0" +vite-node@3.2.4: + version "3.2.4" + resolved "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" + integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== + dependencies: + cac "^6.7.14" + debug "^4.4.1" + es-module-lexer "^1.7.0" + pathe "^2.0.3" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": + version "7.3.6" + resolved "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz#0547a395e68d3746e9a505f1fd4469fe09b49cc4" + integrity sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg== + dependencies: + esbuild "^0.27.0 || ^0.28.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + vite@^7.0.4: version "7.1.5" resolved "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz" @@ -2014,6 +2415,43 @@ vite@^7.0.4: optionalDependencies: fsevents "~2.3.3" +vitest@^3.2.4: + version "3.2.7" + resolved "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz#1944b6ed013a25fd26a73d18e1af92c10a57af6c" + integrity sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/expect" "3.2.7" + "@vitest/mocker" "3.2.7" + "@vitest/pretty-format" "^3.2.7" + "@vitest/runner" "3.2.7" + "@vitest/snapshot" "3.2.7" + "@vitest/spy" "3.2.7" + "@vitest/utils" "3.2.7" + chai "^5.2.0" + debug "^4.4.1" + expect-type "^1.2.1" + magic-string "^0.30.17" + pathe "^2.0.3" + picomatch "^4.0.2" + std-env "^3.9.0" + tinybench "^2.9.0" + tinyexec "^0.3.2" + tinyglobby "^0.2.14" + tinypool "^1.1.1" + tinyrainbow "^2.0.0" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node "3.2.4" + why-is-node-running "^2.3.0" + +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + yallist@^3.0.2: version "3.1.1" resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz" From a16d18a57a92cf6a9354d1072df3a2b50da838d8 Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Mon, 10 Aug 2026 12:50:50 +0200 Subject: [PATCH 02/20] feat: escape separators and encode NULL distinctly in the wire format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packed wire format replaced any 0x1F/0x1E byte occurring inside cell data with a space and encoded SQL NULL as the literal text "null". Both lost information, and both corrupted primary-key values on the way to the frontend, so generated UPDATE and DELETE statements could match the wrong row or no row at all. Separators inside data are now escaped with 0x1D and NULL has its own two-byte marker, so a real NULL, an empty string and the text "null" are three distinguishable values end to end. Decoding takes a fast path when a page contains no escape byte, which is the common case. Result rows are typed (string | null)[][] throughout, and each consumer now makes an explicit choice: the grid draws NULL muted and labelled so it cannot be mistaken for an empty cell, CSV writes an empty field, JSON writes null, SQL writes the NULL keyword, and search skips NULL cells. Two places that had reimplemented the format locally — the export helpers and the diff view — use the shared codec, which also fixes the diff view treating NULL and "" as equal. Adds round-trip tests on both sides covering NULL, the text "null", empty strings, embedded separators, escape sequences and multibyte content. --- src-tauri/src/drivers/pgsql/mod.rs | 6 +- .../drivers/pgsql/query_execution/helpers.rs | 75 ++---- .../drivers/pgsql/query_execution/simple.rs | 14 +- .../pgsql/query_execution/streaming.rs | 27 +- .../pgsql/query_execution/virtual_cache.rs | 18 +- src-tauri/src/drivers/pgsql/wire.rs | 242 ++++++++++++++++++ src/components/results-grid/index.tsx | 7 +- src/components/results-grid/rendering.tsx | 61 +++-- src/components/results-map.tsx | 7 +- src/components/results-panel/diff-view.tsx | 45 ++-- src/components/results-panel/index.tsx | 5 +- .../results-panel/toolbar-export.tsx | 3 +- src/components/results-panel/types.ts | 5 +- src/components/results-panel/use-edit-mode.ts | 3 +- src/components/results-record.tsx | 3 +- src/lib/database-driver/index.ts | 13 +- src/lib/export.ts | 48 ++-- src/lib/sql-utils.ts | 19 +- src/lib/virtual-cache.ts | 10 +- src/lib/wire.test.ts | 123 +++++++++ src/lib/wire.ts | 129 ++++++++++ src/stores/ui-store.ts | 3 +- src/types/index.ts | 6 +- 23 files changed, 673 insertions(+), 199 deletions(-) create mode 100644 src-tauri/src/drivers/pgsql/wire.rs create mode 100644 src/lib/wire.test.ts create mode 100644 src/lib/wire.ts diff --git a/src-tauri/src/drivers/pgsql/mod.rs b/src-tauri/src/drivers/pgsql/mod.rs index 7dcff7d2..2bdbbfab 100644 --- a/src-tauri/src/drivers/pgsql/mod.rs +++ b/src-tauri/src/drivers/pgsql/mod.rs @@ -11,6 +11,7 @@ pub mod metadata_views_functions; pub mod query_execution; pub mod roles_schema_objects; pub mod statistics_activity; +pub mod wire; pub use commands::*; pub use ddl_generation::*; @@ -33,10 +34,7 @@ pub fn get_pool( .ok_or_else(|| AppError::ClientNotConnected(project_id.to_string())) } -/// Cell separator for packed format (Unit Separator, ASCII 0x1F) -pub(crate) const CELL_SEP: char = '\x1F'; -/// Row separator for packed format (Record Separator, ASCII 0x1E) -pub(crate) const ROW_SEP: char = '\x1E'; +pub(crate) use wire::{CELL_SEP, ROW_SEP}; /// A cached query: pre-packed page strings for zero-copy serving. /// Each page is a single large String (~1-2 MB) so the OS reclaims RSS on drop. diff --git a/src-tauri/src/drivers/pgsql/query_execution/helpers.rs b/src-tauri/src/drivers/pgsql/query_execution/helpers.rs index 35898f62..57c3565e 100644 --- a/src-tauri/src/drivers/pgsql/query_execution/helpers.rs +++ b/src-tauri/src/drivers/pgsql/query_execution/helpers.rs @@ -1,35 +1,27 @@ use tokio_postgres::SimpleQueryMessage; -use super::super::{CELL_SEP, ROW_SEP}; +use super::super::wire::Cell; /// Process simple_query messages, returning the last result set that had rows. /// If no result set had rows but commands ran, returns synthetic "N rows affected". /// If nothing at all, returns empty vecs. pub(crate) fn process_simple_messages( messages: Vec, -) -> (Vec, Vec>) { +) -> (Vec, Vec>) { let mut cur_columns: Vec = Vec::new(); - let mut cur_rows: Vec> = Vec::new(); + let mut cur_rows: Vec> = Vec::new(); let mut last_columns: Vec = Vec::new(); - let mut last_rows: Vec> = Vec::new(); + let mut last_rows: Vec> = Vec::new(); let mut has_row_result = false; let mut total_affected: u64 = 0; for msg in messages { match msg { SimpleQueryMessage::Row(row) => { - let col_count = row.columns().len(); if cur_columns.is_empty() { - cur_columns = Vec::with_capacity(col_count); - for c in row.columns() { - cur_columns.push(c.name().to_owned()); - } + cur_columns = column_names(&row); } - let mut cells = Vec::with_capacity(col_count); - for i in 0..col_count { - cells.push(row.get(i).unwrap_or("null").to_owned()); - } - cur_rows.push(cells); + cur_rows.push(row_cells(&row)); } SimpleQueryMessage::CommandComplete(n) => { if !cur_rows.is_empty() { @@ -56,54 +48,27 @@ pub(crate) fn process_simple_messages( } else if total_affected > 0 { ( vec!["Result".into()], - vec![vec![format!("{} rows affected", total_affected)]], + vec![vec![Some(format!("{} rows affected", total_affected))]], ) } else { (Vec::new(), Vec::new()) } } -/// Join string slices with a char separator — avoids .to_string() on the separator. -#[inline] -pub(crate) fn join_sep(items: &[String], sep: char) -> String { - let total: usize = items.iter().map(|s| s.len()).sum::() + items.len(); - let mut out = String::with_capacity(total); - for (i, item) in items.iter().enumerate() { - if i > 0 { - out.push(sep); - } - out.push_str(item); - } - out +/// Column names of a simple-query row, in result order. +pub(crate) fn column_names(row: &tokio_postgres::SimpleQueryRow) -> Vec { + row.columns() + .iter() + .map(|c| c.name().to_owned()) + .collect() } -/// Pack a slice of rows (each row = Vec) into wire format. -/// Pre-allocates capacity and writes directly — zero intermediate allocations. -pub(crate) fn pack_rows_vec(rows: &[Vec]) -> String { - if rows.is_empty() { - return String::new(); - } - // Estimate capacity: avg ~20 chars per cell - let est = rows.len() * rows.first().map_or(10, |r| r.len()) * 20; - let mut out = String::with_capacity(est); - - for (ri, row) in rows.iter().enumerate() { - if ri > 0 { - out.push(ROW_SEP); - } - for (ci, cell) in row.iter().enumerate() { - if ci > 0 { - out.push(CELL_SEP); - } - // Inline separator sanitization — avoids .replace() allocations - for ch in cell.chars() { - if ch == CELL_SEP || ch == ROW_SEP { - out.push(' '); - } else { - out.push(ch); - } - } - } +/// Cell values of a simple-query row. `None` is SQL NULL. +pub(crate) fn row_cells(row: &tokio_postgres::SimpleQueryRow) -> Vec { + let col_count = row.columns().len(); + let mut cells = Vec::with_capacity(col_count); + for i in 0..col_count { + cells.push(row.get(i).map(str::to_owned)); } - out + cells } diff --git a/src-tauri/src/drivers/pgsql/query_execution/simple.rs b/src-tauri/src/drivers/pgsql/query_execution/simple.rs index 28cac88a..a3f229b9 100644 --- a/src-tauri/src/drivers/pgsql/query_execution/simple.rs +++ b/src-tauri/src/drivers/pgsql/query_execution/simple.rs @@ -3,16 +3,17 @@ use tokio_postgres::Client; use crate::common::enums::AppError; -use super::super::{CELL_SEP, ROW_SEP}; -use super::helpers::{join_sep, pack_rows_vec, process_simple_messages}; +use super::super::ROW_SEP; +use super::super::wire::{Cell, pack_columns, pack_rows}; +use super::helpers::process_simple_messages; -/// Execute a timed query and return (columns, rows_as_strings, elapsed_ms). +/// Execute a timed query and return (columns, rows, elapsed_ms). /// Uses simple_query protocol — PG returns all values as text, no type conversion needed. /// Supports multi-statement: returns the last result set that had rows. pub async fn execute_query( client: &Client, sql: &str, -) -> Result<(Vec, Vec>, f32), AppError> { +) -> Result<(Vec, Vec>, f32), AppError> { let start = Instant::now(); let messages = client .simple_query(sql) @@ -25,7 +26,6 @@ pub async fn execute_query( } /// Execute a timed query and return results in compact packed string format. -/// Format: "col1\x1Fcol2\x1E row1val1\x1Frow1val2\x1E row2val1\x1Frow2val2" /// Uses simple_query protocol with multi-statement support. pub async fn execute_query_packed(client: &Client, sql: &str) -> Result<(String, f32), AppError> { let start = Instant::now(); @@ -40,8 +40,8 @@ pub async fn execute_query_packed(client: &Client, sql: &str) -> Result<(String, return Ok((String::new(), start.elapsed().as_millis() as f32)); } - let header = join_sep(&columns, CELL_SEP); - let body = pack_rows_vec(&rows); + let header = pack_columns(&columns); + let body = pack_rows(&rows); let packed = if body.is_empty() { header diff --git a/src-tauri/src/drivers/pgsql/query_execution/streaming.rs b/src-tauri/src/drivers/pgsql/query_execution/streaming.rs index 9e362fb2..d22ec340 100644 --- a/src-tauri/src/drivers/pgsql/query_execution/streaming.rs +++ b/src-tauri/src/drivers/pgsql/query_execution/streaming.rs @@ -3,8 +3,8 @@ use tokio_postgres::{Client, SimpleQueryMessage}; use crate::common::enums::AppError; -use super::super::CELL_SEP; -use super::helpers::{join_sep, pack_rows_vec, process_simple_messages}; +use super::super::wire::{Cell, pack_columns, pack_rows}; +use super::helpers::{column_names, process_simple_messages, row_cells}; /// Events emitted during streamed query execution. #[derive(serde::Serialize, Clone)] @@ -60,24 +60,15 @@ pub async fn execute_query_streamed( } }; - let mut batch_rows: Vec> = Vec::new(); + let mut batch_rows: Vec> = Vec::new(); let mut batch_columns: Option> = None; for msg in messages { if let SimpleQueryMessage::Row(row) = msg { - let col_count = row.columns().len(); if batch_columns.is_none() { - let mut cols = Vec::with_capacity(col_count); - for c in row.columns() { - cols.push(c.name().to_owned()); - } - batch_columns = Some(cols); + batch_columns = Some(column_names(&row)); } - let mut cells = Vec::with_capacity(col_count); - for i in 0..col_count { - cells.push(row.get(i).unwrap_or("null").to_owned()); - } - batch_rows.push(cells); + batch_rows.push(row_cells(&row)); } } @@ -86,7 +77,7 @@ pub async fn execute_query_streamed( } if !columns_sent && let Some(cols) = batch_columns { - let header = join_sep(&cols, CELL_SEP); + let header = pack_columns(&cols); let _ = app.emit( &event_name, QueryStreamEvent::Columns { @@ -97,7 +88,7 @@ pub async fn execute_query_streamed( columns_sent = true; } - let packed = pack_rows_vec(&batch_rows); + let packed = pack_rows(&batch_rows); let _ = app.emit(&event_name, QueryStreamEvent::Chunk { data: packed }); total_sent += batch_rows.len(); @@ -144,7 +135,7 @@ pub async fn execute_query_streamed( }, ); } else { - let header = join_sep(&columns, CELL_SEP); + let header = pack_columns(&columns); let _ = app.emit( &event_name, QueryStreamEvent::Columns { @@ -153,7 +144,7 @@ pub async fn execute_query_streamed( }, ); - let packed = pack_rows_vec(&rows); + let packed = pack_rows(&rows); let _ = app.emit(&event_name, QueryStreamEvent::Chunk { data: packed }); } diff --git a/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs b/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs index 83caeecf..f1bf6e8e 100644 --- a/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs +++ b/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs @@ -4,8 +4,9 @@ use tokio_postgres::Client; use crate::common::enums::AppError; -use super::super::{CELL_SEP, CachedQuery, ROW_SEP, VirtualCache}; -use super::helpers::{join_sep, pack_rows_vec, process_simple_messages}; +use super::super::wire::{Cell, pack_columns, pack_rows}; +use super::super::{CachedQuery, ROW_SEP, VirtualCache}; +use super::helpers::process_simple_messages; /// Execute a query in one shot using simple_query protocol. /// Pre-packs results into page-sized strings cached in-memory. @@ -39,7 +40,7 @@ pub async fn execute_virtual( fallback.push_str(&columns[0]); fallback.push(ROW_SEP); if let Some(r) = all_rows.first() { - fallback.push_str(&join_sep(r, CELL_SEP)); + fallback.push_str(&pack_rows(std::slice::from_ref(r))); } let elapsed = start.elapsed().as_millis() as f32; return Ok((String::new(), 0, fallback, elapsed)); @@ -48,17 +49,14 @@ pub async fn execute_virtual( let total_rows = all_rows.len(); // Pre-pack into pages — use rayon only for large results (>50K rows) - let chunks: Vec<&[Vec]> = all_rows.chunks(page_size).collect(); + let chunks: Vec<&[Vec]> = all_rows.chunks(page_size).collect(); let pages: Vec = if total_rows > 50_000 { - chunks - .par_iter() - .map(|chunk| pack_rows_vec(chunk)) - .collect() + chunks.par_iter().map(|chunk| pack_rows(chunk)).collect() } else { - chunks.iter().map(|chunk| pack_rows_vec(chunk)).collect() + chunks.iter().map(|chunk| pack_rows(chunk)).collect() }; - let columns_packed = join_sep(&columns, CELL_SEP); + let columns_packed = pack_columns(&columns); let first_page_packed = pages.first().cloned().unwrap_or_default(); { diff --git a/src-tauri/src/drivers/pgsql/wire.rs b/src-tauri/src/drivers/pgsql/wire.rs new file mode 100644 index 00000000..a6b703da --- /dev/null +++ b/src-tauri/src/drivers/pgsql/wire.rs @@ -0,0 +1,242 @@ +//! Packed wire format shared by every query path. +//! +//! Cells are joined by [`CELL_SEP`], rows by [`ROW_SEP`]. Because any byte can +//! legitimately appear in a Postgres text value, occurrences of the separators +//! inside data are escaped with [`ESC`] rather than replaced — replacing them +//! silently corrupted values, which broke primary-key matching on row updates. +//! SQL NULL has its own encoding so it stays distinguishable from the text +//! value `"null"` and from the empty string. + +/// Cell separator (Unit Separator, ASCII 0x1F). +pub(crate) const CELL_SEP: char = '\x1F'; +/// Row separator (Record Separator, ASCII 0x1E). +pub(crate) const ROW_SEP: char = '\x1E'; +/// Escape prefix (Group Separator, ASCII 0x1D). +pub(crate) const ESC: char = '\x1D'; + +const TAG_NULL: char = 'N'; +const TAG_CELL_SEP: char = 'A'; +const TAG_ROW_SEP: char = 'B'; +const TAG_ESC: char = 'C'; + +/// A single cell: `None` is SQL NULL, `Some("")` is the empty string. +pub type Cell = Option; + +/// Append one cell in escaped form. `None` becomes the NULL marker. +pub(crate) fn push_cell(out: &mut String, cell: Option<&str>) { + let Some(value) = cell else { + out.push(ESC); + out.push(TAG_NULL); + return; + }; + + // Separators are ASCII, so a byte scan cannot produce false hits inside + // multi-byte characters and lets the common case skip the escape pass. + if value + .as_bytes() + .iter() + .any(|b| matches!(b, 0x1D | 0x1E | 0x1F)) + { + for ch in value.chars() { + match ch { + CELL_SEP => { + out.push(ESC); + out.push(TAG_CELL_SEP); + } + ROW_SEP => { + out.push(ESC); + out.push(TAG_ROW_SEP); + } + ESC => { + out.push(ESC); + out.push(TAG_ESC); + } + other => out.push(other), + } + } + } else { + out.push_str(value); + } +} + +/// Append one row, separating cells with [`CELL_SEP`]. +pub(crate) fn push_row(out: &mut String, row: &[Cell]) { + for (index, cell) in row.iter().enumerate() { + if index > 0 { + out.push(CELL_SEP); + } + push_cell(out, cell.as_deref()); + } +} + +/// Exact byte budget for [`pack_rows`], ignoring the rare escape expansion. +fn packed_capacity(rows: &[Vec]) -> usize { + let mut total = 0; + for row in rows { + for cell in row { + total += cell.as_ref().map_or(2, String::len); + } + total += row.len(); + } + total +} + +/// Encode rows into the packed wire format. +pub(crate) fn pack_rows(rows: &[Vec]) -> String { + if rows.is_empty() { + return String::new(); + } + + let mut out = String::with_capacity(packed_capacity(rows)); + for (index, row) in rows.iter().enumerate() { + if index > 0 { + out.push(ROW_SEP); + } + push_row(&mut out, row); + } + out +} + +/// Encode column names as a single header line. +pub(crate) fn pack_columns(columns: &[String]) -> String { + let mut out = String::with_capacity(columns.iter().map(String::len).sum::() + columns.len()); + for (index, name) in columns.iter().enumerate() { + if index > 0 { + out.push(CELL_SEP); + } + push_cell(&mut out, Some(name)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mirror of the TypeScript decoder, so round-trips can be asserted here. + fn unpack_cell(raw: &str) -> Cell { + if raw == "\x1DN" { + return None; + } + if !raw.contains(ESC) { + return Some(raw.to_owned()); + } + + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars(); + while let Some(ch) = chars.next() { + if ch != ESC { + out.push(ch); + continue; + } + match chars.next() { + Some(TAG_CELL_SEP) => out.push(CELL_SEP), + Some(TAG_ROW_SEP) => out.push(ROW_SEP), + Some(TAG_ESC) => out.push(ESC), + Some(other) => out.push(other), + None => {} + } + } + Some(out) + } + + fn unpack_rows(packed: &str) -> Vec> { + if packed.is_empty() { + return Vec::new(); + } + packed + .split(ROW_SEP) + .map(|row| row.split(CELL_SEP).map(unpack_cell).collect()) + .collect() + } + + fn round_trip(rows: Vec>) { + let packed = pack_rows(&rows); + assert_eq!(unpack_rows(&packed), rows); + } + + #[test] + fn null_is_distinct_from_the_text_null() { + let rows = vec![vec![None, Some("null".into()), Some("NULL".into())]]; + let packed = pack_rows(&rows); + assert_eq!(unpack_rows(&packed), rows); + + let decoded = unpack_rows(&packed); + assert!(decoded[0][0].is_none()); + assert_eq!(decoded[0][1].as_deref(), Some("null")); + assert_eq!(decoded[0][2].as_deref(), Some("NULL")); + } + + #[test] + fn null_is_distinct_from_the_empty_string() { + let rows = vec![vec![None, Some(String::new())]]; + let decoded = unpack_rows(&pack_rows(&rows)); + assert!(decoded[0][0].is_none()); + assert_eq!(decoded[0][1].as_deref(), Some("")); + } + + #[test] + fn separators_in_data_survive() { + round_trip(vec![vec![ + Some("a\x1Fb".into()), + Some("c\x1Ed".into()), + Some("e\x1Df".into()), + ]]); + } + + #[test] + fn a_value_equal_to_the_null_marker_survives() { + let rows = vec![vec![Some("\x1DN".into())]]; + let decoded = unpack_rows(&pack_rows(&rows)); + assert_eq!(decoded[0][0].as_deref(), Some("\x1DN")); + } + + #[test] + fn consecutive_escapes_survive() { + round_trip(vec![vec![Some("\x1D\x1D\x1F\x1E".into())]]); + } + + #[test] + fn multibyte_content_survives() { + round_trip(vec![vec![ + Some("árvíztűrő tükörfúrógép".into()), + Some("日本語".into()), + Some("emoji 🦀".into()), + ]]); + } + + #[test] + fn multibyte_next_to_separators_survives() { + round_trip(vec![vec![Some("🦀\x1F🦀\x1E🦀".into())]]); + } + + #[test] + fn empty_input_packs_to_empty_string() { + assert_eq!(pack_rows(&[]), ""); + assert!(unpack_rows("").is_empty()); + } + + #[test] + fn multiple_rows_and_columns_survive() { + round_trip(vec![ + vec![Some("1".into()), None, Some("x".into())], + vec![None, Some(String::new()), Some("null".into())], + vec![Some("3".into()), Some("y".into()), None], + ]); + } + + #[test] + fn column_names_are_escaped_too() { + let columns = vec!["id".to_string(), "we\x1Fird".to_string()]; + let packed = pack_columns(&columns); + let decoded: Vec = packed.split(CELL_SEP).map(unpack_cell).collect(); + assert_eq!(decoded[0].as_deref(), Some("id")); + assert_eq!(decoded[1].as_deref(), Some("we\x1Fird")); + } + + #[test] + fn capacity_estimate_covers_unescaped_payloads() { + let rows = vec![vec![Some("abc".into()), None], vec![Some("de".into()), Some("f".into())]]; + assert!(packed_capacity(&rows) >= pack_rows(&rows).len()); + } +} diff --git a/src/components/results-grid/index.tsx b/src/components/results-grid/index.tsx index c6912192..550f7b1c 100644 --- a/src/components/results-grid/index.tsx +++ b/src/components/results-grid/index.tsx @@ -20,6 +20,7 @@ import { useState, } from "react"; import "@glideapps/glide-data-grid/dist/index.css"; +import type { CellValue } from "@/lib/wire"; import { useUIStore } from "@/stores/ui-store"; import type { VirtualQuery } from "@/types"; import { @@ -35,7 +36,7 @@ import { interface ResultsGridProps { columns: string[]; - rows: string[][]; + rows: CellValue[][]; isEditing?: boolean; cellEdits?: Map; deletedRows?: Set; @@ -233,8 +234,8 @@ export function ResultsGrid({ if (fkColIndices.has(colIdx) && onFKNavigate) { const colName = columns[colIdx]; - const value = rows[rowIdx]?.[colIdx] ?? ""; - if (value && value !== "null") { + const value = rows[rowIdx]?.[colIdx]; + if (value) { onFKNavigate(colName, value); } } diff --git a/src/components/results-grid/rendering.tsx b/src/components/results-grid/rendering.tsx index 8706c1a6..db093c98 100644 --- a/src/components/results-grid/rendering.tsx +++ b/src/components/results-grid/rendering.tsx @@ -6,6 +6,7 @@ import { type Theme, } from "@glideapps/glide-data-grid"; import * as virtualCache from "@/lib/virtual-cache"; +import type { CellValue } from "@/lib/wire"; import type { VirtualQuery } from "@/types"; export const MIN_COL_WIDTH = 80; @@ -36,7 +37,12 @@ export function buildModifiedOverride(theme: string) { export const FK_OVERRIDE = { textDark: "hsl(220, 70%, 50%)", textLight: "hsl(220, 70%, 65%)" }; -export function computeGridColumns(columns: string[], rows: string[][]): GridColumn[] { +/// SQL NULL is drawn muted and labelled, so it never looks like an empty string +/// or like a column holding the text "null". +export const NULL_OVERRIDE = { textDark: "hsl(250, 10%, 50%)", textLight: "hsl(250, 10%, 55%)" }; +export const NULL_DISPLAY = "NULL"; + +export function computeGridColumns(columns: string[], rows: CellValue[][]): GridColumn[] { const sampleRows = rows.slice(0, 100); return columns.map((col, colIdx) => { let maxLen = col.length + 2; @@ -62,7 +68,7 @@ export function computeFkColIndices( } export interface CellContentContext { - rows: string[][]; + rows: CellValue[][]; cellEdits?: Map; deletedRows?: Set; isEditing?: boolean; @@ -74,6 +80,19 @@ export interface CellContentContext { fkOverride: typeof FK_OVERRIDE; } +/// A non-editable cell that keeps SQL NULL visually distinct from "". +function readonlyCell(raw: CellValue | undefined): GridCell { + const isNull = raw === null; + return { + kind: GridCellKind.Text, + data: raw ?? "", + displayData: isNull ? NULL_DISPLAY : (raw ?? ""), + allowOverlay: false, + readonly: true, + themeOverride: isNull ? NULL_OVERRIDE : undefined, + }; +} + export function buildCellContent(cell: Item, ctx: CellContentContext): GridCell { const [colIdx, rowIdx] = cell; const { @@ -97,47 +116,35 @@ export function buildCellContent(cell: Item, ctx: CellContentContext): GridCell onPageNeeded?.(pageIndex); const fallbackRow = rows[rowIdx]; if (!fallbackRow) return LOADING_CELL; - const value = fallbackRow[colIdx] ?? ""; - return { - kind: GridCellKind.Text, - data: value, - displayData: value, - allowOverlay: false, - readonly: true, - }; + return readonlyCell(fallbackRow[colIdx]); } - const value = row[colIdx] ?? ""; - return { - kind: GridCellKind.Text, - data: value, - displayData: value, - allowOverlay: false, - readonly: true, - }; + return readonlyCell(row[colIdx]); } const key = `${rowIdx}:${colIdx}`; const isModified = cellEdits?.has(key); const isDeleted = deletedRows?.has(rowIdx); - const isFK = fkColIndices.has(colIdx) && !isEditing; - const value = isModified ? (cellEdits?.get(key) ?? "") : (rows[rowIdx]?.[colIdx] ?? ""); + const raw = isModified ? (cellEdits?.get(key) ?? "") : rows[rowIdx]?.[colIdx]; + const isNull = raw === null; + const value = raw ?? ""; + const isFK = fkColIndices.has(colIdx) && !isEditing && !isNull; - const baseCell: GridCell = { + return { kind: GridCellKind.Text, data: value, - displayData: isFK && value !== "null" ? `${value} →` : value, + displayData: isNull ? NULL_DISPLAY : isFK ? `${value} →` : value, allowOverlay: !!isEditing && !isDeleted, readonly: !isEditing || !!isDeleted, themeOverride: isDeleted ? deletedOverride : isModified ? modifiedOverride - : isFK && value !== "null" - ? fkOverride - : undefined, + : isNull + ? NULL_OVERRIDE + : isFK + ? fkOverride + : undefined, }; - - return baseCell; } export function buildGridTheme(theme: string): Partial { diff --git a/src/components/results-map.tsx b/src/components/results-map.tsx index 4a93ee1d..86381b4e 100644 --- a/src/components/results-map.tsx +++ b/src/components/results-map.tsx @@ -1,10 +1,11 @@ import L from "leaflet"; import { useEffect, useMemo, useRef } from "react"; import "leaflet/dist/leaflet.css"; +import type { CellValue } from "@/lib/wire"; interface ResultsMapProps { columns: string[]; - rows: string[][]; + rows: CellValue[][]; } const WKT_PREFIX = @@ -106,7 +107,7 @@ function parseGeoJSON(json: string, rowIndex: number): ParsedGeom | null { return null; } -function detectGeomColumnIndex(columns: string[], rows: string[][]): number { +function detectGeomColumnIndex(columns: string[], rows: CellValue[][]): number { const sample = rows.slice(0, 10); for (let ci = 0; ci < columns.length; ci++) { const colName = columns[ci].toLowerCase(); @@ -134,7 +135,7 @@ function detectGeomColumnIndex(columns: string[], rows: string[][]): number { return -1; } -export function hasGeometryColumn(columns: string[], rows: string[][]): boolean { +export function hasGeometryColumn(columns: string[], rows: CellValue[][]): boolean { return detectGeomColumnIndex(columns, rows) >= 0; } diff --git a/src/components/results-panel/diff-view.tsx b/src/components/results-panel/diff-view.tsx index ed8deccd..6b0201b3 100644 --- a/src/components/results-panel/diff-view.tsx +++ b/src/components/results-panel/diff-view.tsx @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { Diff, Loader2 } from "lucide-react"; import { useRef, useState } from "react"; +import { type CellValue, decodeResult, encodeResult, encodeRow } from "@/lib/wire"; export function DiffView({ pinnedColumns, @@ -9,13 +10,13 @@ export function DiffView({ currentRows, }: { pinnedColumns: string[]; - pinnedRows: string[][]; + pinnedRows: CellValue[][]; currentColumns: string[]; - currentRows: string[][]; + currentRows: CellValue[][]; }) { const [diffResult, setDiffResult] = useState<{ - added: string[][]; - removed: string[][]; + added: CellValue[][]; + removed: CellValue[][]; unchangedCount: number; } | null>(null); const [computing, setComputing] = useState(false); @@ -32,44 +33,32 @@ export function DiffView({ setComputing(true); setDiffResult(null); - // Pack rows into the compact wire format for Rust - const CELL_SEP = "\x1F"; - const ROW_SEP = "\x1E"; - const packRows = (columns: string[], rows: string[][]) => { - const header = columns.join(CELL_SEP); - if (rows.length === 0) return header; - return header + ROW_SEP + rows.map((r) => r.join(CELL_SEP)).join(ROW_SEP); - }; - - const pinnedPacked = packRows(pinnedColumns, pinnedRows); - const currentPacked = packRows(currentColumns, currentRows); + // compute_diff compares rows as opaque strings, so the escaped encoding + // round-trips through it unchanged while keeping NULL apart from "". + const pinnedPacked = encodeResult(pinnedColumns, pinnedRows); + const currentPacked = encodeResult(currentColumns, currentRows); invoke<[string, string, number]>("compute_diff", { pinned_packed: pinnedPacked, current_packed: currentPacked, }) .then(([addedPacked, removedPacked, unchangedCount]) => { - const unpackRows = (packed: string): string[][] => { - if (!packed) return []; - const parts = packed.split(ROW_SEP); - // Skip header (index 0) - return parts.slice(1).map((r) => r.split(CELL_SEP)); - }; + // Both payloads carry a header line that decodeResult strips. setDiffResult({ - added: unpackRows(addedPacked), - removed: unpackRows(removedPacked), + added: decodeResult(addedPacked).rows, + removed: decodeResult(removedPacked).rows, unchangedCount, }); setComputing(false); }) .catch(() => { // Fallback: compute in JS if Rust command fails - const pinnedSet = new Set(pinnedRows.map((r) => r.join(CELL_SEP))); - const currentSet = new Set(currentRows.map((r) => r.join(CELL_SEP))); + const pinnedSet = new Set(pinnedRows.map(encodeRow)); + const currentSet = new Set(currentRows.map(encodeRow)); setDiffResult({ - added: currentRows.filter((r) => !pinnedSet.has(r.join(CELL_SEP))), - removed: pinnedRows.filter((r) => !currentSet.has(r.join(CELL_SEP))), - unchangedCount: currentRows.filter((r) => pinnedSet.has(r.join(CELL_SEP))).length, + added: currentRows.filter((r) => !pinnedSet.has(encodeRow(r))), + removed: pinnedRows.filter((r) => !currentSet.has(encodeRow(r))), + unchangedCount: currentRows.filter((r) => pinnedSet.has(encodeRow(r))).length, }); setComputing(false); }); diff --git a/src/components/results-panel/index.tsx b/src/components/results-panel/index.tsx index e8d0d56e..bff5630b 100644 --- a/src/components/results-panel/index.tsx +++ b/src/components/results-panel/index.tsx @@ -1,6 +1,7 @@ import { Loader2, X, XCircle } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { DriverFactory } from "@/lib/database-driver"; +import { cellText } from "@/lib/wire"; import { useProjectStore } from "@/stores/project-store"; import { useActiveTab } from "@/stores/tab-store"; import { useUIStore } from "@/stores/ui-store"; @@ -84,7 +85,9 @@ export function ResultsPanel() { if (isEditing) return result?.rows ?? []; if (!result || !debouncedSearch.trim()) return result?.rows ?? []; const term = debouncedSearch.toLowerCase(); - return result.rows.filter((row) => row.some((cell) => cell.toLowerCase().includes(term))); + return result.rows.filter((row) => + row.some((cell) => cellText(cell).toLowerCase().includes(term)), + ); }, [result, debouncedSearch, isEditing]); const explainResult = activeTab?.explainResult; diff --git a/src/components/results-panel/toolbar-export.tsx b/src/components/results-panel/toolbar-export.tsx index 1fbb05b6..78ef2b66 100644 --- a/src/components/results-panel/toolbar-export.tsx +++ b/src/components/results-panel/toolbar-export.tsx @@ -2,10 +2,11 @@ import { Copy, Download } from "lucide-react"; import { useRef, useState } from "react"; import { createPortal } from "react-dom"; import { copyToClipboard, type ExportFormat, exportResults } from "@/lib/export"; +import type { CellValue } from "@/lib/wire"; interface ToolbarExportProps { columns: string[]; - filteredRows: string[][]; + filteredRows: CellValue[][]; hasResult: boolean; } diff --git a/src/components/results-panel/types.ts b/src/components/results-panel/types.ts index 03d2c106..ea471fb6 100644 --- a/src/components/results-panel/types.ts +++ b/src/components/results-panel/types.ts @@ -1,3 +1,4 @@ +import type { CellValue } from "@/lib/wire"; export type PanelView = "grid" | "record" | "history" | "explain" | "diff" | "map"; export interface EditState { @@ -11,9 +12,9 @@ export interface EditState { export interface ToolbarProps { panelView: PanelView; setPanelView: (v: PanelView) => void; - result: { rows: string[][]; time: number; capped?: boolean } | null; + result: { rows: CellValue[][]; time: number; capped?: boolean } | null; columns: string[]; - filteredRows: string[][]; + filteredRows: CellValue[][]; searchTerm: string; setSearchTerm: (v: string) => void; filteredCount: number; diff --git a/src/components/results-panel/use-edit-mode.ts b/src/components/results-panel/use-edit-mode.ts index 223f6c84..223ab1f8 100644 --- a/src/components/results-panel/use-edit-mode.ts +++ b/src/components/results-panel/use-edit-mode.ts @@ -8,6 +8,7 @@ import { quoteIdent, quoteLiteral, } from "@/lib/sql-utils"; +import type { CellValue } from "@/lib/wire"; import { useProjectStore } from "@/stores/project-store"; import { useTabStore } from "@/stores/tab-store"; import type { EditState } from "./types"; @@ -16,7 +17,7 @@ interface UseEditModeArgs { projectId: string | undefined; editorValue: string | undefined; result: - | { columns: string[]; rows: string[][]; time: number; capped?: boolean } + | { columns: string[]; rows: CellValue[][]; time: number; capped?: boolean } | null | undefined; } diff --git a/src/components/results-record.tsx b/src/components/results-record.tsx index dc914631..8abf54d5 100644 --- a/src/components/results-record.tsx +++ b/src/components/results-record.tsx @@ -1,9 +1,10 @@ import { Button } from "@/components/ui/button"; +import type { CellValue } from "@/lib/wire"; import { useUIStore } from "@/stores/ui-store"; interface ResultsRecordProps { columns: string[]; - rows: string[][]; + rows: CellValue[][]; } export function ResultsRecord({ columns, rows }: ResultsRecordProps) { diff --git a/src/lib/database-driver/index.ts b/src/lib/database-driver/index.ts index bafb17d9..3735e617 100644 --- a/src/lib/database-driver/index.ts +++ b/src/lib/database-driver/index.ts @@ -1,3 +1,4 @@ +import { type CellValue, decodeResult } from "@/lib/wire"; import type { ColumnDetail, ConstraintDetail, @@ -16,17 +17,13 @@ import type { // Wire types from Rust (tuples) export type WireTableInfo = [string, string]; -export type WireQueryResult = [string[], string[][], number]; +export type WireQueryResult = [string[], CellValue[][], number]; export type WirePackedResult = [string, number]; // [packed_string, elapsed_ms] -export const CELL_SEP = "\x1F"; // Unit Separator -export const ROW_SEP = "\x1E"; // Record Separator +export { CELL_SEP, type CellValue, ESC, ROW_SEP } from "@/lib/wire"; export function unpackResult(packed: string, time: number): WireQueryResult { - if (!packed) return [[], [], time]; - const parts = packed.split(ROW_SEP); - const columns = parts[0].split(CELL_SEP); - const rows = parts.slice(1).map((r) => r.split(CELL_SEP)); + const { columns, rows } = decodeResult(packed); return [columns, rows, time]; } export type WireColumnDetail = [string, string, boolean, string | null]; @@ -53,7 +50,7 @@ export type QueryStreamEvent = export interface StreamCallbacks { onColumns: (columns: string[], totalRows: number) => void; - onChunk: (rows: string[][]) => void; + onChunk: (rows: CellValue[][]) => void; onDone: (elapsed: number, capped: boolean) => void; } diff --git a/src/lib/export.ts b/src/lib/export.ts index 5dd385cb..ed8041e3 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -1,46 +1,58 @@ import { save } from "@tauri-apps/plugin-dialog"; import { writeTextFile } from "@tauri-apps/plugin-fs"; +import type { CellValue } from "@/lib/wire"; export type ExportFormat = "csv" | "json" | "sql" | "markdown" | "xml"; -function escapeCSV(value: string): string { +function escapeCSVText(value: string): string { if (value.includes(",") || value.includes('"') || value.includes("\n")) { return `"${value.replace(/"/g, '""')}"`; } return value; } -function escapeSQL(value: string): string { - if (value === "null" || value === "NULL") return "NULL"; - return `'${value.replace(/'/g, "''")}'`; +/** SQL NULL becomes an unquoted empty field, the usual CSV convention. */ +function escapeCSV(cell: CellValue): string { + return cell === null ? "" : escapeCSVText(cell); } -function escapeXML(value: string): string { - return value +/** + * Only a real SQL NULL becomes the NULL keyword. The text value "null" is + * exported as a quoted literal, which earlier releases silently turned into NULL. + */ +function escapeSQL(cell: CellValue): string { + if (cell === null) return "NULL"; + return `'${cell.replace(/'/g, "''")}'`; +} + +function escapeXML(cell: CellValue): string { + if (cell === null) return ""; + return cell .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } -export function toCSV(columns: string[], rows: string[][]): string { - const header = columns.map(escapeCSV).join(","); +export function toCSV(columns: string[], rows: CellValue[][]): string { + const header = columns.map(escapeCSVText).join(","); const body = rows.map((r) => r.map(escapeCSV).join(",")).join("\n"); return `${header}\n${body}`; } -export function toJSON(columns: string[], rows: string[][]): string { +/** SQL NULL is exported as JSON null rather than the string "null". */ +export function toJSON(columns: string[], rows: CellValue[][]): string { const objects = rows.map((row) => { - const obj: Record = {}; + const obj: Record = {}; columns.forEach((col, i) => { - obj[col] = row[i]; + obj[col] = row[i] ?? null; }); return obj; }); return JSON.stringify(objects, null, 2); } -export function toSQL(columns: string[], rows: string[][], tableName = "table_name"): string { +export function toSQL(columns: string[], rows: CellValue[][], tableName = "table_name"): string { if (rows.length === 0) return `-- No rows to export`; const colList = columns.map((c) => `"${c}"`).join(", "); return rows @@ -51,16 +63,16 @@ export function toSQL(columns: string[], rows: string[][], tableName = "table_na .join("\n"); } -export function toMarkdown(columns: string[], rows: string[][]): string { +export function toMarkdown(columns: string[], rows: CellValue[][]): string { const header = `| ${columns.join(" | ")} |`; const separator = `| ${columns.map(() => "---").join(" | ")} |`; const body = rows - .map((r) => `| ${r.map((c) => c.replace(/\|/g, "\\|")).join(" | ")} |`) + .map((r) => `| ${r.map((c) => (c === null ? "" : c.replace(/\|/g, "\\|"))).join(" | ")} |`) .join("\n"); return `${header}\n${separator}\n${body}`; } -export function toXML(columns: string[], rows: string[][]): string { +export function toXML(columns: string[], rows: CellValue[][]): string { const lines = ['', ""]; for (const row of rows) { lines.push(" "); @@ -75,7 +87,7 @@ export function toXML(columns: string[], rows: string[][]): string { const formatters: Record< ExportFormat, - (cols: string[], rows: string[][], table?: string) => string + (cols: string[], rows: CellValue[][], table?: string) => string > = { csv: toCSV, json: toJSON, @@ -103,7 +115,7 @@ const filterNames: Record = { export async function exportResults( format: ExportFormat, columns: string[], - rows: string[][], + rows: CellValue[][], tableName?: string, ) { const content = formatters[format](columns, rows, tableName); @@ -122,7 +134,7 @@ export async function exportResults( export function copyToClipboard( format: ExportFormat, columns: string[], - rows: string[][], + rows: CellValue[][], tableName?: string, ): Promise { const content = formatters[format](columns, rows, tableName); diff --git a/src/lib/sql-utils.ts b/src/lib/sql-utils.ts index 7b3e1185..3411f2cf 100644 --- a/src/lib/sql-utils.ts +++ b/src/lib/sql-utils.ts @@ -1,3 +1,5 @@ +import type { CellValue } from "@/lib/wire"; + /** * Parse a simple SELECT query to extract the target table. * Returns null for complex queries (JOINs, subqueries, UNIONs, CTEs). @@ -24,8 +26,13 @@ export function quoteIdent(name: string): string { return `"${name.replace(/"/g, '""')}"`; } -export function quoteLiteral(value: string): string { - if (value.toLowerCase() === "null") return "NULL"; +/** + * Only a real SQL NULL becomes the NULL keyword. The text value "null" is + * quoted like any other string — earlier releases conflated the two and wrote + * NULL over genuine "null" values. + */ +export function quoteLiteral(value: CellValue): string { + if (value === null) return "NULL"; return `'${value.replace(/'/g, "''")}'`; } @@ -33,7 +40,7 @@ export function generateUpdate( schema: string, table: string, columns: string[], - originalRow: string[], + originalRow: CellValue[], changes: Map, pkColumns: string[], ): string { @@ -52,7 +59,7 @@ export function generateDelete( schema: string, table: string, columns: string[], - originalRow: string[], + originalRow: CellValue[], pkColumns: string[], ): string { const target = `${quoteIdent(schema)}.${quoteIdent(table)}`; @@ -60,13 +67,13 @@ export function generateDelete( return `DELETE FROM ${target} WHERE ${where}`; } -function buildPKWhere(columns: string[], row: string[], pkColumns: string[]): string { +function buildPKWhere(columns: string[], row: CellValue[], pkColumns: string[]): string { return pkColumns .map((pk) => { const idx = columns.indexOf(pk); if (idx === -1) return null; const val = row[idx]; - return val.toLowerCase() === "null" + return val === null ? `${quoteIdent(pk)} IS NULL` : `${quoteIdent(pk)} = ${quoteLiteral(val)}`; }) diff --git a/src/lib/virtual-cache.ts b/src/lib/virtual-cache.ts index 88f59ba4..17e16a37 100644 --- a/src/lib/virtual-cache.ts +++ b/src/lib/virtual-cache.ts @@ -1,15 +1,17 @@ +import type { CellValue } from "@/lib/wire"; + // Virtual pagination page cache -// Each query gets a Map of pageIndex → rows (string[][]) +// Each query gets a Map of pageIndex → rows (CellValue[][]) // Max pages per query controlled by evictDistant() -const cache = new Map>(); +const cache = new Map>(); -export function setPage(queryId: string, pageIndex: number, rows: string[][]): void { +export function setPage(queryId: string, pageIndex: number, rows: CellValue[][]): void { if (!cache.has(queryId)) cache.set(queryId, new Map()); cache.get(queryId)?.set(pageIndex, rows); } -export function getRow(queryId: string, rowIndex: number, pageSize: number): string[] | null { +export function getRow(queryId: string, rowIndex: number, pageSize: number): CellValue[] | null { const pages = cache.get(queryId); if (!pages) return null; const pageIndex = Math.floor(rowIndex / pageSize); diff --git a/src/lib/wire.test.ts b/src/lib/wire.test.ts new file mode 100644 index 00000000..b579c697 --- /dev/null +++ b/src/lib/wire.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + CELL_SEP, + type CellValue, + decodeCell, + decodeColumns, + decodePage, + decodeResult, + ESC, + encodeCell, + encodeResult, + encodeRows, + ROW_SEP, +} from "./wire"; + +function roundTrip(rows: CellValue[][]) { + expect(decodePage(encodeRows(rows))).toEqual(rows); +} + +describe("decodeCell", () => { + it("distinguishes SQL NULL from the text null", () => { + expect(decodeCell(`${ESC}N`)).toBeNull(); + expect(decodeCell("null")).toBe("null"); + expect(decodeCell("NULL")).toBe("NULL"); + }); + + it("distinguishes SQL NULL from the empty string", () => { + expect(decodeCell(`${ESC}N`)).toBeNull(); + expect(decodeCell("")).toBe(""); + }); + + it("unescapes separators occurring in data", () => { + expect(decodeCell(`a${ESC}Ab`)).toBe(`a${CELL_SEP}b`); + expect(decodeCell(`a${ESC}Bb`)).toBe(`a${ROW_SEP}b`); + expect(decodeCell(`a${ESC}Cb`)).toBe(`a${ESC}b`); + }); + + it("round-trips a value equal to the null marker", () => { + expect(decodeCell(encodeCell(`${ESC}N`))).toBe(`${ESC}N`); + }); + + it("takes the fast path when no escape is present", () => { + expect(decodeCell("plain value")).toBe("plain value"); + }); +}); + +describe("decodePage", () => { + it("returns no rows for an empty payload", () => { + expect(decodePage("")).toEqual([]); + }); + + it("round-trips mixed nulls, empty strings and text nulls", () => { + roundTrip([ + ["1", null, "x"], + [null, "", "null"], + ["3", "y", null], + ]); + }); + + it("round-trips consecutive escapes", () => { + roundTrip([[`${ESC}${ESC}${CELL_SEP}${ROW_SEP}`]]); + }); + + it("round-trips multibyte content next to separators", () => { + roundTrip([[`🦀${CELL_SEP}🦀${ROW_SEP}🦀`], ["árvíztűrő", "日本語"]]); + }); +}); + +describe("decodeColumns", () => { + it("returns no columns for an empty header", () => { + expect(decodeColumns("")).toEqual([]); + }); + + it("unescapes separators in column names", () => { + expect(decodeColumns(`id${CELL_SEP}we${ESC}Aird`)).toEqual(["id", `we${CELL_SEP}ird`]); + }); +}); + +describe("decodeResult", () => { + it("returns empty columns and rows for an empty payload", () => { + expect(decodeResult("")).toEqual({ columns: [], rows: [] }); + }); + + it("handles a header with no rows", () => { + expect(decodeResult(`a${CELL_SEP}b`)).toEqual({ columns: ["a", "b"], rows: [] }); + }); + + it("splits header from body on the first row separator only", () => { + const packed = `a${CELL_SEP}b${ROW_SEP}1${CELL_SEP}2${ROW_SEP}3${CELL_SEP}4`; + expect(decodeResult(packed)).toEqual({ + columns: ["a", "b"], + rows: [ + ["1", "2"], + ["3", "4"], + ], + }); + }); + + it("keeps a NULL first cell of the first row out of the header", () => { + const packed = `a${CELL_SEP}b${ROW_SEP}${ESC}N${CELL_SEP}2`; + expect(decodeResult(packed)).toEqual({ columns: ["a", "b"], rows: [[null, "2"]] }); + }); +}); + +describe("encodeResult", () => { + it("round-trips through decodeResult", () => { + const columns = ["id", "name"]; + const rows: CellValue[][] = [ + ["1", null], + [null, "null"], + ["3", `has${CELL_SEP}sep`], + ]; + expect(decodeResult(encodeResult(columns, rows))).toEqual({ columns, rows }); + }); + + it("emits a header only when there are no rows", () => { + expect(encodeResult(["a", "b"], [])).toBe(`a${CELL_SEP}b`); + }); + + it("keeps NULL and the empty string apart where a plain join would not", () => { + expect(encodeRows([[null]])).not.toBe(encodeRows([[""]])); + }); +}); diff --git a/src/lib/wire.ts b/src/lib/wire.ts new file mode 100644 index 00000000..84d5fbca --- /dev/null +++ b/src/lib/wire.ts @@ -0,0 +1,129 @@ +/** + * Packed wire format shared by every query path. Mirrors + * `src-tauri/src/drivers/pgsql/wire.rs` — the two must be changed together. + * + * Cells are joined by CELL_SEP, rows by ROW_SEP. Because any byte can appear in + * a Postgres text value, separators occurring inside data are escaped with ESC + * rather than replaced. SQL NULL has its own encoding so it stays distinct from + * the text value "null" and from the empty string. + */ + +export const CELL_SEP = "\x1F"; +export const ROW_SEP = "\x1E"; +export const ESC = "\x1D"; + +const NULL_MARKER = `${ESC}N`; +const TAG_CELL_SEP = "A"; +const TAG_ROW_SEP = "B"; +const TAG_ESC = "C"; + +/** A single cell: `null` is SQL NULL, `""` is the empty string. */ +export type CellValue = string | null; + +/** + * Encode one cell. Mirrors `push_cell` in wire.rs. Needed wherever the frontend + * sends rows back to Rust — a plain `join` would turn NULL into "" and would + * leave separators inside data unescaped. + */ +export function encodeCell(cell: CellValue): string { + if (cell === null) return NULL_MARKER; + if (!cell.includes(CELL_SEP) && !cell.includes(ROW_SEP) && !cell.includes(ESC)) { + return cell; + } + + let out = ""; + for (const ch of cell) { + if (ch === CELL_SEP) out += `${ESC}${TAG_CELL_SEP}`; + else if (ch === ROW_SEP) out += `${ESC}${TAG_ROW_SEP}`; + else if (ch === ESC) out += `${ESC}${TAG_ESC}`; + else out += ch; + } + return out; +} + +/** Encode one row. */ +export function encodeRow(row: CellValue[]): string { + return row.map(encodeCell).join(CELL_SEP); +} + +/** Encode rows into a packed page body (no header). */ +export function encodeRows(rows: CellValue[][]): string { + return rows.map(encodeRow).join(ROW_SEP); +} + +/** Encode a full packed result: header line followed by row lines. */ +export function encodeResult(columns: string[], rows: CellValue[][]): string { + const header = encodeRow(columns); + if (rows.length === 0) return header; + return `${header}${ROW_SEP}${encodeRows(rows)}`; +} + +/** Decode one cell, resolving the NULL marker and unescaping separators. */ +export function decodeCell(raw: string): CellValue { + if (raw === NULL_MARKER) return null; + if (!raw.includes(ESC)) return raw; + + let out = ""; + for (let i = 0; i < raw.length; i++) { + if (raw[i] !== ESC) { + out += raw[i]; + continue; + } + i += 1; + switch (raw[i]) { + case TAG_CELL_SEP: + out += CELL_SEP; + break; + case TAG_ROW_SEP: + out += ROW_SEP; + break; + case TAG_ESC: + out += ESC; + break; + default: + if (raw[i] !== undefined) out += raw[i]; + break; + } + } + return out; +} + +/** Decode one packed row. */ +export function decodeRow(raw: string): CellValue[] { + return raw.split(CELL_SEP).map(decodeCell); +} + +/** Decode a packed page body (rows only, no header). */ +export function decodePage(packed: string): CellValue[][] { + if (!packed) return []; + return packed.split(ROW_SEP).map(decodeRow); +} + +/** Decode a packed header line into column names. */ +export function decodeColumns(raw: string): string[] { + if (!raw) return []; + return raw.split(CELL_SEP).map((cell) => decodeCell(cell) ?? ""); +} + +/** Decode a full packed result: header line followed by row lines. */ +export function decodeResult(packed: string): { columns: string[]; rows: CellValue[][] } { + if (!packed) return { columns: [], rows: [] }; + const separator = packed.indexOf(ROW_SEP); + if (separator === -1) { + return { columns: decodeColumns(packed), rows: [] }; + } + return { + columns: decodeColumns(packed.slice(0, separator)), + rows: decodePage(packed.slice(separator + 1)), + }; +} + +/** + * Render a cell for display and for text-oriented operations such as search, + * export and diffing. NULL renders as the empty string so it never masquerades + * as the text "null"; callers that must show NULL distinctly check for `null` + * themselves before calling this. + */ +export function cellText(cell: CellValue): string { + return cell ?? ""; +} diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index ff9e695a..388818fa 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -1,10 +1,11 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; +import type { CellValue } from "@/lib/wire"; import type { QueryResult } from "@/types"; interface PinnedResult { columns: string[]; - rows: string[][]; + rows: CellValue[][]; label: string; } diff --git a/src/types/index.ts b/src/types/index.ts index 800d3a84..1510bd0c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,7 @@ +import type { CellValue } from "@/lib/wire"; + +export type { CellValue }; + export interface ProjectDetails { driver: DriverType; username: string; @@ -83,7 +87,7 @@ export interface ExplainPlan { export interface QueryResult { columns: string[]; - rows: string[][]; + rows: CellValue[][]; time: number; capped?: boolean; } From a28f4fa1fccd0ea0a0f63110ad7dfb2bc29c9a4a Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Mon, 10 Aug 2026 13:02:32 +0200 Subject: [PATCH 03/20] feat: apply row edits as one parameterized transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline row editing could silently do nothing, and in one case wrote to the wrong table. Four separate causes: Commit applied only cell edits and Delete applied only row deletions, and each cleared the whole edit state afterwards, so pressing Commit with rows marked for deletion discarded those deletions without a word. There is now a single Apply that sends updates and deletions together. The edit state lived in the results panel component rather than on a tab, so switching tabs kept the previous table, key columns and marked rows while the rows underneath changed — a delete marked on one table could run against another. The session now lives on the tab and Apply refuses to run when the editor no longer targets the table the session was opened on. Pending changes were keyed by grid row index, which means nothing once a result is refreshed and could index past the end of a shorter result and throw outside the error handler. They are now keyed by the primary-key tuple captured when the change was marked, and rows that cannot be identified are rejected with a message instead of guessed at. Statements were built as SQL text in the frontend and their result was never checked, so a DELETE matching zero rows reported success. Statements are now built in Rust from validated column names, bound as parameters cast to the column's catalog type, and run inside a real transaction that requires every statement to affect exactly one row. Zero or several rows aborts the batch with an error naming the row key. Column names are checked against pg_attribute rather than merely quoted, and the statement timeout is set with SET LOCAL so it cannot leak into a pooled session. --- src-tauri/src/drivers/pgsql/commands/mod.rs | 4 +- .../pgsql/commands/mutation_commands.rs | 149 +++++++++ src-tauri/src/drivers/pgsql/mod.rs | 3 +- .../src/drivers/pgsql/mutation/builder.rs | 308 ++++++++++++++++++ src-tauri/src/drivers/pgsql/mutation/mod.rs | 3 + src-tauri/src/main.rs | 1 + src/components/results-panel/index.tsx | 31 +- src/components/results-panel/toolbar-edit.tsx | 90 ++--- src/components/results-panel/toolbar.tsx | 32 +- src/components/results-panel/types.ts | 21 +- src/components/results-panel/use-edit-mode.ts | 286 ++++++++-------- src/lib/database-driver/index.ts | 8 + src/lib/database-driver/pgsql.ts | 25 +- src/lib/mutations.test.ts | 142 ++++++++ src/lib/mutations.ts | 108 ++++++ src/lib/sql-utils.ts | 45 --- src/stores/tab-store.ts | 60 ++++ src/types/index.ts | 5 +- 18 files changed, 1050 insertions(+), 271 deletions(-) create mode 100644 src-tauri/src/drivers/pgsql/commands/mutation_commands.rs create mode 100644 src-tauri/src/drivers/pgsql/mutation/builder.rs create mode 100644 src-tauri/src/drivers/pgsql/mutation/mod.rs create mode 100644 src/lib/mutations.test.ts create mode 100644 src/lib/mutations.ts diff --git a/src-tauri/src/drivers/pgsql/commands/mod.rs b/src-tauri/src/drivers/pgsql/commands/mod.rs index a417f432..57399d3a 100644 --- a/src-tauri/src/drivers/pgsql/commands/mod.rs +++ b/src-tauri/src/drivers/pgsql/commands/mod.rs @@ -1,8 +1,9 @@ -pub(crate) const CELL_SEP: char = '\x1F'; +pub(crate) use super::wire::CELL_SEP; pub(crate) const SNAPSHOT_PAGE_WRITE_RETRIES: usize = 3; pub mod admin_commands; pub mod metadata_commands; +pub mod mutation_commands; pub mod object_info_commands; pub mod pool_connection; pub mod pubsub_commands; @@ -12,6 +13,7 @@ pub mod statistics_commands; pub use admin_commands::*; pub use metadata_commands::*; +pub use mutation_commands::*; pub use object_info_commands::*; pub use pool_connection::*; pub use pubsub_commands::*; diff --git a/src-tauri/src/drivers/pgsql/commands/mutation_commands.rs b/src-tauri/src/drivers/pgsql/commands/mutation_commands.rs new file mode 100644 index 00000000..2096cb35 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/mutation_commands.rs @@ -0,0 +1,149 @@ +use crate::AppState; +use crate::common::enums::AppError; +use crate::drivers::pgsql::mutation::{ + BuiltStatement, ColumnTypes, MutationKind, RowMutation, build_statement, +}; + +use tauri::{Result, State}; +use tokio_postgres::types::ToSql; + +use super::pool_connection::{acquire_client, set_cancel_token}; + +#[derive(serde::Serialize)] +pub struct MutationReport { + pub updated: usize, + pub deleted: usize, +} + +/// Column types straight from the catalog. Used both to cast parameters and to +/// reject column names the table does not actually have. +async fn load_column_types( + client: &deadpool_postgres::Client, + schema: &str, + table: &str, +) -> std::result::Result { + let rows = client + .query( + "SELECT attname::text, format_type(atttypid, atttypmod) + FROM pg_attribute + WHERE attrelid = format('%I.%I', $1::text, $2::text)::regclass + AND attnum > 0 + AND NOT attisdropped", + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + if rows.is_empty() { + return Err(AppError::QueryFailed(format!( + "Table {}.{} has no readable columns", + schema, table + ))); + } + + Ok(rows + .into_iter() + .map(|row| (row.get::<_, String>(0), row.get::<_, String>(1))) + .collect()) +} + +/// Render a row key for error messages, e.g. `id=7, tenant=acme`. +fn describe_key(key: &[(String, Option)]) -> String { + key.iter() + .map(|(column, value)| match value { + Some(v) => format!("{}={}", column, v), + None => format!("{}=NULL", column), + }) + .collect::>() + .join(", ") +} + +/// Apply grid row edits as parameterized statements in a single transaction. +/// +/// Every statement must affect exactly one row. Zero means the row is gone or +/// its key changed underneath the grid; more than one means the supplied key is +/// not unique. Either aborts the whole transaction, so a partial apply is not +/// possible and a silent no-op is reported as an error instead of success. +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_apply_row_mutations( + project_id: &str, + schema: &str, + table: &str, + mutations: Vec, + timeout_ms: Option, + app_state: State<'_, AppState>, +) -> Result { + if mutations.is_empty() { + return Ok(MutationReport { + updated: 0, + deleted: 0, + }); + } + + let mut client = acquire_client(&app_state.clients, project_id).await?; + set_cancel_token(&app_state, project_id, client.cancel_token()).await?; + + let types = load_column_types(&client, schema, table).await?; + + // Build everything up front so a rejected payload never opens a transaction. + let planned: Vec = mutations + .iter() + .map(|mutation| build_statement(schema, table, mutation, &types)) + .collect::>()?; + + let tx = client + .transaction() + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + // Transaction-scoped, so it cannot leak into the pooled session the way a + // session-level SET followed by RESET can when the reset never runs. + if let Some(ms) = timeout_ms.filter(|ms| *ms > 0) { + tx.batch_execute(&format!("SET LOCAL statement_timeout = {}", ms)) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + } + + let mut updated = 0usize; + let mut deleted = 0usize; + + for (mutation, statement) in mutations.iter().zip(&planned) { + let params: Vec<&(dyn ToSql + Sync)> = statement + .params + .iter() + .map(|p| p as &(dyn ToSql + Sync)) + .collect(); + + let affected = tx + .execute(statement.sql.as_str(), ¶ms) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + if affected != 1 { + let key = describe_key(&mutation.pk); + // Dropping `tx` without committing rolls the whole batch back. + return Err(AppError::QueryFailed(format!( + "Expected 1 row for {} but matched {}. Row key: {}. \ + Nothing was changed — refresh the results and try again.", + match mutation.kind { + MutationKind::Update => "update", + MutationKind::Delete => "delete", + }, + affected, + key + )) + .into()); + } + + match mutation.kind { + MutationKind::Update => updated += 1, + MutationKind::Delete => deleted += 1, + } + } + + tx.commit() + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(MutationReport { updated, deleted }) +} diff --git a/src-tauri/src/drivers/pgsql/mod.rs b/src-tauri/src/drivers/pgsql/mod.rs index 2bdbbfab..06fd1351 100644 --- a/src-tauri/src/drivers/pgsql/mod.rs +++ b/src-tauri/src/drivers/pgsql/mod.rs @@ -8,6 +8,7 @@ pub mod ddl_generation; pub mod extensions; pub mod metadata_schema; pub mod metadata_views_functions; +pub mod mutation; pub mod query_execution; pub mod roles_schema_objects; pub mod statistics_activity; @@ -34,7 +35,7 @@ pub fn get_pool( .ok_or_else(|| AppError::ClientNotConnected(project_id.to_string())) } -pub(crate) use wire::{CELL_SEP, ROW_SEP}; +pub(crate) use wire::ROW_SEP; /// A cached query: pre-packed page strings for zero-copy serving. /// Each page is a single large String (~1-2 MB) so the OS reclaims RSS on drop. diff --git a/src-tauri/src/drivers/pgsql/mutation/builder.rs b/src-tauri/src/drivers/pgsql/mutation/builder.rs new file mode 100644 index 00000000..7d1ea3d7 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/mutation/builder.rs @@ -0,0 +1,308 @@ +//! Builds parameterized UPDATE and DELETE statements for grid row edits. +//! +//! Values arrive from the frontend as text, because that is how the simple +//! query protocol hands them out. Binding them as text and casting on the +//! Postgres side (`$1::text::`) keeps every value out of the SQL string. +//! The double cast is deliberate: with a bare `$1::int4` Postgres infers the +//! parameter as `int4` while the client binds `&str`, which is a type mismatch. +//! `$1::text` pins the parameter to text and the second cast converts. The +//! compared column stays bare, so the expression folds to a constant at plan +//! time and indexes stay usable. + +use std::collections::BTreeMap; + +use serde::Deserialize; + +use crate::common::enums::AppError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MutationKind { + Update, + Delete, +} + +/// One row mutation. `None` values are SQL NULL. +#[derive(Debug, Clone, Deserialize)] +pub struct RowMutation { + pub kind: MutationKind, + #[serde(default)] + pub set: Vec<(String, Option)>, + pub pk: Vec<(String, Option)>, +} + +/// A statement plus its bound parameters, in placeholder order. +#[derive(Debug, PartialEq, Eq)] +pub struct BuiltStatement { + pub sql: String, + pub params: Vec>, +} + +/// Column name to the type expression returned by `format_type`. +pub type ColumnTypes = BTreeMap; + +pub fn quote_ident(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} + +/// Resolve a column's type, rejecting anything the table does not have. This is +/// what makes the statement injection-proof: names are matched against the live +/// catalog rather than merely quoted. +fn column_type<'a>(types: &'a ColumnTypes, column: &str) -> Result<&'a str, AppError> { + types + .get(column) + .map(String::as_str) + .ok_or_else(|| AppError::QueryFailed(format!("Unknown column \"{}\"", column))) +} + +/// Append the key predicates and their parameters, returning the WHERE body. +fn build_key_predicates( + key: &[(String, Option)], + types: &ColumnTypes, + params: &mut Vec>, +) -> Result { + if key.is_empty() { + return Err(AppError::QueryFailed( + "Refusing to build a statement with no key columns".into(), + )); + } + + let mut predicates = Vec::with_capacity(key.len()); + for (column, value) in key { + let ty = column_type(types, column)?; + match value { + None => predicates.push(format!("{} IS NULL", quote_ident(column))), + Some(_) => { + params.push(value.clone()); + predicates.push(format!( + "{} = ${}::text::{}", + quote_ident(column), + params.len(), + ty + )); + } + } + } + + Ok(predicates.join(" AND ")) +} + +pub fn build_statement( + schema: &str, + table: &str, + mutation: &RowMutation, + types: &ColumnTypes, +) -> Result { + let target = format!("{}.{}", quote_ident(schema), quote_ident(table)); + let mut params: Vec> = Vec::new(); + + let sql = match mutation.kind { + MutationKind::Delete => { + let where_clause = build_key_predicates(&mutation.pk, types, &mut params)?; + format!("DELETE FROM {} WHERE {}", target, where_clause) + } + MutationKind::Update => { + if mutation.set.is_empty() { + return Err(AppError::QueryFailed( + "Refusing to build an UPDATE with no assignments".into(), + )); + } + + let mut assignments = Vec::with_capacity(mutation.set.len()); + for (column, value) in &mutation.set { + let ty = column_type(types, column)?; + params.push(value.clone()); + assignments.push(format!( + "{} = ${}::text::{}", + quote_ident(column), + params.len(), + ty + )); + } + + let where_clause = build_key_predicates(&mutation.pk, types, &mut params)?; + format!( + "UPDATE {} SET {} WHERE {}", + target, + assignments.join(", "), + where_clause + ) + } + }; + + Ok(BuiltStatement { sql, params }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn types() -> ColumnTypes { + [ + ("id", "bigint"), + ("name", "character varying(255)"), + ("tags", "text[]"), + ("kind", "public.my_enum"), + ("note", "text"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + fn cell(value: &str) -> Option { + Some(value.to_string()) + } + + #[test] + fn delete_casts_the_key_parameter_to_the_column_type() { + let mutation = RowMutation { + kind: MutationKind::Delete, + set: vec![], + pk: vec![("id".into(), cell("7"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert_eq!( + built.sql, + "DELETE FROM \"public\".\"t\" WHERE \"id\" = $1::text::bigint" + ); + assert_eq!(built.params, vec![cell("7")]); + } + + #[test] + fn update_numbers_assignments_before_key_predicates() { + let mutation = RowMutation { + kind: MutationKind::Update, + set: vec![("name".into(), cell("ada"))], + pk: vec![("id".into(), cell("7"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert_eq!( + built.sql, + "UPDATE \"public\".\"t\" SET \"name\" = $1::text::character varying(255) \ + WHERE \"id\" = $2::text::bigint" + ); + assert_eq!(built.params, vec![cell("ada"), cell("7")]); + } + + #[test] + fn a_null_key_becomes_is_null_and_consumes_no_placeholder() { + let mutation = RowMutation { + kind: MutationKind::Delete, + set: vec![], + pk: vec![("note".into(), None), ("id".into(), cell("7"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert_eq!( + built.sql, + "DELETE FROM \"public\".\"t\" WHERE \"note\" IS NULL AND \"id\" = $1::text::bigint" + ); + assert_eq!(built.params, vec![cell("7")]); + } + + #[test] + fn a_null_assignment_binds_null_rather_than_the_text_null() { + let mutation = RowMutation { + kind: MutationKind::Update, + set: vec![("note".into(), None)], + pk: vec![("id".into(), cell("7"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert_eq!(built.params, vec![None, cell("7")]); + } + + #[test] + fn the_text_null_is_bound_as_a_value() { + let mutation = RowMutation { + kind: MutationKind::Update, + set: vec![("note".into(), cell("null"))], + pk: vec![("id".into(), cell("7"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert_eq!(built.params, vec![cell("null"), cell("7")]); + } + + #[test] + fn composite_keys_are_all_required() { + let mutation = RowMutation { + kind: MutationKind::Delete, + set: vec![], + pk: vec![("id".into(), cell("7")), ("name".into(), cell("ada"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert!(built.sql.contains("\"id\" = $1::text::bigint")); + assert!(built.sql.contains("\"name\" = $2::text::character varying(255)")); + assert_eq!(built.params.len(), 2); + } + + #[test] + fn array_and_enum_columns_use_their_catalog_type() { + let mutation = RowMutation { + kind: MutationKind::Update, + set: vec![("tags".into(), cell("{a,b}")), ("kind".into(), cell("active"))], + pk: vec![("id".into(), cell("7"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert!(built.sql.contains("\"tags\" = $1::text::text[]")); + assert!(built.sql.contains("\"kind\" = $2::text::public.my_enum")); + } + + #[test] + fn unknown_columns_are_rejected() { + let mutation = RowMutation { + kind: MutationKind::Delete, + set: vec![], + pk: vec![("id; DROP TABLE users".into(), cell("7"))], + }; + let err = build_statement("public", "t", &mutation, &types()).unwrap_err(); + assert!(err.to_string().contains("Unknown column")); + } + + #[test] + fn an_unknown_assignment_column_is_rejected() { + let mutation = RowMutation { + kind: MutationKind::Update, + set: vec![("nope".into(), cell("x"))], + pk: vec![("id".into(), cell("7"))], + }; + assert!(build_statement("public", "t", &mutation, &types()).is_err()); + } + + #[test] + fn a_statement_without_key_columns_is_refused() { + let mutation = RowMutation { + kind: MutationKind::Delete, + set: vec![], + pk: vec![], + }; + let err = build_statement("public", "t", &mutation, &types()).unwrap_err(); + assert!(err.to_string().contains("no key columns")); + } + + #[test] + fn an_update_without_assignments_is_refused() { + let mutation = RowMutation { + kind: MutationKind::Update, + set: vec![], + pk: vec![("id".into(), cell("7"))], + }; + let err = build_statement("public", "t", &mutation, &types()).unwrap_err(); + assert!(err.to_string().contains("no assignments")); + } + + #[test] + fn identifiers_containing_quotes_are_escaped() { + assert_eq!(quote_ident("we\"ird"), "\"we\"\"ird\""); + } + + #[test] + fn schema_and_table_names_are_quoted() { + let mutation = RowMutation { + kind: MutationKind::Delete, + set: vec![], + pk: vec![("id".into(), cell("1"))], + }; + let built = build_statement("my schema", "my table", &mutation, &types()).unwrap(); + assert!(built.sql.starts_with("DELETE FROM \"my schema\".\"my table\" ")); + } +} diff --git a/src-tauri/src/drivers/pgsql/mutation/mod.rs b/src-tauri/src/drivers/pgsql/mutation/mod.rs new file mode 100644 index 00000000..342062db --- /dev/null +++ b/src-tauri/src/drivers/pgsql/mutation/mod.rs @@ -0,0 +1,3 @@ +mod builder; + +pub use builder::*; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0e6e1a36..7d4bcafd 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -104,6 +104,7 @@ fn main() { drivers::pgsql::pgsql_load_available_extensions, drivers::pgsql::pgsql_load_enum_types, drivers::pgsql::pgsql_table_action, + drivers::pgsql::pgsql_apply_row_mutations, drivers::pgsql::pgsql_load_pg_settings, terminal::terminal_spawn, terminal::terminal_write, diff --git a/src/components/results-panel/index.tsx b/src/components/results-panel/index.tsx index bff5630b..2c465196 100644 --- a/src/components/results-panel/index.tsx +++ b/src/components/results-panel/index.tsx @@ -58,24 +58,27 @@ export function ResultsPanel() { const { isEditing, - editState, editError, setEditError, isCommitting, - pendingDeleteCount, + confirmingApply, + pending, + sessionMatchesEditor, editableTable, fkMap, + editedCells, + deletedRowIndices, handleFKNavigate, handleEnterEdit, handleDiscard, - handleCommit, - handleDeleteRows, - handleConfirmDelete, - handleCancelDelete, + handleRequestApply, + handleConfirmApply, + handleCancelApply, handleCellEdit, handleRowDelete, handleRowRestore, } = useEditMode({ + tabId: activeTab?.id, projectId: activeTab?.projectId, editorValue: activeTab?.editorValue, result, @@ -103,16 +106,16 @@ export function ResultsPanel() { hasExplain, isExecuting: !!isExecuting, isEditing, - editState, editableTable: !!editableTable && !vq, isCommitting, editError, + pending, + sessionMatchesEditor, + confirmingApply, onEnterEdit: handleEnterEdit, - onCommit: handleCommit, - onDeleteRows: handleDeleteRows, - onConfirmDelete: handleConfirmDelete, - onCancelDelete: handleCancelDelete, - pendingDeleteCount, + onRequestApply: handleRequestApply, + onConfirmApply: handleConfirmApply, + onCancelApply: handleCancelApply, onDiscard: handleDiscard, onCancel: handleCancel, virtualQuery: vq, @@ -246,8 +249,8 @@ export function ResultsPanel() { columns={result.columns} rows={filteredRows} isEditing={isEditing} - cellEdits={editState?.cellEdits} - deletedRows={editState?.deletedRows} + cellEdits={editedCells} + deletedRows={deletedRowIndices} onCellEdit={handleCellEdit} onRowDelete={handleRowDelete} onRowRestore={handleRowRestore} diff --git a/src/components/results-panel/toolbar-edit.tsx b/src/components/results-panel/toolbar-edit.tsx index 6fea1651..50ea6f94 100644 --- a/src/components/results-panel/toolbar-edit.tsx +++ b/src/components/results-panel/toolbar-edit.tsx @@ -1,4 +1,4 @@ -import { Loader2, Save, Trash2, X } from "lucide-react"; +import { Check, Loader2, X } from "lucide-react"; import { Dialog, DialogContent, @@ -7,84 +7,100 @@ import { DialogHeader, DialogTitle, } from "../ui/dialog"; -import type { EditState } from "./types"; interface ToolbarEditProps { - editState: EditState | null; + pending: { updates: number; deletes: number }; + sessionMatchesEditor: boolean; editError: string | null; isCommitting: boolean; - pendingDeleteCount: number; - onCommit: () => void; - onDeleteRows: () => void; - onConfirmDelete: () => void; - onCancelDelete: () => void; + confirmingApply: boolean; + onRequestApply: () => void; + onConfirmApply: () => void; + onCancelApply: () => void; onDiscard: () => void; } +function summarize({ updates, deletes }: { updates: number; deletes: number }): string { + const parts: string[] = []; + if (updates > 0) parts.push(`${updates} update${updates === 1 ? "" : "s"}`); + if (deletes > 0) parts.push(`${deletes} deletion${deletes === 1 ? "" : "s"}`); + return parts.join(" and "); +} + export function ToolbarEdit({ - editState, + pending, + sessionMatchesEditor, editError, isCommitting, - pendingDeleteCount, - onCommit, - onDeleteRows, - onConfirmDelete, - onCancelDelete, + confirmingApply, + onRequestApply, + onConfirmApply, + onCancelApply, onDiscard, }: ToolbarEditProps) { + const total = pending.updates + pending.deletes; + const summary = summarize(pending); + return ( <> {editError && ( - + {editError} )} + {!sessionMatchesEditor && ( + + Query changed — cannot apply + + )} - 0} + open={confirmingApply} onOpenChange={(open) => { - if (!open) onCancelDelete(); + if (!open) onCancelApply(); }} > - Delete rows + Apply changes - Are you sure you want to permanently delete {pendingDeleteCount} row - {pendingDeleteCount !== 1 ? "s" : ""}? This action cannot be undone. + This will apply {summary} in a single transaction. Deletions cannot be undone. If any + statement does not match exactly one row, nothing is changed. diff --git a/src/components/results-panel/toolbar.tsx b/src/components/results-panel/toolbar.tsx index 797394df..5dc774a5 100644 --- a/src/components/results-panel/toolbar.tsx +++ b/src/components/results-panel/toolbar.tsx @@ -32,16 +32,16 @@ export function ResultsToolbar(props: ToolbarProps) { hasExplain, isExecuting, isEditing, - editState, editableTable, isCommitting, editError, + pending, + sessionMatchesEditor, + confirmingApply, onEnterEdit, - onCommit, - onDeleteRows, - onConfirmDelete, - onCancelDelete, - pendingDeleteCount, + onRequestApply, + onConfirmApply, + onCancelApply, onDiscard, onCancel, virtualQuery, @@ -147,19 +147,19 @@ export function ResultsToolbar(props: ToolbarProps) { {result.time.toFixed(0)}ms - {isEditing && editState?.cellEdits.size ? ( + {isEditing && pending.updates ? ( <> - {editState.cellEdits.size} edit{editState.cellEdits.size !== 1 ? "s" : ""} + {pending.updates} edit{pending.updates !== 1 ? "s" : ""} ) : null} - {isEditing && editState?.deletedRows.size ? ( + {isEditing && pending.deletes ? ( <> - {editState.deletedRows.size} delete{editState.deletedRows.size !== 1 ? "s" : ""} + {pending.deletes} delete{pending.deletes !== 1 ? "s" : ""} ) : null} @@ -183,14 +183,14 @@ export function ResultsToolbar(props: ToolbarProps) { {/* Edit mode controls */} {isEditing ? ( ) : ( diff --git a/src/components/results-panel/types.ts b/src/components/results-panel/types.ts index ea471fb6..adc8563c 100644 --- a/src/components/results-panel/types.ts +++ b/src/components/results-panel/types.ts @@ -1,13 +1,6 @@ import type { CellValue } from "@/lib/wire"; -export type PanelView = "grid" | "record" | "history" | "explain" | "diff" | "map"; -export interface EditState { - schema: string; - table: string; - pkColumns: string[]; - cellEdits: Map; - deletedRows: Set; -} +export type PanelView = "grid" | "record" | "history" | "explain" | "diff" | "map"; export interface ToolbarProps { panelView: PanelView; @@ -23,16 +16,16 @@ export interface ToolbarProps { hasExplain: boolean; isExecuting: boolean; isEditing: boolean; - editState: EditState | null; editableTable: boolean; isCommitting: boolean; editError: string | null; + pending: { updates: number; deletes: number }; + sessionMatchesEditor: boolean; + confirmingApply: boolean; onEnterEdit: () => void; - onCommit: () => void; - onDeleteRows: () => void; - onConfirmDelete: () => void; - onCancelDelete: () => void; - pendingDeleteCount: number; + onRequestApply: () => void; + onConfirmApply: () => void; + onCancelApply: () => void; onDiscard: () => void; onCancel?: () => void; virtualQuery?: { queryId: string; totalRows: number; time: number; pageSize: number }; diff --git a/src/components/results-panel/use-edit-mode.ts b/src/components/results-panel/use-edit-mode.ts index 223ab1f8..95992210 100644 --- a/src/components/results-panel/use-edit-mode.ts +++ b/src/components/results-panel/use-edit-mode.ts @@ -1,39 +1,54 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { ForeignKey } from "@/lib/database-driver"; import { DriverFactory } from "@/lib/database-driver"; -import { - generateDelete, - generateUpdate, - parseSelectTable, - quoteIdent, - quoteLiteral, -} from "@/lib/sql-utils"; +import { buildMutations, countPending, emptySession, rowKeys } from "@/lib/mutations"; +import { parseSelectTable, quoteIdent, quoteLiteral } from "@/lib/sql-utils"; import type { CellValue } from "@/lib/wire"; import { useProjectStore } from "@/stores/project-store"; import { useTabStore } from "@/stores/tab-store"; -import type { EditState } from "./types"; +import type { QueryResult } from "@/types"; interface UseEditModeArgs { + tabId: string | undefined; projectId: string | undefined; editorValue: string | undefined; - result: - | { columns: string[]; rows: CellValue[][]; time: number; capped?: boolean } - | null - | undefined; + result: QueryResult | null | undefined; } -export function useEditMode({ projectId, editorValue, result }: UseEditModeArgs) { - const [isEditing, setIsEditing] = useState(false); - const [editState, setEditState] = useState(null); +export function useEditMode({ tabId, projectId, editorValue, result }: UseEditModeArgs) { const [editError, setEditError] = useState(null); const [isCommitting, setIsCommitting] = useState(false); - const [pendingDeleteCount, setPendingDeleteCount] = useState(0); + const [confirmingApply, setConfirmingApply] = useState(false); + + const editSession = useTabStore((s) => s.tabs.find((t) => t.id === tabId)?.editSession); + const isEditing = !!editSession; const editableTable = useMemo(() => { if (!editorValue) return null; return parseSelectTable(editorValue); }, [editorValue]); + // A session belongs to the table it was opened against. If the editor now + // points somewhere else, applying it would write to the wrong table. + const sessionMatchesEditor = + !editSession || + (!!editableTable && + editableTable.schema === editSession.schema && + editableTable.table === editSession.table); + + const keys = useMemo( + () => + editSession && result + ? rowKeys(result.columns, result.rows, editSession.pkColumns) + : ([] as (string | null)[]), + [editSession, result], + ); + + const pending = useMemo( + () => (editSession ? countPending(editSession) : { updates: 0, deletes: 0 }), + [editSession], + ); + const [fkMap, setFkMap] = useState< Map >(new Map()); @@ -93,7 +108,7 @@ export function useEditMode({ projectId, editorValue, result }: UseEditModeArgs) ); const handleEnterEdit = useCallback(async () => { - if (!editableTable || !projectId) return; + if (!editableTable || !projectId || !tabId) return; const d = useProjectStore.getState().projects[projectId]; if (!d) return; setEditError(null); @@ -121,161 +136,160 @@ export function useEditMode({ projectId, editorValue, result }: UseEditModeArgs) return; } - setEditState({ - schema: editableTable.schema, - table: editableTable.table, - pkColumns, - cellEdits: new Map(), - deletedRows: new Set(), - }); - setIsEditing(true); + useTabStore + .getState() + .startEditSession( + tabId, + emptySession(editableTable.schema, editableTable.table, pkColumns), + ); } catch (err: any) { setEditError(err?.message ?? "Failed to load table info"); } - }, [editableTable, projectId, result?.columns]); + }, [editableTable, projectId, tabId, result?.columns]); const handleDiscard = useCallback(() => { - setIsEditing(false); - setEditState(null); + if (!tabId) return; + useTabStore.getState().discardEditSession(tabId); setEditError(null); - }, []); - - const runAndRefresh = useCallback( - async (statements: string[]) => { - if (!projectId || statements.length === 0) return; - setIsCommitting(true); - setEditError(null); - - try { - const d = useProjectStore.getState().projects[projectId]; - if (!d) throw new Error("Project not found"); - const driver = DriverFactory.getDriver(d.driver); - - const txnSql = ["BEGIN", ...statements, "COMMIT"].join(";\n"); - await driver.runQuery(projectId, txnSql, 30000); - - const [cols, rows, time] = await driver.runQuery(projectId, editorValue ?? ""); - const tabIdx = useTabStore.getState().selectedTabIndex; - useTabStore.getState().updateResult(tabIdx, { columns: cols, rows, time }); - - setIsEditing(false); - setEditState(null); - setPendingDeleteCount(0); - } catch (err: any) { - setEditError(err?.message ?? "Commit failed"); - } finally { - setIsCommitting(false); - } - }, - [projectId, editorValue], - ); + setConfirmingApply(false); + }, [tabId]); - const handleCommit = useCallback(() => { - if (!editState || !result) return; - const { schema, table, pkColumns, cellEdits, deletedRows } = editState; - const columns = result.columns; - const originalRows = result.rows; - - const editsByRow = new Map>(); - for (const [key, value] of cellEdits) { - const [rowStr, colStr] = key.split(":"); - const rowIdx = parseInt(rowStr, 10); - const colIdx = parseInt(colStr, 10); - if (deletedRows.has(rowIdx)) continue; - if (!editsByRow.has(rowIdx)) editsByRow.set(rowIdx, new Map()); - editsByRow.get(rowIdx)?.set(colIdx, value); - } + const handleRequestApply = useCallback(() => { + if (pending.updates + pending.deletes === 0) return; + setConfirmingApply(true); + }, [pending]); - const statements: string[] = []; - for (const [rowIdx, changes] of editsByRow) { - statements.push( - generateUpdate(schema, table, columns, originalRows[rowIdx], changes, pkColumns), - ); - } + const handleCancelApply = useCallback(() => setConfirmingApply(false), []); - if (statements.length === 0) { + const handleConfirmApply = useCallback(async () => { + setConfirmingApply(false); + if (!editSession || !projectId || !tabId) return; + + const mutations = buildMutations(editSession); + if (mutations.length === 0) { handleDiscard(); return; } - void runAndRefresh(statements); - }, [editState, result, handleDiscard, runAndRefresh]); - - const handleDeleteRows = useCallback(() => { - if (!editState || editState.deletedRows.size === 0) return; - setPendingDeleteCount(editState.deletedRows.size); - }, [editState]); + setIsCommitting(true); + setEditError(null); - const handleConfirmDelete = useCallback(() => { - if (!editState || !result) return; - const { schema, table, pkColumns, deletedRows } = editState; - const columns = result.columns; - const originalRows = result.rows; + try { + const d = useProjectStore.getState().projects[projectId]; + if (!d) throw new Error("Project not found"); + const driver = DriverFactory.getDriver(d.driver); + if (!driver.applyRowMutations) throw new Error("Driver does not support inline editing"); - const statements: string[] = []; - for (const rowIdx of deletedRows) { - statements.push(generateDelete(schema, table, columns, originalRows[rowIdx], pkColumns)); - } + // One transaction for updates and deletes together. The backend requires + // each statement to affect exactly one row and rolls the batch back + // otherwise, so a no-op surfaces as an error rather than a silent success. + await driver.applyRowMutations( + projectId, + editSession.schema, + editSession.table, + mutations, + 30000, + ); - setPendingDeleteCount(0); - void runAndRefresh(statements); - }, [editState, result, runAndRefresh]); + const [cols, rows, time] = await driver.runQuery(projectId, editorValue ?? ""); + const refreshedIdx = useTabStore.getState().tabs.findIndex((t) => t.id === tabId); + if (refreshedIdx >= 0) { + useTabStore.getState().updateResult(refreshedIdx, { columns: cols, rows, time }); + } - const handleCancelDelete = useCallback(() => { - setPendingDeleteCount(0); - }, []); + useTabStore.getState().discardEditSession(tabId); + } catch (err: any) { + setEditError(err?.message ?? String(err)); + } finally { + setIsCommitting(false); + } + }, [editSession, projectId, tabId, editorValue, handleDiscard]); const handleCellEdit = useCallback( (rowIndex: number, colIndex: number, value: string) => { - setEditState((prev) => { - if (!prev) return prev; - const newEdits = new Map(prev.cellEdits); - const original = result?.rows[rowIndex]?.[colIndex] ?? ""; - if (value === original) { - newEdits.delete(`${rowIndex}:${colIndex}`); - } else { - newEdits.set(`${rowIndex}:${colIndex}`, value); - } - return { ...prev, cellEdits: newEdits }; - }); + if (!tabId || !editSession || !result) return; + const key = keys[rowIndex]; + if (!key) { + setEditError("This row cannot be identified by its primary key and cannot be edited."); + return; + } + const column = result.columns[colIndex]; + if (!column) return; + + const original = result.rows[rowIndex]?.[colIndex]; + const next: CellValue | undefined = value === original ? undefined : value; + useTabStore.getState().setCellEdit(tabId, key, column, next); }, - [result], + [tabId, editSession, result, keys], ); - const handleRowDelete = useCallback((rowIndex: number) => { - setEditState((prev) => { - if (!prev) return prev; - const newDeleted = new Set(prev.deletedRows); - newDeleted.add(rowIndex); - return { ...prev, deletedRows: newDeleted }; + const setRowDeleted = useCallback( + (rowIndex: number, deleted: boolean) => { + if (!tabId || !editSession) return; + const key = keys[rowIndex]; + if (!key) { + setEditError("This row cannot be identified by its primary key and cannot be deleted."); + return; + } + useTabStore.getState().setRowDeleted(tabId, key, deleted); + }, + [tabId, editSession, keys], + ); + + const handleRowDelete = useCallback( + (rowIndex: number) => setRowDeleted(rowIndex, true), + [setRowDeleted], + ); + const handleRowRestore = useCallback( + (rowIndex: number) => setRowDeleted(rowIndex, false), + [setRowDeleted], + ); + + // The grid still works in row positions, so translate identities back to it. + const editedCells = useMemo(() => { + const map = new Map(); + if (!editSession || !result) return map; + keys.forEach((key, rowIndex) => { + if (!key) return; + const columns = editSession.edits[key]; + if (!columns) return; + for (const [column, value] of Object.entries(columns)) { + const colIndex = result.columns.indexOf(column); + if (colIndex >= 0) map.set(`${rowIndex}:${colIndex}`, value ?? ""); + } }); - }, []); - - const handleRowRestore = useCallback((rowIndex: number) => { - setEditState((prev) => { - if (!prev) return prev; - const newDeleted = new Set(prev.deletedRows); - newDeleted.delete(rowIndex); - return { ...prev, deletedRows: newDeleted }; + return map; + }, [editSession, result, keys]); + + const deletedRowIndices = useMemo(() => { + const set = new Set(); + if (!editSession) return set; + const marked = new Set(editSession.deletes); + keys.forEach((key, rowIndex) => { + if (key && marked.has(key)) set.add(rowIndex); }); - }, []); + return set; + }, [editSession, keys]); return { isEditing, - editState, + editSession, editError, setEditError, isCommitting, - pendingDeleteCount, + confirmingApply, + pending, + sessionMatchesEditor, editableTable, fkMap, + editedCells, + deletedRowIndices, handleFKNavigate, handleEnterEdit, handleDiscard, - handleCommit, - handleDeleteRows, - handleConfirmDelete, - handleCancelDelete, + handleRequestApply, + handleConfirmApply, + handleCancelApply, handleCellEdit, handleRowDelete, handleRowRestore, diff --git a/src/lib/database-driver/index.ts b/src/lib/database-driver/index.ts index 3735e617..8d249388 100644 --- a/src/lib/database-driver/index.ts +++ b/src/lib/database-driver/index.ts @@ -1,3 +1,4 @@ +import type { MutationReport, RowMutation } from "@/lib/mutations"; import { type CellValue, decodeResult } from "@/lib/wire"; import type { ColumnDetail, @@ -149,6 +150,13 @@ export interface DatabaseDriver { loadAvailableExtensions?(projectId: string): Promise; loadEnumTypes?(projectId: string): Promise; loadPgSettings?(projectId: string): Promise; + applyRowMutations?( + projectId: string, + schema: string, + table: string, + mutations: RowMutation[], + timeoutMs?: number, + ): Promise; tableAction?( projectId: string, action: string, diff --git a/src/lib/database-driver/pgsql.ts b/src/lib/database-driver/pgsql.ts index 20bf7974..e3ba6f03 100644 --- a/src/lib/database-driver/pgsql.ts +++ b/src/lib/database-driver/pgsql.ts @@ -1,5 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import type { MutationReport, RowMutation } from "@/lib/mutations"; +import { decodeColumns, decodePage } from "@/lib/wire"; import type { DbGrant, PgRole, ProjectConnectionStatus, SchemaObject, TableGrant } from "@/types"; import type { DatabaseDriver, @@ -18,7 +20,6 @@ import type { WireTriggerFunctionInfo, } from "./index"; import { - CELL_SEP, parseColumnDetails, parseConstraintDetails, parseFunctionInfo, @@ -27,7 +28,6 @@ import { parseRuleDetails, parseTriggerDetails, parseTriggerFunctionInfo, - ROW_SEP, unpackResult, } from "./index"; @@ -149,14 +149,12 @@ export class PostgreSQLDriver implements DatabaseDriver { const p = event.payload; switch (p.type) { case "columns": { - const cols = p.columns ? p.columns.split(CELL_SEP) : []; - onColumns(cols, p.total_rows); + onColumns(decodeColumns(p.columns), p.total_rows); break; } case "chunk": { if (p.data) { - const rows = p.data.split(ROW_SEP).map((r) => r.split(CELL_SEP)); - onChunk(rows); + onChunk(decodePage(p.data)); } break; } @@ -351,6 +349,21 @@ export class PostgreSQLDriver implements DatabaseDriver { async loadPgSettings(projectId: string) { return invoke("pgsql_load_pg_settings", { project_id: projectId }); } + async applyRowMutations( + projectId: string, + schema: string, + table: string, + mutations: RowMutation[], + timeoutMs?: number, + ) { + return invoke("pgsql_apply_row_mutations", { + project_id: projectId, + schema, + table, + mutations, + timeout_ms: timeoutMs ?? null, + }); + } async tableAction( projectId: string, action: string, diff --git a/src/lib/mutations.test.ts b/src/lib/mutations.test.ts new file mode 100644 index 00000000..b661d66e --- /dev/null +++ b/src/lib/mutations.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { + buildMutations, + countPending, + type EditSession, + emptySession, + parsePkKey, + pkKey, + rowKeys, +} from "./mutations"; +import type { CellValue } from "./wire"; + +const COLUMNS = ["id", "name", "note"]; + +function session(over: Partial = {}): EditSession { + return { ...emptySession("public", "users", ["id"]), ...over }; +} + +describe("pkKey", () => { + it("identifies a row by its key values, not its position", () => { + const first = pkKey(COLUMNS, ["7", "ada", null], ["id"]); + const second = pkKey(COLUMNS, ["7", "grace", "x"], ["id"]); + expect(first).toBe(second); + }); + + it("keeps NULL apart from the text null and from the empty string", () => { + const asNull = pkKey(COLUMNS, [null, "a", "b"], ["id"]); + const asText = pkKey(COLUMNS, ["null", "a", "b"], ["id"]); + const asEmpty = pkKey(COLUMNS, ["", "a", "b"], ["id"]); + expect(new Set([asNull, asText, asEmpty]).size).toBe(3); + }); + + it("supports composite keys and is order-stable", () => { + const key = pkKey(COLUMNS, ["7", "ada", "x"], ["id", "name"]); + expect(parsePkKey(key as string)).toEqual([ + ["id", "7"], + ["name", "ada"], + ]); + }); + + it("returns null when a key column is missing from the result", () => { + expect(pkKey(COLUMNS, ["7", "ada", "x"], ["tenant"])).toBeNull(); + }); + + it("returns null for a short row rather than inventing a value", () => { + expect(pkKey(COLUMNS, ["7"], ["id", "name"])).toBeNull(); + }); + + it("returns null for a missing row", () => { + expect(pkKey(COLUMNS, undefined, ["id"])).toBeNull(); + }); + + it("returns null when there are no key columns", () => { + expect(pkKey(COLUMNS, ["7", "ada", "x"], [])).toBeNull(); + }); +}); + +describe("rowKeys", () => { + it("maps every row to an identity, marking unidentifiable rows null", () => { + const rows: CellValue[][] = [["1", "a", null], ["2", "b", "c"], ["3"]]; + expect(rowKeys(COLUMNS, rows, ["id", "name"])).toEqual([ + JSON.stringify([ + ["id", "1"], + ["name", "a"], + ]), + JSON.stringify([ + ["id", "2"], + ["name", "b"], + ]), + null, + ]); + }); +}); + +describe("buildMutations", () => { + const keyOne = JSON.stringify([["id", "1"]]); + const keyTwo = JSON.stringify([["id", "2"]]); + + it("sends updates and deletes together in one payload", () => { + const result = buildMutations( + session({ edits: { [keyOne]: { name: "ada" } }, deletes: [keyTwo] }), + ); + expect(result).toEqual([ + { kind: "update", set: [["name", "ada"]], pk: [["id", "1"]] }, + { kind: "delete", set: [], pk: [["id", "2"]] }, + ]); + }); + + it("drops edits on a row that is also marked for deletion", () => { + const result = buildMutations( + session({ edits: { [keyOne]: { name: "ada" } }, deletes: [keyOne] }), + ); + expect(result).toEqual([{ kind: "delete", set: [], pk: [["id", "1"]] }]); + }); + + it("carries a NULL assignment through as null", () => { + const result = buildMutations(session({ edits: { [keyOne]: { note: null } } })); + expect(result[0].set).toEqual([["note", null]]); + }); + + it("skips rows whose edits were all reverted", () => { + expect(buildMutations(session({ edits: { [keyOne]: {} } }))).toEqual([]); + }); + + it("produces nothing for an untouched session", () => { + expect(buildMutations(session())).toEqual([]); + }); + + it("round-trips composite keys into the payload", () => { + const composite = JSON.stringify([ + ["id", "1"], + ["tenant", null], + ]); + const result = buildMutations(session({ deletes: [composite] })); + expect(result[0].pk).toEqual([ + ["id", "1"], + ["tenant", null], + ]); + }); +}); + +describe("countPending", () => { + const keyOne = JSON.stringify([["id", "1"]]); + const keyTwo = JSON.stringify([["id", "2"]]); + + it("counts updates and deletes separately", () => { + const counts = countPending(session({ edits: { [keyOne]: { name: "a" } }, deletes: [keyTwo] })); + expect(counts).toEqual({ updates: 1, deletes: 1 }); + }); + + it("does not count an edit that is shadowed by a delete", () => { + const counts = countPending(session({ edits: { [keyOne]: { name: "a" } }, deletes: [keyOne] })); + expect(counts).toEqual({ updates: 0, deletes: 1 }); + }); + + it("does not count a row whose edits were reverted", () => { + expect(countPending(session({ edits: { [keyOne]: {} } }))).toEqual({ + updates: 0, + deletes: 0, + }); + }); +}); diff --git a/src/lib/mutations.ts b/src/lib/mutations.ts new file mode 100644 index 00000000..b83bca79 --- /dev/null +++ b/src/lib/mutations.ts @@ -0,0 +1,108 @@ +/** + * Pending grid row edits, keyed by row identity rather than grid position. + * + * Row indices are not a stable identity: they shift when a result is refreshed + * and they mean nothing at all against a different result set, which is how an + * edit marked in one tab could previously be applied to another table. A pending + * mutation is therefore keyed by the primary-key tuple captured at the moment it + * was marked, and that key is itself the serialized tuple, so the key and the + * values it stands for cannot drift apart. + */ + +import type { CellValue } from "@/lib/wire"; + +export type MutationKind = "update" | "delete"; + +export type KeyTuple = [string, CellValue][]; + +export interface RowMutation { + kind: MutationKind; + set: KeyTuple; + pk: KeyTuple; +} + +export interface MutationReport { + updated: number; + deleted: number; +} + +export interface EditSession { + schema: string; + table: string; + pkColumns: string[]; + /** pkKey -> column -> new value */ + edits: Record>; + /** pkKey list */ + deletes: string[]; +} + +export function emptySession(schema: string, table: string, pkColumns: string[]): EditSession { + return { schema, table, pkColumns, edits: {}, deletes: [] }; +} + +/** + * Identity of a row, or `null` when the row cannot be identified — a short row, + * a stale index, or a result that no longer carries every key column. Callers + * must treat `null` as "not editable" rather than falling back to a position. + */ +export function pkKey( + columns: string[], + row: CellValue[] | undefined, + pkColumns: string[], +): string | null { + if (!row || pkColumns.length === 0) return null; + + const tuple: KeyTuple = []; + for (const column of pkColumns) { + const index = columns.indexOf(column); + if (index === -1) return null; + const value = row[index]; + if (value === undefined) return null; + tuple.push([column, value]); + } + return JSON.stringify(tuple); +} + +export function parsePkKey(key: string): KeyTuple { + return JSON.parse(key) as KeyTuple; +} + +/** Identity for every row of a result, computed once per result. */ +export function rowKeys( + columns: string[], + rows: CellValue[][], + pkColumns: string[], +): (string | null)[] { + return rows.map((row) => pkKey(columns, row, pkColumns)); +} + +export function countPending(session: EditSession): { updates: number; deletes: number } { + const deletes = new Set(session.deletes); + const updates = Object.entries(session.edits).filter( + ([key, columns]) => !deletes.has(key) && Object.keys(columns).length > 0, + ).length; + return { updates, deletes: deletes.size }; +} + +/** + * Turn a session into the backend payload. Updates come first, and edits on a + * row that is also marked for deletion are dropped rather than sent — deleting + * a row makes its cell edits moot. + */ +export function buildMutations(session: EditSession): RowMutation[] { + const deletes = new Set(session.deletes); + const out: RowMutation[] = []; + + for (const [key, columns] of Object.entries(session.edits)) { + if (deletes.has(key)) continue; + const set = Object.entries(columns) as KeyTuple; + if (set.length === 0) continue; + out.push({ kind: "update", set, pk: parsePkKey(key) }); + } + + for (const key of session.deletes) { + out.push({ kind: "delete", set: [], pk: parsePkKey(key) }); + } + + return out; +} diff --git a/src/lib/sql-utils.ts b/src/lib/sql-utils.ts index 3411f2cf..5df0adc0 100644 --- a/src/lib/sql-utils.ts +++ b/src/lib/sql-utils.ts @@ -35,48 +35,3 @@ export function quoteLiteral(value: CellValue): string { if (value === null) return "NULL"; return `'${value.replace(/'/g, "''")}'`; } - -export function generateUpdate( - schema: string, - table: string, - columns: string[], - originalRow: CellValue[], - changes: Map, - pkColumns: string[], -): string { - const target = `${quoteIdent(schema)}.${quoteIdent(table)}`; - - const setClauses: string[] = []; - for (const [colIdx, newValue] of changes) { - setClauses.push(`${quoteIdent(columns[colIdx])} = ${quoteLiteral(newValue)}`); - } - - const where = buildPKWhere(columns, originalRow, pkColumns); - return `UPDATE ${target} SET ${setClauses.join(", ")} WHERE ${where}`; -} - -export function generateDelete( - schema: string, - table: string, - columns: string[], - originalRow: CellValue[], - pkColumns: string[], -): string { - const target = `${quoteIdent(schema)}.${quoteIdent(table)}`; - const where = buildPKWhere(columns, originalRow, pkColumns); - return `DELETE FROM ${target} WHERE ${where}`; -} - -function buildPKWhere(columns: string[], row: CellValue[], pkColumns: string[]): string { - return pkColumns - .map((pk) => { - const idx = columns.indexOf(pk); - if (idx === -1) return null; - const val = row[idx]; - return val === null - ? `${quoteIdent(pk)} IS NULL` - : `${quoteIdent(pk)} = ${quoteLiteral(val)}`; - }) - .filter(Boolean) - .join(" AND "); -} diff --git a/src/stores/tab-store.ts b/src/stores/tab-store.ts index fde6d142..39165749 100644 --- a/src/stores/tab-store.ts +++ b/src/stores/tab-store.ts @@ -1,6 +1,8 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; +import type { EditSession } from "@/lib/mutations"; +import type { CellValue } from "@/lib/wire"; import type { ExplainPlan, QueryResult, Tab, VirtualQuery } from "@/types"; let nextId = 1; @@ -38,6 +40,18 @@ interface TabState { setSplitResult: (index: number, result: QueryResult) => void; setSplitExecuting: (index: number, executing: boolean) => void; setQueryTimeout: (index: number, timeout: number) => void; + + startEditSession: (tabId: string, session: EditSession) => void; + discardEditSession: (tabId: string) => void; + setCellEdit: (tabId: string, key: string, column: string, value: CellValue | undefined) => void; + setRowDeleted: (tabId: string, key: string, deleted: boolean) => void; +} + +/// Edit-session actions address tabs by id, never by index: the index a caller +/// captured can point at a different tab by the time the action runs. +function withTab(state: TabState, tabId: string, apply: (tab: Tab) => void): void { + const tab = state.tabs.find((t) => t.id === tabId); + if (tab) apply(tab); } function makeSingletonTab( @@ -212,6 +226,52 @@ export const useTabStore = create()( set((s) => { s.tabs[index].queryTimeout = timeout; }), + + startEditSession: (tabId, session) => + set((s) => { + withTab(s, tabId, (tab) => { + tab.editSession = session; + }); + }), + + discardEditSession: (tabId) => + set((s) => { + withTab(s, tabId, (tab) => { + tab.editSession = undefined; + }); + }), + + // `undefined` means the cell was returned to its original value, so the + // pending edit is dropped rather than recorded as a no-op assignment. + setCellEdit: (tabId, key, column, value) => + set((s) => { + withTab(s, tabId, (tab) => { + const session = tab.editSession; + if (!session) return; + if (value === undefined) { + const columns = session.edits[key]; + if (!columns) return; + delete columns[column]; + if (Object.keys(columns).length === 0) delete session.edits[key]; + return; + } + session.edits[key] ??= {}; + session.edits[key][column] = value; + }); + }), + + setRowDeleted: (tabId, key, deleted) => + set((s) => { + withTab(s, tabId, (tab) => { + const session = tab.editSession; + if (!session) return; + const marked = session.deletes.includes(key); + if (deleted && !marked) session.deletes.push(key); + if (!deleted && marked) { + session.deletes = session.deletes.filter((k) => k !== key); + } + }); + }), })), { name: "rsql-tabs", diff --git a/src/types/index.ts b/src/types/index.ts index 1510bd0c..2e9d481d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,7 @@ +import type { EditSession } from "@/lib/mutations"; import type { CellValue } from "@/lib/wire"; -export type { CellValue }; +export type { CellValue, EditSession }; export interface ProjectDetails { driver: DriverType; @@ -50,6 +51,8 @@ export interface Tab { splitEditorValue?: string; splitResult?: QueryResult; isSplitExecuting?: boolean; + /** Pending inline row edits. Lives on the tab so it cannot leak across tabs. */ + editSession?: EditSession; } export interface ExplainNode { From 72268ebaeed1f0ab7867baa99260d6155d8149e6 Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Mon, 10 Aug 2026 13:05:52 +0200 Subject: [PATCH 04/20] fix: address tabs by id instead of index Every tab-store mutation took a position, and callers captured that position before awaiting a query. Closing or reordering a tab while a query ran therefore wrote the result into whichever tab now sat at that index, or threw on an index past the end. The throw happened inside the try block whose catch wrote to the same index and threw again, leaving an unhandled rejection and a tab stuck on "Executing query..." forever. Mutating actions now take a tab id and no-op when that tab is gone, and the query lifecycle captures the id before its first await and clears the executing flag in a finally. Selection still works by index; only addressing changed. Also routes two remaining raw separator splits in the query lifecycle through the wire decoder, so streamed and virtual results decode NULL and escaped separators like every other path. --- src/App.tsx | 11 +- src/components/command-palette/index.tsx | 3 +- src/components/editor-toolbar.tsx | 10 +- src/components/query-history.tsx | 5 +- src/components/results-panel/use-edit-mode.ts | 15 ++- src/components/top-bar.tsx | 5 +- src/hooks/use-query-lifecycle.ts | 77 ++++++------ src/stores/tab-store.ts | 119 +++++++++++------- 8 files changed, 141 insertions(+), 104 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e371e156..116b2829 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,7 +24,7 @@ import { useAppStartup } from "@/hooks/use-app-startup"; import { useQueryLifecycle } from "@/hooks/use-query-lifecycle"; import { checkForUpdates } from "@/lib/updater"; import { useProjectStore } from "@/stores/project-store"; -import { useActiveTab, useTabStore } from "@/stores/tab-store"; +import { useActiveTab, useActiveTabId, useTabStore } from "@/stores/tab-store"; import { useUIStore } from "@/stores/ui-store"; import type { ProjectDetails } from "@/types"; import "@/monaco/setup"; @@ -40,8 +40,7 @@ export default function App() { const projects = useProjectStore((s) => s.projects); const saveConnection = useProjectStore((s) => s.saveConnection); const updateConnection = useProjectStore((s) => s.updateConnection); - - const selectedTabIndex = useTabStore((s) => s.selectedTabIndex); + const activeTabId = useActiveTabId(); const activeTab = useActiveTab(); const updateContent = useTabStore((s) => s.updateContent); @@ -206,7 +205,7 @@ export default function App() { > updateContent(selectedTabIndex, v)} + onChange={(v) => activeTabId && updateContent(activeTabId, v)} onExecute={() => void runQuery()} onExplain={() => void runExplain()} /> @@ -225,7 +224,7 @@ export default function App() { - useTabStore.getState().updateSplitContent(selectedTabIndex, v) + activeTabId && useTabStore.getState().updateSplitContent(activeTabId, v) } onExecute={() => void runSplitQuery()} /> @@ -270,7 +269,7 @@ export default function App() {
updateContent(selectedTabIndex, v)} + onChange={(v) => activeTabId && updateContent(activeTabId, v)} onExecute={() => void runQuery()} onExplain={() => void runExplain()} /> diff --git a/src/components/command-palette/index.tsx b/src/components/command-palette/index.tsx index 786ebb96..e23083e2 100644 --- a/src/components/command-palette/index.tsx +++ b/src/components/command-palette/index.tsx @@ -177,7 +177,8 @@ export function CommandPalette({ tabWidth: 2, keywordCase: "upper", }); - useTabStore.getState().updateContent(idx, formatted); + const tabId = useTabStore.getState().tabs[idx]?.id; + if (tabId) useTabStore.getState().updateContent(tabId, formatted); } catch { /* ignore */ } diff --git a/src/components/editor-toolbar.tsx b/src/components/editor-toolbar.tsx index 6767b26c..52ccebff 100644 --- a/src/components/editor-toolbar.tsx +++ b/src/components/editor-toolbar.tsx @@ -12,7 +12,7 @@ import { import { Input } from "@/components/ui/input"; import { useProjectStore } from "@/stores/project-store"; import { useQueryStore } from "@/stores/query-store"; -import { useActiveTab, useTabStore } from "@/stores/tab-store"; +import { tabIdAt, useActiveTab, useActiveTabId, useTabStore } from "@/stores/tab-store"; const TIMEOUT_OPTIONS = [ { label: "No limit", value: 0 }, @@ -35,6 +35,7 @@ export function EditorToolbar({ }) { const activeTab = useActiveTab(); const selectedTabIndex = useTabStore((s) => s.selectedTabIndex); + const activeTabId = useActiveTabId(); const updateContent = useTabStore((s) => s.updateContent); const toggleSplit = useTabStore((s) => s.toggleSplit); const setQueryTimeout = useTabStore((s) => s.setQueryTimeout); @@ -75,7 +76,8 @@ export function EditorToolbar({ tabWidth: 2, keywordCase: "upper", }); - updateContent(selectedTabIndex, formatted); + const tabId = tabIdAt(selectedTabIndex); + if (tabId) updateContent(tabId, formatted); } catch { // silently ignore formatting errors } @@ -112,7 +114,7 @@ export function EditorToolbar({ variant={activeTab?.isSplit ? "outline" : "ghost"} size="sm" className="h-7 gap-1.5 text-xs px-2" - onClick={() => toggleSplit(selectedTabIndex)} + onClick={() => activeTabId && toggleSplit(activeTabId)} title="Toggle split editor" > @@ -125,7 +127,7 @@ export function EditorToolbar({