From f48da3cff3017f180edf27805942da50434616f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Sm=C3=B3=C5=82ka?= Date: Fri, 25 Oct 2024 11:07:38 +0200 Subject: [PATCH 01/10] CI: Run short tests first --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7851062b6..5a64ced96 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,9 +43,9 @@ jobs: - run: cat .env >> $GITHUB_ENV || true - run: make up - run: make wait + - run: make test_short - run: make test timeout-minutes: 30 - - run: make test_short - run: make test_race - run: make test_stress if: ${{ inputs.stress-tests }} From c32fdc44caf4d87ad240e02ea69b044cb330e8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Sm=C3=B3=C5=82ka?= Date: Fri, 25 Oct 2024 14:13:30 +0200 Subject: [PATCH 02/10] Requeuer and Message Delay (#469) * Added the `Requeuer` component. * It works as a simpler version of the Forwarder, routing messages from one topic to another (a dynamic one). * Can be used to move messages that failed to process back to the original topic, so they don't block other messages. * Can be used together with the poison middleware and the `delay` component to delay the forwarding. * Added the `delay` package. It contains helpers for setting delay metadata on messages. * **Does not do anything by itself.** A Pub/Sub needs to support it explicitly. For now, that's the delayed postgres Pub/Sub implemented in https://github.com/ThreeDotsLabs/watermill-sql/pull/34 * Use case 1: publishing a message after a given delay or at given time (see the example). * Use case 2: automatically moving messages out of the poison queue to the original topic after a delay (used together with the `Requeuer` component). * Added the `pq` CLI tool for working with poison queues. --- .../delayed-messages/docker-compose.yml | 25 ++ .../delayed-messages/go.mod | 32 ++ .../delayed-messages/go.sum | 144 +++++++ .../delayed-messages/main.go | 221 ++++++++++ .../delayed-requeue/docker-compose.yml | 25 ++ .../delayed-requeue/go.mod | 33 ++ .../delayed-requeue/go.sum | 146 +++++++ .../delayed-requeue/main.go | 198 +++++++++ components/delay/delay.go | 68 +++ components/delay/publisher.go | 83 ++++ components/delay/publisher_test.go | 178 ++++++++ components/requeuer/requeuer.go | 158 +++++++ components/requeuer/requeuer_test.go | 102 +++++ docs/build.sh | 3 + docs/content/advanced/delayed-messages.md | 43 ++ docs/hugo_stats.json | 13 + message/router/middleware/delay_on_error.go | 47 ++ .../router/middleware/delay_on_error_test.go | 58 +++ message/router/middleware/retry_test.go | 6 +- tools/pq/README.md | 38 ++ tools/pq/backend/postgres.go | 99 +++++ tools/pq/cli/backend.go | 32 ++ tools/pq/cli/message.go | 45 ++ tools/pq/cli/model.go | 403 ++++++++++++++++++ tools/pq/go.mod | 44 ++ tools/pq/go.sum | 90 ++++ tools/pq/main.go | 51 +++ 27 files changed, 2381 insertions(+), 4 deletions(-) create mode 100644 _examples/real-world-examples/delayed-messages/docker-compose.yml create mode 100644 _examples/real-world-examples/delayed-messages/go.mod create mode 100644 _examples/real-world-examples/delayed-messages/go.sum create mode 100644 _examples/real-world-examples/delayed-messages/main.go create mode 100644 _examples/real-world-examples/delayed-requeue/docker-compose.yml create mode 100644 _examples/real-world-examples/delayed-requeue/go.mod create mode 100644 _examples/real-world-examples/delayed-requeue/go.sum create mode 100644 _examples/real-world-examples/delayed-requeue/main.go create mode 100644 components/delay/delay.go create mode 100644 components/delay/publisher.go create mode 100644 components/delay/publisher_test.go create mode 100644 components/requeuer/requeuer.go create mode 100644 components/requeuer/requeuer_test.go create mode 100644 docs/content/advanced/delayed-messages.md create mode 100644 message/router/middleware/delay_on_error.go create mode 100644 message/router/middleware/delay_on_error_test.go create mode 100644 tools/pq/README.md create mode 100644 tools/pq/backend/postgres.go create mode 100644 tools/pq/cli/backend.go create mode 100644 tools/pq/cli/message.go create mode 100644 tools/pq/cli/model.go create mode 100644 tools/pq/go.mod create mode 100644 tools/pq/go.sum create mode 100644 tools/pq/main.go diff --git a/_examples/real-world-examples/delayed-messages/docker-compose.yml b/_examples/real-world-examples/delayed-messages/docker-compose.yml new file mode 100644 index 000000000..b8310f17c --- /dev/null +++ b/_examples/real-world-examples/delayed-messages/docker-compose.yml @@ -0,0 +1,25 @@ +services: + server: + image: golang:1.23 + restart: unless-stopped + volumes: + - .:/app + - $GOPATH/pkg/mod:/go/pkg/mod + working_dir: /app + command: go run main.go + + redis: + image: redis:7 + ports: + - 6379:6379 + restart: unless-stopped + + postgres: + image: postgres:15 + restart: unless-stopped + ports: + - 5432:5432 + environment: + POSTGRES_USER: watermill + POSTGRES_DB: watermill + POSTGRES_PASSWORD: "password" diff --git a/_examples/real-world-examples/delayed-messages/go.mod b/_examples/real-world-examples/delayed-messages/go.mod new file mode 100644 index 000000000..ee8d58d6d --- /dev/null +++ b/_examples/real-world-examples/delayed-messages/go.mod @@ -0,0 +1,32 @@ +module delayed-messsages + +go 1.23.0 + +require ( + github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241024100330-cb068b72e948 + github.com/ThreeDotsLabs/watermill-redisstream v1.4.2 + github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024102321-584a6f7dab93 + github.com/brianvoe/gofakeit/v6 v6.28.0 + github.com/google/uuid v1.6.0 + github.com/lib/pq v1.10.2 + github.com/redis/go-redis/v9 v9.6.1 +) + +require ( + github.com/Rican7/retry v0.3.1 // indirect + github.com/cenkalti/backoff/v3 v3.2.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/lithammer/shortuuid/v3 v3.0.7 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/sony/gobreaker v1.0.0 // indirect + github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) diff --git a/_examples/real-world-examples/delayed-messages/go.sum b/_examples/real-world-examples/delayed-messages/go.sum new file mode 100644 index 000000000..841fdf6a2 --- /dev/null +++ b/_examples/real-world-examples/delayed-messages/go.sum @@ -0,0 +1,144 @@ +github.com/Rican7/retry v0.3.1 h1:scY4IbO8swckzoA/11HgBwaZRJEyY9vaNJshcdhp1Mc= +github.com/Rican7/retry v0.3.1/go.mod h1:CxSDrhAyXmTMeEuRAnArMu1FHu48vtfjLREWqVl7Vw0= +github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241024100330-cb068b72e948 h1:b8qRHpWtlO94x6dVzSulrO2znSQqz8iYsxUyrdTixHo= +github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241024100330-cb068b72e948/go.mod h1:lBnrLbxOjeMRgcJbv+UiZr8Ylz8RkJ4m6i/VN/Nk+to= +github.com/ThreeDotsLabs/watermill-redisstream v1.4.2 h1:FY6tsBcbhbJpKDOssU4bfybstqY0hQHwiZmVq9qyILQ= +github.com/ThreeDotsLabs/watermill-redisstream v1.4.2/go.mod h1:69++855LyB+ckYDe60PiJLBcUrpckfDE2WwyzuVJRCk= +github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024101952-75257d7d0602 h1:CKdW3wb3+C36mMa44DF53KUyM5L6mGOjI3hikBOlAl4= +github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024101952-75257d7d0602/go.mod h1:GMWcpauehgI40EeoKPxLnXBWjT7oOm7dJfzk5uU4IOc= +github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024102321-584a6f7dab93 h1:KeRk2EG5AtdxfpjqIVPigZqscMvIcy0E2h8k7y38OAE= +github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024102321-584a6f7dab93/go.mod h1:GMWcpauehgI40EeoKPxLnXBWjT7oOm7dJfzk5uU4IOc= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= +github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= +github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= +github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw= +github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgx/v4 v4.18.2 h1:xVpYkNR5pk5bMCZGfClbO962UIqVABcAGt7ha1s/FeU= +github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= +github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= +github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= +github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= +github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg= +golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/_examples/real-world-examples/delayed-messages/main.go b/_examples/real-world-examples/delayed-messages/main.go new file mode 100644 index 000000000..520235274 --- /dev/null +++ b/_examples/real-world-examples/delayed-messages/main.go @@ -0,0 +1,221 @@ +package main + +import ( + "context" + stdSQL "database/sql" + "fmt" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/google/uuid" + _ "github.com/lib/pq" + "github.com/redis/go-redis/v9" + + "github.com/ThreeDotsLabs/watermill" + "github.com/ThreeDotsLabs/watermill-redisstream/pkg/redisstream" + "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" + "github.com/ThreeDotsLabs/watermill/components/cqrs" + "github.com/ThreeDotsLabs/watermill/components/delay" + "github.com/ThreeDotsLabs/watermill/components/forwarder" + "github.com/ThreeDotsLabs/watermill/message" +) + +func main() { + db, err := stdSQL.Open("postgres", "postgres://watermill:password@postgres:5432/watermill?sslmode=disable") + if err != nil { + panic(err) + } + + logger := watermill.NewStdLogger(false, false) + + redisClient := redis.NewClient(&redis.Options{Addr: "redis:6379"}) + marshaler := cqrs.JSONMarshaler{ + GenerateName: cqrs.StructName, + } + + redisPublisher, err := redisstream.NewPublisher(redisstream.PublisherConfig{ + Client: redisClient, + }, logger) + if err != nil { + panic(err) + } + + var sqlPublisher message.Publisher + sqlPublisher, err = sql.NewDelayedPostgreSQLPublisher(db, sql.DelayedPostgreSQLPublisherConfig{ + DelayPublisherConfig: delay.PublisherConfig{}, + Logger: logger, + }) + if err != nil { + panic(err) + } + + sqlPublisher = forwarder.NewPublisher(sqlPublisher, forwarder.PublisherConfig{ + ForwarderTopic: "forwarder", + }) + + eventBus, err := cqrs.NewEventBusWithConfig(redisPublisher, cqrs.EventBusConfig{ + GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) { + return params.EventName, nil + }, + Marshaler: marshaler, + Logger: logger, + }) + if err != nil { + panic(err) + } + + commandBus, err := cqrs.NewCommandBusWithConfig(sqlPublisher, cqrs.CommandBusConfig{ + GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { + return params.CommandName, nil + }, + Marshaler: marshaler, + Logger: logger, + }) + if err != nil { + panic(err) + } + + router := message.NewDefaultRouter(logger) + + eventProcessor, err := cqrs.NewEventProcessorWithConfig(router, cqrs.EventProcessorConfig{ + GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { + return params.EventName, nil + }, + SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { + return redisstream.NewSubscriber(redisstream.SubscriberConfig{ + Client: redisClient, + ConsumerGroup: params.HandlerName, + }, logger) + }, + Marshaler: marshaler, + Logger: logger, + }) + if err != nil { + panic(err) + } + + commandProcessor, err := cqrs.NewCommandProcessorWithConfig(router, cqrs.CommandProcessorConfig{ + GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { + return params.CommandName, nil + }, + SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { + return redisstream.NewSubscriber(redisstream.SubscriberConfig{ + Client: redisClient, + ConsumerGroup: params.HandlerName, + }, logger) + }, + Marshaler: marshaler, + Logger: logger, + }) + if err != nil { + panic(err) + } + + err = eventProcessor.AddHandlers( + cqrs.NewEventHandler( + "OnOrderPlacedHandler", + func(ctx context.Context, event *OrderPlaced) error { + fmt.Printf("Received order placed from %v\n", event.Customer.Name) + + cmd := SendFeedbackForm{ + To: event.Customer.Email, + Name: event.Customer.Name, + } + + // In a real world scenario, we would delay the command by a few days + ctx = delay.WithContext(ctx, delay.For(8*time.Second)) + + err := commandBus.Send(ctx, cmd) + if err != nil { + return err + } + + return nil + }, + ), + ) + if err != nil { + panic(err) + } + + err = commandProcessor.AddHandlers( + cqrs.NewCommandHandler( + "OnSendFeedbackForm", + func(ctx context.Context, cmd *SendFeedbackForm) error { + msg := fmt.Sprintf("Hello %s! It's been a while since you placed your order, how did you like it? Let us know!", cmd.Name) + + fmt.Println("Sending feedback form to:", cmd.To) + fmt.Println("\tMessage:", msg) + + // In a real world scenario, we would send an email to the customer here + + return nil + }, + ), + ) + if err != nil { + panic(err) + } + + sqlSubscriber, err := sql.NewDelayedPostgreSQLSubscriber(db, sql.DelayedPostgreSQLSubscriberConfig{ + DeleteOnAck: true, + Logger: logger, + }) + if err != nil { + panic(err) + } + + _, err = forwarder.NewForwarder( + sqlSubscriber, + redisPublisher, + logger, + forwarder.Config{ + ForwarderTopic: "forwarder", + Router: router, + }, + ) + if err != nil { + panic(err) + } + + go func() { + err = router.Run(context.Background()) + if err != nil { + panic(err) + } + }() + + <-router.Running() + + for { + e := OrderPlaced{ + OrderID: uuid.NewString(), + Customer: Customer{ + Name: gofakeit.FirstName(), + Email: gofakeit.Email(), + }, + } + + err = eventBus.Publish(context.Background(), e) + if err != nil { + panic(err) + } + + time.Sleep(5 * time.Second) + } +} + +type Customer struct { + Name string `json:"name"` + Email string `json:"email"` +} + +type OrderPlaced struct { + OrderID string `json:"order_id"` + Customer Customer `json:"customer"` +} + +type SendFeedbackForm struct { + To string `json:"to"` + Name string `json:"name"` +} diff --git a/_examples/real-world-examples/delayed-requeue/docker-compose.yml b/_examples/real-world-examples/delayed-requeue/docker-compose.yml new file mode 100644 index 000000000..b8310f17c --- /dev/null +++ b/_examples/real-world-examples/delayed-requeue/docker-compose.yml @@ -0,0 +1,25 @@ +services: + server: + image: golang:1.23 + restart: unless-stopped + volumes: + - .:/app + - $GOPATH/pkg/mod:/go/pkg/mod + working_dir: /app + command: go run main.go + + redis: + image: redis:7 + ports: + - 6379:6379 + restart: unless-stopped + + postgres: + image: postgres:15 + restart: unless-stopped + ports: + - 5432:5432 + environment: + POSTGRES_USER: watermill + POSTGRES_DB: watermill + POSTGRES_PASSWORD: "password" diff --git a/_examples/real-world-examples/delayed-requeue/go.mod b/_examples/real-world-examples/delayed-requeue/go.mod new file mode 100644 index 000000000..f76ffb215 --- /dev/null +++ b/_examples/real-world-examples/delayed-requeue/go.mod @@ -0,0 +1,33 @@ +module delayed-requeue + +go 1.23.0 + +require ( + github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241024100330-cb068b72e948 + github.com/ThreeDotsLabs/watermill-redisstream v1.4.2 + github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024102321-584a6f7dab93 + github.com/brianvoe/gofakeit/v6 v6.28.0 + github.com/lib/pq v1.10.9 + github.com/redis/go-redis/v9 v9.7.0 +) + +require ( + github.com/Rican7/retry v0.3.1 // indirect + github.com/cenkalti/backoff/v3 v3.2.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/lithammer/shortuuid/v3 v3.0.7 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/sony/gobreaker v1.0.0 // indirect + github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) diff --git a/_examples/real-world-examples/delayed-requeue/go.sum b/_examples/real-world-examples/delayed-requeue/go.sum new file mode 100644 index 000000000..ae0952ed3 --- /dev/null +++ b/_examples/real-world-examples/delayed-requeue/go.sum @@ -0,0 +1,146 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Rican7/retry v0.3.1 h1:scY4IbO8swckzoA/11HgBwaZRJEyY9vaNJshcdhp1Mc= +github.com/Rican7/retry v0.3.1/go.mod h1:CxSDrhAyXmTMeEuRAnArMu1FHu48vtfjLREWqVl7Vw0= +github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241024100330-cb068b72e948 h1:b8qRHpWtlO94x6dVzSulrO2znSQqz8iYsxUyrdTixHo= +github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241024100330-cb068b72e948/go.mod h1:lBnrLbxOjeMRgcJbv+UiZr8Ylz8RkJ4m6i/VN/Nk+to= +github.com/ThreeDotsLabs/watermill-redisstream v1.4.2 h1:FY6tsBcbhbJpKDOssU4bfybstqY0hQHwiZmVq9qyILQ= +github.com/ThreeDotsLabs/watermill-redisstream v1.4.2/go.mod h1:69++855LyB+ckYDe60PiJLBcUrpckfDE2WwyzuVJRCk= +github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024101952-75257d7d0602 h1:CKdW3wb3+C36mMa44DF53KUyM5L6mGOjI3hikBOlAl4= +github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024101952-75257d7d0602/go.mod h1:GMWcpauehgI40EeoKPxLnXBWjT7oOm7dJfzk5uU4IOc= +github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024102321-584a6f7dab93 h1:KeRk2EG5AtdxfpjqIVPigZqscMvIcy0E2h8k7y38OAE= +github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024102321-584a6f7dab93/go.mod h1:GMWcpauehgI40EeoKPxLnXBWjT7oOm7dJfzk5uU4IOc= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= +github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= +github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= +github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw= +github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgx/v4 v4.18.2 h1:xVpYkNR5pk5bMCZGfClbO962UIqVABcAGt7ha1s/FeU= +github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= +github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= +github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg= +golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/_examples/real-world-examples/delayed-requeue/main.go b/_examples/real-world-examples/delayed-requeue/main.go new file mode 100644 index 000000000..edd62b588 --- /dev/null +++ b/_examples/real-world-examples/delayed-requeue/main.go @@ -0,0 +1,198 @@ +package main + +import ( + "context" + stdSQL "database/sql" + "fmt" + "math/rand" + "time" + + "github.com/brianvoe/gofakeit/v6" + _ "github.com/lib/pq" + "github.com/redis/go-redis/v9" + + "github.com/ThreeDotsLabs/watermill" + "github.com/ThreeDotsLabs/watermill-redisstream/pkg/redisstream" + "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" + "github.com/ThreeDotsLabs/watermill/components/cqrs" + "github.com/ThreeDotsLabs/watermill/components/delay" + "github.com/ThreeDotsLabs/watermill/components/requeuer" + "github.com/ThreeDotsLabs/watermill/message" +) + +func main() { + db, err := stdSQL.Open("postgres", "postgres://watermill:password@postgres:5432/watermill?sslmode=disable") + if err != nil { + panic(err) + } + + logger := watermill.NewStdLogger(false, false) + + redisClient := redis.NewClient(&redis.Options{Addr: "redis:6379"}) + + redisPublisher, err := redisstream.NewPublisher(redisstream.PublisherConfig{ + Client: redisClient, + }, logger) + if err != nil { + panic(err) + } + + delayedRequeuer, err := sql.NewPostgreSQLDelayedRequeuer(sql.DelayedRequeuerConfig{ + DB: db, + Publisher: redisPublisher, + Logger: logger, + }) + if err != nil { + panic(err) + } + + marshaler := cqrs.JSONMarshaler{ + GenerateName: cqrs.StructName, + } + + eventBus, err := cqrs.NewEventBusWithConfig(redisPublisher, cqrs.EventBusConfig{ + GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) { + return params.EventName, nil + }, + Marshaler: marshaler, + Logger: logger, + }) + if err != nil { + panic(err) + } + + router := message.NewDefaultRouter(logger) + router.AddMiddleware(delayedRequeuer.Middleware()...) + + eventProcessor, err := cqrs.NewEventProcessorWithConfig(router, cqrs.EventProcessorConfig{ + GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { + return params.EventName, nil + }, + SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { + return redisstream.NewSubscriber(redisstream.SubscriberConfig{ + Client: redisClient, + ConsumerGroup: params.HandlerName, + }, logger) + }, + Marshaler: marshaler, + Logger: logger, + }) + if err != nil { + panic(err) + } + + err = eventProcessor.AddHandlers( + cqrs.NewEventHandler( + "OnOrderPlacedHandler", + func(ctx context.Context, event *OrderPlaced) error { + fmt.Println("Received order placed:", event.OrderID) + + msg := cqrs.OriginalMessageFromCtx(ctx) + retries := msg.Metadata.Get(requeuer.RetriesKey) + delayedUntil := msg.Metadata.Get(delay.DelayedUntilKey) + delayedFor := msg.Metadata.Get(delay.DelayedForKey) + + if retries != "" { + fmt.Println("\tRetries:", retries) + fmt.Println("\tDelayed until:", delayedUntil) + fmt.Println("\tDelayed for:", delayedFor) + } + + if event.OrderID == "" { + return fmt.Errorf("empty order_id") + } + + return nil + }, + ), + ) + if err != nil { + panic(err) + } + + go func() { + err = delayedRequeuer.Run(context.Background()) + if err != nil { + panic(err) + } + }() + + go func() { + err = router.Run(context.Background()) + if err != nil { + panic(err) + } + }() + + <-router.Running() + + for { + e := newFakeOrderPlaced() + + chance := rand.Intn(10) + if chance < 2 { + e.OrderID = "" + } + + err = eventBus.Publish(context.Background(), e) + if err != nil { + panic(err) + } + + time.Sleep(1 * time.Second) + } +} + +func newFakeOrderPlaced() OrderPlaced { + var products []Product + + for i := 0; i < rand.Intn(5)+1; i++ { + products = append(products, Product{ + ID: watermill.NewShortUUID(), + Name: gofakeit.ProductName(), + }) + } + + return OrderPlaced{ + OrderID: watermill.NewUUID(), + Customer: Customer{ + ID: watermill.NewULID(), + Name: gofakeit.Name(), + Email: gofakeit.Email(), + Phone: gofakeit.Phone(), + }, + Address: Address{ + Street: gofakeit.Street(), + City: gofakeit.City(), + Zip: gofakeit.Zip(), + Country: gofakeit.Country(), + }, + Products: products, + } +} + +type OrderPlaced struct { + OrderID string `json:"order_id"` + Customer Customer `json:"customer"` + Address Address `json:"address"` + Products []Product `json:"products"` +} + +type Customer struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` +} + +type Address struct { + Street string `json:"street"` + City string `json:"city"` + Zip string `json:"zip"` + Country string `json:"country"` +} + +type Product struct { + ID string `json:"id"` + Name string `json:"name"` +} diff --git a/components/delay/delay.go b/components/delay/delay.go new file mode 100644 index 000000000..255419af1 --- /dev/null +++ b/components/delay/delay.go @@ -0,0 +1,68 @@ +package delay + +import ( + "context" + "time" + + "github.com/ThreeDotsLabs/watermill/message" +) + +// Delay represents a message's delay. +// It can be either a delay until a specific time or a delay for a specific duration. +// The zero value of Delay is a zero delay. +// +// IMPORTANT: Delay doesn't work with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it. +// See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/ +type Delay struct { + time time.Time + duration time.Duration +} + +func (d Delay) IsZero() bool { + return d.time.IsZero() +} + +// Until returns a delay of the given time. +func Until(delayedUntil time.Time) Delay { + return Delay{ + time: delayedUntil, + duration: delayedUntil.Sub(time.Now().UTC()), + } +} + +// For returns a delay of now plus the given duration. +func For(delayedFor time.Duration) Delay { + return Delay{ + time: time.Now().UTC().Add(delayedFor), + duration: delayedFor, + } +} + +type contextKey string + +var ( + delayContextKey = contextKey("delay") +) + +// WithContext returns a new context with the given delay. +// If used together with a publisher wrapped with NewPublisher, the delay will be applied to the message. +// +// IMPORTANT: Delay doesn't work with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it. +// See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/ +func WithContext(ctx context.Context, delay Delay) context.Context { + return context.WithValue(ctx, delayContextKey, delay) +} + +const ( + DelayedUntilKey = "_watermill_delayed_until" + DelayedForKey = "_watermill_delayed_for" +) + +// Message sets the delay metadata on the message. +// +// IMPORTANT: Delay doesn't work with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it. +// See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/ +func Message(msg *message.Message, delay Delay) { + msg.Metadata.Set(DelayedUntilKey, delay.time.Format(time.RFC3339)) + msg.Metadata.Set(DelayedForKey, delay.duration.String()) +} diff --git a/components/delay/publisher.go b/components/delay/publisher.go new file mode 100644 index 000000000..7318ecfb0 --- /dev/null +++ b/components/delay/publisher.go @@ -0,0 +1,83 @@ +package delay + +import ( + "errors" + + "github.com/ThreeDotsLabs/watermill/message" +) + +type DefaultDelayGeneratorParams struct { + Topic string + Message *message.Message +} + +// PublisherConfig is a configuration for the delay publisher. +type PublisherConfig struct { + // DefaultDelayGenerator is a function that generates the default delay for a message. + // If the message doesn't have the delay metadata set, the default delay will be applied. + DefaultDelayGenerator func(params DefaultDelayGeneratorParams) (Delay, error) + + // AllowNoDelay allows publishing messages without a delay set. + // By default, the publisher returns an error when a message is published without a delay and no default delay generator is provided. + AllowNoDelay bool +} + +// NewPublisher wraps a publisher with a delay mechanism. +// A message can be published with delay metadata set in the context by using the WithContext function. +// If the message doesn't have the delay metadata set, the default delay will be applied, if provided. +func NewPublisher(pub message.Publisher, config PublisherConfig) (message.Publisher, error) { + return &publisher{ + pub: pub, + config: config, + }, nil +} + +type publisher struct { + pub message.Publisher + config PublisherConfig +} + +func (p *publisher) Publish(topic string, messages ...*message.Message) error { + for i := range messages { + err := p.applyDelay(topic, messages[i]) + if err != nil { + return err + } + } + return p.pub.Publish(topic, messages...) +} + +func (p *publisher) Close() error { + return p.pub.Close() +} + +func (p *publisher) applyDelay(topic string, msg *message.Message) error { + if msg.Metadata.Get(DelayedForKey) != "" { + return nil + } + + if msg.Context().Value(delayContextKey) != nil { + delay := msg.Context().Value(delayContextKey).(Delay) + Message(msg, delay) + return nil + } + + if p.config.DefaultDelayGenerator != nil { + delay, err := p.config.DefaultDelayGenerator(DefaultDelayGeneratorParams{ + Topic: topic, + Message: msg, + }) + if err != nil { + return err + } + Message(msg, delay) + + return nil + } + + if !p.config.AllowNoDelay { + return errors.New("message doesn't have a delay set") + } + + return nil +} diff --git a/components/delay/publisher_test.go b/components/delay/publisher_test.go new file mode 100644 index 000000000..bd6ba3e58 --- /dev/null +++ b/components/delay/publisher_test.go @@ -0,0 +1,178 @@ +package delay_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ThreeDotsLabs/watermill/components/delay" + "github.com/ThreeDotsLabs/watermill/message" + "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" +) + +func TestPublisher(t *testing.T) { + pubSub := gochannel.NewGoChannel(gochannel.Config{}, nil) + + messages, err := pubSub.Subscribe(context.Background(), "test") + require.NoError(t, err) + + pub, err := delay.NewPublisher(pubSub, delay.PublisherConfig{}) + require.NoError(t, err) + + pubAllowNoDelay, err := delay.NewPublisher(pubSub, delay.PublisherConfig{ + AllowNoDelay: true, + }) + require.NoError(t, err) + + defaultDelayPub, err := delay.NewPublisher(pubSub, delay.PublisherConfig{ + DefaultDelayGenerator: func(params delay.DefaultDelayGeneratorParams) (delay.Delay, error) { + return delay.For(1 * time.Second), nil + }, + }) + require.NoError(t, err) + + testCases := []struct { + name string + publisher message.Publisher + messageConstructor func(id string) *message.Message + expectedError bool + expectedDelay time.Duration + }{ + { + name: "no delay", + publisher: pub, + messageConstructor: func(id string) *message.Message { + return message.NewMessage(id, nil) + }, + expectedError: true, + expectedDelay: 0, + }, + { + name: "no delay but allowed", + publisher: pubAllowNoDelay, + messageConstructor: func(id string) *message.Message { + return message.NewMessage(id, nil) + }, + expectedDelay: 0, + }, + { + name: "default delay", + publisher: defaultDelayPub, + messageConstructor: func(id string) *message.Message { + return message.NewMessage(id, nil) + }, + expectedDelay: 1 * time.Second, + }, + { + name: "delay from metadata", + publisher: pub, + messageConstructor: func(id string) *message.Message { + msg := message.NewMessage(id, nil) + delay.Message(msg, delay.For(2*time.Second)) + return msg + }, + expectedDelay: 2 * time.Second, + }, + { + name: "default delay override with metadata", + publisher: defaultDelayPub, + messageConstructor: func(id string) *message.Message { + msg := message.NewMessage(id, nil) + delay.Message(msg, delay.For(2*time.Second)) + return msg + }, + expectedDelay: 2 * time.Second, + }, + { + name: "delay from context", + publisher: pub, + messageConstructor: func(id string) *message.Message { + msg := message.NewMessage(id, nil) + ctx := delay.WithContext(context.Background(), delay.For(3*time.Second)) + msg.SetContext(ctx) + return msg + }, + expectedDelay: 3 * time.Second, + }, + { + name: "default delay override with context", + publisher: defaultDelayPub, + messageConstructor: func(id string) *message.Message { + msg := message.NewMessage(id, nil) + ctx := delay.WithContext(context.Background(), delay.For(3*time.Second)) + msg.SetContext(ctx) + return msg + }, + expectedDelay: 3 * time.Second, + }, + { + name: "delay with until", + publisher: pub, + messageConstructor: func(id string) *message.Message { + msg := message.NewMessage(id, nil) + delay.Message(msg, delay.Until(time.Now().UTC().Add(4*time.Second))) + return msg + }, + expectedDelay: 4 * time.Second, + }, + { + name: "both metadata and context set", + publisher: defaultDelayPub, + messageConstructor: func(id string) *message.Message { + msg := message.NewMessage(id, nil) + delay.Message(msg, delay.For(5*time.Second)) + ctx := delay.WithContext(context.Background(), delay.For(6*time.Second)) + msg.SetContext(ctx) + return msg + }, + expectedDelay: 5 * time.Second, + }, + } + + for i, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + id := fmt.Sprint(i) + + msg := testCase.messageConstructor(id) + err = testCase.publisher.Publish("test", msg) + + if testCase.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + assertMessage(t, messages, id, testCase.expectedDelay) + }) + } +} + +func assertMessage(t *testing.T, messages <-chan *message.Message, expectedID string, expectedDelay time.Duration) { + t.Helper() + select { + case msg := <-messages: + assert.Equal(t, expectedID, msg.UUID) + + if expectedDelay == 0 { + assert.Empty(t, msg.Metadata.Get(delay.DelayedUntilKey)) + assert.Empty(t, msg.Metadata.Get(delay.DelayedForKey)) + } else { + delayedFor, err := time.ParseDuration(msg.Metadata.Get(delay.DelayedForKey)) + require.NoError(t, err) + assert.Equal(t, expectedDelay, delayedFor.Round(time.Second)) + + delayedUntil, err := time.Parse(time.RFC3339, msg.Metadata.Get(delay.DelayedUntilKey)) + require.NoError(t, err) + + assert.WithinDuration(t, time.Now().UTC().Add(expectedDelay), delayedUntil, 1*time.Second) + } + + msg.Ack() + case <-time.After(100 * time.Millisecond): + require.Fail(t, "timeout") + } +} diff --git a/components/requeuer/requeuer.go b/components/requeuer/requeuer.go new file mode 100644 index 000000000..623b43fe0 --- /dev/null +++ b/components/requeuer/requeuer.go @@ -0,0 +1,158 @@ +package requeuer + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/ThreeDotsLabs/watermill" + "github.com/ThreeDotsLabs/watermill/message" +) + +const RetriesKey = "_watermill_requeuer_retries" + +// Requeuer is a component that moves messages from one topic to another. +// It can be used to requeue messages that failed to process. +type Requeuer struct { + config Config +} + +// GeneratePublishTopicParams are the parameters passed to the GeneratePublishTopic function. +type GeneratePublishTopicParams struct { + Message *message.Message +} + +// Config is the configuration for the Requeuer. +type Config struct { + // Subscriber is the subscriber to consume messages from. Required. + Subscriber message.Subscriber + + // SubscribeTopic is the topic related to the Subscriber to consume messages from. Required. + SubscribeTopic string + + // Publisher is the publisher to publish requeued messages to. Required. + Publisher message.Publisher + + // GeneratePublishTopic is the topic related to the Publisher to publish the requeued message to. + // For example, it could be a constant, or taken from the message's metadata. + // Required. + GeneratePublishTopic func(params GeneratePublishTopicParams) (string, error) + + // Delay is the duration to wait before requeueing the message. Optional. + // The default is no delay. + // + // This can be useful to avoid requeueing messages too quickly, for example, to avoid + // requeueing a message that failed to process due to a temporary issue. + // + // Avoid setting this to a very high value, as it will block the message processing. + Delay time.Duration + + // Router is the custom router to run the requeue handler on. Optional. + Router *message.Router +} + +func (c *Config) setDefaults(logger watermill.LoggerAdapter) error { + if c.Router == nil { + router, err := message.NewRouter(message.RouterConfig{}, logger) + if err != nil { + return fmt.Errorf("could not create router: %w", err) + } + + c.Router = router + } + + return nil +} + +func (c *Config) validate() error { + if c.Subscriber == nil { + return errors.New("subscriber is required") + } + + if c.SubscribeTopic == "" { + return errors.New("subscribe topic is required") + } + + if c.Publisher == nil { + return errors.New("publisher is required") + } + + if c.GeneratePublishTopic == nil { + return errors.New("generate publish topic is required") + } + + return nil +} + +// NewRequeuer creates a new Requeuer with the provided Config. +// It's not started automatically. You need to call Run on the returned Requeuer. +func NewRequeuer( + config Config, + logger watermill.LoggerAdapter, +) (*Requeuer, error) { + if logger == nil { + logger = watermill.NewStdLogger(false, false) + } + + err := config.setDefaults(logger) + if err != nil { + return nil, err + } + + err = config.validate() + if err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + r := &Requeuer{ + config: config, + } + + config.Router.AddNoPublisherHandler( + "requeuer", + config.SubscribeTopic, + config.Subscriber, + r.handler, + ) + + return r, nil +} + +func (r *Requeuer) handler(msg *message.Message) error { + if r.config.Delay > 0 { + select { + case <-msg.Context().Done(): + return msg.Context().Err() + case <-time.After(r.config.Delay): + } + } + + topic, err := r.config.GeneratePublishTopic(GeneratePublishTopicParams{Message: msg}) + if err != nil { + return err + } + + retriesStr := msg.Metadata.Get(RetriesKey) + retries, err := strconv.Atoi(retriesStr) + if err != nil { + retries = 0 + } + + retries++ + + msg.Metadata.Set(RetriesKey, strconv.Itoa(retries)) + + err = r.config.Publisher.Publish(topic, msg) + if err != nil { + return err + } + + return nil +} + +// Run runs the Requeuer. +func (r *Requeuer) Run(ctx context.Context) error { + return r.config.Router.Run(ctx) +} diff --git a/components/requeuer/requeuer_test.go b/components/requeuer/requeuer_test.go new file mode 100644 index 000000000..bf48c8d8c --- /dev/null +++ b/components/requeuer/requeuer_test.go @@ -0,0 +1,102 @@ +package requeuer_test + +import ( + "context" + "errors" + "fmt" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ThreeDotsLabs/watermill" + "github.com/ThreeDotsLabs/watermill/components/requeuer" + "github.com/ThreeDotsLabs/watermill/message" + "github.com/ThreeDotsLabs/watermill/message/router/middleware" + "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" +) + +func TestRequeue(t *testing.T) { + logger := watermill.NewStdLogger(false, false) + + pubSub := gochannel.NewGoChannel(gochannel.Config{}, logger) + + requeue, err := requeuer.NewRequeuer(requeuer.Config{ + Subscriber: pubSub, + SubscribeTopic: "requeue", + Publisher: pubSub, + GeneratePublishTopic: func(params requeuer.GeneratePublishTopicParams) (string, error) { + return "test", nil + }, + Delay: time.Millisecond * 200, + }, logger) + require.NoError(t, err) + + go func() { + err := requeue.Run(context.Background()) + require.NoError(t, err) + }() + + router, err := message.NewRouter(message.RouterConfig{}, logger) + require.NoError(t, err) + + pq, err := middleware.PoisonQueue(pubSub, "requeue") + require.NoError(t, err) + + router.AddMiddleware(pq) + + receivedMessages := make(chan int, 10) + + counter := 0 + + router.AddNoPublisherHandler( + "test", + "test", + pubSub, + func(msg *message.Message) error { + i, err := strconv.Atoi(string(msg.Payload)) + if err != nil { + return err + } + + counter++ + + if counter < 10 && i%2 == 0 { + return errors.New("error") + } + + receivedMessages <- i + + return nil + }, + ) + + go func() { + err := router.Run(context.Background()) + require.NoError(t, err) + }() + + time.Sleep(time.Second) + + for i := 0; i < 10; i++ { + msg := message.NewMessage(watermill.NewUUID(), []byte(fmt.Sprint(i))) + err := pubSub.Publish("test", msg) + require.NoError(t, err) + } + + var received []int + + timeout := false + for !timeout { + select { + case i := <-receivedMessages: + received = append(received, i) + case <-time.After(5 * time.Second): + timeout = true + break + } + } + + require.ElementsMatch(t, []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, received) +} diff --git a/docs/build.sh b/docs/build.sh index 2ea03e0fa..7024a56ea 100755 --- a/docs/build.sh +++ b/docs/build.sh @@ -46,6 +46,9 @@ else "components/cqrs/cqrs.go" "components/cqrs/marshaler.go" + "components/delay/delay.go" + "components/delay/publisher.go" + "components/metrics/builder.go" "components/metrics/http.go" diff --git a/docs/content/advanced/delayed-messages.md b/docs/content/advanced/delayed-messages.md new file mode 100644 index 000000000..f5d6ff729 --- /dev/null +++ b/docs/content/advanced/delayed-messages.md @@ -0,0 +1,43 @@ ++++ +title = "Delayed Messages" +description = "Receive messages with a delay" +weight = -40 +draft = false +bref = "Receive messages with a delay" ++++ + +Delaying events or commands is a common use case in many applications. +For example, you may want to send the user a reminder after a few days of signing up. +It's not a complex logic to implement, but you can leverage messages to use it out of the box. + +## Delay Metadata + +Watermill's [`delay`](https://github.com/ThreeDotsLabs/watermill/tree/master/components/delay) package allows you to +*add delay metadata* to messages. + +{{< callout "danger" >}} +**The delay metadata does nothing by itself. You need to use a Pub/Sub implementation that supports it to make it work.** + +See below for supported Pub/Subs. +{{< /callout >}} + +There are two APIs you can use. If you work with raw messages, use `delay.Message`: + +```go +msg := message.NewMessage(watermill.NewUUID(), []byte("hello")) +delay.Message(msg, delay.For(time.Second * 10)) +``` + +If you use the CQRS component, use `delay.WithContext` instead (since you can't access the message directly): + +{{% load-snippet-partial file="src-link/_examples/real-world-examples/delayed-messages/main.go" first_line_contains="cmd := SendFeedbackForm" last_line_contains="return err" padding_after="1" %}} + +You can also use `delay.Until` instead of `delay.For` to specify `time.Time` instead of `time.Duration`. + +## Supported Pub/Subs + +* [PostgreSQL](/pubsubs/sql/) + +## Full Example + +See the [full example](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/real-world-examples/delayed-messages) in the Watermill repository. diff --git a/docs/hugo_stats.json b/docs/hugo_stats.json index 391e6403f..cb541b1e1 100644 --- a/docs/hugo_stats.json +++ b/docs/hugo_stats.json @@ -80,6 +80,10 @@ "btn-link", "btn-outline-primary", "btn-primary", + "callout", + "callout-body", + "callout-content", + "callout-danger", "card", "card-body", "card-list", @@ -190,6 +194,7 @@ "ms-auto", "ms-lg-2", "mt-3", + "mt-4", "mt-5", "mt-n3", "mx-2", @@ -220,7 +225,11 @@ "page-footer-meta", "page-links", "page-nav", + "pb-2", "pb-3", + "pe-4", + "ps-3", + "pt-4", "pubsubs", "px-0", "query-no-results", @@ -307,6 +316,8 @@ "customization", "debugging-pubsub-tests", "deduplicator", + "delay-metadata", + "delay-on-error", "doks-docs-nav", "duplicator", "ensuring-that-the-router-is-running", @@ -329,6 +340,7 @@ "fanin-component", "fanout-component", "forwarder-component", + "full-example", "generic-handlers", "grafana-dashboard", "grep-is-your-friend", @@ -408,6 +420,7 @@ "subscribing-for-messages", "subscription-name", "support", + "supported-pubsubs", "tabs-getting-started-0", "tabs-getting-started-0-tab", "tabs-getting-started-1", diff --git a/message/router/middleware/delay_on_error.go b/message/router/middleware/delay_on_error.go new file mode 100644 index 000000000..9b70370e5 --- /dev/null +++ b/message/router/middleware/delay_on_error.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "time" + + "github.com/ThreeDotsLabs/watermill/components/delay" + "github.com/ThreeDotsLabs/watermill/message" +) + +// DelayOnError is a middleware that adds the delay metadata to the message if an error occurs. +// +// IMPORTANT: The delay metadata doesn't cause delays with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it. +// See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/ +type DelayOnError struct { + // InitialInterval is the first interval between retries. Subsequent intervals will be scaled by Multiplier. + InitialInterval time.Duration + // MaxInterval sets the limit for the exponential backoff of retries. The interval will not be increased beyond MaxInterval. + MaxInterval time.Duration + // Multiplier is the factor by which the waiting interval will be multiplied between retries. + Multiplier float64 +} + +func (d *DelayOnError) Middleware(h message.HandlerFunc) message.HandlerFunc { + return func(msg *message.Message) ([]*message.Message, error) { + msgs, err := h(msg) + if err != nil { + d.applyDelay(msg) + } + + return msgs, err + } +} + +func (d *DelayOnError) applyDelay(msg *message.Message) { + delayedForStr := msg.Metadata.Get(delay.DelayedForKey) + delayedFor, err := time.ParseDuration(delayedForStr) + if delayedForStr != "" && err == nil { + delayedFor *= time.Duration(d.Multiplier) + if delayedFor > d.MaxInterval { + delayedFor = d.MaxInterval + } + + delay.Message(msg, delay.For(delayedFor)) + } else { + delay.Message(msg, delay.For(d.InitialInterval)) + } +} diff --git a/message/router/middleware/delay_on_error_test.go b/message/router/middleware/delay_on_error_test.go new file mode 100644 index 000000000..e8fc89fa1 --- /dev/null +++ b/message/router/middleware/delay_on_error_test.go @@ -0,0 +1,58 @@ +package middleware_test + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/ThreeDotsLabs/watermill/components/delay" + "github.com/ThreeDotsLabs/watermill/message" + "github.com/ThreeDotsLabs/watermill/message/router/middleware" +) + +func TestDelayOnError(t *testing.T) { + m := middleware.DelayOnError{ + InitialInterval: time.Second, + MaxInterval: time.Second * 10, + Multiplier: 2, + } + + msg := message.NewMessage("1", []byte("test")) + + getDelayFor := func(msg *message.Message) string { + return msg.Metadata.Get(delay.DelayedForKey) + } + + okHandler := func(msg *message.Message) ([]*message.Message, error) { + return nil, nil + } + + errHandler := func(msg *message.Message) ([]*message.Message, error) { + return nil, errors.New("error") + } + + assert.Equal(t, "", getDelayFor(msg)) + + _, _ = m.Middleware(okHandler)(msg) + assert.Equal(t, "", getDelayFor(msg)) + + _, _ = m.Middleware(errHandler)(msg) + assert.Equal(t, "1s", getDelayFor(msg)) + + _, _ = m.Middleware(errHandler)(msg) + assert.Equal(t, "2s", getDelayFor(msg)) + + _, _ = m.Middleware(errHandler)(msg) + assert.Equal(t, "4s", getDelayFor(msg)) + + _, _ = m.Middleware(errHandler)(msg) + assert.Equal(t, "8s", getDelayFor(msg)) + + _, _ = m.Middleware(errHandler)(msg) + assert.Equal(t, "10s", getDelayFor(msg)) + + _, _ = m.Middleware(errHandler)(msg) + assert.Equal(t, "10s", getDelayFor(msg)) +} diff --git a/message/router/middleware/retry_test.go b/message/router/middleware/retry_test.go index cf7bd84f1..8c2fc91f7 100644 --- a/message/router/middleware/retry_test.go +++ b/message/router/middleware/retry_test.go @@ -5,13 +5,11 @@ import ( "testing" "time" - "github.com/ThreeDotsLabs/watermill" - + "github.com/pkg/errors" "github.com/stretchr/testify/assert" + "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" - "github.com/pkg/errors" - "github.com/ThreeDotsLabs/watermill/message/router/middleware" ) diff --git a/tools/pq/README.md b/tools/pq/README.md new file mode 100644 index 000000000..b482cbbe6 --- /dev/null +++ b/tools/pq/README.md @@ -0,0 +1,38 @@ +# pq + +pq is a CLI tool for working with delayed messages on poison queues. + +For now, it supports the PostgreSQL Pub/Sub implementation. + +## Install + +```bash +go install github.com/ThreeDotsLabs/watermill/tools/pq@latest +``` + +## Usage + +Set the `DATABASE_URL` environment variable to your PostgreSQL connection string. + +For example, to connect to the database used for the [delayed requeue example](../../_examples/real-world-examples/delayed-requeue): + +```bash +export DATABASE_URL="postgres://watermill:password@postgres:5432/watermill?sslmode=disable" +``` + +```bash +pq -backend postgres -topic requeue +``` + +This will use the default `watermill_` prefix, so will use the `watermill_requeue` table. + +If you use a custom prefix, use the `-raw-topic` flag instead: + +```bash +pq -backend postgres -raw-topic my_prefix_requeue +``` + +## Commands + +- Requeue — Updates the `_watermill_delayed_until` metadata to the current time, so the message will be instantly requeued. +- Ack — Removes the message from the queue (be careful — you will lose the message forever). diff --git a/tools/pq/backend/postgres.go b/tools/pq/backend/postgres.go new file mode 100644 index 000000000..f4418c127 --- /dev/null +++ b/tools/pq/backend/postgres.go @@ -0,0 +1,99 @@ +package backend + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + + "github.com/ThreeDotsLabs/watermill/components/delay" + "github.com/ThreeDotsLabs/watermill/tools/pq/cli" +) + +type PostgresMessage struct { + Offset int `db:"offset"` + UUID string `db:"uuid"` + Payload string `db:"payload"` + Metadata string `db:"metadata"` +} + +type PostgresBackend struct { + db *sqlx.DB + config cli.BackendConfig +} + +func NewPostgresBackend(ctx context.Context, config cli.BackendConfig) (*PostgresBackend, error) { + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + return nil, fmt.Errorf("missing DATABASE_URL") + } + + db, err := sqlx.Connect("postgres", dbURL) + if err != nil { + return nil, err + } + + return &PostgresBackend{ + db: db, + config: config, + }, nil +} + +func (r *PostgresBackend) AllMessages(ctx context.Context) ([]cli.Message, error) { + var dbMessages []PostgresMessage + err := r.db.SelectContext(ctx, &dbMessages, fmt.Sprintf(`SELECT "offset", uuid, payload, metadata FROM %v WHERE acked = false ORDER BY "offset"`, r.topic())) + if err != nil { + return nil, err + } + + var messages []cli.Message + + for _, dbMsg := range dbMessages { + var metadata map[string]string + err := json.Unmarshal([]byte(dbMsg.Metadata), &metadata) + if err != nil { + return nil, err + } + + msg, err := cli.NewMessage(fmt.Sprint(dbMsg.Offset), dbMsg.UUID, dbMsg.Payload, metadata) + if err != nil { + return nil, err + } + + messages = append(messages, msg) + } + + return messages, nil +} + +func (r *PostgresBackend) Requeue(ctx context.Context, msg cli.Message) error { + _, err := r.db.ExecContext(ctx, fmt.Sprintf(`UPDATE %v SET metadata = metadata::jsonb || jsonb_build_object($1::text, $2::text) WHERE "offset" = $3`, r.topic()), + delay.DelayedUntilKey, time.Now().UTC().Format(time.RFC3339), msg.ID, + ) + if err != nil { + return err + } + + return nil +} + +func (r *PostgresBackend) Ack(ctx context.Context, msg cli.Message) error { + _, err := r.db.ExecContext(ctx, fmt.Sprintf(`UPDATE %v SET acked = true WHERE "offset" = %v`, r.topic(), msg.ID)) + if err != nil { + return err + } + + return nil +} + +func (r *PostgresBackend) topic() string { + if r.config.Topic != "" { + return fmt.Sprintf(`"watermill_%v"`, r.config.Topic) + } + + return fmt.Sprintf(`"%v"`, r.config.RawTopic) +} diff --git a/tools/pq/cli/backend.go b/tools/pq/cli/backend.go new file mode 100644 index 000000000..9aad18c66 --- /dev/null +++ b/tools/pq/cli/backend.go @@ -0,0 +1,32 @@ +package cli + +import ( + "context" + + "github.com/pkg/errors" +) + +type BackendConfig struct { + Topic string + RawTopic string +} + +func (c BackendConfig) Validate() error { + if c.Topic == "" && c.RawTopic == "" { + return errors.New("topic or raw topic must be provided") + } + + if c.Topic != "" && c.RawTopic != "" { + return errors.New("only one of topic or raw topic must be provided") + } + + return nil +} + +type BackendConstructor func(ctx context.Context, cfg BackendConfig) (Backend, error) + +type Backend interface { + AllMessages(ctx context.Context) ([]Message, error) + Requeue(ctx context.Context, msg Message) error + Ack(ctx context.Context, msg Message) error +} diff --git a/tools/pq/cli/message.go b/tools/pq/cli/message.go new file mode 100644 index 000000000..654171ed5 --- /dev/null +++ b/tools/pq/cli/message.go @@ -0,0 +1,45 @@ +package cli + +import ( + "time" + + "github.com/ThreeDotsLabs/watermill/components/delay" + "github.com/ThreeDotsLabs/watermill/message/router/middleware" +) + +type Message struct { + // ID is a unique message ID across the Pub/Sub's topic. + ID string + UUID string + Payload string + Metadata map[string]string + + OriginalTopic string + DelayedUntil string + DelayedFor string + RequeueIn time.Duration +} + +func NewMessage(id string, uuid string, payload string, metadata map[string]string) (Message, error) { + originalTopic := metadata[middleware.PoisonedTopicKey] + + // Calculate the time until the message should be requeued + delayedUntil, err := time.Parse(time.RFC3339, metadata[delay.DelayedUntilKey]) + if err != nil { + return Message{}, err + } + + delayedFor := metadata[delay.DelayedForKey] + requeueIn := delayedUntil.Sub(time.Now().UTC()).Round(time.Second) + + return Message{ + ID: id, + UUID: uuid, + Payload: payload, + Metadata: metadata, + OriginalTopic: originalTopic, + DelayedUntil: delayedUntil.String(), + DelayedFor: delayedFor, + RequeueIn: requeueIn, + }, nil +} diff --git a/tools/pq/cli/model.go b/tools/pq/cli/model.go new file mode 100644 index 000000000..05593b831 --- /dev/null +++ b/tools/pq/cli/model.go @@ -0,0 +1,403 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "time" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "golang.org/x/exp/maps" +) + +var warningStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("196")). + Align(lipgloss.Center). + Padding(1, 10) + +var dialogStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("241")). + Padding(1, 4) + +var buttonStyle = lipgloss.NewStyle() + +var buttonSelectedStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("57")) + +var readOnlyMessageActions = []string{"<- Back", "Show payload"} +var writeMessageActions = []string{"Requeue", "Ack (drop)"} + +var dialogActions = []string{"Cancel", "Confirm"} + +type MessagesUpdated struct { + Messages []Message +} + +type DialogResult struct { + Err error +} + +func (m Model) FetchMessages() tea.Cmd { + return func() tea.Msg { + for { + msgs, err := m.backend.AllMessages(context.Background()) + if err != nil { + panic(err) + } + + m.sub <- MessagesUpdated{ + Messages: msgs, + } + + time.Sleep(time.Second) + } + } +} + +func (m Model) WaitForMessages() tea.Cmd { + return func() tea.Msg { + return <-m.sub + } +} + +var baseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")) + +type Model struct { + backend Backend + sub chan MessagesUpdated + + chosenMessage *Message + chosenMessageGone bool + + table table.Model + messages []Message + + chosenAction int + currentDialog *Dialog + + showingPayload bool + payloadViewport viewport.Model +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + m.FetchMessages(), + m.WaitForMessages(), + ) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case MessagesUpdated: + rows := make([]table.Row, len(msg.Messages)) + for i, message := range msg.Messages { + rows[i] = table.Row{ + message.ID, + message.UUID, + message.OriginalTopic, + message.DelayedFor, + message.RequeueIn.String(), + } + } + m.table.SetRows(rows) + m.messages = msg.Messages + + // If the chosen message is no longer in the list, go back to the table. + // This is to avoid accidentally making an action on a message that has been requeued or deleted. + if m.chosenMessage != nil { + found := false + for _, message := range m.messages { + if message.ID == m.chosenMessage.ID { + foundMessage := message + m.chosenMessage = &foundMessage + found = true + break + } + } + + if found { + m.chosenMessageGone = false + } else { + if !m.chosenMessageGone { + m.chosenAction = 0 + } + + m.chosenMessageGone = true + } + } + + return m, m.WaitForMessages() + } + + if m.chosenMessage == nil { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case " ", "enter": + c := m.table.Cursor() + m.chosenAction = 0 + chosenMessage := m.messages[c] + m.chosenMessage = &chosenMessage + m.chosenMessageGone = false + } + } + + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return m, cmd + } else if m.showingPayload { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc", "backspace": + m.showingPayload = false + } + } + + var cmd tea.Cmd + m.payloadViewport, cmd = m.payloadViewport.Update(msg) + return m, cmd + } else if m.currentDialog != nil { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc", "backspace": + m.currentDialog = nil + case "h", "left": + m.currentDialog.Choice-- + if m.currentDialog.Choice < 0 { + m.currentDialog.Choice = 0 + } + case "l", "right": + m.currentDialog.Choice++ + if m.currentDialog.Choice >= len(dialogActions) { + m.currentDialog.Choice = len(dialogActions) - 1 + } + case " ", "enter": + switch m.currentDialog.Choice { + case 0: + m.currentDialog = nil + case 1: + m.currentDialog.Running = true + return m, m.currentDialog.Action + } + } + case DialogResult: + if msg.Err != nil { + // TODO Could be handled better + panic(msg.Err) + } + + m.currentDialog = nil + } + + return m, nil + } else { + messageActions := len(readOnlyMessageActions) + if !m.chosenMessageGone { + messageActions += len(writeMessageActions) + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc", "backspace": + m.chosenMessage = nil + m.chosenMessageGone = false + case "j", "down": + m.chosenAction++ + if m.chosenAction >= messageActions { + m.chosenAction = messageActions - 1 + } + case "k", "up": + m.chosenAction-- + if m.chosenAction < 0 { + m.chosenAction = 0 + } + case " ", "enter": + switch m.chosenAction { + case 0: + m.chosenMessage = nil + m.chosenMessageGone = false + case 1: + // Show payload + m.showingPayload = true + m.payloadViewport = viewport.New(80, 20) + b := lipgloss.RoundedBorder() + m.payloadViewport.Style = lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) + + payload := m.chosenMessage.Payload + + var jsonPayload any + err := json.Unmarshal([]byte(payload), &jsonPayload) + if err == nil { + pretty, err := json.MarshalIndent(jsonPayload, "", " ") + if err == nil { + payload = string(pretty) + } + } + + m.payloadViewport.SetContent(payload) + case 2: + chosenMessage := *m.chosenMessage + m.currentDialog = &Dialog{ + Prompt: "Requeue message? It will go back to the original topic.", + Action: func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return DialogResult{ + Err: m.backend.Requeue(ctx, chosenMessage), + } + }, + } + case 3: + chosenMessage := *m.chosenMessage + m.currentDialog = &Dialog{ + Prompt: "Acknowledge message? It will be dropped from the topic.", + Action: func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return DialogResult{ + Err: m.backend.Ack(ctx, chosenMessage), + } + }, + } + } + } + } + + return m, nil + } +} + +func (m Model) View() string { + if m.chosenMessage == nil { + return baseStyle.Render(m.table.View()) + "\n " + m.table.HelpView() + "\n" + } + + msg := m.chosenMessage + + var out string + + if m.chosenMessageGone { + out += warningStyle.Render("Read only — the message is gone.") + out += "\n" + } + + out += fmt.Sprintf( + "ID: %v\nUUID: %v\nOriginal Topic: %v\nDelayed For: %v\nDelayed Until: %v\nRequeue In: %v\n\n", + msg.ID, + msg.UUID, + msg.OriginalTopic, + msg.DelayedFor, + msg.DelayedUntil, + msg.RequeueIn, + ) + + if m.showingPayload { + out += m.payloadViewport.View() + return out + } + + out += "Metadata:\n" + + keys := maps.Keys(msg.Metadata) + slices.Sort(keys) + for _, k := range keys { + v := msg.Metadata[k] + out += fmt.Sprintf(" %v: %v\n", k, v) + } + + if m.currentDialog != nil { + prompt := m.currentDialog.Prompt + "\n\n" + + if m.currentDialog.Running { + prompt += "Running..." + } else { + for i, action := range dialogActions { + style := buttonStyle + if i == m.currentDialog.Choice { + style = buttonSelectedStyle + } + + prompt += fmt.Sprintf("%v", style.MarginLeft(13).Render(action)) + } + } + + out += dialogStyle.Render(prompt) + } else { + out += "\nActions:\n" + + messageActions := readOnlyMessageActions + if !m.chosenMessageGone { + messageActions = append(messageActions, writeMessageActions...) + } + + for i, action := range messageActions { + style := buttonStyle + if i == m.chosenAction { + style = buttonSelectedStyle + } + + out += fmt.Sprintf("%v\n", style.MarginLeft(2).Render(action)) + } + } + + return out +} + +func NewModel(backend Backend) Model { + columns := []table.Column{ + {Title: "ID", Width: 8}, + {Title: "UUID", Width: 40}, + {Title: "Original Topic", Width: 20}, + {Title: "Delayed For", Width: 14}, + {Title: "Requeue In", Width: 14}, + } + + t := table.New( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(20), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + return Model{ + backend: backend, + sub: make(chan MessagesUpdated), + table: t, + } +} + +type Dialog struct { + Prompt string + Action func() tea.Msg + Choice int + Running bool +} diff --git a/tools/pq/go.mod b/tools/pq/go.mod new file mode 100644 index 000000000..5f2f50e39 --- /dev/null +++ b/tools/pq/go.mod @@ -0,0 +1,44 @@ +module github.com/ThreeDotsLabs/watermill/tools/pq + +go 1.23.0 + +require ( + github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241011082756-1cb09cdf7d08 + github.com/charmbracelet/bubbles v0.19.0 + github.com/charmbracelet/bubbletea v0.27.1 + github.com/charmbracelet/lipgloss v0.13.0 + github.com/jmoiron/sqlx v1.4.0 + github.com/lib/pq v1.10.9 + golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cenkalti/backoff/v3 v3.2.2 // indirect + github.com/charmbracelet/x/ansi v0.1.4 // indirect + github.com/charmbracelet/x/input v0.1.0 // indirect + github.com/charmbracelet/x/term v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.1.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/lithammer/shortuuid/v3 v3.0.7 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sony/gobreaker v1.0.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/text v0.14.0 // indirect +) + +replace github.com/ThreeDotsLabs/watermill => ../.. diff --git a/tools/pq/go.sum b/tools/pq/go.sum new file mode 100644 index 000000000..9585210df --- /dev/null +++ b/tools/pq/go.sum @@ -0,0 +1,90 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= +github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= +github.com/charmbracelet/bubbles v0.19.0 h1:gKZkKXPP6GlDk6EcfujDK19PCQqRjaJZQ7QRERx1UF0= +github.com/charmbracelet/bubbles v0.19.0/go.mod h1:WILteEqZ+krG5c3ntGEMeG99nCupcuIk7V0/zOP0tOA= +github.com/charmbracelet/bubbletea v0.27.1 h1:/yhaJKX52pxG4jZVKCNWj/oq0QouPdXycriDRA6m6r8= +github.com/charmbracelet/bubbletea v0.27.1/go.mod h1:xc4gm5yv+7tbniEvQ0naiG9P3fzYhk16cTgDZQQW6YE= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= +github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= +github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= +github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= +github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= +github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= +github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= +github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= +github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/pq/main.go b/tools/pq/main.go new file mode 100644 index 000000000..292d50d9d --- /dev/null +++ b/tools/pq/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "flag" + "log" + + "github.com/ThreeDotsLabs/watermill/tools/pq/backend" + "github.com/ThreeDotsLabs/watermill/tools/pq/cli" + + tea "github.com/charmbracelet/bubbletea" +) + +var ( + backendFlag = flag.String("backend", "", "backend to use") + topicFlag = flag.String("topic", "", "topic to use") + rawTopicFlag = flag.String("raw-topic", "", "raw topic to use") +) + +func main() { + flag.Parse() + + config := cli.BackendConfig{ + Topic: *topicFlag, + RawTopic: *rawTopicFlag, + } + + err := config.Validate() + if err != nil { + log.Fatal(err) + } + + var b cli.Backend + switch *backendFlag { + case "postgres": + b, err = backend.NewPostgresBackend(context.Background(), config) + if err != nil { + log.Fatal(err) + } + default: + log.Fatalf("unknown backend: %s", *backendFlag) + } + + m := cli.NewModel(b) + + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err = p.Run() + if err != nil { + log.Fatal(err) + } +} From 0c3de00872ee337b66b7d1d0887427b21b782495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Sm=C3=B3=C5=82ka?= Date: Fri, 25 Oct 2024 14:20:56 +0200 Subject: [PATCH 03/10] Adjust codecov settings --- codecov.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/codecov.yml b/codecov.yml index 1a05fc5a2..6e50bd520 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,2 +1,8 @@ ignore: - - "pubsub/tests" # test helpers used to test Pub/Subs + - "pubsub/tests" # test helpers used to test Pub/Subs + +comment: no # do not comment PR with the result + +coverage: + status: + patch: false # do not run coverage on patch nor changes From 545295b5ff921d6cbee4e5f4d3ab3b4c7d81a076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Sm=C3=B3=C5=82ka?= Date: Fri, 25 Oct 2024 14:23:07 +0200 Subject: [PATCH 04/10] codecov: Adjust settings --- codecov.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codecov.yml b/codecov.yml index 6e50bd520..4181db8d3 100644 --- a/codecov.yml +++ b/codecov.yml @@ -4,5 +4,9 @@ ignore: comment: no # do not comment PR with the result coverage: + project: + default: + target: auto + threshold: 5% status: patch: false # do not run coverage on patch nor changes From 2b127e52eff5eadf9e4c0921ba41dbca98f05aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Sm=C3=B3=C5=82ka?= Date: Fri, 25 Oct 2024 15:27:25 +0200 Subject: [PATCH 05/10] Add AWS examples (#507) --- .../pubsubs/aws-sns/.validate_example.yml | 4 + _examples/pubsubs/aws-sns/docker-compose.yml | 24 ++++ _examples/pubsubs/aws-sns/go.mod | 23 +++ _examples/pubsubs/aws-sns/go.sum | 59 ++++++++ _examples/pubsubs/aws-sns/main.go | 133 ++++++++++++++++++ .../pubsubs/aws-sqs/.validate_example.yml | 4 + _examples/pubsubs/aws-sqs/docker-compose.yml | 24 ++++ _examples/pubsubs/aws-sqs/go.mod | 22 +++ _examples/pubsubs/aws-sqs/go.sum | 57 ++++++++ _examples/pubsubs/aws-sqs/main.go | 85 +++++++++++ docs/content/docs/getting-started.md | 44 ++++++ docs/hugo_stats.json | 8 ++ 12 files changed, 487 insertions(+) create mode 100644 _examples/pubsubs/aws-sns/.validate_example.yml create mode 100644 _examples/pubsubs/aws-sns/docker-compose.yml create mode 100644 _examples/pubsubs/aws-sns/go.mod create mode 100644 _examples/pubsubs/aws-sns/go.sum create mode 100644 _examples/pubsubs/aws-sns/main.go create mode 100644 _examples/pubsubs/aws-sqs/.validate_example.yml create mode 100644 _examples/pubsubs/aws-sqs/docker-compose.yml create mode 100644 _examples/pubsubs/aws-sqs/go.mod create mode 100644 _examples/pubsubs/aws-sqs/go.sum create mode 100644 _examples/pubsubs/aws-sqs/main.go diff --git a/_examples/pubsubs/aws-sns/.validate_example.yml b/_examples/pubsubs/aws-sns/.validate_example.yml new file mode 100644 index 000000000..3688c1f21 --- /dev/null +++ b/_examples/pubsubs/aws-sns/.validate_example.yml @@ -0,0 +1,4 @@ +validation_cmd: "docker compose up" +teardown_cmd: "docker compose down" +timeout: 120 +expected_output: "A received message: [0-9a-f\\-]+, payload: Hello, world!" diff --git a/_examples/pubsubs/aws-sns/docker-compose.yml b/_examples/pubsubs/aws-sns/docker-compose.yml new file mode 100644 index 000000000..fa75e2c2e --- /dev/null +++ b/_examples/pubsubs/aws-sns/docker-compose.yml @@ -0,0 +1,24 @@ +services: + server: + image: golang:1.23 + restart: unless-stopped + volumes: + - .:/app + - $GOPATH/pkg/mod:/go/pkg/mod + working_dir: /app + command: go run main.go + + localstack: + image: localstack/localstack:latest + environment: + - SERVICES=sqs,sns + - AWS_DEFAULT_REGION=us-east-1 + - EDGE_PORT=4566 + ports: + - "4566-4597:4566-4597" + healthcheck: + test: awslocal sqs list-queues && awslocal sns list-topics + interval: 5s + timeout: 5s + retries: 5 + start_period: 30s diff --git a/_examples/pubsubs/aws-sns/go.mod b/_examples/pubsubs/aws-sns/go.mod new file mode 100644 index 000000000..f6245a5e0 --- /dev/null +++ b/_examples/pubsubs/aws-sns/go.mod @@ -0,0 +1,23 @@ +module main + +require ( + github.com/ThreeDotsLabs/watermill v1.3.7 + github.com/ThreeDotsLabs/watermill-aws v1.0.0-rc.2 + github.com/aws/aws-sdk-go-v2 v1.30.4 + github.com/aws/aws-sdk-go-v2/service/sns v1.31.4 + github.com/aws/aws-sdk-go-v2/service/sqs v1.34.4 + github.com/aws/smithy-go v1.20.4 + github.com/samber/lo v1.47.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/lithammer/shortuuid/v3 v3.0.7 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + golang.org/x/text v0.16.0 // indirect +) + +go 1.21 diff --git a/_examples/pubsubs/aws-sns/go.sum b/_examples/pubsubs/aws-sns/go.sum new file mode 100644 index 000000000..5d1d2ffc4 --- /dev/null +++ b/_examples/pubsubs/aws-sns/go.sum @@ -0,0 +1,59 @@ +github.com/ThreeDotsLabs/watermill v1.3.7 h1:NV0PSTmuACVEOV4dMxRnmGXrmbz8U83LENOvpHekN7o= +github.com/ThreeDotsLabs/watermill v1.3.7/go.mod h1:lBnrLbxOjeMRgcJbv+UiZr8Ylz8RkJ4m6i/VN/Nk+to= +github.com/ThreeDotsLabs/watermill-aws v1.0.0-rc.2 h1:5eyROGg3sIB4eZGEkDjbjE/TXgqCVpR1yTGAWp9zxbQ= +github.com/ThreeDotsLabs/watermill-aws v1.0.0-rc.2/go.mod h1:TTht9EHVmVF5iR5joom6VymqgkwcuxsRQfLBc5ITy28= +github.com/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8= +github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0= +github.com/aws/aws-sdk-go-v2/config v1.27.28 h1:OTxWGW/91C61QlneCtnD62NLb4W616/NM1jA8LhJqbg= +github.com/aws/aws-sdk-go-v2/config v1.27.28/go.mod h1:uzVRVtJSU5EFv6Fu82AoVFKozJi2ZCY6WRCXj06rbvs= +github.com/aws/aws-sdk-go-v2/credentials v1.17.28 h1:m8+AHY/ND8CMHJnPoH7PJIRakWGa4gbfbxuY9TGTUXM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.28/go.mod h1:6TF7dSc78ehD1SL6KpRIPKMA1GyyWflIkjqg+qmf4+c= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 h1:yjwoSyDZF8Jth+mUk5lSPJCkMC0lMy6FaCD51jm6ayE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12/go.mod h1:fuR57fAgMk7ot3WcNQfb6rSEn+SUffl7ri+aa8uKysI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 h1:TNyt/+X43KJ9IJJMjKfa3bNTiZbUP7DeCxfbTROESwY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 h1:KypMCbLPPHEmf9DgMGw51jMj77VfGPAN2Kv4cfhlfgI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4/go.mod h1:Vz1JQXliGcQktFTN/LN6uGppAIRoLBR2bMvIMP0gOjc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 h1:tJ5RnkHCiSH0jyd6gROjlJtNwov0eGYNz8s8nFcR0jQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18/go.mod h1:++NHzT+nAF7ZPrHPsA+ENvsXkOO8wEu+C6RXltAG4/c= +github.com/aws/aws-sdk-go-v2/service/sns v1.31.4 h1:Bwb1nTBy6jrLJgSlI+jLt27rjyS1Kg030X5yWPnTecI= +github.com/aws/aws-sdk-go-v2/service/sns v1.31.4/go.mod h1:wDacBq+NshhM8KhdysbM4wRFxVyghyj7AAI+l8+o9f0= +github.com/aws/aws-sdk-go-v2/service/sqs v1.34.4 h1:FXPO72iKC5YmYNEANltl763bUj8A6qT20wx8Jwvxlsw= +github.com/aws/aws-sdk-go-v2/service/sqs v1.34.4/go.mod h1:7idt3XszF6sE9WPS1GqZRiDJOxw4oPtlRBXodWnCGjU= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 h1:zCsFCKvbj25i7p1u94imVoO447I/sFv8qq+lGJhRN0c= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.5/go.mod h1:ZeDX1SnKsVlejeuz41GiajjZpRSWR7/42q/EyA/QEiM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 h1:SKvPgvdvmiTWoi0GAJ7AsJfOz3ngVkD/ERbs5pUnHNI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5/go.mod h1:20sz31hv/WsPa3HhU3hfrIet2kxM4Pe0r20eBZ20Tac= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.4 h1:iAckBT2OeEK/kBDyN/jDtpEExhjeeA/Im2q4X0rJZT8= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.4/go.mod h1:vmSqFK+BVIwVpDAGZB3CoCXHzurt4qBE8lf+I/kRTh0= +github.com/aws/smithy-go v1.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4= +github.com/aws/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= +github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= +github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/_examples/pubsubs/aws-sns/main.go b/_examples/pubsubs/aws-sns/main.go new file mode 100644 index 000000000..e66e116a7 --- /dev/null +++ b/_examples/pubsubs/aws-sns/main.go @@ -0,0 +1,133 @@ +// Sources for https://watermill.io/docs/getting-started/ +package main + +import ( + "context" + "fmt" + "log" + "net/url" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + amazonsns "github.com/aws/aws-sdk-go-v2/service/sns" + amazonsqs "github.com/aws/aws-sdk-go-v2/service/sqs" + transport "github.com/aws/smithy-go/endpoints" + "github.com/samber/lo" + + "github.com/ThreeDotsLabs/watermill" + "github.com/ThreeDotsLabs/watermill-aws/sns" + "github.com/ThreeDotsLabs/watermill-aws/sqs" + "github.com/ThreeDotsLabs/watermill/message" +) + +func main() { + logger := watermill.NewStdLogger(false, false) + + snsOpts := []func(*amazonsns.Options){ + amazonsns.WithEndpointResolverV2(sns.OverrideEndpointResolver{ + Endpoint: transport.Endpoint{ + URI: *lo.Must(url.Parse("http://localstack:4566")), + }, + }), + } + + sqsOpts := []func(*amazonsqs.Options){ + amazonsqs.WithEndpointResolverV2(sqs.OverrideEndpointResolver{ + Endpoint: transport.Endpoint{ + URI: *lo.Must(url.Parse("http://localstack:4566")), + }, + }), + } + + topicResolver, err := sns.NewGenerateArnTopicResolver("000000000000", "us-east-1") + if err != nil { + panic(err) + } + + newSubscriber := func(name string) (message.Subscriber, error) { + subscriberConfig := sns.SubscriberConfig{ + AWSConfig: aws.Config{ + Credentials: aws.AnonymousCredentials{}, + }, + OptFns: snsOpts, + TopicResolver: topicResolver, + GenerateSqsQueueName: func(ctx context.Context, snsTopic sns.TopicArn) (string, error) { + topic, err := sns.ExtractTopicNameFromTopicArn(snsTopic) + if err != nil { + return "", err + } + + return fmt.Sprintf("%v-%v", topic, name), nil + }, + } + + sqsSubscriberConfig := sqs.SubscriberConfig{ + AWSConfig: aws.Config{ + Credentials: aws.AnonymousCredentials{}, + }, + OptFns: sqsOpts, + } + + return sns.NewSubscriber(subscriberConfig, sqsSubscriberConfig, logger) + } + + subA, err := newSubscriber("subA") + if err != nil { + panic(err) + } + + subB, err := newSubscriber("subB") + if err != nil { + panic(err) + } + + messagesA, err := subA.Subscribe(context.Background(), "example-topic") + if err != nil { + panic(err) + } + + messagesB, err := subB.Subscribe(context.Background(), "example-topic") + if err != nil { + panic(err) + } + + go process("A", messagesA) + go process("B", messagesB) + + publisherConfig := sns.PublisherConfig{ + AWSConfig: aws.Config{ + Credentials: aws.AnonymousCredentials{}, + }, + OptFns: snsOpts, + TopicResolver: topicResolver, + } + + publisher, err := sns.NewPublisher(publisherConfig, logger) + if err != nil { + panic(err) + } + + publishMessages(publisher) +} + +func publishMessages(publisher message.Publisher) { + for { + msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) + + if err := publisher.Publish("example-topic", msg); err != nil { + panic(err) + } + + time.Sleep(time.Second) + } +} + +func process(prefix string, messages <-chan *message.Message) { + for msg := range messages { + log.Printf("%v received message: %s, payload: %s", prefix, msg.UUID, string(msg.Payload)) + + // we need to Acknowledge that we received and processed the message, + // otherwise, it will be resent over and over again. + msg.Ack() + } +} diff --git a/_examples/pubsubs/aws-sqs/.validate_example.yml b/_examples/pubsubs/aws-sqs/.validate_example.yml new file mode 100644 index 000000000..b60d0b097 --- /dev/null +++ b/_examples/pubsubs/aws-sqs/.validate_example.yml @@ -0,0 +1,4 @@ +validation_cmd: "docker compose up" +teardown_cmd: "docker compose down" +timeout: 120 +expected_output: "received message: [0-9a-f\\-]+, payload: Hello, world!" diff --git a/_examples/pubsubs/aws-sqs/docker-compose.yml b/_examples/pubsubs/aws-sqs/docker-compose.yml new file mode 100644 index 000000000..fa75e2c2e --- /dev/null +++ b/_examples/pubsubs/aws-sqs/docker-compose.yml @@ -0,0 +1,24 @@ +services: + server: + image: golang:1.23 + restart: unless-stopped + volumes: + - .:/app + - $GOPATH/pkg/mod:/go/pkg/mod + working_dir: /app + command: go run main.go + + localstack: + image: localstack/localstack:latest + environment: + - SERVICES=sqs,sns + - AWS_DEFAULT_REGION=us-east-1 + - EDGE_PORT=4566 + ports: + - "4566-4597:4566-4597" + healthcheck: + test: awslocal sqs list-queues && awslocal sns list-topics + interval: 5s + timeout: 5s + retries: 5 + start_period: 30s diff --git a/_examples/pubsubs/aws-sqs/go.mod b/_examples/pubsubs/aws-sqs/go.mod new file mode 100644 index 000000000..282fe5acf --- /dev/null +++ b/_examples/pubsubs/aws-sqs/go.mod @@ -0,0 +1,22 @@ +module main + +require ( + github.com/ThreeDotsLabs/watermill v1.3.7 + github.com/ThreeDotsLabs/watermill-aws v1.0.0-rc.2 + github.com/aws/aws-sdk-go-v2 v1.30.4 + github.com/aws/aws-sdk-go-v2/service/sqs v1.34.4 + github.com/aws/smithy-go v1.20.4 + github.com/samber/lo v1.47.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/lithammer/shortuuid/v3 v3.0.7 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + golang.org/x/text v0.16.0 // indirect +) + +go 1.21 diff --git a/_examples/pubsubs/aws-sqs/go.sum b/_examples/pubsubs/aws-sqs/go.sum new file mode 100644 index 000000000..d04189996 --- /dev/null +++ b/_examples/pubsubs/aws-sqs/go.sum @@ -0,0 +1,57 @@ +github.com/ThreeDotsLabs/watermill v1.3.7 h1:NV0PSTmuACVEOV4dMxRnmGXrmbz8U83LENOvpHekN7o= +github.com/ThreeDotsLabs/watermill v1.3.7/go.mod h1:lBnrLbxOjeMRgcJbv+UiZr8Ylz8RkJ4m6i/VN/Nk+to= +github.com/ThreeDotsLabs/watermill-aws v1.0.0-rc.2 h1:5eyROGg3sIB4eZGEkDjbjE/TXgqCVpR1yTGAWp9zxbQ= +github.com/ThreeDotsLabs/watermill-aws v1.0.0-rc.2/go.mod h1:TTht9EHVmVF5iR5joom6VymqgkwcuxsRQfLBc5ITy28= +github.com/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8= +github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0= +github.com/aws/aws-sdk-go-v2/config v1.27.28 h1:OTxWGW/91C61QlneCtnD62NLb4W616/NM1jA8LhJqbg= +github.com/aws/aws-sdk-go-v2/config v1.27.28/go.mod h1:uzVRVtJSU5EFv6Fu82AoVFKozJi2ZCY6WRCXj06rbvs= +github.com/aws/aws-sdk-go-v2/credentials v1.17.28 h1:m8+AHY/ND8CMHJnPoH7PJIRakWGa4gbfbxuY9TGTUXM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.28/go.mod h1:6TF7dSc78ehD1SL6KpRIPKMA1GyyWflIkjqg+qmf4+c= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 h1:yjwoSyDZF8Jth+mUk5lSPJCkMC0lMy6FaCD51jm6ayE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12/go.mod h1:fuR57fAgMk7ot3WcNQfb6rSEn+SUffl7ri+aa8uKysI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 h1:TNyt/+X43KJ9IJJMjKfa3bNTiZbUP7DeCxfbTROESwY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 h1:KypMCbLPPHEmf9DgMGw51jMj77VfGPAN2Kv4cfhlfgI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4/go.mod h1:Vz1JQXliGcQktFTN/LN6uGppAIRoLBR2bMvIMP0gOjc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 h1:tJ5RnkHCiSH0jyd6gROjlJtNwov0eGYNz8s8nFcR0jQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18/go.mod h1:++NHzT+nAF7ZPrHPsA+ENvsXkOO8wEu+C6RXltAG4/c= +github.com/aws/aws-sdk-go-v2/service/sqs v1.34.4 h1:FXPO72iKC5YmYNEANltl763bUj8A6qT20wx8Jwvxlsw= +github.com/aws/aws-sdk-go-v2/service/sqs v1.34.4/go.mod h1:7idt3XszF6sE9WPS1GqZRiDJOxw4oPtlRBXodWnCGjU= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 h1:zCsFCKvbj25i7p1u94imVoO447I/sFv8qq+lGJhRN0c= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.5/go.mod h1:ZeDX1SnKsVlejeuz41GiajjZpRSWR7/42q/EyA/QEiM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 h1:SKvPgvdvmiTWoi0GAJ7AsJfOz3ngVkD/ERbs5pUnHNI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5/go.mod h1:20sz31hv/WsPa3HhU3hfrIet2kxM4Pe0r20eBZ20Tac= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.4 h1:iAckBT2OeEK/kBDyN/jDtpEExhjeeA/Im2q4X0rJZT8= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.4/go.mod h1:vmSqFK+BVIwVpDAGZB3CoCXHzurt4qBE8lf+I/kRTh0= +github.com/aws/smithy-go v1.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4= +github.com/aws/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= +github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= +github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/_examples/pubsubs/aws-sqs/main.go b/_examples/pubsubs/aws-sqs/main.go new file mode 100644 index 000000000..b3c96848b --- /dev/null +++ b/_examples/pubsubs/aws-sqs/main.go @@ -0,0 +1,85 @@ +// Sources for https://watermill.io/docs/getting-started/ +package main + +import ( + "context" + "log" + "net/url" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + amazonsqs "github.com/aws/aws-sdk-go-v2/service/sqs" + transport "github.com/aws/smithy-go/endpoints" + "github.com/samber/lo" + + "github.com/ThreeDotsLabs/watermill" + "github.com/ThreeDotsLabs/watermill-aws/sqs" + "github.com/ThreeDotsLabs/watermill/message" +) + +func main() { + logger := watermill.NewStdLogger(false, false) + + sqsOpts := []func(*amazonsqs.Options){ + amazonsqs.WithEndpointResolverV2(sqs.OverrideEndpointResolver{ + Endpoint: transport.Endpoint{ + URI: *lo.Must(url.Parse("http://localstack:4566")), + }, + }), + } + + subscriberConfig := sqs.SubscriberConfig{ + AWSConfig: aws.Config{ + Credentials: aws.AnonymousCredentials{}, + }, + OptFns: sqsOpts, + } + + subscriber, err := sqs.NewSubscriber(subscriberConfig, logger) + if err != nil { + panic(err) + } + + messages, err := subscriber.Subscribe(context.Background(), "example-topic") + if err != nil { + panic(err) + } + + go process(messages) + + publisherConfig := sqs.PublisherConfig{ + AWSConfig: aws.Config{ + Credentials: aws.AnonymousCredentials{}, + }, + OptFns: sqsOpts, + } + + publisher, err := sqs.NewPublisher(publisherConfig, logger) + if err != nil { + panic(err) + } + + publishMessages(publisher) +} + +func publishMessages(publisher message.Publisher) { + for { + msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) + + if err := publisher.Publish("example-topic", msg); err != nil { + panic(err) + } + + time.Sleep(time.Second) + } +} + +func process(messages <-chan *message.Message) { + for msg := range messages { + log.Printf("received message: %s, payload: %s", msg.UUID, string(msg.Payload)) + + // we need to Acknowledge that we received and processed the message, + // otherwise, it will be resent over and over again. + msg.Ack() + } +} diff --git a/docs/content/docs/getting-started.md b/docs/content/docs/getting-started.md index 4c0765937..abf46ab1c 100644 --- a/docs/content/docs/getting-started.md +++ b/docs/content/docs/getting-started.md @@ -198,6 +198,42 @@ A more detailed explanation of how it is working (and how to add live code reloa {{% load-snippet-partial file="src-link/_examples/pubsubs/sql/main.go" first_line_contains="func process" %}} {{< /tab >}} +{{< tab "AWS SQS" "aws-sqs" >}} + +
+Running in Docker + +{{% load-snippet file="src-link/_examples/pubsubs/aws-sqs/docker-compose.yml" type="yaml" %}} + +The source should go to `main.go`. + +To run, execute `docker-compose up`. + +A more detailed explanation of how it is working (and how to add live code reload) can be found in [*Go Docker dev environment* article](https://threedots.tech/post/go-docker-dev-environment-with-go-modules-and-live-code-reloading/). +
+ +{{% load-snippet-partial file="src-link/_examples/pubsubs/aws-sqs/main.go" first_line_contains="package main" last_line_contains="process(messages)" %}} +{{% load-snippet-partial file="src-link/_examples/pubsubs/aws-sqs/main.go" first_line_contains="func process" %}} +{{< /tab >}} + +{{< tab "AWS SNS" "aws-sns" >}} + +
+Running in Docker + +{{% load-snippet file="src-link/_examples/pubsubs/aws-sns/docker-compose.yml" type="yaml" %}} + +The source should go to `main.go`. + +To run, execute `docker-compose up`. + +A more detailed explanation of how it is working (and how to add live code reload) can be found in [*Go Docker dev environment* article](https://threedots.tech/post/go-docker-dev-environment-with-go-modules-and-live-code-reloading/). +
+ +{{% load-snippet-partial file="src-link/_examples/pubsubs/aws-sns/main.go" first_line_contains="package main" last_line_contains="go process(" padding_after="1" %}} +{{% load-snippet-partial file="src-link/_examples/pubsubs/aws-sns/main.go" first_line_contains="func process" %}} +{{< /tab >}} + {{< /tabs >}} ### Creating Messages @@ -249,6 +285,14 @@ if err != nil { {{% load-snippet-partial file="src-link/_examples/pubsubs/sql/main.go" first_line_contains="message.NewMessage" last_line_contains="publisher.Publish" padding_after="2" %}} {{< /tab >}} +{{< tab "AWS SQS" "aws-sqs" >}} +{{% load-snippet-partial file="src-link/_examples/pubsubs/aws-sqs/main.go" first_line_contains="message.NewMessage" last_line_contains="publisher.Publish" padding_after="2" %}} +{{< /tab >}} + +{{< tab "AWS SNS" "aws-sns" >}} +{{% load-snippet-partial file="src-link/_examples/pubsubs/aws-sns/main.go" first_line_contains="message.NewMessage" last_line_contains="publisher.Publish" padding_after="2" %}} +{{< /tab >}} + {{< /tabs >}} ## Router diff --git a/docs/hugo_stats.json b/docs/hugo_stats.json index cb541b1e1..c201774b2 100644 --- a/docs/hugo_stats.json +++ b/docs/hugo_stats.json @@ -433,6 +433,10 @@ "tabs-getting-started-4-tab", "tabs-getting-started-5", "tabs-getting-started-5-tab", + "tabs-getting-started-6", + "tabs-getting-started-6-tab", + "tabs-getting-started-7", + "tabs-getting-started-7-tab", "tabs-publishing-0", "tabs-publishing-0-tab", "tabs-publishing-1", @@ -445,6 +449,10 @@ "tabs-publishing-4-tab", "tabs-publishing-5", "tabs-publishing-5-tab", + "tabs-publishing-6", + "tabs-publishing-6-tab", + "tabs-publishing-7", + "tabs-publishing-7-tab", "testing", "the-pubsub-interface", "throttle", From 573eef2d85f1edc0683605eb8d5d3b84f61636f5 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Sun, 27 Oct 2024 21:42:26 +0100 Subject: [PATCH 06/10] added NewSlogLoggerWithLevelMapping (#510) * added NewSlogLoggerWithLevelMapping * added docs * gitignore hugo stuff --- .gitignore | 1 + docs/content/docs/getting-started.md | 3 + docs/hugo_stats.json | 474 --------------------------- slog.go | 40 ++- slog_test.go | 48 ++- 5 files changed, 85 insertions(+), 481 deletions(-) delete mode 100644 docs/hugo_stats.json diff --git a/.gitignore b/.gitignore index f85d2bf11..f7a78e709 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ docs/node_modules/ docs/public/ docs/content/src-link docs/content/middleware +docs/hugo_stats.json *.out *.log .mod-cache diff --git a/docs/content/docs/getting-started.md b/docs/content/docs/getting-started.md index abf46ab1c..2a645473f 100644 --- a/docs/content/docs/getting-started.md +++ b/docs/content/docs/getting-started.md @@ -348,6 +348,9 @@ The complete example's source can be found at [/_examples/basic/3-router/main.go To see Watermill's logs, pass any logger that implements the [LoggerAdapter](https://github.com/ThreeDotsLabs/watermill/blob/master/log.go). For experimental development, you can use `NewStdLogger`. +Watermill provides ready-to-use `slog` adapter. You can create it with [`watermill.NewSlogLogger`](https://github.com/ThreeDotsLabs/watermill/blob/master/slog.go). +You can also map Watermill's log levels to `slog` levels with [`watermill.NewSlogLoggerWithLevelMapping`](https://github.com/ThreeDotsLabs/watermill/blob/master/slog.go). + ## What's next? For more details, see [documentation topics]({{< ref "/docs" >}}). diff --git a/docs/hugo_stats.json b/docs/hugo_stats.json deleted file mode 100644 index c201774b2..000000000 --- a/docs/hugo_stats.json +++ /dev/null @@ -1,474 +0,0 @@ -{ - "htmlElements": { - "tags": [ - "a", - "article", - "aside", - "base", - "blockquote", - "body", - "br", - "button", - "circle", - "code", - "details", - "div", - "em", - "figcaption", - "figure", - "footer", - "form", - "g", - "h1", - "h2", - "h3", - "h4", - "h5", - "head", - "header", - "html", - "iframe", - "img", - "input", - "kbd", - "label", - "li", - "line", - "link", - "main", - "meta", - "nav", - "noscript", - "ol", - "p", - "path", - "pre", - "script", - "section", - "small", - "span", - "strong", - "style", - "summary", - "svg", - "table", - "tbody", - "td", - "template", - "th", - "thead", - "time", - "title", - "tr", - "ul" - ], - "classes": [ - "DocSearch-Label", - "active", - "advanced", - "anchor", - "bi", - "bi-airplane-fill", - "bi-globe-americas", - "bi-puzzle-fill", - "bi-rocket", - "bi-shield-fill-check", - "btn", - "btn-close", - "btn-cta", - "btn-lg", - "btn-link", - "btn-outline-primary", - "btn-primary", - "callout", - "callout-body", - "callout-content", - "callout-danger", - "card", - "card-body", - "card-list", - "categories", - "chroma", - "col-lg-10", - "col-lg-11", - "col-lg-12", - "col-lg-5", - "col-lg-8", - "col-lg-9", - "col-md-12", - "col-xl-3", - "col-xl-4", - "col-xl-8", - "col-xl-9", - "container", - "container-fluid", - "container-lg", - "content", - "contributors", - "created-date", - "d-flex", - "d-lg-block", - "d-lg-none", - "d-md-block", - "d-md-none", - "d-none", - "d-xl-block", - "d-xl-none", - "development", - "docs", - "docs-content", - "docs-links", - "docs-sidebar", - "docs-toc", - "doks-sidebar", - "edit-page", - "error404", - "expressive-code", - "fade", - "feather", - "feather-edit-2", - "flex-column", - "flex-grow-1", - "flex-lg-row", - "flex-md-row", - "flex-row", - "flex-sm-row", - "flex-xl-nowrap", - "footer", - "form-control", - "form-control-lg", - "frame", - "fs-5", - "h-auto", - "h4", - "h5", - "header", - "highlight", - "home", - "icon", - "icon-tabler", - "icon-tabler-arrow-left", - "icon-tabler-arrow-right", - "icon-tabler-brand-github", - "icon-tabler-dots-vertical", - "icon-tabler-external-link", - "icon-tabler-menu", - "icon-tabler-moon", - "icon-tabler-search", - "icon-tabler-sun", - "icon-tabler-x", - "is-terminal", - "justify-content-between", - "justify-content-center", - "justify-content-end", - "lead", - "list", - "list-inline", - "list-inline-item", - "list-nested", - "list-unstyled", - "list-view", - "m-2", - "m-3", - "mb-0", - "mb-1", - "mb-4", - "mb-5", - "me-2", - "me-auto", - "me-lg-1", - "me-lg-3", - "message", - "modal", - "modal-body", - "modal-content", - "modal-dialog", - "modal-dialog-scrollable", - "modal-footer", - "modal-fullscreen-md-down", - "modal-header", - "modal-title", - "ms-1", - "ms-2", - "ms-3", - "ms-auto", - "ms-lg-2", - "mt-3", - "mt-4", - "mt-5", - "mt-n3", - "mx-2", - "mx-auto", - "mx-xl-auto", - "my-3", - "nav", - "nav-item", - "nav-link", - "nav-tabs", - "navbar", - "navbar-brand", - "navbar-expand-lg", - "navbar-nav", - "not-content", - "offcanvas", - "offcanvas-body", - "offcanvas-end", - "offcanvas-header", - "offcanvas-start", - "offcanvas-title", - "only-dark", - "only-light", - "order-3", - "order-lg-4", - "p-0", - "p-2", - "page-footer-meta", - "page-links", - "page-nav", - "pb-2", - "pb-3", - "pe-4", - "ps-3", - "pt-4", - "pubsubs", - "px-0", - "query-no-results", - "rounded-pill", - "row", - "search-form", - "search-input", - "search-loading", - "search-no-recent", - "search-no-results", - "search-result", - "search-results", - "search-text", - "section", - "section-features", - "section-md", - "section-nav", - "show", - "single", - "smaller", - "social-link", - "src-link", - "status", - "sticky-top", - "stretched-link", - "submitted", - "support", - "tab-content", - "tab-pane", - "tags", - "taxonomy", - "text-body-secondary", - "text-center", - "text-decoration-none", - "text-end", - "text-lg-end", - "text-lg-start", - "text-muted", - "text-reset", - "title", - "title-submitted", - "toc-mobile", - "visually-hidden", - "w-100", - "wrap" - ], - "ids": [ - "TableOfContents", - "ack", - "acknack-mechanism", - "adding-handler-after-the-router-has-started", - "amqp-consumer-groups", - "amqp-topologybuilder", - "async-publish", - "at-least-once-delivery", - "available-middleware", - "building-a-read-model-with-the-event-handler", - "building-blocks", - "built-in-implementations", - "buttonColorMode", - "characteristics", - "circuit-breaker", - "close", - "close-1", - "closing-the-router", - "code-standards", - "command", - "command-and-event-marshaler", - "command-bus", - "command-handler", - "command-handler-1", - "command-processor", - "community-support", - "configuration", - "configuring", - "connecting", - "context", - "controlling-fanin-component", - "core-nats", - "correlation", - "cqrs", - "creating-messages", - "custom-http-status-codes", - "customization", - "debugging-pubsub-tests", - "deduplicator", - "delay-metadata", - "delay-on-error", - "doks-docs-nav", - "duplicator", - "ensuring-that-the-router-is-running", - "event", - "event-bus", - "event-group-processor", - "event-handler", - "event-handler-1", - "event-handler-groups", - "event-processor", - "example", - "example-application", - "example-domain", - "examples", - "execution-models", - "existing-issues", - "exported-metrics", - "exposing-the-metrics-endpoint", - "extending-schema", - "fanin-component", - "fanout-component", - "forwarder-component", - "full-example", - "generic-handlers", - "grafana-dashboard", - "grep-is-your-friend", - "h-rh-i-0", - "handler", - "handlers", - "how-can-i-help", - "i-have-a-deadlock", - "ignore-errors", - "implementing-custom-pubsub", - "importing-the-dashboard", - "install", - "installation", - "instant-ack", - "introduction", - "local-development", - "logging", - "marshaler", - "marshalingunmarshaling", - "middleware", - "nack", - "nav-tab", - "nav-tabContent", - "new-ideas", - "new-pubsub-implementations", - "no-publisher-handler", - "observability", - "offcanvasNavMain", - "offcanvasNavMainLabel", - "offcanvasNavSection", - "offcanvasNavSectionLabel", - "offsets-adapter", - "one-minute-background", - "other", - "partitioning", - "passing-custom-sarama-config", - "passing-redisuniversalclient", - "plugin", - "poison", - "producing-messages", - "professional-support", - "publisher", - "publisher--subscriber", - "publisher-configuration", - "publishing", - "publishing-an-event-first-storing-data-next", - "publishing-messages", - "publishing-messages-in-transactions-and-why-we-should-care", - "publishing-multiple-messages", - "pubsubs", - "query", - "randomfail", - "receiving-acknack", - "recoverer", - "retry", - "router", - "router-configuration", - "running", - "running-a-single-test", - "running-the-router", - "schema", - "search-form", - "searchModal", - "searchModalLabel", - "searchResults", - "searchToggleDesktop", - "searchToggleMobile", - "sending-a-command", - "sending-ack", - "socialMenu", - "stopping-running-handler", - "storing-data-and-publishing-an-event-in-one-transaction", - "storing-data-first-publishing-an-event-next", - "subscriber", - "subscriber-configuration", - "subscribing", - "subscribing-for-messages", - "subscription-name", - "support", - "supported-pubsubs", - "tabs-getting-started-0", - "tabs-getting-started-0-tab", - "tabs-getting-started-1", - "tabs-getting-started-1-tab", - "tabs-getting-started-2", - "tabs-getting-started-2-tab", - "tabs-getting-started-3", - "tabs-getting-started-3-tab", - "tabs-getting-started-4", - "tabs-getting-started-4-tab", - "tabs-getting-started-5", - "tabs-getting-started-5-tab", - "tabs-getting-started-6", - "tabs-getting-started-6-tab", - "tabs-getting-started-7", - "tabs-getting-started-7-tab", - "tabs-publishing-0", - "tabs-publishing-0-tab", - "tabs-publishing-1", - "tabs-publishing-1-tab", - "tabs-publishing-2", - "tabs-publishing-2-tab", - "tabs-publishing-3", - "tabs-publishing-3-tab", - "tabs-publishing-4", - "tabs-publishing-4-tab", - "tabs-publishing-5", - "tabs-publishing-5-tab", - "tabs-publishing-6", - "tabs-publishing-6-tab", - "tabs-publishing-7", - "tabs-publishing-7-tab", - "testing", - "the-pubsub-interface", - "throttle", - "timeout", - "tls-config", - "toc", - "todo-list", - "topic", - "transactions", - "universal-tests", - "usage", - "what-is-watermill", - "whats-next", - "why-use-watermill", - "wiring-it-up", - "wrapping-publishers-subscribers-and-handlers" - ] - } -} diff --git a/slog.go b/slog.go index a7f887766..47dbf930c 100644 --- a/slog.go +++ b/slog.go @@ -21,25 +21,40 @@ func slogAttrsFromFields(fields LogFields) []any { // SlogLoggerAdapter wraps [slog.Logger]. type SlogLoggerAdapter struct { slog *slog.Logger + + watermillLevelToSlog map[slog.Level]slog.Level } // Error logs a message to [slog.LevelError]. func (s *SlogLoggerAdapter) Error(msg string, err error, fields LogFields) { - s.slog.Error(msg, append(slogAttrsFromFields(fields), "error", err)...) + s.log(slog.LevelError, msg, append(slogAttrsFromFields(fields), "error", err)...) } // Info logs a message to [slog.LevelInfo]. func (s *SlogLoggerAdapter) Info(msg string, fields LogFields) { - s.slog.Info(msg, slogAttrsFromFields(fields)...) + s.log(slog.LevelInfo, msg, slogAttrsFromFields(fields)...) } // Debug logs a message to [slog.LevelDebug]. func (s *SlogLoggerAdapter) Debug(msg string, fields LogFields) { - s.slog.Debug(msg, slogAttrsFromFields(fields)...) + s.log(slog.LevelDebug, msg, slogAttrsFromFields(fields)...) } // Trace logs a message to [LevelTrace]. func (s *SlogLoggerAdapter) Trace(msg string, fields LogFields) { + s.log( + LevelTrace, + msg, + slogAttrsFromFields(fields)..., + ) +} + +func (s *SlogLoggerAdapter) log(level slog.Level, msg string, args ...any) { + mappedLevel, ok := s.watermillLevelToSlog[level] + if ok { + level = mappedLevel + } + s.slog.Log( // Void context, following the slog example // as it treats context slightly differently from @@ -48,15 +63,15 @@ func (s *SlogLoggerAdapter) Trace(msg string, fields LogFields) { // See the [slog] package documentation // for more details. context.Background(), - LevelTrace, + level, msg, - slogAttrsFromFields(fields)..., + args..., ) } // With return a [SlogLoggerAdapter] with a set of fields injected into all consequent logging messages. func (s *SlogLoggerAdapter) With(fields LogFields) LoggerAdapter { - return &SlogLoggerAdapter{slog: s.slog.With(slogAttrsFromFields(fields)...)} + return &SlogLoggerAdapter{slog: s.slog.With(slogAttrsFromFields(fields)...), watermillLevelToSlog: s.watermillLevelToSlog} } // NewSlogLogger creates an adapter to the standard library's structured logging package. A `nil` logger is substituted for the result of [slog.Default]. @@ -68,3 +83,16 @@ func NewSlogLogger(logger *slog.Logger) LoggerAdapter { slog: logger, } } + +// NewSlogLoggerWithLevelMapping creates an adapter to the standard library's structured logging package. A `nil` logger is substituted for the result of [slog.Default]. +// The `watermillLevelToSlog` parameter is a map that maps Watermill's log levels to the levels of the structured logger. +// It's helpful, when want to for example log Watermill's info logs as debug in slog. +func NewSlogLoggerWithLevelMapping(logger *slog.Logger, watermillLevelToSlog map[slog.Level]slog.Level) LoggerAdapter { + if logger == nil { + logger = slog.Default() + } + return &SlogLoggerAdapter{ + slog: logger, + watermillLevelToSlog: watermillLevelToSlog, + } +} diff --git a/slog_test.go b/slog_test.go index 7d27b469c..fc61568e6 100644 --- a/slog_test.go +++ b/slog_test.go @@ -42,12 +42,58 @@ func TestSlogLoggerAdapter(t *testing.T) { }) assert.Equal(t, - strings.TrimSpace(b.String()), strings.TrimSpace(` time=[omit] level=DEBUG-4 msg="test trace" common1=commonvalue field1=value1 time=[omit] level=ERROR msg="test error" common1=commonvalue field2=value2 error="error message" time=[omit] level=INFO msg="test info" common1=commonvalue field3=value3 `), + strings.TrimSpace(b.String()), + "Logging output does not match saved template.", + ) +} + +func TestSlogLoggerAdapter_level_mapping(t *testing.T) { + b := &bytes.Buffer{} + + logger := NewSlogLoggerWithLevelMapping( + slog.New(slog.NewTextHandler( + b, // output + &slog.HandlerOptions{ + Level: LevelTrace, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == "time" && len(groups) == 0 { + // omit time stamp to make the test idempotent + a.Value = slog.StringValue("[omit]") + } + return a + }, + }, + )), + map[slog.Level]slog.Level{ + slog.LevelInfo: slog.LevelDebug, + }, + ) + + logger = logger.With(LogFields{ + "common1": "commonvalue", + }) + logger.Trace("test trace", LogFields{ + "field1": "value1", + }) + logger.Error("test error", errors.New("error message"), LogFields{ + "field2": "value2", + }) + logger.Info("test info mapped to debug", LogFields{ + "field3": "value3", + }) + + assert.Equal(t, + strings.TrimSpace(` +time=[omit] level=DEBUG-4 msg="test trace" common1=commonvalue field1=value1 +time=[omit] level=ERROR msg="test error" common1=commonvalue field2=value2 error="error message" +time=[omit] level=DEBUG msg="test info mapped to debug" common1=commonvalue field3=value3 + `), + strings.TrimSpace(b.String()), "Logging output does not match saved template.", ) } From 94c0209624b7aa6a3263d0038a484542d99f7edf Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Mon, 28 Oct 2024 11:20:49 +0100 Subject: [PATCH 07/10] added AWS v1.0 docs (#506) --- docs/build.sh | 1 + docs/content/pubsubs/amazonsqs.md | 9 - docs/content/pubsubs/aws.md | 248 ++++++++++++++++++ ...s_cdf9d7c9eb97e4550ded64a8776dd9e8.content | 2 +- ...scss_cdf9d7c9eb97e4550ded64a8776dd9e8.json | 2 +- netlify.toml | 5 + 6 files changed, 256 insertions(+), 11 deletions(-) delete mode 100644 docs/content/pubsubs/amazonsqs.md create mode 100644 docs/content/pubsubs/aws.md diff --git a/docs/build.sh b/docs/build.sh index 7024a56ea..cef7aee2a 100755 --- a/docs/build.sh +++ b/docs/build.sh @@ -77,6 +77,7 @@ cloneOrPull "https://github.com/ThreeDotsLabs/watermill-sql.git" content/src-lin cloneOrPull "https://github.com/ThreeDotsLabs/watermill-firestore.git" content/src-link/watermill-firestore cloneOrPull "https://github.com/ThreeDotsLabs/watermill-bolt.git" content/src-link/watermill-bolt cloneOrPull "https://github.com/ThreeDotsLabs/watermill-redisstream.git" content/src-link/watermill-redisstream +cloneOrPull "https://github.com/ThreeDotsLabs/watermill-aws.git" content/src-link/watermill-aws find content/src-link -name '*.md' -delete find content/src-link -name '*.html' -delete diff --git a/docs/content/pubsubs/amazonsqs.md b/docs/content/pubsubs/amazonsqs.md deleted file mode 100644 index ce370f195..000000000 --- a/docs/content/pubsubs/amazonsqs.md +++ /dev/null @@ -1,9 +0,0 @@ -+++ -title = "Amazon SNS/SQS (alpha)" -description = "Work in Progress" -date = 2021-07-29T15:30:00+02:00 -bref = "Work in Progress" -weight = 10 -+++ - -There's an initial implementation looking for contributors and testers: https://github.com/ThreeDotsLabs/watermill-amazonsqs diff --git a/docs/content/pubsubs/aws.md b/docs/content/pubsubs/aws.md new file mode 100644 index 000000000..f54460a17 --- /dev/null +++ b/docs/content/pubsubs/aws.md @@ -0,0 +1,248 @@ ++++ +title = "Amazon AWS SNS/SQS" +description = "AWS SQS and SNS are fully-managed message queuing and Pub/Sub-like services that make it easy to decouple and scale microservices, distributed systems, and serverless applications." +date = 2024-10-19T15:30:00+02:00 +bref = "AWS SQS and SNS are fully-managed message queuing and Pub/Sub-like services that make it easy to decouple and scale microservices, distributed systems, and serverless applications." +weight = 10 ++++ + +AWS SQS and SNS are fully-managed message queuing and Pub/Sub-like services that make it easy to decouple +and scale microservices, distributed systems, and serverless applications. + +Watermill provides a simple way to use AWS SQS and SNS with Go. +It handles all the AWS SDK internals and provides a simple API to publish and subscribe messages. + +Official Documentation: +- [SQS](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/welcome.html) +- [SNS](https://docs.aws.amazon.com/sns/latest/dg/welcome.html) + +You can find a fully functional example with AWS SNS in the [Watermill examples](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/aws). + +## Installation + +```bash +go get github.com/ThreeDotsLabs/watermill-aws +``` + +## SQS vs SNS + +While both SQS and SNS are messaging services provided by AWS, they serve different purposes and are best suited for different scenarios in your Watermill applications. + +### How SNS is connected with SQS + +To use SNS as a Pub/Sub (to have multiple subscribers receiving the same message), you need to create an SNS topic and subscribe to SQS queues. +When a message is published to the SNS topic, it will be delivered to all subscribed SQS queues. +We implemented this logic in the `watermill-aws` package out of the box. + +When you subscribe to an SNS topic, Watermill AWS creates an SQS queue and subscribes to it. + +[![](https://mermaid.ink/img/pako:eNptkU1uwyAQRq-CWLWScwEW2TjbVnboru4Cw9hB5ccZoFIV5e7FxVRKUjaM5j0-jZgLlV4BZTTAOYGTcNBiRmEHR_JZBEYt9SJcJB0RgfBXTro0Gh1OgI_OijfrzS9a_mP0xchXnyABeXcfj1ZbU3gag0Q9AhaxqN1uv8-U1VGIhRDEDKHgjFahzwIHpyot0Hi_lKqtUueNIZPH-5ie77LSMnKEmNDd5rQFdehlblf2FJ7vwg9gIMLt2zV5w0ew_usPkwm9Jef1Y4qZx6cNtYBWaJW3dFnbA40nsDBQlksl8HOgg7tmT6To-beTlEVM0NC0KBHrRimbhAm5C0pHjy9l7b_bbyj6NJ824_oDU0CtBA?type=png)](https://mermaid.live/edit#pako:eNptkU1uwyAQRq-CWLWScwEW2TjbVnboru4Cw9hB5ccZoFIV5e7FxVRKUjaM5j0-jZgLlV4BZTTAOYGTcNBiRmEHR_JZBEYt9SJcJB0RgfBXTro0Gh1OgI_OijfrzS9a_mP0xchXnyABeXcfj1ZbU3gag0Q9AhaxqN1uv8-U1VGIhRDEDKHgjFahzwIHpyot0Hi_lKqtUueNIZPH-5ie77LSMnKEmNDd5rQFdehlblf2FJ7vwg9gIMLt2zV5w0ew_usPkwm9Jef1Y4qZx6cNtYBWaJW3dFnbA40nsDBQlksl8HOgg7tmT6To-beTlEVM0NC0KBHrRimbhAm5C0pHjy9l7b_bbyj6NJ824_oDU0CtBA) + +We can say, that a single SQS queue acts as a consumer group or subscription in other Pub/Sub implementations. + +The mechanism is detailed in [AWS documentation](https://docs.aws.amazon.com/sns/latest/dg/subscribe-sqs-queue-to-sns-topic.html). + +### How to choose between SQS and SNS + +#### SQS (Simple Queue Service) + +- Use when you need a simple message queue with a single consumer. +- Great for task queues or background job processing. +- Supports exactly-once processing (with FIFO queues) and guaranteed order (mostly). + +Example use case: Processing user uploads in the background. + +[![](https://mermaid.ink/img/pako:eNplkT1uwzAMRq8icGoB5wIasjhrASdevdASHQvVj0NJAYogd69c2wmaaCL4HilI3w1U0AQSIl0yeUUHg2dG13lRzoScjDIT-iQagVG0x1Y0ubcmjsTvzoxX65gp07tRb7zNfVRs-nnNojW7_b4QuV0gHMWIZ4oLtiFMS1U_xGCtGAK_mIXtilJLcaKU2W_4OV1Qw0GV9sY-4ufL8gNZSvR_dt684hO5cH1gMXBw4vJ8M3kNFThih0aX773N7Q7SSI46kKXUyN8ddP5ePMwptD9egUycqYI8aUxbFCAHtLF0SZsU-GvJ6y-2Cjjk87ga91_dnpaW?type=png)](https://mermaid.live/edit#pako:eNplkT1uwzAMRq8icGoB5wIasjhrASdevdASHQvVj0NJAYogd69c2wmaaCL4HilI3w1U0AQSIl0yeUUHg2dG13lRzoScjDIT-iQagVG0x1Y0ubcmjsTvzoxX65gp07tRb7zNfVRs-nnNojW7_b4QuV0gHMWIZ4oLtiFMS1U_xGCtGAK_mIXtilJLcaKU2W_4OV1Qw0GV9sY-4ufL8gNZSvR_dt684hO5cH1gMXBw4vJ8M3kNFThih0aX773N7Q7SSI46kKXUyN8ddP5ePMwptD9egUycqYI8aUxbFCAHtLF0SZsU-GvJ6y-2Cjjk87ga91_dnpaW) + +#### SNS (Simple Notification Service) + +- Use when you need to broadcast messages to multiple subscribers. +- Perfect for implementing pub/sub patterns. +- Useful for event-driven architectures. +- Supports multiple types of subscribers (SQS, Lambda, HTTP/S, email, SMS, etc.). + +Example use case: Notifying multiple services about a new user registration. + +Our SNS implementation in Watermill automatically creates and manages SQS queues for each subscriber, simplifying the process of using SNS with multiple SQS queues. + +Remember, you can use both in the same application where appropriate. For instance, you might use SNS to broadcast events and SQS to process specific tasks triggered by those events. + +To learn how SNS and SQS work together, see the [How SNS is connected with SQS](#how-sns-is-connected-with-sqs) section. + +## SQS + +### Characteristics + +| Feature | Implements | Note | +|---------------------|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ConsumerGroups | no | it's a queue, for consumer groups-like functionality use [SNS](#sns) | +| ExactlyOnceDelivery | no | [yes](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues-exactly-once-processing.html) | +| GuaranteedOrder | yes\* | from [AWS Docs](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues.html): _"(...) due to the highly distributed architecture, more than one copy of a message might be delivered, and messages may occasionally arrive out of order. Despite this, standard queues make a best-effort attempt to maintain the order in which messages are sent."_ | +| Persistent | yes | | + +### Required permissions + +- `"sqs:ReceiveMessage"` +- `"sqs:DeleteMessage"` +- `"sqs:GetQueueUrl"` +- `"sqs:CreateQueue"` +- `"sqs:GetQueueAttributes"` +- `"sqs:SendMessage"` +- `"sqs:ChangeMessageVisibility"` + +[todo - verify] + +### SQS Configuration + +{{% load-snippet-partial file="src-link/watermill-aws/sqs/config.go" first_line_contains="type SubscriberConfig struct " last_line_contains="type GenerateCreateQueueInputFunc" %}} + +### Resolving Queue URL + +In the Watermill model, we are normalizing the AWS queue url to `topic` used in the `Publish` and `Subscribe` methods. + +To give you flexibility of what you want to use as a topic in Watermill, you can customize resolving the queue URL. + +{{% load-snippet-partial file="src-link/watermill-aws/sqs/url_resolver.go" first_line_contains="// QueueUrlResolver" last_line_contains="GenerateQueueUrlResolver" %}} + +You can implement your own `QueueUrlResolver` or use one of the provided resolvers. + +By default, `GetQueueUrlByNameUrlResolver` resolver is used: + +{{% load-snippet-partial file="src-link/watermill-aws/sqs/url_resolver.go" first_line_contains="// GetQueueUrlByNameUrlResolver " last_line_contains="NewGetQueueUrlByNameUrlResolver" %}} + +There are two more resolvers available: + +{{% load-snippet-partial file="src-link/watermill-aws/sqs/url_resolver.go" first_line_contains="// GenerateQueueUrlResolver" last_line_contains="}" %}} + +{{% load-snippet-partial file="src-link/watermill-aws/sqs/url_resolver.go" first_line_contains="// TransparentUrlResolver" last_line_contains="}" %}} + +### Using with SQS emulator + +You may want to use [`goaws`](https://github.com/Admiral-Piett/goaws) or [`localstack`](https://hub.docker.com/r/localstack/localstack) for local development or testing. + +You can override the endpoint using the `OptFns` option in the `SubscriberConfig` or `PublisherConfig`. + +```go +package main + +import ( + amazonsqs "github.com/aws/aws-sdk-go-v2/service/sqs" + "github.com/ThreeDotsLabs/watermill-amazonsqs/sqs" +) + +func main() { + // ... + + sqsOpts := []func(*amazonsqs.Options){ + amazonsqs.WithEndpointResolverV2(sqs.OverrideEndpointResolver{ + Endpoint: transport.Endpoint{ + URI: *lo.Must(url.Parse("http://localstack:4566")), + }, + }), + } + + sqsConfig := sqs.SubscriberConfig{ + AWSConfig: cfg, + OptFns: sqsOpts, + } + + sub, err := sqs.NewSubscriber(sqsConfig, logger) + if err != nil { + panic(fmt.Errorf("unable to create new subscriber: %w", err)) + } + + // ... +} + +``` + +## SNS + +### Characteristics + +| Feature | Implements | Note | +|---------------------|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ConsumerGroups | yes | yes | +| ExactlyOnceDelivery | no | [yes](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues-exactly-once-processing.html) | +| GuaranteedOrder | yes\* | from [AWS Docs](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues.html): _"(...) due to the highly distributed architecture, more than one copy of a message might be delivered, and messages may occasionally arrive out of order. Despite this, standard queues make a best-effort attempt to maintain the order in which messages are sent."_ | +| Persistent | yes | | + + +### Required permissions + +- `sns:Subscribe` +- `sns:ConfirmSubscription` +- `sns:Receive` +- `sns:Unsubscribe` + +and all permissions required for SQS: + +- `sqs:ReceiveMessage` +- `sqs:DeleteMessage` +- `sqs:GetQueueUrl` +- `sqs:CreateQueue` +- `sqs:GetQueueAttributes` +- `sqs:SendMessage` +- `sqs:ChangeMessageVisibility` +- `sqs:SetQueueAttributes` + +Additionally, if `sns.SubscriberConfig.DoNotSetQueueAccessPolicy` is not enabled, you should have the following: + +- `sqs:SetQueueAttributes` + +### SNS Configuration + +{{% load-snippet-partial file="src-link/watermill-aws/sns/config.go" first_line_contains="type SubscriberConfig struct " last_line_contains="type GenerateSqsQueueNameFn" %}} + +Additionally, because SNS Subscriber uses SQS ques as "subscriptions", you need to pass [SQS configuration](#sqs-configuration) as well. + +### Resolving Queue URL + +In the Watermill model, we normalise AWS Topic ARN to the `topic` used in the `Publish` and `Subscribe` methods. + +{{% load-snippet-partial file="src-link/watermill-aws/sns/topic.go" first_line_contains="// TopicResolver" last_line_contains="}" %}} + +We are providing two out-of-the-box resolvers: + +{{% load-snippet-partial file="src-link/watermill-aws/sns/topic.go" first_line_contains="// TransparentTopicResolver" last_line_contains="}" %}} + +{{% load-snippet-partial file="src-link/watermill-aws/sns/topic.go" first_line_contains="// GenerateArnTopicResolver" last_line_contains="}" %}} + +### Using with SNS emulator + +You may want to use [`goaws`](https://github.com/Admiral-Piett/goaws) or [`localstack`](https://hub.docker.com/r/localstack/localstack) for local development or testing. + +You can override the endpoint using the `OptFns` option in the `SubscriberConfig` or `PublisherConfig`. + +```go +package main + +import ( + amazonsns "github.com/aws/aws-sdk-go-v2/service/sns" + "github.com/ThreeDotsLabs/watermill-amazonsns/sns" +) + +func main() { + // ... + + snsOpts := []func(*amazonsns.Options){ + amazonsns.WithEndpointResolverV2(sns.OverrideEndpointResolver{ + Endpoint: transport.Endpoint{ + URI: *lo.Must(url.Parse("http://localstack:4566")), + }, + }), + } + + snsConfig := sns.SubscriberConfig{ + AWSConfig: cfg, + OptFns: snsOpts, + } + + sub, err := sns.NewSubscriber(snsConfig, sqsConfig, logger) + if err != nil { + panic(fmt.Errorf("unable to create new subscriber: %w", err)) + } + + // ... +} +``` diff --git a/docs/resources/_gen/assets/scss/app.scss_cdf9d7c9eb97e4550ded64a8776dd9e8.content b/docs/resources/_gen/assets/scss/app.scss_cdf9d7c9eb97e4550ded64a8776dd9e8.content index 733dbbb37..8ec6aa497 100644 --- a/docs/resources/_gen/assets/scss/app.scss_cdf9d7c9eb97e4550ded64a8776dd9e8.content +++ b/docs/resources/_gen/assets/scss/app.scss_cdf9d7c9eb97e4550ded64a8776dd9e8.content @@ -1 +1 @@ -:root[data-bs-theme="light"],[data-bs-theme="light"] ::backdrop{--sl-color-white: hsl(224, 10%, 10%);--sl-color-gray-1: hsl(224, 14%, 16%);--sl-color-gray-2: hsl(224, 10%, 23%);--sl-color-gray-3: hsl(224, 7%, 36%);--sl-color-gray-4: hsl(224, 6%, 56%);--sl-color-gray-5: hsl(224, 6%, 77%);--sl-color-gray-6: hsl(224, 20%, 94%);--sl-color-gray-7: hsl(224, 19%, 97%);--sl-color-black: hsl(0, 0%, 100%)}:root,::backdrop{--sl-color-white: hsl(0, 0%, 100%);--sl-color-gray-1: hsl(224, 20%, 94%);--sl-color-gray-2: hsl(224, 6%, 77%);--sl-color-gray-3: hsl(224, 6%, 56%);--sl-color-gray-4: hsl(224, 7%, 36%);--sl-color-gray-5: hsl(224, 10%, 23%);--sl-color-gray-6: hsl(224, 14%, 16%);--sl-color-black: hsl(224, 10%, 10%);--sl-hue-orange: 41;--sl-color-orange-low: hsl(var(--sl-hue-orange), 39%, 22%);--sl-color-orange: hsl(var(--sl-hue-orange), 82%, 63%);--sl-color-orange-high: hsl(var(--sl-hue-orange), 82%, 87%);--sl-hue-green: 101;--sl-color-green-low: hsl(var(--sl-hue-green), 39%, 22%);--sl-color-green: hsl(var(--sl-hue-green), 82%, 63%);--sl-color-green-high: hsl(var(--sl-hue-green), 82%, 80%);--sl-hue-blue: 234;--sl-color-blue-low: hsl(var(--sl-hue-blue), 54%, 20%);--sl-color-blue: hsl(var(--sl-hue-blue), 100%, 60%);--sl-color-blue-high: hsl(var(--sl-hue-blue), 100%, 87%);--sl-hue-purple: 281;--sl-color-purple-low: hsl(var(--sl-hue-purple), 39%, 22%);--sl-color-purple: hsl(var(--sl-hue-purple), 82%, 63%);--sl-color-purple-high: hsl(var(--sl-hue-purple), 82%, 89%);--sl-hue-red: 339;--sl-color-red-low: hsl(var(--sl-hue-red), 39%, 22%);--sl-color-red: hsl(var(--sl-hue-red), 82%, 63%);--sl-color-red-high: hsl(var(--sl-hue-red), 82%, 87%);--sl-color-accent-low: hsl(224, 54%, 20%);--sl-color-accent: hsl(224, 100%, 60%);--sl-color-accent-high: hsl(224, 100%, 85%);--sl-color-text: var(--sl-color-gray-2);--sl-color-text-accent: var(--sl-color-accent-high);--sl-color-text-invert: var(--sl-color-accent-low);--sl-color-bg: var(--sl-color-black);--sl-color-bg-nav: var(--sl-color-gray-6);--sl-color-bg-sidebar: var(--sl-color-gray-6);--sl-color-bg-inline-code: var(--sl-color-gray-5);--sl-color-hairline-light: var(--sl-color-gray-5);--sl-color-hairline: var(--sl-color-gray-6);--sl-color-hairline-shade: var(--sl-color-black);--sl-color-backdrop-overlay: hsla(223, 13%, 10%, 0.66);--sl-shadow-sm: 0px 1px 1px hsla(0, 0%, 0%, 0.12), 0px 2px 1px hsla(0, 0%, 0%, 0.24);--sl-shadow-md: 0px 8px 4px hsla(0, 0%, 0%, 0.08), 0px 5px 2px hsla(0, 0%, 0%, 0.08), 0px 3px 2px hsla(0, 0%, 0%, 0.12), 0px 1px 1px hsla(0, 0%, 0%, 0.15);--sl-shadow-lg: 0px 25px 7px hsla(0, 0%, 0%, 0.03), 0px 16px 6px hsla(0, 0%, 0%, 0.1), 0px 9px 5px hsla(223, 13%, 10%, 0.33), 0px 4px 4px hsla(0, 0%, 0%, 0.75), 0px 4px 2px hsla(0, 0%, 0%, 0.25);--sl-text-xs: 0.8125rem;--sl-text-sm: 0.875rem;--sl-text-base: 1rem;--sl-text-lg: 1.125rem;--sl-text-xl: 1.25rem;--sl-text-2xl: 1.5rem;--sl-text-3xl: 1.8125rem;--sl-text-4xl: 2.1875rem;--sl-text-5xl: 2.625rem;--sl-text-6xl: 4rem;--sl-text-body: var(--sl-text-base);--sl-text-body-sm: var(--sl-text-xs);--sl-text-code: var(--sl-text-sm);--sl-text-code-sm: var(--sl-text-xs);--sl-text-h1: var(--sl-text-4xl);--sl-text-h2: var(--sl-text-3xl);--sl-text-h3: var(--sl-text-2xl);--sl-text-h4: var(--sl-text-xl);--sl-text-h5: var(--sl-text-lg);--sl-line-height: 1.8;--sl-line-height-headings: 1.2;--sl-font-system: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--sl-font-system-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--__sl-font: var(--sl-font, ""), var(--sl-font-system);--__sl-font-mono: var(--sl-font-mono, ""), var(--sl-font-system-mono);--sl-nav-height: 3.5rem;--sl-nav-pad-x: 1rem;--sl-nav-pad-y: 0.75rem;--sl-mobile-toc-height: 3rem;--sl-sidebar-width: 18.75rem;--sl-sidebar-pad-x: 1rem;--sl-content-width: 45rem;--sl-content-pad-x: 1rem;--sl-menu-button-size: 2rem;--sl-nav-gap: var(--sl-content-pad-x);--sl-outline-offset-inside: -0.1875rem;--sl-z-index-toc: 4;--sl-z-index-menu: 5;--sl-z-index-navbar: 10;--sl-z-index-skiplink: 20}:root{--purple-hsl: 255, 60%, 60%;--overlay-blurple: hsla(var(--purple-hsl), 0.2)}:root{--ec-brdRad: 0px;--ec-brdWd: 1px;--ec-brdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-codeFontFml: var(--__sl-font-mono);--ec-codeFontSize: var(--sl-text-code);--ec-codeFontWg: 400;--ec-codeLineHt: var(--sl-line-height);--ec-codePadBlk: 0.75rem;--ec-codePadInl: 1rem;--ec-codeBg: #011627;--ec-codeFg: #d6deeb;--ec-codeSelBg: #1d3b53;--ec-uiFontFml: var(--__sl-font);--ec-uiFontSize: 0.9rem;--ec-uiFontWg: 400;--ec-uiLineHt: 1.65;--ec-uiPadBlk: 0.25rem;--ec-uiPadInl: 1rem;--ec-uiSelBg: #234d708c;--ec-uiSelFg: #ffffff;--ec-focusBrd: #122d42;--ec-sbThumbCol: #ffffff17;--ec-sbThumbHoverCol: #ffffff49;--ec-tm-lineMarkerAccentMarg: 0rem;--ec-tm-lineMarkerAccentWd: 0.15rem;--ec-tm-lineDiffIndMargLeft: 0.25rem;--ec-tm-inlMarkerBrdWd: 1.5px;--ec-tm-inlMarkerBrdRad: 0.2rem;--ec-tm-inlMarkerPad: 0.15rem;--ec-tm-insDiffIndContent: "+";--ec-tm-delDiffIndContent: "-";--ec-tm-markBg: #ffffff17;--ec-tm-markBrdCol: #ffffff40;--ec-tm-insBg: #1e571599;--ec-tm-insBrdCol: #487f3bd0;--ec-tm-insDiffIndCol: #79b169d0;--ec-tm-delBg: #862d2799;--ec-tm-delBrdCol: #b4554bd0;--ec-tm-delDiffIndCol: #ed8779d0;--ec-frm-shdCol: #011627;--ec-frm-frameBoxShdCssVal: none;--ec-frm-edActTabBg: var(--sl-color-gray-6);--ec-frm-edActTabFg: var(--sl-color-text);--ec-frm-edActTabBrdCol: transparent;--ec-frm-edActTabIndHt: 1px;--ec-frm-edActTabIndTopCol: var(--sl-color-accent-high);--ec-frm-edActTabIndBtmCol: transparent;--ec-frm-edTabsMargInlStart: 0;--ec-frm-edTabsMargBlkStart: 0;--ec-frm-edTabBrdRad: 0px;--ec-frm-edTabBarBg: var(--sl-color-black);--ec-frm-edTabBarBrdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-edTabBarBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-edBg: var(--sl-color-gray-6);--ec-frm-trmTtbDotsFg: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-trmTtbDotsOpa: 0.75;--ec-frm-trmTtbBg: var(--sl-color-black);--ec-frm-trmTtbFg: var(--sl-color-text);--ec-frm-trmTtbBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-trmBg: var(--sl-color-gray-6);--ec-frm-inlBtnFg: var(--sl-color-text);--ec-frm-inlBtnBg: var(--sl-color-text);--ec-frm-inlBtnBgIdleOpa: 0;--ec-frm-inlBtnBgHoverOrFocusOpa: 0.2;--ec-frm-inlBtnBgActOpa: 0.3;--ec-frm-inlBtnBrd: var(--sl-color-text);--ec-frm-inlBtnBrdOpa: 0.4;--ec-frm-tooltipSuccessBg: #158744;--ec-frm-tooltipSuccessFg: white}:root,[data-bs-theme="light"]{--bs-blue: #3347ff;--bs-indigo: #6610f2;--bs-purple: #bd53ee;--bs-pink: #d63384;--bs-red: #ee5389;--bs-orange: #fd7e14;--bs-yellow: #eebd53;--bs-green: #84ee53;--bs-teal: #20c997;--bs-cyan: #0dcaf0;--bs-black: #000;--bs-white: #fff;--bs-gray: #6c757d;--bs-gray-dark: #343a40;--bs-gray-100: #f8f9fa;--bs-gray-200: #e9ecef;--bs-gray-300: #dee2e6;--bs-gray-400: #ced4da;--bs-gray-500: #adb5bd;--bs-gray-600: #6c757d;--bs-gray-700: #495057;--bs-gray-800: #343a40;--bs-gray-900: #212529;--bs-primary: #4f46e5;--bs-secondary: #6c757d;--bs-success: #84ee53;--bs-info: #3347ff;--bs-warning: #eebd53;--bs-danger: #ee5389;--bs-light: #f8f9fa;--bs-dark: #212529;--bs-primary-rgb: 79,70,229;--bs-secondary-rgb: 108,117,125;--bs-success-rgb: 132.2821,238.017,83.283;--bs-info-rgb: 51,71.4,255;--bs-warning-rgb: 238.017,189.0179,83.283;--bs-danger-rgb: 238.017,83.283,137.4399;--bs-light-rgb: 248,249,250;--bs-dark-rgb: 33,37,41;--bs-primary-text-emphasis: #201c5c;--bs-secondary-text-emphasis: #2b2f32;--bs-success-text-emphasis: #355f21;--bs-info-text-emphasis: #141d66;--bs-warning-text-emphasis: #5f4c21;--bs-danger-text-emphasis: #5f2137;--bs-light-text-emphasis: #495057;--bs-dark-text-emphasis: #495057;--bs-primary-bg-subtle: #dcdafa;--bs-secondary-bg-subtle: #e2e3e5;--bs-success-bg-subtle: #e6fcdd;--bs-info-bg-subtle: #d6daff;--bs-warning-bg-subtle: #fcf2dd;--bs-danger-bg-subtle: #fcdde7;--bs-light-bg-subtle: #fcfcfd;--bs-dark-bg-subtle: #ced4da;--bs-primary-border-subtle: #b9b5f5;--bs-secondary-border-subtle: #c4c8cb;--bs-success-border-subtle: #cef8ba;--bs-info-border-subtle: #adb6ff;--bs-warning-border-subtle: #f8e5ba;--bs-danger-border-subtle: #f8bad0;--bs-light-border-subtle: #e9ecef;--bs-dark-border-subtle: #adb5bd;--bs-white-rgb: 255,255,255;--bs-black-rgb: 0,0,0;--bs-font-sans-serif: "Heebo", "sans-serif", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--bs-gradient: linear-gradient(180deg, rgba(255,255,255,0.15), rgba(255,255,255,0));--bs-body-font-family: var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight: 400;--bs-body-line-height: 1.5;--bs-body-color: #1d2d35;--bs-body-color-rgb: 29,45,53;--bs-body-bg: #fff;--bs-body-bg-rgb: 255,255,255;--bs-emphasis-color: #000;--bs-emphasis-color-rgb: 0,0,0;--bs-secondary-color: rgba(29,45,53,0.75);--bs-secondary-color-rgb: 29,45,53;--bs-secondary-bg: #e9ecef;--bs-secondary-bg-rgb: 233,236,239;--bs-tertiary-color: rgba(29,45,53,0.5);--bs-tertiary-color-rgb: 29,45,53;--bs-tertiary-bg: #f8f9fa;--bs-tertiary-bg-rgb: 248,249,250;--bs-heading-color: inherit;--bs-link-color: #4f46e5;--bs-link-color-rgb: 79,70,229;--bs-link-decoration: none;--bs-link-hover-color: #3f38b7;--bs-link-hover-color-rgb: 63,56,183;--bs-link-hover-decoration: underline;--bs-code-color: #d63384;--bs-highlight-color: #1d2d35;--bs-highlight-bg: #fcf2dd;--bs-border-width: 1px;--bs-border-style: solid;--bs-border-color: #dee2e6;--bs-border-color-translucent: rgba(0,0,0,0.175);--bs-border-radius: .375rem;--bs-border-radius-sm: .25rem;--bs-border-radius-lg: .5rem;--bs-border-radius-xl: 1rem;--bs-border-radius-xxl: 2rem;--bs-border-radius-2xl: var(--bs-border-radius-xxl);--bs-border-radius-pill: 50rem;--bs-box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.15);--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0,0,0,0.075);--bs-box-shadow-lg: 0 1rem 3rem rgba(0,0,0,0.175);--bs-box-shadow-inset: inset 0 1px 2px rgba(0,0,0,0.075);--bs-focus-ring-width: .25rem;--bs-focus-ring-opacity: .25;--bs-focus-ring-color: rgba(79,70,229,0.25);--bs-form-valid-color: #84ee53;--bs-form-valid-border-color: #84ee53;--bs-form-invalid-color: #ee5389;--bs-form-invalid-border-color: #ee5389}[data-bs-theme="dark"]{color-scheme:dark;--bs-body-color: #c1c3c8;--bs-body-color-rgb: 192.831,194.7078,199.869;--bs-body-bg: #17181c;--bs-body-bg-rgb: 22.95,24.31,28.05;--bs-emphasis-color: #fff;--bs-emphasis-color-rgb: 255,255,255;--bs-secondary-color: rgba(193,195,200,0.75);--bs-secondary-color-rgb: 192.831,194.7078,199.869;--bs-secondary-bg: #343a40;--bs-secondary-bg-rgb: 52,58,64;--bs-tertiary-color: rgba(193,195,200,0.5);--bs-tertiary-color-rgb: 192.831,194.7078,199.869;--bs-tertiary-bg: #2b3035;--bs-tertiary-bg-rgb: 43,48,53;--bs-primary-text-emphasis: #9590ef;--bs-secondary-text-emphasis: #a7acb1;--bs-success-text-emphasis: #b5f598;--bs-info-text-emphasis: #8591ff;--bs-warning-text-emphasis: #f5d798;--bs-danger-text-emphasis: #f598b8;--bs-light-text-emphasis: #f8f9fa;--bs-dark-text-emphasis: #dee2e6;--bs-primary-bg-subtle: #100e2e;--bs-secondary-bg-subtle: #161719;--bs-success-bg-subtle: #1a3011;--bs-info-bg-subtle: #0a0e33;--bs-warning-bg-subtle: #302611;--bs-danger-bg-subtle: #30111b;--bs-light-bg-subtle: #23262f;--bs-dark-bg-subtle: #1a1d20;--bs-primary-border-subtle: #2f2a89;--bs-secondary-border-subtle: #41464b;--bs-success-border-subtle: #4f8f32;--bs-info-border-subtle: #1f2b99;--bs-warning-border-subtle: #8f7132;--bs-danger-border-subtle: #8f3252;--bs-light-border-subtle: #353841;--bs-dark-border-subtle: #343a40;--bs-heading-color: #fff;--bs-link-color: #b3c7ff;--bs-link-hover-color: #c2d2ff;--bs-link-color-rgb: 178.5,198.9,255;--bs-link-hover-color-rgb: 194,210,255;--bs-code-color: #e685b5;--bs-highlight-color: #c1c3c8;--bs-highlight-bg: #5f4c21;--bs-border-color: #495057;--bs-border-color-translucent: rgba(255,255,255,0.15);--bs-form-valid-color: #b5f598;--bs-form-valid-border-color: #b5f598;--bs-form-invalid-color: #f598b8;--bs-form-invalid-border-color: #f598b8}*,*::before,*::after{box-sizing:border-box}@media (prefers-reduced-motion: no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0)}h5,.h5,h4,.h4,h3,.h3,h2,.h2,h1,.h1{margin-top:0;margin-bottom:.5rem;font-weight:700;line-height:1.2;color:var(--bs-heading-color)}h1,.h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width: 1200px){h1,.h1{font-size:2.5rem}}h2,.h2{font-size:calc(1.325rem + .9vw)}@media (min-width: 1200px){h2,.h2{font-size:2rem}}h3,.h3{font-size:calc(1.3rem + .6vw)}@media (min-width: 1200px){h3,.h3{font-size:1.75rem}}h4,.h4{font-size:calc(1.275rem + .3vw)}@media (min-width: 1200px){h4,.h4{font-size:1.5rem}}h5,.h5{font-size:1.25rem}p{margin-top:0;margin-bottom:1rem}ol,ul{padding-left:2rem}ol,ul,dl{margin-top:0;margin-bottom:1rem}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}strong{font-weight:bolder}small,.small{font-size:.875em}mark,.mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}a{color:rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));text-decoration:none}a:hover{--bs-link-color-rgb: var(--bs-link-hover-color-rgb);text-decoration:underline}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}pre,code,kbd,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}th{text-align:inherit;text-align:-webkit-match-parent}thead,tbody,tr,td,th{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}input,button{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button{text-transform:none}[list]:not([type="date"]):not([type="datetime-local"]):not([type="month"]):not([type="week"]):not([type="time"])::-webkit-calendar-picker-indicator{display:none !important}button,[type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button:not(:disabled),[type="button"]:not(:disabled),[type="reset"]:not(:disabled),[type="submit"]:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-text,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit;-webkit-appearance:button}iframe{border:0}summary{display:list-item;cursor:pointer}[hidden]{display:none !important}.lead{font-size:1.25rem;font-weight:400}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.img-fluid{max-width:100%;height:auto}.figure{display:inline-block}.container,.container-fluid,.container-lg{--bs-gutter-x: 3rem;--bs-gutter-y: 0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width: 576px){.container{max-width:540px}}@media (min-width: 768px){.container{max-width:720px}}@media (min-width: 992px){.container-lg,.container{max-width:960px}}@media (min-width: 1200px){.container-lg,.container{max-width:1240px}}@media (min-width: 1400px){.container-lg,.container{max-width:1820px}}:root{--bs-breakpoint-xs: 0;--bs-breakpoint-sm: 576px;--bs-breakpoint-md: 768px;--bs-breakpoint-lg: 992px;--bs-breakpoint-xl: 1200px;--bs-breakpoint-xxl: 1400px}.row{--bs-gutter-x: 3rem;--bs-gutter-y: 0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}@media (min-width: 768px){.col-md-12{flex:0 0 auto;width:75%}}@media (min-width: 992px){.col-lg-5{flex:0 0 auto;width:31.25%}.col-lg-8{flex:0 0 auto;width:50%}.col-lg-9{flex:0 0 auto;width:56.25%}.col-lg-10{flex:0 0 auto;width:62.5%}.col-lg-11{flex:0 0 auto;width:68.75%}.col-lg-12{flex:0 0 auto;width:75%}}@media (min-width: 1200px){.col-xl-3{flex:0 0 auto;width:18.75%}.col-xl-4{flex:0 0 auto;width:25%}.col-xl-8{flex:0 0 auto;width:50%}.col-xl-9{flex:0 0 auto;width:56.25%}}.sticky-top{position:sticky;top:0;z-index:1020}.visually-hidden{width:1px !important;height:1px !important;padding:0 !important;margin:-1px !important;overflow:hidden !important;clip:rect(0, 0, 0, 0) !important;white-space:nowrap !important;border:0 !important}.visually-hidden:not(caption){position:absolute !important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.table,table{--bs-table-color-type: initial;--bs-table-bg-type: initial;--bs-table-color-state: initial;--bs-table-bg-state: initial;--bs-table-color: var(--bs-emphasis-color);--bs-table-bg: var(--bs-body-bg);--bs-table-border-color: var(--bs-border-color);--bs-table-accent-bg: rgba(0,0,0,0);--bs-table-striped-color: var(--bs-emphasis-color);--bs-table-striped-bg: rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color: var(--bs-emphasis-color);--bs-table-active-bg: rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color: var(--bs-emphasis-color);--bs-table-hover-bg: rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*,table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg)))}.table>tbody,table>tbody{vertical-align:inherit}.table>thead,table>thead{vertical-align:bottom}[data-bs-theme="dark"] table{--bs-table-color: #fff;--bs-table-bg: #212529;--bs-table-border-color: #4d5154;--bs-table-striped-bg: #2c3034;--bs-table-striped-color: #fff;--bs-table-active-bg: #373b3e;--bs-table-active-color: #fff;--bs-table-hover-bg: #323539;--bs-table-hover-color: #fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control{transition:none}}.form-control[type="file"]{overflow:hidden}.form-control[type="file"]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#a7a3f2;outline:0;box-shadow:none}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;margin-inline-end:1rem}li input[type="checkbox"]{--bs-form-check-bg: var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;print-color-adjust:exact}li input[type="checkbox"]{border-radius:.25em}li input[type="radio"][type="checkbox"]{border-radius:50%}li input[type="checkbox"]:active{filter:brightness(90%)}li input[type="checkbox"]:focus{border-color:#a7a3f2;outline:0;box-shadow:0 0 0 .25rem rgba(79,70,229,0.25)}li input[type="checkbox"]:checked{background-color:#4f46e5;border-color:#4f46e5}li input:checked[type="checkbox"]{--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}li input[type="checkbox"]:checked[type="radio"]{--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}li input[type="checkbox"]:indeterminate{background-color:#4f46e5;border-color:#4f46e5;--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}li input[type="checkbox"]:disabled{pointer-events:none;filter:none;opacity:.5}.btn{--bs-btn-padding-x: .75rem;--bs-btn-padding-y: .375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight: 400;--bs-btn-line-height: 1.5;--bs-btn-color: var(--bs-body-color);--bs-btn-bg: transparent;--bs-btn-border-width: var(--bs-border-width);--bs-btn-border-color: transparent;--bs-btn-border-radius: var(--bs-border-radius);--bs-btn-hover-border-color: transparent;--bs-btn-box-shadow: inset 0 1px 0 rgba(255,255,255,0.15),0 1px 1px rgba(0,0,0,0.075);--bs-btn-disabled-opacity: .65;--bs-btn-focus-box-shadow: 0 0 0 0 rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);text-decoration:none;background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}:not(.btn-check)+.btn:active,.btn:first-child:active,.btn.active,.btn.show{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}:not(.btn-check)+.btn:active:focus-visible,.btn:first-child:active:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn:disabled,.btn.disabled{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color: #fff;--bs-btn-bg: #4f46e5;--bs-btn-border-color: #4f46e5;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #433cc3;--bs-btn-hover-border-color: #3f38b7;--bs-btn-focus-shadow-rgb: 105,98,233;--bs-btn-active-color: #fff;--bs-btn-active-bg: #3f38b7;--bs-btn-active-border-color: #3b35ac;--bs-btn-active-shadow: inset 0 3px 5px rgba(0,0,0,0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #4f46e5;--bs-btn-disabled-border-color: #4f46e5}.btn-outline-primary{--bs-btn-color: #4f46e5;--bs-btn-border-color: #4f46e5;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #4f46e5;--bs-btn-hover-border-color: #4f46e5;--bs-btn-focus-shadow-rgb: 79,70,229;--bs-btn-active-color: #fff;--bs-btn-active-bg: #4f46e5;--bs-btn-active-border-color: #4f46e5;--bs-btn-active-shadow: inset 0 3px 5px rgba(0,0,0,0.125);--bs-btn-disabled-color: #4f46e5;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #4f46e5;--bs-gradient: none}.btn-link{--bs-btn-font-weight: 400;--bs-btn-color: var(--bs-link-color);--bs-btn-bg: transparent;--bs-btn-border-color: transparent;--bs-btn-hover-color: var(--bs-link-hover-color);--bs-btn-hover-border-color: transparent;--bs-btn-active-color: var(--bs-link-hover-color);--bs-btn-active-border-color: transparent;--bs-btn-disabled-color: #6c757d;--bs-btn-disabled-border-color: transparent;--bs-btn-box-shadow: 0 0 0 #000;--bs-btn-focus-shadow-rgb: 105,98,233;text-decoration:none}.btn-link:hover,.btn-link:focus-visible{text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-lg{--bs-btn-padding-y: .5rem;--bs-btn-padding-x: 1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius: var(--bs-border-radius-lg)}.fade{transition:opacity 0.15s linear}@media (prefers-reduced-motion: reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.nav{--bs-nav-link-padding-x: 1rem;--bs-nav-link-padding-y: .5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color: var(--bs-link-color);--bs-nav-link-hover-color: var(--bs-link-hover-color);--bs-nav-link-disabled-color: var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);background:none;border:0;transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.nav-link{transition:none}}.nav-link:hover,.nav-link:focus{color:var(--bs-nav-link-hover-color);text-decoration:none}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(79,70,229,0.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width: var(--bs-border-width);--bs-nav-tabs-border-color: var(--bs-border-color);--bs-nav-tabs-border-radius: var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color: var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color: var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg: var(--bs-body-bg);--bs-nav-tabs-link-active-border-color: var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:hover,.nav-tabs .nav-link:focus{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.active,.nav-tabs .nav-item.show .nav-link{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x: 0;--bs-navbar-padding-y: .5rem;--bs-navbar-color: rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color: rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color: rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y: .3125rem;--bs-navbar-brand-margin-end: 1rem;--bs-navbar-brand-font-size: 1.25rem;--bs-navbar-brand-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x: .5rem;--bs-navbar-toggler-padding-y: .25rem;--bs-navbar-toggler-padding-x: .75rem;--bs-navbar-toggler-font-size: 1.25rem;--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2829,45,53,0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color: rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius: var(--bs-border-radius);--bs-navbar-toggler-focus-width: 0;--bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);white-space:nowrap}.navbar-brand:hover,.navbar-brand:focus{color:var(--bs-navbar-brand-hover-color);text-decoration:none}.navbar-nav{--bs-nav-link-padding-x: 0;--bs-nav-link-padding-y: .5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color: var(--bs-navbar-color);--bs-nav-link-hover-color: var(--bs-navbar-hover-color);--bs-nav-link-disabled-color: var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}@media (min-width: 992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:transparent !important;border:0 !important;transform:none !important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar[data-bs-theme="dark"]{--bs-navbar-color: #c1c3c8;--bs-navbar-hover-color: #b3c7ff;--bs-navbar-disabled-color: rgba(255,255,255,0.25);--bs-navbar-active-color: #b3c7ff;--bs-navbar-brand-color: #b3c7ff;--bs-navbar-brand-hover-color: #b3c7ff;--bs-navbar-toggler-border-color: rgba(255,255,255,0.1);--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='%23c1c3c8' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y: 1rem;--bs-card-spacer-x: 1rem;--bs-card-title-spacer-y: .5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width: var(--bs-border-width);--bs-card-border-color: #e9ecef;--bs-card-border-radius: var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius: calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y: .5rem;--bs-card-cap-padding-x: 1rem;--bs-card-cap-bg: rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg: var(--bs-body-bg);--bs-card-img-overlay-padding: 1rem;--bs-card-group-margin: 1.5rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);text-decoration:none;background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.page-link.active,.active>.page-link{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.page-link.disabled,.disabled>.page-link{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.btn-close{--bs-btn-close-color: #000;--bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity: .5;--bs-btn-close-hover-opacity: .75;--bs-btn-close-focus-shadow: 0 0 0 .25rem rgba(79,70,229,0.25);--bs-btn-close-focus-opacity: 1;--bs-btn-close-disabled-opacity: .25;--bs-btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close:disabled,.btn-close.disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}[data-bs-theme="dark"] .btn-close{filter:var(--bs-btn-close-white-filter)}.modal{--bs-modal-zindex: 1055;--bs-modal-width: 500px;--bs-modal-padding: 1rem;--bs-modal-margin: .5rem;--bs-modal-color: ;--bs-modal-bg: var(--bs-body-bg);--bs-modal-border-color: var(--bs-border-color-translucent);--bs-modal-border-width: var(--bs-border-width);--bs-modal-border-radius: var(--bs-border-radius-lg);--bs-modal-box-shadow: var(--bs-box-shadow-sm);--bs-modal-inner-border-radius: calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x: 1rem;--bs-modal-header-padding-y: 1rem;--bs-modal-header-padding: 1rem 1rem;--bs-modal-header-border-color: var(--bs-border-color);--bs-modal-header-border-width: var(--bs-border-width);--bs-modal-title-line-height: 1.5;--bs-modal-footer-gap: .5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color: var(--bs-border-color);--bs-modal-footer-border-width: var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform 0.3s ease-out;transform:translate(0, -50px)}@media (prefers-reduced-motion: reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex: 1050;--bs-backdrop-bg: #000;--bs-backdrop-opacity: .5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width: 576px){.modal{--bs-modal-margin: 1.75rem;--bs-modal-box-shadow: var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}}@media (max-width: 767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header,.modal-fullscreen-md-down .modal-footer{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@keyframes spinner-border{to{transform:rotate(360deg) /* rtl:ignore */}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.offcanvas{--bs-offcanvas-zindex: 1045;--bs-offcanvas-width: 332px;--bs-offcanvas-height: 30vh;--bs-offcanvas-padding-x: 1rem;--bs-offcanvas-padding-y: 1rem;--bs-offcanvas-color: var(--bs-body-color);--bs-offcanvas-bg: var(--bs-body-bg);--bs-offcanvas-border-width: var(--bs-border-width);--bs-offcanvas-border-color: var(--bs-border-color-translucent);--bs-offcanvas-box-shadow: var(--bs-box-shadow-sm);--bs-offcanvas-transition: transform .3s ease-in-out;--bs-offcanvas-title-line-height: 1.5}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion: reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.showing,.offcanvas.show:not(.hiding){transform:none}.offcanvas.showing,.offcanvas.hiding,.offcanvas.show{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin:calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}@keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.d-flex{display:flex !important}.d-none{display:none !important}.w-100{width:100% !important}.h-auto{height:auto !important}.flex-row{flex-direction:row !important}.flex-column{flex-direction:column !important}.flex-grow-1{flex-grow:1 !important}.justify-content-end{justify-content:flex-end !important}.justify-content-center{justify-content:center !important}.justify-content-between{justify-content:space-between !important}.order-3{order:3 !important}.m-2{margin:.5rem !important}.m-3{margin:1rem !important}.mx-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-auto{margin-right:auto !important;margin-left:auto !important}.my-3{margin-top:1rem !important;margin-bottom:1rem !important}.mt-3{margin-top:1rem !important}.mt-5{margin-top:3rem !important}.me-2{margin-right:.5rem !important}.me-auto{margin-right:auto !important}.mb-0{margin-bottom:0 !important}.mb-1{margin-bottom:.25rem !important}.mb-4{margin-bottom:1.5rem !important}.mb-5{margin-bottom:3rem !important}.ms-1{margin-left:.25rem !important}.ms-2{margin-left:.5rem !important}.ms-3{margin-left:1rem !important}.ms-auto{margin-left:auto !important}.mt-n3{margin-top:-1rem !important}.p-0{padding:0 !important}.p-2{padding:.5rem !important}.px-0{padding-right:0 !important;padding-left:0 !important}.pb-3{padding-bottom:1rem !important}.fs-5{font-size:1.25rem !important}.text-end{text-align:right !important}.text-center{text-align:center !important}.text-decoration-none{text-decoration:none !important}.text-muted{--bs-text-opacity: 1;color:var(--bs-secondary-color) !important}.text-body-secondary{--bs-text-opacity: 1;color:var(--bs-secondary-color) !important}.text-reset{--bs-text-opacity: 1;color:inherit !important}.rounded-pill{border-radius:var(--bs-border-radius-pill) !important}@media (min-width: 576px){.flex-sm-row{flex-direction:row !important}}@media (min-width: 768px){.d-md-block{display:block !important}.d-md-none{display:none !important}.flex-md-row{flex-direction:row !important}}@media (min-width: 992px){.d-lg-block{display:block !important}.d-lg-none{display:none !important}.flex-lg-row{flex-direction:row !important}.order-lg-4{order:4 !important}.me-lg-1{margin-right:.25rem !important}.me-lg-3{margin-right:1rem !important}.ms-lg-2{margin-left:.5rem !important}.text-lg-start{text-align:left !important}.text-lg-end{text-align:right !important}}@media (min-width: 1200px){.d-xl-block{display:block !important}.d-xl-none{display:none !important}.flex-xl-nowrap{flex-wrap:nowrap !important}.mx-xl-auto{margin-right:auto !important;margin-left:auto !important}}@font-face{font-family:Jost;font-style:normal;font-weight:400;font-display:swap;src:local("Jost Regular Regular"),local("Jost-Regular"),local("Jost* Book"),local("Jost-Book"),url("fonts/vendor/jost/jost-v4-latin-regular.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-regular.woff") format("woff")}@font-face{font-family:Jost;font-style:normal;font-weight:500;font-display:swap;src:local("Jost Regular Medium"),local("JostRoman-Medium"),local("Jost* Medium"),local("Jost-Medium"),url("fonts/vendor/jost/jost-v4-latin-500.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-500.woff") format("woff")}@font-face{font-family:Jost;font-style:normal;font-weight:700;font-display:swap;src:local("Jost Regular Bold"),local("JostRoman-Bold"),local("Jost* Bold"),local("Jost-Bold"),url("fonts/vendor/jost/jost-v4-latin-700.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-700.woff") format("woff")}@font-face{font-family:Jost;font-style:italic;font-weight:400;font-display:swap;src:local("Jost Italic Italic"),local("Jost-Italic"),local("Jost* BookItalic"),local("Jost-BookItalic"),url("fonts/vendor/jost/jost-v4-latin-italic.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-italic.woff") format("woff")}@font-face{font-family:Jost;font-style:italic;font-weight:500;font-display:swap;src:local("Jost Italic Medium Italic"),local("JostItalic-Medium"),local("Jost* Medium Italic"),local("Jost-MediumItalic"),url("fonts/vendor/jost/jost-v4-latin-500italic.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-500italic.woff") format("woff")}@font-face{font-family:Jost;font-style:italic;font-weight:700;font-display:swap;src:local("Jost Italic Bold Italic"),local("JostItalic-Bold"),local("Jost* Bold Italic"),local("Jost-BoldItalic"),url("fonts/vendor/jost/jost-v4-latin-700italic.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-700italic.woff") format("woff")}html[data-bs-theme="dark"] .icon-tabler-sun{display:block}html[data-bs-theme="dark"] .icon-tabler-moon{display:none}html[data-bs-theme="light"] .icon-tabler-sun{display:none}html[data-bs-theme="light"] .icon-tabler-moon{display:block}.contributors .content,.error404 .content,.docs.list .content,.categories.list .content,.tags.list .content,.list.section .content{padding-top:1rem;padding-bottom:3rem}.content img{max-width:100%}h5,.h5,h4,.h4,h3,.h3,h2,.h2,h1,.h1{margin-top:2rem;margin-bottom:1rem}@media (min-width: 768px){body{font-size:1.125rem}h1,h2,h3,h4,h5,.h1,.h2,.h3,.h4,.h5{margin-bottom:1.125rem}}.home h1,.home .h1{font-size:calc(1.875rem + 1.5vw);margin-top:-1rem}a:hover,a:focus{text-decoration:underline}a.btn:hover,a.btn:focus{text-decoration:none}.section{padding-top:5rem;padding-bottom:5rem}body.section{padding-top:0;padding-bottom:0}.section-md{padding-top:3rem;padding-bottom:3rem}.docs-sidebar{order:2}@media (min-width: 992px){.docs-sidebar{order:0;border-right:1px solid #e9ecef}@supports (position: sticky){.docs-sidebar{position:sticky;top:4.25rem;z-index:1000;height:calc(100vh - 4.25rem)}}}@media (min-width: 1200px){.docs-sidebar{flex:0 1 320px}}.docs-links{padding-bottom:5rem}@media (min-width: 992px){@supports (position: sticky){.docs-links{max-height:calc(100vh - 4rem);overflow-y:scroll}}}@media (min-width: 992px){.docs-links{display:block;width:auto;margin-right:-1.5rem;padding-bottom:4rem}}.docs-toc{order:2}@supports (position: sticky){.docs-toc{position:sticky;top:4.25rem;height:calc(100vh - 4.25rem);overflow-y:auto}}.docs-content{padding-bottom:3rem;order:1}.navbar a:hover,.navbar a:focus{text-decoration:none}#TableOfContents ul,#toc ul{padding-left:0;list-style:none}#toc a.active{color:#4f46e5;font-weight:500}.section-features{padding-top:2rem}.modal-backdrop{background-color:#fff}.modal-backdrop.show{opacity:0.7}@media (min-width: 768px){.modal-backdrop.show{opacity:0}}li input[type="checkbox"]{margin:0.25rem;border:1px solid #ced4da}li input[type="checkbox"]:disabled{pointer-events:none;filter:none;opacity:1}li input[type="checkbox"]:checked{background-color:#5d2f86;border-color:#5d2f86}[data-bs-theme="dark"] li input[type="checkbox"]{border:1px solid #6c757d}[data-bs-theme="dark"] li input[type="checkbox"]:checked{background-color:#b3c7ff;border-color:#b3c7ff;--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%231d2d35' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.highlight>.chroma{border:1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)}.bg{background-color:var(--sl-color-gray-7)}.chroma{background-color:var(--sl-color-gray-7)}.chroma .err{color:inherit}.chroma .lnlinks{outline:none;text-decoration:none;color:inherit}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}.chroma .hl{background-color:#0000001a}.chroma .hl{border-inline-start:0.15rem solid #00000055;margin-left:-1rem;margin-right:-1rem;padding-left:1rem;padding-right:1rem}.chroma .hl .ln{margin-left:-0.15rem}.chroma .lnt{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f}.chroma .ln{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f}.chroma .line{display:flex}.chroma .k{color:#000000;font-weight:bold}.chroma .kc{color:#000000;font-weight:bold}.chroma .kd{color:#000000;font-weight:bold}.chroma .kn{color:#000000;font-weight:bold}.chroma .kp{color:#000000;font-weight:bold}.chroma .kr{color:#000000;font-weight:bold}.chroma .kt{color:#445588;font-weight:bold}.chroma .na{color:#008080}.chroma .nb{color:#0086b3}.chroma .bp{color:#999999}.chroma .nc{color:#445588;font-weight:bold}.chroma .no{color:#008080}.chroma .nd{color:#3c5d5d;font-weight:bold}.chroma .ni{color:#800080}.chroma .ne{color:#990000;font-weight:bold}.chroma .nf{color:#990000;font-weight:bold}.chroma .nl{color:#990000;font-weight:bold}.chroma .nn{color:#555555}.chroma .nt{color:#000080}.chroma .nv{color:#008080}.chroma .vc{color:#008080}.chroma .vg{color:#008080}.chroma .vi{color:#008080}.chroma .s{color:#dd1144}.chroma .sa{color:#dd1144}.chroma .sb{color:#dd1144}.chroma .sc{color:#dd1144}.chroma .dl{color:#dd1144}.chroma .sd{color:#dd1144}.chroma .s2{color:#dd1144}.chroma .se{color:#dd1144}.chroma .sh{color:#dd1144}.chroma .si{color:#dd1144}.chroma .sx{color:#dd1144}.chroma .sr{color:#009926}.chroma .s1{color:#dd1144}.chroma .ss{color:#990073}.chroma .m{color:#009999}.chroma .mb{color:#009999}.chroma .mf{color:#009999}.chroma .mh{color:#009999}.chroma .mi{color:#009999}.chroma .il{color:#009999}.chroma .mo{color:#009999}.chroma .o{color:#000000;font-weight:bold}.chroma .ow{color:#000000;font-weight:bold}.chroma .c{color:#999988;font-style:italic}.chroma .ch{color:#999988;font-style:italic}.chroma .cm{color:#999988;font-style:italic}.chroma .c1{color:#999988;font-style:italic}.chroma .cs{color:#999999;font-weight:bold;font-style:italic}.chroma .cp{color:#999999;font-weight:bold;font-style:italic}.chroma .cpf{color:#999999;font-weight:bold;font-style:italic}.chroma .gd{color:#000000;background-color:#ffdddd}.chroma .ge{color:inherit;font-style:italic}.chroma .gr{color:#aa0000}.chroma .gh{color:#999999}.chroma .gi{color:#000000;background-color:#ddffdd}.chroma .go{color:#888888}.chroma .gp{color:#555555}.chroma .gs{font-weight:bold}.chroma .gu{color:#aaaaaa}.chroma .gt{color:#aa0000}.chroma .gl{text-decoration:underline}.chroma .w{color:#bbbbbb}[data-bs-theme="dark"] .highlight>.chroma{border:1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)}[data-bs-theme="dark"] .bg{color:#c9d1d9;background-color:var(--sl-color-gray-6)}[data-bs-theme="dark"] .chroma{color:#c9d1d9;background-color:var(--sl-color-gray-6)}[data-bs-theme="dark"] .chroma .err{color:inherit}[data-bs-theme="dark"] .chroma .lnlinks{outline:none;text-decoration:none;color:inherit}[data-bs-theme="dark"] .chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}[data-bs-theme="dark"] .chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}[data-bs-theme="dark"] .chroma .hl{background-color:#ffffff17}[data-bs-theme="dark"] .chroma .hl{border-inline-start:0.15rem solid #ffffff40;margin-left:-1rem;margin-right:-1rem;padding-left:1rem;padding-right:1rem}[data-bs-theme="dark"] .chroma .hl .ln{margin-left:-0.15rem}[data-bs-theme="dark"] .chroma .lnt{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#64686c}[data-bs-theme="dark"] .chroma .ln{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681}[data-bs-theme="dark"] .chroma .line{display:flex}[data-bs-theme="dark"] .chroma .k{color:#ff7b72}[data-bs-theme="dark"] .chroma .kc{color:#79c0ff}[data-bs-theme="dark"] .chroma .kd{color:#ff7b72}[data-bs-theme="dark"] .chroma .kn{color:#ff7b72}[data-bs-theme="dark"] .chroma .kp{color:#79c0ff}[data-bs-theme="dark"] .chroma .kr{color:#ff7b72}[data-bs-theme="dark"] .chroma .kt{color:#ff7b72}[data-bs-theme="dark"] .chroma .na{color:#d2a8ff}[data-bs-theme="dark"] .chroma .nc{color:#f0883e;font-weight:bold}[data-bs-theme="dark"] .chroma .no{color:#79c0ff;font-weight:bold}[data-bs-theme="dark"] .chroma .nd{color:#d2a8ff;font-weight:bold}[data-bs-theme="dark"] .chroma .ni{color:#ffa657}[data-bs-theme="dark"] .chroma .ne{color:#f0883e;font-weight:bold}[data-bs-theme="dark"] .chroma .nf{color:#d2a8ff;font-weight:bold}[data-bs-theme="dark"] .chroma .nl{color:#79c0ff;font-weight:bold}[data-bs-theme="dark"] .chroma .nn{color:#ff7b72}[data-bs-theme="dark"] .chroma .py{color:#79c0ff}[data-bs-theme="dark"] .chroma .nt{color:#7ee787}[data-bs-theme="dark"] .chroma .nv{color:#79c0ff}[data-bs-theme="dark"] .chroma .l{color:#a5d6ff}[data-bs-theme="dark"] .chroma .ld{color:#79c0ff}[data-bs-theme="dark"] .chroma .s{color:#a5d6ff}[data-bs-theme="dark"] .chroma .sa{color:#79c0ff}[data-bs-theme="dark"] .chroma .sb{color:#a5d6ff}[data-bs-theme="dark"] .chroma .sc{color:#a5d6ff}[data-bs-theme="dark"] .chroma .dl{color:#79c0ff}[data-bs-theme="dark"] .chroma .sd{color:#a5d6ff}[data-bs-theme="dark"] .chroma .s2{color:#a5d6ff}[data-bs-theme="dark"] .chroma .se{color:#79c0ff}[data-bs-theme="dark"] .chroma .sh{color:#79c0ff}[data-bs-theme="dark"] .chroma .si{color:#a5d6ff}[data-bs-theme="dark"] .chroma .sx{color:#a5d6ff}[data-bs-theme="dark"] .chroma .sr{color:#79c0ff}[data-bs-theme="dark"] .chroma .s1{color:#a5d6ff}[data-bs-theme="dark"] .chroma .ss{color:#a5d6ff}[data-bs-theme="dark"] .chroma .m{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mb{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mf{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mh{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mi{color:#a5d6ff}[data-bs-theme="dark"] .chroma .il{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mo{color:#a5d6ff}[data-bs-theme="dark"] .chroma .o{color:inherit;font-weight:bold}[data-bs-theme="dark"] .chroma .ow{color:#ff7b72;font-weight:bold}[data-bs-theme="dark"] .chroma .c{color:#8b949e;font-style:italic}[data-bs-theme="dark"] .chroma .ch{color:#8b949e;font-style:italic}[data-bs-theme="dark"] .chroma .cm{color:#8b949e;font-style:italic}[data-bs-theme="dark"] .chroma .c1{color:#8b949e;font-style:italic}[data-bs-theme="dark"] .chroma .cs{color:#8b949e;font-weight:bold;font-style:italic}[data-bs-theme="dark"] .chroma .cp{color:#8b949e;font-weight:bold;font-style:italic}[data-bs-theme="dark"] .chroma .cpf{color:#8b949e;font-weight:bold;font-style:italic}[data-bs-theme="dark"] .chroma .gd{color:#ffa198;background-color:#490202}[data-bs-theme="dark"] .chroma .ge{font-style:italic}[data-bs-theme="dark"] .chroma .gr{color:#ffa198}[data-bs-theme="dark"] .chroma .gh{color:#79c0ff;font-weight:bold}[data-bs-theme="dark"] .chroma .gi{color:#56d364;background-color:#0f5323}[data-bs-theme="dark"] .chroma .go{color:#8b949e}[data-bs-theme="dark"] .chroma .gp{color:#8b949e}[data-bs-theme="dark"] .chroma .gs{font-weight:bold}[data-bs-theme="dark"] .chroma .gu{color:#79c0ff}[data-bs-theme="dark"] .chroma .gt{color:#ff7b72}[data-bs-theme="dark"] .chroma .gl{text-decoration:underline}[data-bs-theme="dark"] .chroma .w{color:#6e7681}[data-bs-theme="dark"] h1,[data-bs-theme="dark"] .h1,[data-bs-theme="dark"] h2,[data-bs-theme="dark"] .h2,[data-bs-theme="dark"] h3,[data-bs-theme="dark"] .h3,[data-bs-theme="dark"] h4,[data-bs-theme="dark"] .h4{color:#fff}[data-bs-theme="dark"] body{background:#17181c;color:#c1c3c8}[data-bs-theme="dark"] a{color:#b3c7ff}[data-bs-theme="dark"] .btn-primary{--bs-btn-color: #000;--bs-btn-bg: #b3c7ff;--bs-btn-border-color: #b3c7ff;--bs-btn-hover-color: #000;--bs-btn-hover-bg: #becfff;--bs-btn-hover-border-color: #bacdff;--bs-btn-focus-shadow-rgb: 152,169,217;--bs-btn-active-color: #000;--bs-btn-active-bg: #c2d2ff;--bs-btn-active-border-color: #bacdff;--bs-btn-active-shadow: inset 0 3px 5px rgba(0,0,0,0.125);--bs-btn-disabled-color: #000;--bs-btn-disabled-bg: #b3c7ff;--bs-btn-disabled-border-color: #b3c7ff;color:#17181c}[data-bs-theme="dark"] .btn-outline-primary{--bs-btn-color: #b3c7ff;--bs-btn-border-color: #b3c7ff;--bs-btn-hover-color: #b3c7ff;--bs-btn-hover-bg: #b3c7ff;--bs-btn-hover-border-color: #b3c7ff;--bs-btn-focus-shadow-rgb: 178.5,198.9,255;--bs-btn-active-color: #000;--bs-btn-active-bg: #b3c7ff;--bs-btn-active-border-color: #b3c7ff;--bs-btn-active-shadow: inset 0 3px 5px rgba(0,0,0,0.125);--bs-btn-disabled-color: #b3c7ff;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #b3c7ff;--bs-gradient: none;color:#b3c7ff}[data-bs-theme="dark"] .btn-outline-primary:hover{color:#17181c}[data-bs-theme="dark"] .navbar{background-color:rgba(23,24,28,0.95);border-bottom:1px solid #23262f}[data-bs-theme="dark"] body.home .navbar{border-bottom:0}[data-bs-theme="dark"] .offcanvas-header{border-bottom:1px solid #343a40}[data-bs-theme="dark"] .offcanvas .nav-link{color:#c1c3c8}[data-bs-theme="dark"] .offcanvas .nav-link:hover,[data-bs-theme="dark"] .offcanvas .nav-link:focus{color:#b3c7ff}[data-bs-theme="dark"] .offcanvas .nav-link.active{color:#b3c7ff}[data-bs-theme="dark"] .page-links a{color:#c1c3c8}[data-bs-theme="dark"] .page-links a:hover{text-decoration:none;color:#b3c7ff}[data-bs-theme="dark"] .navbar .btn-link{color:#c1c3c8}[data-bs-theme="dark"] .content .btn-link{color:#b3c7ff}[data-bs-theme="dark"] .content .btn-link:hover{color:#b3c7ff}[data-bs-theme="dark"] .navbar .btn-link:hover{color:#b3c7ff}[data-bs-theme="dark"] .navbar .btn-link:active{color:#b3c7ff}[data-bs-theme="dark"] .form-control{color:#dee2e6}[data-bs-theme="dark"] .form-control::-moz-placeholder{color:#ced4da;opacity:1}[data-bs-theme="dark"] .form-control::placeholder{color:#ced4da;opacity:1}@media (min-width: 992px){[data-bs-theme="dark"] .docs-sidebar{order:0;border-right:1px solid #23262f}}[data-bs-theme="dark"] blockquote{border-left:3px solid #23262f}[data-bs-theme="dark"] .footer{border-top:1px solid #23262f}[data-bs-theme="dark"] .docs-links,[data-bs-theme="dark"] .docs-toc{scrollbar-width:thin;scrollbar-color:#17181c #17181c}[data-bs-theme="dark"] .docs-links::-webkit-scrollbar,[data-bs-theme="dark"] .docs-toc::-webkit-scrollbar{width:5px}[data-bs-theme="dark"] .docs-links::-webkit-scrollbar-track,[data-bs-theme="dark"] .docs-toc::-webkit-scrollbar-track{background:#17181c}[data-bs-theme="dark"] .docs-links::-webkit-scrollbar-thumb,[data-bs-theme="dark"] .docs-toc::-webkit-scrollbar-thumb{background:#17181c}[data-bs-theme="dark"] .docs-links:hover,[data-bs-theme="dark"] .docs-toc:hover{scrollbar-width:thin;scrollbar-color:#23262f #17181c}[data-bs-theme="dark"] .docs-links:hover::-webkit-scrollbar-thumb,[data-bs-theme="dark"] .docs-toc:hover::-webkit-scrollbar-thumb{background:#23262f}[data-bs-theme="dark"] .docs-links::-webkit-scrollbar-thumb:hover,[data-bs-theme="dark"] .docs-toc::-webkit-scrollbar-thumb:hover{background:#23262f}[data-bs-theme="dark"] .docs-links h3:not(:first-child),[data-bs-theme="dark"] .docs-links .h3:not(:first-child){border-top:1px solid #23262f}[data-bs-theme="dark"] .page-links li:not(:first-child){border-top:1px dashed #23262f}[data-bs-theme="dark"] .card{background:#17181c;border:1px solid #23262f}[data-bs-theme="dark"] .text-muted{color:#adafb6 !important}[data-bs-theme="dark"] .offcanvas{background-color:#17181c}[data-bs-theme="dark"] .page-link{color:#b3c7ff;background-color:transparent;border:var(--bs-border-width) solid #23262f}[data-bs-theme="dark"] .page-link:hover{color:#17181c;background-color:#c1c3c8;border-color:#c1c3c8}[data-bs-theme="dark"] .page-link:focus{color:#17181c;background-color:#c1c3c8}[data-bs-theme="dark"] .page-item.active .page-link{color:#17181c;background-color:#b3c7ff;border-color:#b3c7ff}[data-bs-theme="dark"] .page-item.disabled .page-link{color:var(--bs-secondary-color);background-color:#23262f;border-color:#23262f}[data-bs-theme="dark"] details{border:1px solid #23262f}[data-bs-theme="dark"] summary:hover{background:#23262f}[data-bs-theme="dark"] details[open]>summary{border-bottom:1px solid #23262f}[data-bs-theme="dark"] details summary::after{content:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%28222, 226, 230, 0.75%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e")}[data-bs-theme="dark"] #toc a.active{color:#b3c7ff}[data-bs-theme="dark"] table th{color:#fff}[data-bs-theme="dark"] table,[data-bs-theme="dark"] [data-bs-theme="dark"] table{--bs-table-color: inherit;--bs-table-bg: $body-bg-dark;background:#17181c;border-color:#23262f}.btn-close:focus,.btn-close:active{outline:none;box-shadow:none}.navbar .btn-link{color:rgba(var(--bs-emphasis-color-rgb), 0.65);padding:0.4375rem 0}.btn-link:focus{outline:0;box-shadow:none}@media (min-width: 992px){.navbar .btn-link{padding:0.5625em 0.25rem 0.5rem 0.125rem}}.navbar .btn-link:hover{color:rgba(var(--bs-emphasis-color-rgb), 0.8)}.navbar .btn-link:active{color:rgba(var(--bs-emphasis-color-rgb), 1)}.btn-close{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-x'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");background-size:1.5rem}.offcanvas-header .btn-close{margin-right:0 !important}.clipboard{position:relative;float:right}.btn-clipboard{transition:opacity 0.25s ease-in-out;opacity:0;position:absolute;right:0.5rem;top:0.5rem;line-height:1;padding:0.3125rem 0.3125rem 0.1875rem;background-color:transparent;border-color:transparent}@media (max-width: 767.98px){.btn-clipboard{position:absolute;right:-0.5rem;top:0.5rem}}.btn-clipboard::after{width:22px;height:22px;display:inline-block;content:"";-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;-webkit-mask-size:cover;mask-size:cover;background-color:#495057}.btn-clipboard:hover{border-color:transparent}.btn-clipboard:hover::after{width:22px;height:22px;display:inline-block;content:"";-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;-webkit-mask-size:cover;mask-size:cover;background-color:#212529}.btn-clipboard:focus,.btn-clipboard:active{border-color:transparent !important}.btn-clipboard:focus::after,.btn-clipboard:active::after{width:22px;height:22px;display:inline-block;content:"";-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;-webkit-mask-size:cover;mask-size:cover;background-color:#212529}[data-bs-theme="dark"] .btn-clipboard{background-color:transparent;border-color:transparent}[data-bs-theme="dark"] .btn-clipboard::after{background-color:#ced4da}[data-bs-theme="dark"] .btn-clipboard:hover{border-color:transparent}[data-bs-theme="dark"] .btn-clipboard:hover::after{background-color:#e9ecef}[data-bs-theme="dark"] .btn-clipboard:focus,[data-bs-theme="dark"] .btn-clipboard:active{border-color:transparent}[data-bs-theme="dark"] .btn-clipboard:focus::after,[data-bs-theme="dark"] .btn-clipboard:active::after{background-color:#e9ecef}.highlight{position:relative}@media (min-width: 768px){.highlight:hover .btn-clipboard{opacity:1}}.btn-cta{padding-left:2rem;padding-right:2rem}.expressive-code{font-family:var(--ec-uiFontFml);font-size:var(--ec-uiFontSize);line-height:var(--ec-uiLineHt);-moz-text-size-adjust:none;text-size-adjust:none;-webkit-text-size-adjust:none;margin:1.5rem 0}.expressive-code *:not(path){all:revert;box-sizing:border-box}.expressive-code pre{display:flex;margin:0;padding:0;border:var(--ec-brdWd) solid var(--ec-brdCol);border-radius:calc(var(--ec-brdRad) + var(--ec-brdWd));background:var(--ec-codeBg)}.expressive-code pre:focus-visible{outline:3px solid var(--ec-focusBrd);outline-offset:-3px}.expressive-code pre>code{all:unset;display:block;flex:1 0 100%;padding:var(--ec-codePadBlk) 0;color:var(--ec-codeFg);font-family:var(--ec-codeFontFml);font-size:var(--ec-codeFontSize);line-height:var(--ec-codeLineHt)}.expressive-code pre{overflow-x:auto}.expressive-code pre::-webkit-scrollbar,.expressive-code pre::-webkit-scrollbar-track{background-color:inherit;border-radius:calc(var(--ec-brdRad) + var(--ec-brdWd));border-top-left-radius:0;border-top-right-radius:0}.expressive-code pre::-webkit-scrollbar-thumb{background-color:var(--ec-sbThumbCol);border:4px solid transparent;background-clip:content-box;border-radius:10px}.expressive-code pre::-webkit-scrollbar-thumb:hover{background-color:var(--ec-sbThumbHoverCol)}.expressive-code .ec-line{padding-inline:var(--ec-codePadInl);padding-inline-end:calc(2rem + var(--ec-codePadInl));direction:ltr;unicode-bidi:isolate}.expressive-code .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);white-space:nowrap;border-width:0}.expressive-code .ec-line.mark{--tmLineBgCol: var(--ec-tm-markBg);--tmLineBrdCol: var(--ec-tm-markBrdCol)}.expressive-code .ec-line.ins{--tmLineBgCol: var(--ec-tm-insBg);--tmLineBrdCol: var(--ec-tm-insBrdCol)}.expressive-code .ec-line.ins::before{content:var(--ec-tm-insDiffIndContent);color:var(--ec-tm-insDiffIndCol)}.expressive-code .ec-line.del{--tmLineBgCol: var(--ec-tm-delBg);--tmLineBrdCol: var(--ec-tm-delBrdCol)}.expressive-code .ec-line.del::before{content:var(--ec-tm-delDiffIndContent);color:var(--ec-tm-delDiffIndCol)}.expressive-code .ec-line.mark,.expressive-code .ec-line.ins,.expressive-code .ec-line.del{position:relative;background:var(--tmLineBgCol);min-width:calc(100% - var(--ec-tm-lineMarkerAccentMarg));margin-inline-start:var(--ec-tm-lineMarkerAccentMarg);border-inline-start:var(--ec-tm-lineMarkerAccentWd) solid var(--tmLineBrdCol);padding-inline-start:calc(var(--ec-codePadInl) - var(--ec-tm-lineMarkerAccentMarg) - var(--ec-tm-lineMarkerAccentWd)) !important}.expressive-code .ec-line.mark::before,.expressive-code .ec-line.ins::before,.expressive-code .ec-line.del::before{position:absolute;left:var(--ec-tm-lineDiffIndMargLeft)}.expressive-code .ec-line mark,.expressive-code .ec-line .mark{--tmInlineBgCol: var(--ec-tm-markBg);--tmInlineBrdCol: var(--ec-tm-markBrdCol)}.expressive-code .ec-line ins{--tmInlineBgCol: var(--ec-tm-insBg);--tmInlineBrdCol: var(--ec-tm-insBrdCol)}.expressive-code .ec-line del{--tmInlineBgCol: var(--ec-tm-delBg);--tmInlineBrdCol: var(--ec-tm-delBrdCol)}.expressive-code .ec-line mark,.expressive-code .ec-line .mark,.expressive-code .ec-line ins,.expressive-code .ec-line del{all:unset;display:inline-block;position:relative;--tmBrdL: var(--ec-tm-inlMarkerBrdWd);--tmBrdR: var(--ec-tm-inlMarkerBrdWd);--tmRadL: var(--ec-tm-inlMarkerBrdRad);--tmRadR: var(--ec-tm-inlMarkerBrdRad);margin-inline:0.025rem;padding-inline:var(--ec-tm-inlMarkerPad);border-radius:var(--tmRadL) var(--tmRadR) var(--tmRadR) var(--tmRadL);background:var(--tmInlineBgCol);background-clip:padding-box}.expressive-code .ec-line mark.open-start,.expressive-code .ec-line .open-start.mark,.expressive-code .ec-line ins.open-start,.expressive-code .ec-line del.open-start{margin-inline-start:0;padding-inline-start:0;--tmBrdL: 0px;--tmRadL: 0}.expressive-code .ec-line mark.open-end,.expressive-code .ec-line .open-end.mark,.expressive-code .ec-line ins.open-end,.expressive-code .ec-line del.open-end{margin-inline-end:0;padding-inline-end:0;--tmBrdR: 0px;--tmRadR: 0}.expressive-code .ec-line mark::before,.expressive-code .ec-line .mark::before,.expressive-code .ec-line ins::before,.expressive-code .ec-line del::before{content:"";position:absolute;pointer-events:none;display:inline-block;inset:0;border-radius:var(--tmRadL) var(--tmRadR) var(--tmRadR) var(--tmRadL);border:var(--ec-tm-inlMarkerBrdWd) solid var(--tmInlineBrdCol);border-inline-width:var(--tmBrdL) var(--tmBrdR)}.expressive-code .frame{all:unset;position:relative;display:block;--header-border-radius: calc(var(--ec-brdRad) + var(--ec-brdWd));--tab-border-radius: calc(var(--ec-frm-edTabBrdRad) + var(--ec-brdWd));--button-spacing: 0.4rem;--code-background: var(--ec-frm-edBg);border-radius:var(--header-border-radius);box-shadow:var(--ec-frm-frameBoxShdCssVal)}.expressive-code .frame .header{display:none;z-index:1;position:relative;border-radius:var(--header-border-radius) var(--header-border-radius) 0 0}.expressive-code .frame.has-title pre,.expressive-code .frame.has-title code,.expressive-code .frame.is-terminal pre,.expressive-code .frame.is-terminal code{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.expressive-code .frame .title:empty:before{content:"\a0"}.expressive-code .frame.has-title:not(.is-terminal){--button-spacing: calc(1.9rem + 2 * (var(--ec-uiPadBlk) + var(--ec-frm-edActTabIndHt)))}.expressive-code .frame.has-title:not(.is-terminal) .title{position:relative;color:var(--ec-frm-edActTabFg);background:var(--ec-frm-edActTabBg);background-clip:padding-box;margin-block-start:var(--ec-frm-edTabsMargBlkStart);padding:calc(var(--ec-uiPadBlk) + var(--ec-frm-edActTabIndHt)) var(--ec-uiPadInl);border:var(--ec-brdWd) solid var(--ec-frm-edActTabBrdCol);border-radius:var(--tab-border-radius) var(--tab-border-radius) 0 0;border-bottom:none;overflow:hidden}.expressive-code .frame.has-title:not(.is-terminal) .title::after{content:"";position:absolute;pointer-events:none;inset:0;border-top:var(--ec-frm-edActTabIndHt) solid var(--ec-frm-edActTabIndTopCol);border-bottom:var(--ec-frm-edActTabIndHt) solid var(--ec-frm-edActTabIndBtmCol)}.expressive-code .frame.has-title:not(.is-terminal) .header{display:flex;background:linear-gradient(to top, var(--ec-frm-edTabBarBrdBtmCol) var(--ec-brdWd), transparent var(--ec-brdWd)),linear-gradient(var(--ec-frm-edTabBarBg), var(--ec-frm-edTabBarBg));background-repeat:no-repeat;padding-inline-start:var(--ec-frm-edTabsMargInlStart)}.expressive-code .frame.has-title:not(.is-terminal) .header::before{content:"";position:absolute;pointer-events:none;inset:0;border:var(--ec-brdWd) solid var(--ec-frm-edTabBarBrdCol);border-radius:inherit;border-bottom:none}.expressive-code .frame.is-terminal{--button-spacing: calc(1.9rem + var(--ec-brdWd) + 2 * var(--ec-uiPadBlk));--code-background: var(--ec-frm-trmBg)}.expressive-code .frame.is-terminal .header{display:flex;align-items:center;justify-content:center;padding-block:var(--ec-uiPadBlk);padding-block-end:calc(var(--ec-uiPadBlk) + var(--ec-brdWd));position:relative;font-weight:500;letter-spacing:0.025ch;color:var(--ec-frm-trmTtbFg);background:var(--ec-frm-trmTtbBg);border:var(--ec-brdWd) solid var(--ec-brdCol);border-bottom:none}.expressive-code .frame.is-terminal .header::before{content:"";position:absolute;pointer-events:none;left:var(--ec-uiPadInl);width:2.1rem;height:0.56rem;line-height:0;background-color:var(--ec-frm-trmTtbDotsFg);opacity:var(--ec-frm-trmTtbDotsOpa);-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 16' preserveAspectRatio='xMidYMid meet'%3E%3Ccircle cx='8' cy='8' r='8'/%3E%3Ccircle cx='30' cy='8' r='8'/%3E%3Ccircle cx='52' cy='8' r='8'/%3E%3C/svg%3E");-webkit-mask-repeat:no-repeat;mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 16' preserveAspectRatio='xMidYMid meet'%3E%3Ccircle cx='8' cy='8' r='8'/%3E%3Ccircle cx='30' cy='8' r='8'/%3E%3Ccircle cx='52' cy='8' r='8'/%3E%3C/svg%3E");mask-repeat:no-repeat}.expressive-code .frame.is-terminal .header::after{content:"";position:absolute;pointer-events:none;inset:0;border-bottom:var(--ec-brdWd) solid var(--ec-frm-trmTtbBrdBtmCol)}.expressive-code .frame pre{background:var(--code-background)}.expressive-code .copy{display:flex;gap:0.25rem;flex-direction:row;position:absolute;inset-block-start:calc(var(--ec-brdWd) + var(--button-spacing));inset-inline-end:calc(var(--ec-brdWd) + var(--ec-uiPadInl) / 2);direction:ltr;unicode-bidi:isolate}.expressive-code .copy button{position:relative;align-self:flex-end;margin:0;padding:0;border:none;border-radius:0.2rem;z-index:1;cursor:pointer;transition-property:opacity, background, border-color;transition-duration:0.2s;transition-timing-function:cubic-bezier(0.25, 0.46, 0.45, 0.94);width:2.5rem;height:2.5rem;background:var(--code-background);opacity:0.75}.expressive-code .copy button div{position:absolute;inset:0;border-radius:inherit;background:var(--ec-frm-inlBtnBg);opacity:var(--ec-frm-inlBtnBgIdleOpa);transition-property:inherit;transition-duration:inherit;transition-timing-function:inherit}.expressive-code .copy button::before{content:"";position:absolute;pointer-events:none;inset:0;border-radius:inherit;border:var(--ec-brdWd) solid var(--ec-frm-inlBtnBrd);opacity:var(--ec-frm-inlBtnBrdOpa)}.expressive-code .copy button::after{content:"";position:absolute;pointer-events:none;inset:0;background-color:var(--ec-frm-inlBtnFg);-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75'%3E%3Cpath d='M3 19a2 2 0 0 1-1-2V2a2 2 0 0 1 1-1h13a2 2 0 0 1 2 1'/%3E%3Crect x='6' y='5' width='16' height='18' rx='1.5' ry='1.5'/%3E%3C/svg%3E");-webkit-mask-repeat:no-repeat;mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75'%3E%3Cpath d='M3 19a2 2 0 0 1-1-2V2a2 2 0 0 1 1-1h13a2 2 0 0 1 2 1'/%3E%3Crect x='6' y='5' width='16' height='18' rx='1.5' ry='1.5'/%3E%3C/svg%3E");mask-repeat:no-repeat;margin:0.475rem;line-height:0}.expressive-code .copy button:focus::after,.expressive-code .copy button:active::after{display:inline-block;content:"";-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='black' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='black' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;-webkit-mask-size:cover;mask-size:cover;margin:0.2375rem}.expressive-code .copy button:hover,.expressive-code .copy button:focus:focus-visible{opacity:1}.expressive-code .copy button:hover div,.expressive-code .copy button:focus:focus-visible div{opacity:var(--ec-frm-inlBtnBgHoverOrFocusOpa)}.expressive-code .copy button:active{opacity:1}.expressive-code .copy button:active div{opacity:var(--ec-frm-inlBtnBgActOpa)}.expressive-code .copy .feedback{--tooltip-arrow-size: 0.35rem;--tooltip-bg: var(--ec-frm-tooltipSuccessBg);color:var(--ec-frm-tooltipSuccessFg);pointer-events:none;-moz-user-select:none;user-select:none;-webkit-user-select:none;position:relative;align-self:center;background-color:var(--tooltip-bg);z-index:99;padding:0.125rem 0.75rem;border-radius:0.2rem;margin-inline-end:var(--tooltip-arrow-size);opacity:0;transition-property:opacity, transform;transition-duration:0.2s;transition-timing-function:ease-in-out;transform:translate3d(0, 0.25rem, 0)}.expressive-code .copy .feedback::after{content:"";position:absolute;pointer-events:none;top:calc(50% - var(--tooltip-arrow-size));inset-inline-end:calc(-2 * (var(--tooltip-arrow-size) - 0.5px));border:var(--tooltip-arrow-size) solid transparent;border-inline-start-color:var(--tooltip-bg)}.expressive-code .copy .feedback.show{opacity:1;transform:translate3d(0, 0, 0)}@media (hover: hover){.expressive-code .copy button{opacity:0;width:2rem;height:2rem}.expressive-code .frame:hover .copy button:not(:hover),.expressive-code .frame:focus-within :focus-visible~.copy button:not(:hover),.expressive-code .frame .copy .feedback.show~button:not(:hover){opacity:0.75}}:root{--ec-brdRad: 0px;--ec-brdWd: 1px;--ec-brdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-codeFontFml: var(--__sl-font-mono);--ec-codeFontSize: var(--sl-text-code);--ec-codeFontWg: 400;--ec-codeLineHt: var(--sl-line-height);--ec-codePadBlk: 0;--ec-codePadInl: 1rem;--ec-codeBg: #011627;--ec-codeFg: #d6deeb;--ec-codeSelBg: #1d3b53;--ec-uiFontFml: var(--__sl-font);--ec-uiFontSize: 0.9rem;--ec-uiFontWg: 400;--ec-uiLineHt: 1.65;--ec-uiPadBlk: 0.25rem;--ec-uiPadInl: 1rem;--ec-uiSelBg: #234d708c;--ec-uiSelFg: #ffffff;--ec-focusBrd: #122d42;--ec-sbThumbCol: #ffffff17;--ec-sbThumbHoverCol: #ffffff49;--ec-tm-lineMarkerAccentMarg: 0rem;--ec-tm-lineMarkerAccentWd: 0.15rem;--ec-tm-lineDiffIndMargLeft: 0.25rem;--ec-tm-inlMarkerBrdWd: 1.5px;--ec-tm-inlMarkerBrdRad: 0.2rem;--ec-tm-inlMarkerPad: 0.15rem;--ec-tm-insDiffIndContent: "+";--ec-tm-delDiffIndContent: "-";--ec-tm-markBg: #ffffff17;--ec-tm-markBrdCol: #ffffff40;--ec-tm-insBg: #1e571599;--ec-tm-insBrdCol: #487f3bd0;--ec-tm-insDiffIndCol: #79b169d0;--ec-tm-delBg: #862d2799;--ec-tm-delBrdCol: #b4554bd0;--ec-tm-delDiffIndCol: #ed8779d0;--ec-frm-shdCol: #011627;--ec-frm-frameBoxShdCssVal: none;--ec-frm-edActTabBg: var(--sl-color-gray-6);--ec-frm-edActTabFg: var(--sl-color-text);--ec-frm-edActTabBrdCol: transparent;--ec-frm-edActTabIndHt: 1px;--ec-frm-edActTabIndTopCol: var(--sl-color-accent-high);--ec-frm-edActTabIndBtmCol: transparent;--ec-frm-edTabsMargInlStart: 0;--ec-frm-edTabsMargBlkStart: 0;--ec-frm-edTabBrdRad: 0px;--ec-frm-edTabBarBg: var(--sl-color-black);--ec-frm-edTabBarBrdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-edTabBarBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-edBg: var(--sl-color-gray-6);--ec-frm-trmTtbDotsFg: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-trmTtbDotsOpa: 0.75;--ec-frm-trmTtbBg: var(--sl-color-black);--ec-frm-trmTtbFg: var(--sl-color-text);--ec-frm-trmTtbBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-trmBg: var(--sl-color-gray-6);--ec-frm-inlBtnFg: var(--sl-color-text);--ec-frm-inlBtnBg: var(--sl-color-text);--ec-frm-inlBtnBgIdleOpa: 0;--ec-frm-inlBtnBgHoverOrFocusOpa: 0.2;--ec-frm-inlBtnBgActOpa: 0.3;--ec-frm-inlBtnBrd: var(--sl-color-text);--ec-frm-inlBtnBrdOpa: 0.4;--ec-frm-tooltipSuccessBg: #158744;--ec-frm-tooltipSuccessFg: white}.expressive-code .ec-line span[style^="--"]:not([class]){color:var(0, inherit);font-style:var(0fs, inherit);font-weight:var(0fw, inherit);-webkit-text-decoration:var(0td, inherit);text-decoration:var(0td, inherit)}@media (prefers-color-scheme: light){:root:not([data-bs-theme="dark"]){--ec-codeBg: #fbfbfb;--ec-codeFg: #403f53;--ec-codeSelBg: #e0e0e0;--ec-uiSelBg: #d3e8f8;--ec-uiSelFg: #403f53;--ec-focusBrd: #93a1a1;--ec-sbThumbCol: #0000001a;--ec-sbThumbHoverCol: #0000005c;--ec-tm-markBg: #0000001a;--ec-tm-markBrdCol: #00000055;--ec-tm-insBg: #8ec77d99;--ec-tm-insDiffIndCol: #336a28d0;--ec-tm-delBg: #ff9c8e99;--ec-tm-delDiffIndCol: #9d4138d0;--ec-frm-shdCol: #d9d9d9;--ec-frm-edActTabBg: var(--sl-color-gray-7);--ec-frm-edActTabIndTopCol: #5d2f86;--ec-frm-edTabBarBg: var(--sl-color-gray-6);--ec-frm-edBg: var(--sl-color-gray-7);--ec-frm-trmTtbBg: var(--sl-color-gray-6);--ec-frm-trmBg: var(--sl-color-gray-7);--ec-frm-tooltipSuccessBg: #078662}:root:not([data-bs-theme="dark"]) .expressive-code .ec-line span[style^="--"]:not([class]){color:var(1, inherit);font-style:var(1fs, inherit);font-weight:var(1fw, inherit);-webkit-text-decoration:var(1td, inherit);text-decoration:var(1td, inherit)}}:root[data-bs-theme="light"] .expressive-code,.expressive-code[data-bs-theme="light"]{--ec-codeBg: #fbfbfb;--ec-codeFg: #403f53;--ec-codeSelBg: #e0e0e0;--ec-uiSelBg: #d3e8f8;--ec-uiSelFg: #403f53;--ec-focusBrd: #93a1a1;--ec-sbThumbCol: #0000001a;--ec-sbThumbHoverCol: #0000005c;--ec-tm-markBg: #0000001a;--ec-tm-markBrdCol: #00000055;--ec-tm-insBg: #8ec77d99;--ec-tm-insDiffIndCol: #336a28d0;--ec-tm-delBg: #ff9c8e99;--ec-tm-delDiffIndCol: #9d4138d0;--ec-frm-shdCol: #d9d9d9;--ec-frm-edActTabBg: var(--sl-color-gray-7);--ec-frm-edActTabIndTopCol: #5d2f86;--ec-frm-edTabBarBg: var(--sl-color-gray-6);--ec-frm-edBg: var(--sl-color-gray-7);--ec-frm-trmTtbBg: var(--sl-color-gray-6);--ec-frm-trmBg: var(--sl-color-gray-7);--ec-frm-tooltipSuccessBg: #078662}:root[data-bs-theme="light"] .expressive-code .ec-line span[style^="--"]:not([class]),.expressive-code[data-bs-theme="light"] .ec-line span[style^="--"]:not([class]){color:var(1, inherit);font-style:var(1fs, inherit);font-weight:var(1fw, inherit);-webkit-text-decoration:var(1td, inherit);text-decoration:var(1td, inherit)}pre,code,kbd,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:.875rem}code:not(:where(.not-content *)){background-color:var(--sl-color-gray-6);margin-block:-0.125rem;padding:0.125rem 0.375rem;color:inherit}[data-bs-theme="dark"] code:not(:where(.not-content *)){background-color:var(--sl-color-gray-5)}.math-block{display:block;margin:2rem 0;overflow-x:auto}.math-inline{display:inline}[data-bs-theme="dark"] .math-inline img,[data-bs-theme="dark"] .math-block img{filter:invert(1)}img.diagram{height:auto;width:100%;margin:1rem 0 2rem}img.diagram-kroki-mermaid{background:#fff}.highlight>pre{padding:0.875rem 1rem}.highlight div{padding:0}.highlight>.chroma{overflow-x:auto;border:1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)}.chroma .ln{padding:0 0.5rem 0 0}.chroma .hl{border-inline-start:0.15rem solid #0005;margin-left:-1rem;margin-right:-1rem;padding-left:1rem;padding-right:1rem}.chroma .hl .ln{margin-left:-0.15rem}.highlight .chroma .lntable .lnt,.highlight .chroma .lntable .hl{display:flex}.chroma .lntd:first-child{padding:0}.chroma .lntd:first-child .lnt{padding-left:1rem}.chroma .lntd:nth-child(2){padding:0}.highlight .chroma .lntable .lntd+.lntd{width:100%}[data-bs-theme="dark"] .chroma .ln{padding:0 0.5em 0 0}.chroma .lntd pre{padding:1rem 0;margin-bottom:0}.highlight>.chroma::-webkit-scrollbar,.highlight>.chroma::-webkit-scrollbar-track{background-color:inherit;border-radius:1px;border-top-left-radius:0;border-top-right-radius:0}.highlight>.chroma::-webkit-scrollbar-thumb{background-color:#dddee0;border:4px solid transparent;background-clip:content-box;border-radius:10px}.highlight>.chroma::-webkit-scrollbar-thumb:hover{background-color:#9d9e9f}[data-bs-theme="dark"] .highlight>.chroma::-webkit-scrollbar-thumb{background-color:#ffffff17}[data-bs-theme="dark"] .highlight>.chroma::-webkit-scrollbar-thumb:hover{background-color:#ffffff49}[data-bs-theme="dark"] .highlight>.chroma{border:1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)}[data-bs-theme="dark"] .chroma .hl{border-inline-start:0.15rem solid #ffffff40;margin-left:-1rem;margin-right:-1rem;padding-left:1rem;padding-right:1rem}[data-bs-theme="dark"] .chroma .hl .ln{margin-left:-0.15rem}blockquote{margin-bottom:1rem;font-size:1.25rem;border-left:3px solid #dee2e6;padding-left:1rem}details{display:block;position:relative;border:1px solid #e9ecef;border-radius:0.25rem;padding:0.5rem 1rem 0;margin:0.5rem 0}summary{list-style:none;display:inline-block;width:calc(100% + 2rem);margin:-0.5rem -1rem 0;padding:0.5rem 1rem}summary::-webkit-details-marker{display:none}summary:hover{background:#f8f9fa}details summary::after{display:inline-block;content:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%2829, 45, 53, 0.75%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e");transition:transform 0.35s ease;transform-origin:center center;position:absolute;right:1rem}details[open]>summary::after{transform:rotate(90deg)}details[open]{padding:0.5rem 1rem}details[open]>summary{border-bottom:1px solid #dee2e6;margin-bottom:0.5rem}details h2,details .h2,details h3,details .h3,details h4,details .h4{margin:1rem 0 0.5rem}details p:last-child{margin-bottom:0}details ul,details ol{margin-bottom:0}details pre{margin:0 0 1rem}.search-form label{font-weight:normal}img{max-width:100%;height:auto}img[data-sizes="auto"]{display:block}img{font-size:0}figcaption{font-size:1rem;margin-top:0.5rem;font-style:italic}.blur-up{filter:blur(5px);transition:filter 400ms}.blur-up.lazyloaded{filter:unset}.search-form .form-control:focus{border:2px solid #4f46e5}[data-bs-theme="dark"] .search-form .form-control:focus{border:2px solid #b3c7ff}[data-bs-theme="dark"] .search-form .btn-link{color:#b3c7ff}.search-form .btn-link,.modal-body p.message,.modal-footer{font-size:.875rem}.modal-body::-webkit-scrollbar{width:0.25rem}.modal-body::-webkit-scrollbar-track{background-color:#f1f1f1}.modal-body::-webkit-scrollbar-thumb{background-color:#c1c1c1}[data-bs-theme="dark"] .modal-body::-webkit-scrollbar-track{background-color:#424242}[data-bs-theme="dark"] .modal-body::-webkit-scrollbar-thumb{background-color:#686868}@media (min-width: 768px){#searchModal .modal-dialog{max-height:40rem}}.search-result h2,.search-result .h2{margin-top:0}.search-result a:focus{outline:0 none}.search-result .content{margin-top:0.5rem;padding-top:0 !important;padding-bottom:0 !important}.search-result .card .content p{margin-bottom:0}.search-result .card .content a{position:relative;z-index:1}.search-result:hover .card,.search-result.selected .card{background-color:#4f46e5;color:#fff}.search-result:hover .card .content a,.search-result.selected .card .content a{color:#fff;text-decoration:underline}[data-bs-theme="dark"] .search-result:hover .card,[data-bs-theme="dark"] .search-result.selected .card{background-color:#b3c7ff;color:#23262f}[data-bs-theme="dark"] .search-result:hover .card .content a,[data-bs-theme="dark"] .search-result.selected .card .content a{color:#23262f;text-decoration:underline}[data-bs-theme="dark"] .search-result:hover .card h2,[data-bs-theme="dark"] .search-result:hover .card .h2,[data-bs-theme="dark"] .search-result.selected .card h2,[data-bs-theme="dark"] .search-result.selected .card .h2{color:#17181c}.search-result .submitted{font-size:.875rem;margin-top:0.5rem}.section-nav{padding-top:2rem}.section-nav details{border:0;padding:0;margin:0.5rem 0}.section-nav details[open]{padding:0}.section-nav summary{width:100%;padding:0;margin:0;font-weight:700}.section-nav summary:hover{background:none}.section-nav details[open]>summary{border-bottom:0;margin-bottom:0}.section-nav ul.list-nested details{padding-left:1rem;margin-top:0.5rem}.section-nav ul.list-nested li{margin:0}.section-nav a{display:block;margin:0.5rem 0;color:#1d2d35;font-size:1rem;text-decoration:none}.section-nav a:hover,.section-nav a:active{color:#4f46e5}.section-nav li.active a{color:#4f46e5;font-weight:500}.section-nav ul.list-nested li a{padding-left:1rem}.section-nav ul.list-nested{border-left:1px solid #e9ecef}[data-bs-theme="dark"] .section-nav ul.list-nested{border-left:1px solid #23262f}[data-bs-theme="dark"] .section-nav a{color:#c1c3c8}[data-bs-theme="dark"] .section-nav a:hover,[data-bs-theme="dark"] .section-nav a:active{color:var(--sl-color-text-accent)}[data-bs-theme="dark"] .section-nav li.active a{color:var(--sl-color-text-accent);font-weight:500}[data-bs-theme="dark"] .section-nav summary{color:#fff}table{margin:3rem 0}.nav-tabs{border-bottom:0.0625rem solid #d8dee4;margin-bottom:1rem}.nav-tabs .nav-link{margin-bottom:-0.0625rem !important;background:none;border:0;border-top-left-radius:0;border-top-right-radius:0;color:inherit}.nav-tabs .nav-link:hover,.nav-tabs .nav-link:focus{isolation:isolate;border-color:transparent;color:var(--bs-emphasis-color)}.nav-tabs .nav-link.active,.nav-tabs .nav-item.show .nav-link{background-color:transparent;border-color:transparent;border-bottom:0.125rem solid #4f46e5}[data-bs-theme="dark"] .nav-tabs{border-bottom:0.0625rem solid #343a40}[data-bs-theme="dark"] .nav-tabs .nav-link.active,[data-bs-theme="dark"] .nav-tabs .nav-item.show .nav-link{border-bottom:0.125rem solid #b3c7ff}.footer{border-top:1px solid #e9ecef;padding-top:1.125rem;padding-bottom:1.125rem}.footer ul{margin-bottom:0}.footer li{font-size:.875rem;margin-bottom:0}.footer .list-inline-item:not(:last-child){margin-right:1rem}@media (max-width: 991.98px){.footer .col-lg-8{margin-top:0.25rem;margin-bottom:0.25rem}}@media (min-width: 768px){.footer li{font-size:1rem}}.navbar-brand{font-weight:700}.navbar-brand svg{margin-right:0.25rem}[data-bs-theme="dark"] .navbar-brand{color:inherit}.navbar{z-index:1000;background-color:rgba(255,255,255,0.95);border-bottom:1px solid #e9ecef}@media (min-width: 992px){.navbar{z-index:1025}}@media (min-width: 768px){.navbar-brand{font-size:1.375rem}}.nav-item{margin-left:0}@media (max-width: 991.98px){.navbar-nav .nav-link{font-weight:400}}@media (min-width: 768px){.nav-item{margin-left:0.5rem}}@media (max-width: 575.98px){.navbar .offcanvas.offcanvas-start,.navbar .offcanvas.offcanvas-end{width:80vw}}.offcanvas-header{border-bottom:1px solid #dee2e6;padding-top:1.0625rem;padding-bottom:0.8125rem}h5.offcanvas-title,.offcanvas-title.h5{margin:0;color:inherit}.offcanvas .nav-link{color:#1d2d35}.offcanvas .nav-link:hover,.offcanvas .nav-link:focus{color:#4f46e5}.offcanvas .nav-link.active{color:#4f46e5}.home .navbar{border-bottom:0}@media (min-width: 992px){.navbar-brand{margin-right:0.75rem !important}}.social-link{padding-right:0.375rem;padding-left:0.375rem}@media (max-width: 991.98px){#buttonColorMode{margin:0.5rem 0}#socialMenu{margin:0.5rem 0 0.5rem -0.25rem}.navbar-nav{margin-top:1rem}.nav-item .nav-link{font-weight:400;font-size:1.125rem}}.modal-backdrop,.offcanvas-backdrop{visibility:hidden;background:rgba(23,24,28,0.5);opacity:0}[data-bs-theme="dark"] .modal-backdrop,[data-bs-theme="dark"] .offcanvas-backdrop{visibility:hidden;background:rgba(23,24,28,0.5);opacity:0}.modal-backdrop.show,.offcanvas-backdrop.show{visibility:visible;opacity:1;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.showing,.hiding{transition:none;display:none}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg{padding-right:0.75rem}.docs-content>h2[id]::before,.docs-content>[id].h2::before,.docs-content>h3[id]::before,.docs-content>[id].h3::before,.docs-content>h4[id]::before,.docs-content>[id].h4::before{display:block;height:6rem;margin-top:-6rem;content:""}.docs-content ul,.docs-content ol{margin-bottom:1rem}.anchor{visibility:hidden;margin-left:0.375rem}.edit-page a{color:var(--sl-color-gray-3)}h1:hover a,.h1:hover a,h2:hover a,.h2:hover a,h3:hover a,.h3:hover a,h4:hover a,.h4:hover a{visibility:visible;text-decoration:none}.card-list{margin-top:2.25rem}.page-footer-meta{margin-top:2rem;margin-bottom:2rem}.edit-page{font-size:.875rem;margin-top:0.25rem;margin-bottom:0.25rem}@media (min-width: 768px){.edit-page{font-size:1rem;margin-top:0.75rem;margin-bottom:0.25rem}}.edit-page a:hover{color:var(--sl-color-gray-4);text-decoration:none}[data-bs-theme="dark"] .edit-page a:hover{color:var(--sl-color-gray-2)}.edit-page svg{margin-right:0.25rem;margin-bottom:0.25rem}p.meta{margin-top:0.5rem;font-size:1rem}.toc-mobile{margin-top:2rem;margin-bottom:2rem}.page-link:hover{text-decoration:none}ul li{margin:0.25rem 0}.page-nav .card .icon-tabler-arrow-left{margin-right:0.75rem}.page-nav .card .icon-tabler-arrow-right{margin-left:0.75rem}.page-nav .card:hover{border:1px solid #d9d9d9}[data-bs-theme="dark"] .page-nav .card{border:1px solid #353841}[data-bs-theme="dark"] .page-nav .card:hover{border:1px solid #888c96}.home .card,.contributors.list .card,.categories.list .card,.tags.list .card{margin-top:2rem;margin-bottom:2rem;transition:transform 0.3s}.home .content .card:hover,.contributors.list .content .card:hover,.categories.list .content .card:hover,.tags.list .content .card:hover{transform:scale(1.025)}.home .content .card-body,.contributors.list .content .card-body,.categories.list .content .card-body,.tags.list .content .card-body{padding:0 2rem 1rem}.page-item:first-child,.page-item:last-child,.page-item.disabled{display:none}.page-item a{margin-left:0.5rem;margin-right:0.5rem;padding-left:0.875rem;padding-right:0.875rem}.docs-links,.docs-toc{scrollbar-width:thin;scrollbar-color:#fff #fff}.docs-links::-webkit-scrollbar,.docs-toc::-webkit-scrollbar{width:5px}.docs-links::-webkit-scrollbar-track,.docs-toc::-webkit-scrollbar-track{background:#fff}.docs-links::-webkit-scrollbar-thumb,.docs-toc::-webkit-scrollbar-thumb{background:#fff}.docs-links:hover,.docs-toc:hover{scrollbar-width:thin;scrollbar-color:#e9ecef #fff}.docs-links:hover::-webkit-scrollbar-thumb,.docs-toc:hover::-webkit-scrollbar-thumb{background:#e9ecef}.docs-links::-webkit-scrollbar-thumb:hover,.docs-toc::-webkit-scrollbar-thumb:hover{background:#e9ecef}.docs-links h3,.docs-links .h3,.page-links h3,.page-links .h3{font-size:1.125rem;margin:1.25rem 0 0.5rem;padding:1.5rem 0 0}@media (min-width: 992px){.docs-links h3,.docs-links .h3,.page-links h3,.page-links .h3{margin:1.125rem 1.5rem 0.75rem 0;padding:1.375rem 0 0}}.docs-links h3:not(:first-child),.docs-links .h3:not(:first-child){border-top:1px solid #e9ecef}.page-links li{margin-top:0.375rem;padding-top:0.375rem}.page-links li ul li{border-top:none;padding-left:1rem;margin-top:0.125rem;padding-top:0.125rem}.page-links li:not(:first-child){border-top:1px dashed #e9ecef}.page-links a{color:#1d2d35;display:block;padding:0.125rem 0;font-size:.9375rem;text-decoration:none}.page-links a:hover,.page-links a.active{text-decoration:none;color:#4f46e5}.nav-link.active{font-weight:500}*{-webkit-font-smoothing:antialiased}h1,.h1,h2,.h2,h3,.h3,h4,.h4,h5,.h5,.navbar-brand{font-family:Quicksand, sans-serif;font-weight:700}[data-bs-theme="dark"] .only-light{display:none}[data-bs-theme="light"] .only-dark{display:none} +:root[data-bs-theme="light"],[data-bs-theme="light"] ::backdrop{--sl-color-white: hsl(224, 10%, 10%);--sl-color-gray-1: hsl(224, 14%, 16%);--sl-color-gray-2: hsl(224, 10%, 23%);--sl-color-gray-3: hsl(224, 7%, 36%);--sl-color-gray-4: hsl(224, 6%, 56%);--sl-color-gray-5: hsl(224, 6%, 77%);--sl-color-gray-6: hsl(224, 20%, 94%);--sl-color-gray-7: hsl(224, 19%, 97%);--sl-color-black: hsl(0, 0%, 100%)}:root,::backdrop{--sl-color-white: hsl(0, 0%, 100%);--sl-color-gray-1: hsl(224, 20%, 94%);--sl-color-gray-2: hsl(224, 6%, 77%);--sl-color-gray-3: hsl(224, 6%, 56%);--sl-color-gray-4: hsl(224, 7%, 36%);--sl-color-gray-5: hsl(224, 10%, 23%);--sl-color-gray-6: hsl(224, 14%, 16%);--sl-color-black: hsl(224, 10%, 10%);--sl-hue-orange: 41;--sl-color-orange-low: hsl(var(--sl-hue-orange), 39%, 22%);--sl-color-orange: hsl(var(--sl-hue-orange), 82%, 63%);--sl-color-orange-high: hsl(var(--sl-hue-orange), 82%, 87%);--sl-hue-green: 101;--sl-color-green-low: hsl(var(--sl-hue-green), 39%, 22%);--sl-color-green: hsl(var(--sl-hue-green), 82%, 63%);--sl-color-green-high: hsl(var(--sl-hue-green), 82%, 80%);--sl-hue-blue: 234;--sl-color-blue-low: hsl(var(--sl-hue-blue), 54%, 20%);--sl-color-blue: hsl(var(--sl-hue-blue), 100%, 60%);--sl-color-blue-high: hsl(var(--sl-hue-blue), 100%, 87%);--sl-hue-purple: 281;--sl-color-purple-low: hsl(var(--sl-hue-purple), 39%, 22%);--sl-color-purple: hsl(var(--sl-hue-purple), 82%, 63%);--sl-color-purple-high: hsl(var(--sl-hue-purple), 82%, 89%);--sl-hue-red: 339;--sl-color-red-low: hsl(var(--sl-hue-red), 39%, 22%);--sl-color-red: hsl(var(--sl-hue-red), 82%, 63%);--sl-color-red-high: hsl(var(--sl-hue-red), 82%, 87%);--sl-color-accent-low: hsl(224, 54%, 20%);--sl-color-accent: hsl(224, 100%, 60%);--sl-color-accent-high: hsl(224, 100%, 85%);--sl-color-text: var(--sl-color-gray-2);--sl-color-text-accent: var(--sl-color-accent-high);--sl-color-text-invert: var(--sl-color-accent-low);--sl-color-bg: var(--sl-color-black);--sl-color-bg-nav: var(--sl-color-gray-6);--sl-color-bg-sidebar: var(--sl-color-gray-6);--sl-color-bg-inline-code: var(--sl-color-gray-5);--sl-color-hairline-light: var(--sl-color-gray-5);--sl-color-hairline: var(--sl-color-gray-6);--sl-color-hairline-shade: var(--sl-color-black);--sl-color-backdrop-overlay: hsla(223, 13%, 10%, 0.66);--sl-shadow-sm: 0px 1px 1px hsla(0, 0%, 0%, 0.12), 0px 2px 1px hsla(0, 0%, 0%, 0.24);--sl-shadow-md: 0px 8px 4px hsla(0, 0%, 0%, 0.08), 0px 5px 2px hsla(0, 0%, 0%, 0.08), 0px 3px 2px hsla(0, 0%, 0%, 0.12), 0px 1px 1px hsla(0, 0%, 0%, 0.15);--sl-shadow-lg: 0px 25px 7px hsla(0, 0%, 0%, 0.03), 0px 16px 6px hsla(0, 0%, 0%, 0.1), 0px 9px 5px hsla(223, 13%, 10%, 0.33), 0px 4px 4px hsla(0, 0%, 0%, 0.75), 0px 4px 2px hsla(0, 0%, 0%, 0.25);--sl-text-xs: 0.8125rem;--sl-text-sm: 0.875rem;--sl-text-base: 1rem;--sl-text-lg: 1.125rem;--sl-text-xl: 1.25rem;--sl-text-2xl: 1.5rem;--sl-text-3xl: 1.8125rem;--sl-text-4xl: 2.1875rem;--sl-text-5xl: 2.625rem;--sl-text-6xl: 4rem;--sl-text-body: var(--sl-text-base);--sl-text-body-sm: var(--sl-text-xs);--sl-text-code: var(--sl-text-sm);--sl-text-code-sm: var(--sl-text-xs);--sl-text-h1: var(--sl-text-4xl);--sl-text-h2: var(--sl-text-3xl);--sl-text-h3: var(--sl-text-2xl);--sl-text-h4: var(--sl-text-xl);--sl-text-h5: var(--sl-text-lg);--sl-line-height: 1.8;--sl-line-height-headings: 1.2;--sl-font-system: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--sl-font-system-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--__sl-font: var(--sl-font, ""), var(--sl-font-system);--__sl-font-mono: var(--sl-font-mono, ""), var(--sl-font-system-mono);--sl-nav-height: 3.5rem;--sl-nav-pad-x: 1rem;--sl-nav-pad-y: 0.75rem;--sl-mobile-toc-height: 3rem;--sl-sidebar-width: 18.75rem;--sl-sidebar-pad-x: 1rem;--sl-content-width: 45rem;--sl-content-pad-x: 1rem;--sl-menu-button-size: 2rem;--sl-nav-gap: var(--sl-content-pad-x);--sl-outline-offset-inside: -0.1875rem;--sl-z-index-toc: 4;--sl-z-index-menu: 5;--sl-z-index-navbar: 10;--sl-z-index-skiplink: 20}:root{--purple-hsl: 255, 60%, 60%;--overlay-blurple: hsla(var(--purple-hsl), 0.2)}:root{--ec-brdRad: 0px;--ec-brdWd: 1px;--ec-brdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-codeFontFml: var(--__sl-font-mono);--ec-codeFontSize: var(--sl-text-code);--ec-codeFontWg: 400;--ec-codeLineHt: var(--sl-line-height);--ec-codePadBlk: 0.75rem;--ec-codePadInl: 1rem;--ec-codeBg: #011627;--ec-codeFg: #d6deeb;--ec-codeSelBg: #1d3b53;--ec-uiFontFml: var(--__sl-font);--ec-uiFontSize: 0.9rem;--ec-uiFontWg: 400;--ec-uiLineHt: 1.65;--ec-uiPadBlk: 0.25rem;--ec-uiPadInl: 1rem;--ec-uiSelBg: #234d708c;--ec-uiSelFg: #ffffff;--ec-focusBrd: #122d42;--ec-sbThumbCol: #ffffff17;--ec-sbThumbHoverCol: #ffffff49;--ec-tm-lineMarkerAccentMarg: 0rem;--ec-tm-lineMarkerAccentWd: 0.15rem;--ec-tm-lineDiffIndMargLeft: 0.25rem;--ec-tm-inlMarkerBrdWd: 1.5px;--ec-tm-inlMarkerBrdRad: 0.2rem;--ec-tm-inlMarkerPad: 0.15rem;--ec-tm-insDiffIndContent: "+";--ec-tm-delDiffIndContent: "-";--ec-tm-markBg: #ffffff17;--ec-tm-markBrdCol: #ffffff40;--ec-tm-insBg: #1e571599;--ec-tm-insBrdCol: #487f3bd0;--ec-tm-insDiffIndCol: #79b169d0;--ec-tm-delBg: #862d2799;--ec-tm-delBrdCol: #b4554bd0;--ec-tm-delDiffIndCol: #ed8779d0;--ec-frm-shdCol: #011627;--ec-frm-frameBoxShdCssVal: none;--ec-frm-edActTabBg: var(--sl-color-gray-6);--ec-frm-edActTabFg: var(--sl-color-text);--ec-frm-edActTabBrdCol: transparent;--ec-frm-edActTabIndHt: 1px;--ec-frm-edActTabIndTopCol: var(--sl-color-accent-high);--ec-frm-edActTabIndBtmCol: transparent;--ec-frm-edTabsMargInlStart: 0;--ec-frm-edTabsMargBlkStart: 0;--ec-frm-edTabBrdRad: 0px;--ec-frm-edTabBarBg: var(--sl-color-black);--ec-frm-edTabBarBrdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-edTabBarBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-edBg: var(--sl-color-gray-6);--ec-frm-trmTtbDotsFg: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-trmTtbDotsOpa: 0.75;--ec-frm-trmTtbBg: var(--sl-color-black);--ec-frm-trmTtbFg: var(--sl-color-text);--ec-frm-trmTtbBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-trmBg: var(--sl-color-gray-6);--ec-frm-inlBtnFg: var(--sl-color-text);--ec-frm-inlBtnBg: var(--sl-color-text);--ec-frm-inlBtnBgIdleOpa: 0;--ec-frm-inlBtnBgHoverOrFocusOpa: 0.2;--ec-frm-inlBtnBgActOpa: 0.3;--ec-frm-inlBtnBrd: var(--sl-color-text);--ec-frm-inlBtnBrdOpa: 0.4;--ec-frm-tooltipSuccessBg: #158744;--ec-frm-tooltipSuccessFg: white}:root,[data-bs-theme="light"]{--bs-blue: #3347ff;--bs-indigo: #6610f2;--bs-purple: #bd53ee;--bs-pink: #d63384;--bs-red: #ee5389;--bs-orange: #fd7e14;--bs-yellow: #eebd53;--bs-green: #84ee53;--bs-teal: #20c997;--bs-cyan: #0dcaf0;--bs-black: #000;--bs-white: #fff;--bs-gray: #6c757d;--bs-gray-dark: #343a40;--bs-gray-100: #f8f9fa;--bs-gray-200: #e9ecef;--bs-gray-300: #dee2e6;--bs-gray-400: #ced4da;--bs-gray-500: #adb5bd;--bs-gray-600: #6c757d;--bs-gray-700: #495057;--bs-gray-800: #343a40;--bs-gray-900: #212529;--bs-primary: #4f46e5;--bs-secondary: #6c757d;--bs-success: #84ee53;--bs-info: #3347ff;--bs-warning: #eebd53;--bs-danger: #ee5389;--bs-light: #f8f9fa;--bs-dark: #212529;--bs-primary-rgb: 79,70,229;--bs-secondary-rgb: 108,117,125;--bs-success-rgb: 132.2821,238.017,83.283;--bs-info-rgb: 51,71.4,255;--bs-warning-rgb: 238.017,189.0179,83.283;--bs-danger-rgb: 238.017,83.283,137.4399;--bs-light-rgb: 248,249,250;--bs-dark-rgb: 33,37,41;--bs-primary-text-emphasis: #201c5c;--bs-secondary-text-emphasis: #2b2f32;--bs-success-text-emphasis: #355f21;--bs-info-text-emphasis: #141d66;--bs-warning-text-emphasis: #5f4c21;--bs-danger-text-emphasis: #5f2137;--bs-light-text-emphasis: #495057;--bs-dark-text-emphasis: #495057;--bs-primary-bg-subtle: #dcdafa;--bs-secondary-bg-subtle: #e2e3e5;--bs-success-bg-subtle: #e6fcdd;--bs-info-bg-subtle: #d6daff;--bs-warning-bg-subtle: #fcf2dd;--bs-danger-bg-subtle: #fcdde7;--bs-light-bg-subtle: #fcfcfd;--bs-dark-bg-subtle: #ced4da;--bs-primary-border-subtle: #b9b5f5;--bs-secondary-border-subtle: #c4c8cb;--bs-success-border-subtle: #cef8ba;--bs-info-border-subtle: #adb6ff;--bs-warning-border-subtle: #f8e5ba;--bs-danger-border-subtle: #f8bad0;--bs-light-border-subtle: #e9ecef;--bs-dark-border-subtle: #adb5bd;--bs-white-rgb: 255,255,255;--bs-black-rgb: 0,0,0;--bs-font-sans-serif: "Heebo", "sans-serif", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--bs-gradient: linear-gradient(180deg, rgba(255,255,255,0.15), rgba(255,255,255,0));--bs-body-font-family: var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight: 400;--bs-body-line-height: 1.5;--bs-body-color: #1d2d35;--bs-body-color-rgb: 29,45,53;--bs-body-bg: #fff;--bs-body-bg-rgb: 255,255,255;--bs-emphasis-color: #000;--bs-emphasis-color-rgb: 0,0,0;--bs-secondary-color: rgba(29,45,53,0.75);--bs-secondary-color-rgb: 29,45,53;--bs-secondary-bg: #e9ecef;--bs-secondary-bg-rgb: 233,236,239;--bs-tertiary-color: rgba(29,45,53,0.5);--bs-tertiary-color-rgb: 29,45,53;--bs-tertiary-bg: #f8f9fa;--bs-tertiary-bg-rgb: 248,249,250;--bs-heading-color: inherit;--bs-link-color: #4f46e5;--bs-link-color-rgb: 79,70,229;--bs-link-decoration: none;--bs-link-hover-color: #3f38b7;--bs-link-hover-color-rgb: 63,56,183;--bs-link-hover-decoration: underline;--bs-code-color: #d63384;--bs-highlight-color: #1d2d35;--bs-highlight-bg: #fcf2dd;--bs-border-width: 1px;--bs-border-style: solid;--bs-border-color: #dee2e6;--bs-border-color-translucent: rgba(0,0,0,0.175);--bs-border-radius: .375rem;--bs-border-radius-sm: .25rem;--bs-border-radius-lg: .5rem;--bs-border-radius-xl: 1rem;--bs-border-radius-xxl: 2rem;--bs-border-radius-2xl: var(--bs-border-radius-xxl);--bs-border-radius-pill: 50rem;--bs-box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.15);--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0,0,0,0.075);--bs-box-shadow-lg: 0 1rem 3rem rgba(0,0,0,0.175);--bs-box-shadow-inset: inset 0 1px 2px rgba(0,0,0,0.075);--bs-focus-ring-width: .25rem;--bs-focus-ring-opacity: .25;--bs-focus-ring-color: rgba(79,70,229,0.25);--bs-form-valid-color: #84ee53;--bs-form-valid-border-color: #84ee53;--bs-form-invalid-color: #ee5389;--bs-form-invalid-border-color: #ee5389}[data-bs-theme="dark"]{color-scheme:dark;--bs-body-color: #c1c3c8;--bs-body-color-rgb: 192.831,194.7078,199.869;--bs-body-bg: #17181c;--bs-body-bg-rgb: 22.95,24.31,28.05;--bs-emphasis-color: #fff;--bs-emphasis-color-rgb: 255,255,255;--bs-secondary-color: rgba(193,195,200,0.75);--bs-secondary-color-rgb: 192.831,194.7078,199.869;--bs-secondary-bg: #343a40;--bs-secondary-bg-rgb: 52,58,64;--bs-tertiary-color: rgba(193,195,200,0.5);--bs-tertiary-color-rgb: 192.831,194.7078,199.869;--bs-tertiary-bg: #2b3035;--bs-tertiary-bg-rgb: 43,48,53;--bs-primary-text-emphasis: #9590ef;--bs-secondary-text-emphasis: #a7acb1;--bs-success-text-emphasis: #b5f598;--bs-info-text-emphasis: #8591ff;--bs-warning-text-emphasis: #f5d798;--bs-danger-text-emphasis: #f598b8;--bs-light-text-emphasis: #f8f9fa;--bs-dark-text-emphasis: #dee2e6;--bs-primary-bg-subtle: #100e2e;--bs-secondary-bg-subtle: #161719;--bs-success-bg-subtle: #1a3011;--bs-info-bg-subtle: #0a0e33;--bs-warning-bg-subtle: #302611;--bs-danger-bg-subtle: #30111b;--bs-light-bg-subtle: #23262f;--bs-dark-bg-subtle: #1a1d20;--bs-primary-border-subtle: #2f2a89;--bs-secondary-border-subtle: #41464b;--bs-success-border-subtle: #4f8f32;--bs-info-border-subtle: #1f2b99;--bs-warning-border-subtle: #8f7132;--bs-danger-border-subtle: #8f3252;--bs-light-border-subtle: #353841;--bs-dark-border-subtle: #343a40;--bs-heading-color: #fff;--bs-link-color: #b3c7ff;--bs-link-hover-color: #c2d2ff;--bs-link-color-rgb: 178.5,198.9,255;--bs-link-hover-color-rgb: 194,210,255;--bs-code-color: #e685b5;--bs-highlight-color: #c1c3c8;--bs-highlight-bg: #5f4c21;--bs-border-color: #495057;--bs-border-color-translucent: rgba(255,255,255,0.15);--bs-form-valid-color: #b5f598;--bs-form-valid-border-color: #b5f598;--bs-form-invalid-color: #f598b8;--bs-form-invalid-border-color: #f598b8}*,*::before,*::after{box-sizing:border-box}@media (prefers-reduced-motion: no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0)}h5,.h5,h4,.h4,h3,.h3,h2,.h2,h1,.h1{margin-top:0;margin-bottom:.5rem;font-weight:700;line-height:1.2;color:var(--bs-heading-color)}h1,.h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width: 1200px){h1,.h1{font-size:2.5rem}}h2,.h2{font-size:calc(1.325rem + .9vw)}@media (min-width: 1200px){h2,.h2{font-size:2rem}}h3,.h3{font-size:calc(1.3rem + .6vw)}@media (min-width: 1200px){h3,.h3{font-size:1.75rem}}h4,.h4{font-size:calc(1.275rem + .3vw)}@media (min-width: 1200px){h4,.h4{font-size:1.5rem}}h5,.h5{font-size:1.25rem}p{margin-top:0;margin-bottom:1rem}ol,ul{padding-left:2rem}ol,ul,dl{margin-top:0;margin-bottom:1rem}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}strong{font-weight:bolder}small,.small{font-size:.875em}mark,.mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}a{color:rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));text-decoration:none}a:hover{--bs-link-color-rgb: var(--bs-link-hover-color-rgb);text-decoration:underline}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}pre,code,kbd,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}th{text-align:inherit;text-align:-webkit-match-parent}thead,tbody,tr,td,th{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}input,button{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button{text-transform:none}[list]:not([type="date"]):not([type="datetime-local"]):not([type="month"]):not([type="week"]):not([type="time"])::-webkit-calendar-picker-indicator{display:none !important}button,[type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button:not(:disabled),[type="button"]:not(:disabled),[type="reset"]:not(:disabled),[type="submit"]:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-text,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit;-webkit-appearance:button}iframe{border:0}summary{display:list-item;cursor:pointer}[hidden]{display:none !important}.lead{font-size:1.25rem;font-weight:400}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.img-fluid{max-width:100%;height:auto}.figure{display:inline-block}.container,.container-fluid,.container-lg{--bs-gutter-x: 3rem;--bs-gutter-y: 0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width: 576px){.container{max-width:540px}}@media (min-width: 768px){.container{max-width:720px}}@media (min-width: 992px){.container-lg,.container{max-width:960px}}@media (min-width: 1200px){.container-lg,.container{max-width:1240px}}@media (min-width: 1400px){.container-lg,.container{max-width:1820px}}:root{--bs-breakpoint-xs: 0;--bs-breakpoint-sm: 576px;--bs-breakpoint-md: 768px;--bs-breakpoint-lg: 992px;--bs-breakpoint-xl: 1200px;--bs-breakpoint-xxl: 1400px}.row{--bs-gutter-x: 3rem;--bs-gutter-y: 0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}@media (min-width: 768px){.col-md-12{flex:0 0 auto;width:75%}}@media (min-width: 992px){.col-lg-5{flex:0 0 auto;width:31.25%}.col-lg-8{flex:0 0 auto;width:50%}.col-lg-9{flex:0 0 auto;width:56.25%}.col-lg-10{flex:0 0 auto;width:62.5%}.col-lg-11{flex:0 0 auto;width:68.75%}.col-lg-12{flex:0 0 auto;width:75%}}@media (min-width: 1200px){.col-xl-3{flex:0 0 auto;width:18.75%}.col-xl-4{flex:0 0 auto;width:25%}.col-xl-8{flex:0 0 auto;width:50%}.col-xl-9{flex:0 0 auto;width:56.25%}}.sticky-top{position:sticky;top:0;z-index:1020}.visually-hidden{width:1px !important;height:1px !important;padding:0 !important;margin:-1px !important;overflow:hidden !important;clip:rect(0, 0, 0, 0) !important;white-space:nowrap !important;border:0 !important}.visually-hidden:not(caption){position:absolute !important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.table,table{--bs-table-color-type: initial;--bs-table-bg-type: initial;--bs-table-color-state: initial;--bs-table-bg-state: initial;--bs-table-color: var(--bs-emphasis-color);--bs-table-bg: var(--bs-body-bg);--bs-table-border-color: var(--bs-border-color);--bs-table-accent-bg: rgba(0,0,0,0);--bs-table-striped-color: var(--bs-emphasis-color);--bs-table-striped-bg: rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color: var(--bs-emphasis-color);--bs-table-active-bg: rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color: var(--bs-emphasis-color);--bs-table-hover-bg: rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*,table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg)))}.table>tbody,table>tbody{vertical-align:inherit}.table>thead,table>thead{vertical-align:bottom}[data-bs-theme="dark"] table{--bs-table-color: #fff;--bs-table-bg: #212529;--bs-table-border-color: #4d5154;--bs-table-striped-bg: #2c3034;--bs-table-striped-color: #fff;--bs-table-active-bg: #373b3e;--bs-table-active-color: #fff;--bs-table-hover-bg: #323539;--bs-table-hover-color: #fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control{transition:none}}.form-control[type="file"]{overflow:hidden}.form-control[type="file"]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#a7a3f2;outline:0;box-shadow:none}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;margin-inline-end:1rem}li input[type="checkbox"]{--bs-form-check-bg: var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;print-color-adjust:exact}li input[type="checkbox"]{border-radius:.25em}li input[type="radio"][type="checkbox"]{border-radius:50%}li input[type="checkbox"]:active{filter:brightness(90%)}li input[type="checkbox"]:focus{border-color:#a7a3f2;outline:0;box-shadow:0 0 0 .25rem rgba(79,70,229,0.25)}li input[type="checkbox"]:checked{background-color:#4f46e5;border-color:#4f46e5}li input:checked[type="checkbox"]{--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}li input[type="checkbox"]:checked[type="radio"]{--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}li input[type="checkbox"]:indeterminate{background-color:#4f46e5;border-color:#4f46e5;--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}li input[type="checkbox"]:disabled{pointer-events:none;filter:none;opacity:.5}.btn{--bs-btn-padding-x: .75rem;--bs-btn-padding-y: .375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight: 400;--bs-btn-line-height: 1.5;--bs-btn-color: var(--bs-body-color);--bs-btn-bg: transparent;--bs-btn-border-width: var(--bs-border-width);--bs-btn-border-color: transparent;--bs-btn-border-radius: var(--bs-border-radius);--bs-btn-hover-border-color: transparent;--bs-btn-box-shadow: inset 0 1px 0 rgba(255,255,255,0.15),0 1px 1px rgba(0,0,0,0.075);--bs-btn-disabled-opacity: .65;--bs-btn-focus-box-shadow: 0 0 0 0 rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);text-decoration:none;background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}:not(.btn-check)+.btn:active,.btn:first-child:active,.btn.active,.btn.show{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}:not(.btn-check)+.btn:active:focus-visible,.btn:first-child:active:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn:disabled,.btn.disabled{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color: #fff;--bs-btn-bg: #4f46e5;--bs-btn-border-color: #4f46e5;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #433cc3;--bs-btn-hover-border-color: #3f38b7;--bs-btn-focus-shadow-rgb: 105,98,233;--bs-btn-active-color: #fff;--bs-btn-active-bg: #3f38b7;--bs-btn-active-border-color: #3b35ac;--bs-btn-active-shadow: inset 0 3px 5px rgba(0,0,0,0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #4f46e5;--bs-btn-disabled-border-color: #4f46e5}.btn-outline-primary{--bs-btn-color: #4f46e5;--bs-btn-border-color: #4f46e5;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #4f46e5;--bs-btn-hover-border-color: #4f46e5;--bs-btn-focus-shadow-rgb: 79,70,229;--bs-btn-active-color: #fff;--bs-btn-active-bg: #4f46e5;--bs-btn-active-border-color: #4f46e5;--bs-btn-active-shadow: inset 0 3px 5px rgba(0,0,0,0.125);--bs-btn-disabled-color: #4f46e5;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #4f46e5;--bs-gradient: none}.btn-link{--bs-btn-font-weight: 400;--bs-btn-color: var(--bs-link-color);--bs-btn-bg: transparent;--bs-btn-border-color: transparent;--bs-btn-hover-color: var(--bs-link-hover-color);--bs-btn-hover-border-color: transparent;--bs-btn-active-color: var(--bs-link-hover-color);--bs-btn-active-border-color: transparent;--bs-btn-disabled-color: #6c757d;--bs-btn-disabled-border-color: transparent;--bs-btn-box-shadow: 0 0 0 #000;--bs-btn-focus-shadow-rgb: 105,98,233;text-decoration:none}.btn-link:hover,.btn-link:focus-visible{text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-lg{--bs-btn-padding-y: .5rem;--bs-btn-padding-x: 1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius: var(--bs-border-radius-lg)}.fade{transition:opacity 0.15s linear}@media (prefers-reduced-motion: reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.nav{--bs-nav-link-padding-x: 1rem;--bs-nav-link-padding-y: .5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color: var(--bs-link-color);--bs-nav-link-hover-color: var(--bs-link-hover-color);--bs-nav-link-disabled-color: var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);background:none;border:0;transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.nav-link{transition:none}}.nav-link:hover,.nav-link:focus{color:var(--bs-nav-link-hover-color);text-decoration:none}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(79,70,229,0.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width: var(--bs-border-width);--bs-nav-tabs-border-color: var(--bs-border-color);--bs-nav-tabs-border-radius: var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color: var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color: var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg: var(--bs-body-bg);--bs-nav-tabs-link-active-border-color: var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:hover,.nav-tabs .nav-link:focus{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.active,.nav-tabs .nav-item.show .nav-link{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x: 0;--bs-navbar-padding-y: .5rem;--bs-navbar-color: rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color: rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color: rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y: .3125rem;--bs-navbar-brand-margin-end: 1rem;--bs-navbar-brand-font-size: 1.25rem;--bs-navbar-brand-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x: .5rem;--bs-navbar-toggler-padding-y: .25rem;--bs-navbar-toggler-padding-x: .75rem;--bs-navbar-toggler-font-size: 1.25rem;--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2829,45,53,0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color: rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius: var(--bs-border-radius);--bs-navbar-toggler-focus-width: 0;--bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);white-space:nowrap}.navbar-brand:hover,.navbar-brand:focus{color:var(--bs-navbar-brand-hover-color);text-decoration:none}.navbar-nav{--bs-nav-link-padding-x: 0;--bs-nav-link-padding-y: .5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color: var(--bs-navbar-color);--bs-nav-link-hover-color: var(--bs-navbar-hover-color);--bs-nav-link-disabled-color: var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}@media (min-width: 992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:transparent !important;border:0 !important;transform:none !important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar[data-bs-theme="dark"]{--bs-navbar-color: #c1c3c8;--bs-navbar-hover-color: #b3c7ff;--bs-navbar-disabled-color: rgba(255,255,255,0.25);--bs-navbar-active-color: #b3c7ff;--bs-navbar-brand-color: #b3c7ff;--bs-navbar-brand-hover-color: #b3c7ff;--bs-navbar-toggler-border-color: rgba(255,255,255,0.1);--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='%23c1c3c8' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y: 1rem;--bs-card-spacer-x: 1rem;--bs-card-title-spacer-y: .5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width: var(--bs-border-width);--bs-card-border-color: #e9ecef;--bs-card-border-radius: var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius: calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y: .5rem;--bs-card-cap-padding-x: 1rem;--bs-card-cap-bg: rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg: var(--bs-body-bg);--bs-card-img-overlay-padding: 1rem;--bs-card-group-margin: 1.5rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);text-decoration:none;background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.page-link.active,.active>.page-link{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.page-link.disabled,.disabled>.page-link{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.btn-close{--bs-btn-close-color: #000;--bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity: .5;--bs-btn-close-hover-opacity: .75;--bs-btn-close-focus-shadow: 0 0 0 .25rem rgba(79,70,229,0.25);--bs-btn-close-focus-opacity: 1;--bs-btn-close-disabled-opacity: .25;--bs-btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close:disabled,.btn-close.disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}[data-bs-theme="dark"] .btn-close{filter:var(--bs-btn-close-white-filter)}.modal{--bs-modal-zindex: 1055;--bs-modal-width: 500px;--bs-modal-padding: 1rem;--bs-modal-margin: .5rem;--bs-modal-color: ;--bs-modal-bg: var(--bs-body-bg);--bs-modal-border-color: var(--bs-border-color-translucent);--bs-modal-border-width: var(--bs-border-width);--bs-modal-border-radius: var(--bs-border-radius-lg);--bs-modal-box-shadow: var(--bs-box-shadow-sm);--bs-modal-inner-border-radius: calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x: 1rem;--bs-modal-header-padding-y: 1rem;--bs-modal-header-padding: 1rem 1rem;--bs-modal-header-border-color: var(--bs-border-color);--bs-modal-header-border-width: var(--bs-border-width);--bs-modal-title-line-height: 1.5;--bs-modal-footer-gap: .5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color: var(--bs-border-color);--bs-modal-footer-border-width: var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform 0.3s ease-out;transform:translate(0, -50px)}@media (prefers-reduced-motion: reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex: 1050;--bs-backdrop-bg: #000;--bs-backdrop-opacity: .5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width: 576px){.modal{--bs-modal-margin: 1.75rem;--bs-modal-box-shadow: var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}}@media (max-width: 767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header,.modal-fullscreen-md-down .modal-footer{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@keyframes spinner-border{to{transform:rotate(360deg) /* rtl:ignore */}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.offcanvas{--bs-offcanvas-zindex: 1045;--bs-offcanvas-width: 332px;--bs-offcanvas-height: 30vh;--bs-offcanvas-padding-x: 1rem;--bs-offcanvas-padding-y: 1rem;--bs-offcanvas-color: var(--bs-body-color);--bs-offcanvas-bg: var(--bs-body-bg);--bs-offcanvas-border-width: var(--bs-border-width);--bs-offcanvas-border-color: var(--bs-border-color-translucent);--bs-offcanvas-box-shadow: var(--bs-box-shadow-sm);--bs-offcanvas-transition: transform .3s ease-in-out;--bs-offcanvas-title-line-height: 1.5}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion: reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.showing,.offcanvas.show:not(.hiding){transform:none}.offcanvas.showing,.offcanvas.hiding,.offcanvas.show{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin:calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}@keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.d-flex{display:flex !important}.d-none{display:none !important}.w-100{width:100% !important}.h-auto{height:auto !important}.flex-row{flex-direction:row !important}.flex-column{flex-direction:column !important}.flex-grow-1{flex-grow:1 !important}.justify-content-end{justify-content:flex-end !important}.justify-content-center{justify-content:center !important}.justify-content-between{justify-content:space-between !important}.order-3{order:3 !important}.m-2{margin:.5rem !important}.m-3{margin:1rem !important}.mx-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-auto{margin-right:auto !important;margin-left:auto !important}.my-3{margin-top:1rem !important;margin-bottom:1rem !important}.mt-3{margin-top:1rem !important}.mt-4{margin-top:1.5rem !important}.mt-5{margin-top:3rem !important}.me-2{margin-right:.5rem !important}.me-auto{margin-right:auto !important}.mb-0{margin-bottom:0 !important}.mb-1{margin-bottom:.25rem !important}.mb-4{margin-bottom:1.5rem !important}.mb-5{margin-bottom:3rem !important}.ms-1{margin-left:.25rem !important}.ms-2{margin-left:.5rem !important}.ms-3{margin-left:1rem !important}.ms-auto{margin-left:auto !important}.mt-n3{margin-top:-1rem !important}.p-0{padding:0 !important}.p-2{padding:.5rem !important}.px-0{padding-right:0 !important;padding-left:0 !important}.pt-4{padding-top:1.5rem !important}.pe-4{padding-right:1.5rem !important}.pb-2{padding-bottom:.5rem !important}.pb-3{padding-bottom:1rem !important}.ps-3{padding-left:1rem !important}.fs-5{font-size:1.25rem !important}.text-end{text-align:right !important}.text-center{text-align:center !important}.text-decoration-none{text-decoration:none !important}.text-muted{--bs-text-opacity: 1;color:var(--bs-secondary-color) !important}.text-body-secondary{--bs-text-opacity: 1;color:var(--bs-secondary-color) !important}.text-reset{--bs-text-opacity: 1;color:inherit !important}.rounded-pill{border-radius:var(--bs-border-radius-pill) !important}@media (min-width: 576px){.flex-sm-row{flex-direction:row !important}}@media (min-width: 768px){.d-md-block{display:block !important}.d-md-none{display:none !important}.flex-md-row{flex-direction:row !important}}@media (min-width: 992px){.d-lg-block{display:block !important}.d-lg-none{display:none !important}.flex-lg-row{flex-direction:row !important}.order-lg-4{order:4 !important}.me-lg-1{margin-right:.25rem !important}.me-lg-3{margin-right:1rem !important}.ms-lg-2{margin-left:.5rem !important}.text-lg-start{text-align:left !important}.text-lg-end{text-align:right !important}}@media (min-width: 1200px){.d-xl-block{display:block !important}.d-xl-none{display:none !important}.flex-xl-nowrap{flex-wrap:nowrap !important}.mx-xl-auto{margin-right:auto !important;margin-left:auto !important}}@font-face{font-family:Jost;font-style:normal;font-weight:400;font-display:swap;src:local("Jost Regular Regular"),local("Jost-Regular"),local("Jost* Book"),local("Jost-Book"),url("fonts/vendor/jost/jost-v4-latin-regular.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-regular.woff") format("woff")}@font-face{font-family:Jost;font-style:normal;font-weight:500;font-display:swap;src:local("Jost Regular Medium"),local("JostRoman-Medium"),local("Jost* Medium"),local("Jost-Medium"),url("fonts/vendor/jost/jost-v4-latin-500.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-500.woff") format("woff")}@font-face{font-family:Jost;font-style:normal;font-weight:700;font-display:swap;src:local("Jost Regular Bold"),local("JostRoman-Bold"),local("Jost* Bold"),local("Jost-Bold"),url("fonts/vendor/jost/jost-v4-latin-700.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-700.woff") format("woff")}@font-face{font-family:Jost;font-style:italic;font-weight:400;font-display:swap;src:local("Jost Italic Italic"),local("Jost-Italic"),local("Jost* BookItalic"),local("Jost-BookItalic"),url("fonts/vendor/jost/jost-v4-latin-italic.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-italic.woff") format("woff")}@font-face{font-family:Jost;font-style:italic;font-weight:500;font-display:swap;src:local("Jost Italic Medium Italic"),local("JostItalic-Medium"),local("Jost* Medium Italic"),local("Jost-MediumItalic"),url("fonts/vendor/jost/jost-v4-latin-500italic.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-500italic.woff") format("woff")}@font-face{font-family:Jost;font-style:italic;font-weight:700;font-display:swap;src:local("Jost Italic Bold Italic"),local("JostItalic-Bold"),local("Jost* Bold Italic"),local("Jost-BoldItalic"),url("fonts/vendor/jost/jost-v4-latin-700italic.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-700italic.woff") format("woff")}html[data-bs-theme="dark"] .icon-tabler-sun{display:block}html[data-bs-theme="dark"] .icon-tabler-moon{display:none}html[data-bs-theme="light"] .icon-tabler-sun{display:none}html[data-bs-theme="light"] .icon-tabler-moon{display:block}.contributors .content,.error404 .content,.docs.list .content,.categories.list .content,.tags.list .content,.list.section .content{padding-top:1rem;padding-bottom:3rem}.content img{max-width:100%}h5,.h5,h4,.h4,h3,.h3,h2,.h2,h1,.h1{margin-top:2rem;margin-bottom:1rem}@media (min-width: 768px){body{font-size:1.125rem}h1,h2,h3,h4,h5,.h1,.h2,.h3,.h4,.h5{margin-bottom:1.125rem}}.home h1,.home .h1{font-size:calc(1.875rem + 1.5vw);margin-top:-1rem}a:hover,a:focus{text-decoration:underline}a.btn:hover,a.btn:focus{text-decoration:none}.section{padding-top:5rem;padding-bottom:5rem}body.section{padding-top:0;padding-bottom:0}.section-md{padding-top:3rem;padding-bottom:3rem}.docs-sidebar{order:2}@media (min-width: 992px){.docs-sidebar{order:0;border-right:1px solid #e9ecef}@supports (position: sticky){.docs-sidebar{position:sticky;top:4.25rem;z-index:1000;height:calc(100vh - 4.25rem)}}}@media (min-width: 1200px){.docs-sidebar{flex:0 1 320px}}.docs-links{padding-bottom:5rem}@media (min-width: 992px){@supports (position: sticky){.docs-links{max-height:calc(100vh - 4rem);overflow-y:scroll}}}@media (min-width: 992px){.docs-links{display:block;width:auto;margin-right:-1.5rem;padding-bottom:4rem}}.docs-toc{order:2}@supports (position: sticky){.docs-toc{position:sticky;top:4.25rem;height:calc(100vh - 4.25rem);overflow-y:auto}}.docs-content{padding-bottom:3rem;order:1}.navbar a:hover,.navbar a:focus{text-decoration:none}#TableOfContents ul,#toc ul{padding-left:0;list-style:none}#toc a.active{color:#4f46e5;font-weight:500}.section-features{padding-top:2rem}.modal-backdrop{background-color:#fff}.modal-backdrop.show{opacity:0.7}@media (min-width: 768px){.modal-backdrop.show{opacity:0}}li input[type="checkbox"]{margin:0.25rem;border:1px solid #ced4da}li input[type="checkbox"]:disabled{pointer-events:none;filter:none;opacity:1}li input[type="checkbox"]:checked{background-color:#5d2f86;border-color:#5d2f86}[data-bs-theme="dark"] li input[type="checkbox"]{border:1px solid #6c757d}[data-bs-theme="dark"] li input[type="checkbox"]:checked{background-color:#b3c7ff;border-color:#b3c7ff;--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%231d2d35' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.highlight>.chroma{border:1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)}.bg{background-color:var(--sl-color-gray-7)}.chroma{background-color:var(--sl-color-gray-7)}.chroma .err{color:inherit}.chroma .lnlinks{outline:none;text-decoration:none;color:inherit}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}.chroma .hl{background-color:#0000001a}.chroma .hl{border-inline-start:0.15rem solid #00000055;margin-left:-1rem;margin-right:-1rem;padding-left:1rem;padding-right:1rem}.chroma .hl .ln{margin-left:-0.15rem}.chroma .lnt{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f}.chroma .ln{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f}.chroma .line{display:flex}.chroma .k{color:#000000;font-weight:bold}.chroma .kc{color:#000000;font-weight:bold}.chroma .kd{color:#000000;font-weight:bold}.chroma .kn{color:#000000;font-weight:bold}.chroma .kp{color:#000000;font-weight:bold}.chroma .kr{color:#000000;font-weight:bold}.chroma .kt{color:#445588;font-weight:bold}.chroma .na{color:#008080}.chroma .nb{color:#0086b3}.chroma .bp{color:#999999}.chroma .nc{color:#445588;font-weight:bold}.chroma .no{color:#008080}.chroma .nd{color:#3c5d5d;font-weight:bold}.chroma .ni{color:#800080}.chroma .ne{color:#990000;font-weight:bold}.chroma .nf{color:#990000;font-weight:bold}.chroma .nl{color:#990000;font-weight:bold}.chroma .nn{color:#555555}.chroma .nt{color:#000080}.chroma .nv{color:#008080}.chroma .vc{color:#008080}.chroma .vg{color:#008080}.chroma .vi{color:#008080}.chroma .s{color:#dd1144}.chroma .sa{color:#dd1144}.chroma .sb{color:#dd1144}.chroma .sc{color:#dd1144}.chroma .dl{color:#dd1144}.chroma .sd{color:#dd1144}.chroma .s2{color:#dd1144}.chroma .se{color:#dd1144}.chroma .sh{color:#dd1144}.chroma .si{color:#dd1144}.chroma .sx{color:#dd1144}.chroma .sr{color:#009926}.chroma .s1{color:#dd1144}.chroma .ss{color:#990073}.chroma .m{color:#009999}.chroma .mb{color:#009999}.chroma .mf{color:#009999}.chroma .mh{color:#009999}.chroma .mi{color:#009999}.chroma .il{color:#009999}.chroma .mo{color:#009999}.chroma .o{color:#000000;font-weight:bold}.chroma .ow{color:#000000;font-weight:bold}.chroma .c{color:#999988;font-style:italic}.chroma .ch{color:#999988;font-style:italic}.chroma .cm{color:#999988;font-style:italic}.chroma .c1{color:#999988;font-style:italic}.chroma .cs{color:#999999;font-weight:bold;font-style:italic}.chroma .cp{color:#999999;font-weight:bold;font-style:italic}.chroma .cpf{color:#999999;font-weight:bold;font-style:italic}.chroma .gd{color:#000000;background-color:#ffdddd}.chroma .ge{color:inherit;font-style:italic}.chroma .gr{color:#aa0000}.chroma .gh{color:#999999}.chroma .gi{color:#000000;background-color:#ddffdd}.chroma .go{color:#888888}.chroma .gp{color:#555555}.chroma .gs{font-weight:bold}.chroma .gu{color:#aaaaaa}.chroma .gt{color:#aa0000}.chroma .gl{text-decoration:underline}.chroma .w{color:#bbbbbb}[data-bs-theme="dark"] .highlight>.chroma{border:1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)}[data-bs-theme="dark"] .bg{color:#c9d1d9;background-color:var(--sl-color-gray-6)}[data-bs-theme="dark"] .chroma{color:#c9d1d9;background-color:var(--sl-color-gray-6)}[data-bs-theme="dark"] .chroma .err{color:inherit}[data-bs-theme="dark"] .chroma .lnlinks{outline:none;text-decoration:none;color:inherit}[data-bs-theme="dark"] .chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}[data-bs-theme="dark"] .chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}[data-bs-theme="dark"] .chroma .hl{background-color:#ffffff17}[data-bs-theme="dark"] .chroma .hl{border-inline-start:0.15rem solid #ffffff40;margin-left:-1rem;margin-right:-1rem;padding-left:1rem;padding-right:1rem}[data-bs-theme="dark"] .chroma .hl .ln{margin-left:-0.15rem}[data-bs-theme="dark"] .chroma .lnt{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#64686c}[data-bs-theme="dark"] .chroma .ln{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681}[data-bs-theme="dark"] .chroma .line{display:flex}[data-bs-theme="dark"] .chroma .k{color:#ff7b72}[data-bs-theme="dark"] .chroma .kc{color:#79c0ff}[data-bs-theme="dark"] .chroma .kd{color:#ff7b72}[data-bs-theme="dark"] .chroma .kn{color:#ff7b72}[data-bs-theme="dark"] .chroma .kp{color:#79c0ff}[data-bs-theme="dark"] .chroma .kr{color:#ff7b72}[data-bs-theme="dark"] .chroma .kt{color:#ff7b72}[data-bs-theme="dark"] .chroma .na{color:#d2a8ff}[data-bs-theme="dark"] .chroma .nc{color:#f0883e;font-weight:bold}[data-bs-theme="dark"] .chroma .no{color:#79c0ff;font-weight:bold}[data-bs-theme="dark"] .chroma .nd{color:#d2a8ff;font-weight:bold}[data-bs-theme="dark"] .chroma .ni{color:#ffa657}[data-bs-theme="dark"] .chroma .ne{color:#f0883e;font-weight:bold}[data-bs-theme="dark"] .chroma .nf{color:#d2a8ff;font-weight:bold}[data-bs-theme="dark"] .chroma .nl{color:#79c0ff;font-weight:bold}[data-bs-theme="dark"] .chroma .nn{color:#ff7b72}[data-bs-theme="dark"] .chroma .py{color:#79c0ff}[data-bs-theme="dark"] .chroma .nt{color:#7ee787}[data-bs-theme="dark"] .chroma .nv{color:#79c0ff}[data-bs-theme="dark"] .chroma .l{color:#a5d6ff}[data-bs-theme="dark"] .chroma .ld{color:#79c0ff}[data-bs-theme="dark"] .chroma .s{color:#a5d6ff}[data-bs-theme="dark"] .chroma .sa{color:#79c0ff}[data-bs-theme="dark"] .chroma .sb{color:#a5d6ff}[data-bs-theme="dark"] .chroma .sc{color:#a5d6ff}[data-bs-theme="dark"] .chroma .dl{color:#79c0ff}[data-bs-theme="dark"] .chroma .sd{color:#a5d6ff}[data-bs-theme="dark"] .chroma .s2{color:#a5d6ff}[data-bs-theme="dark"] .chroma .se{color:#79c0ff}[data-bs-theme="dark"] .chroma .sh{color:#79c0ff}[data-bs-theme="dark"] .chroma .si{color:#a5d6ff}[data-bs-theme="dark"] .chroma .sx{color:#a5d6ff}[data-bs-theme="dark"] .chroma .sr{color:#79c0ff}[data-bs-theme="dark"] .chroma .s1{color:#a5d6ff}[data-bs-theme="dark"] .chroma .ss{color:#a5d6ff}[data-bs-theme="dark"] .chroma .m{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mb{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mf{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mh{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mi{color:#a5d6ff}[data-bs-theme="dark"] .chroma .il{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mo{color:#a5d6ff}[data-bs-theme="dark"] .chroma .o{color:inherit;font-weight:bold}[data-bs-theme="dark"] .chroma .ow{color:#ff7b72;font-weight:bold}[data-bs-theme="dark"] .chroma .c{color:#8b949e;font-style:italic}[data-bs-theme="dark"] .chroma .ch{color:#8b949e;font-style:italic}[data-bs-theme="dark"] .chroma .cm{color:#8b949e;font-style:italic}[data-bs-theme="dark"] .chroma .c1{color:#8b949e;font-style:italic}[data-bs-theme="dark"] .chroma .cs{color:#8b949e;font-weight:bold;font-style:italic}[data-bs-theme="dark"] .chroma .cp{color:#8b949e;font-weight:bold;font-style:italic}[data-bs-theme="dark"] .chroma .cpf{color:#8b949e;font-weight:bold;font-style:italic}[data-bs-theme="dark"] .chroma .gd{color:#ffa198;background-color:#490202}[data-bs-theme="dark"] .chroma .ge{font-style:italic}[data-bs-theme="dark"] .chroma .gr{color:#ffa198}[data-bs-theme="dark"] .chroma .gh{color:#79c0ff;font-weight:bold}[data-bs-theme="dark"] .chroma .gi{color:#56d364;background-color:#0f5323}[data-bs-theme="dark"] .chroma .go{color:#8b949e}[data-bs-theme="dark"] .chroma .gp{color:#8b949e}[data-bs-theme="dark"] .chroma .gs{font-weight:bold}[data-bs-theme="dark"] .chroma .gu{color:#79c0ff}[data-bs-theme="dark"] .chroma .gt{color:#ff7b72}[data-bs-theme="dark"] .chroma .gl{text-decoration:underline}[data-bs-theme="dark"] .chroma .w{color:#6e7681}[data-bs-theme="dark"] h1,[data-bs-theme="dark"] .h1,[data-bs-theme="dark"] h2,[data-bs-theme="dark"] .h2,[data-bs-theme="dark"] h3,[data-bs-theme="dark"] .h3,[data-bs-theme="dark"] h4,[data-bs-theme="dark"] .h4{color:#fff}[data-bs-theme="dark"] body{background:#17181c;color:#c1c3c8}[data-bs-theme="dark"] a{color:#b3c7ff}[data-bs-theme="dark"] .callout a{color:inherit}[data-bs-theme="dark"] .btn-primary{--bs-btn-color: #000;--bs-btn-bg: #b3c7ff;--bs-btn-border-color: #b3c7ff;--bs-btn-hover-color: #000;--bs-btn-hover-bg: #becfff;--bs-btn-hover-border-color: #bacdff;--bs-btn-focus-shadow-rgb: 152,169,217;--bs-btn-active-color: #000;--bs-btn-active-bg: #c2d2ff;--bs-btn-active-border-color: #bacdff;--bs-btn-active-shadow: inset 0 3px 5px rgba(0,0,0,0.125);--bs-btn-disabled-color: #000;--bs-btn-disabled-bg: #b3c7ff;--bs-btn-disabled-border-color: #b3c7ff;color:#17181c}[data-bs-theme="dark"] .btn-outline-primary{--bs-btn-color: #b3c7ff;--bs-btn-border-color: #b3c7ff;--bs-btn-hover-color: #b3c7ff;--bs-btn-hover-bg: #b3c7ff;--bs-btn-hover-border-color: #b3c7ff;--bs-btn-focus-shadow-rgb: 178.5,198.9,255;--bs-btn-active-color: #000;--bs-btn-active-bg: #b3c7ff;--bs-btn-active-border-color: #b3c7ff;--bs-btn-active-shadow: inset 0 3px 5px rgba(0,0,0,0.125);--bs-btn-disabled-color: #b3c7ff;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #b3c7ff;--bs-gradient: none;color:#b3c7ff}[data-bs-theme="dark"] .btn-outline-primary:hover{color:#17181c}[data-bs-theme="dark"] .navbar{background-color:rgba(23,24,28,0.95);border-bottom:1px solid #23262f}[data-bs-theme="dark"] body.home .navbar{border-bottom:0}[data-bs-theme="dark"] .offcanvas-header{border-bottom:1px solid #343a40}[data-bs-theme="dark"] .offcanvas .nav-link{color:#c1c3c8}[data-bs-theme="dark"] .offcanvas .nav-link:hover,[data-bs-theme="dark"] .offcanvas .nav-link:focus{color:#b3c7ff}[data-bs-theme="dark"] .offcanvas .nav-link.active{color:#b3c7ff}[data-bs-theme="dark"] .page-links a{color:#c1c3c8}[data-bs-theme="dark"] .page-links a:hover{text-decoration:none;color:#b3c7ff}[data-bs-theme="dark"] .navbar .btn-link{color:#c1c3c8}[data-bs-theme="dark"] .content .btn-link{color:#b3c7ff}[data-bs-theme="dark"] .content .btn-link:hover{color:#b3c7ff}[data-bs-theme="dark"] .navbar .btn-link:hover{color:#b3c7ff}[data-bs-theme="dark"] .navbar .btn-link:active{color:#b3c7ff}[data-bs-theme="dark"] .form-control{color:#dee2e6}[data-bs-theme="dark"] .form-control::-moz-placeholder{color:#ced4da;opacity:1}[data-bs-theme="dark"] .form-control::placeholder{color:#ced4da;opacity:1}@media (min-width: 992px){[data-bs-theme="dark"] .docs-sidebar{order:0;border-right:1px solid #23262f}}[data-bs-theme="dark"] blockquote{border-left:3px solid #23262f}[data-bs-theme="dark"] .footer{border-top:1px solid #23262f}[data-bs-theme="dark"] .docs-links,[data-bs-theme="dark"] .docs-toc{scrollbar-width:thin;scrollbar-color:#17181c #17181c}[data-bs-theme="dark"] .docs-links::-webkit-scrollbar,[data-bs-theme="dark"] .docs-toc::-webkit-scrollbar{width:5px}[data-bs-theme="dark"] .docs-links::-webkit-scrollbar-track,[data-bs-theme="dark"] .docs-toc::-webkit-scrollbar-track{background:#17181c}[data-bs-theme="dark"] .docs-links::-webkit-scrollbar-thumb,[data-bs-theme="dark"] .docs-toc::-webkit-scrollbar-thumb{background:#17181c}[data-bs-theme="dark"] .docs-links:hover,[data-bs-theme="dark"] .docs-toc:hover{scrollbar-width:thin;scrollbar-color:#23262f #17181c}[data-bs-theme="dark"] .docs-links:hover::-webkit-scrollbar-thumb,[data-bs-theme="dark"] .docs-toc:hover::-webkit-scrollbar-thumb{background:#23262f}[data-bs-theme="dark"] .docs-links::-webkit-scrollbar-thumb:hover,[data-bs-theme="dark"] .docs-toc::-webkit-scrollbar-thumb:hover{background:#23262f}[data-bs-theme="dark"] .docs-links h3:not(:first-child),[data-bs-theme="dark"] .docs-links .h3:not(:first-child){border-top:1px solid #23262f}[data-bs-theme="dark"] .page-links li:not(:first-child){border-top:1px dashed #23262f}[data-bs-theme="dark"] .card{background:#17181c;border:1px solid #23262f}[data-bs-theme="dark"] .text-muted{color:#adafb6 !important}[data-bs-theme="dark"] .offcanvas{background-color:#17181c}[data-bs-theme="dark"] .page-link{color:#b3c7ff;background-color:transparent;border:var(--bs-border-width) solid #23262f}[data-bs-theme="dark"] .page-link:hover{color:#17181c;background-color:#c1c3c8;border-color:#c1c3c8}[data-bs-theme="dark"] .page-link:focus{color:#17181c;background-color:#c1c3c8}[data-bs-theme="dark"] .page-item.active .page-link{color:#17181c;background-color:#b3c7ff;border-color:#b3c7ff}[data-bs-theme="dark"] .page-item.disabled .page-link{color:var(--bs-secondary-color);background-color:#23262f;border-color:#23262f}[data-bs-theme="dark"] details{border:1px solid #23262f}[data-bs-theme="dark"] summary:hover{background:#23262f}[data-bs-theme="dark"] details[open]>summary{border-bottom:1px solid #23262f}[data-bs-theme="dark"] details summary::after{content:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%28222, 226, 230, 0.75%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e")}[data-bs-theme="dark"] #toc a.active{color:#b3c7ff}[data-bs-theme="dark"] table th{color:#fff}[data-bs-theme="dark"] table,[data-bs-theme="dark"] [data-bs-theme="dark"] table{--bs-table-color: inherit;--bs-table-bg: $body-bg-dark;background:#17181c;border-color:#23262f}.btn-close:focus,.btn-close:active{outline:none;box-shadow:none}.navbar .btn-link{color:rgba(var(--bs-emphasis-color-rgb), 0.65);padding:0.4375rem 0}.btn-link:focus{outline:0;box-shadow:none}@media (min-width: 992px){.navbar .btn-link{padding:0.5625em 0.25rem 0.5rem 0.125rem}}.navbar .btn-link:hover{color:rgba(var(--bs-emphasis-color-rgb), 0.8)}.navbar .btn-link:active{color:rgba(var(--bs-emphasis-color-rgb), 1)}.btn-close{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-x'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");background-size:1.5rem}.offcanvas-header .btn-close{margin-right:0 !important}.clipboard{position:relative;float:right}.btn-clipboard{transition:opacity 0.25s ease-in-out;opacity:0;position:absolute;right:0.5rem;top:0.5rem;line-height:1;padding:0.3125rem 0.3125rem 0.1875rem;background-color:transparent;border-color:transparent}@media (max-width: 767.98px){.btn-clipboard{position:absolute;right:-0.5rem;top:0.5rem}}.btn-clipboard::after{width:22px;height:22px;display:inline-block;content:"";-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;-webkit-mask-size:cover;mask-size:cover;background-color:#495057}.btn-clipboard:hover{border-color:transparent}.btn-clipboard:hover::after{width:22px;height:22px;display:inline-block;content:"";-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;-webkit-mask-size:cover;mask-size:cover;background-color:#212529}.btn-clipboard:focus,.btn-clipboard:active{border-color:transparent !important}.btn-clipboard:focus::after,.btn-clipboard:active::after{width:22px;height:22px;display:inline-block;content:"";-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;-webkit-mask-size:cover;mask-size:cover;background-color:#212529}[data-bs-theme="dark"] .btn-clipboard{background-color:transparent;border-color:transparent}[data-bs-theme="dark"] .btn-clipboard::after{background-color:#ced4da}[data-bs-theme="dark"] .btn-clipboard:hover{border-color:transparent}[data-bs-theme="dark"] .btn-clipboard:hover::after{background-color:#e9ecef}[data-bs-theme="dark"] .btn-clipboard:focus,[data-bs-theme="dark"] .btn-clipboard:active{border-color:transparent}[data-bs-theme="dark"] .btn-clipboard:focus::after,[data-bs-theme="dark"] .btn-clipboard:active::after{background-color:#e9ecef}.highlight{position:relative}@media (min-width: 768px){.highlight:hover .btn-clipboard{opacity:1}}.btn-cta{padding-left:2rem;padding-right:2rem}.callout{--bs-link-color-rgb: var(--callout-link);--bs-code-color: var(--callout-code-color);color:var(--callout-color, inherit);background-color:var(--callout-bg, var(--bs-gray-100));border-left:0.25rem solid var(--callout-border, var(--bs-gray-300));border-radius:0}.callout a{text-decoration:underline}.callout .highlight{background-color:rgba(0,0,0,0.05)}.callout-content{min-width:0}.callout.callout-danger{border-color:var(--sl-color-red);background-color:var(--sl-color-red-high)}.callout.callout-danger .callout-body a{color:var(--sl-color-red-low)}.callout.callout-danger .callout-body,.callout.callout-danger .callout-body a:hover,.callout.callout-danger .callout-body a:active{color:var(--sl-color-white)}[data-bs-theme="dark"] .callout{color:var(--sl-color-gray-1)}[data-bs-theme="dark"] .callout.callout-danger{border-color:var(--sl-color-red);background-color:var(--sl-color-red-low)}[data-bs-theme="dark"] .callout.callout-danger .callout-body a{color:var(--sl-color-red-high)}[data-bs-theme="dark"] .callout.callout-danger .callout-body,[data-bs-theme="dark"] .callout.callout-danger .callout-body a:hover,[data-bs-theme="dark"] .callout.callout-danger .callout-body a:active{color:var(--sl-color-white)}[data-bs-theme="dark"] .callout.callout-danger code:not(:where(.not-content *)){color:var(--ec-codeFg)}.expressive-code{font-family:var(--ec-uiFontFml);font-size:var(--ec-uiFontSize);line-height:var(--ec-uiLineHt);-moz-text-size-adjust:none;text-size-adjust:none;-webkit-text-size-adjust:none;margin:1.5rem 0}.expressive-code *:not(path){all:revert;box-sizing:border-box}.expressive-code pre{display:flex;margin:0;padding:0;border:var(--ec-brdWd) solid var(--ec-brdCol);border-radius:calc(var(--ec-brdRad) + var(--ec-brdWd));background:var(--ec-codeBg)}.expressive-code pre:focus-visible{outline:3px solid var(--ec-focusBrd);outline-offset:-3px}.expressive-code pre>code{all:unset;display:block;flex:1 0 100%;padding:var(--ec-codePadBlk) 0;color:var(--ec-codeFg);font-family:var(--ec-codeFontFml);font-size:var(--ec-codeFontSize);line-height:var(--ec-codeLineHt)}.expressive-code pre{overflow-x:auto}.expressive-code pre::-webkit-scrollbar,.expressive-code pre::-webkit-scrollbar-track{background-color:inherit;border-radius:calc(var(--ec-brdRad) + var(--ec-brdWd));border-top-left-radius:0;border-top-right-radius:0}.expressive-code pre::-webkit-scrollbar-thumb{background-color:var(--ec-sbThumbCol);border:4px solid transparent;background-clip:content-box;border-radius:10px}.expressive-code pre::-webkit-scrollbar-thumb:hover{background-color:var(--ec-sbThumbHoverCol)}.expressive-code .ec-line{padding-inline:var(--ec-codePadInl);padding-inline-end:calc(2rem + var(--ec-codePadInl));direction:ltr;unicode-bidi:isolate}.expressive-code .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);white-space:nowrap;border-width:0}.expressive-code .ec-line.mark{--tmLineBgCol: var(--ec-tm-markBg);--tmLineBrdCol: var(--ec-tm-markBrdCol)}.expressive-code .ec-line.ins{--tmLineBgCol: var(--ec-tm-insBg);--tmLineBrdCol: var(--ec-tm-insBrdCol)}.expressive-code .ec-line.ins::before{content:var(--ec-tm-insDiffIndContent);color:var(--ec-tm-insDiffIndCol)}.expressive-code .ec-line.del{--tmLineBgCol: var(--ec-tm-delBg);--tmLineBrdCol: var(--ec-tm-delBrdCol)}.expressive-code .ec-line.del::before{content:var(--ec-tm-delDiffIndContent);color:var(--ec-tm-delDiffIndCol)}.expressive-code .ec-line.mark,.expressive-code .ec-line.ins,.expressive-code .ec-line.del{position:relative;background:var(--tmLineBgCol);min-width:calc(100% - var(--ec-tm-lineMarkerAccentMarg));margin-inline-start:var(--ec-tm-lineMarkerAccentMarg);border-inline-start:var(--ec-tm-lineMarkerAccentWd) solid var(--tmLineBrdCol);padding-inline-start:calc(var(--ec-codePadInl) - var(--ec-tm-lineMarkerAccentMarg) - var(--ec-tm-lineMarkerAccentWd)) !important}.expressive-code .ec-line.mark::before,.expressive-code .ec-line.ins::before,.expressive-code .ec-line.del::before{position:absolute;left:var(--ec-tm-lineDiffIndMargLeft)}.expressive-code .ec-line mark,.expressive-code .ec-line .mark{--tmInlineBgCol: var(--ec-tm-markBg);--tmInlineBrdCol: var(--ec-tm-markBrdCol)}.expressive-code .ec-line ins{--tmInlineBgCol: var(--ec-tm-insBg);--tmInlineBrdCol: var(--ec-tm-insBrdCol)}.expressive-code .ec-line del{--tmInlineBgCol: var(--ec-tm-delBg);--tmInlineBrdCol: var(--ec-tm-delBrdCol)}.expressive-code .ec-line mark,.expressive-code .ec-line .mark,.expressive-code .ec-line ins,.expressive-code .ec-line del{all:unset;display:inline-block;position:relative;--tmBrdL: var(--ec-tm-inlMarkerBrdWd);--tmBrdR: var(--ec-tm-inlMarkerBrdWd);--tmRadL: var(--ec-tm-inlMarkerBrdRad);--tmRadR: var(--ec-tm-inlMarkerBrdRad);margin-inline:0.025rem;padding-inline:var(--ec-tm-inlMarkerPad);border-radius:var(--tmRadL) var(--tmRadR) var(--tmRadR) var(--tmRadL);background:var(--tmInlineBgCol);background-clip:padding-box}.expressive-code .ec-line mark.open-start,.expressive-code .ec-line .open-start.mark,.expressive-code .ec-line ins.open-start,.expressive-code .ec-line del.open-start{margin-inline-start:0;padding-inline-start:0;--tmBrdL: 0px;--tmRadL: 0}.expressive-code .ec-line mark.open-end,.expressive-code .ec-line .open-end.mark,.expressive-code .ec-line ins.open-end,.expressive-code .ec-line del.open-end{margin-inline-end:0;padding-inline-end:0;--tmBrdR: 0px;--tmRadR: 0}.expressive-code .ec-line mark::before,.expressive-code .ec-line .mark::before,.expressive-code .ec-line ins::before,.expressive-code .ec-line del::before{content:"";position:absolute;pointer-events:none;display:inline-block;inset:0;border-radius:var(--tmRadL) var(--tmRadR) var(--tmRadR) var(--tmRadL);border:var(--ec-tm-inlMarkerBrdWd) solid var(--tmInlineBrdCol);border-inline-width:var(--tmBrdL) var(--tmBrdR)}.expressive-code .frame{all:unset;position:relative;display:block;--header-border-radius: calc(var(--ec-brdRad) + var(--ec-brdWd));--tab-border-radius: calc(var(--ec-frm-edTabBrdRad) + var(--ec-brdWd));--button-spacing: 0.4rem;--code-background: var(--ec-frm-edBg);border-radius:var(--header-border-radius);box-shadow:var(--ec-frm-frameBoxShdCssVal)}.expressive-code .frame .header{display:none;z-index:1;position:relative;border-radius:var(--header-border-radius) var(--header-border-radius) 0 0}.expressive-code .frame.has-title pre,.expressive-code .frame.has-title code,.expressive-code .frame.is-terminal pre,.expressive-code .frame.is-terminal code{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.expressive-code .frame .title:empty:before{content:"\a0"}.expressive-code .frame.has-title:not(.is-terminal){--button-spacing: calc(1.9rem + 2 * (var(--ec-uiPadBlk) + var(--ec-frm-edActTabIndHt)))}.expressive-code .frame.has-title:not(.is-terminal) .title{position:relative;color:var(--ec-frm-edActTabFg);background:var(--ec-frm-edActTabBg);background-clip:padding-box;margin-block-start:var(--ec-frm-edTabsMargBlkStart);padding:calc(var(--ec-uiPadBlk) + var(--ec-frm-edActTabIndHt)) var(--ec-uiPadInl);border:var(--ec-brdWd) solid var(--ec-frm-edActTabBrdCol);border-radius:var(--tab-border-radius) var(--tab-border-radius) 0 0;border-bottom:none;overflow:hidden}.expressive-code .frame.has-title:not(.is-terminal) .title::after{content:"";position:absolute;pointer-events:none;inset:0;border-top:var(--ec-frm-edActTabIndHt) solid var(--ec-frm-edActTabIndTopCol);border-bottom:var(--ec-frm-edActTabIndHt) solid var(--ec-frm-edActTabIndBtmCol)}.expressive-code .frame.has-title:not(.is-terminal) .header{display:flex;background:linear-gradient(to top, var(--ec-frm-edTabBarBrdBtmCol) var(--ec-brdWd), transparent var(--ec-brdWd)),linear-gradient(var(--ec-frm-edTabBarBg), var(--ec-frm-edTabBarBg));background-repeat:no-repeat;padding-inline-start:var(--ec-frm-edTabsMargInlStart)}.expressive-code .frame.has-title:not(.is-terminal) .header::before{content:"";position:absolute;pointer-events:none;inset:0;border:var(--ec-brdWd) solid var(--ec-frm-edTabBarBrdCol);border-radius:inherit;border-bottom:none}.expressive-code .frame.is-terminal{--button-spacing: calc(1.9rem + var(--ec-brdWd) + 2 * var(--ec-uiPadBlk));--code-background: var(--ec-frm-trmBg)}.expressive-code .frame.is-terminal .header{display:flex;align-items:center;justify-content:center;padding-block:var(--ec-uiPadBlk);padding-block-end:calc(var(--ec-uiPadBlk) + var(--ec-brdWd));position:relative;font-weight:500;letter-spacing:0.025ch;color:var(--ec-frm-trmTtbFg);background:var(--ec-frm-trmTtbBg);border:var(--ec-brdWd) solid var(--ec-brdCol);border-bottom:none}.expressive-code .frame.is-terminal .header::before{content:"";position:absolute;pointer-events:none;left:var(--ec-uiPadInl);width:2.1rem;height:0.56rem;line-height:0;background-color:var(--ec-frm-trmTtbDotsFg);opacity:var(--ec-frm-trmTtbDotsOpa);-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 16' preserveAspectRatio='xMidYMid meet'%3E%3Ccircle cx='8' cy='8' r='8'/%3E%3Ccircle cx='30' cy='8' r='8'/%3E%3Ccircle cx='52' cy='8' r='8'/%3E%3C/svg%3E");-webkit-mask-repeat:no-repeat;mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 16' preserveAspectRatio='xMidYMid meet'%3E%3Ccircle cx='8' cy='8' r='8'/%3E%3Ccircle cx='30' cy='8' r='8'/%3E%3Ccircle cx='52' cy='8' r='8'/%3E%3C/svg%3E");mask-repeat:no-repeat}.expressive-code .frame.is-terminal .header::after{content:"";position:absolute;pointer-events:none;inset:0;border-bottom:var(--ec-brdWd) solid var(--ec-frm-trmTtbBrdBtmCol)}.expressive-code .frame pre{background:var(--code-background)}.expressive-code .copy{display:flex;gap:0.25rem;flex-direction:row;position:absolute;inset-block-start:calc(var(--ec-brdWd) + var(--button-spacing));inset-inline-end:calc(var(--ec-brdWd) + var(--ec-uiPadInl) / 2);direction:ltr;unicode-bidi:isolate}.expressive-code .copy button{position:relative;align-self:flex-end;margin:0;padding:0;border:none;border-radius:0.2rem;z-index:1;cursor:pointer;transition-property:opacity, background, border-color;transition-duration:0.2s;transition-timing-function:cubic-bezier(0.25, 0.46, 0.45, 0.94);width:2.5rem;height:2.5rem;background:var(--code-background);opacity:0.75}.expressive-code .copy button div{position:absolute;inset:0;border-radius:inherit;background:var(--ec-frm-inlBtnBg);opacity:var(--ec-frm-inlBtnBgIdleOpa);transition-property:inherit;transition-duration:inherit;transition-timing-function:inherit}.expressive-code .copy button::before{content:"";position:absolute;pointer-events:none;inset:0;border-radius:inherit;border:var(--ec-brdWd) solid var(--ec-frm-inlBtnBrd);opacity:var(--ec-frm-inlBtnBrdOpa)}.expressive-code .copy button::after{content:"";position:absolute;pointer-events:none;inset:0;background-color:var(--ec-frm-inlBtnFg);-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75'%3E%3Cpath d='M3 19a2 2 0 0 1-1-2V2a2 2 0 0 1 1-1h13a2 2 0 0 1 2 1'/%3E%3Crect x='6' y='5' width='16' height='18' rx='1.5' ry='1.5'/%3E%3C/svg%3E");-webkit-mask-repeat:no-repeat;mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75'%3E%3Cpath d='M3 19a2 2 0 0 1-1-2V2a2 2 0 0 1 1-1h13a2 2 0 0 1 2 1'/%3E%3Crect x='6' y='5' width='16' height='18' rx='1.5' ry='1.5'/%3E%3C/svg%3E");mask-repeat:no-repeat;margin:0.475rem;line-height:0}.expressive-code .copy button:focus::after,.expressive-code .copy button:active::after{display:inline-block;content:"";-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='black' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='black' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;-webkit-mask-size:cover;mask-size:cover;margin:0.2375rem}.expressive-code .copy button:hover,.expressive-code .copy button:focus:focus-visible{opacity:1}.expressive-code .copy button:hover div,.expressive-code .copy button:focus:focus-visible div{opacity:var(--ec-frm-inlBtnBgHoverOrFocusOpa)}.expressive-code .copy button:active{opacity:1}.expressive-code .copy button:active div{opacity:var(--ec-frm-inlBtnBgActOpa)}.expressive-code .copy .feedback{--tooltip-arrow-size: 0.35rem;--tooltip-bg: var(--ec-frm-tooltipSuccessBg);color:var(--ec-frm-tooltipSuccessFg);pointer-events:none;-moz-user-select:none;user-select:none;-webkit-user-select:none;position:relative;align-self:center;background-color:var(--tooltip-bg);z-index:99;padding:0.125rem 0.75rem;border-radius:0.2rem;margin-inline-end:var(--tooltip-arrow-size);opacity:0;transition-property:opacity, transform;transition-duration:0.2s;transition-timing-function:ease-in-out;transform:translate3d(0, 0.25rem, 0)}.expressive-code .copy .feedback::after{content:"";position:absolute;pointer-events:none;top:calc(50% - var(--tooltip-arrow-size));inset-inline-end:calc(-2 * (var(--tooltip-arrow-size) - 0.5px));border:var(--tooltip-arrow-size) solid transparent;border-inline-start-color:var(--tooltip-bg)}.expressive-code .copy .feedback.show{opacity:1;transform:translate3d(0, 0, 0)}@media (hover: hover){.expressive-code .copy button{opacity:0;width:2rem;height:2rem}.expressive-code .frame:hover .copy button:not(:hover),.expressive-code .frame:focus-within :focus-visible~.copy button:not(:hover),.expressive-code .frame .copy .feedback.show~button:not(:hover){opacity:0.75}}:root{--ec-brdRad: 0px;--ec-brdWd: 1px;--ec-brdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-codeFontFml: var(--__sl-font-mono);--ec-codeFontSize: var(--sl-text-code);--ec-codeFontWg: 400;--ec-codeLineHt: var(--sl-line-height);--ec-codePadBlk: 0;--ec-codePadInl: 1rem;--ec-codeBg: #011627;--ec-codeFg: #d6deeb;--ec-codeSelBg: #1d3b53;--ec-uiFontFml: var(--__sl-font);--ec-uiFontSize: 0.9rem;--ec-uiFontWg: 400;--ec-uiLineHt: 1.65;--ec-uiPadBlk: 0.25rem;--ec-uiPadInl: 1rem;--ec-uiSelBg: #234d708c;--ec-uiSelFg: #ffffff;--ec-focusBrd: #122d42;--ec-sbThumbCol: #ffffff17;--ec-sbThumbHoverCol: #ffffff49;--ec-tm-lineMarkerAccentMarg: 0rem;--ec-tm-lineMarkerAccentWd: 0.15rem;--ec-tm-lineDiffIndMargLeft: 0.25rem;--ec-tm-inlMarkerBrdWd: 1.5px;--ec-tm-inlMarkerBrdRad: 0.2rem;--ec-tm-inlMarkerPad: 0.15rem;--ec-tm-insDiffIndContent: "+";--ec-tm-delDiffIndContent: "-";--ec-tm-markBg: #ffffff17;--ec-tm-markBrdCol: #ffffff40;--ec-tm-insBg: #1e571599;--ec-tm-insBrdCol: #487f3bd0;--ec-tm-insDiffIndCol: #79b169d0;--ec-tm-delBg: #862d2799;--ec-tm-delBrdCol: #b4554bd0;--ec-tm-delDiffIndCol: #ed8779d0;--ec-frm-shdCol: #011627;--ec-frm-frameBoxShdCssVal: none;--ec-frm-edActTabBg: var(--sl-color-gray-6);--ec-frm-edActTabFg: var(--sl-color-text);--ec-frm-edActTabBrdCol: transparent;--ec-frm-edActTabIndHt: 1px;--ec-frm-edActTabIndTopCol: var(--sl-color-accent-high);--ec-frm-edActTabIndBtmCol: transparent;--ec-frm-edTabsMargInlStart: 0;--ec-frm-edTabsMargBlkStart: 0;--ec-frm-edTabBrdRad: 0px;--ec-frm-edTabBarBg: var(--sl-color-black);--ec-frm-edTabBarBrdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-edTabBarBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-edBg: var(--sl-color-gray-6);--ec-frm-trmTtbDotsFg: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-trmTtbDotsOpa: 0.75;--ec-frm-trmTtbBg: var(--sl-color-black);--ec-frm-trmTtbFg: var(--sl-color-text);--ec-frm-trmTtbBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-trmBg: var(--sl-color-gray-6);--ec-frm-inlBtnFg: var(--sl-color-text);--ec-frm-inlBtnBg: var(--sl-color-text);--ec-frm-inlBtnBgIdleOpa: 0;--ec-frm-inlBtnBgHoverOrFocusOpa: 0.2;--ec-frm-inlBtnBgActOpa: 0.3;--ec-frm-inlBtnBrd: var(--sl-color-text);--ec-frm-inlBtnBrdOpa: 0.4;--ec-frm-tooltipSuccessBg: #158744;--ec-frm-tooltipSuccessFg: white}.expressive-code .ec-line span[style^="--"]:not([class]){color:var(0, inherit);font-style:var(0fs, inherit);font-weight:var(0fw, inherit);-webkit-text-decoration:var(0td, inherit);text-decoration:var(0td, inherit)}@media (prefers-color-scheme: light){:root:not([data-bs-theme="dark"]){--ec-codeBg: #fbfbfb;--ec-codeFg: #403f53;--ec-codeSelBg: #e0e0e0;--ec-uiSelBg: #d3e8f8;--ec-uiSelFg: #403f53;--ec-focusBrd: #93a1a1;--ec-sbThumbCol: #0000001a;--ec-sbThumbHoverCol: #0000005c;--ec-tm-markBg: #0000001a;--ec-tm-markBrdCol: #00000055;--ec-tm-insBg: #8ec77d99;--ec-tm-insDiffIndCol: #336a28d0;--ec-tm-delBg: #ff9c8e99;--ec-tm-delDiffIndCol: #9d4138d0;--ec-frm-shdCol: #d9d9d9;--ec-frm-edActTabBg: var(--sl-color-gray-7);--ec-frm-edActTabIndTopCol: #5d2f86;--ec-frm-edTabBarBg: var(--sl-color-gray-6);--ec-frm-edBg: var(--sl-color-gray-7);--ec-frm-trmTtbBg: var(--sl-color-gray-6);--ec-frm-trmBg: var(--sl-color-gray-7);--ec-frm-tooltipSuccessBg: #078662}:root:not([data-bs-theme="dark"]) .expressive-code .ec-line span[style^="--"]:not([class]){color:var(1, inherit);font-style:var(1fs, inherit);font-weight:var(1fw, inherit);-webkit-text-decoration:var(1td, inherit);text-decoration:var(1td, inherit)}}:root[data-bs-theme="light"] .expressive-code,.expressive-code[data-bs-theme="light"]{--ec-codeBg: #fbfbfb;--ec-codeFg: #403f53;--ec-codeSelBg: #e0e0e0;--ec-uiSelBg: #d3e8f8;--ec-uiSelFg: #403f53;--ec-focusBrd: #93a1a1;--ec-sbThumbCol: #0000001a;--ec-sbThumbHoverCol: #0000005c;--ec-tm-markBg: #0000001a;--ec-tm-markBrdCol: #00000055;--ec-tm-insBg: #8ec77d99;--ec-tm-insDiffIndCol: #336a28d0;--ec-tm-delBg: #ff9c8e99;--ec-tm-delDiffIndCol: #9d4138d0;--ec-frm-shdCol: #d9d9d9;--ec-frm-edActTabBg: var(--sl-color-gray-7);--ec-frm-edActTabIndTopCol: #5d2f86;--ec-frm-edTabBarBg: var(--sl-color-gray-6);--ec-frm-edBg: var(--sl-color-gray-7);--ec-frm-trmTtbBg: var(--sl-color-gray-6);--ec-frm-trmBg: var(--sl-color-gray-7);--ec-frm-tooltipSuccessBg: #078662}:root[data-bs-theme="light"] .expressive-code .ec-line span[style^="--"]:not([class]),.expressive-code[data-bs-theme="light"] .ec-line span[style^="--"]:not([class]){color:var(1, inherit);font-style:var(1fs, inherit);font-weight:var(1fw, inherit);-webkit-text-decoration:var(1td, inherit);text-decoration:var(1td, inherit)}pre,code,kbd,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:.875rem}code:not(:where(.not-content *)){background-color:var(--sl-color-gray-6);margin-block:-0.125rem;padding:0.125rem 0.375rem;color:inherit}[data-bs-theme="dark"] code:not(:where(.not-content *)){background-color:var(--sl-color-gray-5)}.math-block{display:block;margin:2rem 0;overflow-x:auto}.math-inline{display:inline}[data-bs-theme="dark"] .math-inline img,[data-bs-theme="dark"] .math-block img{filter:invert(1)}img.diagram{height:auto;width:100%;margin:1rem 0 2rem}img.diagram-kroki-mermaid{background:#fff}.highlight>pre{padding:0.875rem 1rem}.highlight div{padding:0}.highlight>.chroma{overflow-x:auto;border:1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)}.chroma .ln{padding:0 0.5rem 0 0}.chroma .hl{border-inline-start:0.15rem solid #0005;margin-left:-1rem;margin-right:-1rem;padding-left:1rem;padding-right:1rem}.chroma .hl .ln{margin-left:-0.15rem}.highlight .chroma .lntable .lnt,.highlight .chroma .lntable .hl{display:flex}.chroma .lntd:first-child{padding:0}.chroma .lntd:first-child .lnt{padding-left:1rem}.chroma .lntd:nth-child(2){padding:0}.highlight .chroma .lntable .lntd+.lntd{width:100%}[data-bs-theme="dark"] .chroma .ln{padding:0 0.5em 0 0}.chroma .lntd pre{padding:1rem 0;margin-bottom:0}.highlight>.chroma::-webkit-scrollbar,.highlight>.chroma::-webkit-scrollbar-track{background-color:inherit;border-radius:1px;border-top-left-radius:0;border-top-right-radius:0}.highlight>.chroma::-webkit-scrollbar-thumb{background-color:#dddee0;border:4px solid transparent;background-clip:content-box;border-radius:10px}.highlight>.chroma::-webkit-scrollbar-thumb:hover{background-color:#9d9e9f}[data-bs-theme="dark"] .highlight>.chroma::-webkit-scrollbar-thumb{background-color:#ffffff17}[data-bs-theme="dark"] .highlight>.chroma::-webkit-scrollbar-thumb:hover{background-color:#ffffff49}[data-bs-theme="dark"] .highlight>.chroma{border:1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)}[data-bs-theme="dark"] .chroma .hl{border-inline-start:0.15rem solid #ffffff40;margin-left:-1rem;margin-right:-1rem;padding-left:1rem;padding-right:1rem}[data-bs-theme="dark"] .chroma .hl .ln{margin-left:-0.15rem}blockquote{margin-bottom:1rem;font-size:1.25rem;border-left:3px solid #dee2e6;padding-left:1rem}details{display:block;position:relative;border:1px solid #e9ecef;border-radius:0.25rem;padding:0.5rem 1rem 0;margin:0.5rem 0}summary{list-style:none;display:inline-block;width:calc(100% + 2rem);margin:-0.5rem -1rem 0;padding:0.5rem 1rem}summary::-webkit-details-marker{display:none}summary:hover{background:#f8f9fa}details summary::after{display:inline-block;content:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%2829, 45, 53, 0.75%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e");transition:transform 0.35s ease;transform-origin:center center;position:absolute;right:1rem}details[open]>summary::after{transform:rotate(90deg)}details[open]{padding:0.5rem 1rem}details[open]>summary{border-bottom:1px solid #dee2e6;margin-bottom:0.5rem}details h2,details .h2,details h3,details .h3,details h4,details .h4{margin:1rem 0 0.5rem}details p:last-child{margin-bottom:0}details ul,details ol{margin-bottom:0}details pre{margin:0 0 1rem}.search-form label{font-weight:normal}img{max-width:100%;height:auto}img[data-sizes="auto"]{display:block}img{font-size:0}figcaption{font-size:1rem;margin-top:0.5rem;font-style:italic}.blur-up{filter:blur(5px);transition:filter 400ms}.blur-up.lazyloaded{filter:unset}.search-form .form-control:focus{border:2px solid #4f46e5}[data-bs-theme="dark"] .search-form .form-control:focus{border:2px solid #b3c7ff}[data-bs-theme="dark"] .search-form .btn-link{color:#b3c7ff}.search-form .btn-link,.modal-body p.message,.modal-footer{font-size:.875rem}.modal-body::-webkit-scrollbar{width:0.25rem}.modal-body::-webkit-scrollbar-track{background-color:#f1f1f1}.modal-body::-webkit-scrollbar-thumb{background-color:#c1c1c1}[data-bs-theme="dark"] .modal-body::-webkit-scrollbar-track{background-color:#424242}[data-bs-theme="dark"] .modal-body::-webkit-scrollbar-thumb{background-color:#686868}@media (min-width: 768px){#searchModal .modal-dialog{max-height:40rem}}.search-result h2,.search-result .h2{margin-top:0}.search-result a:focus{outline:0 none}.search-result .content{margin-top:0.5rem;padding-top:0 !important;padding-bottom:0 !important}.search-result .card .content p{margin-bottom:0}.search-result .card .content a{position:relative;z-index:1}.search-result:hover .card,.search-result.selected .card{background-color:#4f46e5;color:#fff}.search-result:hover .card .content a,.search-result.selected .card .content a{color:#fff;text-decoration:underline}[data-bs-theme="dark"] .search-result:hover .card,[data-bs-theme="dark"] .search-result.selected .card{background-color:#b3c7ff;color:#23262f}[data-bs-theme="dark"] .search-result:hover .card .content a,[data-bs-theme="dark"] .search-result.selected .card .content a{color:#23262f;text-decoration:underline}[data-bs-theme="dark"] .search-result:hover .card h2,[data-bs-theme="dark"] .search-result:hover .card .h2,[data-bs-theme="dark"] .search-result.selected .card h2,[data-bs-theme="dark"] .search-result.selected .card .h2{color:#17181c}.search-result .submitted{font-size:.875rem;margin-top:0.5rem}.section-nav{padding-top:2rem}.section-nav details{border:0;padding:0;margin:0.5rem 0}.section-nav details[open]{padding:0}.section-nav summary{width:100%;padding:0;margin:0;font-weight:700}.section-nav summary:hover{background:none}.section-nav details[open]>summary{border-bottom:0;margin-bottom:0}.section-nav ul.list-nested details{padding-left:1rem;margin-top:0.5rem}.section-nav ul.list-nested li{margin:0}.section-nav a{display:block;margin:0.5rem 0;color:#1d2d35;font-size:1rem;text-decoration:none}.section-nav a:hover,.section-nav a:active{color:#4f46e5}.section-nav li.active a{color:#4f46e5;font-weight:500}.section-nav ul.list-nested li a{padding-left:1rem}.section-nav ul.list-nested{border-left:1px solid #e9ecef}[data-bs-theme="dark"] .section-nav ul.list-nested{border-left:1px solid #23262f}[data-bs-theme="dark"] .section-nav a{color:#c1c3c8}[data-bs-theme="dark"] .section-nav a:hover,[data-bs-theme="dark"] .section-nav a:active{color:var(--sl-color-text-accent)}[data-bs-theme="dark"] .section-nav li.active a{color:var(--sl-color-text-accent);font-weight:500}[data-bs-theme="dark"] .section-nav summary{color:#fff}table{margin:3rem 0}.nav-tabs{border-bottom:0.0625rem solid #d8dee4;margin-bottom:1rem}.nav-tabs .nav-link{margin-bottom:-0.0625rem !important;background:none;border:0;border-top-left-radius:0;border-top-right-radius:0;color:inherit}.nav-tabs .nav-link:hover,.nav-tabs .nav-link:focus{isolation:isolate;border-color:transparent;color:var(--bs-emphasis-color)}.nav-tabs .nav-link.active,.nav-tabs .nav-item.show .nav-link{background-color:transparent;border-color:transparent;border-bottom:0.125rem solid #4f46e5}[data-bs-theme="dark"] .nav-tabs{border-bottom:0.0625rem solid #343a40}[data-bs-theme="dark"] .nav-tabs .nav-link.active,[data-bs-theme="dark"] .nav-tabs .nav-item.show .nav-link{border-bottom:0.125rem solid #b3c7ff}.footer{border-top:1px solid #e9ecef;padding-top:1.125rem;padding-bottom:1.125rem}.footer ul{margin-bottom:0}.footer li{font-size:.875rem;margin-bottom:0}.footer .list-inline-item:not(:last-child){margin-right:1rem}@media (max-width: 991.98px){.footer .col-lg-8{margin-top:0.25rem;margin-bottom:0.25rem}}@media (min-width: 768px){.footer li{font-size:1rem}}.navbar-brand{font-weight:700}.navbar-brand svg{margin-right:0.25rem}[data-bs-theme="dark"] .navbar-brand{color:inherit}.navbar{z-index:1000;background-color:rgba(255,255,255,0.95);border-bottom:1px solid #e9ecef}@media (min-width: 992px){.navbar{z-index:1025}}@media (min-width: 768px){.navbar-brand{font-size:1.375rem}}.nav-item{margin-left:0}@media (max-width: 991.98px){.navbar-nav .nav-link{font-weight:400}}@media (min-width: 768px){.nav-item{margin-left:0.5rem}}@media (max-width: 575.98px){.navbar .offcanvas.offcanvas-start,.navbar .offcanvas.offcanvas-end{width:80vw}}.offcanvas-header{border-bottom:1px solid #dee2e6;padding-top:1.0625rem;padding-bottom:0.8125rem}h5.offcanvas-title,.offcanvas-title.h5{margin:0;color:inherit}.offcanvas .nav-link{color:#1d2d35}.offcanvas .nav-link:hover,.offcanvas .nav-link:focus{color:#4f46e5}.offcanvas .nav-link.active{color:#4f46e5}.home .navbar{border-bottom:0}@media (min-width: 992px){.navbar-brand{margin-right:0.75rem !important}}.social-link{padding-right:0.375rem;padding-left:0.375rem}@media (max-width: 991.98px){#buttonColorMode{margin:0.5rem 0}#socialMenu{margin:0.5rem 0 0.5rem -0.25rem}.navbar-nav{margin-top:1rem}.nav-item .nav-link{font-weight:400;font-size:1.125rem}}.modal-backdrop,.offcanvas-backdrop{visibility:hidden;background:rgba(23,24,28,0.5);opacity:0}[data-bs-theme="dark"] .modal-backdrop,[data-bs-theme="dark"] .offcanvas-backdrop{visibility:hidden;background:rgba(23,24,28,0.5);opacity:0}.modal-backdrop.show,.offcanvas-backdrop.show{visibility:visible;opacity:1;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.showing,.hiding{transition:none;display:none}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg{padding-right:0.75rem}.docs-content>h2[id]::before,.docs-content>[id].h2::before,.docs-content>h3[id]::before,.docs-content>[id].h3::before,.docs-content>h4[id]::before,.docs-content>[id].h4::before{display:block;height:6rem;margin-top:-6rem;content:""}.docs-content ul,.docs-content ol{margin-bottom:1rem}.anchor{visibility:hidden;margin-left:0.375rem}.edit-page a{color:var(--sl-color-gray-3)}h1:hover a,.h1:hover a,h2:hover a,.h2:hover a,h3:hover a,.h3:hover a,h4:hover a,.h4:hover a{visibility:visible;text-decoration:none}.card-list{margin-top:2.25rem}.page-footer-meta{margin-top:2rem;margin-bottom:2rem}.edit-page{font-size:.875rem;margin-top:0.25rem;margin-bottom:0.25rem}@media (min-width: 768px){.edit-page{font-size:1rem;margin-top:0.75rem;margin-bottom:0.25rem}}.edit-page a:hover{color:var(--sl-color-gray-4);text-decoration:none}[data-bs-theme="dark"] .edit-page a:hover{color:var(--sl-color-gray-2)}.edit-page svg{margin-right:0.25rem;margin-bottom:0.25rem}p.meta{margin-top:0.5rem;font-size:1rem}.toc-mobile{margin-top:2rem;margin-bottom:2rem}.page-link:hover{text-decoration:none}ul li{margin:0.25rem 0}.page-nav .card .icon-tabler-arrow-left{margin-right:0.75rem}.page-nav .card .icon-tabler-arrow-right{margin-left:0.75rem}.page-nav .card:hover{border:1px solid #d9d9d9}[data-bs-theme="dark"] .page-nav .card{border:1px solid #353841}[data-bs-theme="dark"] .page-nav .card:hover{border:1px solid #888c96}.home .card,.contributors.list .card,.categories.list .card,.tags.list .card{margin-top:2rem;margin-bottom:2rem;transition:transform 0.3s}.home .content .card:hover,.contributors.list .content .card:hover,.categories.list .content .card:hover,.tags.list .content .card:hover{transform:scale(1.025)}.home .content .card-body,.contributors.list .content .card-body,.categories.list .content .card-body,.tags.list .content .card-body{padding:0 2rem 1rem}.page-item:first-child,.page-item:last-child,.page-item.disabled{display:none}.page-item a{margin-left:0.5rem;margin-right:0.5rem;padding-left:0.875rem;padding-right:0.875rem}.docs-links,.docs-toc{scrollbar-width:thin;scrollbar-color:#fff #fff}.docs-links::-webkit-scrollbar,.docs-toc::-webkit-scrollbar{width:5px}.docs-links::-webkit-scrollbar-track,.docs-toc::-webkit-scrollbar-track{background:#fff}.docs-links::-webkit-scrollbar-thumb,.docs-toc::-webkit-scrollbar-thumb{background:#fff}.docs-links:hover,.docs-toc:hover{scrollbar-width:thin;scrollbar-color:#e9ecef #fff}.docs-links:hover::-webkit-scrollbar-thumb,.docs-toc:hover::-webkit-scrollbar-thumb{background:#e9ecef}.docs-links::-webkit-scrollbar-thumb:hover,.docs-toc::-webkit-scrollbar-thumb:hover{background:#e9ecef}.docs-links h3,.docs-links .h3,.page-links h3,.page-links .h3{font-size:1.125rem;margin:1.25rem 0 0.5rem;padding:1.5rem 0 0}@media (min-width: 992px){.docs-links h3,.docs-links .h3,.page-links h3,.page-links .h3{margin:1.125rem 1.5rem 0.75rem 0;padding:1.375rem 0 0}}.docs-links h3:not(:first-child),.docs-links .h3:not(:first-child){border-top:1px solid #e9ecef}.page-links li{margin-top:0.375rem;padding-top:0.375rem}.page-links li ul li{border-top:none;padding-left:1rem;margin-top:0.125rem;padding-top:0.125rem}.page-links li:not(:first-child){border-top:1px dashed #e9ecef}.page-links a{color:#1d2d35;display:block;padding:0.125rem 0;font-size:.9375rem;text-decoration:none}.page-links a:hover,.page-links a.active{text-decoration:none;color:#4f46e5}.nav-link.active{font-weight:500}*{-webkit-font-smoothing:antialiased}h1,.h1,h2,.h2,h3,.h3,h4,.h4,h5,.h5,.navbar-brand{font-family:Quicksand, sans-serif;font-weight:700}[data-bs-theme="dark"] .only-light{display:none}[data-bs-theme="light"] .only-dark{display:none} diff --git a/docs/resources/_gen/assets/scss/app.scss_cdf9d7c9eb97e4550ded64a8776dd9e8.json b/docs/resources/_gen/assets/scss/app.scss_cdf9d7c9eb97e4550ded64a8776dd9e8.json index c2019e168..80a1da8c1 100644 --- a/docs/resources/_gen/assets/scss/app.scss_cdf9d7c9eb97e4550ded64a8776dd9e8.json +++ b/docs/resources/_gen/assets/scss/app.scss_cdf9d7c9eb97e4550ded64a8776dd9e8.json @@ -1 +1 @@ -{"Target":"main.e18d306ea87fc31ccdc18bc02f9617933c7a3056a2ba2de6830a8ba47283543cbbd494e24b7d778b41d54d3074f438879353bddabcc0bd0d0389eb3d84f0aa38.css","MediaType":"text/css","Data":{"Integrity":"sha512-4Y0wbqh/wxzNwYvAL5YXkzx6MFaiui3mgwqLpHKDVDy71JTiS313i0HVTTB09DiHk1O92rzAvQ0Dies9hPCqOA=="}} \ No newline at end of file +{"Target":"main.3e9cfa18ef8eea818e5322c5eccce2bcedbda0100f6cc0469a0d0d315f86d01a5e46c2424f782e7e0c2e12da5c5afc98309d50b5aa60f98949293da86193c731.css","MediaType":"text/css","Data":{"Integrity":"sha512-Ppz6GO+O6oGOUyLF7MzivO29oBAPbMBGmg0NMV+G0BpeRsJCT3gufgwuEtpcWvyYMJ1Qtapg+YlJKT2oYZPHMQ=="}} \ No newline at end of file diff --git a/netlify.toml b/netlify.toml index e213b7c54..0388f619e 100644 --- a/netlify.toml +++ b/netlify.toml @@ -39,3 +39,8 @@ from = "/docs/pub-sub-implementing" to = "/development/pub-sub-implementing/" status = 301 + +[[redirects]] + from = "/pubsubs/amazonsqs/" + to = "/pubsubs/aws/" + status = 301 From 48e2ba08f36a46064eca7285d1e751b5df9735c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Sm=C3=B3=C5=82ka?= Date: Tue, 29 Oct 2024 15:59:01 +0100 Subject: [PATCH 08/10] Add more docs on requeue (#509) --- .../delayed-messages/go.mod | 4 +- .../delayed-messages/go.sum | 10 ++-- .../delayed-messages/main.go | 13 ++--- .../delayed-requeue/go.mod | 4 +- .../delayed-requeue/go.sum | 10 ++-- .../delayed-requeue/main.go | 35 ++++++----- docs/build.sh | 2 + .../content/advanced/requeuing-after-error.md | 55 ++++++++++++++++++ docs/content/docs/getting-started.md | 1 - docs/content/pubsubs/sql.md | 35 ++++++++--- ...9940_c0fe83760c1bebd5a39d4ddb7fce622e.webp | Bin 0 -> 16278 bytes ...3739_cb9243f2e37f830fb14160ae4284ce39.webp | Bin 0 -> 13512 bytes tools/pq/go.mod | 6 +- tools/pq/go.sum | 2 + 14 files changed, 123 insertions(+), 54 deletions(-) create mode 100644 docs/content/advanced/requeuing-after-error.md create mode 100644 docs/resources/_gen/images/_hu70523d59fb738bec5c06336403a46531_39940_c0fe83760c1bebd5a39d4ddb7fce622e.webp create mode 100644 docs/resources/_gen/images/_hu89002a090cbbd5e6897ae6b591dddabc_33739_cb9243f2e37f830fb14160ae4284ce39.webp diff --git a/_examples/real-world-examples/delayed-messages/go.mod b/_examples/real-world-examples/delayed-messages/go.mod index ee8d58d6d..f16b3362b 100644 --- a/_examples/real-world-examples/delayed-messages/go.mod +++ b/_examples/real-world-examples/delayed-messages/go.mod @@ -3,9 +3,9 @@ module delayed-messsages go 1.23.0 require ( - github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241024100330-cb068b72e948 + github.com/ThreeDotsLabs/watermill v1.4.0-rc.2 github.com/ThreeDotsLabs/watermill-redisstream v1.4.2 - github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024102321-584a6f7dab93 + github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-rc.1 github.com/brianvoe/gofakeit/v6 v6.28.0 github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.2 diff --git a/_examples/real-world-examples/delayed-messages/go.sum b/_examples/real-world-examples/delayed-messages/go.sum index 841fdf6a2..2fdf15843 100644 --- a/_examples/real-world-examples/delayed-messages/go.sum +++ b/_examples/real-world-examples/delayed-messages/go.sum @@ -1,13 +1,11 @@ github.com/Rican7/retry v0.3.1 h1:scY4IbO8swckzoA/11HgBwaZRJEyY9vaNJshcdhp1Mc= github.com/Rican7/retry v0.3.1/go.mod h1:CxSDrhAyXmTMeEuRAnArMu1FHu48vtfjLREWqVl7Vw0= -github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241024100330-cb068b72e948 h1:b8qRHpWtlO94x6dVzSulrO2znSQqz8iYsxUyrdTixHo= -github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241024100330-cb068b72e948/go.mod h1:lBnrLbxOjeMRgcJbv+UiZr8Ylz8RkJ4m6i/VN/Nk+to= +github.com/ThreeDotsLabs/watermill v1.4.0-rc.2 h1:K62uIAKOkCHTXtAwY+Nj95vyLR0y25UMBsbf/FuWCeQ= +github.com/ThreeDotsLabs/watermill v1.4.0-rc.2/go.mod h1:lBnrLbxOjeMRgcJbv+UiZr8Ylz8RkJ4m6i/VN/Nk+to= github.com/ThreeDotsLabs/watermill-redisstream v1.4.2 h1:FY6tsBcbhbJpKDOssU4bfybstqY0hQHwiZmVq9qyILQ= github.com/ThreeDotsLabs/watermill-redisstream v1.4.2/go.mod h1:69++855LyB+ckYDe60PiJLBcUrpckfDE2WwyzuVJRCk= -github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024101952-75257d7d0602 h1:CKdW3wb3+C36mMa44DF53KUyM5L6mGOjI3hikBOlAl4= -github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024101952-75257d7d0602/go.mod h1:GMWcpauehgI40EeoKPxLnXBWjT7oOm7dJfzk5uU4IOc= -github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024102321-584a6f7dab93 h1:KeRk2EG5AtdxfpjqIVPigZqscMvIcy0E2h8k7y38OAE= -github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024102321-584a6f7dab93/go.mod h1:GMWcpauehgI40EeoKPxLnXBWjT7oOm7dJfzk5uU4IOc= +github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-rc.1 h1:uYfnh1EoqXrzHu+bX/TboRyv4ou+EFcmkC1MABeQ0lI= +github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-rc.1/go.mod h1:ttA/lhzSh0YyDkosq1Cgc7IYz6Arrba0jIWfdnON0WA= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= diff --git a/_examples/real-world-examples/delayed-messages/main.go b/_examples/real-world-examples/delayed-messages/main.go index 520235274..a0cd483ea 100644 --- a/_examples/real-world-examples/delayed-messages/main.go +++ b/_examples/real-world-examples/delayed-messages/main.go @@ -4,6 +4,7 @@ import ( "context" stdSQL "database/sql" "fmt" + "strings" "time" "github.com/brianvoe/gofakeit/v6" @@ -115,7 +116,7 @@ func main() { cqrs.NewEventHandler( "OnOrderPlacedHandler", func(ctx context.Context, event *OrderPlaced) error { - fmt.Printf("Received order placed from %v\n", event.Customer.Name) + fmt.Printf("💰 Received order from %v <%v>\n", event.Customer.Name, event.Customer.Email) cmd := SendFeedbackForm{ To: event.Customer.Email, @@ -142,10 +143,7 @@ func main() { cqrs.NewCommandHandler( "OnSendFeedbackForm", func(ctx context.Context, cmd *SendFeedbackForm) error { - msg := fmt.Sprintf("Hello %s! It's been a while since you placed your order, how did you like it? Let us know!", cmd.Name) - - fmt.Println("Sending feedback form to:", cmd.To) - fmt.Println("\tMessage:", msg) + fmt.Printf("📧 Sending feedback form to %v <%v>\n", cmd.Name, cmd.To) // In a real world scenario, we would send an email to the customer here @@ -188,11 +186,12 @@ func main() { <-router.Running() for { + name := gofakeit.FirstName() e := OrderPlaced{ OrderID: uuid.NewString(), Customer: Customer{ - Name: gofakeit.FirstName(), - Email: gofakeit.Email(), + Name: name, + Email: fmt.Sprintf("%v@%v", strings.ToLower(name), gofakeit.DomainName()), }, } diff --git a/_examples/real-world-examples/delayed-requeue/go.mod b/_examples/real-world-examples/delayed-requeue/go.mod index f76ffb215..78b5e2376 100644 --- a/_examples/real-world-examples/delayed-requeue/go.mod +++ b/_examples/real-world-examples/delayed-requeue/go.mod @@ -3,9 +3,9 @@ module delayed-requeue go 1.23.0 require ( - github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241024100330-cb068b72e948 + github.com/ThreeDotsLabs/watermill v1.4.0-rc.2 github.com/ThreeDotsLabs/watermill-redisstream v1.4.2 - github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024102321-584a6f7dab93 + github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-rc.1 github.com/brianvoe/gofakeit/v6 v6.28.0 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.7.0 diff --git a/_examples/real-world-examples/delayed-requeue/go.sum b/_examples/real-world-examples/delayed-requeue/go.sum index ae0952ed3..7349c7b99 100644 --- a/_examples/real-world-examples/delayed-requeue/go.sum +++ b/_examples/real-world-examples/delayed-requeue/go.sum @@ -2,14 +2,12 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Rican7/retry v0.3.1 h1:scY4IbO8swckzoA/11HgBwaZRJEyY9vaNJshcdhp1Mc= github.com/Rican7/retry v0.3.1/go.mod h1:CxSDrhAyXmTMeEuRAnArMu1FHu48vtfjLREWqVl7Vw0= -github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241024100330-cb068b72e948 h1:b8qRHpWtlO94x6dVzSulrO2znSQqz8iYsxUyrdTixHo= -github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241024100330-cb068b72e948/go.mod h1:lBnrLbxOjeMRgcJbv+UiZr8Ylz8RkJ4m6i/VN/Nk+to= +github.com/ThreeDotsLabs/watermill v1.4.0-rc.2 h1:K62uIAKOkCHTXtAwY+Nj95vyLR0y25UMBsbf/FuWCeQ= +github.com/ThreeDotsLabs/watermill v1.4.0-rc.2/go.mod h1:lBnrLbxOjeMRgcJbv+UiZr8Ylz8RkJ4m6i/VN/Nk+to= github.com/ThreeDotsLabs/watermill-redisstream v1.4.2 h1:FY6tsBcbhbJpKDOssU4bfybstqY0hQHwiZmVq9qyILQ= github.com/ThreeDotsLabs/watermill-redisstream v1.4.2/go.mod h1:69++855LyB+ckYDe60PiJLBcUrpckfDE2WwyzuVJRCk= -github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024101952-75257d7d0602 h1:CKdW3wb3+C36mMa44DF53KUyM5L6mGOjI3hikBOlAl4= -github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024101952-75257d7d0602/go.mod h1:GMWcpauehgI40EeoKPxLnXBWjT7oOm7dJfzk5uU4IOc= -github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024102321-584a6f7dab93 h1:KeRk2EG5AtdxfpjqIVPigZqscMvIcy0E2h8k7y38OAE= -github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-20241024102321-584a6f7dab93/go.mod h1:GMWcpauehgI40EeoKPxLnXBWjT7oOm7dJfzk5uU4IOc= +github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-rc.1 h1:uYfnh1EoqXrzHu+bX/TboRyv4ou+EFcmkC1MABeQ0lI= +github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0-rc.1/go.mod h1:ttA/lhzSh0YyDkosq1Cgc7IYz6Arrba0jIWfdnON0WA= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= diff --git a/_examples/real-world-examples/delayed-requeue/main.go b/_examples/real-world-examples/delayed-requeue/main.go index edd62b588..7aa0a06fd 100644 --- a/_examples/real-world-examples/delayed-requeue/main.go +++ b/_examples/real-world-examples/delayed-requeue/main.go @@ -7,6 +7,8 @@ import ( "math/rand" "time" + "github.com/ThreeDotsLabs/watermill/message/router/middleware" + "github.com/brianvoe/gofakeit/v6" _ "github.com/lib/pq" "github.com/redis/go-redis/v9" @@ -15,8 +17,6 @@ import ( "github.com/ThreeDotsLabs/watermill-redisstream/pkg/redisstream" "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" "github.com/ThreeDotsLabs/watermill/components/cqrs" - "github.com/ThreeDotsLabs/watermill/components/delay" - "github.com/ThreeDotsLabs/watermill/components/requeuer" "github.com/ThreeDotsLabs/watermill/message" ) @@ -40,7 +40,12 @@ func main() { delayedRequeuer, err := sql.NewPostgreSQLDelayedRequeuer(sql.DelayedRequeuerConfig{ DB: db, Publisher: redisPublisher, - Logger: logger, + DelayOnError: &middleware.DelayOnError{ + InitialInterval: 10 * time.Second, + MaxInterval: 3 * time.Minute, + Multiplier: 2, + }, + Logger: logger, }) if err != nil { panic(err) @@ -85,23 +90,13 @@ func main() { cqrs.NewEventHandler( "OnOrderPlacedHandler", func(ctx context.Context, event *OrderPlaced) error { - fmt.Println("Received order placed:", event.OrderID) - - msg := cqrs.OriginalMessageFromCtx(ctx) - retries := msg.Metadata.Get(requeuer.RetriesKey) - delayedUntil := msg.Metadata.Get(delay.DelayedUntilKey) - delayedFor := msg.Metadata.Get(delay.DelayedForKey) - - if retries != "" { - fmt.Println("\tRetries:", retries) - fmt.Println("\tDelayed until:", delayedUntil) - fmt.Println("\tDelayed for:", delayedFor) - } - if event.OrderID == "" { + fmt.Println("ERROR: Received order placed without order_id") return fmt.Errorf("empty order_id") } + fmt.Println("Received order placed:", event.OrderID) + return nil }, ), @@ -126,12 +121,16 @@ func main() { <-router.Running() + i := 0 + for { e := newFakeOrderPlaced() - chance := rand.Intn(10) - if chance < 2 { + i++ + + if i == 10 { e.OrderID = "" + i = 0 } err = eventBus.Publish(context.Background(), e) diff --git a/docs/build.sh b/docs/build.sh index cef7aee2a..16125dfee 100755 --- a/docs/build.sh +++ b/docs/build.sh @@ -49,6 +49,8 @@ else "components/delay/delay.go" "components/delay/publisher.go" + "components/requeuer/requeuer.go" + "components/metrics/builder.go" "components/metrics/http.go" diff --git a/docs/content/advanced/requeuing-after-error.md b/docs/content/advanced/requeuing-after-error.md new file mode 100644 index 000000000..f137dae81 --- /dev/null +++ b/docs/content/advanced/requeuing-after-error.md @@ -0,0 +1,55 @@ ++++ +title = "Requeuing After Error" +description = "How to requeue a message after it fails to process" +weight = -20 +draft = false +bref = "How to requeue a message after it fails to process" ++++ + +When a message fails to process (a nack is sent), it usually blocks other messages on the same topic (within the same consumer group or partition). + +Depending on your setup, it may be useful to requeue the failed message back to the tail of the queue. + +Consider this if: +* You don't care about the order of messages. +* Your system isn't resilient to blocked messages. + +## Requeuer + +The `Requeuer` component is a wrapper on the `Router` that moves messages from one topic to another. + +{{% load-snippet-partial file="src-link/components/requeuer/requeuer.go" first_line_contains="type Config" last_line_contains="}" %}} + +A trivial usage can look like this. It requeues messages from one topic to the same topic after a delay. + +{{< callout "danger" >}} +Using the delay this way is not recommended, as it blocks the entire requeue process for the given time. +{{< /callout >}} + +```go +req, err := requeuer.NewRequeuer(requeuer.Config{ + Subscriber: sub, + SubscribeTopic: "topic", + Publisher: pub, + GeneratePublishTopic: func(params requeuer.GeneratePublishTopicParams) (string, error) { + return "topic", nil + }, + Delay: time.Millisecond * 200, +}, logger) +if err != nil { + return err +} + +err := req.Run(context.Background()) +if err != nil { + return err +} +``` + +A better way to use the `Requeuer` is to combine it with the `Poison` middleware. +The middleware moves messages to a separate "poison" topic. +Then, the requeuer moves them back to the original topic based on the metadata. + +You combine this with a Pub/Sub that supports delayed messages. +See the [full example based on PostgreSQL](https://github.com/ThreeDotsLabs/watermill/blob/master/_examples/real-world-examples/delayed-requeue/main.go). + diff --git a/docs/content/docs/getting-started.md b/docs/content/docs/getting-started.md index 2a645473f..37e1cc13e 100644 --- a/docs/content/docs/getting-started.md +++ b/docs/content/docs/getting-started.md @@ -258,7 +258,6 @@ if err != nil { } ``` - {{< tabs "publishing" >}} {{< tab "Go Channel" "go-channel" >}} diff --git a/docs/content/pubsubs/sql.md b/docs/content/pubsubs/sql.md index 5413c9436..49df08b1e 100644 --- a/docs/content/pubsubs/sql.md +++ b/docs/content/pubsubs/sql.md @@ -30,12 +30,12 @@ go get github.com/ThreeDotsLabs/watermill-sql/v3 ### Characteristics -| Feature | Implements | Note | -|---------------------|------------|-------------------------------------------| -| ConsumerGroups | yes | See `ConsumerGroup` in `SubscriberConfig` | -| ExactlyOnceDelivery | yes* | Just for MySQL implementation | -| GuaranteedOrder | yes | | -| Persistent | yes | | +| Feature | Implements | Note | +|---------------------|------------|-------------------------------------------------------------------------------| +| ConsumerGroups | yes | See `ConsumerGroup` in `SubscriberConfig` (not supported by the queue schema) | +| ExactlyOnceDelivery | yes* | Just for MySQL implementation | +| GuaranteedOrder | yes | | +| Persistent | yes | | ### Schema @@ -83,7 +83,7 @@ constructor. You have to create one publisher for each transaction. Example: {{% load-snippet-partial file="src-link/_examples/real-world-examples/transactional-events/main.go" first_line_contains="func simulateEvents" last_line_contains="return pub.Publish(" padding_after="3" %}} -### Subscribing +## Subscribing To create a subscriber, you need to pass not only proper schema adapter, but also an offsets adapter. @@ -97,9 +97,28 @@ Example: {{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/subscriber.go" first_line_contains="func (s *Subscriber) Subscribe" last_line_contains="func (s *Subscriber) Subscribe" %}} -### Offsets Adapter +## Offsets Adapter The logic for storing offsets of messages is provided by the `OffsetsAdapter`. If your schema uses auto-incremented integer as the row ID, it should work out of the box with default offset adapters. {{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/offsets_adapter.go" first_line_contains="type OffsetsAdapter" %}} + +## Queue + +Instead of the default Pub/Sub schema, you can use the *queue* schema and offsets adapters. + +It's a simpler schema that doesn't support consumer groups. +However, it has other advantages. + +It lets you specify a custom `WHERE` clause for getting the messages. +You can use it to filter messages by some condition in the payload or in the metadata. + +Additionally, you can choose to delete messages from the table after they are acknowledged. +Thanks to this, the table doesn't grow in size with time. + +Currently, this schema is supported only for PostgreSQL. + +{{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/queue_schema_adapter_postgresql.go" first_line_contains="// PostgreSQLQueueSchema" last_line_contains="}" %}} + +{{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/queue_offsets_adapter_postgresql.go" first_line_contains="// PostgreSQLQueueOffsetsAdapter" last_line_contains="}" %}} diff --git a/docs/resources/_gen/images/_hu70523d59fb738bec5c06336403a46531_39940_c0fe83760c1bebd5a39d4ddb7fce622e.webp b/docs/resources/_gen/images/_hu70523d59fb738bec5c06336403a46531_39940_c0fe83760c1bebd5a39d4ddb7fce622e.webp new file mode 100644 index 0000000000000000000000000000000000000000..fa79b73532ce1d996d27e50f4ad73874f1f18969 GIT binary patch literal 16278 zcmbt*Wl&^Ivt~E$?l8E!!{9KuyASRTgAVR)!{BZMgS)%CySux4FYo)^8ykCLWB=@r z?x=H4WmZOJcGmM`omQ3-7mpDH05rrz6x9{EHQ@jN0Lte_7aU*-1`w82l$5~(0DuIL zwyVsrzDuv)P*Bl@ejiy2g~N{&njX035XU$TB4X1SGWl2q7+YcESNG2u1EeS}xRIZ} zAise+71C@sd4mvl#0xI`Tang0r}7Ce{4r4@(|NYKq9ZVA>%Tevz~G{tC#21yCRC$S zM}6EX4!}o|PQeQ&At4bGh=qRh*@XBF=-hP7h=!52A(Q$={!+ANjvOg8k+uHy!*#78 zn%Fje(U;EW{F1UM{!knh7#dG5rktVX@~Ar5b}F(~()s50^`K*V?{}+%i!b~QDPn5I zOkDEkc$5TPt!Mt7!L59PeYzQ5P4cnAh%r3_Uy({Q|D&t#9V`=f)YnVp-JA zp*e2^hAVKVt2$sY()}m(puh~pq}FQ@YYU5?SS9-;r)Dpw2xF{WI7@i2LQ>vxd|RHV z0zgh-|4`hj6-SmTBVo-O{14lPPMzgFw{{sLdUm6M2SN@tKl?2o;&$#`H5odMPmV-4 zi$OlCiFnlwHRmzcSfss$zPsYybwcq6Wa z{*UwzQ&vVG^$(mXkH&O+Jm&Nk0CeqkSvp)-=m`;B6<{i90SrLy_kFt&M{?HBn?n7} z8SLd!T=nPJ3UOPGN-v60 zbKl>*gj^wXhq1XC*fQjWKyrX6*1dg5tego)JXI3-VZ=689r06Ooll0n@BQDeaRfl! zxgbIYSFQ|s&Bf?H0Qr7C{yRFFyhDn4(99@iY!2xQw$hm ztg(n^uLF-^rkHk5&NOV{{)A8SwXO6QcO_^1n3U{xkQg`}#Om?d+Wj(r^+|{Vb9uci zXquN~(>2-YqL2G~K%Xw^5zD(hQphaq>C~&;c7FK&I8*%1n|>P5Os9s@!N$GNqaJ`s zrgg2IOn4AR^Ej`?OMgN%qP&TV5UO$#g@|4wv15XY+J0)ooM~|f*{duxBYq#ZLG^5g#!>5{f7G#vY~$0Ca&yhHp^o<8)hx)%!#WX1>^w8IjPce{c$QInV#b zzNQtd4^I&zu_o2#D3BzC4pYL(?xSf_Fv3)Orn-(uAG+s=_I>{6_mH^#sGMBe2xhXq z6VvbPWZGvrJ#r2L#CZjg+x7!vFZ^_RFD+(}IUq3zsnk;S!Q*@q%K2Vn1_gcmWjzj)vA^;OMb|}&ta-k7eSvn^uhaFUV4(<&Rdo%#q z5nI_Tm;aszA6aG&G7gr+^h{F0Y!aq#ZvJ2qst6)r2+Qj_$s9BNSi z$o2-&18=-X;S%`98y+ve1r0H?=TlcrH|8UXmT=R(AOA^6qL%oJ~UhU@#J^`s=# z(GOGS^2Z;|r>rOt3IjGC3Ab?y_c>IhN~0%CO~@>uXb+LAp-Klgq_l-e!O4|L&0 zPQB7S4~PKj;+sR!eJ#0huix)hr2$9ZRHHFqVXX2dh08ImU;*)w#q)3bpe6ScHx*Hg zVlQchIaz!|zbqc`ym`%Zq&Zm%2t~bsA+W^P!A<1^Z={FKRa}(s5YzQJ5es4~V~AHY zfa*fnyc}sSq##tq3k^mSWP(={-Dnm8K&ta9HP~ciFpSICZKd(T z!T(S8wO2%Pv`cXa-V_2%1N>bo=1KKt`U>1U=aq6+<;5_#wBoSp=*VlR{6LKUn6I%RzbmzeysQfG(kNAri>)(rj0 zj|kR5MBg;47^Rj#ws+;M<(NFl%4{l90sHP79D~crk90Mbu8F)v!c;gqZqxqp%pYpA z!RhkEM3cv?$Aoe`6=l%w(~7<0(UE?lu3x%2Zk{I3B5TxrjQu-$30OH{%|%0p7jHj0dv~ zWQK(Ra{yF5d1gb|(4tfpD^{1;$~jDz)jXLPC5OGpsGq=I%p%49@txBCaS6{<&yCc# zGr+w7KpBohp6CXGIbtArpJl)SZ!LD=b3=f3eoD0S0{eHPAWz2p@b=A7pLwG;_`3m_ zW+Bf*W!F-&-sOtEC58bohhvvlxq;l03XDD!A=z3SdQ1k+eU%RkiKp5UD@PF;^i{Qo zuCc3{g;g<5pb)VGf0}j(Dq-*o?X2FuhXk0bx(*~1m@WeklKe1HXhJ!rV!Vo!p}VqI zx;~W8KBNFKC*Off*E4G8P+aHrj%;A;oibZu)3y#mo#VfbWq6nwGB|tOQ_2){&mOyo zyl2!Ie(8&hO>Thl{Ie}4-uSop<@%m?2?^2Dz-{+#Ty7O4ZfDeNn;;Qd%e`ACfy}GF zIDj!q&n~G~^D^tF^S>gxQlQ1`^M3Bk!e8FxryKUtjh8}p?bY=xWgS);r2YzvFb95_ zEQQ}$^EkFrMZ|xV0?u`MuNUw^Q-exIyF>A@FKO2-MaZdtbe2)eY8z{jLGJqyJF$H}NcrumhQ5^%T3&NxyrAK)bT3+7~3+rL0pJ~9V%-&g;!_@a*< z|MEf5kW?O|1(5yaY28>jLwa!Wz3^gQFEH(k(3fSlq!<%~;n0Pu4k1oXH3#`|O|x=L zR3!+JI>66ai?d-|kh0(iXaalqJ{dK>p!u-)CNVxdVPS!WsS`-zNW#JD3Kv+7A1%XuCJ4X%@Z8tRx8dn1j$1N2K-D z;CxGh;GU%%s5XNsCLujHgEda&AS>=NM3&91NbID;&&q_%+34lQx^VfuDisa?xfm`< zs3F09@!R)hlmye@3ifPA`mIx_=4-S8R%_Egz$KG1B7T4R%QtS-xW8sIGhPIqObJncq5GYdAQ$?O#p!Nm8O1F^dTtqQuY8A%%YP zF=&yCJ|;j##Pq~!7e|KyGW1&lzp;B!SEUs~Dwg-xA$eJVl}L?OiO@HJe+qYmhB2w~ zF;e4m6aEt_CY>)naT7q%RJYX?(J%btJxt^IubjZfA0S3eEY62N#_+DdL5NqBLI6B~ zgCt6!Yj8DAp&tYhrexd*!h1;*r+%cvzU$T3ioO4*vSy%mEro^|dP?eF9lZ{yO%>OB z!56_U?ef3)ZKcf2{ZxxFHe>5(sc9JCK%oOxrC@;r$cY*sY>F!DrL&-^A(@&u+Nz%GRmDE!j<~ zZDb>D;MuihuBCk$rjx#dn7UK-u`6hR-^HkMZw!h@y%2MW{uw638It7iXnR`Lak`yM z1P?JSb*WZ;U!{c0N~Y<7k$#7N(9c!J)FycQI?i|Z>US&+N0sNn94UAGxTf&K!@B9( z)yevue(5r;1Gxa%G;7oayPF$QpvZ8m@+?H2U8adJ+4;1YSmu0ez45%MhHy*ln;?w< z9Kc~g4sz?;eJrLXz(U3vc*XG#ei5aiVlE%T?FyG@<5+X&Z59Sw!t{ysXGdJpFzV-q z5BZd6RC~GKYX8gj+SfY7Yb3lJysu!v|Ewt##4Wn${5toen-f3~=k&AOg0^w+B{- z&rei-5Pt=0Du(6PKAYeZBj+$MqwmlyCDb@1-Q)BlLUfxB1!tCMov8(!0q6iV)SBS8 zf74kGW?1C~{u5a=1%Ucj31IRdOKy8N52VOnTrx6x>Y3sYi~p|8Rmt+>@b2qy|2N>i zfRp4V<2t^PW!9_H8{L?&%DBH4?$`nm-b^}W{IW^V#SO{#m&qO1U;Pd_ALR@4S;ND* z0Cbnl`DxzVl>+hq4aXaIZ5MH^^a0>6@a*0)*9heQvT@1|5r!v-iQZMP?P6G{@}R#Z zu5XDm&JTX;Tl4uB;v;Heq9ciW|G=+5aU4&$&reb7D6)W4Nk*U(sHOU5s5*uz7Nb;MKS(G&GDv`6jd88J|x4c(Q zTkXrPjo@(bPFCX59puTd^0{aU3!p2TkG9e>Y63&|UBtF?O(k(rb>KALXkjVb>+?PW z(~0&dbPiOh;SD6}DX4OpV(3J;4wqL$6O>%&?KfiGk~7#yqblsub5HZGbPAnwAjV^K znI13$w_$v^{!F1$22eFp0ar=`<408s8eiirSXw{>5LZMo#Gn>MnImg}e65fE#v%d$ zMx#wY_}zYPPK%9E+XKOl_x%kW(mX+AFQ}ZCkV&5rqYA-cgZ3tr2G*tB!1p|Tj;XDP zkl89daXe<<9ie2XylLJO{@?wHuF;v#AKJTgC28;>59zsYex1(Krq`f=Oe|s0X{22_ z=zi0-9Q3*ENr{#~%M{99%*B^i)~B$^OMh=CNir%JNSBK)Ne$tw24Kxf?|R=WS7Z#_L1i2_URR%WyTeh@-ya+3d|LL&$+Afl7V-+ROMhBE0r0tP9K ztN^dCnk?=OVDjulIKqKA@fmwG#=w7zlOH9>iz2xw2bKZ+C61S@3D!@SJHNIiZoay0 z#o;dBB?AMApbQ5Jv8cOD@{3O)es&j08MQZcnn}mi(X9?%!Lw4Aay$H|oJH7farofB z94iz)*iXe2FA%dA=S-4pQy*&T#j)dk;1^bxKRdBs45)Zx0*~}9-io&|9Ie6FydQ~Szt1lYc=PDKn9mk>MEAW~d49gsz zo@t2MiF|}H!!LdPKFDwW4gHH9ciV%^aAeBab!w4&$@H_nck)AxnIh@pmy3z)|AqJh zKf}~@K3E~WspkX@2+iKGKnJJy(_(4_7!zW&ho8V2$h&@E`XJ;plY0N${_f*k`x06+ z=LQA7IkfrZI1wYrRGO4Fs~=ep+3Dy()f*k@hIzsM1udyrUmb(8 zyt*v>6OP3-rJ{h{xqC&FK#}?nJz+q`J0J-qc0Z|-UGqH|c#)#z56GFqkye!cY3oo6 zKt7yvo>C$B$B7;EEXpK-eJ04 zY}+rG*K5b)}C>9&k_#aUmCxUwEq03)sUFGMIl;;;psHcITyS0WxA{T zS$vINRxfQ6m5UKfQ)(gV&Tfv#@5J+?KK1NR(&;tZe={S<>AF%4;^U;daN1+PiIY}B zpXcfnNiralH?NIq$QX<|;ePHWw5*15)9E5Ra#a7q&Mg*wEYMAXqs|BG%uP;Vu8r7k zM-R>~cMCm>Qr0S(|6f4*Mux}34KOgdM&E=C@9R;R>}ui^Mg{QfthwMnlbi`2_=&y9 ziwXkcT;VL_1-Spi$t({@wr1@A3nF;nfunk$5${9bM3$TCf&nhFto$IV=*YE;?7@a_ z-2=Ou1!W!;1^paF!J!X-rl>$Wm>U%w8tbO#ECGl~XY7);T*4d5?v%oO09zj9#JTrF z5l~YpNyf9Dr++9^w&eDYo#&zl31O0fVXX)QM%L+;jbvTxDu_ppoNTpWM@5f-kIX)} zi9cXn+h3^JXXsg^MudjsL- zc2o75%vd@JPltb#{2Gf^B-i^Vp-s@sbL^_dB1|C7M}hxQT1Vm=h;#gmvq&ivgTFT* z6Xyb+!MgV^n}hJ-f279Il(gIWjj9=04s?l`CEm!g(#TE&9~O-^IZm%QtT%_`igFY+ zUOMvK3qLe0AnNb^*U2kDFJT!+)l0api5!?_#4U+lf2l%>aX;5WbKnwgaQ#x+KkUxK zv*h0vU>NiLacY+WPIq)ScOFX|dF^h*PXq&GEw&lAd>TKM>&GZ-wt2a8tUP$_`uNes zaltRPik`M>QGoGFv@Sk2JV3*2bMtAfCthIvlJkQordTr(dcxW@W(AFsGC1r!xp$sdm2wsYKe*`_^LIU5gfm)&aa?s? zU9-UfHhACVYj)}x`NyVlKGUDGZKh4TYgGjisb~%tpAzNkhmy1?Y{T1@xBtCAW=oJayr&k-3_ulE0X{s;$xh5pTVT@LWD`lg!NK{MZN~c}g2+b$ zZMwaxdj4l%Y(i^N^r;oNap`C>?bMl&IT4PpZQ;}u zGabC81pzywn*RfAC?d*Sp%&6ip(}|kjkSM^uw?)m%GE1k4Th;;PKDq8^v900DY-lZ ze6i^2ac7B%&c)tcL;eBYMKTbT*sptBDBeCZK3KovTZk`!r#tO{H!&&+eXB3dQ?T&N zIz!|m7<*$Fz5f*egQu@FugiRAtikp>QM~L~phlbNA4V0?W7^ztwD6*@?`-{E$<*?R zG8zZ8L7(H%m7EghB3g%z<;T@U6jd3;ikyFrI$9GQH=n98rX_v=+_q0Xu=d38SicsM zFnx{}Jk9O%q?=kg_bcX8DS7^VTpKT|3JI(6_&<+H{y8%_SFHFUdAzWU?DE{!4tOL^ zP$8;%I9(6}eWeBfK2Mp52`B&nW(r^?koG&c6_~(x`d?xsxq@-W!O^dfeRqxCdvo`U zyYdEalj4F7e3u{12jGR^Qy9d*W@12i?Pv!gOYvsB9BUAe3kZ?x3r0H9s*ndw`eNAXkw8uE)bC^gHv}66g|4^pyks_<({a(KR#TpP#x*-`=0uS zza_oGd>FoSeJDH;-T1r-wEMb&K(BcpGrk~lOhm6oQOy!ni?t~yF(LOdWb3>2=>Z+_ z8M1J_jg?bMgv_Sd6K-TYGw+H@ds~-ZcUoyVeCp8kFLp<}4EdI*eUQW~UxU6K4jwM#M5c6bkP19|tuip~17su$+iQMwLloOgi@%}I{}}kjE}K=06XmCh zNl@^WiC*T0@Whxu2i0-Z?-@GoROa$oSDJi-Kt{Ae28y_Yg#hr^{?cIY0JoSajF-XO zZHYeZ_f_TfPV}DFqxpQloxz+48Q#c~tzwN(cPUK|YYQ6i?2fYFiMXT@_ly+n4jKVm zq)hw9^NcqIRlfXCRvD?Lb~!1?5N-w7X9YPUOh@Fun&0itqCb@xn^3Utz>M7tL*Xw= zcVGl(W#xGsW7vev-kr$!M|(NuI_E(fEFsQ5P3kHtCzGF@bvvMD7p{T0IGnP){21GtA(X&lIXzZ}pOa9FOS)StaTMvZ zo74<%N5RJ61*elyw+@u+I&38hE;x*5i2a#?69$ey`5hW?sd zjg%o2LZZ(`g^P$k@W9_QtbvgLN^nU0qh279@zBSr4V)!`10kIn`PKdElKkT}G8cAR zs8UZ+UFD(eLM67PvRr>=%@{G+&%stk{g?cQ5Vbiv#V=E`iKKz<6+-#QuA*8CuSv7T zf95-fbmWr$$_FfBEjJH;-Vu|#^3aYaH1!Rw{f_OgV5Y--TlhJTz4%1Os`-~8-A1*< zx3R8#rI4CpL=Ws+I%TqnM73AW_UOQoIj{DjH#1~9`NY_nGRv^c06R3duRIT&FvIR+ zg#R%;Fz<@yY_z6!p@g?0J%x35I>NxSpAF?~8^ye3_D~SJ%!DVhptwU%uj@re?Bt!g zeMI#%?~3}nVDEB8WEY2&jan1x9sU@~*U3xlIu)@WB4B@c3^BR7_pL-d5@)!suW*E`zk$KF!z5tbdABeKEYx-JoW^|vV_hC4qM^s!8&iKGxgI^&Lx%0bpqzK z49SmDraevGo#~d7_e8;NiQZ_b(>X}m0Zt&k3OjfT! z$t+w(quK7)=Dov&jyx^|!FCRGu6)Nf)=Uhao?ooC9Iu;V_!mRA!7s8)EL^@RvtK!rf!_|Aoy zmEEtb$MR@T5k*LuM6(~q$|{`xlLI!AJWOC&W36=jZZ>c(yHOIE4AK5C5W*3@|4JGO zPrGA0m@aFbwi>8Z5*U42W`jK0FELK@nK!Y?b)cy3eapE_Y=`=p1qE-z6uxqa$y80B z?oY4$SVcqnC(Rf~Ccm$OWx8)e?^qw1mJ4_B{Fhy>jmH0GqdMPV$EWkGeh#FBFOgk` zT7FzTF5m`^^gh7-6N9qG6`xkNa;5ew*WjPO{!yvF=x0Xsk9YoezOS@W%jKw*$_`HG zV~aJbvsNGS-}3)wfts5oj0am!thSYVKMf2fT@Udy^zdxD(r&tlOS7rxtX7@sLm6W& z4;wg7&h|(>+n=M}MW@D+&H~@U5%A9icj+fUZT$Bk1~*mj0rj6Tv}Sn7?!_IRnmtPT z$2>Kku?E9W1qKo6TQ{H2M?lC%s&iq|d;g@N((QpdQPuy*dsmWrVzhif=bHa^p7Bdi zgme_xsh^d(9b>=)5f_o=%ZjyVnko;{_THN72m%7TsS@I8G9#s6>d=(j+o1op@>sdl zoXm8%Z5CTCL&?=iWdEoI)vW?|W^xS*F4BSCs{ie)Ar76wqgbA!!sC?HB=R4L-}iQc zr3p{9#pz5fA7A`H#rkEl#uGq(XBEv{HxNaj*TU4d&h4|wAVLv^ZshBr*_~<%Zs#1L z+yd6MxN2i0B7zJ=lYffa4!_-S@Dls%qkN1(uq?33&R>X2HM!+x+XQhw zi2fs?M<^v>@d;I)(nd+E-9r=GBY(l;q?Gg$a2mD74Hd@zB(Ov%&bvX8L+ii9p(e(X zR0cbHv&}KezSb%rcOo-jV6ue~wRFdHi>bmL^Id)m+$h=tFbhHuJt`d4>Cy-LF;RpK zB#JPS5ibVA#LX-qptg}&oP0BYq7&tW^3xb8^T||`f zp|68v?mGoruaF<$BVwerH?PzK$>UPN?{=Q!au!Gy>St+p_O(@dG*E$KNd)YrnUt1>DcQsK*e|}|J@UDtb}~2fXTlXAoTkueoGhUn zS@8Xb)X%SNwlq)~2$FP^NI(H*PKpd-`%i(Mj-YT6HW>;Pr3pzu^#2xc>0)nmC0H*s z?Ji0?BLRBr#$#e6kG6H*bxF?n^uH}?9ZpTOOFaQZf6p|V#SbrpW`8snv=eM_{`R*m zR;JL)u~Da#U~kFlo@o~l3ehT^iHA*4A>N2FkjU4S&lfuLs=VzIGq#`-MN%Zw0^FGNtXDnbR_t;3H?H$`&?_0(bYw?=K%J zLf!Gb991Oh(1~6Jso~F}D^c%epIrgESzRq#uRSzZS z|KzUOxp5obz!~dZoqtP6Qq6h_fN%0{Z1PXS(19`_uFF=V8CK`?%Bt=itqAoBL1^A* z*x#StNNtYMKyWQo$I8rkVt>kaC+l_)>Ve2p=j?pjb7!Q%uO0zJ(?9AL*o}kh@-$sxydn_h>+e+N zs-k04(#o@2gEw2CA6h7Lpu5Mq86`8IS~1gn>QeK}z9El7ykyy~v(&}SAWe4+kA->* z9hE|t8I4X&msEM(@1yw~`EbprDWKFh!Rg`@90rPdQ4_fma0e4?%mG^V@}p=l&&wlS z3=0ILzFf@b~fCOG2gPB2Xp{UfRw{hM zS{EO^%2B1TW_5-1zl8Il6V~HkMja5rLK4M*uR6$zdv0>|J@eHe%hu^yalM2V{rt83 zRe5vJHleiBgr0yAB$_LrK5;VnDO}D=5(pIPToHVwfZYJ@C9TrV_xXC!R;Ln+bK8e{ zTESu}u*=qi?`m!JNMvduhe?}&UcO&GyE5MLmofuUAD^M5SKox3o4-j`8XR?WEB`MU z4xTU=r%j+1x=BuxUQfu0(-_lri=uBo8mH-$*m|sag^&qO<-^C%O!W``i!>yCTl5jimgc5W*fA;jFZH}bfo<>2+;$INmhr~c7UOx z(cQLV-c!L|D;0@0`7t5@#AhEwFu$vc$5m=JZW6d+$OjTsZM%FgIN_jf;s#p%ZX6^U z5dDPv4xz<|jD_Ulc=42dX71!dV5&fto?M5-BNf-4HlcXC@0c6fA1vN>Pir@llvDEmUDV~qQe`VQ+wlu zZ<@aAI`NF%+L#>KBLJjE*AyZwev|6U-6ifL3ymgWgz7&59>6i{Kb(YuePxtw;=Ajc zC)*H_$+u4^lJd3LZs}yV#5tOtV}*8_+lq&G>FH&wCL~gi1JjtC!q^^;rQ z+IwC1p%cwfFB5b%(SzJVBzVYsAiVKChrF-hUBg8oA|deHPuEaH34hxhukq#Pou|4! zdQj;1v@zhEg#I-Nhe8;(1HMGHV+ft}^iV(^yQ7St%Vgu6OQccRuOYuJ4 zah)~BB&%~%0?SkSa}8~+7HPJfGC_v^a_|G;l|ywb`SH*F)ViUu(bX??xloFpGSI#d z(c=sHobs)-i9*qTq`~zrBAZ-#Vu=~sS`s^EzL>usxZeCJ z-E0?Ixf+UtiCI9XY2{TQH2m>1^PVY{97xz#C_i@3I;$@{+xF+pC#K}n$K_$Rq_5UtoozB0PpE4!0PirsHn=TH z{M-1z9=({RDw=fZDTRiTvyf(x8 zJYs=O6}?zco+J5C&=4nzz)x<#)}u7Wt8>ci`+-6B=Cuz&7XV1cNa%-(8$~fl4Dj}1 z?VCD#;jL6e$EEHAuL#!Aat5n6Xa`?lK0nz-uOU$+1CVzreaDazAQp!&{{4u|7)wo} zts3C?O5YWs^N!}>&ehE#+PD-KzI!%(dT91iQ`Mleh|q8|&s4;0E6Z8MtZG}iGNh&7 zR^G1#jnP5*0mK^b=}koG}xgC#U(COrvJg0ddDNHm}!FI^kJn(Px4Yp9ydehS8?1FO6ezk2m9jcI_Er*JXa!O!rBPy%6}s(`A3HM z6$gFdo?cfW*ksOoupuD1gZl}0!8jMvi>Ah2MWS-uCrur(I8XCPvw~`egcg}K*?9tt zXoK21k3GVK(o1(--HR-qB}Nb5+r;^}V^dFAiZ*)G>IvqJ#yjp29`_HvD~Is`ZF8|9zZAaq7g;BqCNR$Q0WTz)QoIlP%Q;2e@O(Q2W;TJa+vC8R%3ZZ*JU={o zS=o8UkoeUFD0ndH83)U9?R`FChAy2^qanG7eGlHkvL5VWjS*Cn>2u(Gw!2sSJ@c{F$;6e3Rs{` zUsjb+A;HSf)id2v6j;Ph`aQ`CuumczOt9Qgl2sjv3&FU|?~a@OjXew+ggI@|yLwrv zc!AX6uF0*nS9=PTO=@l1G|PTS&u=L^2m4a8XQdbaUGbiG`X%CS;0WYHHM4q9zcP@bLuO?E9OsozF6MCYd^|3L=1ICJ{z zjT&+Q0BIkisI?^t|~=p}~b=4lM~`^Qud|fl(vd*VG9MLhSV6cyDirAK+JBq)=Al z7HkUDN@|dbzg0DU$NRPsZH;EdP7%o%hsI}g z=+mc-s5zfKVM3-}Fy$Jfo1&Y?_UM%0U3Eh)$61W0K9LIvK~Th0U3N;=9a_fTiL{wM z{%I)|``Eh5_$@`IZEhv@r;U@PPTyX;7RkmIN|(PC8&0pn^~v(K+qF<$wG?Mq7aDo- z_jS~gcSkEOLDVMOR2q!%%+1#@|A=3*RN4HajU4B0a-@s2xA0P1CW;|^9`yzzY0y(8 zS+xgU*62lL!+FyNxEfDE7UhQGC&hl?;>!3mTo;PTW|wmrz8}155^F7xa{m3-KPwpd zkN7+H0413yn${Q&pef4et-h!C_gj`R@rWmt;)0hn03YRpprbmbWwOE$Ha{mco-1`E+TX+1N=eX3XBuD^0ioO>lM9jT+) z3N_>3#uq7#7ID%1Q63El`Is&xUN}cPiXI4yx+P zz^P1`ESv~o`BdzeRx<2pN<1O4^X4DCDkm1!qRXL_&?E zo- z4yRrn-d~q6wuRxGugZ=8>0y3MqjgB3jC6E{ZxtgTUKHo~f}d{sJSjVD9I(+R(y=An zw0q>v*1=Pw6fi3TbGSvOT)CVFU=0RFq?^#F%?vC^&ED@Jv_{^&`TZ{R#@d~Jd zz_>Ra=%J;nuV}8)$L0$y+)RSK=UZ(twj}Gu@*tOF)#dkz^87IeqrC_HXBrhO)XT2b z5C*gf$B4UBt}5vaQ1G0Qg>zPYLtz%tkg+XxtugCV%fSV+a8Anl)z9HmBN<1H7Vf(L zOpnXV+5gohV!wWDeLgIG2Chia^#ZvzW3}=3nN{?5H>WLu?R+57cns~$`$nD!9FjUV zkq~ND%wOYeU01#YqZ$BYJf)@!_DuqpqbE_RlJr%+i0SEGakjUu9fa`Cx02YK zf#@JBQaXojnWwGh$5$ySv3`#+y&=-~anGlcn-+sL%YRx9zu0BnPZY8nWlavA-NW-Z zDYOyw4i`H;?VFffN%Y15N2k%DtX z=I+{MGwWAbCcP?_hzQ|5mV}9T9w>st<5fq(U#zS0_$#92Uf#&=@H(D3q&heFqE-EA zB=?iFD&uHQTSMxgqUV}y%REa%Z|UhF()&=)iG8H7;Cu)p^~uX5e(V!cMeA>UmC7JC zyLU#xxgj)2Eg!sn2F~YRLY^MM`LFyQQ%_~8_QEI$ZI?qA3Xf5!$-vnh6WHTQSD3Ok z2^-fsEvZ^ig^wVAW9#);;&$Z*5Cs9g!slW}gz;BC5ka~BW{_fFM>1Lfa@AES?fjXB zslCd_tH0p0O6{DaX^B7O(@ddq7I15E5lPa#{yvs^miEqc#RoM#M%`R#2;Anz=&Xy? zmaQF?Ob$}NZT;v_Yj*t69ov7CsGg=O4zhwcI zo#C2t?$LdC&Zmf=U@+>x&Eg8WV=$G!k08IU;)>;6jpO)#=SVBvbTKF#YC_kI(^j`B znsnjKfI6^!U3nZcjP2p+i zofnY#E2pvO8gAJZXUfGOEhhV9jV4Q=@*QRFBo>+PN&ID3m1a$3iPz!APm@%=sZ%%g z(F=h`&hmkkj3R1v^!MtZ-jDPn=YR;Xz!q*;@-TY46Zw7R)@n>IrM~ardj9^aGk?k* zaZ?uqJ|nZ3OVXTLI930IU_2|)hZR%+CwxNU?lH{7Q@SAPaftx?1)!9#SO4QSSM?Y) zQWkRH4y14`?l=<+I>s1pJNOnaUfKDb&?-?2K?4Q<7OO}4;7IybR~mEo2Uf|0%P79G zj4@-#Z^bP+KC&-^G6{P@XDfQHMMnk~`V#+?Auq8u^E@FDND0l>HD; zjTqx8IP$Q`CMS$^vzZ2IzOAZC-sxvz`@QmX7(I|Y{zNrXNbk|g+8?W!fvpS8>Wa}D z$9ooRdMUx@1v{nMj^hel9xr<%35;Lp>qoA35#Qt7w%jCUl{{qCZ$lKlP?{#{C)sJ*j)W zhD&Fk<&>)$csu6F2yLfk!+28wJK9-=MT(Y$G{N)5m|}xqY5bjGYoFnjn=Pc8&EGvB zs#Weh1u6@=BN(L1Xe=-1t}!pTAR#z(fEc#~szvL_ad;9Si8R0yp zw@gyKQFDTmb0w;hHVZdpGbJ?>fu5!E%q}$S4x1+j3`y1H~mfdv|0O z^SU+2e!KAh5=QmWqptTZBEk}){@;f>;$+%6>BplNh-W3$5jkE2u z141?0E_ik_*ryUWpG6`fZ#aQ>kuPd2akUyuc5zXH_C|)oW5Zzk3FMU_ z<^6&pfM-OMn=l48@A^?HvivVP?<^4D8Su)bk04U#c4rowqDWqPL?M~6912}mhehhm zz663sv*5z!U!h0RAx$%xAryL79ryJIbOCNLRg_qjbrha8R8O6ZS9b&ZpJkDIwR(0W zAQT@m0%{|>H9X%`3aSLuHjgk@1de!|*59=0Y|gtxX@K0*42a4tX+(S0h=F+IVj}F% zY=m*1q?U!EpH@@ZBzX=o)7P8)J4W|)ZzkII@@s-UGhwN6o^692UlsBl8|{S?Clyq| zE?lYZ64At{lv_DH0$+H;a??#W7E~MUXjP4qUJSX1GR3i@c*A4HaAOLx+FvEkRW3qJ z=oj2Xx3BoZ+-E>X}iMk(BJ=006WkM3ps_d9)D$008>?kq83tfdC@%%F@2L0058> z#&%U2)o*E40G-?zRN}PmN7uq&P_DRM4(r#(7m9Z6B4+{R*`&GPw0S$z*je8ZGYMPu zeG3BdnX!4^AQRT!wv;H);Z{6aXv5}-X)FB5Kpv5v*XQr`hfMw0#Qrntz4!^)Ak<(! zb4E)kVQ7Gp`cEq4Y50(!AiyI(@x`+bmaym*UkHXsxGkm34{=E1^NA70VSDi}NZhKl z6J;Yr@!na=?h2}bwA@-NDfthNa#cUYvm82ee+wG= z=TQ_#egb{>q#fd)Et=4IZ6`0H1A`Zj5?#4!_{dU*8~P3le&9wmZ%YRAo9Ao4E-z6{86S5C{8=#^OmQ_>1jfy72;c4JgWZ zZ1Zv1?P_iEd=y_CMUgkPd(u~+jLa@56wItfZA~RI2uRzikrM%jj$|~&H?&2{N`{|( zs%SE|{(w;A7@O9e)nF6$%hiYU>3i*^13hS_ z(n0>&W>2#Sq!4%jn$K5#bKpnunVfGwm9n;y290%)a>lPkLv>l0{O7QGuy143%v4|h z24UE7U^p=E{LR%3A);qsCdd(h(Ug0RKmwcq5ddE}Y{0F%$u2l?yV)SU?BTORsDEzJ z{23l7Nv)~g%4+i3-T-@1nA${GYw_|X2~`f$ez<+o=pj)1lI4e2*)*|ol%j3fti|Tl zM`W8fQb#-B^^1h)wirt?XL9F<1y)&FHu-L_P$Ph`X)oqL`(EeOlekQ(fkP}E`z$CN zp=@nkGdgy!j2I=vB$3{0{ej;-gMljVUjL>D>*b9+3=h3g<1ObtTKdrBn$gX}@Pon! zJegzx4ctMckREsd;H9}t&rbw%l#}2DT3TH8^s)&=na4iZ2sAIz9qI_v3jRADBrQ>A z9$+FSQECDt!`O+h3A>lJEU56Lv!ooyJ6iD?4_>}V&zq!!&N#%$Be zYxM@-uNv0rFmRuHlcpjRuIA2HGBkt}Y%u^tzhKGcaz4Z>-~pY5=w$EG0e zvIU9)8_{Ndg5cfk2S%>_5h)~l6FMRt=ML@lO@}wL$YGuZF%;53VDpu8NMtLbFq(?3 z6%D%qfK@T>?mc?SHi@h&3_fEpfr`2r$MvjLl4uHq|Bk3|yy7anp;jLp1#JLAgj6kv ztWhg|Yi?YFk5SC-fJnbRb?9Sl5Vh^&eOgkl1*V?79ea}mJ6v4bjS9l4%Oa0z-Ia|p ze0L0}JsbT=B;?o1bahKFwimOuRN-!-^D=7t4T^O8#JpT|lyxF9;Upfieu5tYJmif1 z11$o$=}i0u3dcH}BPbmvIzM?WFu7{k@%o$amzOVMT}7yKfV{0r-u5@Jg(=&Sw!->= zqxPr3EQcIUCg>tHsg5PM910)4P*{NQoyHI@p3xqcu`AD(7%@Dgn94Azv6x*y(;h%Z zzUQ<%1hABqL*9|?1yHN8P#S>x-FklRgD5PO8D_rGM}ji_oASg%-8}k%1O56js_W+%)UFnW*U-5=#|6^uqe{8p6trH%%^mS3rX@p4 z_0MN#WETDP5IVypvGI1X81P`z+NDDTv&~4*xq;Ou&9E}4d_N{1f^{qdeP08-S=i*%QK z!bX0l3BK`Ek-3t!PWbTq6>z~JGe9y_NT-1A6>6`igAp%7e%JHe^oKTsTa=Wz=ypqM zMOfHzGFx>wf$_nQw~Yb}Z@drpxxZbk0^k~iRLVZ!4R0m(52?0dkkP(O2#`)zz&Hzm zpyO-57}|s0NETb(7H_+$$u;^In=~uOtpQO#Xv*D-)Bw)Kmcb1h*67P99C3v;F63fi z-&QT6$1`ec10v~%oK_da#c66(pmNZn2i{&Bt6hkDW4q)u2Hip5eBGH}-t@0_|0unz zw@T*q?s_1av{nv;io&`ZstVoTV8YBMVEB!EkJ4GnJeGi?Su3o@A5H`2Im{HUDk2&) zHt%uC&yfhTazf##C8TX;Mp}=8k!%eKtwX@@7*<^2cw@FGInbd{tnqF0FK2oMdVrjj zNuFeC*M@`7jST;c#!a#&d(OR&4eZNKMeno6P|2?EDs+U^t~Je=0tk>bklLL?6W1YR zui@Ax?q|rf>-p!F7}l+}a!*)b;cp;m6y^6BOicp3ScXtTwdU}Nq(v>9eyvR6R^5~O zMzgKkF81BtP;-4}Yi8ks8wp*Yhhbw?0w!jO#m6yQ|E|$riJGZdI!^PGfW{g=cWqRR zY2ZbS8RbVPUvzO~X}1*J%d<&BU=A^tfY)$UQ?!uck+E!4@g7*5yM0j(@xVH)#uQ3=zJkZPD%2 zds^3%;WfO#%aKIlbWyxmaBMSM$PWlSI_7W|@Aqo~rCFS=I*WMr3yI&ApUo(| z;UNKr*Rzb)i;eRv?}!QK=F2E(;Cr{7hPI7_XFl&RT``P-*cZP3j}O0qj5#m>!1Qn> z)ascw>ydO20<^F|1(ogg*``Ey`;q(bN2t_S7KJx58q%#sUb9S;6UC7|wn|ps(%nJw zs;vhP_HmQR9r4vc@Ol+@L=fFp<=)=up{-+G4#y~*@O4QaH%3}jlv>ZxtEi-;v>d_5 z8%av=bVFRbU-N$%mJnU`;3avOv(IyIsMIJzZ7D~Wo969P=#L<)bneTkaiH1<7jW2q z4>>c_(gExE{(tflFmYUT7jd_X*|Vuu7z{H&n^UY8S`T@j6mW@$_Ur@>_R92Hp4}<1 zqX7mVpCp#)0N_YBOn~1v@3tjGVLlxca5bGnp0IeL8%~0pJ|M$qNOx>(bs7o7(BO&! zcE)r|xi!cU(I$@tpTV0fHy@Vs0t3Sg65s5{%?&H8ro)~9>+ld?RM)V}RYyL7g1)W= zD~m@6hGIuwZ;jb$EA!rS+R*vB5%cw?B`!hD^MhImO-+WNMFyz2l+7fVC8v{`KZ-#z z_M8dUUMuYg9l&;~EyKj4`NN{PtZrb$blr%7CnUujW&YfakomxAyK(Pdt6dkrKAH<} zAlwuQZ&~19f9Q@QdcgPJF76w^^X`^_a>zyZJAwkF{=d->oS1m>G63-1+QWm-)4sC- zT2%?4HOHrzB=fU4EQk7cl-Z&}o}9YW-k+*AC;;Ph*ER^?B@7bAK_M_?ZpAm| zV%p!``F8;dyhzelO2Dc~6Zae7@rNZ5COmv22@F_H>cgDYPp(N%MeuNR-zLhU7M3J% zEvm7c{#EMES(Dg_t+_oVALW?3q~XrOv+&o*=k4gp-fup`X+Fxj^rH zxXAV0GK1Ec&6a;(6ly>5w+$jEXhuzo#3GUhP+lSaN`(WByu9+>S<^RfMy*>*R%GCC zch@{+W!-kw-ro74Ym};4H9_`4jB}y*O&8M6hldX1j}Hvqe`Km|hY2vsUkAV?l~-^z z^mhQf+kUZBag*7E@%p7be-)1SQtPIm$?ZX9ytxO7x|7m2T3_}h?2bhH73w*A!_&nW zj4cR}Aa{vMd38wCC4zRNdG2{j?z%2_*}5d@1XNKlVSCaSMNhf(@0PwS4E}a6bwOdY z5RripD(8Z;S6<%L-qFVDc9*jmCT?u;8%l5aN{%OYe#Q&i{BZM_KRFYe_n&-o<7WVR3a_pn?l?19`bFYq=b1AGSmvLRu zkPr$GFxtNVan>cbCXGS|upvILm!B4;Cc4{#|G21U^3mW((^(uzL6rq5PKcyYE|D{6 zi_5LF>8+an3Bb9?Q!pHBrLKU2dN}ixPyL8BK!MuDR8_Js;I^!#-K7A^fhF(}FOPH; zS0`$!CsCQ6&;$UCY;0W9cr;KlKP&Y=F!kU70J%16LBW?noVq?r+H&ys(8||02oda6 z6^MaZE61Dd3t21WvDDmi86nr?@PUsk0jxonv5pH#FuFO{F=M#=_2mLqR5l` z^s@b_lP1$-st9^TfNMw z^dxkK`lNgQ|5BQ?uRP1MPd;AF2KloC+kuzw1pV&VM4HQX$N8*#|s~p7oeCd{|Oz`^{G@-3O0a!1Ya2ZE>`a889KZ z<5`O)EcKK;TVs3?1XXE8#+u2?PClLs1guPd1AyUsGM~}Q`++1^hR`IWIdy?6go{@6 z<$4p-5e*u2v~hvhffjsQ^3MJk9AOxvU$VGh|II-Fj^{D!R(_OmOSY+$f~YMhVeAQO zsuX8F4GrCxw}y~}zW6|FvFbk@so14EEH39r|E3v<`i#wv=dWWyEvz543!Kf~HO6fh z#7LR`L;-;NlehxZlF|aO`_WIN&q%7EEU!DJjC>wbdgA2`u_f#@PK{x>3vr~TOn(;* z0#5uLIneWd>v{Fed`wkb?+r6T*Q7*1CksVkOilwfR`vlv2zk;>X!wPg`u2^0^=WaYd0GJY`xiq;Z>$lzWI;7^ zH%pY&^n-TQ(X`7GJlUzfUk;a(8%W zUVOryoh6hWg^hIYT!1-1DIsWdGfGY^jWkT&duJvhOdgZktN$Ir0p$Nb2u|LO`R}>H zM{4g+v}a;J|IVadfbVEzlR!Io8BR;u^xV`zL7S~`yFVKZ&n!^U#fRY0e?kd93cc+@ z9$t(70dW;=W5y;i-VIv-fWVi|Pnn=2Mo8VD2_ejTX>bbraYXe9UWM<~xGF%4+{j1| z23JG)_<9gV>j}V?e($UN;unp#<|H*+r3lbPM;tvQEpff2Xz0p+!>hK_)1owzr@|vLD5!1>;;ECWCUu3>T0{wu3qBd zly{5>wL?wK%Yo{I$UFCFyAb@#Wwdx%Pyb6(#FUs_W+K2@Vc)=zK-j0tQSQpKy*$Go zq~_cm0AR1>$Q}=wfj*_lo))fpNAG{Jrs#tZ6R^BOAj|_u`U%le<}K{MI*aE3&*Ao? zUPA)lLCqwqs3#nR5$A>zCDFXB1q8P@ZX$s_~iPrRTxY4Nwz!yFc`n1k}C0Na$K!xk8@QeI17agY^ zM*!*wV67E(C~sAvVG?2MtP;jkZ)B8?>ggB`0FgF_u}}m3tKsvB-@LtROt~ zP&`E}G2zun`h-LaT_y$?Xn?Au@6t^+2~XM`4Yw#=Jmq1| z74E_wufc0NGuI@+4=wv48K$8b9LS9^NFLFow=1sgC6B0=pZ z(D0Z67V>m+L?}_VPb)ibXE3Fyp7fF%|JaGZX*#^(NpS>K+A%2cqfvCiq3x{v9=f#K z;?oIIYuf;v-17ccylrWyY=A5c9D(3Y5$#AjTkze7kyYLFf5;RpqnWX5G6U8Cf&fQx zG14#Qli06>J-&5D1@8IbVCQA?p+@{;YOF9iyLC-wgHQJdc%8hKXY%@mU34HuXJddN zu6{-*S9LMjyIhmWhu3IWr~3utYU8^gKApTSM4|KZkTCpSlpdZuk*@p#iR*0jG$lE& z;BO-#@f-A?9H&MGBF482(p!1<9q8I(6jRg(-3vU8{wwtWHAbe`(=*7nnk@-i>`8q(c##o~N_R~*$a;6%yeUy;$r6kaN5{#A0Qc$*o zJ%11QglDvXFko%sno1;9q2jzZA1lkStK=}ZRG=omN+YX61vcM-e(4JLbf?PL+w9#v z4+}HveF7$Us$^bEZ0G^HLZ^PpeQoNjlHez-!bJw0Jkn=3hWd<~WUkApm#>-uAb{)8 z7$$k%3mgVP^6&Vz`{P3!IGV+-vzb=8<~4Qn>4XcmTTiTZXD{5?Q*$J-^5ZCh*f3Lq zRe1ZIqm8JQ@)44{7m!@7K5G)}con6XwQ1yOUC< zm}V%)Mwf6Y-;N<=d1%TIuI1xPlaCr|flzG?Z-Ais+V2Cs&4bwU9mCAeh-1w%%4n;6 zZqrA`L>L;OrzcYC4fWW{LXrI1nbe9JVips#tiDJ!FaTj%0&XKCexq^CO{@jXJ^`Qu zyp@RrzLsXNh|=lpx&FCOBe$D^UCC#Z0||84YXfMbu;oW`c&PIOeNK)qf+nzN3ih-L zxXXTC&SiN+@V0)jvnTImGF>5w;4SAnn20X>-{uMZ$nPCk{Nie0{#}ZiVSo2}1)pmU zq7(avCN>Umc^y_2Cm9D8{6i*>q|BMR$SQQb_cQM&2Bnk!A)58eITagJuXo;1>$9TN zgVRXv|1Y!r|3ff;%>0o?c7L#2_~vy7dgtXy35fuJntebfkUj~-4-(X8{2`v3D-?Hx zJ@^bWaMSEA8Rov4B!N#M8+zPs!p`NP$ zki2)%H2t#PL>_=oz!#`os0*kYs0WxTSmWZOZZK}QeH_=D$p0&Z*!pp(RMzE$9|& z&tONW&-cX7>c;2|_gZevZ@9Y~(*Ml&40w}xxqdm=Z@v@C5Ssta`p8{_cC~qIa3a*< zEA7JsVSpIC*gul2Dp_@B_(edv-^5-?M!2tH|3WSyt-k6nXHQSBs}RUp?eizdOU}3c zjQZXTDa>gb2Ae)G_n756?e9rGcd7M3Q!cnBR>?%VJ#Ldvs1~K|`Dfd81W>DZtZ9lFdYM35 ziDs#VkV3Gd%s$E`(-Vluuvs48tswqDjA~{68Sf94&lq?aIFz0+Q=6g~_YmU*c6Ub# zu$Mr?dQjMdzl0l-EYhOM$93fkd$O&qE$6O}c5;`bH@db)tn682Q0Qe^ZH&T-~HBV;K1F9&6R9T{f0;<0*8Jo<$9ogiNav}0jTGc&gItKxWqGn80~$Ovg6bU0rh-9kEqeeKqHakzU|%28MNF8G+z30)7TSIzI>_8q!XjR5W$}dYqDU(3TRFQE zI?C~|JjtJ5u#4Y|gKWhL;cHI#M#PrbqNJ>ncUva%CVvX)Xw6DY)=DD}KRcBaK&{F!44y3Fq*Ch=yG;$+rMST8bX7_Dd{t(^Bd@VU^vb@gbID?na11jr0@AyW8W?oM#0aPJAphMdnDXkCSZhifC}HZ2lae z`!(a_6UCG#5?{-o2V|%TGLQ~#-;~w&sPfNhd`XA{+kfRXgiyh+u})^C{?J(NANLdB z)O6kNTv*Fbk!3BovtU6EKUp?m$!uxFJ8{hgy|3eHa|c~cFiTD}kul)@ZLrPr3tC|f zYmuzK4&7rSR$=l)DXBO40=mSIYr=PW4A7hIIAnO6#++OLO`R|`($ZGxE zzwZBGa_cjOLd_OD6A_g$qAVtiq;R+nqf)r|3E?ME>lk=ra1pibp`^%sylo=&J5Rqk z&S(oI(}cE3b+%-DX9lCo`CeQshml(ugztIxr^R3?Y;^BNDwKz<^?U_35>KqJL^@>PU2hq7N9Wlp#9 zR1ZF?yXy!b=96s;r3&k^jqz8E&+B$!fmggR2>90pALuAf+bK(gdN5P}IRKX6MMSuc z%?TjCQ5Nac5=9rk`@oGBvECF|Vp!Nn`<+GhJ+y+x1v}g$MaOuARuet+dGsjI{lK#~ zGwE{ao#Sihf6vHGPs$InEL9WUw`g24eeDyKGCnQ6(oHp}6`uJ(bCwlj6##fs&DT?{ zD!g*-)uY6BgvrH!WhsC|78YmUaBpFlrM!!kW@_R|55KeYh6*kD7o!`Y; zP2fUFJIo{i#!v1qu+K@a@O&@$$;hlN3uw;aYJaKuus&qo_qn%ly`u6g^ox`Xl7!-4 z?_^Fls=PK>N#!$S?bH@n9c_%Y&2Qb`raRTomFBF4tlrLn3BM&jxn;g}EJaDII?)YxdN zn0<|TQf#HFubuunvtk!;JQR2o*um0mb!jeKD4zS6mYsESJ{TFuYsJMrNb)`6w2V%s zV1az2+f>_}OGL5Us7Bz2$9D>b&_U7wX|hAkoYKo@Xu~ls(XrRs!WCZY{r(Rba<7&8 z`(vRLF%-Dn;0I&9GcQ|j6NZvnUCof%Y~v8?V+ysN#c9Y_p1))0rg*%lFIWa^_)#qhGht$MK_O$p*5lY=8;wkey)~D2b zH}eUw{~Uw`cQHZ|54nf=1O^duxyGy%N?rZ@;C`&UnqVX6R@kB;hlt;hA742_tulp5 zk_wJ&_{;=eooG+?4bIrrQJB`pH8#cHvfn8{j{aPIqK?n++b1I)Kq_HCA8lCx>=7&kf2!PTa+`Ia9cKx6iPvi!Oaqs zXVGbV(HX0+Hb4Ki%xa3)Mf?FeJsn#?OL0`K;?i%sw-HU289QIK7FHzarqCu}%7r;r zIG->mic+DKgd+DrGMKUDv)9f_gmp^*i09*Xf*$5loyb{S!-tI~kp%uUw`M5E48w`z zOe3_@?)3KJZJwW31cZNx%+u{&8}QcA3+(2~RZ5o$PLeuK*}{wcnpBO(WQL^xur=J0 zF~7|3Y~(spl0#0{m*=pcKU~fNC!9>eer8(cdR1C z_0!EgHk96Kpr`!X-{;G5v|#~oy!R)4HUu>~9eN;o8?V^JGGlp6b7%t2u#bl9(CRYL zsBUZMYLGZU;!0~T-pVb#ku-Oj-8TkFvtAyUl;Lxt2If3{w3&TJJR288V zoR`n|IL|P3e|NZLF0PN%34EvbNdgGNXO0pq;+tnf{6jF)L&+o84t=Sp2Kk}n^O9*6 zca~pH6hvZ^U>3!)KY;w%)fDcB$Lmny8|V~xeH5HcQGXLk!JK-Z=vtOlN->e>{1WfC z?^gj@IBa>CE~teiCCgb636fT}bMR7#*{v2LFv3NO*RUmPx>vW=Ho@;UKtNn|)y^6>xhdxuuy~QNTvHs**=-D_x3Ud{WU6P{Pz2y4x+Tgu2IkOn0#eg=VWs z@5ci@)Y-ZJS+7FMIX_F1f?09C6w$Qot4^2UH-}=&8^`#4ROwr+;}5K2WD#cp-!X4* zhAh+DtTNGE%|ne|8zkKA)RmOmc0?&0HC#W4o)&lS*kp2aAV!)u%+i4KAEMS!T@rq1!{rhfUw;3rlo7+v-L9>|E{I zw6r(8HqkOz#flQ=<}_ZyEl@k`rzZIN{v~82A_blx?s$>+TfE zAhI#1Y3ze&hv7UQmk~{wyzJhaZ{vHv3+ArGBumujGA-wcRu0`4j=5{?r56?riqE-> zAF|i$szXwy2uk3h_j#Cr*LmuZR;$e{Czu2|f%;oHSOnvidiPSrr!UIAY1H^~8irI! zr~^NMGDW~{uZ?+$EXcHwyvTZK$52hg_(UEyQYbC2duk$9b%hO6fzYSsTi`+w`_rs_ z-LthWc=EK;r$<@L?x8M_1obLNw6R$(ilO938Ruej6z7Dx26xO=-goyZ+31O%H1!%S zfO<46lv9@o@({mHkC2~nOu&6Bc#U?2R-4#*aXd~BJU!X@9j0$y>R#rv(F0KE9}hZE zo)``)pCBl;AT7Ylp=aag#xGRN6kj1|Z!7}`006n|zK8(8cH`xgjYr0aAl${$y;yig z|0UHE`*Wa4#iO4V-;b|WlMHK^5GLgBM~T&_wiNJ`2};g$&y$BaRMgloHo^smc+%Ro z6MOm1?qHttx;8t`HR>EbSA~Z9n8CooGb^RGsd<`j=qbw3y@WPwK~ar9C=W%pRWN#k zEAIV!eKe`)&m_lxzKgo3E@=f_l^n4LEy1W?d|wf!jnab#Dfxqq<81_90A@$RP9&&Vb70p6a$=J{3J zjt+Brb3B7vG1^&Ln$EbEiKlCqMNH1oS`p%>z~w8;06+B>8;gt}Gli1e;|pl2ojAr1O`DM zxZA$n@&iqrhP66Vt`kG0 zFSC?$`6RIP5MjqYdlZ-5wCfN%CwsF3&xPjBTZfL@=muH z!*Si^Wg#1(;l5(N|4FGWYJ8c>10xpKd0T}Yp1-v>44%9YXpl!7I)mCDftV4$SB2qzPXjVN0AaN+~Xu05XYx>akS3rKF%6 z4$eWHy}Bx!ipN>Sw)MNlOE??@b8hGSwPoVybV^6pHoID8k-@|!C>$aVGQCDLLXf=` z-ml+xqnV^{xqY_*1u|&QP3VueE-lyoxi|49{MFc%A_T&6#EeN`5YN}0Cm+`fPv0FT z`wJ2P07N3veY%HM(g%6mvvwn{kD$keQkxbKYVXU9mXQzTzr%9G>&aY@MHChWsRh>d zudBN5#C14dVmoD80>b#Xm^@-JVP7vvo)A2ABdgXcxD$#R3L?cNwE6c`|7NEN87b8z z0<3)hq#|-sV>b-cxYlGHah5-Y%^U7_R1qFI+NJ_z9o!g!7Sewy#q*g4X4I#E!eojZ zQdqq|xm>I{H|wUXym@@oIBA zs`#dByjC-pq8eN=Z34b;AO6fLPGdG+8dOgUx~1CVMFh&Sim4LgAH)!yHtp?{ugC0$ zcZa<$kKw;?Ua5Y7jgEYJl~^2M(8oJ_;prH<7f5084_a}vk$nlEf1UKWxEJj=>B^@% z2M|-D^qg2f*skJcaS&vT3Q*`|qznA?U#(iG1=9jOM6u5K)N8vlVyYQD46qJ-fa(pC zZSBgaJ_N`)8mkjMbuCY~HOVx9L{)Uy$0Jhcz#9n*-LX`3U#Q?wFs$C8zXQ@_SdxeZ z-eR(VbC;htt#^L1&eE13;9UCGyT@Y27kJN)G_izU*9y~5o-;A2w$8rVRw2{QxtGWq zb#D~%7hk?`q&-vPCeu0C?6dJvXy)BpynU-ELr!d??fjWV%CSl%p|MmUMGdp<9=cxw z96ss~i0yPxZAP6e677%tlbrrb+DV+o5{2~2gW#aGIzB+(88*zLQ`- zCI3eLZ4pa}987g;1l?eyu!MLK(peJE^L&VyA}Gz$FQ zHP6!YQQMTVCU&J*D(SDv+^`SuHc`Bg885A?(93R}G}_Zwjs~0$q6iaQrk^DfTnUpm z<~(yIaANqp``tYp#M^9;$S?E}tYiHmNb4NH(%fv0P&4e-zg=4vXr3N0!ec~KUAJXP z_D^rMcais`FzG9JKNr=2Lv|7hobWhB|C*igUOLlKSR-faf&!}N&cbCmtrcR&d>S{Br>kjU6aqLy3pj@Z2<`mgZfC9uA-W|<_yN4F~# z1TWVq)15lDn+9ec#UDKT+j-%LiiOi);EyrV*syK+ts+v539XENAtoF2P^8Kc54rWYQ^JLo6 zP8%7U`RzaiD`jJOin;uHMVKb{_*wD9G9z5a7+BUV^U15*V4N_|TSqQ?>gS^24LZ`v zYaO70`Av5%DFyRIq|mw;*^qv7t$oRpw*(i((_i&|+iC-iR)kAEVO9&dh5_g66HaVm z-bn2LR+?Y=Rx2MCkdr$C#nq*xcLut)#_91jH}p^0&XW3{CAI^DtAhj|N|ZOp@KwqF zs1(v^Eu-$hb1W3QTcn>TTwlHTBDWst4zc6##of%?p6Wjc_Q+kpSIBGhoMdwe(`#PL zB-G#}Akyt|R=VTvhJU~m(V2De4GYQja)om@)J?<5Z5A1H6%E4fgc!09Y!oQA@_1JV ztJY9s1bKHKZk)azezM(6OX1LXbd7JJ$wb)I+xCUCqRU}z5(M|&j5H#(jul3 z&P_S6Yc7l(rU#ql7vHCAWfCor~=#Jz|~BiI@M9Q-7@b$Gl~17498-HCo<>3$S$ z@hGwt_Is5vaWbQ|<=@{WwB;S)gMmWXsExhGb|MLZ&?k3`!%n4^FMjzPI>6LFljJ9TDg0d!SKIok`$O zp~4QzT?@pYZgmM<4#Ad&nQEU$Hx{!&gx|hMaDQ)wXr-VH*lPDFB?}TFN(6L~dAU5L zjYTC6TxO1`GKr=o`h0y_<_y?p&7E#Jg(gf65xaU4jqexKl9kZU{D1i>qA z<#B_3*Mtu6lEem(eJ@*)fMPBTfBKV47E9hL?UHqIXSjF}b|zFZnwa$*u4`W(M6h4b zrp5y1UbA+7iLo7G`Mybwi^)l;&T(D14Z;*;hdcNnmf^XZ+B} zII%Z=SCtkdX>1xj{8Q4JKq8QED#>S5*I-1zYTiYu`rgEOj<#NQR%aQ3o%xjeCF=wDbfL zaD2S_J0)Io_+tk~(`k_N%M@+rHmt0Y(<56?gTR_Qa1_!QH#;rC1D90@!?Z3U6B+rW zv*lN+8-)838+(Wgg?QiLYEfETyW)93Ii#}K-P25|fPB%^G5Bd(yDic4vFNcRm`Zzv zxiWglaD&&0KYm12h~m|6^n4T6DjzXVa9K?j-?`q8{=1ES#+zOT9jTkgJ-lGnj3`6N z&{x2HQgRQsB7bSVxaXyX@RM|Kp+FT+v%jF-2Y?;vZbVV?2}3kJTUB0VrTxf4uOm!8 z+n-IJYZim)S87phdDb^*&<#_u(9f9PCA;T1p=qu$s;w+ksN6F$MfyrZ3&Fpo+8Naa zRMa%pD;yV?Fxw(`qRRhR@LYMvfIR|Hn7^sx*^d#WE9XmLoM?QABK6_X`kX*Jr9xS3A(eQ;sc{vIoBH zhINzZbjv>-dR1A5os$b>1TLjt*?)kQs>{gOYIser6kdS?}! zXFn@y=bBc+2eXP<#5Btey&YN5@9Sqy2({H$7_e literal 0 HcmV?d00001 diff --git a/tools/pq/go.mod b/tools/pq/go.mod index 5f2f50e39..4edb60ec2 100644 --- a/tools/pq/go.mod +++ b/tools/pq/go.mod @@ -3,12 +3,13 @@ module github.com/ThreeDotsLabs/watermill/tools/pq go 1.23.0 require ( - github.com/ThreeDotsLabs/watermill v1.4.0-rc.1.0.20241011082756-1cb09cdf7d08 + github.com/ThreeDotsLabs/watermill v1.4.0-rc.2 github.com/charmbracelet/bubbles v0.19.0 github.com/charmbracelet/bubbletea v0.27.1 github.com/charmbracelet/lipgloss v0.13.0 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 + github.com/pkg/errors v0.9.1 golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 ) @@ -32,7 +33,6 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/oklog/ulid v1.3.1 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sony/gobreaker v1.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect @@ -40,5 +40,3 @@ require ( golang.org/x/sys v0.24.0 // indirect golang.org/x/text v0.14.0 // indirect ) - -replace github.com/ThreeDotsLabs/watermill => ../.. diff --git a/tools/pq/go.sum b/tools/pq/go.sum index 9585210df..d10441494 100644 --- a/tools/pq/go.sum +++ b/tools/pq/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/ThreeDotsLabs/watermill v1.4.0-rc.2 h1:K62uIAKOkCHTXtAwY+Nj95vyLR0y25UMBsbf/FuWCeQ= +github.com/ThreeDotsLabs/watermill v1.4.0-rc.2/go.mod h1:lBnrLbxOjeMRgcJbv+UiZr8Ylz8RkJ4m6i/VN/Nk+to= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= From d6b5dc244d49fe3d7c77cbd259215e32d01ae009 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Tue, 29 Oct 2024 22:18:01 +0100 Subject: [PATCH 09/10] proper AWS examples link --- docs/content/pubsubs/aws.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/content/pubsubs/aws.md b/docs/content/pubsubs/aws.md index f54460a17..fd90edcd8 100644 --- a/docs/content/pubsubs/aws.md +++ b/docs/content/pubsubs/aws.md @@ -16,7 +16,9 @@ Official Documentation: - [SQS](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/welcome.html) - [SNS](https://docs.aws.amazon.com/sns/latest/dg/welcome.html) -You can find a fully functional example with AWS SNS in the [Watermill examples](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/aws). +You can find a fully functional example with AWS SNS in the Watermill examples: +- [SNS](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/aws-sns) +- [SQS](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/aws-sqs) ## Installation From e6daa877138d055cec3ea92f42439653054244e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Sm=C3=B3=C5=82ka?= Date: Sat, 2 Nov 2024 16:48:18 +0100 Subject: [PATCH 10/10] Router: publish messages in bulk (#513) Currently, the router will publish produced messaged by calling Publish individually, even though the bulk API exists. This change works the same, although it can be handy for some custom implementations when you want to treat the produced messages as a group. For some implementations, it could also slightly improve publish performance. --- message/router.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/message/router.go b/message/router.go index 1b93e85a6..0ecf48c6d 100644 --- a/message/router.go +++ b/message/router.go @@ -825,15 +825,12 @@ func (h *handler) publishProducedMessages(producedMessages Messages, msgFields w "publish_topic": h.publishTopic, })) - for _, msg := range producedMessages { - if err := h.publisher.Publish(h.publishTopic, msg); err != nil { - // todo - how to deal with it better/transactional/retry? - h.logger.Error("Cannot publish message", err, msgFields.Add(watermill.LogFields{ - "not_sent_message": fmt.Sprintf("%#v", producedMessages), - })) - - return err - } + if err := h.publisher.Publish(h.publishTopic, producedMessages...); err != nil { + h.logger.Error("Cannot publish messages", err, msgFields.Add(watermill.LogFields{ + "not_sent_message": fmt.Sprintf("%#v", producedMessages), + })) + + return err } return nil