diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c235f0de..153677e9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -47,7 +47,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/CHANGELOG.md b/CHANGELOG.md index 679a62024..35613431a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## Unreleased +- [BE] ๐Ÿ”’ **Cryptography 50+ ๋ณด์•ˆ ๊ฒฝ๊ณ„ ๊ฐฑ์‹ **: `pyproject.toml`๊ณผ ๋‘ hash-locked ์š”๊ตฌ์‚ฌํ•ญ ํŒŒ์ผ์„ ๋™์ผํ•œ Cryptography 50+ ํ•ด์„์œผ๋กœ ์ •ํ•ฉํ™”ํ•˜์—ฌ PKCS#7 ์˜ค๋ฅ˜ยทํƒ€์ด๋ฐ ๊ตฌ๋ถ„์œผ๋กœ ์ธํ•œ CVE-2026-69247 ์™„ํ™”๋ฅผ ์‹ค์ œ ์„ค์น˜ยท๊ฒ€์ฆ ๊ฒฝ๋กœ์— ๋ฐ˜์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. +- [FE] โšก **๊ฒ€์ƒ‰ ๋…ธ๋“œ ์ฐธ์กฐ ์•ˆ์ •ํ™” ๋ฐ ์ˆœ์ฐจ ์Šค๋ƒ…์ƒท ํด๋ง**: ๊ฐ™์€ ์ •๊ทœํ™” ๊ฒ€์ƒ‰์–ด์™€ ์›๋ณธ ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ์—๋Š” ์žฅ์‹๋œ `node.data` ์ฐธ์กฐ๋ฅผ ์žฌ์‚ฌ์šฉํ•˜์—ฌ ๋“œ๋ž˜๊ทธ ์ค‘ ๋ถˆํ•„์š”ํ•œ ํ•˜์œ„ ๋ Œ๋”๋ง๊ณผ ํ• ๋‹น์„ ์ค„์ž…๋‹ˆ๋‹ค. ์Šค๋ƒ…์ƒท ํด๋ง์€ ์ด์ „ ์š”์ฒญ์ด ๋๋‚œ ๋’ค์—๋งŒ ๋‹ค์Œ ์š”์ฒญ์„ ์˜ˆ์•ฝํ•˜๋ฉฐ, ์„ ํƒ ๋ณ€๊ฒฝยท์–ธ๋งˆ์šดํŠธ ํ›„ ๋„์ฐฉํ•œ ์˜ค๋ž˜๋œ ์„ฑ๊ณต ๋˜๋Š” ์‹คํŒจ ์‘๋‹ต์„ ๋ฌด์‹œํ•ฉ๋‹ˆ๋‹ค. - [BE] ๐Ÿ”’ **๊ณต์œ  export ์ „ ๊ฒฝ๋กœ redaction**: ๊ณต๊ฐœ share์˜ SQL / index-design / reversing-spec export์—์„œ ์ฝ”๋ฉ˜ํŠธยท`example_value`๋ฅผ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋กœ ๋ˆ„์ถœ์„ ์ฐจ๋‹จํ•ฉ๋‹ˆ๋‹ค. - [BE] ๐Ÿ› ๏ธ **ํ•จ์ˆ˜ ์ธ๋ฑ์Šค ์ค‘๋ณต ์˜คํƒ ์ˆ˜์ •**: `lower(email)` ๋“ฑ expression index๋ฅผ ํ‰๋ฌธ ์ปฌ๋Ÿผ ์ธ๋ฑ์Šค์˜ ์ค‘๋ณต์œผ๋กœ ์ž˜๋ชป ํŒ๋‹จํ•˜์ง€ ์•Š๋„๋ก ๊ด„ํ˜ธ ํŒŒ์„œ๋ฅผ ๊ฐ•ํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค. - [Docs] README๋ฅผ ์ƒ์šฉ ๊ธฐ์ค€ ๊ธฐ๋Šฅ ์„ค๋ช…์œผ๋กœ ๊ฐฑ์‹  (MVP skeleton ํ‘œํ˜„ ์ œ๊ฑฐ, share redactionยทdiff/export ๋ฐ˜์˜). diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ed076d534..b2d47dd2a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "asyncpg>=0.31.0", "psycopg[binary]>=3.3.4", "alembic>=1.18.5", - "cryptography>=46.0.7", + "cryptography>=50.0.0", "httpx>=0.28.1", "python-jose[cryptography]>=3.5.0", "redis>=5.0.0", diff --git a/backend/requirements-dev.lock b/backend/requirements-dev.lock index a5a77b940..94ebb2dbe 100644 --- a/backend/requirements-dev.lock +++ b/backend/requirements-dev.lock @@ -544,53 +544,53 @@ coverage==7.15.2 \ --hash=sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243 \ --hash=sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a # via pytest-cov -cryptography==49.0.0 \ - --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ - --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ - --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ - --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ - --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ - --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ - --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ - --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ - --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ - --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ - --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ - --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ - --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ - --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ - --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ - --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ - --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ - --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ - --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ - --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ - --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ - --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ - --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ - --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ - --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ - --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ - --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ - --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ - --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ - --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ - --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ - --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ - --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ - --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ - --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ - --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ - --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ - --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ - --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ - --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ - --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ - --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ - --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ - --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ - --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ - --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 # via # pg-erd-cloud-backend (backend/pyproject.toml) # python-jose diff --git a/backend/requirements.lock b/backend/requirements.lock index a333302c7..6b0a1ef79 100644 --- a/backend/requirements.lock +++ b/backend/requirements.lock @@ -405,53 +405,53 @@ charset-normalizer==3.4.9 \ --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 # via requests -cryptography==49.0.0 \ - --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ - --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ - --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ - --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ - --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ - --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ - --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ - --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ - --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ - --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ - --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ - --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ - --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ - --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ - --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ - --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ - --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ - --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ - --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ - --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ - --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ - --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ - --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ - --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ - --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ - --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ - --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ - --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ - --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ - --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ - --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ - --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ - --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ - --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ - --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ - --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ - --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ - --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ - --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ - --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ - --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ - --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ - --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ - --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ - --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ - --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 # via # pg-erd-cloud-backend (backend/pyproject.toml) # python-jose diff --git a/docs/doctoring/search-identity-and-sequential-polling.md b/docs/doctoring/search-identity-and-sequential-polling.md new file mode 100644 index 000000000..0e0ee1900 --- /dev/null +++ b/docs/doctoring/search-identity-and-sequential-polling.md @@ -0,0 +1,39 @@ +# Search identity and sequential snapshot polling + +## Decision + +The ERD canvas treats the immutable `TableNodeData` object as the identity of the source table payload. While a normalized search query is active, a query-scoped `WeakMap` stores the derived highlight/dim payload. Re-rendering with the same source object reuses the exact derived reference; changing the normalized query replaces the cache, and replacing the source payload produces a new derived object. + +Snapshot status polling is one sequential asynchronous process per active `(selectedProjectId, snapshotId)` effect. The first request starts immediately. A non-terminal response schedules one `setTimeout` only after the request completes. Terminal states stop polling and may refresh the snapshot list. Cleanup marks the process obsolete and clears the pending timeout, so late success, refresh, or rejection continuations cannot update the current view. + +## Why + +React Flow can emit frequent position-only node updates. Reallocating every derived `node.data` object during those updates defeats reference-sensitive memoization below the canvas and creates garbage unrelated to actual table-content changes. `WeakMap` keys use object identity and do not keep otherwise unreachable key objects alive, which fits a cache whose lifetime follows the source payload. + +A fixed `setInterval` can start another request while the prior request is unresolved. Network responses are not guaranteed to complete in issue order, so an older non-terminal result can overwrite a newer terminal result. React's effect guidance explicitly recommends cleanup-scoped invalidation for manually fetched data because responses may arrive out of order. Completion-scheduled `setTimeout` polling additionally guarantees at most one in-flight status request per effect generation. + +## Invariants + +- The source `node.data` object is never mutated with search-only state. +- Equivalent normalized queries and position-only updates reuse the derived data reference. +- A changed query or changed source data object yields a fresh derived reference. +- At most one `getSnapshot` call is in flight for one effect generation. +- `succeeded`, `failed`, and `not_found` stop future status requests. +- Dependency change or unmount invalidates all pending continuations before they can publish snapshot, list, or error state. +- Polling errors remain visible only for the still-current snapshot process. + +## Verification + +`frontend/src/App.searchPolling.test.tsx` observes the `ReactFlow` node payload rather than implementation internals. It asserts reference identity with `toBe`, drives a position-only update and a source-data replacement, exercises reversed response order, rejects a superseded request with sensitive detail, and uses controlled timers to prove non-overlap and terminal shutdown. Repository CI remains authoritative for npm-only type checking, complete statement/branch/function/line coverage, and the production build. + +## Operational monitoring and rollback + +Monitor snapshot-status request concurrency, terminal-to-render latency, stale-response suppression, browser memory growth during long search/drag sessions, and frontend error rates. Roll back by restoring the prior uncached derivation and interval loop only if a verified regression requires it; doing so reopens the documented allocation and race risks and therefore requires a replacement isolation design and regression evidence. + +## References + +MDN Web Docs contributors. (2026). *WeakMap*. Mozilla. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap + +React Team. (n.d.). *useEffect*. React. Retrieved August 7, 2026, from https://react.dev/reference/react/useEffect + +Web Hypertext Application Technology Working Group. (2026, July 13). *HTML Standard: Timers*. https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 891bcd09c..ee2736077 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,13 +16,13 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/react": "^19.2.10", + "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitest/coverage-v8": "^4.1.9", "fast-check": "^4.8.0", "jsdom": "^30.0.1", "typescript": "^6.0.3", - "vite": "^8.2.0", + "vite": "^8.2.1", "vitest": "^4.1.10" }, "engines": { @@ -898,9 +898,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2429,16 +2429,16 @@ } }, "node_modules/vite": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", - "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.23", - "rolldown": "~1.2.0", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/frontend/package.json b/frontend/package.json index f179f4570..14cc671ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,13 +25,13 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/react": "^19.2.10", + "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitest/coverage-v8": "^4.1.9", "fast-check": "^4.8.0", "jsdom": "^30.0.1", "typescript": "^6.0.3", - "vite": "^8.2.0", + "vite": "^8.2.1", "vitest": "^4.1.10" }, "overrides": { diff --git a/frontend/src/App.searchPolling.test.tsx b/frontend/src/App.searchPolling.test.tsx new file mode 100644 index 000000000..6c48313fc --- /dev/null +++ b/frontend/src/App.searchPolling.test.tsx @@ -0,0 +1,290 @@ +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const api = vi.hoisted(() => ({ + getMe: vi.fn(), + listProjects: vi.fn(), + listConnections: vi.fn(), + listSnapshots: vi.fn(), + createProject: vi.fn(), + createConnection: vi.fn(), + createSnapshot: vi.fn(), + getSnapshot: vi.fn(), + createShareLink: vi.fn(), +})) + +type CapturedNode = { + id: string + position: { x: number; y: number } + data: Record +} + +const flowCapture = vi.hoisted(() => ({ + renders: [] as CapturedNode[][], + setNodes: undefined as + | ((update: CapturedNode[] | ((current: CapturedNode[]) => CapturedNode[])) => void) + | undefined, +})) + +vi.mock('./api', () => api) +vi.mock('./erd/TableNode', () => ({ default: () => null })) +vi.mock('./erd/export', () => ({ + downloadText: vi.fn(), + exportDDL: vi.fn(() => ''), + exportDiagramSvg: vi.fn(() => ''), + exportDictionaryCsv: vi.fn(() => ''), + exportDictionaryMarkdown: vi.fn(() => ''), + exportPlantUml: vi.fn(() => ''), +})) +vi.mock('./erd/mermaid', () => ({ exportMermaid: vi.fn(() => '') })) +vi.mock('./erd/dbml', () => ({ exportDbml: vi.fn(() => '') })) +vi.mock('./erd/prisma', () => ({ exportPrisma: vi.fn(() => '') })) +vi.mock('./erd/autoInfer', () => ({ inferRelationships: vi.fn(() => []) })) +vi.mock('./components/modals', () => ({ + AddTableModal: () => null, + CardinalityModal: () => null, + EditEdgeModal: () => null, + EditTableModal: () => null, + ExportModal: () => null, + GroupModal: () => null, +})) + +vi.mock('@xyflow/react', async () => { + const React = await import('react') + return { + Background: () => null, + Controls: () => null, + MiniMap: () => null, + ReactFlow: (props: { nodes: CapturedNode[]; children?: React.ReactNode }) => { + flowCapture.renders.push(props.nodes) + return ( +
+ {props.nodes.map((node) => {String(node.data.title)})} + {props.children} +
+ ) + }, + addEdge: (edge: unknown, edges: unknown[]) => [...edges, edge], + useNodesState: (initial: CapturedNode[]) => { + const [nodes, setNodes] = React.useState(initial) + flowCapture.setNodes = setNodes as typeof flowCapture.setNodes + return [nodes, setNodes, vi.fn()] + }, + useEdgesState: (initial: unknown[]) => { + const [edges, setEdges] = React.useState(initial) + return [edges, setEdges, vi.fn()] + }, + } +}) + +const graphData = vi.hoisted(() => ({ + firstUsers: { + title: 'public.users', + columns: [{ column_name: 'id', data_type: 'bigint', is_not_null: true, is_pk: true }], + badges: { pk: true, fk: false }, + }, + firstOrders: { + title: 'public.orders', + columns: [{ column_name: 'user_id', data_type: 'bigint', is_not_null: true, is_pk: false }], + badges: { pk: false, fk: true }, + }, + secondAccounts: { + title: 'public.accounts', + columns: [{ column_name: 'account_id', data_type: 'bigint', is_not_null: true, is_pk: true }], + badges: { pk: true, fk: false }, + }, +})) + +vi.mock('./erd/convert', () => ({ + snapshotToGraph: vi.fn((snapshotJson: { marker?: string }) => snapshotJson.marker === 'second' + ? { nodes: [{ id: 'accounts', type: 'tableNode', position: { x: 0, y: 0 }, data: graphData.secondAccounts }], edges: [] } + : { + nodes: [ + { id: 'users', type: 'tableNode', position: { x: 0, y: 0 }, data: graphData.firstUsers }, + { id: 'orders', type: 'tableNode', position: { x: 200, y: 0 }, data: graphData.firstOrders }, + ], + edges: [], + }), +})) + +import App from './App' + +const projects = [{ project_space_uuid: 'project-one', project_name: 'Project One' }] +const snapshots = [ + { schema_snapshot_uuid: 'snapshot-one', status: 'running', schema_filter: null }, + { schema_snapshot_uuid: 'snapshot-two', status: 'succeeded', schema_filter: null }, +] + +function detail(id: string, marker: string, status = 'succeeded') { + return { + schema_snapshot_uuid: id, + status, + schema_filter: null, + error_message: null, + snapshot_json: { marker, relations: [], columns: [], pk_columns: [], fk_edges: [] }, + } +} + +async function renderReadyApp() { + render() + await waitFor(() => expect(api.listSnapshots).toHaveBeenCalledWith('project-one')) +} + +async function diagramOpenButtons() { + fireEvent.click(screen.getByRole('button', { name: '๋‹ค์ด์–ด๊ทธ๋žจ' })) + return screen.findAllByRole('button', { name: '์—ด๊ธฐ' }) +} + +async function openSnapshot(index: number) { + const openButtons = await diagramOpenButtons() + fireEvent.click(openButtons[index]!) + await waitFor(() => expect(api.getSnapshot).toHaveBeenCalled()) +} + +function lastNodeData(nodeId: string): Record { + const data = flowCapture.renders.at(-1)?.find((node) => node.id === nodeId)?.data + if (!data) throw new Error(`No rendered data captured for ${nodeId}`) + return data +} + +describe('App search identity and polling isolation', () => { + beforeEach(() => { + vi.clearAllMocks() + flowCapture.renders.length = 0 + flowCapture.setNodes = undefined + api.getMe.mockResolvedValue({ subject: 'user-one', display_name: 'User One' }) + api.listProjects.mockResolvedValue(projects) + api.listConnections.mockResolvedValue([]) + api.listSnapshots.mockResolvedValue(snapshots) + api.createShareLink.mockResolvedValue({ url: 'https://example.test/share' }) + }) + + afterEach(() => { + vi.useRealTimers() + cleanup() + }) + + it('preserves decorated data identity for normalized-query and position-only updates', async () => { + api.getSnapshot.mockResolvedValue(detail('snapshot-one', 'first')) + await renderReadyApp() + await openSnapshot(0) + await screen.findByText('public.users') + + const search = screen.getByLabelText('ํ…Œ์ด๋ธ” ๋˜๋Š” ์ปฌ๋Ÿผ ๊ฒ€์ƒ‰') + fireEvent.change(search, { target: { value: 'users' } }) + await waitFor(() => expect(lastNodeData('users').isHighlighted).toBe(true)) + const firstDecorated = lastNodeData('users') + + fireEvent.change(search, { target: { value: ' users ' } }) + await waitFor(() => expect(search).toHaveValue(' users ')) + expect(lastNodeData('users')).toBe(firstDecorated) + + await act(async () => { + flowCapture.setNodes?.((current) => current.map((node) => ( + node.id === 'users' + ? { ...node, position: { x: node.position.x + 25, y: node.position.y } } + : node + ))) + }) + expect(lastNodeData('users')).toBe(firstDecorated) + + await act(async () => { + flowCapture.setNodes?.((current) => current.map((node) => ( + node.id === 'users' ? { ...node, data: { ...node.data } } : node + ))) + }) + const replacedSourceData = lastNodeData('users') + expect(replacedSourceData).not.toBe(firstDecorated) + + fireEvent.change(search, { target: { value: 'orders' } }) + await waitFor(() => expect(lastNodeData('orders').isHighlighted).toBe(true)) + expect(lastNodeData('users')).not.toBe(replacedSourceData) + expect(graphData.firstUsers).not.toHaveProperty('isHighlighted') + }) + + it('ignores a terminal response from a superseded snapshot request', async () => { + let resolveFirst!: (value: ReturnType) => void + api.getSnapshot.mockImplementation((snapshotId: string) => { + if (snapshotId === 'snapshot-one') { + return new Promise((resolve) => { resolveFirst = resolve }) + } + return Promise.resolve(detail('snapshot-two', 'second')) + }) + + await renderReadyApp() + await openSnapshot(0) + await waitFor(() => expect(api.getSnapshot).toHaveBeenCalledWith('snapshot-one')) + + await openSnapshot(1) + await screen.findByText('public.accounts') + const refreshCountAfterCurrentTerminal = api.listSnapshots.mock.calls.length + + await act(async () => { + resolveFirst(detail('snapshot-one', 'first')) + await Promise.resolve() + }) + + expect(screen.getByText('public.accounts')).toBeInTheDocument() + expect(screen.queryByText('public.users')).not.toBeInTheDocument() + expect(api.listSnapshots).toHaveBeenCalledTimes(refreshCountAfterCurrentTerminal) + }) + + it('does not publish a stale polling rejection after the selected snapshot changes', async () => { + let rejectFirst!: (reason: Error) => void + api.getSnapshot.mockImplementation((snapshotId: string) => { + if (snapshotId === 'snapshot-one') { + return new Promise((_, reject) => { rejectFirst = reject }) + } + return Promise.resolve(detail('snapshot-two', 'second')) + }) + + await renderReadyApp() + await openSnapshot(0) + await openSnapshot(1) + await screen.findByText('public.accounts') + + await act(async () => { + rejectFirst(new Error('stale polling failure with secret detail')) + await Promise.resolve() + }) + + expect(screen.getByText('public.accounts')).toBeInTheDocument() + expect(screen.queryByText(/stale polling failure with secret detail/i)).not.toBeInTheDocument() + }) + + it('waits for each non-terminal request before scheduling the next poll', async () => { + let resolveFirst!: (value: ReturnType) => void + let resolveSecond!: (value: ReturnType) => void + api.getSnapshot + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + + await renderReadyApp() + const openButtons = await diagramOpenButtons() + vi.useFakeTimers() + + fireEvent.click(openButtons[0]!) + await act(async () => { await Promise.resolve() }) + expect(api.getSnapshot).toHaveBeenCalledTimes(1) + + await act(async () => { await vi.advanceTimersByTimeAsync(2000) }) + expect(api.getSnapshot).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveFirst(detail('snapshot-one', 'first', 'running')) + await Promise.resolve() + }) + await act(async () => { await vi.advanceTimersByTimeAsync(999) }) + expect(api.getSnapshot).toHaveBeenCalledTimes(1) + await act(async () => { await vi.advanceTimersByTimeAsync(1) }) + expect(api.getSnapshot).toHaveBeenCalledTimes(2) + + await act(async () => { + resolveSecond(detail('snapshot-one', 'first')) + await Promise.resolve() + }) + await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) + expect(api.getSnapshot).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0fa62ede3..49812e448 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -198,20 +198,30 @@ export default function App() { const searchMatchedNodeIds = useMemo(() => { return findSearchMatchedNodeIds(nodes, normalizedNodeSearch); }, [nodes, normalizedNodeSearch]); + + // โšก Bolt: Cache decorated search state to preserve node.data identity during 60fps drag updates + const searchCache = useMemo(() => new WeakMap(), [normalizedNodeSearch]); + const visibleNodes = useMemo(() => { if (!normalizedNodeSearch) return nodes; + return nodes.map((node) => { - const isHighlighted = searchMatchedNodeIds.has(node.id); - return { - ...node, - data: { + let cachedData = searchCache.get(node.data); + if (!cachedData) { + const isHighlighted = searchMatchedNodeIds.has(node.id); + cachedData = { ...node.data, isDimmed: !isHighlighted, isHighlighted, - }, + }; + searchCache.set(node.data, cachedData); + } + return { + ...node, + data: cachedData, }; }); - }, [nodes, normalizedNodeSearch, searchMatchedNodeIds]); + }, [nodes, normalizedNodeSearch, searchMatchedNodeIds, searchCache]); const nodeSearchStatus = normalizedNodeSearch ? `${searchMatchedNodeIds.size}๊ฐœ ํ…Œ์ด๋ธ” ์ผ์น˜` : ""; @@ -309,22 +319,46 @@ export default function App() { useEffect(() => { if (!snapshotId) return; - const timer = setInterval(() => { - getSnapshot(snapshotId) - .then((s) => { - setSnapshot(s); - if (s.status === "succeeded" || s.status === "failed" || s.status === "not_found") { - clearInterval(timer); - if (selectedProjectId) { - listSnapshots(selectedProjectId) - .then(setSnapshots) - .catch((e) => setError(String(e))); + let isCurrent = true; + let timer: number | null = null; + + async function poll() { + try { + const s = await getSnapshot(snapshotId as string); + if (!isCurrent) return; + setSnapshot(s); + + if (s.status === "succeeded" || s.status === "failed" || s.status === "not_found") { + if (selectedProjectId) { + try { + const snaps = await listSnapshots(selectedProjectId); + if (isCurrent) setSnapshots(snaps); + } catch (e) { + if (isCurrent) setError(String(e)); } } - }) - .catch((e) => setError(String(e))); - }, 1000); - return () => clearInterval(timer); + return; + } + + if (isCurrent) { + timer = window.setTimeout(poll, 1000); + } + } catch (e) { + if (isCurrent) { + setError(String(e)); + timer = window.setTimeout(poll, 1000); + } + } + } + + poll(); + + return () => { + isCurrent = false; + if (timer !== null) { + clearTimeout(timer); + } + }; }, [selectedProjectId, snapshotId]); const graph = useMemo(() => {