diff --git a/.github/workflows/bump-version.yaml b/.github/workflows/bump-version.yaml index b139c64..7d97a1f 100644 --- a/.github/workflows/bump-version.yaml +++ b/.github/workflows/bump-version.yaml @@ -1,161 +1,159 @@ name: Bump version on: - workflow_dispatch: - inputs: - version: - description: The new version of the interface package and crates, e.g. 0.1.0 or 0.2.0-dev - required: true - type: string - default: "0.3.0-dev" # the current version, kept current by scripts/bump-version.sh + workflow_dispatch: + inputs: + version: + description: The new version of the interface package and crates, e.g. 0.1.0 or 0.2.0-dev + required: true + type: string + default: "0.3.0-dev" # the current version, kept current by scripts/bump-version.sh jobs: - # bumps the version with read only access, the changes are handed to the pull-request job as a - # patch so the third party actions used to build never run with write access - bump: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false - - uses: actions-rust-lang/setup-rust-toolchain@v2 - - name: Install cargo binstall - uses: cargo-bins/cargo-binstall@main - - name: Install tools - run: | - make tools - make -s tools-path >> "${GITHUB_PATH}" - - name: Start Valkey service - run: docker run --rm --name valkey -d -p 6379:6379 valkey/valkey:8 - - name: Bump version - # also fetches the wit dependencies for the new version, and builds and tests the components - run: scripts/bump-version.sh "${VERSION}" - env: - VERSION: ${{ inputs.version }} - - name: Collect changes - run: | - git add --all - git diff --cached --binary > bump-version.patch - - name: Upload changes - uses: actions/upload-artifact@v7 - with: - name: bump-version.patch - path: bump-version.patch - if-no-files-found: error - retention-days: 1 + # bumps the version with read only access, the changes are handed to the pull-request job as a + # patch so the third party actions used to build never run with write access + bump: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions-rust-lang/setup-rust-toolchain@v2 + - name: Install cargo binstall + uses: cargo-bins/cargo-binstall@main + - name: Install tools + run: | + make tools + make -s tools-path >> "${GITHUB_PATH}" + - name: Bump version + # also fetches the wit dependencies for the new version, and builds and tests the components + run: scripts/bump-version.sh "${VERSION}" + env: + VERSION: ${{ inputs.version }} + - name: Collect changes + run: | + git add --all + git diff --cached --binary > bump-version.patch + - name: Upload changes + uses: actions/upload-artifact@v7 + with: + name: bump-version.patch + path: bump-version.patch + if-no-files-found: error + retention-days: 1 - # opens the pull request using only first party actions and the gh cli - pull-request: - needs: - - bump - runs-on: ubuntu-latest - # the branch and pull request are created with a token for the custodian GitHub App rather than - # the GITHUB_TOKEN, which can't change workflow files and doesn't trigger the CI workflow - permissions: - contents: read + # opens the pull request using only first party actions and the gh cli + pull-request: + needs: + - bump + runs-on: ubuntu-latest + # the branch and pull request are created with a token for the custodian GitHub App rather than + # the GITHUB_TOKEN, which can't change workflow files and doesn't trigger the CI workflow + permissions: + contents: read + env: + VERSION: ${{ inputs.version }} + steps: + - name: Check custodian app credentials + run: | + if [ -z "${CLIENT_ID}" ] || [ -z "${PRIVATE_KEY}" ] ; then + echo "::error::the CUSTODIAN_CLIENT_ID and CUSTODIAN_PRIVATE_KEY secrets must be available to this repository, the private key of the custodian GitHub App is needed to create a token" + exit 1 + fi + env: + CLIENT_ID: ${{ secrets.CUSTODIAN_CLIENT_ID }} + PRIVATE_KEY: ${{ secrets.CUSTODIAN_PRIVATE_KEY }} + - name: Create custodian app token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ secrets.CUSTODIAN_CLIENT_ID }} + private-key: ${{ secrets.CUSTODIAN_PRIVATE_KEY }} + # only this repository, with only the permissions the bump needs + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + # the bump changes the default version in this workflow + permission-workflows: write + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Download changes + uses: actions/download-artifact@v8 + with: + name: bump-version.patch + path: ${{ runner.temp }} + - name: Read current version + # the checkout is before the bump, the crates' workspace version is the current version + run: | + current=$( sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"$/\1/p' Cargo.toml ) + echo "CURRENT_VERSION=${current}" >> "${GITHUB_ENV}" + - name: Commit changes + # the commit is created with the REST API, as the app's token can't push. The patch is applied + # locally only to find the changed files and their modes. env: - VERSION: ${{ inputs.version }} - steps: - - name: Check custodian app credentials - run: | - if [ -z "${CLIENT_ID}" ] || [ -z "${PRIVATE_KEY}" ] ; then - echo "::error::the CUSTODIAN_CLIENT_ID and CUSTODIAN_PRIVATE_KEY secrets must be available to this repository, the private key of the custodian GitHub App is needed to create a token" - exit 1 - fi - env: - CLIENT_ID: ${{ secrets.CUSTODIAN_CLIENT_ID }} - PRIVATE_KEY: ${{ secrets.CUSTODIAN_PRIVATE_KEY }} - - name: Create custodian app token - id: app-token - uses: actions/create-github-app-token@v3 - with: - client-id: ${{ secrets.CUSTODIAN_CLIENT_ID }} - private-key: ${{ secrets.CUSTODIAN_PRIVATE_KEY }} - # only this repository, with only the permissions the bump needs - repositories: ${{ github.event.repository.name }} - permission-contents: write - permission-pull-requests: write - # the bump changes the default version in this workflow - permission-workflows: write - - uses: actions/checkout@v7 - with: - persist-credentials: false - - name: Download changes - uses: actions/download-artifact@v8 - with: - name: bump-version.patch - path: ${{ runner.temp }} - - name: Read current version - # the checkout is before the bump, the crates' workspace version is the current version - run: | - current=$( sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"$/\1/p' Cargo.toml ) - echo "CURRENT_VERSION=${current}" >> "${GITHUB_ENV}" - - name: Commit changes - # the commit is created with the REST API, as the app's token can't push. The patch is applied - # locally only to find the changed files and their modes. - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - APP_SLUG: ${{ steps.app-token.outputs.app-slug }} - run: | - branch="bump-version/${VERSION}" - api="repos/${GITHUB_REPOSITORY}" - base=$( git rev-parse HEAD ) - git apply --index "${RUNNER_TEMP}/bump-version.patch" + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + run: | + branch="bump-version/${VERSION}" + api="repos/${GITHUB_REPOSITORY}" + base=$( git rev-parse HEAD ) + git apply --index "${RUNNER_TEMP}/bump-version.patch" - # a blob for each changed file, or a null sha for a deleted file - entries="${RUNNER_TEMP}/tree-entries.json" - echo '[]' > "${entries}" - git diff --cached --no-renames --name-status "${base}" | while IFS=$'\t' read -r status path ; do - if [ "${status}" = "D" ] ; then - entry=$( jq -n --arg path "${path}" '{path: $path, mode: "100644", type: "blob", sha: null}' ) - else - mode=$( git ls-files --stage -- "${path}" | cut -d' ' -f1 ) - sha=$( base64 < "${path}" | tr -d '\n' | jq -Rs '{encoding: "base64", content: .}' | gh api --method POST "${api}/git/blobs" --input - --jq .sha ) - entry=$( jq -n --arg path "${path}" --arg mode "${mode}" --arg sha "${sha}" '{path: $path, mode: $mode, type: "blob", sha: $sha}' ) - fi - jq --argjson entry "${entry}" '. + [$entry]' "${entries}" > "${entries}.tmp" && mv "${entries}.tmp" "${entries}" - echo "${status} ${path}" - done - tree=$( jq --arg base "$( git rev-parse "${base}^{tree}" )" '{base_tree: $base, tree: .}' "${entries}" | gh api --method POST "${api}/git/trees" --input - --jq .sha ) + # a blob for each changed file, or a null sha for a deleted file + entries="${RUNNER_TEMP}/tree-entries.json" + echo '[]' > "${entries}" + git diff --cached --no-renames --name-status "${base}" | while IFS=$'\t' read -r status path ; do + if [ "${status}" = "D" ] ; then + entry=$( jq -n --arg path "${path}" '{path: $path, mode: "100644", type: "blob", sha: null}' ) + else + mode=$( git ls-files --stage -- "${path}" | cut -d' ' -f1 ) + sha=$( base64 < "${path}" | tr -d '\n' | jq -Rs '{encoding: "base64", content: .}' | gh api --method POST "${api}/git/blobs" --input - --jq .sha ) + entry=$( jq -n --arg path "${path}" --arg mode "${mode}" --arg sha "${sha}" '{path: $path, mode: $mode, type: "blob", sha: $sha}' ) + fi + jq --argjson entry "${entry}" '. + [$entry]' "${entries}" > "${entries}.tmp" && mv "${entries}.tmp" "${entries}" + echo "${status} ${path}" + done + tree=$( jq --arg base "$( git rev-parse "${base}^{tree}" )" '{base_tree: $base, tree: .}' "${entries}" | gh api --method POST "${api}/git/trees" --input - --jq .sha ) - # authored and signed off (DCO) by the user who triggered the workflow, with their GitHub - # noreply email so the commit is attributed to them without exposing their email address. - # Committed by the custodian app's bot, which made the commit on their behalf. The commit is - # unsigned, GitHub only signs commits it attributes entirely to the app. - name=$( gh api "users/${GITHUB_ACTOR}" --jq '.name // .login' ) - name="${name:-${GITHUB_ACTOR}}" - email="${GITHUB_ACTOR_ID}+${GITHUB_ACTOR}@users.noreply.github.com" - bot="${APP_SLUG}[bot]" - bot_email="$( gh api "users/${bot}" --jq .id )+${bot}@users.noreply.github.com" - commit=$( jq -n \ - --arg message "$( printf 'Bump version from %s to %s\n\nSigned-off-by: %s <%s>' "${CURRENT_VERSION}" "${VERSION}" "${name}" "${email}" )" \ - --arg tree "${tree}" --arg parent "${base}" --arg name "${name}" --arg email "${email}" \ - --arg bot "${bot}" --arg bot_email "${bot_email}" \ - '{message: $message, tree: $tree, parents: [$parent], author: {name: $name, email: $email}, committer: {name: $bot, email: $bot_email}}' \ - | gh api --method POST "${api}/git/commits" --input - --jq .sha ) - echo "created commit ${commit}" + # authored and signed off (DCO) by the user who triggered the workflow, with their GitHub + # noreply email so the commit is attributed to them without exposing their email address. + # Committed by the custodian app's bot, which made the commit on their behalf. The commit is + # unsigned, GitHub only signs commits it attributes entirely to the app. + name=$( gh api "users/${GITHUB_ACTOR}" --jq '.name // .login' ) + name="${name:-${GITHUB_ACTOR}}" + email="${GITHUB_ACTOR_ID}+${GITHUB_ACTOR}@users.noreply.github.com" + bot="${APP_SLUG}[bot]" + bot_email="$( gh api "users/${bot}" --jq .id )+${bot}@users.noreply.github.com" + commit=$( jq -n \ + --arg message "$( printf 'Bump version from %s to %s\n\nSigned-off-by: %s <%s>' "${CURRENT_VERSION}" "${VERSION}" "${name}" "${email}" )" \ + --arg tree "${tree}" --arg parent "${base}" --arg name "${name}" --arg email "${email}" \ + --arg bot "${bot}" --arg bot_email "${bot_email}" \ + '{message: $message, tree: $tree, parents: [$parent], author: {name: $name, email: $email}, committer: {name: $bot, email: $bot_email}}' \ + | gh api --method POST "${api}/git/commits" --input - --jq .sha ) + echo "created commit ${commit}" - # points the branch at the commit, replacing the branch left by an earlier run for the same version - if gh api "${api}/git/ref/heads/${branch}" --silent 2> /dev/null ; then - gh api --method PATCH "${api}/git/refs/heads/${branch}" -f sha="${commit}" -F force=true --silent - else - gh api --method POST "${api}/git/refs" -f ref="refs/heads/${branch}" -f sha="${commit}" --silent - fi - - name: Open pull request - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - branch="bump-version/${VERSION}" - if [ -n "$( gh pr list --head "${branch}" --state open --json number --jq '.[].number' )" ] ; then - echo "A pull request for ${branch} is already open, updated by the new commit" - exit 0 - fi - gh pr create \ - --base "${GITHUB_REF_NAME}" \ - --head "${branch}" \ - --title "Bump version from \`${CURRENT_VERSION}\` to \`${VERSION}\`" \ - --body "Bumps the wit package and crates from \`${CURRENT_VERSION}\` to \`${VERSION}\`. + # points the branch at the commit, replacing the branch left by an earlier run for the same version + if gh api "${api}/git/ref/heads/${branch}" --silent 2> /dev/null ; then + gh api --method PATCH "${api}/git/refs/heads/${branch}" -f sha="${commit}" -F force=true --silent + else + gh api --method POST "${api}/git/refs" -f ref="refs/heads/${branch}" -f sha="${commit}" --silent + fi + - name: Open pull request + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + branch="bump-version/${VERSION}" + if [ -n "$( gh pr list --head "${branch}" --state open --json number --jq '.[].number' )" ] ; then + echo "A pull request for ${branch} is already open, updated by the new commit" + exit 0 + fi + gh pr create \ + --base "${GITHUB_REF_NAME}" \ + --head "${branch}" \ + --title "Bump version from \`${CURRENT_VERSION}\` to \`${VERSION}\`" \ + --body "Bumps the wit package and crates from \`${CURRENT_VERSION}\` to \`${VERSION}\`. - Triggered by @${GITHUB_ACTOR} from the [Bump version](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) workflow." + Triggered by @${GITHUB_ACTOR} from the [Bump version](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) workflow." diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 21bf394..a84a9a1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,10 +38,10 @@ jobs: name: components.tar path: components.tar retention-days: 7 - - name: Start Valkey service - run: docker run --rm --name valkey -d -p 6379:6379 valkey/valkey:8 - name: Test run: make test + - name: Test against Valkey + run: make test-valkey - name: Capture WIT working-directory: ./target/components run: | diff --git a/Cargo.lock b/Cargo.lock index 1a626fc..a88cd69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,126 +3,496 @@ version = 4 [[package]] -name = "anstream" -version = "1.0.0" +name = "addr2line" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", + "gimli", ] [[package]] -name = "anstyle" -version = "1.0.14" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] -name = "anstyle-parse" -version = "1.0.0" +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" + +[[package]] +name = "as-keyvalue" +version = "0.1.0" dependencies = [ - "utf8parse", + "test-harness", + "tokio", + "wasmtime", + "wit-bindgen", ] [[package]] -name = "anstyle-query" -version = "1.1.2" +name = "async-trait" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ - "windows-sys", + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] -name = "anstyle-wincon" -version = "3.0.7" +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "anstyle", - "once_cell", - "windows-sys", + "generic-array", ] [[package]] -name = "anyhow" -version = "1.0.102" +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "bytes" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] -name = "as-keyvalue" +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "commands" version = "0.1.0" dependencies = [ + "componentized-valkey", + "test-harness", + "tokio", + "wasmtime", "wit-bindgen", ] [[package]] -name = "bitflags" -version = "2.11.1" +name = "componentized-valkey" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "connection" +version = "0.3.0-dev" +dependencies = [ + "componentized-valkey", + "futures-channel", + "futures-core", + "test-harness", + "tokio", + "wasmtime", + "wit-bindgen", +] + +[[package]] +name = "connection-minimal" +version = "0.3.0-dev" +dependencies = [ + "componentized-valkey", + "futures-channel", + "futures-core", + "wit-bindgen", +] + +[[package]] +name = "core_detect" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpp_demangle" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-assembler-x64" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658e8623bea025d142fb11c299b8c6572ffe9fd663b52887c4dc124735cc130b" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4b12b8c55c31c3d8598c2de9a657c3b978f905f6886c43e02216e0b15fe45a3" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4710c34489bc844e45c1c0005d3e44d3d2b29cee77187507ba8d0bb400c621f" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] [[package]] -name = "clap" -version = "4.6.7" +name = "cranelift-bitset" +version = "0.136.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +checksum = "76b63a4b0cfea80610150fffda11b1a9c3dc5a835403d9f381bf7d1a2874b4d7" dependencies = [ - "clap_builder", - "clap_derive", + "serde", + "serde_derive", + "wasmtime-internal-core", ] [[package]] -name = "clap_builder" -version = "4.6.7" +name = "cranelift-codegen" +version = "0.136.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +checksum = "e1af881f3b3392433e6f4f9b172b8d99270c538db4effea62b28bc48f02f0833" dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.0", + "libm", + "log", + "postcard", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", ] [[package]] -name = "clap_derive" -version = "4.6.7" +name = "cranelift-codegen-meta" +version = "0.136.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +checksum = "24ce99c30dc7a692294613241119eea0de3c3cb4cf10ac2570ed72adf9a8c2eb" dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df315a8b1cfd6e620971e3d4249bd2c4528167717e8f48a6317a87c55891bcb8" + +[[package]] +name = "cranelift-control" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3fff7610fb17f228f16598e1a044db79a12806ece7496ff2c69cf1a27a79a5" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e73c65538d460b1062cb9a97428ee6bb4b616f5cb17be3b3ac0a3e4f1f789166" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ed70ab5849184973c42b57366f57611dc81c3e355d5b612c68a2900bea92242" +dependencies = [ + "cranelift-codegen", + "hashbrown 0.17.0", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "379f10a451c9b1bf4f59e4ce38061c53ca2454783041a3c1fafa6deef3c37108" + +[[package]] +name = "cranelift-native" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f496865b1a14bc85bcc9fb51a9539ca93b1d1f44fb92bd6a091fba02d6ab4a7" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4944ef05513e1c1a85a945cf8f29d95ddea8bcbb19e0bfd4e71063450967420" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ "proc-macro2", "quote", "syn 3.0.3", ] [[package]] -name = "clap_lex" -version = "1.0.0" +name = "either" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] -name = "colorchoice" -version = "1.0.3" +name = "embedded-io" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" [[package]] -name = "dep-cli" -version = "0.1.0" +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ - "clap", - "wit-bindgen", + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -131,12 +501,66 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "futures" version = "0.3.34" @@ -191,273 +615,1637 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] [[package]] -name = "hashbrown" -version = "0.17.0" +name = "fxprof-processed-profile" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" dependencies = [ - "foldhash", + "bitflags", + "debugid", + "rustc-hash", + "serde", + "serde_derive", + "serde_json", ] [[package]] -name = "heck" -version = "0.5.0" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] [[package]] -name = "id-arena" -version = "2.3.0" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "internal-cli" +version = "0.1.0" +dependencies = [ + "componentized-valkey", + "futures-channel", + "futures-core", + "wit-bindgen", +] + +[[package]] +name = "internal-tcp-connector-egress-config" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "internal-tcp-connector-lookup-config" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memfd" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +dependencies = [ + "rustix", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "crc32fast", + "hashbrown 0.17.0", + "indexmap", + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6924ced06e1f7dfe3fa48d57b9f74f55d8915f5036121bef647ef4b204895fac" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulley-interpreter" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0091857bc410bd7117eb6ac042ced7714c69f994a8b9c2ae30add2f73e3a25ac" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8798ebc6bf0ef227e204fa3fad05b04970599a85f456fd28365450fd679b2600" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regalloc2" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.0", + "log", + "rustc-hash", + "serde", + "smallvec", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" + +[[package]] +name = "sample-http-incrementor" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.139" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tcp" +version = "0.1.0" +dependencies = [ + "componentized-valkey", + "futures-channel", + "futures-core", + "test-harness", + "tokio", + "wasmtime", + "wit-bindgen", +] + +[[package]] +name = "tcp-connector" +version = "0.1.0" +dependencies = [ + "componentized-valkey", + "test-harness", + "tokio", + "wasmtime", + "wit-bindgen", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "test-commands-suite" +version = "0.3.0-dev" +dependencies = [ + "componentized-valkey", + "wit-bindgen", +] + +[[package]] +name = "test-harness" +version = "0.3.0-dev" +dependencies = [ + "componentized-valkey", + "tokio", + "wasmtime", + "wasmtime-wasi", +] + +[[package]] +name = "test-stub-sockets" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "test-tcp-connector-latch-suite" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "test-tcp-connector-latch-tests" +version = "0.1.0" +dependencies = [ + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.3", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-compose" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd08e5283f98238f2f16f5f978188a33af9901b81186bb549faad306a7b5388" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "log", + "petgraph", + "smallvec", + "wasm-encoder 0.258.0", + "wasmparser 0.258.0", + "wat", +] + +[[package]] +name = "wasm-encoder" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e974fe6821a8cf64575d51ea2194e2c8f77e7b66e9afe7419ce8a97f9ee0d251" +dependencies = [ + "leb128fmt", + "wasmparser 0.258.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22" +dependencies = [ + "leb128fmt", + "wasmparser 0.261.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a11585adb92fe9b55ad1d760e8d8fb5d87e0d2e303cb8eed57f078d54293a2" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.258.0", + "wasmparser 0.258.0", +] + +[[package]] +name = "wasmparser" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a61719f93a87b16d325921e251800c4833f8fab50fa21c7de73aed50086313" +dependencies = [ + "bitflags", + "hashbrown 0.17.0", + "indexmap", + "semver", + "serde", +] [[package]] -name = "indexmap" -version = "2.14.0" +name = "wasmparser" +version = "0.261.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8" dependencies = [ - "equivalent", - "hashbrown", - "serde", - "serde_core", + "bitflags", + "indexmap", + "semver", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.1" +name = "wasmprinter" +version = "0.258.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "3a65e30fb2cd3cc5cb7761a6064787b2abf017e6566d8d0dedf2bb7f55ad5383" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.258.0", +] [[package]] -name = "itoa" -version = "1.0.14" +name = "wasmtime" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +checksum = "942ddd2fc5800ec01d4b11c28ad62ab00517dcd416aff728f7d73fddf75872c1" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "bytes", + "cc", + "encoding_rs", + "futures", + "fxprof-processed-profile", + "gimli", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon", + "tempfile", + "wasm-compose", + "wasm-encoder 0.258.0", + "wasmparser 0.258.0", + "wasmtime-environ", + "wasmtime-internal-cache", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wat", + "windows-sys 0.61.2", + "wit-parser", +] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "wasmtime-environ" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "98f2ca21b23a51c1b1944297e9671a628c368e92aa6250e4c38f48e2dc251f0c" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.0", + "indexmap", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasm-encoder 0.258.0", + "wasmparser 0.258.0", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] [[package]] -name = "log" -version = "0.4.26" +name = "wasmtime-internal-cache" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +checksum = "76c83428ffb93757881522619aef1f6571a235c25ea5e9e6b67aa6a2324da72a" +dependencies = [ + "base64", + "directories-next", + "log", + "postcard", + "rustix", + "serde", + "serde_derive", + "sha2", + "toml", + "wasmtime-environ", + "windows-sys 0.61.2", + "zstd", +] [[package]] -name = "macro-string" -version = "0.2.0" +name = "wasmtime-internal-component-macro" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +checksum = "92c42f95d879875994319bd6689b17570e68aebd1a17e0dc54430928ed0699b5" dependencies = [ + "anyhow", "proc-macro2", "quote", "syn 2.0.117", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", ] [[package]] -name = "memchr" -version = "2.7.4" +name = "wasmtime-internal-component-util" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "294c7f12f062ceb148cd26c77170ff739552db76d1f203f6127f249684988a70" [[package]] -name = "once_cell" -version = "1.20.3" +name = "wasmtime-internal-core" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" - -[[package]] -name = "ops" -version = "0.1.0" +checksum = "64b67114739063bb72bb8ed2b4994b8b22809d354432db68553ad529850f9536" dependencies = [ - "wit-bindgen", + "anyhow", + "hashbrown 0.17.0", + "libm", + "serde", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "wasmtime-internal-cranelift" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "b7d19868a30cc20df6f6648c9a4b3f700fcd1ddd0a63af9a98cb75f2b58dc874" +dependencies = [ + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.21", + "wasmparser 0.258.0", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] [[package]] -name = "prettyplease" -version = "0.2.29" +name = "wasmtime-internal-fiber" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6924ced06e1f7dfe3fa48d57b9f74f55d8915f5036121bef647ef4b204895fac" +checksum = "4f1999aee268c3f625666b12ba0ded55a6ac2c800b60b426af7d164d63411908" dependencies = [ - "proc-macro2", - "syn 2.0.117", + "cc", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "wasmtime-internal-jit-debug" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "33ca2499301c6f11cc9eb42c2beafc8efae396ae4cb91077e3ac080dc5d765e4" dependencies = [ - "unicode-ident", + "cc", + "object", + "rustix", + "wasmtime-internal-versioned-export-macros", ] [[package]] -name = "quote" -version = "1.0.45" +name = "wasmtime-internal-jit-icache-coherence" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "7162d829c56265e183d23b17d67afc5c6876433daaed54c137388a8078a1bb6b" dependencies = [ - "proc-macro2", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", ] [[package]] -name = "ryu" -version = "1.0.19" +name = "wasmtime-internal-unwinder" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" +checksum = "4e167cf144f3cee40838f7663e3a589f93bba65151efa084a2d58b2bd47469f1" +dependencies = [ + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] [[package]] -name = "sample-http-incrementor" -version = "0.1.0" +name = "wasmtime-internal-versioned-export-macros" +version = "49.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d09e038ad69c741dae29da4606ccfc0df66f156e62cb7f0664c8d7d1272a36d" dependencies = [ - "wit-bindgen", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "semver" -version = "1.0.25" +name = "wasmtime-internal-wit-bindgen" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79dfe2d285b0488816f30e700a7438c5a73d816b5b7d3ac72fbc48b0d185e03" +checksum = "c63d97a742fc9b415dad7ed2ac33ddb8fe6c5413fcd0c7ee58907f2ab6f9a430" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap", + "wit-component", + "wit-parser", +] [[package]] -name = "serde" -version = "1.0.228" +name = "wasmtime-wasi" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "ce11727cb331abeaace3681a4b072e4fe04818d7b99786d183b318671f9e4e0e" dependencies = [ - "serde_core", + "async-trait", + "bitflags", + "bytes", + "cap-primitives", + "futures", + "rand", + "rustix", + "rustix-linux-procfs", + "thiserror 2.0.21", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.61.2", + "winx", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "wasmtime-wasi-io" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "35f5ed8d710d8daf71cb4742c6b90c1fd121acb62b6848556eb4e0661cbd28bd" dependencies = [ - "serde_derive", + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "wast" +version = "35.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "leb128", ] [[package]] -name = "serde_json" -version = "1.0.139" +name = "wast" +version = "261.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6" +checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a" dependencies = [ - "itoa", + "bumpalo", + "leb128fmt", "memchr", - "ryu", - "serde", + "unicode-width", + "wasm-encoder 0.261.0", ] [[package]] -name = "slab" -version = "0.4.12" +name = "wat" +version = "1.261.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f" +dependencies = [ + "wast 261.0.0", +] [[package]] -name = "strsim" -version = "0.11.1" +name = "wiggle" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "3dfb96da9ecf96621fee71a01916ca99038cc8f212cd19a8923fb32c36060477" +dependencies = [ + "bitflags", + "thiserror 2.0.21", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle-macro", +] [[package]] -name = "syn" -version = "2.0.117" +name = "wiggle-generate" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "ba7cbdab1c5fa79f98a96c3b0b02b81f83585a4cdc3ce1d2744e6cfa2aef333b" dependencies = [ + "heck", "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.117", + "wasmtime-environ", + "witx", ] [[package]] -name = "syn" -version = "3.0.3" +name = "wiggle-macro" +version = "49.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "daa97f5b7ad6ace3254b6bec1f82ef53f7c49c4cf5f117fad4171acc15fd6ff0" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.117", + "wiggle-generate", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "winapi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] [[package]] -name = "utf8parse" -version = "0.2.2" +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] -name = "wasm-encoder" -version = "0.258.0" +name = "winapi-util" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e974fe6821a8cf64575d51ea2194e2c8f77e7b66e9afe7419ce8a97f9ee0d251" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "leb128fmt", - "wasmparser", + "windows-sys 0.59.0", ] [[package]] -name = "wasm-metadata" -version = "0.258.0" +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a11585adb92fe9b55ad1d760e8d8fb5d87e0d2e303cb8eed57f078d54293a2" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "wasmparser" -version = "0.258.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a61719f93a87b16d325921e251800c4833f8fab50fa21c7de73aed50086313" -dependencies = [ - "bitflags", - "hashbrown", - "indexmap", - "semver", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-sys" @@ -468,6 +2256,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -532,6 +2329,22 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.61.1" @@ -599,9 +2412,9 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.258.0", "wasm-metadata", - "wasmparser", + "wasmparser 0.258.0", "wit-parser", ] @@ -612,7 +2425,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff4daaa3cd97ae49ecd0a99dc009d453f93e0f083dd3be38c0f24a83a93e37ac" dependencies = [ "anyhow", - "hashbrown", + "hashbrown 0.17.0", "id-arena", "indexmap", "log", @@ -621,5 +2434,128 @@ dependencies = [ "serde_derive", "serde_json", "unicode-ident", - "wasmparser", + "wasmparser 0.258.0", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.1+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078" +dependencies = [ + "cc", + "pkg-config", ] diff --git a/Cargo.toml b/Cargo.toml index 56cd6f2..a1ca8ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,15 +2,34 @@ resolver = "2" members = [ "components/as-keyvalue", - "components/dep-cli", - "components/ops", + "components/commands", + "components/connection", + "components/connection-minimal", + "components/internal-cli", + "components/test-commands-suite", + "components/test-stub-sockets", + "components/test-tcp-connector-latch", + "components/test-tcp-connector-latch-suite", "components/sample-http-incrementor", + "components/tcp", + "components/tcp-connector", + "components/internal-tcp-connector-egress-config", + "components/internal-tcp-connector-lookup-config", + "crates/componentized-valkey", + "crates/test-harness", ] [workspace.package] version = "0.3.0-dev" [workspace.dependencies] +futures-channel = "0.3" +futures-core = "0.3" +componentized-valkey = { path = "./crates/componentized-valkey" } +test-harness = { path = "./crates/test-harness" } +tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "time"] } +wasmtime = { version = "49", features = ["component-model-async"] } +wasmtime-wasi = { version = "49", features = ["p3"] } wit-bindgen = "0.61.0" [profile.release] @@ -18,3 +37,12 @@ opt-level = "s" lto = true codegen-units = 1 strip = "debuginfo" + +[profile.component-debug] +inherits = "dev" +opt-level = 1 # most of the size win; locals mostly still visible +lto = true +codegen-units = 1 +incremental = false # no benefit with one codegen unit +debug = "full" # variables and types for lldb, file:line in backtraces +# kept from dev: debug-assertions, overflow-checks, no strip diff --git a/Makefile b/Makefile index 17b8b96..2ce84ee 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,8 @@ SHELL := /bin/bash export RUST_BACKTRACE ?= 1 export WASMTIME_BACKTRACE_DETAILS ?= 1 export WKG_CONFIG_FILE := $(abspath .config/wasm-pkg/config.toml) -WASMTIME_RUN_FLAGS ?= -Sinherit-network -Sallow-ip-name-lookup +# the sockets gates of the `cli` need `implements` and `threading` +WASMTIME_RUN_FLAGS ?= -Sinherit-network -Sallow-ip-name-lookup -Sconfig -Wcomponent-model-map -Wcomponent-model-implements -Wcomponent-model-threading # `make` without a goal runs all, otherwise the first target of an included file would run .DEFAULT_GOAL := all @@ -26,12 +27,31 @@ clean: clean-components clean-wit clean-all: clean-components clean-tools clean-wit cargo clean +# runs the cli with the given arguments, e.g. `$(call run_cli,ping)` +run_cli = $(WASMTIME) run $(WASMTIME_RUN_FLAGS) ${COMPONENTS_DIR}/cli/cli.debug.wasm $(1) .PHONY: run ## Run the cli against a Valkey server, e.g. `make run cmd="keys '*'"` run: ${COMPONENTS_DIR}/cli/cli.debug.wasm | $(call tool,wasmtime-cli) - @$(WASMTIME) run $(WASMTIME_RUN_FLAGS) ${COMPONENTS_DIR}/cli/cli.debug.wasm $(cmd) + @$(call run_cli,$(cmd)) + +# the Valkey server of `make test-valkey`, a container removed afterwards, e.g. `VALKEY_VERSION=8.1` +VALKEY_IMAGE ?= valkey/valkey +VALKEY_VERSION ?= 9.1 + +# the tests that need a Valkey server, ignored tests in `with_valkey` modules, other ignored tests +# stay ignored. The server runs in a new container, on a free port, so it starts empty. +.PHONY: test-valkey ## Run the tests against a Valkey server in a container, `VALKEY_VERSION` picks the version +test-valkey: components | $(call tool,wasmtime-cli) + @set -e; \ + container=$$(docker run --detach --rm --publish 127.0.0.1::6379 $(VALKEY_IMAGE):$(VALKEY_VERSION)); \ + trap 'docker stop $$container > /dev/null' EXIT; \ + port=$$(docker port $$container 6379/tcp | head -n 1 | sed 's/.*://'); \ + until docker exec $$container valkey-cli ping > /dev/null 2>&1; do sleep 0.1; done; \ + echo "Valkey $$(docker exec $$container valkey-server --version | sed 's/.* v=\([^ ]*\).*/\1/') on 127.0.0.1:$$port"; \ + VALKEY_HOST=127.0.0.1 VALKEY_PORT=$$port cargo test --workspace --no-fail-fast -- --ignored with_valkey::; \ + $(WASMTIME) run $(WASMTIME_RUN_FLAGS) -Sconfig-var=port=$$port ${COMPONENTS_DIR}/cli/cli.debug.wasm ping; \ + $(WASMTIME) run $(WASMTIME_RUN_FLAGS) -Sconfig-var=hostname=localhost -Sconfig-var=port=$$port ${COMPONENTS_DIR}/cli/cli.debug.wasm ping -# requires a running Valkey server .PHONY: test test: components - @$(MAKE) run cmd="hello" + cargo test --workspace diff --git a/NOTICE b/NOTICE index 5397c13..09279a4 100644 --- a/NOTICE +++ b/NOTICE @@ -2,5 +2,36 @@ Componentized valkey Copyright 2025 the original author or authors. This product may include a number of subcomponents with separate copyright notices and license terms. -Your use of these subcomponents is subject to the terms and conditions of the subcomponent's license, -as noted in the LICENSE file. +Your use of these subcomponents is subject to the terms and conditions of the subcomponent's license. + +------------------------------------------------------------------------------- + +This product includes a RESP codec adapted from resp v1.0.3 +(https://github.com/iorust/resp/tree/v1.0.3), in crates/componentized-valkey/src/resp.rs, +used under the MIT license: + +Copyright (c) 2016-2022 IORust Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index fb42323..94abd01 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,10 @@ A WASM component client for Valkey (and Redis). +- [Components](#components) +- [Network access](#network-access) - [Build](#build) +- [Test](#test) - [Run](#run) - [Samples](#samples) - [Community](#community) @@ -12,6 +15,33 @@ A WASM component client for Valkey (and Redis). - [Acknowledgements](#acknowledgements) - [License](#license) +## Components + +The `componentized:valkey` WIT package, in [`wit`](./wit), separates the connection to a server from the transport carrying it, and from the commands sent over it. Connections always speak RESP3. + +| Component | | +| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`connection`](./components/connection/) | the mechanics of a connection: pairs commands with replies, keeps push messages apart, establishes it with `HELLO 3`, optionally keeps it alive | +| [`tcp`](./components/tcp/) | connections over wasi:sockets TCP | +| [`tcp-connector`](./components/tcp-connector/) | connections over TCP to the server configured with wasi:config, for components that need a connection without a say in how it is made | +| [`tcp-connector-constants`](./components/tcp-connector-constants/) | the keys of the `tcp-connector` config and their defaults, for components that derive their config from it | +| [`tcp-connector-latch`](./components/tcp-connector-latch/) | a componentized:sockets latch, for a gate, limiting sockets to the server in the `tcp-connector` config | +| [`commands`](./components/commands/) | the commands, an interface for each of Valkey's ACL categories, e.g. `hash-commands`, each also available as a component of its own, e.g. [`hash-commands`](./components/hash-commands/) | +| [`as-keyvalue`](./components/as-keyvalue/) | wasmcloud:keyvalue, backed by Valkey hashes | +| [`client`](./components/client/) | everything above, composed into one component | +| [`cli`](./components/cli/) | a command line client, behind a gate limiting it to the server it is configured for | + +A composition picks the transport, e.g. `tcp` and `tcp-connector`, other transports can provide connections to the same commands. The [`*.wac`](./components/client/client.wac) files show how the components are composed. + +## Network access + +The network access of a client can be limited with a [componentized:sockets](https://github.com/componentized/sockets) gate. A gate imports `wasi:sockets` and exports it to the components it is put in front of, asking a latch before each socket operation, and denying the operation if the latch does. + +A critical aspect of gates and latches is how they protect a slice of the composed component. Behavior is selectively applied to parts of an application by wrapping that portion of the composition with a gate. For example, network access to a Valkey server can be segregated from network access to other services. Host based controls apply equally to all parts of the component. A gate can never increase privilege, only restrict otherwise granted capabilities. + +The [`tcp-connector-latch`](./components/tcp-connector-latch/) allows normal access to the Valkey server in the `tcp-connector` config, and denies every other socket. Suppose a client connects to a malicious server that is able to trigger a remote code execution within the client. The gate prevents the client from communicating with any other network resources, dramatically limiting the blast radius of exploitation. The [`cli`](./components/cli/) is always behind a gate with this latch. + +Many other latches are available in the [`componentized/sockets`](https://github.com/componentized/sockets) repo, or a custom latch can be implemented by exporting the `componentized:sockets/latch` interface. ## Build @@ -20,6 +50,7 @@ A [dev container](https://containers.dev) is available that contains the necessa Prereqs: - a rust toolchain - [`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall), optional, to download prebuilt tools instead of building them +- Docker, to run the integration tests `make test-valkey` ```sh make components @@ -27,7 +58,29 @@ make components The build creates each component in [`components`](./components) into `target/components`, e.g. the cli at `target/components/cli/cli.wasm`, along with `target/components/interface.wasm`, the `componentized:valkey` WIT package. Each component is also built with debug info, e.g. `target/components/cli/cli.debug.wasm`. -The cli tools the build uses, [`wasm-tools`](https://github.com/bytecodealliance/wasm-tools), [`wac`](https://github.com/bytecodealliance/wac), [`wasmtime`](https://github.com/bytecodealliance/wasmtime) and [`wkg`](https://github.com/bytecodealliance/wasm-pkg-tools), are pinned in [`tools/Cargo.toml`](./tools/Cargo.toml) and installed into `target/tools/`, e.g. `target/tools/aarch64-apple-darwin`, as needed, or ahead of time with `make tools`. Dependabot bumps the pinned versions. +Every component is published, except those with these prefixes: + +| Prefix | | +| ----------- | ---------------------------------------------------------------------------------- | +| `dep-` | pulled from elsewhere, named for its source, e.g. `dep-componentized-sockets-gate` | +| `internal-` | built here, for use by other components, e.g. `internal-cli` composed into `cli` | +| `test-` | tests, e.g. `test-commands` | + +The cli tools the build uses, [`constants`](https://github.com/componentized/constants), [`wasm-tools`](https://github.com/bytecodealliance/wasm-tools), [`wac`](https://github.com/bytecodealliance/wac), [`wasmtime`](https://github.com/bytecodealliance/wasmtime) and [`wkg`](https://github.com/bytecodealliance/wasm-pkg-tools), are pinned in [`tools/Cargo.toml`](./tools/Cargo.toml) and installed into `target/tools/`, e.g. `target/tools/aarch64-apple-darwin`, as needed, or ahead of time with `make tools`. Dependabot bumps the pinned versions. + +## Test + +```sh +make test +``` + +Tests the components without a Valkey server, playing the server themselves. The tests against a Valkey server, ignored by `cargo test`, run with: + +```sh +make test-valkey +``` + +It runs Valkey in a new container, on a free port, removed afterwards. `VALKEY_VERSION` picks the version, e.g. `make test-valkey VALKEY_VERSION=8.1`. ## Run @@ -37,12 +90,14 @@ Prereqs: - access to a running [Valkey](https://valkey.io) server ```sh -wasmtime run -Sinherit-network -Sallow-ip-name-lookup target/components/cli/cli.wasm keys '*' +wasmtime run -Sinherit-network -Sallow-ip-name-lookup -Sconfig -Wcomponent-model-map -Wcomponent-model-implements -Wcomponent-model-threading \ + target/components/cli/cli.wasm keys '*' ``` -- use `--host` to specify a host other than `127.0.0.1` -- use `--port` to specify a port other than `6379` -- `-Sallow-ip-name-lookup` is only required if a hostname is used for the connection instead of an IP address. +The arguments are sent as a command and the reply is printed. A command without a reply, like `SUBSCRIBE`, prints the push messages that arrive until interrupted or the connection closes. + +- the server is configured with wasi:config variables, e.g. `-Sconfig-var=hostname=valkey.example.com -Sconfig-var=port=6380`, see [`tcp-connector`](./components/tcp-connector/) for the keys and their defaults +- `-Sallow-ip-name-lookup` is required, the `tcp` component looks up the `hostname`, an IP address too, which wasi:sockets returns as is To aid incremental development a make target is available to rebuild and run the CLI: diff --git a/components/as-keyvalue/Cargo.toml b/components/as-keyvalue/Cargo.toml index 65e84e2..c326a37 100644 --- a/components/as-keyvalue/Cargo.toml +++ b/components/as-keyvalue/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "as-keyvalue" version = "0.1.0" -edition = "2021" +edition = "2024" license = "Apache-2.0" [lib] @@ -9,3 +9,8 @@ crate-type = ["cdylib"] [dependencies] wit-bindgen = { workspace = true } + +[dev-dependencies] +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/as-keyvalue/README.md b/components/as-keyvalue/README.md index 7c5671f..d2b9576 100644 --- a/components/as-keyvalue/README.md +++ b/components/as-keyvalue/README.md @@ -1,6 +1,19 @@ # as-keyvalue -Adapter to back wasmcloud:keyvalue with the componentized:valkey client. +Adapter to back `wasmcloud:keyvalue` with the `componentized:valkey` client. > [!NOTE] > `wasi:keyvalue` is currently unsupportable with an async based client. It will be restored when either it updates it's own API to be async, or the component model is fully able to make async calls from sync functions. + +Each bucket is a Valkey hash, its keys are the hash's fields. The hash is named for the bucket's identifier, after an optional prefix: + +| Key | Default | | +| --- | --- | --- | +| `key-prefix` | | prefix of the hash for each bucket, e.g. `app:` stores the bucket `users` in the hash `app:users` | + +Opening a bucket establishes a new connection with the imported `connector`, which decides the transport and the server, e.g. [`tcp-connector`](../tcp-connector/) with its own configuration. A connector that fails to connect makes the store unavailable. [`client`](../client/) composes it with `tcp-connector`. + +- values are stored as strings, a value must be UTF-8 +- a ttl expires the key with `HEXPIRE`, which needs Valkey 9 or later and has whole second precision, a ttl is rounded up to whole seconds +- keys are listed with `HSCAN`, a prefix matches literally +- compare and swap, `wasmcloud:keyvalue/cas`, is not supported diff --git a/components/as-keyvalue/src/lib.rs b/components/as-keyvalue/src/lib.rs index 31966c0..e56f951 100644 --- a/components/as-keyvalue/src/lib.rs +++ b/components/as-keyvalue/src/lib.rs @@ -1,6 +1,6 @@ -#![no_main] - -use componentized::valkey::store::{self as valkey, Connection, Duration, HelloOpts, HscanOpts}; +use componentized::valkey::commands::{self, Cursor, DurationMilliseconds, StringOrBytes}; +use componentized::valkey::connection::{Connection, ErrorCode as ValkeyError}; +use componentized::valkey::connector; use exports::wasmcloud::keyvalue::atomics::Guest as AtomicsGuest; use exports::wasmcloud::keyvalue::batch::Guest as BatchGuest; use exports::wasmcloud::keyvalue::store::Guest as StoreGuest; @@ -9,13 +9,6 @@ use exports::wasmcloud::keyvalue::types::{ }; use wasi::config::store::{self as config}; -const HOSTNAME_KEY: &str = "hostname"; -const HOSTNAME_DEFAULT: &str = "127.0.0.1"; -const PORT_KEY: &str = "port"; -const PORT_DEFAULT: &str = "6379"; -const USERNAME_KEY: &str = "username"; -const USERNAME_DEFAULT: &str = "default"; -const PASSWORD_KEY: &str = "password"; const KEY_PREFIX_KEY: &str = "key-prefix"; const KEY_PREFIX_DEFAULT: &str = ""; @@ -28,25 +21,8 @@ impl TypesGuest for AsKeyvalue { impl StoreGuest for AsKeyvalue { async fn open(identifier: String) -> Result { - let hostname: String = config::get(HOSTNAME_KEY)?.unwrap_or(HOSTNAME_DEFAULT.to_string()); - let port = config::get(PORT_KEY)?.unwrap_or(PORT_DEFAULT.to_string()); - let port: u16 = port - .parse() - .map_err(|_| Error::Other(String::from("port must be an integer")))?; - - let opts = HelloOpts { - proto_ver: Some("3".to_string()), - auth: match config::get(PASSWORD_KEY)? { - Some(password) => { - let username: String = - config::get(USERNAME_KEY)?.unwrap_or(USERNAME_DEFAULT.to_string()); - Some((username, password)) - } - None => None, - }, - client_name: None, - }; - let connection = valkey::connect(hostname, port, Some(opts)) + // the connector decides the transport and the server, e.g. from its own configuration + let connection = connector::connect() .await .map_err(|_| Error::StoreUnavailable)?; @@ -67,10 +43,11 @@ struct KeyvalueToValkeyBucket { impl GuestBucket for KeyvalueToValkeyBucket { async fn get(&self, key: String) -> Result>, Error> { - match self.connection.hget(self.hash_key.clone(), key).await? { - Some(value) => Ok(Some(value.into_bytes())), - None => Ok(None), - } + let value = commands::hget(&self.connection, self.hash_key.clone(), key).await?; + Ok(value.map(|value| match value { + StringOrBytes::String(value) => value.into_bytes(), + StringOrBytes::Bytes(value) => value, + })) } async fn set( @@ -79,41 +56,48 @@ impl GuestBucket for KeyvalueToValkeyBucket { value: Vec, options: Option, ) -> Result<(), Error> { - let value = String::from_utf8(value).map_err(|e| Error::Other(e.to_string()))?; + let value = StringOrBytes::Bytes(value); let options = options.unwrap_or(SetOptions { ttl_ms: None, if_not_exists: false, }); if options.if_not_exists { - match self - .connection - .hsetnx(self.hash_key.clone(), key.clone(), value) + match commands::hsetnx(&self.connection, self.hash_key.clone(), key.clone(), value) .await? { true => (), false => Err(Error::PreconditionFailed)?, } } else { - self.connection - .hset(self.hash_key.clone(), key.clone(), value) - .await? + commands::hset( + &self.connection, + self.hash_key.clone(), + vec![(key.clone(), value)], + ) + .await?; } if let Some(ttl_ms) = options.ttl_ms { - let ttl: Duration = ttl_ms / 1000; - self.connection - .hexpire(self.hash_key.clone(), ttl, None, vec![key]) - .await?; + let ttl = DurationMilliseconds { px: ttl_ms }; + commands::hpexpire( + &self.connection, + self.hash_key.clone(), + ttl, + None, + vec![key], + ) + .await?; } Ok(()) } async fn delete(&self, key: String) -> Result<(), Error> { - Ok(self.connection.hdel(self.hash_key.clone(), key).await?) + commands::hdel(&self.connection, self.hash_key.clone(), vec![key]).await?; + Ok(()) } async fn exists(&self, key: String) -> Result { - Ok(self.connection.hexists(self.hash_key.clone(), key).await?) + Ok(commands::hexists(&self.connection, self.hash_key.clone(), key).await?) } async fn list_keys( @@ -121,20 +105,26 @@ impl GuestBucket for KeyvalueToValkeyBucket { prefix: Option, cursor: Option, ) -> Result { - let opts = HscanOpts { - match_: prefix.map(|prefix| format!("{}*", escape_glob(&prefix))), - count: None, - no_values: Some(true), - }; - let (cursor, fields) = self - .connection - .hscan(self.hash_key.clone(), cursor, Some(opts)) - .await?; - - Ok(KeyResponse { - keys: fields.into_iter().map(|(key, _)| key).collect(), + let match_ = prefix.map(|prefix| format!("{}*", escape_glob(&prefix))); + let count = None; + + // the keyvalue cursor is none to start, and none once the keys are listed, like a scan's + let cursor = cursor.map_or(Cursor::Start, Cursor::Resume); + let (cursor, keys) = commands::hscan_novalues( + &self.connection, + self.hash_key.clone(), cursor, - }) + match_, + count, + ) + .await?; + + // a returned cursor is always one to resume from + let cursor = cursor.map(|cursor| match cursor { + Cursor::Resume(cursor) => cursor, + Cursor::Start => unreachable!("a scan never returns start"), + }); + Ok(KeyResponse { keys, cursor }) } } @@ -142,10 +132,7 @@ impl AtomicsGuest for AsKeyvalue { async fn increment(bucket: BucketBorrow<'_>, key: String, delta: i64) -> Result { let bucket: &KeyvalueToValkeyBucket = bucket.get(); - Ok(bucket - .connection - .hincrby(bucket.hash_key.clone(), key, delta) - .await?) + Ok(commands::hincrby(&bucket.connection, bucket.hash_key.clone(), key, delta).await?) } } @@ -214,10 +201,13 @@ impl From for Error { } } -impl From for Error { - fn from(e: valkey::Error) -> Self { - // TODO distinguish between IO, auth and other types of errors - Self::Other(format!("Valkey: {e}")) +impl From for Error { + fn from(e: ValkeyError) -> Self { + match e { + // the transport closed, retrying with a new bucket may succeed + ValkeyError::Closed(_) => Self::StoreUnavailable, + e => Self::Other(format!("Valkey: {e}")), + } } } diff --git a/components/as-keyvalue/tests/keyvalue.rs b/components/as-keyvalue/tests/keyvalue.rs new file mode 100644 index 0000000..938c791 --- /dev/null +++ b/components/as-keyvalue/tests/keyvalue.rs @@ -0,0 +1,392 @@ +//! Tests for `as-keyvalue`, run against `client`, which composes it with `tcp-connector` and the +//! rest of the client components. + +use std::time::Duration; + +use test_harness::keyvalue::types::{Error, KeyResponse, SetOptions}; +use test_harness::{Client, Harness, RespValue, Server, bulk, command, hello_reply, ok, setinfo}; +use wasmtime::component::ResourceAny; + +/// Each bucket is a hash, named for the test, with a prefix to keep them apart from other data. +const KEY_PREFIX: &str = "as-keyvalue-test:"; + +#[tokio::test(flavor = "multi_thread")] +async fn unreachable_servers_are_unavailable() -> wasmtime::Result<()> { + // a port nothing listens on + let port = test_harness::Server::bind().await?.port().to_string(); + let mut harness = Harness::with_config("client", &[("port", &port)]).await?; + harness + .run(async |accessor, exports| { + let bucket = exports + .keyvalue_store() + .call_open(accessor, "bucket".to_string()) + .await?; + assert_eq!(bucket.err(), Some(Error::StoreUnavailable)); + Ok(()) + }) + .await +} + +/// Answer the handshake of a connection. +async fn handshake(client: &mut Client) { + assert_eq!(client.request().await, Some(command(&["HELLO", "3"]))); + for setinfo in setinfo() { + assert_eq!(client.request().await, Some(setinfo)); + } + client.reply(hello_reply()).await; + for _ in setinfo() { + client.reply(ok()).await; + } +} + +fn array(items: Vec) -> RespValue { + RespValue::Array(items.into_iter().map(test_harness::encode).collect()) +} + +/// Answer each command the call sends with the next reply, returning the call's result and the +/// commands, as words. +async fn served( + client: &mut Client, + replies: Vec, + call: impl Future>, +) -> (T, Vec>) { + let serve = async { + let mut requests = vec![]; + for reply in replies { + requests.push(client.request().await.expect("a command")); + client.reply(reply).await; + } + requests + }; + let (result, requests) = tokio::join!(call, serve); + (result.expect("call"), requests) +} + +#[tokio::test(flavor = "multi_thread")] +async fn buckets_are_hashes() -> wasmtime::Result<()> { + let server = Server::bind().await?; + let port = server.port().to_string(); + let config = [("port", port.as_str()), ("key-prefix", "app:")]; + let mut harness = Harness::with_config("client", &config).await?; + harness + .run(async |accessor, exports| { + let s = |value: &str| value.to_string(); + let b = |value: &str| value.as_bytes().to_vec(); + let open = exports.keyvalue_store().call_open(accessor, s("bucket")); + let serve = async { + let mut client = server.accept().await.expect("accept"); + handshake(&mut client).await; + client + }; + let (bucket, mut client) = tokio::join!(open, serve); + let bucket: ResourceAny = + bucket?.map_err(|error| wasmtime::format_err!("{error:?}"))?; + let buckets = exports.keyvalue_types().bucket(); + + let (result, sent) = served( + &mut client, + vec![bulk("1")], + buckets.call_get(accessor, bucket, s("a")), + ) + .await; + assert_eq!(sent, vec![command(&["HGET", "app:bucket", "a"])]); + assert_eq!(result, Ok(Some(b("1")))); + + let (result, _) = served( + &mut client, + vec![RespValue::Null], + buckets.call_get(accessor, bucket, s("missing")), + ) + .await; + assert_eq!(result, Ok(None)); + + let (result, sent) = served( + &mut client, + vec![RespValue::Integer(1)], + buckets.call_set(accessor, bucket, s("a"), b("2"), None), + ) + .await; + assert_eq!(sent, vec![command(&["HSET", "app:bucket", "a", "2"])]); + assert_eq!(result, Ok(())); + + // if-not-exists only writes a new field + let if_not_exists = Some(SetOptions { + ttl_ms: None, + if_not_exists: true, + }); + let (result, sent) = served( + &mut client, + vec![RespValue::Integer(0)], + buckets.call_set(accessor, bucket, s("a"), b("3"), if_not_exists), + ) + .await; + assert_eq!(sent, vec![command(&["HSETNX", "app:bucket", "a", "3"])]); + assert_eq!(result, Err(Error::PreconditionFailed)); + + // a ttl to the millisecond, on the field + let ttl = Some(SetOptions { + ttl_ms: Some(1500), + if_not_exists: false, + }); + let (result, sent) = served( + &mut client, + vec![RespValue::Integer(1), array(vec![RespValue::Integer(1)])], + buckets.call_set(accessor, bucket, s("t"), b("1"), ttl), + ) + .await; + assert_eq!( + sent, + vec![ + command(&["HSET", "app:bucket", "t", "1"]), + command(&["HPEXPIRE", "app:bucket", "1500", "FIELDS", "1", "t"]), + ] + ); + assert_eq!(result, Ok(())); + + let (result, sent) = served( + &mut client, + vec![RespValue::Integer(1)], + buckets.call_exists(accessor, bucket, s("a")), + ) + .await; + assert_eq!(sent, vec![command(&["HEXISTS", "app:bucket", "a"])]); + assert_eq!(result, Ok(true)); + + let (result, sent) = served( + &mut client, + vec![RespValue::Integer(1)], + buckets.call_delete(accessor, bucket, s("a")), + ) + .await; + assert_eq!(sent, vec![command(&["HDEL", "app:bucket", "a"])]); + assert_eq!(result, Ok(())); + + // keys by prefix, glob characters in it match literally, resumed with the cursor + let (result, sent) = served( + &mut client, + vec![array(vec![bulk("17"), array(vec![bulk("a*1")])])], + buckets.call_list_keys(accessor, bucket, Some(s("a*")), None), + ) + .await; + assert_eq!( + sent, + vec![command(&[ + "HSCAN", + "app:bucket", + "0", + "MATCH", + "a\\**", + "NOVALUES" + ])] + ); + assert_eq!( + result, + Ok(KeyResponse { + keys: vec![s("a*1")], + cursor: Some(s("17")), + }) + ); + let (result, sent) = served( + &mut client, + vec![array(vec![bulk("0"), array(vec![])])], + buckets.call_list_keys(accessor, bucket, None, Some(s("17"))), + ) + .await; + assert_eq!( + sent, + vec![command(&["HSCAN", "app:bucket", "17", "NOVALUES"])] + ); + assert_eq!( + result, + Ok(KeyResponse { + keys: vec![], + cursor: None, + }) + ); + + let (result, sent) = served( + &mut client, + vec![RespValue::Integer(5)], + exports + .keyvalue_atomics() + .call_increment(accessor, bucket, s("count"), 5), + ) + .await; + assert_eq!( + sent, + vec![command(&["HINCRBY", "app:bucket", "count", "5"])] + ); + assert_eq!(result, Ok(5)); + + let batch = exports.keyvalue_batch(); + let (result, sent) = served( + &mut client, + vec![bulk("1"), RespValue::Null], + batch.call_get_many(accessor, bucket, vec![s("x"), s("z")]), + ) + .await; + assert_eq!( + sent, + vec![ + command(&["HGET", "app:bucket", "x"]), + command(&["HGET", "app:bucket", "z"]), + ] + ); + assert_eq!(result, Ok(vec![Some((s("x"), b("1"))), None])); + + let (result, sent) = served( + &mut client, + vec![RespValue::Integer(1), RespValue::Integer(1)], + batch.call_set_many(accessor, bucket, vec![(s("x"), b("1")), (s("y"), b("2"))]), + ) + .await; + assert_eq!( + sent, + vec![ + command(&["HSET", "app:bucket", "x", "1"]), + command(&["HSET", "app:bucket", "y", "2"]), + ] + ); + assert_eq!(result, Ok(())); + + let (result, sent) = served( + &mut client, + vec![RespValue::Integer(1), RespValue::Integer(1)], + batch.call_delete_many(accessor, bucket, vec![s("x"), s("y")]), + ) + .await; + assert_eq!( + sent, + vec![ + command(&["HDEL", "app:bucket", "x"]), + command(&["HDEL", "app:bucket", "y"]), + ] + ); + assert_eq!(result, Ok(())); + + // a closed connection is unavailable, a new bucket may connect again + client.close().await; + let result = buckets.call_get(accessor, bucket, s("a")).await?; + assert_eq!(result, Err(Error::StoreUnavailable)); + Ok(()) + }) + .await +} + +/// The tests against a Valkey server, ignored unless run with `make test-valkey`, which runs one in +/// a container and only runs the ignored tests in `with_valkey` modules. +mod with_valkey { + use super::*; + + #[tokio::test(flavor = "multi_thread")] + #[ignore = "needs a Valkey server, see make test-valkey"] + async fn buckets_against_valkey() -> wasmtime::Result<()> { + let mut harness = Harness::with_valkey("client", &[("key-prefix", KEY_PREFIX)]).await?; + harness + .run(async |accessor, exports| { + let s = |value: &str| value.to_string(); + let b = |value: &str| value.as_bytes().to_vec(); + let bucket = exports + .keyvalue_store() + .call_open(accessor, s("buckets")) + .await? + .map_err(|error| wasmtime::format_err!("{error:?}"))?; + let buckets = exports.keyvalue_types().bucket(); + + buckets + .call_set(accessor, bucket, s("a"), b("1"), None) + .await??; + assert_eq!( + buckets.call_get(accessor, bucket, s("a")).await??, + Some(b("1")) + ); + assert!(buckets.call_exists(accessor, bucket, s("a")).await??); + assert_eq!( + buckets.call_get(accessor, bucket, s("missing")).await??, + None + ); + + // if-not-exists only writes a new key + let if_not_exists = Some(SetOptions { + ttl_ms: None, + if_not_exists: true, + }); + assert_eq!( + buckets + .call_set(accessor, bucket, s("a"), b("2"), if_not_exists.clone()) + .await?, + Err(Error::PreconditionFailed) + ); + buckets + .call_set(accessor, bucket, s("b"), b("2"), if_not_exists) + .await??; + + // keys are listed by prefix, glob characters in it match literally + buckets + .call_set(accessor, bucket, s("a*"), b("3"), None) + .await??; + let KeyResponse { mut keys, cursor } = buckets + .call_list_keys(accessor, bucket, Some(s("a")), None) + .await??; + keys.sort(); + assert_eq!((keys, cursor), (vec![s("a"), s("a*")], None)); + let KeyResponse { keys, .. } = buckets + .call_list_keys(accessor, bucket, Some(s("a*")), None) + .await??; + assert_eq!(keys, vec![s("a*")]); + + assert_eq!( + exports + .keyvalue_atomics() + .call_increment(accessor, bucket, s("count"), 5) + .await??, + 5 + ); + + let batch = exports.keyvalue_batch(); + batch + .call_set_many(accessor, bucket, vec![(s("x"), b("1")), (s("y"), b("2"))]) + .await??; + assert_eq!( + batch + .call_get_many(accessor, bucket, vec![s("x"), s("z")]) + .await??, + vec![Some((s("x"), b("1"))), None] + ); + + // a ttl to the millisecond, with HPEXPIRE + buckets + .call_set( + accessor, + bucket, + s("expiring"), + b("1"), + Some(SetOptions { + ttl_ms: Some(200), + if_not_exists: false, + }), + ) + .await??; + assert!( + buckets + .call_exists(accessor, bucket, s("expiring")) + .await?? + ); + tokio::time::sleep(Duration::from_millis(500)).await; + assert!( + !buckets + .call_exists(accessor, bucket, s("expiring")) + .await?? + ); + + let keys = vec![s("a"), s("b"), s("a*"), s("count"), s("x"), s("y")]; + batch.call_delete_many(accessor, bucket, keys).await??; + let KeyResponse { keys, .. } = buckets + .call_list_keys(accessor, bucket, None, None) + .await??; + assert!(keys.is_empty(), "left over {keys:?}"); + Ok(()) + }) + .await + } +} diff --git a/components/cli/README.md b/components/cli/README.md index 05a41eb..97a51e6 100644 --- a/components/cli/README.md +++ b/components/cli/README.md @@ -1,3 +1,29 @@ # Valkey CLI -A wasi:cli/command exposing valkey commands. +A command line client for Valkey, sending its arguments as a command and printing the reply. + +Composed of [`internal-cli`](../internal-cli/) and a [`tcp-connector`](../tcp-connector/) configured with wasi:config. + +Every connection the cli makes is through the connector, so the cli is behind a [componentized:sockets](https://github.com/componentized/sockets) [`gate`](../dep-componentized-sockets-gate/) with the [`tcp-connector-latch`](../tcp-connector-latch/). It can only reach the server it is configured for, see [network access](../../README.md#network-access). The gate and the latch log denials with [`to-stderr`](../dep-componentized-logging-to-stderr/), keeping them out of the cli's output. + +## Run + +Start a Valkey server: + +```sh +docker run --rm -p 6379:6379 valkey/valkey:9 +``` + +```sh +wasmtime run -Sinherit-network -Sallow-ip-name-lookup -Sconfig -Wcomponent-model-map -Wcomponent-model-implements -Wcomponent-model-threading \ + target/components/cli/cli.wasm ping +``` +```txt +PONG +``` + +The server is configured with wasi:config variables, e.g. `-Sconfig-var=hostname=localhost`, see [`tcp-connector`](../tcp-connector/) for the keys and their defaults. The latch follows the same config. + +The gate needs the component model's `implements` and `threading` features, `-Wcomponent-model-implements -Wcomponent-model-threading`. + +See [`cli.wac`](./cli.wac) for the composition. diff --git a/components/cli/cli.wac b/components/cli/cli.wac index c39d6a9..0196198 100644 --- a/components/cli/cli.wac +++ b/components/cli/cli.wac @@ -1,10 +1,75 @@ -package componentized:valkey; +package componentized:valkey-cli; -let ops = new local:ops { ... }; -let cli = new local:dep-cli { - "componentized:valkey/store@0.3.0-dev": ops.store, - "componentized:valkey/resp@0.3.0-dev": ops.resp, +// The host interfaces the components behind the gate may use. Their imports are wired explicitly, +// without `...`, so an import of anything else, like wasi:sockets of any version, fails to compose +// instead of reaching the host around the gate. +import clocks-types: wasi:clocks/types@0.3.0; +import monotonic-clock: wasi:clocks/monotonic-clock@0.3.0; +import config: wasi:config/store@0.2.0-rc.1; +import environment: wasi:cli/environment@0.3.0; +import cli-types: wasi:cli/types@0.3.0; +import stdin: wasi:cli/stdin@0.3.0; +import stdout: wasi:cli/stdout@0.3.0; +import stderr: wasi:cli/stderr@0.3.0; +import valkey-types: componentized:valkey/types@0.3.0-dev; + +// The gate, the latch and the logger see the host, wasi:sockets included, the interfaces they +// share with the components behind the gate are wired to the same imports. + +// wasmtime does not provide wasi:logging, so the composition does, for the latch and the gate +let logging = new local:dep-componentized-logging-to-stderr { + "wasi:cli/types@0.3.0": cli-types, + "wasi:cli/stderr@0.3.0": stderr, + ... +}; + +// the latch, limiting sockets to the server in the tcp-connector's config +let latch = new local:tcp-connector-latch { + "wasi:config/store@0.2.0-rc.1": config, + "wasi:logging/logging@0.1.0-draft": logging["wasi:logging/logging@0.1.0-draft"], + ... +}; + +// wasi:sockets, behind the gate, opened by the latch +let gate = new local:dep-componentized-sockets-gate { + "wasi:clocks/types@0.3.0": clocks-types, + "componentized:sockets/latch@0.1.0": latch["componentized:sockets/latch@0.1.0"], + "wasi:logging/logging@0.1.0-draft": logging["wasi:logging/logging@0.1.0-draft"], ... }; +// Behind the gate, every import is wired explicitly. + +let connection = new local:connection { + "wasi:clocks/types@0.3.0": clocks-types, + "wasi:clocks/monotonic-clock@0.3.0": monotonic-clock, + "componentized:valkey/types@0.3.0-dev": valkey-types, +}; +// connections over wasi:sockets from the gate instead of the host +let tcp = new local:tcp { + "wasi:sockets/types@0.3.0": gate["wasi:sockets/types@0.3.0"], + "wasi:sockets/ip-name-lookup@0.3.0": gate["wasi:sockets/ip-name-lookup@0.3.0"], + "componentized:valkey/types@0.3.0-dev": valkey-types, + "componentized:valkey/connection@0.3.0-dev": connection.connection, +}; +// the keys of the tcp-connector's config, and their defaults +let tcp-connector-constants = new local:tcp-connector-constants {}; +let connector = new local:tcp-connector { + "wasi:config/store@0.2.0-rc.1": config, + "componentized:valkey/tcp-connector-constants@0.3.0-dev": tcp-connector-constants["componentized:valkey/tcp-connector-constants@0.3.0-dev"], + "componentized:valkey/types@0.3.0-dev": valkey-types, + "componentized:valkey/connection@0.3.0-dev": connection.connection, + "componentized:valkey/tcp@0.3.0-dev": tcp.tcp, +}; +let cli = new local:internal-cli { + "componentized:valkey/types@0.3.0-dev": valkey-types, + "componentized:valkey/connection@0.3.0-dev": connection.connection, + "componentized:valkey/connector@0.3.0-dev": connector.connector, + "wasi:cli/environment@0.3.0": environment, + "wasi:cli/types@0.3.0": cli-types, + "wasi:cli/stdin@0.3.0": stdin, + "wasi:cli/stdout@0.3.0": stdout, + "wasi:cli/stderr@0.3.0": stderr, +}; + export cli...; diff --git a/components/client/README.md b/components/client/README.md index bf0b3f6..5773f4f 100644 --- a/components/client/README.md +++ b/components/client/README.md @@ -1,8 +1,8 @@ # client -Client for interacting with a Valkey server, as componentized:valkey and wasmcloud:keyvalue. +Client for interacting with a Valkey server, as `componentized:valkey` and `wasmcloud:keyvalue`. > [!NOTE] > `wasi:keyvalue` is currently unsupportable with an async based client. It will be restored when either it updates it's own API to be async, or the component model is fully able to make async calls from sync functions. -Composes [`ops`](../ops/) with [`as-keyvalue`](../as-keyvalue/), exporting both. +Composes [`resp`](../resp/), [`connection`](../connection/), [`tcp`](../tcp/), [`tcp-connector`](../tcp-connector/), [`commands`](../commands/) and [`as-keyvalue`](../as-keyvalue/), exporting each of their interfaces. diff --git a/components/client/client.wac b/components/client/client.wac index 0a2d838..c199f8b 100644 --- a/components/client/client.wac +++ b/components/client/client.wac @@ -1,11 +1,35 @@ package componentized:valkey; -let ops = new local:ops { ... }; +let connection = new local:connection { + ... +}; +let tcp = new local:tcp { + "componentized:valkey/connection@0.3.0-dev": connection.connection, + ... +}; +let commands = new local:commands { + "componentized:valkey/connection@0.3.0-dev": connection.connection, + ... +}; +// the keys of the tcp-connector's config, and their defaults +let tcp-connector-constants = new local:tcp-connector-constants { + ... +}; +let connector = new local:tcp-connector { + "componentized:valkey/tcp-connector-constants@0.3.0-dev": tcp-connector-constants["componentized:valkey/tcp-connector-constants@0.3.0-dev"], + "componentized:valkey/connection@0.3.0-dev": connection.connection, + "componentized:valkey/tcp@0.3.0-dev": tcp.tcp, + ... +}; let keyvalue = new local:as-keyvalue { - "componentized:valkey/store@0.3.0-dev": ops.store, - "componentized:valkey/resp@0.3.0-dev": ops.resp, + "componentized:valkey/connection@0.3.0-dev": connection.connection, + "componentized:valkey/connector@0.3.0-dev": connector.connector, + "componentized:valkey/commands@0.3.0-dev": commands.commands, ... }; -export ops...; +export connection.connection; +export tcp.tcp; +export connector.connector; +export commands...; export keyvalue...; diff --git a/components/commands/Cargo.toml b/components/commands/Cargo.toml new file mode 100644 index 0000000..644a197 --- /dev/null +++ b/components/commands/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "commands" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +componentized-valkey = { workspace = true } +wit-bindgen = { workspace = true } + +[dev-dependencies] +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/commands/README.md b/components/commands/README.md new file mode 100644 index 0000000..696e491 --- /dev/null +++ b/components/commands/README.md @@ -0,0 +1,5 @@ +# commands + +Every implemented command interface, in one component. + +Exports the `-commands` interfaces of each of Valkey's ACL categories that has commands, over an imported [`connection`](../connection/). Each is also available on its own, e.g. [`hash-commands`](../hash-commands/). diff --git a/components/commands/src/lib.rs b/components/commands/src/lib.rs new file mode 100644 index 0000000..632f3ee --- /dev/null +++ b/components/commands/src/lib.rs @@ -0,0 +1,5563 @@ +use crate::exports::componentized::valkey::commands::{ + AclDryrunResult, AclListEntry, AclLogEntry, AclRule, BgsaveOperation, BitRange, BitfieldGet, + BitfieldOperation, BitopOperation, Bytes, BzmpopEnd, ClientFilter, ClientPauseMode, + ClientSetinfoAttr, ClientTrackingOptions, ClientTrackinginfoReply, ClientUnblockType, + ClusterBumpepochResult, ClusterFailoverMode, ClusterGetslotmigrationsEntry, + ClusterMigrateslotsGroup, ClusterResetMode, ClusterSetslotState, CommandDocsReply, + CommandInfoReply, CommandListFilter, CommandlogEntry, CommandlogType, Connection, Cursor, + Double, DurationMilliseconds, DurationSeconds, ErrorCode, ExpiretimeReply, FailoverTarget, + FlushMode, FunctionListLibrary, FunctionRestorePolicy, FunctionStatsReply, GeoCoordinates, + GeoUnit, GeoaddCondition, GeosearchCount, GeosearchShape, GeosearchWithFlags, + GeosearchWithResult, GetexTtl, Guest, HexpireResult, HgetexTtl, HpersistResult, + HsetexCondition, HsetexFieldCondition, HsetexTtl, Integer, KeyType, LatencyHistogramEntry, + LatencyLatestEntry, LcsIdxReply, LexBound, Limit, LinsertPosition, LinsertResult, ListSide, + MigrateAuth, MigrateFlags, ModuleListEntry, MsetexPrecondition, MsetexTtl, PexpiretimeReply, + PttlReply, RespValue, RestoreTtl, RoleReply, ScoreBound, SetPrecondition, SetTtl, + ShutdownFlags, ShutdownSave, SortOpts, SortOrder, SortRoOpts, StreamId, StreamTrim, + StringOrBytes, TtlMode, TtlReply, UnixTimeMilliseconds, UnixTimeSeconds, XclaimOpts, + XinfoConsumersEntry, XinfoGroupsEntry, XinfoStreamReply, XinfoStreamReplyFull, + XpendingExtendedEntry, XpendingSummary, ZaddComparison, ZaddCondition, ZmpopEnd, ZrangeBy, + ZrangeWithscoresBy, ZsetAggregate, +}; +use componentized_valkey::command::{Flag, NamedOption, NonEmpty, NumKeys}; +use componentized_valkey::commands::admin::{AclList, AclUserRules, ClientList}; +use componentized_valkey::commands::bitmap::Bit; +use componentized_valkey::commands::connection::{CommandDocsList, CommandInfoList, OnOff}; +use componentized_valkey::commands::hash::Fields; +use componentized_valkey::commands::keyspace::{Migrated, OptionalKeyType}; +use componentized_valkey::commands::scripting::KeysAndArgs; +use componentized_valkey::commands::shared::{DecimalSeconds, NextCursor}; +use componentized_valkey::commands::sortedset::{LexRange, ScoreRange, ScoredMembers, Zrange}; +use componentized_valkey::commands::stream::OrZeroId; +use componentized_valkey::value::{Binary, FlatPairs, OrNever}; + +use wit_bindgen::rt::Map; + +use componentized_valkey::{FromValue, IntoCommand}; + +/// Send a command, its words as bulk strings, and convert its reply. +/// +/// An error reply fails the command, see `connection.exchange`. +async fn exchange( + connection: &Connection, + command: impl IntoCommand, +) -> Result { + let reply = connection.exchange(command.into_command()?).await?; + T::from_value(reply) +} + +struct Commands; + +/// The rows of SORT's values, `row` values each. +fn rows(values: Vec>, row: usize) -> Vec>> { + let mut values = values.into_iter(); + std::iter::from_fn(|| Some(values.by_ref().take(row).collect::>())) + .take_while(|row| !row.is_empty()) + .collect() +} + +/// The words of RESTORE's ttl, `0` for none, and whether it is absolute, ABSTTL. +fn restore_ttl(ttl: Option) -> (StringOrBytes, bool) { + match ttl { + None => ("0".into(), false), + Some(RestoreTtl::ExpireIn(duration)) => (duration.px.to_string().into(), false), + Some(RestoreTtl::ExpireAt(unix_time)) => (unix_time.pxat.to_string().into(), true), + } +} + +/// The number of values SORT returns for each element, one for each GET pattern, or the element. +fn sort_row_len(opts: &Option) -> usize { + opts.as_ref() + .and_then(|opts| opts.get.as_ref()) + .map_or(1, Vec::len) +} + +impl Guest for Commands { + #[doc = " Deletes ACL users, and terminates their connections."] + #[doc = ""] + #[doc = " ACL DELUSER username [ username ... ]"] + #[doc = " https://valkey.io/commands/acl-deluser/"] + #[allow(async_fn_in_trait)] + async fn acl_deluser( + connection: &Connection, + usernames: Vec, + ) -> Result { + exchange( + connection, + ("ACL", "DELUSER", NonEmpty::new("usernames", usernames)), + ) + .await + } + + #[doc = " Generates a pseudorandom, secure password that can be used to identify ACL users."] + #[doc = ""] + #[doc = " ACL GENPASS [ bits ]"] + #[doc = " https://valkey.io/commands/acl-genpass/"] + #[allow(async_fn_in_trait)] + async fn acl_genpass( + connection: &Connection, + bits: Option, + ) -> Result { + exchange(connection, ("ACL", "GENPASS", bits)).await + } + + #[doc = " Creates and modifies an ACL user and its rules."] + #[doc = ""] + #[doc = " ACL SETUSER username [ rule ] [ [ rule ] ... ]"] + #[doc = " https://valkey.io/commands/acl-setuser/"] + #[allow(async_fn_in_trait)] + async fn acl_setuser( + connection: &Connection, + username: String, + rules: Option>, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ( + "ACL", + "SETUSER", + username, + NonEmpty::optional("rules", rules), + ), + ) + .await + } + + #[doc = " Lists the ACL categories, or the commands inside a category."] + #[doc = ""] + #[doc = " ACL CAT [ category ]"] + #[doc = " https://valkey.io/commands/acl-cat/"] + #[allow(async_fn_in_trait)] + async fn acl_cat( + connection: &Connection, + category: Option, + ) -> Result, ErrorCode> { + exchange(connection, ("ACL", "CAT", category)).await + } + + #[doc = " Simulates the execution of a command by a user, without executing the command."] + #[doc = ""] + #[doc = " ACL DRYRUN username command [ arg [ arg ... ] ]"] + #[doc = " https://valkey.io/commands/acl-dryrun/"] + #[allow(async_fn_in_trait)] + async fn acl_dryrun( + connection: &Connection, + username: String, + command: Vec, + ) -> Result { + exchange( + connection, + ("ACL", "DRYRUN", username, NonEmpty::new("command", command)), + ) + .await + } + + #[doc = " Lists the ACL rules of a user."] + #[doc = ""] + #[doc = " None when the user does not exist. The rules are as the server normalized them, e.g."] + #[doc = " `allkeys` is `~*`. Applied with `acl-setuser`, after `reset`, they recreate the user."] + #[doc = ""] + #[doc = " ACL GETUSER username"] + #[doc = " https://valkey.io/commands/acl-getuser/"] + #[allow(async_fn_in_trait)] + async fn acl_getuser( + connection: &Connection, + username: String, + ) -> Result>, ErrorCode> { + let rules: Option = + exchange(connection, ("ACL", "GETUSER", username)).await?; + Ok(rules.map(|rules| rules.0)) + } + + #[doc = " Dumps the effective rules in ACL file format."] + #[doc = ""] + #[doc = " ACL LIST"] + #[doc = " https://valkey.io/commands/acl-list/"] + #[allow(async_fn_in_trait)] + async fn acl_list(connection: &Connection) -> Result, ErrorCode> { + let users: AclList = exchange(connection, ("ACL", "LIST")).await?; + Ok(users.0) + } + + #[doc = " Reloads the rules from the configured ACL file."] + #[doc = ""] + #[doc = " ACL LOAD"] + #[doc = " https://valkey.io/commands/acl-load/"] + #[allow(async_fn_in_trait)] + async fn acl_load(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, ("ACL", "LOAD")).await + } + + #[doc = " Lists recent security events generated due to ACL rules."] + #[doc = ""] + #[doc = " See also `acl-log-reset`."] + #[doc = ""] + #[doc = " ACL LOG [ count ]"] + #[doc = " https://valkey.io/commands/acl-log/"] + #[allow(async_fn_in_trait)] + async fn acl_log( + connection: &Connection, + count: Option, + ) -> Result, ErrorCode> { + exchange(connection, ("ACL", "LOG", count)).await + } + + #[doc = " Lists recent security events generated due to ACL rules."] + #[doc = ""] + #[doc = " The `RESET` form, clearing the security events."] + #[doc = ""] + #[doc = " See also `acl-log`."] + #[doc = ""] + #[doc = " ACL LOG RESET"] + #[doc = " https://valkey.io/commands/acl-log/"] + #[allow(async_fn_in_trait)] + async fn acl_log_reset(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, ("ACL", "LOG", "RESET")).await + } + + #[doc = " Saves the effective ACL rules in the configured ACL file."] + #[doc = ""] + #[doc = " ACL SAVE"] + #[doc = " https://valkey.io/commands/acl-save/"] + #[allow(async_fn_in_trait)] + async fn acl_save(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, ("ACL", "SAVE")).await + } + + #[doc = " Lists all ACL users."] + #[doc = ""] + #[doc = " ACL USERS"] + #[doc = " https://valkey.io/commands/acl-users/"] + #[allow(async_fn_in_trait)] + async fn acl_users(connection: &Connection) -> Result, ErrorCode> { + exchange(connection, ("ACL", "USERS")).await + } + + #[doc = " Returns the authenticated username of the current connection."] + #[doc = ""] + #[doc = " ACL WHOAMI"] + #[doc = " https://valkey.io/commands/acl-whoami/"] + #[allow(async_fn_in_trait)] + async fn acl_whoami(connection: &Connection) -> Result { + exchange(connection, ("ACL", "WHOAMI")).await + } + + #[doc = " Asynchronously rewrites the append-only file to disk."] + #[doc = ""] + #[doc = " BGREWRITEAOF"] + #[doc = " https://valkey.io/commands/bgrewriteaof/"] + #[allow(async_fn_in_trait)] + async fn bgrewriteaof(connection: &Connection) -> Result { + exchange(connection, "BGREWRITEAOF").await + } + + #[doc = " Asynchronously saves the database(s) to disk."] + #[doc = ""] + #[doc = " BGSAVE [ SCHEDULE | CANCEL ]"] + #[doc = " https://valkey.io/commands/bgsave/"] + #[allow(async_fn_in_trait)] + async fn bgsave( + connection: &Connection, + operation: Option, + ) -> Result { + exchange(connection, ("BGSAVE", operation)).await + } + + #[doc = " Terminates open connections."] + #[doc = ""] + #[doc = " CLIENT KILL filter [ filter ... ]"] + #[doc = " https://valkey.io/commands/client-kill/"] + #[allow(async_fn_in_trait)] + async fn client_kill( + connection: &Connection, + filters: Vec, + ) -> Result { + exchange( + connection, + ("CLIENT", "KILL", NonEmpty::new("filters", filters)), + ) + .await + } + + #[doc = " Lists open connections."] + #[doc = ""] + #[doc = " Each connection is a map of its fields, parsed from the server's text, e.g. `id`, `addr` and"] + #[doc = " `name`."] + #[doc = ""] + #[doc = " CLIENT LIST [ filter [ filter ... ] ]"] + #[doc = " https://valkey.io/commands/client-list/"] + #[allow(async_fn_in_trait)] + async fn client_list( + connection: &Connection, + filters: Option>, + ) -> Result>, ErrorCode> { + let command = ("CLIENT", "LIST", NonEmpty::optional("filters", filters)); + let clients: ClientList = exchange(connection, command).await?; + Ok(clients.0) + } + + #[doc = " Sets the client eviction mode of the connection."] + #[doc = ""] + #[doc = " It applies to the connection, so to every caller sharing it."] + #[doc = ""] + #[doc = " CLIENT NO-EVICT 〈 ON | OFF 〉"] + #[doc = " https://valkey.io/commands/client-no-evict/"] + #[allow(async_fn_in_trait)] + async fn client_no_evict(connection: &Connection, enabled: bool) -> Result<(), ErrorCode> { + exchange( + connection, + ("CLIENT", "NO-EVICT", if enabled { "ON" } else { "OFF" }), + ) + .await + } + + #[doc = " Suspends commands processing."] + #[doc = ""] + #[doc = " CLIENT PAUSE timeout [ WRITE | ALL ]"] + #[doc = " https://valkey.io/commands/client-pause/"] + #[allow(async_fn_in_trait)] + async fn client_pause( + connection: &Connection, + timeout: DurationMilliseconds, + mode: Option, + ) -> Result<(), ErrorCode> { + exchange(connection, ("CLIENT", "PAUSE", timeout, mode)).await + } + + #[doc = " Unblocks a client blocked by a blocking command from a different connection."] + #[doc = ""] + #[doc = " CLIENT UNBLOCK client-id [ TIMEOUT | ERROR ]"] + #[doc = " https://valkey.io/commands/client-unblock/"] + #[allow(async_fn_in_trait)] + async fn client_unblock( + connection: &Connection, + client_id: Integer, + unblock_type: Option, + ) -> Result { + exchange(connection, ("CLIENT", "UNBLOCK", client_id, unblock_type)).await + } + + #[doc = " Resumes processing commands from paused clients."] + #[doc = ""] + #[doc = " CLIENT UNPAUSE"] + #[doc = " https://valkey.io/commands/client-unpause/"] + #[allow(async_fn_in_trait)] + async fn client_unpause(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, ("CLIENT", "UNPAUSE")).await + } + + #[doc = " Assigns new hash slots to a node."] + #[doc = ""] + #[doc = " CLUSTER ADDSLOTS slot [ slot ... ]"] + #[doc = " https://valkey.io/commands/cluster-addslots/"] + #[allow(async_fn_in_trait)] + async fn cluster_addslots(connection: &Connection, slots: Vec) -> Result<(), ErrorCode> { + exchange( + connection, + ("CLUSTER", "ADDSLOTS", NonEmpty::new("slots", slots)), + ) + .await + } + + #[doc = " Assigns new hash slot ranges to a node."] + #[doc = ""] + #[doc = " CLUSTER ADDSLOTSRANGE start-slot end-slot [ start-slot end-slot ... ]"] + #[doc = " https://valkey.io/commands/cluster-addslotsrange/"] + #[allow(async_fn_in_trait)] + async fn cluster_addslotsrange( + connection: &Connection, + ranges: Vec<(u16, u16)>, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ("CLUSTER", "ADDSLOTSRANGE", NonEmpty::new("ranges", ranges)), + ) + .await + } + + #[doc = " Advances the cluster config epoch."] + #[doc = ""] + #[doc = " CLUSTER BUMPEPOCH"] + #[doc = " https://valkey.io/commands/cluster-bumpepoch/"] + #[allow(async_fn_in_trait)] + async fn cluster_bumpepoch( + connection: &Connection, + ) -> Result { + exchange(connection, ("CLUSTER", "BUMPEPOCH")).await + } + + #[doc = " Cancel all current ongoing slot migration operations."] + #[doc = ""] + #[doc = " CLUSTER CANCELSLOTMIGRATIONS"] + #[doc = " https://valkey.io/commands/cluster-cancelslotmigrations/"] + #[allow(async_fn_in_trait)] + async fn cluster_cancelslotmigrations(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, ("CLUSTER", "CANCELSLOTMIGRATIONS")).await + } + + #[doc = " Returns the number of active failure reports for a node."] + #[doc = ""] + #[doc = " CLUSTER COUNT-FAILURE-REPORTS node-id"] + #[doc = " https://valkey.io/commands/cluster-count-failure-reports/"] + #[allow(async_fn_in_trait)] + async fn cluster_count_failure_reports( + connection: &Connection, + node_id: String, + ) -> Result { + exchange(connection, ("CLUSTER", "COUNT-FAILURE-REPORTS", node_id)).await + } + + #[doc = " Sets hash slots as unbound for a node."] + #[doc = ""] + #[doc = " CLUSTER DELSLOTS slot [ slot ... ]"] + #[doc = " https://valkey.io/commands/cluster-delslots/"] + #[allow(async_fn_in_trait)] + async fn cluster_delslots(connection: &Connection, slots: Vec) -> Result<(), ErrorCode> { + exchange( + connection, + ("CLUSTER", "DELSLOTS", NonEmpty::new("slots", slots)), + ) + .await + } + + #[doc = " Sets hash slot ranges as unbound for a node."] + #[doc = ""] + #[doc = " CLUSTER DELSLOTSRANGE start-slot end-slot [ start-slot end-slot ... ]"] + #[doc = " https://valkey.io/commands/cluster-delslotsrange/"] + #[allow(async_fn_in_trait)] + async fn cluster_delslotsrange( + connection: &Connection, + ranges: Vec<(u16, u16)>, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ("CLUSTER", "DELSLOTSRANGE", NonEmpty::new("ranges", ranges)), + ) + .await + } + + #[doc = " Forces a replica to perform a manual failover of its primary."] + #[doc = ""] + #[doc = " CLUSTER FAILOVER [ FORCE | TAKEOVER ]"] + #[doc = " https://valkey.io/commands/cluster-failover/"] + #[allow(async_fn_in_trait)] + async fn cluster_failover( + connection: &Connection, + mode: Option, + ) -> Result<(), ErrorCode> { + exchange(connection, ("CLUSTER", "FAILOVER", mode)).await + } + + #[doc = " Deletes all slots information from a node."] + #[doc = ""] + #[doc = " CLUSTER FLUSHSLOTS"] + #[doc = " https://valkey.io/commands/cluster-flushslots/"] + #[allow(async_fn_in_trait)] + async fn cluster_flushslots(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, ("CLUSTER", "FLUSHSLOTS")).await + } + + #[doc = " Removes a node from the nodes table."] + #[doc = ""] + #[doc = " CLUSTER FORGET node-id"] + #[doc = " https://valkey.io/commands/cluster-forget/"] + #[allow(async_fn_in_trait)] + async fn cluster_forget(connection: &Connection, node_id: String) -> Result<(), ErrorCode> { + exchange(connection, ("CLUSTER", "FORGET", node_id)).await + } + + #[doc = " Gets the status of ongoing and recently finished slot import and export operations."] + #[doc = ""] + #[doc = " CLUSTER GETSLOTMIGRATIONS"] + #[doc = " https://valkey.io/commands/cluster-getslotmigrations/"] + #[allow(async_fn_in_trait)] + async fn cluster_getslotmigrations( + connection: &Connection, + ) -> Result, ErrorCode> { + exchange(connection, ("CLUSTER", "GETSLOTMIGRATIONS")).await + } + + #[doc = " Forces a node to handshake with another node."] + #[doc = ""] + #[doc = " CLUSTER MEET ip port [ cluster-bus-port ]"] + #[doc = " https://valkey.io/commands/cluster-meet/"] + #[allow(async_fn_in_trait)] + async fn cluster_meet( + connection: &Connection, + ip: String, + port: u16, + cluster_bus_port: Option, + ) -> Result<(), ErrorCode> { + exchange(connection, ("CLUSTER", "MEET", ip, port, cluster_bus_port)).await + } + + #[doc = " Migrate the given slots from this node to the specified nodes."] + #[doc = ""] + #[doc = " CLUSTER MIGRATESLOTS SLOTSRANGE start-slot end-slot [ start-slot end-slot ... ] NODE node-id [ AUTH username password ] [ SLOTSRANGE ... ]"] + #[doc = " https://valkey.io/commands/cluster-migrateslots/"] + #[allow(async_fn_in_trait)] + async fn cluster_migrateslots( + connection: &Connection, + migrations: Vec, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ( + "CLUSTER", + "MIGRATESLOTS", + NonEmpty::new("migrations", migrations), + ), + ) + .await + } + + #[doc = " Lists the replica nodes of a primary node."] + #[doc = ""] + #[doc = " CLUSTER REPLICAS node-id"] + #[doc = " https://valkey.io/commands/cluster-replicas/"] + #[allow(async_fn_in_trait)] + async fn cluster_replicas( + connection: &Connection, + node_id: String, + ) -> Result, ErrorCode> { + exchange(connection, ("CLUSTER", "REPLICAS", node_id)).await + } + + #[doc = " Configures a node as replica of a primary node or detaches a replica from its primary."] + #[doc = ""] + #[doc = " None detaches the node from its primary, `NO ONE`."] + #[doc = ""] + #[doc = " CLUSTER REPLICATE 〈 node-id | NO ONE 〉"] + #[doc = " https://valkey.io/commands/cluster-replicate/"] + #[allow(async_fn_in_trait)] + async fn cluster_replicate( + connection: &Connection, + node_id: Option, + ) -> Result<(), ErrorCode> { + // NO ONE detaches the replica from its primary + let node = match node_id { + Some(node_id) => vec![node_id], + None => vec!["NO".to_string(), "ONE".to_string()], + }; + exchange(connection, ("CLUSTER", "REPLICATE", node)).await + } + + #[doc = " Resets a node."] + #[doc = ""] + #[doc = " CLUSTER RESET [ HARD | SOFT ]"] + #[doc = " https://valkey.io/commands/cluster-reset/"] + #[allow(async_fn_in_trait)] + async fn cluster_reset( + connection: &Connection, + mode: Option, + ) -> Result<(), ErrorCode> { + exchange(connection, ("CLUSTER", "RESET", mode)).await + } + + #[doc = " Forces a node to save the cluster configuration to disk."] + #[doc = ""] + #[doc = " CLUSTER SAVECONFIG"] + #[doc = " https://valkey.io/commands/cluster-saveconfig/"] + #[allow(async_fn_in_trait)] + async fn cluster_saveconfig(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, ("CLUSTER", "SAVECONFIG")).await + } + + #[doc = " Sets the configuration epoch for a new node."] + #[doc = ""] + #[doc = " CLUSTER SET-CONFIG-EPOCH config-epoch"] + #[doc = " https://valkey.io/commands/cluster-set-config-epoch/"] + #[allow(async_fn_in_trait)] + async fn cluster_set_config_epoch( + connection: &Connection, + config_epoch: Integer, + ) -> Result<(), ErrorCode> { + exchange(connection, ("CLUSTER", "SET-CONFIG-EPOCH", config_epoch)).await + } + + #[doc = " Binds a hash slot to a node."] + #[doc = ""] + #[doc = " CLUSTER SETSLOT slot 〈 IMPORTING node-id | MIGRATING node-id | NODE node-id | STABLE 〉 [ TIMEOUT timeout ]"] + #[doc = " https://valkey.io/commands/cluster-setslot/"] + #[allow(async_fn_in_trait)] + async fn cluster_setslot( + connection: &Connection, + slot: u16, + state: ClusterSetslotState, + timeout: Option, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ( + "CLUSTER", + "SETSLOT", + slot, + state, + NamedOption("TIMEOUT", timeout), + ), + ) + .await + } + + #[doc = " Returns the specified command log's entries."] + #[doc = ""] + #[doc = " COMMANDLOG GET count 〈 slow | large-request | large-reply 〉"] + #[doc = " https://valkey.io/commands/commandlog-get/"] + #[allow(async_fn_in_trait)] + async fn commandlog_get( + connection: &Connection, + count: Integer, + type_: CommandlogType, + ) -> Result, ErrorCode> { + exchange(connection, ("COMMANDLOG", "GET", count, type_)).await + } + + #[doc = " Returns the number of entries in the specified type of command log."] + #[doc = ""] + #[doc = " COMMANDLOG LEN 〈 slow | large-request | large-reply 〉"] + #[doc = " https://valkey.io/commands/commandlog-len/"] + #[allow(async_fn_in_trait)] + async fn commandlog_len( + connection: &Connection, + type_: CommandlogType, + ) -> Result { + exchange(connection, ("COMMANDLOG", "LEN", type_)).await + } + + #[doc = " Clears all entries from the specified type of command log."] + #[doc = ""] + #[doc = " COMMANDLOG RESET 〈 slow | large-request | large-reply 〉"] + #[doc = " https://valkey.io/commands/commandlog-reset/"] + #[allow(async_fn_in_trait)] + async fn commandlog_reset( + connection: &Connection, + type_: CommandlogType, + ) -> Result<(), ErrorCode> { + exchange(connection, ("COMMANDLOG", "RESET", type_)).await + } + + #[doc = " Returns the effective values of configuration parameters."] + #[doc = ""] + #[doc = " CONFIG GET parameter [ parameter ... ]"] + #[doc = " https://valkey.io/commands/config-get/"] + #[allow(async_fn_in_trait)] + async fn config_get( + connection: &Connection, + parameters: Vec, + ) -> Result, ErrorCode> { + exchange( + connection, + ("CONFIG", "GET", NonEmpty::new("parameters", parameters)), + ) + .await + } + + #[doc = " Resets the server's statistics."] + #[doc = ""] + #[doc = " CONFIG RESETSTAT"] + #[doc = " https://valkey.io/commands/config-resetstat/"] + #[allow(async_fn_in_trait)] + async fn config_resetstat(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, ("CONFIG", "RESETSTAT")).await + } + + #[doc = " Persists the effective configuration to file."] + #[doc = ""] + #[doc = " CONFIG REWRITE"] + #[doc = " https://valkey.io/commands/config-rewrite/"] + #[allow(async_fn_in_trait)] + async fn config_rewrite(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, ("CONFIG", "REWRITE")).await + } + + #[doc = " Sets configuration parameters in-flight."] + #[doc = ""] + #[doc = " CONFIG SET parameter value [ parameter value ... ]"] + #[doc = " https://valkey.io/commands/config-set/"] + #[allow(async_fn_in_trait)] + async fn config_set( + connection: &Connection, + parameters_and_values: Vec<(String, String)>, + ) -> Result<(), ErrorCode> { + let parameters_and_values = NonEmpty::new("parameters-and-values", parameters_and_values); + exchange(connection, ("CONFIG", "SET", parameters_and_values)).await + } + + #[doc = " Starts a coordinated failover from a server to one of its replicas."] + #[doc = ""] + #[doc = " FAILOVER [ TO host port [ FORCE ] ] [ ABORT ] [ TIMEOUT milliseconds ]"] + #[doc = " https://valkey.io/commands/failover/"] + #[allow(async_fn_in_trait)] + async fn failover( + connection: &Connection, + target: Option, + abort: bool, + timeout: Option, + ) -> Result<(), ErrorCode> { + let command = ( + "FAILOVER", + target, + Flag("ABORT", abort), + NamedOption("TIMEOUT", timeout), + ); + exchange(connection, command).await + } + + #[doc = " Returns the Unix timestamp of the last successful save to disk."] + #[doc = ""] + #[doc = " LASTSAVE"] + #[doc = " https://valkey.io/commands/lastsave/"] + #[allow(async_fn_in_trait)] + async fn lastsave(connection: &Connection) -> Result { + exchange(connection, "LASTSAVE").await + } + + #[doc = " Returns a human-readable latency analysis report."] + #[doc = ""] + #[doc = " LATENCY DOCTOR"] + #[doc = " https://valkey.io/commands/latency-doctor/"] + #[allow(async_fn_in_trait)] + async fn latency_doctor(connection: &Connection) -> Result { + exchange(connection, ("LATENCY", "DOCTOR")).await + } + + #[doc = " Returns a latency graph for an event."] + #[doc = ""] + #[doc = " LATENCY GRAPH event"] + #[doc = " https://valkey.io/commands/latency-graph/"] + #[allow(async_fn_in_trait)] + async fn latency_graph(connection: &Connection, event: String) -> Result { + exchange(connection, ("LATENCY", "GRAPH", event)).await + } + + #[doc = " Returns the cumulative distribution of latencies of a subset or all commands."] + #[doc = ""] + #[doc = " LATENCY HISTOGRAM [ command [ command ... ] ]"] + #[doc = " https://valkey.io/commands/latency-histogram/"] + #[allow(async_fn_in_trait)] + async fn latency_histogram( + connection: &Connection, + commands: Option>, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "LATENCY", + "HISTOGRAM", + NonEmpty::optional("commands", commands), + ), + ) + .await + } + + #[doc = " Returns timestamp-latency samples for an event."] + #[doc = ""] + #[doc = " LATENCY HISTORY event"] + #[doc = " https://valkey.io/commands/latency-history/"] + #[allow(async_fn_in_trait)] + async fn latency_history( + connection: &Connection, + event: String, + ) -> Result, ErrorCode> { + exchange(connection, ("LATENCY", "HISTORY", event)).await + } + + #[doc = " Returns the latest latency samples for all events."] + #[doc = ""] + #[doc = " LATENCY LATEST"] + #[doc = " https://valkey.io/commands/latency-latest/"] + #[allow(async_fn_in_trait)] + async fn latency_latest(connection: &Connection) -> Result, ErrorCode> { + exchange(connection, ("LATENCY", "LATEST")).await + } + + #[doc = " Resets the latency data for one or more events."] + #[doc = ""] + #[doc = " LATENCY RESET [ event [ event ... ] ]"] + #[doc = " https://valkey.io/commands/latency-reset/"] + #[allow(async_fn_in_trait)] + async fn latency_reset( + connection: &Connection, + events: Option>, + ) -> Result { + exchange( + connection, + ("LATENCY", "RESET", NonEmpty::optional("events", events)), + ) + .await + } + + #[doc = " Returns all loaded modules."] + #[doc = ""] + #[doc = " MODULE LIST"] + #[doc = " https://valkey.io/commands/module-list/"] + #[allow(async_fn_in_trait)] + async fn module_list(connection: &Connection) -> Result, ErrorCode> { + exchange(connection, ("MODULE", "LIST")).await + } + + #[doc = " Loads a module."] + #[doc = ""] + #[doc = " MODULE LOAD path [ arg [ arg ... ] ]"] + #[doc = " https://valkey.io/commands/module-load/"] + #[allow(async_fn_in_trait)] + async fn module_load( + connection: &Connection, + path: String, + args: Option>, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ("MODULE", "LOAD", path, NonEmpty::optional("args", args)), + ) + .await + } + + #[doc = " Loads a module using extended parameters."] + #[doc = ""] + #[doc = " MODULE LOADEX path [ CONFIG name value [ CONFIG name value ... ] ] [ ARGS args [ args ... ] ]"] + #[doc = " https://valkey.io/commands/module-loadex/"] + #[allow(async_fn_in_trait)] + async fn module_loadex( + connection: &Connection, + path: String, + configs: Option>, + args: Option>, + ) -> Result<(), ErrorCode> { + // CONFIG before each name and value, ARGS once before the arguments + let configs = configs.map(|configs| { + let configs = configs + .into_iter() + .map(|(name, value)| ("CONFIG", name, value)); + configs.collect::>() + }); + let configs = NonEmpty::optional("configs", configs); + let args = NonEmpty::optional("args", args).map(|args| ("ARGS", args)); + exchange(connection, ("MODULE", "LOADEX", path, configs, args)).await + } + + #[doc = " Unloads a module."] + #[doc = ""] + #[doc = " MODULE UNLOAD name"] + #[doc = " https://valkey.io/commands/module-unload/"] + #[allow(async_fn_in_trait)] + async fn module_unload(connection: &Connection, name: String) -> Result<(), ErrorCode> { + exchange(connection, ("MODULE", "UNLOAD", name)).await + } + + #[doc = " Configures a server as replica of another, or promotes it to a primary."] + #[doc = ""] + #[doc = " None makes the server a primary, `NO ONE`."] + #[doc = ""] + #[doc = " REPLICAOF 〈 host port | NO ONE 〉"] + #[doc = " https://valkey.io/commands/replicaof/"] + #[allow(async_fn_in_trait)] + async fn replicaof( + connection: &Connection, + primary: Option<(String, u16)>, + ) -> Result { + // NO ONE makes the server a primary + let primary = match primary { + Some((host, port)) => vec![host, port.to_string()], + None => vec!["NO".to_string(), "ONE".to_string()], + }; + exchange(connection, ("REPLICAOF", primary)).await + } + + #[doc = " Returns the replication role."] + #[doc = ""] + #[doc = " ROLE"] + #[doc = " https://valkey.io/commands/role/"] + #[allow(async_fn_in_trait)] + async fn role(connection: &Connection) -> Result { + exchange(connection, "ROLE").await + } + + #[doc = " Synchronously saves the database(s) to disk."] + #[doc = ""] + #[doc = " SAVE"] + #[doc = " https://valkey.io/commands/save/"] + #[allow(async_fn_in_trait)] + async fn save(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, "SAVE").await + } + + #[doc = " Synchronously saves the database(s) to disk and shuts down the server."] + #[doc = ""] + #[doc = " The server closes the connection once it stops, so a shutdown that succeeds fails the call"] + #[doc = " with a closed error."] + #[doc = ""] + #[doc = " See also `shutdown-abort`."] + #[doc = ""] + #[doc = " SHUTDOWN [ NOSAVE | SAVE ] [ NOW ] [ FORCE ] [ SAFE ] [ FAILOVER ]"] + #[doc = " https://valkey.io/commands/shutdown/"] + #[allow(async_fn_in_trait)] + async fn shutdown( + connection: &Connection, + save: Option, + modifiers: ShutdownFlags, + ) -> Result<(), ErrorCode> { + let command = ( + "SHUTDOWN", + save, + Flag("NOW", modifiers.contains(ShutdownFlags::NOW)), + Flag("FORCE", modifiers.contains(ShutdownFlags::FORCE)), + Flag("SAFE", modifiers.contains(ShutdownFlags::SAFE)), + Flag("FAILOVER", modifiers.contains(ShutdownFlags::FAILOVER)), + ); + exchange(connection, command).await + } + + #[doc = " Synchronously saves the database(s) to disk and shuts down the server."] + #[doc = ""] + #[doc = " The `ABORT` form, aborting a shutdown in progress."] + #[doc = ""] + #[doc = " See also `shutdown`."] + #[doc = ""] + #[doc = " SHUTDOWN ABORT"] + #[doc = " https://valkey.io/commands/shutdown/"] + #[allow(async_fn_in_trait)] + async fn shutdown_abort(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, ("SHUTDOWN", "ABORT")).await + } + + #[doc = " Returns the slow log's entries."] + #[doc = ""] + #[doc = " SLOWLOG GET [ count ]"] + #[doc = " https://valkey.io/commands/slowlog-get/"] + #[allow(async_fn_in_trait)] + async fn slowlog_get( + connection: &Connection, + count: Option, + ) -> Result, ErrorCode> { + exchange(connection, ("SLOWLOG", "GET", count)).await + } + + #[doc = " Returns the number of entries in the slow log."] + #[doc = ""] + #[doc = " SLOWLOG LEN"] + #[doc = " https://valkey.io/commands/slowlog-len/"] + #[allow(async_fn_in_trait)] + async fn slowlog_len(connection: &Connection) -> Result { + exchange(connection, ("SLOWLOG", "LEN")).await + } + + #[doc = " Clears all entries from the slow log."] + #[doc = ""] + #[doc = " SLOWLOG RESET"] + #[doc = " https://valkey.io/commands/slowlog-reset/"] + #[allow(async_fn_in_trait)] + async fn slowlog_reset(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, ("SLOWLOG", "RESET")).await + } + + #[doc = " Counts the number of set bits (population counting) in a string."] + #[doc = ""] + #[doc = " BITCOUNT key [ start [ end [ BYTE | BIT ] ] ]"] + #[doc = " https://valkey.io/commands/bitcount/"] + #[allow(async_fn_in_trait)] + async fn bitcount( + connection: &Connection, + key: String, + range: Option, + ) -> Result { + exchange(connection, ("BITCOUNT", key, range)).await + } + + #[doc = " Performs arbitrary bitfield integer operations on strings."] + #[doc = ""] + #[doc = " None for a write that failed with `OVERFLOW FAIL`."] + #[doc = ""] + #[doc = " BITFIELD key [ GET encoding offset | [ OVERFLOW WRAP | SAT | FAIL ] 〈 SET encoding offset value | INCRBY encoding offset increment 〉 [ GET encoding offset | [ OVERFLOW WRAP | SAT | FAIL ] 〈 SET encoding offset value | INCRBY encoding offset increment 〉 ... ] ]"] + #[doc = " https://valkey.io/commands/bitfield/"] + #[allow(async_fn_in_trait)] + async fn bitfield( + connection: &Connection, + key: String, + operations: Option>, + ) -> Result>, ErrorCode> { + exchange( + connection, + ( + "BITFIELD", + key, + NonEmpty::optional("operations", operations), + ), + ) + .await + } + + #[doc = " Performs arbitrary read-only bitfield integer operations on strings."] + #[doc = ""] + #[doc = " BITFIELD_RO key [ GET encoding offset [ GET encoding offset ... ] ]"] + #[doc = " https://valkey.io/commands/bitfield_ro/"] + #[allow(async_fn_in_trait)] + async fn bitfield_ro( + connection: &Connection, + key: String, + gets: Option>, + ) -> Result, ErrorCode> { + exchange( + connection, + ("BITFIELD_RO", key, NonEmpty::optional("gets", gets)), + ) + .await + } + + #[doc = " Performs bitwise operations on multiple strings, and stores the result."] + #[doc = ""] + #[doc = " BITOP 〈 AND | OR | XOR | NOT 〉 destkey key [ key ... ]"] + #[doc = " https://valkey.io/commands/bitop/"] + #[allow(async_fn_in_trait)] + async fn bitop( + connection: &Connection, + destination: String, + operation: BitopOperation, + ) -> Result { + // the operation and its keys are either side of the destination + let (operation, keys) = match operation { + BitopOperation::And(keys) => ("AND", keys), + BitopOperation::Or(keys) => ("OR", keys), + BitopOperation::Xor(keys) => ("XOR", keys), + BitopOperation::Not(key) => ("NOT", vec![key]), + }; + exchange( + connection, + ("BITOP", operation, destination, NonEmpty::new("keys", keys)), + ) + .await + } + + #[doc = " Finds the first set (1) or clear (0) bit in a string."] + #[doc = ""] + #[doc = " None when no bit matches, the server's `-1`."] + #[doc = ""] + #[doc = " BITPOS key bit [ start [ end [ BYTE | BIT ] ] ]"] + #[doc = " https://valkey.io/commands/bitpos/"] + #[allow(async_fn_in_trait)] + async fn bitpos( + connection: &Connection, + key: String, + bit: bool, + range: Option, + ) -> Result, ErrorCode> { + // -1 when there is no such bit + let position: OrNever = + exchange(connection, ("BITPOS", key, Bit(bit), range)).await?; + Ok(position.0) + } + + #[doc = " Returns a bit value by offset."] + #[doc = ""] + #[doc = " GETBIT key offset"] + #[doc = " https://valkey.io/commands/getbit/"] + #[allow(async_fn_in_trait)] + async fn getbit(connection: &Connection, key: String, offset: u64) -> Result { + exchange(connection, ("GETBIT", key, offset)).await + } + + #[doc = " Sets or clears the bit at offset of the string value."] + #[doc = ""] + #[doc = " SETBIT key offset value"] + #[doc = " https://valkey.io/commands/setbit/"] + #[allow(async_fn_in_trait)] + async fn setbit( + connection: &Connection, + key: String, + offset: u64, + value: bool, + ) -> Result { + exchange(connection, ("SETBIT", key, offset, Bit(value))).await + } + + #[doc = " Authenticates the connection."] + #[doc = ""] + #[doc = " AUTH [ username ] password"] + #[doc = " https://valkey.io/commands/auth/"] + #[allow(async_fn_in_trait)] + async fn auth( + connection: &Connection, + username: Option, + password: String, + ) -> Result<(), ErrorCode> { + exchange(connection, ("AUTH", username, password)).await + } + + #[doc = " Returns the server's liveliness response."] + #[doc = ""] + #[doc = " PING [ message ]"] + #[doc = " https://valkey.io/commands/ping/"] + #[allow(async_fn_in_trait)] + async fn ping( + connection: &Connection, + message: Option, + ) -> Result<(), ErrorCode> { + let reply: StringOrBytes = exchange(connection, ("PING", message.clone())).await?; + if reply == message.unwrap_or_else(|| "PONG".into()) { + Ok(()) + } else { + Err(ErrorCode::Client(format!("unexpected reply: {reply:?}"))) + } + } + + #[doc = " Signals that a cluster client is following an -ASK redirect."] + #[doc = ""] + #[doc = " It applies to the next command on the connection, which may be another caller's when the"] + #[doc = " connection is shared. Use a connection of your own."] + #[doc = ""] + #[doc = " ASKING"] + #[doc = " https://valkey.io/commands/asking/"] + #[allow(async_fn_in_trait)] + async fn asking(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, "ASKING").await + } + + #[doc = " Instructs the server whether to track the keys in the next request."] + #[doc = ""] + #[doc = " It applies to the next command on the connection, which may be another caller's when the"] + #[doc = " connection is shared. Use a connection of your own."] + #[doc = ""] + #[doc = " CLIENT CACHING 〈 YES | NO 〉"] + #[doc = " https://valkey.io/commands/client-caching/"] + #[allow(async_fn_in_trait)] + async fn client_caching(connection: &Connection, enabled: bool) -> Result<(), ErrorCode> { + exchange( + connection, + ("CLIENT", "CACHING", if enabled { "YES" } else { "NO" }), + ) + .await + } + + #[doc = " A client claims its capability."] + #[doc = ""] + #[doc = " CLIENT CAPA capability [ capability ... ]"] + #[doc = " https://valkey.io/commands/client-capa/"] + #[allow(async_fn_in_trait)] + async fn client_capa( + connection: &Connection, + capabilities: Vec, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ( + "CLIENT", + "CAPA", + NonEmpty::new("capabilities", capabilities), + ), + ) + .await + } + + #[doc = " Returns the name of the connection."] + #[doc = ""] + #[doc = " CLIENT GETNAME"] + #[doc = " https://valkey.io/commands/client-getname/"] + #[allow(async_fn_in_trait)] + async fn client_getname(connection: &Connection) -> Result, ErrorCode> { + exchange(connection, ("CLIENT", "GETNAME")).await + } + + #[doc = " Returns the unique client ID of the connection."] + #[doc = ""] + #[doc = " CLIENT ID"] + #[doc = " https://valkey.io/commands/client-id/"] + #[allow(async_fn_in_trait)] + async fn client_id(connection: &Connection) -> Result { + exchange(connection, ("CLIENT", "ID")).await + } + + #[doc = " Marks this client as an import source when the server is in import mode."] + #[doc = ""] + #[doc = " CLIENT IMPORT-SOURCE 〈 ON | OFF 〉"] + #[doc = " https://valkey.io/commands/client-import-source/"] + #[allow(async_fn_in_trait)] + async fn client_import_source(connection: &Connection, enabled: bool) -> Result<(), ErrorCode> { + exchange(connection, ("CLIENT", "IMPORT-SOURCE", OnOff(enabled))).await + } + + #[doc = " Returns information about the connection."] + #[doc = ""] + #[doc = " A map of the fields, parsed from the server's text, e.g. `id`, `addr` and `name`."] + #[doc = ""] + #[doc = " CLIENT INFO"] + #[doc = " https://valkey.io/commands/client-info/"] + #[allow(async_fn_in_trait)] + async fn client_info(connection: &Connection) -> Result, ErrorCode> { + // a line of CLIENT LIST + let clients: ClientList = exchange(connection, ("CLIENT", "INFO")).await?; + clients + .0 + .into_iter() + .next() + .ok_or_else(|| ErrorCode::Client("unexpected empty reply".to_string())) + } + + #[doc = " Controls whether commands sent by the client affect the LRU/LFU of accessed keys."] + #[doc = ""] + #[doc = " It applies to the connection, so to every caller sharing it."] + #[doc = ""] + #[doc = " CLIENT NO-TOUCH 〈 ON | OFF 〉"] + #[doc = " https://valkey.io/commands/client-no-touch/"] + #[allow(async_fn_in_trait)] + async fn client_no_touch(connection: &Connection, enabled: bool) -> Result<(), ErrorCode> { + exchange(connection, ("CLIENT", "NO-TOUCH", OnOff(enabled))).await + } + + #[doc = " Sets information specific to the client or connection."] + #[doc = ""] + #[doc = " CLIENT SETINFO 〈 LIB-NAME libname | LIB-VER libver 〉"] + #[doc = " https://valkey.io/commands/client-setinfo/"] + #[allow(async_fn_in_trait)] + async fn client_setinfo( + connection: &Connection, + attr: ClientSetinfoAttr, + ) -> Result<(), ErrorCode> { + exchange(connection, ("CLIENT", "SETINFO", attr)).await + } + + #[doc = " Sets the connection name."] + #[doc = ""] + #[doc = " CLIENT SETNAME connection-name"] + #[doc = " https://valkey.io/commands/client-setname/"] + #[allow(async_fn_in_trait)] + async fn client_setname( + connection: &Connection, + connection_name: String, + ) -> Result<(), ErrorCode> { + exchange(connection, ("CLIENT", "SETNAME", connection_name)).await + } + + #[doc = " Controls server-assisted client-side caching for the connection."] + #[doc = ""] + #[doc = " Invalidations arrive as push messages, see `connection.dead-letters`. It applies to the"] + #[doc = " connection, so to every caller sharing it."] + #[doc = ""] + #[doc = " CLIENT TRACKING 〈 ON | OFF 〉 [ PREFIX prefix [ PREFIX prefix ... ] ] [ BCAST ] [ OPTIN ] [ OPTOUT ] [ NOLOOP ]"] + #[doc = " https://valkey.io/commands/client-tracking/"] + #[allow(async_fn_in_trait)] + async fn client_tracking( + connection: &Connection, + options: Option, + ) -> Result<(), ErrorCode> { + // ON with options, OFF without + let status = OnOff(options.is_some()); + exchange(connection, ("CLIENT", "TRACKING", status, options)).await + } + + #[doc = " Returns information about server-assisted client-side caching for the connection."] + #[doc = ""] + #[doc = " CLIENT TRACKINGINFO"] + #[doc = " https://valkey.io/commands/client-trackinginfo/"] + #[allow(async_fn_in_trait)] + async fn client_trackinginfo( + connection: &Connection, + ) -> Result { + exchange(connection, ("CLIENT", "TRACKINGINFO")).await + } + + #[doc = " Returns a count of commands."] + #[doc = ""] + #[doc = " COMMAND COUNT"] + #[doc = " https://valkey.io/commands/command-count/"] + #[allow(async_fn_in_trait)] + async fn command_count(connection: &Connection) -> Result { + exchange(connection, ("COMMAND", "COUNT")).await + } + + #[doc = " Returns detailed information about all commands."] + #[doc = ""] + #[doc = " COMMAND"] + #[doc = " https://valkey.io/commands/command/"] + #[allow(async_fn_in_trait)] + async fn command(connection: &Connection) -> Result, ErrorCode> { + let commands: CommandInfoList = exchange(connection, "COMMAND").await?; + Ok(commands.0) + } + + #[doc = " Returns documentary information about one, multiple or all commands."] + #[doc = ""] + #[doc = " A container is followed by its subcommands. Commands that do not exist are left out."] + #[doc = ""] + #[doc = " COMMAND DOCS [ command-name [ command-name ... ] ]"] + #[doc = " https://valkey.io/commands/command-docs/"] + #[allow(async_fn_in_trait)] + async fn command_docs( + connection: &Connection, + command_names: Option>, + ) -> Result, ErrorCode> { + let command_names = NonEmpty::optional("command-names", command_names); + let commands: CommandDocsList = + exchange(connection, ("COMMAND", "DOCS", command_names)).await?; + Ok(commands.0) + } + + #[doc = " Returns information about one, multiple or all commands."] + #[doc = ""] + #[doc = " A container is followed by its subcommands. Commands that do not exist are left out."] + #[doc = ""] + #[doc = " COMMAND INFO [ command-name [ command-name ... ] ]"] + #[doc = " https://valkey.io/commands/command-info/"] + #[allow(async_fn_in_trait)] + async fn command_info( + connection: &Connection, + command_names: Option>, + ) -> Result, ErrorCode> { + let command_names = NonEmpty::optional("command-names", command_names); + let commands: CommandInfoList = + exchange(connection, ("COMMAND", "INFO", command_names)).await?; + Ok(commands.0) + } + + #[doc = " Extracts the key names from an arbitrary command."] + #[doc = ""] + #[doc = " COMMAND GETKEYS command [ arg [ arg ... ] ]"] + #[doc = " https://valkey.io/commands/command-getkeys/"] + #[allow(async_fn_in_trait)] + async fn command_getkeys( + connection: &Connection, + command: Vec, + ) -> Result, ErrorCode> { + exchange( + connection, + ("COMMAND", "GETKEYS", NonEmpty::new("command", command)), + ) + .await + } + + #[doc = " Extracts the key names and access flags for an arbitrary command."] + #[doc = ""] + #[doc = " COMMAND GETKEYSANDFLAGS command [ arg [ arg ... ] ]"] + #[doc = " https://valkey.io/commands/command-getkeysandflags/"] + #[allow(async_fn_in_trait)] + async fn command_getkeysandflags( + connection: &Connection, + command: Vec, + ) -> Result)>, ErrorCode> { + exchange( + connection, + ( + "COMMAND", + "GETKEYSANDFLAGS", + NonEmpty::new("command", command), + ), + ) + .await + } + + #[doc = " Returns a list of command names."] + #[doc = ""] + #[doc = " COMMAND LIST [ FILTERBY 〈 MODULE module-name | ACLCAT category | PATTERN pattern 〉 ]"] + #[doc = " https://valkey.io/commands/command-list/"] + #[allow(async_fn_in_trait)] + async fn command_list( + connection: &Connection, + filter: Option, + ) -> Result, ErrorCode> { + exchange(connection, ("COMMAND", "LIST", filter)).await + } + + #[doc = " Returns the given string."] + #[doc = ""] + #[doc = " ECHO message"] + #[doc = " https://valkey.io/commands/echo/"] + #[allow(async_fn_in_trait)] + async fn echo( + connection: &Connection, + message: StringOrBytes, + ) -> Result { + exchange(connection, ("ECHO", message)).await + } + + #[doc = " Enables read-only queries for a connection to a Valkey replica node."] + #[doc = ""] + #[doc = " It applies to the connection, so to every caller sharing it."] + #[doc = ""] + #[doc = " READONLY"] + #[doc = " https://valkey.io/commands/readonly/"] + #[allow(async_fn_in_trait)] + async fn readonly(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, "READONLY").await + } + + #[doc = " Enables read-write queries for a connection to a Valkey replica node."] + #[doc = ""] + #[doc = " It applies to the connection, so to every caller sharing it."] + #[doc = ""] + #[doc = " READWRITE"] + #[doc = " https://valkey.io/commands/readwrite/"] + #[allow(async_fn_in_trait)] + async fn readwrite(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, "READWRITE").await + } + + #[doc = " Changes the selected database."] + #[doc = ""] + #[doc = " It applies to the connection, so to every caller sharing it."] + #[doc = ""] + #[doc = " SELECT index"] + #[doc = " https://valkey.io/commands/select/"] + #[allow(async_fn_in_trait)] + async fn select(connection: &Connection, index: Integer) -> Result<(), ErrorCode> { + exchange(connection, ("SELECT", index)).await + } + + #[doc = " Blocks until the asynchronous replication of all preceding write commands sent by the connection is completed."] + #[doc = ""] + #[doc = " The connection waits too, replies to commands sent after it on the connection wait behind"] + #[doc = " it. Use a connection of your own."] + #[doc = ""] + #[doc = " WAIT numreplicas timeout"] + #[doc = " https://valkey.io/commands/wait/"] + #[allow(async_fn_in_trait)] + async fn wait( + connection: &Connection, + numreplicas: Integer, + timeout: DurationMilliseconds, + ) -> Result { + exchange(connection, ("WAIT", numreplicas, timeout)).await + } + + #[doc = " Blocks until all of the preceding write commands sent by the connection are written to the append-only file of the primary and/or replicas."] + #[doc = ""] + #[doc = " The connection waits too, replies to commands sent after it on the connection wait behind"] + #[doc = " it. Use a connection of your own."] + #[doc = ""] + #[doc = " WAITAOF numlocal numreplicas timeout"] + #[doc = " https://valkey.io/commands/waitaof/"] + #[allow(async_fn_in_trait)] + async fn waitaof( + connection: &Connection, + numlocal: Integer, + numreplicas: Integer, + timeout: DurationMilliseconds, + ) -> Result<(Integer, Integer), ErrorCode> { + exchange(connection, ("WAITAOF", numlocal, numreplicas, timeout)).await + } + + #[doc = " Adds one or more members to a geospatial index."] + #[doc = ""] + #[doc = " GEOADD key [ NX | XX ] [ CH ] longitude latitude member [ longitude latitude member ... ]"] + #[doc = " https://valkey.io/commands/geoadd/"] + #[allow(async_fn_in_trait)] + async fn geoadd( + connection: &Connection, + key: String, + condition: Option, + changed: bool, + coordinates_and_members: Vec<(GeoCoordinates, StringOrBytes)>, + ) -> Result { + exchange( + connection, + ( + "GEOADD", + key, + condition, + Flag("CH", changed), + NonEmpty::new("coordinates-and-members", coordinates_and_members), + ), + ) + .await + } + + #[doc = " Returns the distance between two members of a geospatial index."] + #[doc = ""] + #[doc = " GEODIST key member1 member2 [ m | km | ft | mi ]"] + #[doc = " https://valkey.io/commands/geodist/"] + #[allow(async_fn_in_trait)] + async fn geodist( + connection: &Connection, + key: String, + member1: StringOrBytes, + member2: StringOrBytes, + unit: Option, + ) -> Result, ErrorCode> { + exchange(connection, ("GEODIST", key, member1, member2, unit)).await + } + + #[doc = " Returns members from a geospatial index as geohash strings."] + #[doc = ""] + #[doc = " GEOHASH key [ member [ member ... ] ]"] + #[doc = " https://valkey.io/commands/geohash/"] + #[allow(async_fn_in_trait)] + async fn geohash( + connection: &Connection, + key: String, + members: Option>, + ) -> Result>, ErrorCode> { + exchange( + connection, + ("GEOHASH", key, NonEmpty::optional("members", members)), + ) + .await + } + + #[doc = " Returns the longitude and latitude of members from a geospatial index."] + #[doc = ""] + #[doc = " GEOPOS key [ member [ member ... ] ]"] + #[doc = " https://valkey.io/commands/geopos/"] + #[allow(async_fn_in_trait)] + async fn geopos( + connection: &Connection, + key: String, + members: Option>, + ) -> Result>, ErrorCode> { + exchange( + connection, + ("GEOPOS", key, NonEmpty::optional("members", members)), + ) + .await + } + + #[doc = " Queries a geospatial index for members inside an area of a box, circle, or a polygon."] + #[doc = ""] + #[doc = " See also `geosearch-with`."] + #[doc = ""] + #[doc = " GEOSEARCH key [ FROMMEMBER member | FROMLONLAT longitude latitude ] 〈 BYRADIUS radius 〈 m | km | ft | mi 〉 | BYBOX width height 〈 m | km | ft | mi 〉 | BYPOLYGON num-vertices longitude latitude [ longitude latitude ... ] 〉 [ ASC | DESC ] [ COUNT count [ ANY ] ]"] + #[doc = " https://valkey.io/commands/geosearch/"] + #[allow(async_fn_in_trait)] + async fn geosearch( + connection: &Connection, + key: String, + shape: GeosearchShape, + order: Option, + count: Option, + ) -> Result, ErrorCode> { + exchange(connection, ("GEOSEARCH", key, shape, order, count)).await + } + + #[doc = " Queries a geospatial index for members inside an area of a box, circle, or a polygon."] + #[doc = ""] + #[doc = " Returns each member with the details asked for, `WITHCOORD`, `WITHDIST` and `WITHHASH`."] + #[doc = ""] + #[doc = " See also `geosearch`."] + #[doc = ""] + #[doc = " GEOSEARCH key [ FROMMEMBER member | FROMLONLAT longitude latitude ] 〈 BYRADIUS radius 〈 m | km | ft | mi 〉 | BYBOX width height 〈 m | km | ft | mi 〉 | BYPOLYGON num-vertices longitude latitude [ longitude latitude ... ] 〉 [ ASC | DESC ] [ COUNT count [ ANY ] ] [ WITHCOORD ] [ WITHDIST ] [ WITHHASH ]"] + #[doc = " https://valkey.io/commands/geosearch/"] + #[allow(async_fn_in_trait)] + async fn geosearch_with( + connection: &Connection, + key: String, + shape: GeosearchShape, + order: Option, + count: Option, + details: GeosearchWithFlags, + ) -> Result, ErrorCode> { + let command = ( + "GEOSEARCH", + key, + shape, + order, + count, + Flag("WITHCOORD", details.contains(GeosearchWithFlags::WITHCOORD)), + Flag("WITHDIST", details.contains(GeosearchWithFlags::WITHDIST)), + Flag("WITHHASH", details.contains(GeosearchWithFlags::WITHHASH)), + ); + exchange(connection, command).await + } + + #[doc = " Queries a geospatial index for members inside an area of a box, a circle, or a polygon, optionally stores the result."] + #[doc = ""] + #[doc = " GEOSEARCHSTORE destination source [ FROMMEMBER member | FROMLONLAT longitude latitude ] 〈 BYRADIUS radius 〈 m | km | ft | mi 〉 | BYBOX width height 〈 m | km | ft | mi 〉 | BYPOLYGON num-vertices longitude latitude [ longitude latitude ... ] 〉 [ ASC | DESC ] [ COUNT count [ ANY ] ] [ STOREDIST ]"] + #[doc = " https://valkey.io/commands/geosearchstore/"] + #[allow(async_fn_in_trait)] + async fn geosearchstore( + connection: &Connection, + destination: String, + source: String, + shape: GeosearchShape, + order: Option, + count: Option, + storedist: bool, + ) -> Result { + exchange( + connection, + ( + "GEOSEARCHSTORE", + destination, + source, + shape, + order, + count, + Flag("STOREDIST", storedist), + ), + ) + .await + } + + #[doc = " Deletes one or more fields and their values from a hash."] + #[doc = ""] + #[doc = " HDEL key field [ field ... ]"] + #[doc = " https://valkey.io/commands/hdel/"] + #[allow(async_fn_in_trait)] + async fn hdel( + connection: &Connection, + key: String, + fields: Vec, + ) -> Result { + exchange(connection, ("HDEL", key, NonEmpty::new("fields", fields))).await + } + + #[doc = " Determines whether a field exists in a hash."] + #[doc = ""] + #[doc = " HEXISTS key field"] + #[doc = " https://valkey.io/commands/hexists/"] + #[allow(async_fn_in_trait)] + async fn hexists( + connection: &Connection, + key: String, + field: String, + ) -> Result { + exchange(connection, ("HEXISTS", key, field)).await + } + + #[doc = " Returns Unix timestamps in seconds since the epoch at which the given key's field(s) will expire."] + #[doc = ""] + #[doc = " HEXPIRETIME key FIELDS numfields field [ field ... ]"] + #[doc = " https://valkey.io/commands/hexpiretime/"] + #[allow(async_fn_in_trait)] + async fn hexpiretime( + connection: &Connection, + key: String, + fields: Vec, + ) -> Result, ErrorCode> { + exchange( + connection, + ("HEXPIRETIME", key, Fields(NonEmpty::new("fields", fields))), + ) + .await + } + + #[doc = " Returns the values of one or more fields and deletes them from a hash."] + #[doc = ""] + #[doc = " HGETDEL key FIELDS numfields field [ field ... ]"] + #[doc = " https://valkey.io/commands/hgetdel/"] + #[allow(async_fn_in_trait)] + async fn hgetdel( + connection: &Connection, + key: String, + fields: Vec, + ) -> Result>, ErrorCode> { + exchange( + connection, + ("HGETDEL", key, Fields(NonEmpty::new("fields", fields))), + ) + .await + } + + #[doc = " Gets the value of one or more fields of a given hash key, and optionally sets their expiration time or time-to-live (TTL)."] + #[doc = ""] + #[doc = " HGETEX key [ EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | PERSIST ] FIELDS numfields field [ field ... ]"] + #[doc = " https://valkey.io/commands/hgetex/"] + #[allow(async_fn_in_trait)] + async fn hgetex( + connection: &Connection, + key: String, + ttl: Option, + fields: Vec, + ) -> Result>, ErrorCode> { + exchange( + connection, + ("HGETEX", key, ttl, Fields(NonEmpty::new("fields", fields))), + ) + .await + } + + #[doc = " Remove the existing expiration on a hash key's field(s)."] + #[doc = ""] + #[doc = " HPERSIST key FIELDS numfields field [ field ... ]"] + #[doc = " https://valkey.io/commands/hpersist/"] + #[allow(async_fn_in_trait)] + async fn hpersist( + connection: &Connection, + key: String, + fields: Vec, + ) -> Result, ErrorCode> { + exchange( + connection, + ("HPERSIST", key, Fields(NonEmpty::new("fields", fields))), + ) + .await + } + + #[doc = " Sets the expiration time of one or more hash fields in milliseconds."] + #[doc = ""] + #[doc = " HPEXPIRE key milliseconds [ NX | XX | GT | LT ] FIELDS numfields field [ field ... ]"] + #[doc = " https://valkey.io/commands/hpexpire/"] + #[allow(async_fn_in_trait)] + async fn hpexpire( + connection: &Connection, + key: String, + milliseconds: DurationMilliseconds, + mode: Option, + fields: Vec, + ) -> Result, ErrorCode> { + let command = ( + "HPEXPIRE", + key, + milliseconds, + mode, + Fields(NonEmpty::new("fields", fields)), + ); + exchange(connection, command).await + } + + #[doc = " Sets the expiration time of one or more hash fields to a Unix timestamp in milliseconds."] + #[doc = ""] + #[doc = " HPEXPIREAT key unix-time-milliseconds [ NX | XX | GT | LT ] FIELDS numfields field [ field ... ]"] + #[doc = " https://valkey.io/commands/hpexpireat/"] + #[allow(async_fn_in_trait)] + async fn hpexpireat( + connection: &Connection, + key: String, + milliseconds: UnixTimeMilliseconds, + mode: Option, + fields: Vec, + ) -> Result, ErrorCode> { + let command = ( + "HPEXPIREAT", + key, + milliseconds, + mode, + Fields(NonEmpty::new("fields", fields)), + ); + exchange(connection, command).await + } + + #[doc = " Returns the Unix timestamp in milliseconds since Unix epoch at which the given key's field(s) will expire."] + #[doc = ""] + #[doc = " HPEXPIRETIME key FIELDS numfields field [ field ... ]"] + #[doc = " https://valkey.io/commands/hpexpiretime/"] + #[allow(async_fn_in_trait)] + async fn hpexpiretime( + connection: &Connection, + key: String, + fields: Vec, + ) -> Result, ErrorCode> { + exchange( + connection, + ("HPEXPIRETIME", key, Fields(NonEmpty::new("fields", fields))), + ) + .await + } + + #[doc = " Returns the remaining time to live in milliseconds of a hash key's field(s) that have an associated expiration."] + #[doc = ""] + #[doc = " HPTTL key FIELDS numfields field [ field ... ]"] + #[doc = " https://valkey.io/commands/hpttl/"] + #[allow(async_fn_in_trait)] + async fn hpttl( + connection: &Connection, + key: String, + fields: Vec, + ) -> Result, ErrorCode> { + exchange( + connection, + ("HPTTL", key, Fields(NonEmpty::new("fields", fields))), + ) + .await + } + + #[doc = " Sets the value of one or more fields of a given hash key, and optionally sets their expiration time."] + #[doc = ""] + #[doc = " HSETEX key [ NX | XX ] [ FNX | FXX ] [ EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL ] FIELDS numfields field value [ field value ... ]"] + #[doc = " https://valkey.io/commands/hsetex/"] + #[allow(async_fn_in_trait)] + async fn hsetex( + connection: &Connection, + key: String, + condition: Option, + field_condition: Option, + ttl: Option, + field_and_values: Vec<(String, StringOrBytes)>, + ) -> Result { + let command = ( + "HSETEX", + key, + condition, + field_condition, + ttl, + Fields(NonEmpty::new("field-and-values", field_and_values)), + ); + exchange(connection, command).await + } + + #[doc = " Returns the remaining time to live (in seconds) of a hash key's field(s) that have an associated expiration."] + #[doc = ""] + #[doc = " HTTL key FIELDS numfields field [ field ... ]"] + #[doc = " https://valkey.io/commands/httl/"] + #[allow(async_fn_in_trait)] + async fn httl( + connection: &Connection, + key: String, + fields: Vec, + ) -> Result, ErrorCode> { + exchange( + connection, + ("HTTL", key, Fields(NonEmpty::new("fields", fields))), + ) + .await + } + + #[doc = " Sets the expiration time of one or more hash fields in seconds."] + #[doc = ""] + #[doc = " HEXPIRE key seconds [ NX | XX | GT | LT ] FIELDS numfields field [ field ... ]"] + #[doc = " https://valkey.io/commands/hexpire/"] + #[allow(async_fn_in_trait)] + async fn hexpire( + connection: &Connection, + key: String, + seconds: DurationSeconds, + mode: Option, + fields: Vec, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "HEXPIRE", + key, + seconds, + mode, + Fields(NonEmpty::new("fields", fields)), + ), + ) + .await + } + + #[doc = " Sets the expiration time of one or more hash fields to a Unix timestamp."] + #[doc = ""] + #[doc = " HEXPIREAT key unix-time-seconds [ NX | XX | GT | LT ] FIELDS numfields field [ field ... ]"] + #[doc = " https://valkey.io/commands/hexpireat/"] + #[allow(async_fn_in_trait)] + async fn hexpireat( + connection: &Connection, + key: String, + seconds: UnixTimeSeconds, + mode: Option, + fields: Vec, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "HEXPIREAT", + key, + seconds, + mode, + Fields(NonEmpty::new("fields", fields)), + ), + ) + .await + } + + #[doc = " Returns the value of a field in a hash."] + #[doc = ""] + #[doc = " HGET key field"] + #[doc = " https://valkey.io/commands/hget/"] + #[allow(async_fn_in_trait)] + async fn hget( + connection: &Connection, + key: String, + field: String, + ) -> Result, ErrorCode> { + exchange(connection, ("HGET", key, field)).await + } + + #[doc = " Returns all fields and values in a hash."] + #[doc = ""] + #[doc = " HGETALL key"] + #[doc = " https://valkey.io/commands/hgetall/"] + #[allow(async_fn_in_trait)] + async fn hgetall( + connection: &Connection, + key: String, + ) -> Result, ErrorCode> { + exchange(connection, ("HGETALL", key)).await + } + + #[doc = " Increments the integer value of a field in a hash by a number."] + #[doc = ""] + #[doc = " HINCRBY key field increment"] + #[doc = " https://valkey.io/commands/hincrby/"] + #[allow(async_fn_in_trait)] + async fn hincrby( + connection: &Connection, + key: String, + field: String, + increment: Integer, + ) -> Result { + exchange(connection, ("HINCRBY", key, field, increment)).await + } + + #[doc = " Increments the floating point value of a field by a number."] + #[doc = ""] + #[doc = " HINCRBYFLOAT key field increment"] + #[doc = " https://valkey.io/commands/hincrbyfloat/"] + #[allow(async_fn_in_trait)] + async fn hincrbyfloat( + connection: &Connection, + key: String, + field: String, + increment: Double, + ) -> Result { + exchange(connection, ("HINCRBYFLOAT", key, field, increment)).await + } + + #[doc = " Returns all fields in a hash."] + #[doc = ""] + #[doc = " HKEYS key"] + #[doc = " https://valkey.io/commands/hkeys/"] + #[allow(async_fn_in_trait)] + async fn hkeys(connection: &Connection, key: String) -> Result, ErrorCode> { + exchange(connection, ("HKEYS", key)).await + } + + #[doc = " Returns the number of fields in a hash."] + #[doc = ""] + #[doc = " HLEN key"] + #[doc = " https://valkey.io/commands/hlen/"] + #[allow(async_fn_in_trait)] + async fn hlen(connection: &Connection, key: String) -> Result { + exchange(connection, ("HLEN", key)).await + } + + #[doc = " Returns the values of all fields in a hash."] + #[doc = ""] + #[doc = " HMGET key field [ field ... ]"] + #[doc = " https://valkey.io/commands/hmget/"] + #[allow(async_fn_in_trait)] + async fn hmget( + connection: &Connection, + key: String, + fields: Vec, + ) -> Result>, ErrorCode> { + exchange(connection, ("HMGET", key, NonEmpty::new("fields", fields))).await + } + + #[doc = " Sets the values of multiple fields."] + #[doc = ""] + #[doc = " HMSET key field value [ field value ... ]"] + #[doc = " https://valkey.io/commands/hmset/"] + #[allow(async_fn_in_trait)] + async fn hmset( + connection: &Connection, + key: String, + field_and_values: Vec<(String, StringOrBytes)>, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ( + "HMSET", + key, + NonEmpty::new("field-and-values", field_and_values), + ), + ) + .await + } + + #[doc = " Returns one or more random fields from a hash."] + #[doc = ""] + #[doc = " Without a count, at most one field."] + #[doc = ""] + #[doc = " See also `hrandfield-withvalues`."] + #[doc = ""] + #[doc = " HRANDFIELD key [ count ]"] + #[doc = " https://valkey.io/commands/hrandfield/"] + #[allow(async_fn_in_trait)] + async fn hrandfield( + connection: &Connection, + key: String, + count: Option, + ) -> Result, ErrorCode> { + if count.is_some() { + return exchange(connection, ("HRANDFIELD", key, count)).await; + } + // without a count the reply is the field itself, or null + let field: Option = exchange(connection, ("HRANDFIELD", key)).await?; + Ok(field.into_iter().collect()) + } + + #[doc = " Returns one or more random fields from a hash."] + #[doc = ""] + #[doc = " The `WITHVALUES` form, the fields and their values."] + #[doc = ""] + #[doc = " See also `hrandfield`."] + #[doc = ""] + #[doc = " HRANDFIELD key count WITHVALUES"] + #[doc = " https://valkey.io/commands/hrandfield/"] + #[allow(async_fn_in_trait)] + async fn hrandfield_withvalues( + connection: &Connection, + key: String, + count: Integer, + ) -> Result, ErrorCode> { + exchange(connection, ("HRANDFIELD", key, count, "WITHVALUES")).await + } + + #[doc = " Iterates over fields and values of a hash."] + #[doc = ""] + #[doc = " See also `hscan-novalues`."] + #[doc = ""] + #[doc = " HSCAN key cursor [ MATCH pattern ] [ COUNT count ]"] + #[doc = " https://valkey.io/commands/hscan/"] + #[allow(async_fn_in_trait)] + async fn hscan( + connection: &Connection, + key: String, + cursor: Cursor, + match_: Option, + count: Option, + ) -> Result<(Option, Map), ErrorCode> { + let (cursor, items): (NextCursor, Map) = exchange( + connection, + ( + "HSCAN", + key, + cursor, + match_.map(|m| ("MATCH", m)), + count.map(|c| ("COUNT", c)), + ), + ) + .await?; + Ok((cursor.0, items)) + } + + #[doc = " Iterates over fields and values of a hash."] + #[doc = ""] + #[doc = " The `NOVALUES` form, the fields only."] + #[doc = ""] + #[doc = " See also `hscan`."] + #[doc = ""] + #[doc = " HSCAN key cursor [ MATCH pattern ] [ COUNT count ] NOVALUES"] + #[doc = " https://valkey.io/commands/hscan/"] + #[allow(async_fn_in_trait)] + async fn hscan_novalues( + connection: &Connection, + key: String, + cursor: Cursor, + match_: Option, + count: Option, + ) -> Result<(Option, Vec), ErrorCode> { + let (cursor, items): (NextCursor, Vec) = exchange( + connection, + ( + "HSCAN", + key, + cursor, + match_.map(|m| ("MATCH", m)), + count.map(|c| ("COUNT", c)), + "NOVALUES", + ), + ) + .await?; + Ok((cursor.0, items)) + } + + #[doc = " Creates or modifies the value of a field in a hash."] + #[doc = ""] + #[doc = " HSET key field value [ field value ... ]"] + #[doc = " https://valkey.io/commands/hset/"] + #[allow(async_fn_in_trait)] + async fn hset( + connection: &Connection, + key: String, + field_and_values: Vec<(String, StringOrBytes)>, + ) -> Result { + exchange( + connection, + ( + "HSET", + key, + NonEmpty::new("field-and-values", field_and_values), + ), + ) + .await + } + + #[doc = " Sets the value of a field in a hash only when the field doesn't exist."] + #[doc = ""] + #[doc = " HSETNX key field value"] + #[doc = " https://valkey.io/commands/hsetnx/"] + #[allow(async_fn_in_trait)] + async fn hsetnx( + connection: &Connection, + key: String, + field: String, + value: StringOrBytes, + ) -> Result { + exchange(connection, ("HSETNX", key, field, value)).await + } + + #[doc = " Returns the length of the value of a field."] + #[doc = ""] + #[doc = " HSTRLEN key field"] + #[doc = " https://valkey.io/commands/hstrlen/"] + #[allow(async_fn_in_trait)] + async fn hstrlen( + connection: &Connection, + key: String, + field: String, + ) -> Result { + exchange(connection, ("HSTRLEN", key, field)).await + } + + #[doc = " Returns all values in a hash."] + #[doc = ""] + #[doc = " HVALS key"] + #[doc = " https://valkey.io/commands/hvals/"] + #[allow(async_fn_in_trait)] + async fn hvals(connection: &Connection, key: String) -> Result, ErrorCode> { + exchange(connection, ("HVALS", key)).await + } + + #[doc = " Adds elements to a HyperLogLog key."] + #[doc = ""] + #[doc = " PFADD key [ element [ element ... ] ]"] + #[doc = " https://valkey.io/commands/pfadd/"] + #[allow(async_fn_in_trait)] + async fn pfadd( + connection: &Connection, + key: String, + elements: Option>, + ) -> Result { + exchange( + connection, + ("PFADD", key, NonEmpty::optional("elements", elements)), + ) + .await + } + + #[doc = " Returns the approximated cardinality of the set(s) observed by the HyperLogLog key(s)."] + #[doc = ""] + #[doc = " PFCOUNT key [ key ... ]"] + #[doc = " https://valkey.io/commands/pfcount/"] + #[allow(async_fn_in_trait)] + async fn pfcount(connection: &Connection, keys: Vec) -> Result { + exchange(connection, ("PFCOUNT", NonEmpty::new("keys", keys))).await + } + + #[doc = " Merges one or more HyperLogLog values into a single key."] + #[doc = ""] + #[doc = " PFMERGE destkey [ sourcekey [ sourcekey ... ] ]"] + #[doc = " https://valkey.io/commands/pfmerge/"] + #[allow(async_fn_in_trait)] + async fn pfmerge( + connection: &Connection, + destination: String, + sources: Option>, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ( + "PFMERGE", + destination, + NonEmpty::optional("sources", sources), + ), + ) + .await + } + + #[doc = " Pops an element from a list, pushes it to another list and returns it."] + #[doc = ""] + #[doc = " Returns none when the timeout passed. The connection waits too, replies to commands sent"] + #[doc = " after it on the connection wait behind it. Use a connection of your own."] + #[doc = ""] + #[doc = " BLMOVE source destination 〈 LEFT | RIGHT 〉 〈 LEFT | RIGHT 〉 timeout"] + #[doc = " https://valkey.io/commands/blmove/"] + #[allow(async_fn_in_trait)] + async fn blmove( + connection: &Connection, + source: String, + destination: String, + wherefrom: ListSide, + whereto: ListSide, + timeout: DurationMilliseconds, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "BLMOVE", + source, + destination, + wherefrom, + whereto, + DecimalSeconds(timeout), + ), + ) + .await + } + + #[doc = " Pops the first element from one of multiple lists."] + #[doc = ""] + #[doc = " Returns none when the timeout passed. The connection waits too, replies to commands sent"] + #[doc = " after it on the connection wait behind it. Use a connection of your own."] + #[doc = ""] + #[doc = " BLMPOP timeout numkeys key [ key ... ] 〈 LEFT | RIGHT 〉 [ COUNT count ]"] + #[doc = " https://valkey.io/commands/blmpop/"] + #[allow(async_fn_in_trait)] + async fn blmpop( + connection: &Connection, + timeout: DurationMilliseconds, + keys: Vec, + end: ListSide, + count: Option, + ) -> Result)>, ErrorCode> { + exchange( + connection, + ( + "BLMPOP", + DecimalSeconds(timeout), + NumKeys(NonEmpty::new("keys", keys)), + end, + NamedOption("COUNT", count), + ), + ) + .await + } + + #[doc = " Removes and returns the first element in a list."] + #[doc = ""] + #[doc = " Returns none when the timeout passed. The connection waits too, replies to commands sent"] + #[doc = " after it on the connection wait behind it. Use a connection of your own."] + #[doc = ""] + #[doc = " BLPOP key [ key ... ] timeout"] + #[doc = " https://valkey.io/commands/blpop/"] + #[allow(async_fn_in_trait)] + async fn blpop( + connection: &Connection, + keys: Vec, + timeout: DurationMilliseconds, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "BLPOP", + NonEmpty::new("keys", keys), + DecimalSeconds(timeout), + ), + ) + .await + } + + #[doc = " Removes and returns the last element in a list."] + #[doc = ""] + #[doc = " Returns none when the timeout passed. The connection waits too, replies to commands sent"] + #[doc = " after it on the connection wait behind it. Use a connection of your own."] + #[doc = ""] + #[doc = " BRPOP key [ key ... ] timeout"] + #[doc = " https://valkey.io/commands/brpop/"] + #[allow(async_fn_in_trait)] + async fn brpop( + connection: &Connection, + keys: Vec, + timeout: DurationMilliseconds, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "BRPOP", + NonEmpty::new("keys", keys), + DecimalSeconds(timeout), + ), + ) + .await + } + + #[doc = " Pops an element from a list, pushes it to another list and returns it."] + #[doc = ""] + #[doc = " Returns none when the timeout passed. The connection waits too, replies to commands sent"] + #[doc = " after it on the connection wait behind it. Use a connection of your own."] + #[doc = ""] + #[doc = " BRPOPLPUSH source destination timeout"] + #[doc = " https://valkey.io/commands/brpoplpush/"] + #[allow(async_fn_in_trait)] + async fn brpoplpush( + connection: &Connection, + source: String, + destination: String, + timeout: DurationMilliseconds, + ) -> Result, ErrorCode> { + exchange( + connection, + ("BRPOPLPUSH", source, destination, DecimalSeconds(timeout)), + ) + .await + } + + #[doc = " Returns an element from a list by its index."] + #[doc = ""] + #[doc = " LINDEX key index"] + #[doc = " https://valkey.io/commands/lindex/"] + #[allow(async_fn_in_trait)] + async fn lindex( + connection: &Connection, + key: String, + index: Integer, + ) -> Result, ErrorCode> { + exchange(connection, ("LINDEX", key, index)).await + } + + #[doc = " Inserts an element before or after another element in a list."] + #[doc = ""] + #[doc = " LINSERT key 〈 BEFORE | AFTER 〉 pivot element"] + #[doc = " https://valkey.io/commands/linsert/"] + #[allow(async_fn_in_trait)] + async fn linsert( + connection: &Connection, + key: String, + position: LinsertPosition, + pivot: StringOrBytes, + element: StringOrBytes, + ) -> Result { + exchange(connection, ("LINSERT", key, position, pivot, element)).await + } + + #[doc = " Returns the length of a list."] + #[doc = ""] + #[doc = " LLEN key"] + #[doc = " https://valkey.io/commands/llen/"] + #[allow(async_fn_in_trait)] + async fn llen(connection: &Connection, key: String) -> Result { + exchange(connection, ("LLEN", key)).await + } + + #[doc = " Returns an element after popping it from one list and pushing it to another."] + #[doc = ""] + #[doc = " LMOVE source destination 〈 LEFT | RIGHT 〉 〈 LEFT | RIGHT 〉"] + #[doc = " https://valkey.io/commands/lmove/"] + #[allow(async_fn_in_trait)] + async fn lmove( + connection: &Connection, + source: String, + destination: String, + wherefrom: ListSide, + whereto: ListSide, + ) -> Result, ErrorCode> { + exchange( + connection, + ("LMOVE", source, destination, wherefrom, whereto), + ) + .await + } + + #[doc = " Returns multiple elements from a list after removing them."] + #[doc = ""] + #[doc = " LMPOP numkeys key [ key ... ] 〈 LEFT | RIGHT 〉 [ COUNT count ]"] + #[doc = " https://valkey.io/commands/lmpop/"] + #[allow(async_fn_in_trait)] + async fn lmpop( + connection: &Connection, + keys: Vec, + end: ListSide, + count: Option, + ) -> Result)>, ErrorCode> { + let command = ( + "LMPOP", + NumKeys(NonEmpty::new("keys", keys)), + end, + NamedOption("COUNT", count), + ); + exchange(connection, command).await + } + + #[doc = " Returns and removes one or more elements from the beginning of a list."] + #[doc = ""] + #[doc = " Without a count, at most one element."] + #[doc = ""] + #[doc = " LPOP key [ count ]"] + #[doc = " https://valkey.io/commands/lpop/"] + #[allow(async_fn_in_trait)] + async fn lpop( + connection: &Connection, + key: String, + count: Option, + ) -> Result, ErrorCode> { + match count { + Some(count) => { + // null when the key does not exist + let elements: Option> = + exchange(connection, ("LPOP", key, count)).await?; + Ok(elements.unwrap_or_default()) + } + None => { + // without a count the reply is the element itself, or null + let element: Option = exchange(connection, ("LPOP", key)).await?; + Ok(element.into_iter().collect()) + } + } + } + + #[doc = " Returns the index of matching elements in a list."] + #[doc = ""] + #[doc = " Without a count, at most one position."] + #[doc = ""] + #[doc = " LPOS key element [ RANK rank ] [ COUNT num-matches ] [ MAXLEN len ]"] + #[doc = " https://valkey.io/commands/lpos/"] + #[allow(async_fn_in_trait)] + async fn lpos( + connection: &Connection, + key: String, + element: StringOrBytes, + rank: Option, + count: Option, + maxlen: Option, + ) -> Result, ErrorCode> { + let command = ( + "LPOS", + key, + element, + NamedOption("RANK", rank), + NamedOption("COUNT", count), + NamedOption("MAXLEN", maxlen), + ); + + match count { + Some(_) => exchange(connection, command).await, + None => { + // without a count the reply is the position itself, or null + let position: Option = exchange(connection, command).await?; + Ok(position.into_iter().collect()) + } + } + } + + #[doc = " Prepends one or more elements to a list."] + #[doc = ""] + #[doc = " LPUSH key element [ element ... ]"] + #[doc = " https://valkey.io/commands/lpush/"] + #[allow(async_fn_in_trait)] + async fn lpush( + connection: &Connection, + key: String, + elements: Vec, + ) -> Result { + exchange( + connection, + ("LPUSH", key, NonEmpty::new("elements", elements)), + ) + .await + } + + #[doc = " Prepends one or more elements to a list only when the list exists."] + #[doc = ""] + #[doc = " LPUSHX key element [ element ... ]"] + #[doc = " https://valkey.io/commands/lpushx/"] + #[allow(async_fn_in_trait)] + async fn lpushx( + connection: &Connection, + key: String, + elements: Vec, + ) -> Result { + exchange( + connection, + ("LPUSHX", key, NonEmpty::new("elements", elements)), + ) + .await + } + + #[doc = " Returns a range of elements from a list."] + #[doc = ""] + #[doc = " LRANGE key start stop"] + #[doc = " https://valkey.io/commands/lrange/"] + #[allow(async_fn_in_trait)] + async fn lrange( + connection: &Connection, + key: String, + start: Integer, + stop: Integer, + ) -> Result, ErrorCode> { + exchange(connection, ("LRANGE", key, start, stop)).await + } + + #[doc = " Removes elements from a list."] + #[doc = ""] + #[doc = " LREM key count element"] + #[doc = " https://valkey.io/commands/lrem/"] + #[allow(async_fn_in_trait)] + async fn lrem( + connection: &Connection, + key: String, + count: Integer, + element: StringOrBytes, + ) -> Result { + exchange(connection, ("LREM", key, count, element)).await + } + + #[doc = " Sets the value of an element in a list by its index."] + #[doc = ""] + #[doc = " LSET key index element"] + #[doc = " https://valkey.io/commands/lset/"] + #[allow(async_fn_in_trait)] + async fn lset( + connection: &Connection, + key: String, + index: Integer, + element: StringOrBytes, + ) -> Result<(), ErrorCode> { + exchange(connection, ("LSET", key, index, element)).await + } + + #[doc = " Removes elements from both ends of a list."] + #[doc = ""] + #[doc = " LTRIM key start stop"] + #[doc = " https://valkey.io/commands/ltrim/"] + #[allow(async_fn_in_trait)] + async fn ltrim( + connection: &Connection, + key: String, + start: Integer, + stop: Integer, + ) -> Result<(), ErrorCode> { + exchange(connection, ("LTRIM", key, start, stop)).await + } + + #[doc = " Returns and removes one or more elements from the end of a list."] + #[doc = ""] + #[doc = " Without a count, at most one element."] + #[doc = ""] + #[doc = " RPOP key [ count ]"] + #[doc = " https://valkey.io/commands/rpop/"] + #[allow(async_fn_in_trait)] + async fn rpop( + connection: &Connection, + key: String, + count: Option, + ) -> Result, ErrorCode> { + match count { + Some(count) => { + // null when the key does not exist + let elements: Option> = + exchange(connection, ("RPOP", key, count)).await?; + Ok(elements.unwrap_or_default()) + } + None => { + // without a count the reply is the element itself, or null + let element: Option = exchange(connection, ("RPOP", key)).await?; + Ok(element.into_iter().collect()) + } + } + } + + #[doc = " Returns the last element of a list after removing and pushing it to another list."] + #[doc = ""] + #[doc = " RPOPLPUSH source destination"] + #[doc = " https://valkey.io/commands/rpoplpush/"] + #[allow(async_fn_in_trait)] + async fn rpoplpush( + connection: &Connection, + source: String, + destination: String, + ) -> Result, ErrorCode> { + exchange(connection, ("RPOPLPUSH", source, destination)).await + } + + #[doc = " Appends one or more elements to a list."] + #[doc = ""] + #[doc = " RPUSH key element [ element ... ]"] + #[doc = " https://valkey.io/commands/rpush/"] + #[allow(async_fn_in_trait)] + async fn rpush( + connection: &Connection, + key: String, + elements: Vec, + ) -> Result { + exchange( + connection, + ("RPUSH", key, NonEmpty::new("elements", elements)), + ) + .await + } + + #[doc = " Appends one or more elements to a list only when the list exists."] + #[doc = ""] + #[doc = " RPUSHX key element [ element ... ]"] + #[doc = " https://valkey.io/commands/rpushx/"] + #[allow(async_fn_in_trait)] + async fn rpushx( + connection: &Connection, + key: String, + elements: Vec, + ) -> Result { + exchange( + connection, + ("RPUSHX", key, NonEmpty::new("elements", elements)), + ) + .await + } + + #[doc = " Deletes one or more keys."] + #[doc = ""] + #[doc = " DEL key [ key ... ]"] + #[doc = " https://valkey.io/commands/del/"] + #[allow(async_fn_in_trait)] + async fn del(connection: &Connection, keys: Vec) -> Result { + exchange(connection, ("DEL", NonEmpty::new("keys", keys))).await + } + + #[doc = " Returns a serialized representation of the value stored at a key."] + #[doc = ""] + #[doc = " DUMP key"] + #[doc = " https://valkey.io/commands/dump/"] + #[allow(async_fn_in_trait)] + async fn dump(connection: &Connection, key: String) -> Result, ErrorCode> { + let serialized: Option = exchange(connection, ("DUMP", key)).await?; + Ok(serialized.map(|serialized| serialized.0)) + } + + #[doc = " Determines whether one or more keys exist."] + #[doc = ""] + #[doc = " EXISTS key [ key ... ]"] + #[doc = " https://valkey.io/commands/exists/"] + #[allow(async_fn_in_trait)] + async fn exists(connection: &Connection, keys: Vec) -> Result { + exchange(connection, ("EXISTS", NonEmpty::new("keys", keys))).await + } + + #[doc = " Sets the expiration time of a key in seconds."] + #[doc = ""] + #[doc = " EXPIRE key seconds [ NX | XX | GT | LT ]"] + #[doc = " https://valkey.io/commands/expire/"] + #[allow(async_fn_in_trait)] + async fn expire( + connection: &Connection, + key: String, + seconds: DurationSeconds, + mode: Option, + ) -> Result { + exchange(connection, ("EXPIRE", key, seconds, mode)).await + } + + #[doc = " Sets the expiration time of a key to a Unix timestamp."] + #[doc = ""] + #[doc = " EXPIREAT key unix-time-seconds [ NX | XX | GT | LT ]"] + #[doc = " https://valkey.io/commands/expireat/"] + #[allow(async_fn_in_trait)] + async fn expireat( + connection: &Connection, + key: String, + seconds: UnixTimeSeconds, + mode: Option, + ) -> Result { + exchange(connection, ("EXPIREAT", key, seconds, mode)).await + } + + #[doc = " Returns all key names that match a pattern."] + #[doc = ""] + #[doc = " KEYS pattern"] + #[doc = " https://valkey.io/commands/keys/"] + #[allow(async_fn_in_trait)] + async fn keys(connection: &Connection, pattern: String) -> Result, ErrorCode> { + exchange(connection, ("KEYS", pattern)).await + } + + #[doc = " Removes all keys from the target slot."] + #[doc = ""] + #[doc = " CLUSTER FLUSHSLOT slot [ ASYNC | SYNC ]"] + #[doc = " https://valkey.io/commands/cluster-flushslot/"] + #[allow(async_fn_in_trait)] + async fn cluster_flushslot( + connection: &Connection, + slot: u16, + mode: Option, + ) -> Result<(), ErrorCode> { + exchange(connection, ("CLUSTER", "FLUSHSLOT", slot, mode)).await + } + + #[doc = " Iterates over the keys in the cluster."] + #[doc = ""] + #[doc = " The cursor carries the slot, for clients to route each call to the node that owns it."] + #[doc = ""] + #[doc = " CLUSTERSCAN cursor [ MATCH match-pattern ] [ COUNT count ] [ TYPE type ] [ SLOT slot ]"] + #[doc = " https://valkey.io/commands/clusterscan/"] + #[allow(async_fn_in_trait)] + async fn clusterscan( + connection: &Connection, + cursor: Cursor, + match_: Option, + count: Option, + type_: Option, + slot: Option, + ) -> Result<(Option, Vec), ErrorCode> { + let command = ( + "CLUSTERSCAN", + cursor, + NamedOption("MATCH", match_), + NamedOption("COUNT", count), + NamedOption("TYPE", type_), + NamedOption("SLOT", slot), + ); + let (cursor, keys): (NextCursor, Vec) = exchange(connection, command).await?; + Ok((cursor.0, keys)) + } + + #[doc = " Copies the value of a key to a new key."] + #[doc = ""] + #[doc = " COPY source destination [ DB destination-db ] [ REPLACE ]"] + #[doc = " https://valkey.io/commands/copy/"] + #[allow(async_fn_in_trait)] + async fn copy( + connection: &Connection, + source: String, + destination: String, + destination_db: Option, + replace: bool, + ) -> Result { + exchange( + connection, + ( + "COPY", + source, + destination, + NamedOption("DB", destination_db), + Flag("REPLACE", replace), + ), + ) + .await + } + + #[doc = " Returns the number of keys in the database."] + #[doc = ""] + #[doc = " DBSIZE"] + #[doc = " https://valkey.io/commands/dbsize/"] + #[allow(async_fn_in_trait)] + async fn dbsize(connection: &Connection) -> Result { + exchange(connection, "DBSIZE").await + } + + #[doc = " Returns the expiration time of a key as a Unix timestamp."] + #[doc = ""] + #[doc = " EXPIRETIME key"] + #[doc = " https://valkey.io/commands/expiretime/"] + #[allow(async_fn_in_trait)] + async fn expiretime( + connection: &Connection, + key: String, + ) -> Result { + exchange(connection, ("EXPIRETIME", key)).await + } + + #[doc = " Removes all keys from all databases."] + #[doc = ""] + #[doc = " FLUSHALL [ ASYNC | SYNC ]"] + #[doc = " https://valkey.io/commands/flushall/"] + #[allow(async_fn_in_trait)] + async fn flushall(connection: &Connection, mode: Option) -> Result<(), ErrorCode> { + exchange(connection, ("FLUSHALL", mode)).await + } + + #[doc = " Removes all keys from the current database."] + #[doc = ""] + #[doc = " FLUSHDB [ ASYNC | SYNC ]"] + #[doc = " https://valkey.io/commands/flushdb/"] + #[allow(async_fn_in_trait)] + async fn flushdb(connection: &Connection, mode: Option) -> Result<(), ErrorCode> { + exchange(connection, ("FLUSHDB", mode)).await + } + + #[doc = " Atomically transfers a key from one instance to another."] + #[doc = ""] + #[doc = " False when none of the keys exist, the server's `NOKEY`."] + #[doc = ""] + #[doc = " MIGRATE host port \"\" destination-db timeout [ COPY ] [ REPLACE ] [ AUTH password | AUTH2 username password ] KEYS key [ key ... ]"] + #[doc = " https://valkey.io/commands/migrate/"] + #[allow(async_fn_in_trait)] + async fn migrate( + connection: &Connection, + host: String, + port: u16, + destination_db: Integer, + timeout: DurationMilliseconds, + options: MigrateFlags, + auth: Option, + keys: Vec, + ) -> Result { + // "" in place of the key, the keys follow KEYS + let command = ( + "MIGRATE", + host, + port, + "", + destination_db, + timeout, + Flag("COPY", options.contains(MigrateFlags::COPY)), + Flag("REPLACE", options.contains(MigrateFlags::REPLACE)), + auth, + ("KEYS", NonEmpty::new("keys", keys)), + ); + let migrated: Migrated = exchange(connection, command).await?; + Ok(migrated.0) + } + + #[doc = " Moves a key to another database."] + #[doc = ""] + #[doc = " MOVE key db [ REPLACE ]"] + #[doc = " https://valkey.io/commands/move/"] + #[allow(async_fn_in_trait)] + async fn move_( + connection: &Connection, + key: String, + db: Integer, + replace: bool, + ) -> Result { + exchange(connection, ("MOVE", key, db, Flag("REPLACE", replace))).await + } + + #[doc = " Returns the internal encoding of an object."] + #[doc = ""] + #[doc = " OBJECT ENCODING key"] + #[doc = " https://valkey.io/commands/object-encoding/"] + #[allow(async_fn_in_trait)] + async fn object_encoding( + connection: &Connection, + key: String, + ) -> Result, ErrorCode> { + exchange(connection, ("OBJECT", "ENCODING", key)).await + } + + #[doc = " Returns the logarithmic access frequency counter of an object."] + #[doc = ""] + #[doc = " OBJECT FREQ key"] + #[doc = " https://valkey.io/commands/object-freq/"] + #[allow(async_fn_in_trait)] + async fn object_freq( + connection: &Connection, + key: String, + ) -> Result, ErrorCode> { + exchange(connection, ("OBJECT", "FREQ", key)).await + } + + #[doc = " Returns the time since the last access to an object."] + #[doc = ""] + #[doc = " OBJECT IDLETIME key"] + #[doc = " https://valkey.io/commands/object-idletime/"] + #[allow(async_fn_in_trait)] + async fn object_idletime( + connection: &Connection, + key: String, + ) -> Result, ErrorCode> { + exchange(connection, ("OBJECT", "IDLETIME", key)).await + } + + #[doc = " Returns the reference count of a value of a key."] + #[doc = ""] + #[doc = " OBJECT REFCOUNT key"] + #[doc = " https://valkey.io/commands/object-refcount/"] + #[allow(async_fn_in_trait)] + async fn object_refcount( + connection: &Connection, + key: String, + ) -> Result, ErrorCode> { + exchange(connection, ("OBJECT", "REFCOUNT", key)).await + } + + #[doc = " Removes the expiration time of a key."] + #[doc = ""] + #[doc = " PERSIST key"] + #[doc = " https://valkey.io/commands/persist/"] + #[allow(async_fn_in_trait)] + async fn persist(connection: &Connection, key: String) -> Result { + exchange(connection, ("PERSIST", key)).await + } + + #[doc = " Sets the expiration time of a key in milliseconds."] + #[doc = ""] + #[doc = " PEXPIRE key milliseconds [ NX | XX | GT | LT ]"] + #[doc = " https://valkey.io/commands/pexpire/"] + #[allow(async_fn_in_trait)] + async fn pexpire( + connection: &Connection, + key: String, + milliseconds: DurationMilliseconds, + mode: Option, + ) -> Result { + exchange(connection, ("PEXPIRE", key, milliseconds, mode)).await + } + + #[doc = " Sets the expiration time of a key to a Unix milliseconds timestamp."] + #[doc = ""] + #[doc = " PEXPIREAT key unix-time-milliseconds [ NX | XX | GT | LT ]"] + #[doc = " https://valkey.io/commands/pexpireat/"] + #[allow(async_fn_in_trait)] + async fn pexpireat( + connection: &Connection, + key: String, + milliseconds: UnixTimeMilliseconds, + mode: Option, + ) -> Result { + exchange(connection, ("PEXPIREAT", key, milliseconds, mode)).await + } + + #[doc = " Returns the expiration time of a key as a Unix milliseconds timestamp."] + #[doc = ""] + #[doc = " PEXPIRETIME key"] + #[doc = " https://valkey.io/commands/pexpiretime/"] + #[allow(async_fn_in_trait)] + async fn pexpiretime( + connection: &Connection, + key: String, + ) -> Result { + exchange(connection, ("PEXPIRETIME", key)).await + } + + #[doc = " Returns the expiration time in milliseconds of a key."] + #[doc = ""] + #[doc = " PTTL key"] + #[doc = " https://valkey.io/commands/pttl/"] + #[allow(async_fn_in_trait)] + async fn pttl(connection: &Connection, key: String) -> Result { + exchange(connection, ("PTTL", key)).await + } + + #[doc = " Returns a random key name from the database."] + #[doc = ""] + #[doc = " RANDOMKEY"] + #[doc = " https://valkey.io/commands/randomkey/"] + #[allow(async_fn_in_trait)] + async fn randomkey(connection: &Connection) -> Result, ErrorCode> { + exchange(connection, "RANDOMKEY").await + } + + #[doc = " Renames a key and overwrites the destination."] + #[doc = ""] + #[doc = " RENAME key newkey"] + #[doc = " https://valkey.io/commands/rename/"] + #[allow(async_fn_in_trait)] + async fn rename(connection: &Connection, key: String, newkey: String) -> Result<(), ErrorCode> { + exchange(connection, ("RENAME", key, newkey)).await + } + + #[doc = " Renames a key only when the target key name doesn't exist."] + #[doc = ""] + #[doc = " RENAMENX key newkey"] + #[doc = " https://valkey.io/commands/renamenx/"] + #[allow(async_fn_in_trait)] + async fn renamenx( + connection: &Connection, + key: String, + newkey: String, + ) -> Result { + exchange(connection, ("RENAMENX", key, newkey)).await + } + + #[doc = " Creates a key from the serialized representation of a value."] + #[doc = ""] + #[doc = " A `ttl` of none restores the key without an expiration, `0`, `expire-at` adds `ABSTTL`."] + #[doc = ""] + #[doc = " RESTORE key ttl serialized-value [ REPLACE ] [ ABSTTL ] [ IDLETIME seconds ] [ FREQ frequency ]"] + #[doc = " https://valkey.io/commands/restore/"] + #[allow(async_fn_in_trait)] + async fn restore( + connection: &Connection, + key: String, + ttl: Option, + serialized_value: Bytes, + replace: bool, + idletime: Option, + freq: Option, + ) -> Result<(), ErrorCode> { + let (ttl, absttl) = restore_ttl(ttl); + exchange( + connection, + ( + "RESTORE", + key, + ttl, + StringOrBytes::Bytes(serialized_value), + Flag("REPLACE", replace), + Flag("ABSTTL", absttl), + NamedOption("IDLETIME", idletime), + NamedOption("FREQ", freq), + ), + ) + .await + } + + #[doc = " An internal command for migrating keys in a cluster."] + #[doc = ""] + #[doc = " A `ttl` of none restores the key without an expiration, `0`, `expire-at` adds `ABSTTL`."] + #[doc = ""] + #[doc = " RESTORE-ASKING key ttl serialized-value [ REPLACE ] [ ABSTTL ] [ IDLETIME seconds ] [ FREQ frequency ]"] + #[doc = " https://valkey.io/commands/restore-asking/"] + #[allow(async_fn_in_trait)] + async fn restore_asking( + connection: &Connection, + key: String, + ttl: Option, + serialized_value: Bytes, + replace: bool, + idletime: Option, + freq: Option, + ) -> Result<(), ErrorCode> { + let (ttl, absttl) = restore_ttl(ttl); + exchange( + connection, + ( + "RESTORE-ASKING", + key, + ttl, + StringOrBytes::Bytes(serialized_value), + Flag("REPLACE", replace), + Flag("ABSTTL", absttl), + NamedOption("IDLETIME", idletime), + NamedOption("FREQ", freq), + ), + ) + .await + } + + #[doc = " Iterates over the key names in the database."] + #[doc = ""] + #[doc = " SCAN cursor [ MATCH pattern ] [ COUNT count ] [ TYPE type ]"] + #[doc = " https://valkey.io/commands/scan/"] + #[allow(async_fn_in_trait)] + async fn scan( + connection: &Connection, + cursor: Cursor, + match_: Option, + count: Option, + type_: Option, + ) -> Result<(Option, Vec), ErrorCode> { + let command = ( + "SCAN", + cursor, + NamedOption("MATCH", match_), + NamedOption("COUNT", count), + NamedOption("TYPE", type_), + ); + let (cursor, keys): (NextCursor, Vec) = exchange(connection, command).await?; + Ok((cursor.0, keys)) + } + + #[doc = " Swaps two databases."] + #[doc = ""] + #[doc = " SWAPDB index1 index2"] + #[doc = " https://valkey.io/commands/swapdb/"] + #[allow(async_fn_in_trait)] + async fn swapdb( + connection: &Connection, + index1: Integer, + index2: Integer, + ) -> Result<(), ErrorCode> { + exchange(connection, ("SWAPDB", index1, index2)).await + } + + #[doc = " Returns the number of existing keys out of those specified after updating the time they were last accessed."] + #[doc = ""] + #[doc = " TOUCH key [ key ... ]"] + #[doc = " https://valkey.io/commands/touch/"] + #[allow(async_fn_in_trait)] + async fn touch(connection: &Connection, keys: Vec) -> Result { + exchange(connection, ("TOUCH", NonEmpty::new("keys", keys))).await + } + + #[doc = " Returns the expiration time in seconds of a key."] + #[doc = ""] + #[doc = " TTL key"] + #[doc = " https://valkey.io/commands/ttl/"] + #[allow(async_fn_in_trait)] + async fn ttl(connection: &Connection, key: String) -> Result { + exchange(connection, ("TTL", key)).await + } + + #[doc = " Determines the type of value stored at a key."] + #[doc = ""] + #[doc = " None when the key does not exist, the server's `none`."] + #[doc = ""] + #[doc = " TYPE key"] + #[doc = " https://valkey.io/commands/type/"] + #[allow(async_fn_in_trait)] + async fn type_(connection: &Connection, key: String) -> Result, ErrorCode> { + let key_type: OptionalKeyType = exchange(connection, ("TYPE", key)).await?; + Ok(key_type.0) + } + + #[doc = " Asynchronously deletes one or more keys."] + #[doc = ""] + #[doc = " UNLINK key [ key ... ]"] + #[doc = " https://valkey.io/commands/unlink/"] + #[allow(async_fn_in_trait)] + async fn unlink(connection: &Connection, keys: Vec) -> Result { + exchange(connection, ("UNLINK", NonEmpty::new("keys", keys))).await + } + + #[doc = " Posts a message to a channel."] + #[doc = ""] + #[doc = " PUBLISH channel message"] + #[doc = " https://valkey.io/commands/publish/"] + #[allow(async_fn_in_trait)] + async fn publish( + connection: &Connection, + channel: String, + message: StringOrBytes, + ) -> Result { + exchange(connection, ("PUBLISH", channel, message)).await + } + + #[doc = " Returns the active channels."] + #[doc = ""] + #[doc = " PUBSUB CHANNELS [ pattern ]"] + #[doc = " https://valkey.io/commands/pubsub-channels/"] + #[allow(async_fn_in_trait)] + async fn pubsub_channels( + connection: &Connection, + pattern: Option, + ) -> Result, ErrorCode> { + exchange(connection, ("PUBSUB", "CHANNELS", pattern)).await + } + + #[doc = " Returns a count of unique pattern subscriptions."] + #[doc = ""] + #[doc = " PUBSUB NUMPAT"] + #[doc = " https://valkey.io/commands/pubsub-numpat/"] + #[allow(async_fn_in_trait)] + async fn pubsub_numpat(connection: &Connection) -> Result { + exchange(connection, ("PUBSUB", "NUMPAT")).await + } + + #[doc = " Returns a count of subscribers to channels."] + #[doc = ""] + #[doc = " PUBSUB NUMSUB [ channel ] [ [ channel ] ... ]"] + #[doc = " https://valkey.io/commands/pubsub-numsub/"] + #[allow(async_fn_in_trait)] + async fn pubsub_numsub( + connection: &Connection, + channels: Option>, + ) -> Result, ErrorCode> { + exchange( + connection, + ("PUBSUB", "NUMSUB", NonEmpty::optional("channels", channels)), + ) + .await + } + + #[doc = " Returns the active shard channels."] + #[doc = ""] + #[doc = " PUBSUB SHARDCHANNELS [ pattern ]"] + #[doc = " https://valkey.io/commands/pubsub-shardchannels/"] + #[allow(async_fn_in_trait)] + async fn pubsub_shardchannels( + connection: &Connection, + pattern: Option, + ) -> Result, ErrorCode> { + exchange(connection, ("PUBSUB", "SHARDCHANNELS", pattern)).await + } + + #[doc = " Returns the count of subscribers of shard channels."] + #[doc = ""] + #[doc = " PUBSUB SHARDNUMSUB [ shardchannel [ shardchannel ... ] ]"] + #[doc = " https://valkey.io/commands/pubsub-shardnumsub/"] + #[allow(async_fn_in_trait)] + async fn pubsub_shardnumsub( + connection: &Connection, + shardchannels: Option>, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "PUBSUB", + "SHARDNUMSUB", + NonEmpty::optional("shardchannels", shardchannels), + ), + ) + .await + } + + #[doc = " Executes a server-side Lua script."] + #[doc = ""] + #[doc = " An error the script returns fails the call."] + #[doc = ""] + #[doc = " EVAL script numkeys [ key [ key ... ] ] [ arg [ arg ... ] ]"] + #[doc = " https://valkey.io/commands/eval/"] + #[allow(async_fn_in_trait)] + async fn eval( + connection: &Connection, + script: String, + keys: Option>, + args: Option>, + ) -> Result { + exchange(connection, ("EVAL", script, KeysAndArgs(keys, args))).await + } + + #[doc = " Executes a read-only server-side Lua script."] + #[doc = ""] + #[doc = " An error the script returns fails the call."] + #[doc = ""] + #[doc = " EVAL_RO script numkeys [ key [ key ... ] ] [ arg [ arg ... ] ]"] + #[doc = " https://valkey.io/commands/eval_ro/"] + #[allow(async_fn_in_trait)] + async fn eval_ro( + connection: &Connection, + script: String, + keys: Option>, + args: Option>, + ) -> Result { + exchange(connection, ("EVAL_RO", script, KeysAndArgs(keys, args))).await + } + + #[doc = " Executes a server-side Lua script by SHA1 digest."] + #[doc = ""] + #[doc = " An error the script returns fails the call."] + #[doc = ""] + #[doc = " EVALSHA sha1 numkeys [ key [ key ... ] ] [ arg [ arg ... ] ]"] + #[doc = " https://valkey.io/commands/evalsha/"] + #[allow(async_fn_in_trait)] + async fn evalsha( + connection: &Connection, + sha1: String, + keys: Option>, + args: Option>, + ) -> Result { + exchange(connection, ("EVALSHA", sha1, KeysAndArgs(keys, args))).await + } + + #[doc = " Executes a read-only server-side Lua script by SHA1 digest."] + #[doc = ""] + #[doc = " An error the script returns fails the call."] + #[doc = ""] + #[doc = " EVALSHA_RO sha1 numkeys [ key [ key ... ] ] [ arg [ arg ... ] ]"] + #[doc = " https://valkey.io/commands/evalsha_ro/"] + #[allow(async_fn_in_trait)] + async fn evalsha_ro( + connection: &Connection, + sha1: String, + keys: Option>, + args: Option>, + ) -> Result { + exchange(connection, ("EVALSHA_RO", sha1, KeysAndArgs(keys, args))).await + } + + #[doc = " Invokes a function."] + #[doc = ""] + #[doc = " An error the function returns fails the call."] + #[doc = ""] + #[doc = " FCALL function numkeys [ key [ key ... ] ] [ arg [ arg ... ] ]"] + #[doc = " https://valkey.io/commands/fcall/"] + #[allow(async_fn_in_trait)] + async fn fcall( + connection: &Connection, + function: String, + keys: Option>, + args: Option>, + ) -> Result { + exchange(connection, ("FCALL", function, KeysAndArgs(keys, args))).await + } + + #[doc = " Invokes a read-only function."] + #[doc = ""] + #[doc = " An error the function returns fails the call."] + #[doc = ""] + #[doc = " FCALL_RO function numkeys [ key [ key ... ] ] [ arg [ arg ... ] ]"] + #[doc = " https://valkey.io/commands/fcall_ro/"] + #[allow(async_fn_in_trait)] + async fn fcall_ro( + connection: &Connection, + function: String, + keys: Option>, + args: Option>, + ) -> Result { + exchange(connection, ("FCALL_RO", function, KeysAndArgs(keys, args))).await + } + + #[doc = " Deletes a library and its functions."] + #[doc = ""] + #[doc = " FUNCTION DELETE library-name"] + #[doc = " https://valkey.io/commands/function-delete/"] + #[allow(async_fn_in_trait)] + async fn function_delete( + connection: &Connection, + library_name: String, + ) -> Result<(), ErrorCode> { + exchange(connection, ("FUNCTION", "DELETE", library_name)).await + } + + #[doc = " Dumps all libraries into a serialized binary payload."] + #[doc = ""] + #[doc = " FUNCTION DUMP"] + #[doc = " https://valkey.io/commands/function-dump/"] + #[allow(async_fn_in_trait)] + async fn function_dump(connection: &Connection) -> Result { + let serialized: Binary = exchange(connection, ("FUNCTION", "DUMP")).await?; + Ok(serialized.0) + } + + #[doc = " Deletes all libraries and functions."] + #[doc = ""] + #[doc = " FUNCTION FLUSH [ ASYNC | SYNC ]"] + #[doc = " https://valkey.io/commands/function-flush/"] + #[allow(async_fn_in_trait)] + async fn function_flush( + connection: &Connection, + mode: Option, + ) -> Result<(), ErrorCode> { + exchange(connection, ("FUNCTION", "FLUSH", mode)).await + } + + #[doc = " Terminates a function during execution."] + #[doc = ""] + #[doc = " FUNCTION KILL"] + #[doc = " https://valkey.io/commands/function-kill/"] + #[allow(async_fn_in_trait)] + async fn function_kill(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, ("FUNCTION", "KILL")).await + } + + #[doc = " Returns information about all libraries."] + #[doc = ""] + #[doc = " FUNCTION LIST [ LIBRARYNAME library-name-pattern ] [ WITHCODE ]"] + #[doc = " https://valkey.io/commands/function-list/"] + #[allow(async_fn_in_trait)] + async fn function_list( + connection: &Connection, + library_name_pattern: Option, + withcode: bool, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "FUNCTION", + "LIST", + NamedOption("LIBRARYNAME", library_name_pattern), + Flag("WITHCODE", withcode), + ), + ) + .await + } + + #[doc = " Creates a library."] + #[doc = ""] + #[doc = " FUNCTION LOAD [ REPLACE ] function-code"] + #[doc = " https://valkey.io/commands/function-load/"] + #[allow(async_fn_in_trait)] + async fn function_load( + connection: &Connection, + replace: bool, + function_code: String, + ) -> Result { + exchange( + connection, + ("FUNCTION", "LOAD", Flag("REPLACE", replace), function_code), + ) + .await + } + + #[doc = " Restores all libraries from a payload."] + #[doc = ""] + #[doc = " FUNCTION RESTORE serialized-value [ FLUSH | APPEND | REPLACE ]"] + #[doc = " https://valkey.io/commands/function-restore/"] + #[allow(async_fn_in_trait)] + async fn function_restore( + connection: &Connection, + serialized_value: Bytes, + policy: Option, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ( + "FUNCTION", + "RESTORE", + StringOrBytes::Bytes(serialized_value), + policy, + ), + ) + .await + } + + #[doc = " Returns information about a function during execution."] + #[doc = ""] + #[doc = " FUNCTION STATS"] + #[doc = " https://valkey.io/commands/function-stats/"] + #[allow(async_fn_in_trait)] + async fn function_stats(connection: &Connection) -> Result { + exchange(connection, ("FUNCTION", "STATS")).await + } + + #[doc = " Determines whether server-side Lua scripts exist in the script cache."] + #[doc = ""] + #[doc = " SCRIPT EXISTS sha1 [ sha1 ... ]"] + #[doc = " https://valkey.io/commands/script-exists/"] + #[allow(async_fn_in_trait)] + async fn script_exists( + connection: &Connection, + sha1s: Vec, + ) -> Result, ErrorCode> { + exchange( + connection, + ("SCRIPT", "EXISTS", NonEmpty::new("sha1s", sha1s)), + ) + .await + } + + #[doc = " Removes all server-side Lua scripts from the script cache."] + #[doc = ""] + #[doc = " SCRIPT FLUSH [ ASYNC | SYNC ]"] + #[doc = " https://valkey.io/commands/script-flush/"] + #[allow(async_fn_in_trait)] + async fn script_flush( + connection: &Connection, + mode: Option, + ) -> Result<(), ErrorCode> { + exchange(connection, ("SCRIPT", "FLUSH", mode)).await + } + + #[doc = " Terminates a server-side Lua script during execution."] + #[doc = ""] + #[doc = " SCRIPT KILL"] + #[doc = " https://valkey.io/commands/script-kill/"] + #[allow(async_fn_in_trait)] + async fn script_kill(connection: &Connection) -> Result<(), ErrorCode> { + exchange(connection, ("SCRIPT", "KILL")).await + } + + #[doc = " Loads a server-side Lua script to the script cache."] + #[doc = ""] + #[doc = " SCRIPT LOAD script"] + #[doc = " https://valkey.io/commands/script-load/"] + #[allow(async_fn_in_trait)] + async fn script_load(connection: &Connection, script: String) -> Result { + exchange(connection, ("SCRIPT", "LOAD", script)).await + } + + #[doc = " Show server-side Lua script in the script cache."] + #[doc = ""] + #[doc = " SCRIPT SHOW sha1"] + #[doc = " https://valkey.io/commands/script-show/"] + #[allow(async_fn_in_trait)] + async fn script_show(connection: &Connection, sha1: String) -> Result { + exchange(connection, ("SCRIPT", "SHOW", sha1)).await + } + + #[doc = " Posts a message to a shard channel."] + #[doc = ""] + #[doc = " SPUBLISH shardchannel message"] + #[doc = " https://valkey.io/commands/spublish/"] + #[allow(async_fn_in_trait)] + async fn spublish( + connection: &Connection, + shardchannel: String, + message: StringOrBytes, + ) -> Result { + exchange(connection, ("SPUBLISH", shardchannel, message)).await + } + + #[doc = " Appends a string to the value of a key."] + #[doc = ""] + #[doc = " APPEND key value"] + #[doc = " https://valkey.io/commands/append/"] + #[allow(async_fn_in_trait)] + async fn append( + connection: &Connection, + key: String, + value: StringOrBytes, + ) -> Result { + exchange(connection, ("APPEND", key, value)).await + } + + #[doc = " Decrements the integer value of a key by one."] + #[doc = ""] + #[doc = " DECR key"] + #[doc = " https://valkey.io/commands/decr/"] + #[allow(async_fn_in_trait)] + async fn decr(connection: &Connection, key: String) -> Result { + exchange(connection, ("DECR", key)).await + } + + #[doc = " Decrements the integer value of a key by a number."] + #[doc = ""] + #[doc = " DECRBY key decrement"] + #[doc = " https://valkey.io/commands/decrby/"] + #[allow(async_fn_in_trait)] + async fn decrby( + connection: &Connection, + key: String, + decrement: Integer, + ) -> Result { + exchange(connection, ("DECRBY", key, decrement)).await + } + + #[doc = " Delete key if value matches string."] + #[doc = ""] + #[doc = " DELIFEQ key value"] + #[doc = " https://valkey.io/commands/delifeq/"] + #[allow(async_fn_in_trait)] + async fn delifeq( + connection: &Connection, + key: String, + value: StringOrBytes, + ) -> Result { + exchange(connection, ("DELIFEQ", key, value)).await + } + + #[doc = " Returns the string value of a key."] + #[doc = ""] + #[doc = " GET key"] + #[doc = " https://valkey.io/commands/get/"] + #[allow(async_fn_in_trait)] + async fn get(connection: &Connection, key: String) -> Result, ErrorCode> { + exchange(connection, ("GET", key)).await + } + + #[doc = " Returns the string value of a key after deleting the key."] + #[doc = ""] + #[doc = " GETDEL key"] + #[doc = " https://valkey.io/commands/getdel/"] + #[allow(async_fn_in_trait)] + async fn getdel( + connection: &Connection, + key: String, + ) -> Result, ErrorCode> { + exchange(connection, ("GETDEL", key)).await + } + + #[doc = " Returns the string value of a key after setting its expiration time."] + #[doc = ""] + #[doc = " GETEX key [ EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | PERSIST ]"] + #[doc = " https://valkey.io/commands/getex/"] + #[allow(async_fn_in_trait)] + async fn getex( + connection: &Connection, + key: String, + ttl: Option, + ) -> Result, ErrorCode> { + exchange(connection, ("GETEX", key, ttl)).await + } + + #[doc = " Returns a substring of the string stored at a key."] + #[doc = ""] + #[doc = " GETRANGE key start end"] + #[doc = " https://valkey.io/commands/getrange/"] + #[allow(async_fn_in_trait)] + async fn getrange( + connection: &Connection, + key: String, + start: Integer, + end: Integer, + ) -> Result { + exchange(connection, ("GETRANGE", key, start, end)).await + } + + #[doc = " Returns the previous string value of a key after setting it to a new value."] + #[doc = ""] + #[doc = " GETSET key value"] + #[doc = " https://valkey.io/commands/getset/"] + #[allow(async_fn_in_trait)] + async fn getset( + connection: &Connection, + key: String, + value: StringOrBytes, + ) -> Result, ErrorCode> { + exchange(connection, ("GETSET", key, value)).await + } + + #[doc = " Increments the integer value of a key by one."] + #[doc = ""] + #[doc = " INCR key"] + #[doc = " https://valkey.io/commands/incr/"] + #[allow(async_fn_in_trait)] + async fn incr(connection: &Connection, key: String) -> Result { + exchange(connection, ("INCR", key)).await + } + + #[doc = " Increments the integer value of a key by a number."] + #[doc = ""] + #[doc = " INCRBY key increment"] + #[doc = " https://valkey.io/commands/incrby/"] + #[allow(async_fn_in_trait)] + async fn incrby( + connection: &Connection, + key: String, + increment: Integer, + ) -> Result { + exchange(connection, ("INCRBY", key, increment)).await + } + + #[doc = " Increments the floating point value of a key by a number."] + #[doc = ""] + #[doc = " INCRBYFLOAT key increment"] + #[doc = " https://valkey.io/commands/incrbyfloat/"] + #[allow(async_fn_in_trait)] + async fn incrbyfloat( + connection: &Connection, + key: String, + increment: Double, + ) -> Result { + exchange(connection, ("INCRBYFLOAT", key, increment)).await + } + + #[doc = " Finds the longest common substring."] + #[doc = ""] + #[doc = " See also `lcs-len` and `lcs-idx`."] + #[doc = ""] + #[doc = " LCS key1 key2"] + #[doc = " https://valkey.io/commands/lcs/"] + #[allow(async_fn_in_trait)] + async fn lcs( + connection: &Connection, + key1: String, + key2: String, + ) -> Result { + exchange(connection, ("LCS", key1, key2)).await + } + + #[doc = " Finds the longest common substring."] + #[doc = ""] + #[doc = " The `LEN` form, the length only."] + #[doc = ""] + #[doc = " See also `lcs` and `lcs-idx`."] + #[doc = ""] + #[doc = " LCS key1 key2 LEN"] + #[doc = " https://valkey.io/commands/lcs/"] + #[allow(async_fn_in_trait)] + async fn lcs_len( + connection: &Connection, + key1: String, + key2: String, + ) -> Result { + exchange(connection, ("LCS", key1, key2, "LEN")).await + } + + #[doc = " Finds the longest common substring."] + #[doc = ""] + #[doc = " The `IDX` form, the ranges of the matches."] + #[doc = ""] + #[doc = " See also `lcs` and `lcs-len`."] + #[doc = ""] + #[doc = " LCS key1 key2 IDX [ MINMATCHLEN min-match-len ] [ WITHMATCHLEN ]"] + #[doc = " https://valkey.io/commands/lcs/"] + #[allow(async_fn_in_trait)] + async fn lcs_idx( + connection: &Connection, + key1: String, + key2: String, + min_match_len: Option, + with_match_len: bool, + ) -> Result { + exchange( + connection, + ( + "LCS", + key1, + key2, + "IDX", + min_match_len.map(|len| ("MINMATCHLEN", len)), + Flag("WITHMATCHLEN", with_match_len), + ), + ) + .await + } + + #[doc = " Atomically returns the string values of one or more keys."] + #[doc = ""] + #[doc = " MGET key [ key ... ]"] + #[doc = " https://valkey.io/commands/mget/"] + #[allow(async_fn_in_trait)] + async fn mget( + connection: &Connection, + keys: Vec, + ) -> Result>, ErrorCode> { + exchange(connection, ("MGET", NonEmpty::new("keys", keys))).await + } + + #[doc = " Atomically creates or modifies the string values of one or more keys."] + #[doc = ""] + #[doc = " MSET key value [ key value ... ]"] + #[doc = " https://valkey.io/commands/mset/"] + #[allow(async_fn_in_trait)] + async fn mset( + connection: &Connection, + keys_and_values: Vec<(String, StringOrBytes)>, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ("MSET", NonEmpty::new("keys-and-values", keys_and_values)), + ) + .await + } + + #[doc = " Atomically creates or modifies the string values of one or more keys, and optionally set their expiration."] + #[doc = ""] + #[doc = " MSETEX numkeys key value [ key value ... ] [ NX | XX ] [ EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL ]"] + #[doc = " https://valkey.io/commands/msetex/"] + #[allow(async_fn_in_trait)] + async fn msetex( + connection: &Connection, + keys_and_values: Vec<(String, StringOrBytes)>, + precondition: Option, + ttl: Option, + ) -> Result { + exchange( + connection, + ( + "MSETEX", + NumKeys(NonEmpty::new("keys-and-values", keys_and_values)), + precondition, + ttl, + ), + ) + .await + } + + #[doc = " Atomically modifies the string values of one or more keys only when all keys don't exist."] + #[doc = ""] + #[doc = " MSETNX key value [ key value ... ]"] + #[doc = " https://valkey.io/commands/msetnx/"] + #[allow(async_fn_in_trait)] + async fn msetnx( + connection: &Connection, + keys_and_values: Vec<(String, StringOrBytes)>, + ) -> Result { + exchange( + connection, + ("MSETNX", NonEmpty::new("keys-and-values", keys_and_values)), + ) + .await + } + + #[doc = " Sets both string value and expiration time in milliseconds of a key."] + #[doc = ""] + #[doc = " PSETEX key milliseconds value"] + #[doc = " https://valkey.io/commands/psetex/"] + #[allow(async_fn_in_trait)] + async fn psetex( + connection: &Connection, + key: String, + milliseconds: DurationMilliseconds, + value: StringOrBytes, + ) -> Result<(), ErrorCode> { + exchange(connection, ("PSETEX", key, milliseconds, value)).await + } + + #[doc = " Sets the string value of a key, ignoring its type."] + #[doc = ""] + #[doc = " Returns false when the precondition is not met, the key is unchanged."] + #[doc = ""] + #[doc = " See also `set-get`."] + #[doc = ""] + #[doc = " SET key value [ NX | XX | IFEQ comparison-value | IFNE comparison-not-equal ] [ EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL ]"] + #[doc = " https://valkey.io/commands/set/"] + #[allow(async_fn_in_trait)] + async fn set( + connection: &Connection, + key: String, + value: StringOrBytes, + precondition: Option, + ttl: Option, + ) -> Result { + // OK once set, null when the precondition is not met + let reply: Option<()> = + exchange(connection, ("SET", key, value, precondition, ttl)).await?; + Ok(reply.is_some()) + } + + #[doc = " Sets the string value of a key, ignoring its type."] + #[doc = ""] + #[doc = " The `GET` form, returning the previous value, none when the key did not exist."] + #[doc = ""] + #[doc = " See also `set`."] + #[doc = ""] + #[doc = " SET key value [ NX | XX | IFEQ comparison-value | IFNE comparison-not-equal ] GET [ EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL ]"] + #[doc = " https://valkey.io/commands/set/"] + #[allow(async_fn_in_trait)] + async fn set_get( + connection: &Connection, + key: String, + value: StringOrBytes, + precondition: Option, + ttl: Option, + ) -> Result, ErrorCode> { + exchange(connection, ("SET", key, value, precondition, "GET", ttl)).await + } + + #[doc = " Sets the string value and expiration time of a key."] + #[doc = ""] + #[doc = " SETEX key seconds value"] + #[doc = " https://valkey.io/commands/setex/"] + #[allow(async_fn_in_trait)] + async fn setex( + connection: &Connection, + key: String, + seconds: DurationSeconds, + value: StringOrBytes, + ) -> Result<(), ErrorCode> { + exchange(connection, ("SETEX", key, seconds, value)).await + } + + #[doc = " Sets the string value of a key only when the key doesn't exist."] + #[doc = ""] + #[doc = " SETNX key value"] + #[doc = " https://valkey.io/commands/setnx/"] + #[allow(async_fn_in_trait)] + async fn setnx( + connection: &Connection, + key: String, + value: StringOrBytes, + ) -> Result { + exchange(connection, ("SETNX", key, value)).await + } + + #[doc = " Overwrites a part of a string value with another by an offset."] + #[doc = ""] + #[doc = " SETRANGE key offset value"] + #[doc = " https://valkey.io/commands/setrange/"] + #[allow(async_fn_in_trait)] + async fn setrange( + connection: &Connection, + key: String, + offset: u32, + value: StringOrBytes, + ) -> Result { + exchange(connection, ("SETRANGE", key, offset, value)).await + } + + #[doc = " Returns the length of a string value."] + #[doc = ""] + #[doc = " STRLEN key"] + #[doc = " https://valkey.io/commands/strlen/"] + #[allow(async_fn_in_trait)] + async fn strlen(connection: &Connection, key: String) -> Result { + exchange(connection, ("STRLEN", key)).await + } + + #[doc = " Returns a substring from a string value."] + #[doc = ""] + #[doc = " SUBSTR key start end"] + #[doc = " https://valkey.io/commands/substr/"] + #[allow(async_fn_in_trait)] + async fn substr( + connection: &Connection, + key: String, + start: Integer, + end: Integer, + ) -> Result { + exchange(connection, ("SUBSTR", key, start, end)).await + } + + #[doc = " Returns the number of messages that were successfully acknowledged by the consumer group member of a stream."] + #[doc = ""] + #[doc = " XACK key group ID [ ID ... ]"] + #[doc = " https://valkey.io/commands/xack/"] + #[allow(async_fn_in_trait)] + async fn xack( + connection: &Connection, + key: String, + group: String, + ids: Vec, + ) -> Result { + exchange(connection, ("XACK", key, group, NonEmpty::new("ids", ids))).await + } + + #[doc = " Appends a new message to a stream."] + #[doc = ""] + #[doc = " XADD key [ NOMKSTREAM ] [ 〈 MAXLEN | MINID 〉 [ = | ~ ] threshold [ LIMIT count ] ] 〈 * | id 〉 field value [ field value ... ]"] + #[doc = " https://valkey.io/commands/xadd/"] + #[allow(async_fn_in_trait)] + async fn xadd( + connection: &Connection, + key: String, + nomkstream: bool, + trim: Option, + id: StreamId, + field_and_values: Vec<(String, StringOrBytes)>, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "XADD", + key, + Flag("NOMKSTREAM", nomkstream), + trim, + id, + NonEmpty::new("field-and-values", field_and_values), + ), + ) + .await + } + + #[doc = " Changes, or acquires, ownership of messages in a consumer group, as if the messages were delivered to a consumer group member."] + #[doc = ""] + #[doc = " A start of none scans from the beginning, `0-0`. The next start is returned, none once the"] + #[doc = " scan is complete."] + #[doc = ""] + #[doc = " See also `xautoclaim-justid`."] + #[doc = ""] + #[doc = " XAUTOCLAIM key group consumer min-idle-time start [ COUNT count ]"] + #[doc = " https://valkey.io/commands/xautoclaim/"] + #[allow(async_fn_in_trait)] + async fn xautoclaim( + connection: &Connection, + key: String, + group: String, + consumer: String, + min_idle_time: DurationMilliseconds, + start: Option, + count: Option, + ) -> Result< + ( + Option, + Vec<(StreamId, Map)>, + Vec, + ), + ErrorCode, + > { + let (next, claimed, deleted): (OrZeroId, _, _) = exchange( + connection, + ( + "XAUTOCLAIM", + key, + group, + consumer, + min_idle_time, + OrZeroId(start), + NamedOption("COUNT", count), + ), + ) + .await?; + Ok((next.0, claimed, deleted)) + } + + #[doc = " Changes, or acquires, ownership of messages in a consumer group, as if the messages were delivered to a consumer group member."] + #[doc = ""] + #[doc = " The `JUSTID` form, the IDs only. A start of none scans from the beginning, `0-0`. The next"] + #[doc = " start is returned, none once the scan is complete."] + #[doc = ""] + #[doc = " See also `xautoclaim`."] + #[doc = ""] + #[doc = " XAUTOCLAIM key group consumer min-idle-time start [ COUNT count ] JUSTID"] + #[doc = " https://valkey.io/commands/xautoclaim/"] + #[allow(async_fn_in_trait)] + async fn xautoclaim_justid( + connection: &Connection, + key: String, + group: String, + consumer: String, + min_idle_time: DurationMilliseconds, + start: Option, + count: Option, + ) -> Result<(Option, Vec, Vec), ErrorCode> { + let (next, claimed, deleted): (OrZeroId, _, _) = exchange( + connection, + ( + "XAUTOCLAIM", + key, + group, + consumer, + min_idle_time, + OrZeroId(start), + NamedOption("COUNT", count), + "JUSTID", + ), + ) + .await?; + Ok((next.0, claimed, deleted)) + } + + #[doc = " Changes, or acquires, ownership of a message in a consumer group, as if the message was delivered to a consumer group member."] + #[doc = ""] + #[doc = " See also `xclaim-justid`."] + #[doc = ""] + #[doc = " XCLAIM key group consumer min-idle-time ID [ ID ... ] [ IDLE ms ] [ TIME unix-time-milliseconds ] [ RETRYCOUNT count ] [ FORCE ] [ LASTID lastid ]"] + #[doc = " https://valkey.io/commands/xclaim/"] + #[allow(async_fn_in_trait)] + async fn xclaim( + connection: &Connection, + key: String, + group: String, + consumer: String, + min_idle_time: DurationMilliseconds, + ids: Vec, + opts: Option, + ) -> Result)>, ErrorCode> { + exchange( + connection, + ( + "XCLAIM", + key, + group, + consumer, + min_idle_time, + NonEmpty::new("ids", ids), + opts, + ), + ) + .await + } + + #[doc = " Changes, or acquires, ownership of a message in a consumer group, as if the message was delivered to a consumer group member."] + #[doc = ""] + #[doc = " The `JUSTID` form, the IDs only."] + #[doc = ""] + #[doc = " See also `xclaim`."] + #[doc = ""] + #[doc = " XCLAIM key group consumer min-idle-time ID [ ID ... ] [ IDLE ms ] [ TIME unix-time-milliseconds ] [ RETRYCOUNT count ] [ FORCE ] JUSTID [ LASTID lastid ]"] + #[doc = " https://valkey.io/commands/xclaim/"] + #[allow(async_fn_in_trait)] + async fn xclaim_justid( + connection: &Connection, + key: String, + group: String, + consumer: String, + min_idle_time: DurationMilliseconds, + ids: Vec, + opts: Option, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "XCLAIM", + key, + group, + consumer, + min_idle_time, + NonEmpty::new("ids", ids), + opts, + ), + ) + .await + } + + #[doc = " Returns the number of messages after removing them from a stream."] + #[doc = ""] + #[doc = " XDEL key ID [ ID ... ]"] + #[doc = " https://valkey.io/commands/xdel/"] + #[allow(async_fn_in_trait)] + async fn xdel( + connection: &Connection, + key: String, + ids: Vec, + ) -> Result { + exchange(connection, ("XDEL", key, NonEmpty::new("ids", ids))).await + } + + #[doc = " Creates a consumer group."] + #[doc = ""] + #[doc = " XGROUP CREATE key group 〈 id | $ 〉 [ MKSTREAM ] [ ENTRIESREAD entriesread ]"] + #[doc = " https://valkey.io/commands/xgroup-create/"] + #[allow(async_fn_in_trait)] + async fn xgroup_create( + connection: &Connection, + key: String, + group: String, + id: StreamId, + mkstream: bool, + entriesread: Option, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ( + "XGROUP", + "CREATE", + key, + group, + id, + Flag("MKSTREAM", mkstream), + NamedOption("ENTRIESREAD", entriesread), + ), + ) + .await + } + + #[doc = " Creates a consumer in a consumer group."] + #[doc = ""] + #[doc = " XGROUP CREATECONSUMER key group consumer"] + #[doc = " https://valkey.io/commands/xgroup-createconsumer/"] + #[allow(async_fn_in_trait)] + async fn xgroup_createconsumer( + connection: &Connection, + key: String, + group: String, + consumer: String, + ) -> Result { + exchange( + connection, + ("XGROUP", "CREATECONSUMER", key, group, consumer), + ) + .await + } + + #[doc = " Deletes a consumer from a consumer group."] + #[doc = ""] + #[doc = " XGROUP DELCONSUMER key group consumer"] + #[doc = " https://valkey.io/commands/xgroup-delconsumer/"] + #[allow(async_fn_in_trait)] + async fn xgroup_delconsumer( + connection: &Connection, + key: String, + group: String, + consumer: String, + ) -> Result { + exchange(connection, ("XGROUP", "DELCONSUMER", key, group, consumer)).await + } + + #[doc = " Destroys a consumer group."] + #[doc = ""] + #[doc = " XGROUP DESTROY key group"] + #[doc = " https://valkey.io/commands/xgroup-destroy/"] + #[allow(async_fn_in_trait)] + async fn xgroup_destroy( + connection: &Connection, + key: String, + group: String, + ) -> Result { + exchange(connection, ("XGROUP", "DESTROY", key, group)).await + } + + #[doc = " Sets the last-delivered ID of a consumer group."] + #[doc = ""] + #[doc = " XGROUP SETID key group 〈 id | $ 〉 [ ENTRIESREAD entriesread ]"] + #[doc = " https://valkey.io/commands/xgroup-setid/"] + #[allow(async_fn_in_trait)] + async fn xgroup_setid( + connection: &Connection, + key: String, + group: String, + id: StreamId, + entriesread: Option, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ( + "XGROUP", + "SETID", + key, + group, + id, + NamedOption("ENTRIESREAD", entriesread), + ), + ) + .await + } + + #[doc = " Returns a list of the consumers in a consumer group."] + #[doc = ""] + #[doc = " XINFO CONSUMERS key group"] + #[doc = " https://valkey.io/commands/xinfo-consumers/"] + #[allow(async_fn_in_trait)] + async fn xinfo_consumers( + connection: &Connection, + key: String, + group: String, + ) -> Result, ErrorCode> { + exchange(connection, ("XINFO", "CONSUMERS", key, group)).await + } + + #[doc = " Returns a list of the consumer groups of a stream."] + #[doc = ""] + #[doc = " XINFO GROUPS key"] + #[doc = " https://valkey.io/commands/xinfo-groups/"] + #[allow(async_fn_in_trait)] + async fn xinfo_groups( + connection: &Connection, + key: String, + ) -> Result, ErrorCode> { + exchange(connection, ("XINFO", "GROUPS", key)).await + } + + #[doc = " Returns information about a stream."] + #[doc = ""] + #[doc = " See also `xinfo-stream-full`."] + #[doc = ""] + #[doc = " XINFO STREAM key"] + #[doc = " https://valkey.io/commands/xinfo-stream/"] + #[allow(async_fn_in_trait)] + async fn xinfo_stream( + connection: &Connection, + key: String, + ) -> Result { + exchange(connection, ("XINFO", "STREAM", key)).await + } + + #[doc = " Returns information about a stream."] + #[doc = ""] + #[doc = " The `FULL` form, with the entries, groups and consumers."] + #[doc = ""] + #[doc = " See also `xinfo-stream`."] + #[doc = ""] + #[doc = " XINFO STREAM key FULL [ COUNT count ]"] + #[doc = " https://valkey.io/commands/xinfo-stream/"] + #[allow(async_fn_in_trait)] + async fn xinfo_stream_full( + connection: &Connection, + key: String, + count: Option, + ) -> Result { + exchange( + connection, + ("XINFO", "STREAM", key, "FULL", NamedOption("COUNT", count)), + ) + .await + } + + #[doc = " Returns the number of messages in a stream."] + #[doc = ""] + #[doc = " XLEN key"] + #[doc = " https://valkey.io/commands/xlen/"] + #[allow(async_fn_in_trait)] + async fn xlen(connection: &Connection, key: String) -> Result { + exchange(connection, ("XLEN", key)).await + } + + #[doc = " Returns the information and entries from a stream consumer group's pending entries list."] + #[doc = ""] + #[doc = " The summary form."] + #[doc = ""] + #[doc = " See also `xpending-extended`."] + #[doc = ""] + #[doc = " XPENDING key group"] + #[doc = " https://valkey.io/commands/xpending/"] + #[allow(async_fn_in_trait)] + async fn xpending( + connection: &Connection, + key: String, + group: String, + ) -> Result { + exchange(connection, ("XPENDING", key, group)).await + } + + #[doc = " Returns the information and entries from a stream consumer group's pending entries list."] + #[doc = ""] + #[doc = " The extended form, the pending entries in the range."] + #[doc = ""] + #[doc = " See also `xpending`."] + #[doc = ""] + #[doc = " XPENDING key group [ IDLE min-idle-time ] start end count [ consumer ]"] + #[doc = " https://valkey.io/commands/xpending/"] + #[allow(async_fn_in_trait)] + async fn xpending_extended( + connection: &Connection, + key: String, + group: String, + min_idle_time: Option, + start: StreamId, + end: StreamId, + count: Integer, + consumer: Option, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "XPENDING", + key, + group, + NamedOption("IDLE", min_idle_time), + start, + end, + count, + consumer, + ), + ) + .await + } + + #[doc = " Returns the messages from a stream within a range of IDs."] + #[doc = ""] + #[doc = " XRANGE key start end [ COUNT count ]"] + #[doc = " https://valkey.io/commands/xrange/"] + #[allow(async_fn_in_trait)] + async fn xrange( + connection: &Connection, + key: String, + start: StreamId, + end: StreamId, + count: Option, + ) -> Result)>, ErrorCode> { + exchange( + connection, + ("XRANGE", key, start, end, NamedOption("COUNT", count)), + ) + .await + } + + #[doc = " Returns messages from multiple streams with IDs greater than the ones requested."] + #[doc = ""] + #[doc = " With `block`, returns none when the timeout passed. The connection waits too, replies to"] + #[doc = " commands sent after it on the connection wait behind it. Use a connection of your own."] + #[doc = ""] + #[doc = " XREAD [ COUNT count ] [ BLOCK milliseconds ] STREAMS key [ key ... ] ID [ ID ... ]"] + #[doc = " https://valkey.io/commands/xread/"] + #[allow(async_fn_in_trait)] + async fn xread( + connection: &Connection, + count: Option, + block: Option, + keys: Vec, + ids: Vec, + ) -> Result)>>>, ErrorCode> { + exchange( + connection, + ( + "XREAD", + NamedOption("COUNT", count), + NamedOption("BLOCK", block), + "STREAMS", + NonEmpty::new("keys", keys), + NonEmpty::new("ids", ids), + ), + ) + .await + } + + #[doc = " Returns new or historical messages from a stream for a consumer in a group."] + #[doc = ""] + #[doc = " The fields of an entry are none when it was deleted after it was delivered, e.g. when"] + #[doc = " reading the pending entries with the ID `0`."] + #[doc = ""] + #[doc = " With `block`, returns none when the timeout passed. The connection waits too, replies to"] + #[doc = " commands sent after it on the connection wait behind it. Use a connection of your own."] + #[doc = ""] + #[doc = " XREADGROUP GROUP group consumer [ COUNT count ] [ BLOCK milliseconds ] [ NOACK ] STREAMS key [ key ... ] ID [ ID ... ]"] + #[doc = " https://valkey.io/commands/xreadgroup/"] + #[allow(async_fn_in_trait)] + async fn xreadgroup( + connection: &Connection, + group: String, + consumer: String, + count: Option, + block: Option, + noack: bool, + keys: Vec, + ids: Vec, + ) -> Result>)>>>, ErrorCode> + { + exchange( + connection, + ( + "XREADGROUP", + "GROUP", + group, + consumer, + NamedOption("COUNT", count), + NamedOption("BLOCK", block), + Flag("NOACK", noack), + "STREAMS", + NonEmpty::new("keys", keys), + NonEmpty::new("ids", ids), + ), + ) + .await + } + + #[doc = " Returns the messages from a stream within a range of IDs in reverse order."] + #[doc = ""] + #[doc = " XREVRANGE key end start [ COUNT count ]"] + #[doc = " https://valkey.io/commands/xrevrange/"] + #[allow(async_fn_in_trait)] + async fn xrevrange( + connection: &Connection, + key: String, + end: StreamId, + start: StreamId, + count: Option, + ) -> Result)>, ErrorCode> { + exchange( + connection, + ("XREVRANGE", key, end, start, NamedOption("COUNT", count)), + ) + .await + } + + #[doc = " An internal command for replicating stream values."] + #[doc = ""] + #[doc = " XSETID key last-id [ ENTRIESADDED entries-added ] [ MAXDELETEDID max-deleted-id ]"] + #[doc = " https://valkey.io/commands/xsetid/"] + #[allow(async_fn_in_trait)] + async fn xsetid( + connection: &Connection, + key: String, + last_id: StreamId, + entries_added: Option, + max_deleted_id: Option, + ) -> Result<(), ErrorCode> { + exchange( + connection, + ( + "XSETID", + key, + last_id, + NamedOption("ENTRIESADDED", entries_added), + NamedOption("MAXDELETEDID", max_deleted_id), + ), + ) + .await + } + + #[doc = " Deletes messages from the beginning of a stream."] + #[doc = ""] + #[doc = " XTRIM key 〈 MAXLEN | MINID 〉 [ = | ~ ] threshold [ LIMIT count ]"] + #[doc = " https://valkey.io/commands/xtrim/"] + #[allow(async_fn_in_trait)] + async fn xtrim( + connection: &Connection, + key: String, + trim: StreamTrim, + ) -> Result { + exchange(connection, ("XTRIM", key, trim)).await + } + + #[doc = " Adds one or more members to a set."] + #[doc = ""] + #[doc = " SADD key member [ member ... ]"] + #[doc = " https://valkey.io/commands/sadd/"] + #[allow(async_fn_in_trait)] + async fn sadd( + connection: &Connection, + key: String, + members: Vec, + ) -> Result { + exchange(connection, ("SADD", key, NonEmpty::new("members", members))).await + } + + #[doc = " Returns the number of members in a set."] + #[doc = ""] + #[doc = " SCARD key"] + #[doc = " https://valkey.io/commands/scard/"] + #[allow(async_fn_in_trait)] + async fn scard(connection: &Connection, key: String) -> Result { + exchange(connection, ("SCARD", key)).await + } + + #[doc = " Returns the difference of multiple sets."] + #[doc = ""] + #[doc = " SDIFF key [ key ... ]"] + #[doc = " https://valkey.io/commands/sdiff/"] + #[allow(async_fn_in_trait)] + async fn sdiff( + connection: &Connection, + keys: Vec, + ) -> Result, ErrorCode> { + exchange(connection, ("SDIFF", NonEmpty::new("keys", keys))).await + } + + #[doc = " Stores the difference of multiple sets in a key."] + #[doc = ""] + #[doc = " SDIFFSTORE destination key [ key ... ]"] + #[doc = " https://valkey.io/commands/sdiffstore/"] + #[allow(async_fn_in_trait)] + async fn sdiffstore( + connection: &Connection, + destination: String, + keys: Vec, + ) -> Result { + exchange( + connection, + ("SDIFFSTORE", destination, NonEmpty::new("keys", keys)), + ) + .await + } + + #[doc = " Returns the intersect of multiple sets."] + #[doc = ""] + #[doc = " SINTER key [ key ... ]"] + #[doc = " https://valkey.io/commands/sinter/"] + #[allow(async_fn_in_trait)] + async fn sinter( + connection: &Connection, + keys: Vec, + ) -> Result, ErrorCode> { + exchange(connection, ("SINTER", NonEmpty::new("keys", keys))).await + } + + #[doc = " Returns the number of members of the intersect of multiple sets."] + #[doc = ""] + #[doc = " SINTERCARD numkeys key [ key ... ] [ LIMIT limit ]"] + #[doc = " https://valkey.io/commands/sintercard/"] + #[allow(async_fn_in_trait)] + async fn sintercard( + connection: &Connection, + keys: Vec, + limit: Option, + ) -> Result { + exchange( + connection, + ( + "SINTERCARD", + NumKeys(NonEmpty::new("keys", keys)), + NamedOption("LIMIT", limit), + ), + ) + .await + } + + #[doc = " Stores the intersect of multiple sets in a key."] + #[doc = ""] + #[doc = " SINTERSTORE destination key [ key ... ]"] + #[doc = " https://valkey.io/commands/sinterstore/"] + #[allow(async_fn_in_trait)] + async fn sinterstore( + connection: &Connection, + destination: String, + keys: Vec, + ) -> Result { + exchange( + connection, + ("SINTERSTORE", destination, NonEmpty::new("keys", keys)), + ) + .await + } + + #[doc = " Determines whether a member belongs to a set."] + #[doc = ""] + #[doc = " False when the key does not exist."] + #[doc = ""] + #[doc = " See also `sismember-xx`."] + #[doc = ""] + #[doc = " SISMEMBER key member"] + #[doc = " https://valkey.io/commands/sismember/"] + #[allow(async_fn_in_trait)] + async fn sismember( + connection: &Connection, + key: String, + member: StringOrBytes, + ) -> Result { + exchange(connection, ("SISMEMBER", key, member)).await + } + + #[doc = " Determines whether a member belongs to a set."] + #[doc = ""] + #[doc = " The `XX` form, none when the key does not exist, rather than false."] + #[doc = ""] + #[doc = " See also `sismember`."] + #[doc = ""] + #[doc = " SISMEMBER key member XX"] + #[doc = " https://valkey.io/commands/sismember/"] + #[allow(async_fn_in_trait)] + async fn sismember_xx( + connection: &Connection, + key: String, + member: StringOrBytes, + ) -> Result, ErrorCode> { + // -1 when the key does not exist + let member: OrNever = exchange(connection, ("SISMEMBER", key, member, "XX")).await?; + Ok(member.0) + } + + #[doc = " Returns all members of a set."] + #[doc = ""] + #[doc = " SMEMBERS key"] + #[doc = " https://valkey.io/commands/smembers/"] + #[allow(async_fn_in_trait)] + async fn smembers( + connection: &Connection, + key: String, + ) -> Result, ErrorCode> { + exchange(connection, ("SMEMBERS", key)).await + } + + #[doc = " Determines whether multiple members belong to a set."] + #[doc = ""] + #[doc = " SMISMEMBER key member [ member ... ]"] + #[doc = " https://valkey.io/commands/smismember/"] + #[allow(async_fn_in_trait)] + async fn smismember( + connection: &Connection, + key: String, + members: Vec, + ) -> Result, ErrorCode> { + exchange( + connection, + ("SMISMEMBER", key, NonEmpty::new("members", members)), + ) + .await + } + + #[doc = " Moves a member from one set to another."] + #[doc = ""] + #[doc = " SMOVE source destination member"] + #[doc = " https://valkey.io/commands/smove/"] + #[allow(async_fn_in_trait)] + async fn smove( + connection: &Connection, + source: String, + destination: String, + member: StringOrBytes, + ) -> Result { + exchange(connection, ("SMOVE", source, destination, member)).await + } + + #[doc = " Returns one or more random members from a set after removing them."] + #[doc = ""] + #[doc = " Without a count, at most one member."] + #[doc = ""] + #[doc = " SPOP key [ count ]"] + #[doc = " https://valkey.io/commands/spop/"] + #[allow(async_fn_in_trait)] + async fn spop( + connection: &Connection, + key: String, + count: Option, + ) -> Result, ErrorCode> { + if count.is_some() { + return exchange(connection, ("SPOP", key, count)).await; + } + // without a count the reply is the member itself, or null + let member: Option = exchange(connection, ("SPOP", key)).await?; + Ok(member.into_iter().collect()) + } + + #[doc = " Gets one or multiple random members from a set."] + #[doc = ""] + #[doc = " Without a count, at most one member."] + #[doc = ""] + #[doc = " SRANDMEMBER key [ count ]"] + #[doc = " https://valkey.io/commands/srandmember/"] + #[allow(async_fn_in_trait)] + async fn srandmember( + connection: &Connection, + key: String, + count: Option, + ) -> Result, ErrorCode> { + if count.is_some() { + return exchange(connection, ("SRANDMEMBER", key, count)).await; + } + // without a count the reply is the member itself, or null + let member: Option = exchange(connection, ("SRANDMEMBER", key)).await?; + Ok(member.into_iter().collect()) + } + + #[doc = " Removes one or more members from a set."] + #[doc = ""] + #[doc = " SREM key member [ member ... ]"] + #[doc = " https://valkey.io/commands/srem/"] + #[allow(async_fn_in_trait)] + async fn srem( + connection: &Connection, + key: String, + members: Vec, + ) -> Result { + exchange(connection, ("SREM", key, NonEmpty::new("members", members))).await + } + + #[doc = " Iterates over members of a set."] + #[doc = ""] + #[doc = " SSCAN key cursor [ MATCH pattern ] [ COUNT count ]"] + #[doc = " https://valkey.io/commands/sscan/"] + #[allow(async_fn_in_trait)] + async fn sscan( + connection: &Connection, + key: String, + cursor: Cursor, + match_: Option, + count: Option, + ) -> Result<(Option, Vec), ErrorCode> { + let (cursor, items): (NextCursor, Vec) = exchange( + connection, + ( + "SSCAN", + key, + cursor, + NamedOption("MATCH", match_), + NamedOption("COUNT", count), + ), + ) + .await?; + Ok((cursor.0, items)) + } + + #[doc = " Returns the union of multiple sets."] + #[doc = ""] + #[doc = " SUNION key [ key ... ]"] + #[doc = " https://valkey.io/commands/sunion/"] + #[allow(async_fn_in_trait)] + async fn sunion( + connection: &Connection, + keys: Vec, + ) -> Result, ErrorCode> { + exchange(connection, ("SUNION", NonEmpty::new("keys", keys))).await + } + + #[doc = " Stores the union of multiple sets in a key."] + #[doc = ""] + #[doc = " SUNIONSTORE destination key [ key ... ]"] + #[doc = " https://valkey.io/commands/sunionstore/"] + #[allow(async_fn_in_trait)] + async fn sunionstore( + connection: &Connection, + destination: String, + keys: Vec, + ) -> Result { + exchange( + connection, + ("SUNIONSTORE", destination, NonEmpty::new("keys", keys)), + ) + .await + } + + #[doc = " Sorts the elements in a list, a set, or a sorted set, optionally storing the result."] + #[doc = ""] + #[doc = " Each row is the element, or with `get`, the value of each pattern, none when its key does"] + #[doc = " not exist."] + #[doc = ""] + #[doc = " See also `sort-store`."] + #[doc = ""] + #[doc = " SORT key [ BY pattern ] [ LIMIT offset count ] [ GET pattern ... ] [ ASC | DESC ] [ ALPHA ]"] + #[doc = " https://valkey.io/commands/sort/"] + #[allow(async_fn_in_trait)] + async fn sort( + connection: &Connection, + key: String, + opts: Option, + ) -> Result>>, ErrorCode> { + let row = sort_row_len(&opts); + let values: Vec> = exchange(connection, ("SORT", key, opts)).await?; + Ok(rows(values, row)) + } + + #[doc = " Sorts the elements in a list, a set, or a sorted set, optionally storing the result."] + #[doc = ""] + #[doc = " The `STORE` form, storing the elements as a list, returning how many."] + #[doc = ""] + #[doc = " See also `sort`."] + #[doc = ""] + #[doc = " SORT key [ BY pattern ] [ LIMIT offset count ] [ GET pattern ... ] [ ASC | DESC ] [ ALPHA ] STORE destination"] + #[doc = " https://valkey.io/commands/sort/"] + #[allow(async_fn_in_trait)] + async fn sort_store( + connection: &Connection, + key: String, + opts: Option, + destination: String, + ) -> Result { + exchange(connection, ("SORT", key, opts, "STORE", destination)).await + } + + #[doc = " Returns the sorted elements of a list, a set, or a sorted set."] + #[doc = ""] + #[doc = " Each row is the element, or with `get`, the value of each pattern, none when its key does"] + #[doc = " not exist."] + #[doc = ""] + #[doc = " SORT_RO key [ BY pattern ] [ LIMIT offset count ] [ GET pattern ... ] [ ASC | DESC ] [ ALPHA ]"] + #[doc = " https://valkey.io/commands/sort_ro/"] + #[allow(async_fn_in_trait)] + async fn sort_ro( + connection: &Connection, + key: String, + opts: Option, + ) -> Result>>, ErrorCode> { + let row = sort_row_len(&opts); + let values: Vec> = + exchange(connection, ("SORT_RO", key, opts)).await?; + Ok(rows(values, row)) + } + + #[doc = " Removes and returns a member by score from one or more sorted sets."] + #[doc = ""] + #[doc = " Returns none when the timeout passed. The connection waits too, replies to commands sent"] + #[doc = " after it on the connection wait behind it. Use a connection of your own."] + #[doc = ""] + #[doc = " BZMPOP timeout numkeys key [ key ... ] 〈 MIN | MAX 〉 [ COUNT count ]"] + #[doc = " https://valkey.io/commands/bzmpop/"] + #[allow(async_fn_in_trait)] + async fn bzmpop( + connection: &Connection, + timeout: DurationMilliseconds, + keys: Vec, + end: BzmpopEnd, + count: Option, + ) -> Result)>, ErrorCode> { + let command = ( + "BZMPOP", + DecimalSeconds(timeout), + NumKeys(NonEmpty::new("keys", keys)), + end, + NamedOption("COUNT", count), + ); + exchange(connection, command).await + } + + #[doc = " Removes and returns the member with the highest score from one or more sorted sets."] + #[doc = ""] + #[doc = " Returns none when the timeout passed. The connection waits too, replies to commands sent"] + #[doc = " after it on the connection wait behind it. Use a connection of your own."] + #[doc = ""] + #[doc = " BZPOPMAX key [ key ... ] timeout"] + #[doc = " https://valkey.io/commands/bzpopmax/"] + #[allow(async_fn_in_trait)] + async fn bzpopmax( + connection: &Connection, + keys: Vec, + timeout: DurationMilliseconds, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "BZPOPMAX", + NonEmpty::new("keys", keys), + DecimalSeconds(timeout), + ), + ) + .await + } + + #[doc = " Removes and returns the member with the lowest score from one or more sorted sets."] + #[doc = ""] + #[doc = " Returns none when the timeout passed. The connection waits too, replies to commands sent"] + #[doc = " after it on the connection wait behind it. Use a connection of your own."] + #[doc = ""] + #[doc = " BZPOPMIN key [ key ... ] timeout"] + #[doc = " https://valkey.io/commands/bzpopmin/"] + #[allow(async_fn_in_trait)] + async fn bzpopmin( + connection: &Connection, + keys: Vec, + timeout: DurationMilliseconds, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "BZPOPMIN", + NonEmpty::new("keys", keys), + DecimalSeconds(timeout), + ), + ) + .await + } + + #[doc = " Adds one or more members to a sorted set, or updates their scores."] + #[doc = ""] + #[doc = " See also `zadd-incr`."] + #[doc = ""] + #[doc = " ZADD key [ NX | XX ] [ GT | LT ] [ CH ] score member [ score member ... ]"] + #[doc = " https://valkey.io/commands/zadd/"] + #[allow(async_fn_in_trait)] + async fn zadd( + connection: &Connection, + key: String, + condition: Option, + comparison: Option, + changed: bool, + score_and_members: Vec<(Double, StringOrBytes)>, + ) -> Result, ErrorCode> { + let command = ( + "ZADD", + key, + condition, + comparison, + Flag("CH", changed), + NonEmpty::new("score-and-members", score_and_members), + ); + exchange(connection, command).await + } + + #[doc = " Adds one or more members to a sorted set, or updates their scores."] + #[doc = ""] + #[doc = " The `INCR` form, returning the new score, none when the condition or comparison prevented"] + #[doc = " it."] + #[doc = ""] + #[doc = " See also `zadd`."] + #[doc = ""] + #[doc = " ZADD key [ NX | XX ] [ GT | LT ] INCR score member"] + #[doc = " https://valkey.io/commands/zadd/"] + #[allow(async_fn_in_trait)] + async fn zadd_incr( + connection: &Connection, + key: String, + condition: Option, + comparison: Option, + increment: Double, + member: StringOrBytes, + ) -> Result, ErrorCode> { + let command = ( + "ZADD", key, condition, comparison, "INCR", increment, member, + ); + exchange(connection, command).await + } + + #[doc = " Returns the number of members in a sorted set."] + #[doc = ""] + #[doc = " ZCARD key"] + #[doc = " https://valkey.io/commands/zcard/"] + #[allow(async_fn_in_trait)] + async fn zcard(connection: &Connection, key: String) -> Result { + exchange(connection, ("ZCARD", key)).await + } + + #[doc = " Returns the count of members in a sorted set that have scores within a range."] + #[doc = ""] + #[doc = " ZCOUNT key min max"] + #[doc = " https://valkey.io/commands/zcount/"] + #[allow(async_fn_in_trait)] + async fn zcount( + connection: &Connection, + key: String, + min: ScoreBound, + max: ScoreBound, + ) -> Result { + exchange( + connection, + ( + "ZCOUNT", + key, + ScoreRange { + start: min, + stop: max, + rev: false, + }, + ), + ) + .await + } + + #[doc = " Returns the difference between multiple sorted sets."] + #[doc = ""] + #[doc = " See also `zdiff-withscores`."] + #[doc = ""] + #[doc = " ZDIFF numkeys key [ key ... ]"] + #[doc = " https://valkey.io/commands/zdiff/"] + #[allow(async_fn_in_trait)] + async fn zdiff( + connection: &Connection, + keys: Vec, + ) -> Result, ErrorCode> { + exchange(connection, ("ZDIFF", NumKeys(NonEmpty::new("keys", keys)))).await + } + + #[doc = " Returns the difference between multiple sorted sets."] + #[doc = ""] + #[doc = " The `WITHSCORES` form, the members and their scores."] + #[doc = ""] + #[doc = " See also `zdiff`."] + #[doc = ""] + #[doc = " ZDIFF numkeys key [ key ... ] WITHSCORES"] + #[doc = " https://valkey.io/commands/zdiff/"] + #[allow(async_fn_in_trait)] + async fn zdiff_withscores( + connection: &Connection, + keys: Vec, + ) -> Result, ErrorCode> { + exchange( + connection, + ("ZDIFF", NumKeys(NonEmpty::new("keys", keys)), "WITHSCORES"), + ) + .await + } + + #[doc = " Stores the difference of multiple sorted sets in a key."] + #[doc = ""] + #[doc = " ZDIFFSTORE destination numkeys key [ key ... ]"] + #[doc = " https://valkey.io/commands/zdiffstore/"] + #[allow(async_fn_in_trait)] + async fn zdiffstore( + connection: &Connection, + destination: String, + keys: Vec, + ) -> Result { + exchange( + connection, + ( + "ZDIFFSTORE", + destination, + NumKeys(NonEmpty::new("keys", keys)), + ), + ) + .await + } + + #[doc = " Increments the score of a member in a sorted set."] + #[doc = ""] + #[doc = " ZINCRBY key increment member"] + #[doc = " https://valkey.io/commands/zincrby/"] + #[allow(async_fn_in_trait)] + async fn zincrby( + connection: &Connection, + key: String, + increment: Double, + member: StringOrBytes, + ) -> Result { + exchange(connection, ("ZINCRBY", key, increment, member)).await + } + + #[doc = " Returns the intersect of multiple sorted sets."] + #[doc = ""] + #[doc = " See also `zinter-withscores`."] + #[doc = ""] + #[doc = " ZINTER numkeys key [ key ... ] [ WEIGHTS weight [ weight ... ] ] [ AGGREGATE SUM | MIN | MAX ]"] + #[doc = " https://valkey.io/commands/zinter/"] + #[allow(async_fn_in_trait)] + async fn zinter( + connection: &Connection, + keys: Vec, + weights: Option>, + aggregate: Option, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "ZINTER", + NumKeys(NonEmpty::new("keys", keys)), + NamedOption("WEIGHTS", NonEmpty::optional("weights", weights)), + aggregate, + ), + ) + .await + } + + #[doc = " Returns the intersect of multiple sorted sets."] + #[doc = ""] + #[doc = " The `WITHSCORES` form, the members and their combined scores."] + #[doc = ""] + #[doc = " See also `zinter`."] + #[doc = ""] + #[doc = " ZINTER numkeys key [ key ... ] [ WEIGHTS weight [ weight ... ] ] [ AGGREGATE SUM | MIN | MAX ] WITHSCORES"] + #[doc = " https://valkey.io/commands/zinter/"] + #[allow(async_fn_in_trait)] + async fn zinter_withscores( + connection: &Connection, + keys: Vec, + weights: Option>, + aggregate: Option, + ) -> Result, ErrorCode> { + let command = ( + "ZINTER", + NumKeys(NonEmpty::new("keys", keys)), + NamedOption("WEIGHTS", NonEmpty::optional("weights", weights)), + aggregate, + "WITHSCORES", + ); + exchange(connection, command).await + } + + #[doc = " Returns the number of members of the intersect of multiple sorted sets."] + #[doc = ""] + #[doc = " ZINTERCARD numkeys key [ key ... ] [ LIMIT limit ]"] + #[doc = " https://valkey.io/commands/zintercard/"] + #[allow(async_fn_in_trait)] + async fn zintercard( + connection: &Connection, + keys: Vec, + limit: Option, + ) -> Result { + exchange( + connection, + ( + "ZINTERCARD", + NumKeys(NonEmpty::new("keys", keys)), + NamedOption("LIMIT", limit), + ), + ) + .await + } + + #[doc = " Stores the intersect of multiple sorted sets in a key."] + #[doc = ""] + #[doc = " ZINTERSTORE destination numkeys key [ key ... ] [ WEIGHTS weight [ weight ... ] ] [ AGGREGATE SUM | MIN | MAX ]"] + #[doc = " https://valkey.io/commands/zinterstore/"] + #[allow(async_fn_in_trait)] + async fn zinterstore( + connection: &Connection, + destination: String, + keys: Vec, + weights: Option>, + aggregate: Option, + ) -> Result { + let command = ( + "ZINTERSTORE", + destination, + NumKeys(NonEmpty::new("keys", keys)), + NamedOption("WEIGHTS", NonEmpty::optional("weights", weights)), + aggregate, + ); + exchange(connection, command).await + } + + #[doc = " Returns the number of members in a sorted set within a lexicographical range."] + #[doc = ""] + #[doc = " ZLEXCOUNT key min max"] + #[doc = " https://valkey.io/commands/zlexcount/"] + #[allow(async_fn_in_trait)] + async fn zlexcount( + connection: &Connection, + key: String, + min: LexBound, + max: LexBound, + ) -> Result { + exchange( + connection, + ( + "ZLEXCOUNT", + key, + LexRange { + start: min, + stop: max, + rev: false, + }, + ), + ) + .await + } + + #[doc = " Returns the highest- or lowest-scoring members from one or more sorted sets after removing them."] + #[doc = ""] + #[doc = " ZMPOP numkeys key [ key ... ] 〈 MIN | MAX 〉 [ COUNT count ]"] + #[doc = " https://valkey.io/commands/zmpop/"] + #[allow(async_fn_in_trait)] + async fn zmpop( + connection: &Connection, + keys: Vec, + end: ZmpopEnd, + count: Option, + ) -> Result)>, ErrorCode> { + exchange( + connection, + ( + "ZMPOP", + NumKeys(NonEmpty::new("keys", keys)), + end, + NamedOption("COUNT", count), + ), + ) + .await + } + + #[doc = " Returns the score of one or more members in a sorted set."] + #[doc = ""] + #[doc = " ZMSCORE key member [ member ... ]"] + #[doc = " https://valkey.io/commands/zmscore/"] + #[allow(async_fn_in_trait)] + async fn zmscore( + connection: &Connection, + key: String, + members: Vec, + ) -> Result>, ErrorCode> { + exchange( + connection, + ("ZMSCORE", key, NonEmpty::new("members", members)), + ) + .await + } + + #[doc = " Returns the highest-scoring members from a sorted set after removing them."] + #[doc = ""] + #[doc = " Without a count, at most one member."] + #[doc = ""] + #[doc = " ZPOPMAX key [ count ]"] + #[doc = " https://valkey.io/commands/zpopmax/"] + #[allow(async_fn_in_trait)] + async fn zpopmax( + connection: &Connection, + key: String, + count: Option, + ) -> Result, ErrorCode> { + let members: ScoredMembers = exchange(connection, ("ZPOPMAX", key, count)).await?; + Ok(members.0) + } + + #[doc = " Returns the lowest-scoring members from a sorted set after removing them."] + #[doc = ""] + #[doc = " Without a count, at most one member."] + #[doc = ""] + #[doc = " ZPOPMIN key [ count ]"] + #[doc = " https://valkey.io/commands/zpopmin/"] + #[allow(async_fn_in_trait)] + async fn zpopmin( + connection: &Connection, + key: String, + count: Option, + ) -> Result, ErrorCode> { + let members: ScoredMembers = exchange(connection, ("ZPOPMIN", key, count)).await?; + Ok(members.0) + } + + #[doc = " Returns one or more random members from a sorted set."] + #[doc = ""] + #[doc = " Without a count, at most one member."] + #[doc = ""] + #[doc = " See also `zrandmember-withscores`."] + #[doc = ""] + #[doc = " ZRANDMEMBER key [ count ]"] + #[doc = " https://valkey.io/commands/zrandmember/"] + #[allow(async_fn_in_trait)] + async fn zrandmember( + connection: &Connection, + key: String, + count: Option, + ) -> Result, ErrorCode> { + if count.is_some() { + return exchange(connection, ("ZRANDMEMBER", key, count)).await; + } + // without a count the reply is the member itself, or null + let member: Option = exchange(connection, ("ZRANDMEMBER", key)).await?; + Ok(member.into_iter().collect()) + } + + #[doc = " Returns one or more random members from a sorted set."] + #[doc = ""] + #[doc = " The `WITHSCORES` form, the members and their scores."] + #[doc = ""] + #[doc = " See also `zrandmember`."] + #[doc = ""] + #[doc = " ZRANDMEMBER key count WITHSCORES"] + #[doc = " https://valkey.io/commands/zrandmember/"] + #[allow(async_fn_in_trait)] + async fn zrandmember_withscores( + connection: &Connection, + key: String, + count: Integer, + ) -> Result, ErrorCode> { + exchange(connection, ("ZRANDMEMBER", key, count, "WITHSCORES")).await + } + + #[doc = " Returns members in a sorted set within a range of indexes."] + #[doc = ""] + #[doc = " Also covers ZRANGEBYLEX, ZRANGEBYSCORE, ZREVRANGE, ZREVRANGEBYLEX and ZREVRANGEBYSCORE. A"] + #[doc = " limit fails the command unless the range is by score or by lex. With `xx`, returns none when"] + #[doc = " the key does not exist."] + #[doc = ""] + #[doc = " See also `zrange-withscores`."] + #[doc = ""] + #[doc = " ZRANGE key start stop [ BYSCORE | BYLEX ] [ REV ] [ LIMIT offset count ] [ XX ]"] + #[doc = " https://valkey.io/commands/zrange/"] + #[allow(async_fn_in_trait)] + async fn zrange( + connection: &Connection, + key: String, + range: ZrangeBy, + rev: bool, + limit: Option, + xx: bool, + ) -> Result>, ErrorCode> { + let range = Zrange { range, rev, limit }; + exchange(connection, ("ZRANGE", key, range, Flag("XX", xx))).await + } + + #[doc = " Returns members in a sorted set within a range of indexes."] + #[doc = ""] + #[doc = " The `WITHSCORES` form, the members and their scores. A limit fails the command unless the"] + #[doc = " range is by score. With `xx`, returns none when the key does not exist."] + #[doc = ""] + #[doc = " See also `zrange`."] + #[doc = ""] + #[doc = " ZRANGE key start stop [ BYSCORE ] [ REV ] [ LIMIT offset count ] WITHSCORES [ XX ]"] + #[doc = " https://valkey.io/commands/zrange/"] + #[allow(async_fn_in_trait)] + async fn zrange_withscores( + connection: &Connection, + key: String, + range: ZrangeWithscoresBy, + rev: bool, + limit: Option, + xx: bool, + ) -> Result>, ErrorCode> { + let range = match range { + ZrangeWithscoresBy::Rank(rank) => ZrangeBy::Rank(rank), + ZrangeWithscoresBy::Score(score) => ZrangeBy::Score(score), + }; + let range = Zrange { range, rev, limit }; + let command = ("ZRANGE", key, range, "WITHSCORES", Flag("XX", xx)); + exchange(connection, command).await + } + + #[doc = " Stores a range of members from sorted set in a key."] + #[doc = ""] + #[doc = " A limit fails the command unless the range is by score or by lex."] + #[doc = ""] + #[doc = " ZRANGESTORE dst src min max [ BYSCORE | BYLEX ] [ REV ] [ LIMIT offset count ]"] + #[doc = " https://valkey.io/commands/zrangestore/"] + #[allow(async_fn_in_trait)] + async fn zrangestore( + connection: &Connection, + destination: String, + source: String, + range: ZrangeBy, + rev: bool, + limit: Option, + ) -> Result { + let range = Zrange { range, rev, limit }; + exchange(connection, ("ZRANGESTORE", destination, source, range)).await + } + + #[doc = " Returns the index of a member in a sorted set ordered by ascending scores."] + #[doc = ""] + #[doc = " See also `zrank-withscore`."] + #[doc = ""] + #[doc = " ZRANK key member"] + #[doc = " https://valkey.io/commands/zrank/"] + #[allow(async_fn_in_trait)] + async fn zrank( + connection: &Connection, + key: String, + member: StringOrBytes, + ) -> Result, ErrorCode> { + exchange(connection, ("ZRANK", key, member)).await + } + + #[doc = " Returns the index of a member in a sorted set ordered by ascending scores."] + #[doc = ""] + #[doc = " The `WITHSCORE` form, the position and the score."] + #[doc = ""] + #[doc = " See also `zrank`."] + #[doc = ""] + #[doc = " ZRANK key member WITHSCORE"] + #[doc = " https://valkey.io/commands/zrank/"] + #[allow(async_fn_in_trait)] + async fn zrank_withscore( + connection: &Connection, + key: String, + member: StringOrBytes, + ) -> Result, ErrorCode> { + exchange(connection, ("ZRANK", key, member, "WITHSCORE")).await + } + + #[doc = " Removes one or more members from a sorted set."] + #[doc = ""] + #[doc = " ZREM key member [ member ... ]"] + #[doc = " https://valkey.io/commands/zrem/"] + #[allow(async_fn_in_trait)] + async fn zrem( + connection: &Connection, + key: String, + members: Vec, + ) -> Result { + exchange(connection, ("ZREM", key, NonEmpty::new("members", members))).await + } + + #[doc = " Removes members in a sorted set within a lexicographical range."] + #[doc = ""] + #[doc = " ZREMRANGEBYLEX key min max"] + #[doc = " https://valkey.io/commands/zremrangebylex/"] + #[allow(async_fn_in_trait)] + async fn zremrangebylex( + connection: &Connection, + key: String, + min: LexBound, + max: LexBound, + ) -> Result { + exchange( + connection, + ( + "ZREMRANGEBYLEX", + key, + LexRange { + start: min, + stop: max, + rev: false, + }, + ), + ) + .await + } + + #[doc = " Removes members in a sorted set within a range of indexes."] + #[doc = ""] + #[doc = " ZREMRANGEBYRANK key start stop"] + #[doc = " https://valkey.io/commands/zremrangebyrank/"] + #[allow(async_fn_in_trait)] + async fn zremrangebyrank( + connection: &Connection, + key: String, + start: Integer, + stop: Integer, + ) -> Result { + exchange(connection, ("ZREMRANGEBYRANK", key, start, stop)).await + } + + #[doc = " Removes members in a sorted set within a range of scores."] + #[doc = ""] + #[doc = " ZREMRANGEBYSCORE key min max"] + #[doc = " https://valkey.io/commands/zremrangebyscore/"] + #[allow(async_fn_in_trait)] + async fn zremrangebyscore( + connection: &Connection, + key: String, + min: ScoreBound, + max: ScoreBound, + ) -> Result { + exchange( + connection, + ( + "ZREMRANGEBYSCORE", + key, + ScoreRange { + start: min, + stop: max, + rev: false, + }, + ), + ) + .await + } + + #[doc = " Returns the index of a member in a sorted set ordered by descending scores."] + #[doc = ""] + #[doc = " See also `zrevrank-withscore`."] + #[doc = ""] + #[doc = " ZREVRANK key member"] + #[doc = " https://valkey.io/commands/zrevrank/"] + #[allow(async_fn_in_trait)] + async fn zrevrank( + connection: &Connection, + key: String, + member: StringOrBytes, + ) -> Result, ErrorCode> { + exchange(connection, ("ZREVRANK", key, member)).await + } + + #[doc = " Returns the index of a member in a sorted set ordered by descending scores."] + #[doc = ""] + #[doc = " The `WITHSCORE` form, the position and the score."] + #[doc = ""] + #[doc = " See also `zrevrank`."] + #[doc = ""] + #[doc = " ZREVRANK key member WITHSCORE"] + #[doc = " https://valkey.io/commands/zrevrank/"] + #[allow(async_fn_in_trait)] + async fn zrevrank_withscore( + connection: &Connection, + key: String, + member: StringOrBytes, + ) -> Result, ErrorCode> { + exchange(connection, ("ZREVRANK", key, member, "WITHSCORE")).await + } + + #[doc = " Iterates over members and scores of a sorted set."] + #[doc = ""] + #[doc = " See also `zscan-noscores`."] + #[doc = ""] + #[doc = " ZSCAN key cursor [ MATCH pattern ] [ COUNT count ]"] + #[doc = " https://valkey.io/commands/zscan/"] + #[allow(async_fn_in_trait)] + async fn zscan( + connection: &Connection, + key: String, + cursor: Cursor, + match_: Option, + count: Option, + ) -> Result<(Option, Vec<(StringOrBytes, Double)>), ErrorCode> { + // the members and scores are a flat array, the scores bulk strings + let (cursor, members): (NextCursor, FlatPairs) = exchange( + connection, + ( + "ZSCAN", + key, + cursor, + NamedOption("MATCH", match_), + NamedOption("COUNT", count), + ), + ) + .await?; + Ok((cursor.0, members.0)) + } + + #[doc = " Iterates over members and scores of a sorted set."] + #[doc = ""] + #[doc = " The `NOSCORES` form, the members only."] + #[doc = ""] + #[doc = " See also `zscan`."] + #[doc = ""] + #[doc = " ZSCAN key cursor [ MATCH pattern ] [ COUNT count ] NOSCORES"] + #[doc = " https://valkey.io/commands/zscan/"] + #[allow(async_fn_in_trait)] + async fn zscan_noscores( + connection: &Connection, + key: String, + cursor: Cursor, + match_: Option, + count: Option, + ) -> Result<(Option, Vec), ErrorCode> { + let (cursor, items): (NextCursor, Vec) = exchange( + connection, + ( + "ZSCAN", + key, + cursor, + NamedOption("MATCH", match_), + NamedOption("COUNT", count), + "NOSCORES", + ), + ) + .await?; + Ok((cursor.0, items)) + } + + #[doc = " Returns the score of a member in a sorted set."] + #[doc = ""] + #[doc = " ZSCORE key member"] + #[doc = " https://valkey.io/commands/zscore/"] + #[allow(async_fn_in_trait)] + async fn zscore( + connection: &Connection, + key: String, + member: StringOrBytes, + ) -> Result, ErrorCode> { + exchange(connection, ("ZSCORE", key, member)).await + } + + #[doc = " Returns the union of multiple sorted sets."] + #[doc = ""] + #[doc = " See also `zunion-withscores`."] + #[doc = ""] + #[doc = " ZUNION numkeys key [ key ... ] [ WEIGHTS weight [ weight ... ] ] [ AGGREGATE SUM | MIN | MAX ]"] + #[doc = " https://valkey.io/commands/zunion/"] + #[allow(async_fn_in_trait)] + async fn zunion( + connection: &Connection, + keys: Vec, + weights: Option>, + aggregate: Option, + ) -> Result, ErrorCode> { + exchange( + connection, + ( + "ZUNION", + NumKeys(NonEmpty::new("keys", keys)), + NamedOption("WEIGHTS", NonEmpty::optional("weights", weights)), + aggregate, + ), + ) + .await + } + + #[doc = " Returns the union of multiple sorted sets."] + #[doc = ""] + #[doc = " The `WITHSCORES` form, the members and their combined scores."] + #[doc = ""] + #[doc = " See also `zunion`."] + #[doc = ""] + #[doc = " ZUNION numkeys key [ key ... ] [ WEIGHTS weight [ weight ... ] ] [ AGGREGATE SUM | MIN | MAX ] WITHSCORES"] + #[doc = " https://valkey.io/commands/zunion/"] + #[allow(async_fn_in_trait)] + async fn zunion_withscores( + connection: &Connection, + keys: Vec, + weights: Option>, + aggregate: Option, + ) -> Result, ErrorCode> { + let command = ( + "ZUNION", + NumKeys(NonEmpty::new("keys", keys)), + NamedOption("WEIGHTS", NonEmpty::optional("weights", weights)), + aggregate, + "WITHSCORES", + ); + exchange(connection, command).await + } + + #[doc = " Stores the union of multiple sorted sets in a key."] + #[doc = ""] + #[doc = " ZUNIONSTORE destination numkeys key [ key ... ] [ WEIGHTS weight [ weight ... ] ] [ AGGREGATE SUM | MIN | MAX ]"] + #[doc = " https://valkey.io/commands/zunionstore/"] + #[allow(async_fn_in_trait)] + async fn zunionstore( + connection: &Connection, + destination: String, + keys: Vec, + weights: Option>, + aggregate: Option, + ) -> Result { + let command = ( + "ZUNIONSTORE", + destination, + NumKeys(NonEmpty::new("keys", keys)), + NamedOption("WEIGHTS", NonEmpty::optional("weights", weights)), + aggregate, + ); + exchange(connection, command).await + } +} + +wit_bindgen::generate!({ + path: "../wit", + world: "commands", + // the resp types shared by every component + with: { + "componentized:valkey/types@0.3.0-dev": componentized_valkey::types, + }, + generate_all, +}); + +export!(Commands); diff --git a/components/commands/tests/commands.rs b/components/commands/tests/commands.rs new file mode 100644 index 0000000..48c30c9 --- /dev/null +++ b/components/commands/tests/commands.rs @@ -0,0 +1,29 @@ +//! The tests of the commands, run inside `test-commands-suite`, composed with `commands` and +//! `connection` as `test-commands`. Each test plays the server over the streams of a connection, +//! checking the command a function sends and how it converts the reply. +//! +//! Wasmtime's bindings for the host cannot call `commands` itself, which has functions named for +//! Rust keywords, `move` and `type`, so the tests run in a component. + +use test_harness::{Harness, run_suite}; + +/// The tests that do not need a Valkey server. +#[tokio::test(flavor = "multi_thread")] +async fn commands() -> wasmtime::Result<()> { + let harness = Harness::new("test-commands").await?; + run_suite(harness, |test| !test.starts_with("with-valkey ")).await +} + +/// The tests against a Valkey server, ignored unless run with `make test-valkey`, which runs one in +/// a container and only runs the ignored tests in `with_valkey` modules. +mod with_valkey { + use super::*; + + /// Against a Valkey server, see [`test_harness::valkey_server`]. + #[tokio::test(flavor = "multi_thread")] + #[ignore = "needs a Valkey server, see make test-valkey"] + async fn against_valkey() -> wasmtime::Result<()> { + let harness = Harness::with_valkey("test-commands", &[]).await?; + run_suite(harness, |test| test.starts_with("with-valkey ")).await + } +} diff --git a/components/connection-minimal/Cargo.toml b/components/connection-minimal/Cargo.toml new file mode 100644 index 0000000..4746e90 --- /dev/null +++ b/components/connection-minimal/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "connection-minimal" +version.workspace = true +edition = "2024" +license = "Apache-2.0" + +# the source of `connection`, built without its default features, e.g. pub/sub +[lib] +crate-type = ["cdylib"] +path = "../connection/src/lib.rs" + +[features] +# off, see `connection` +pubsub = [] + +[dependencies] +futures-channel = { workspace = true } +futures-core = { workspace = true } +componentized-valkey = { workspace = true } +wit-bindgen = { workspace = true, features = ["async-spawn", "inter-task-wakeup"] } diff --git a/components/connection-minimal/README.md b/components/connection-minimal/README.md new file mode 100644 index 0000000..cb16a18 --- /dev/null +++ b/components/connection-minimal/README.md @@ -0,0 +1,5 @@ +# connection-minimal + +A connection component built without pub/sub, for a smaller component when subscriptions are not needed. + +The interface is the same: `connection.subscription` fails with a client error, every push message is a dead letter, and SUBSCRIBE and the like may still be sent with `exchange`. diff --git a/components/connection/Cargo.toml b/components/connection/Cargo.toml new file mode 100644 index 0000000..1e1c550 --- /dev/null +++ b/components/connection/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "connection" +version.workspace = true +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[features] +default = ["pubsub"] +# subscriptions, see `connection.subscription`, without it every push message is a dead letter +pubsub = [] + +[dependencies] +futures-channel = { workspace = true } +futures-core = { workspace = true } +componentized-valkey = { workspace = true } +wit-bindgen = { workspace = true, features = ["async-spawn", "inter-task-wakeup"] } + +[dev-dependencies] +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/connection/README.md b/components/connection/README.md new file mode 100644 index 0000000..0554365 --- /dev/null +++ b/components/connection/README.md @@ -0,0 +1,7 @@ +# connection + +The mechanics of a connection to a Valkey server, independent of the transport carrying it. + +Pairs each command with its reply, keeps push messages apart for any number of readers, owns pub/sub subscriptions, runs transactions with `WATCH`, `MULTI` and `EXEC`, establishes the connection with `HELLO 3`, resets it, and optionally keeps it alive with `PING`. A transport, e.g. [`tcp`](../tcp/), carries the RESP values to and from the server. + +[`connection-minimal`](../connection-minimal/) is a smaller build without pub/sub, with the same interface. diff --git a/components/connection/src/lib.rs b/components/connection/src/lib.rs new file mode 100644 index 0000000..d02fc3a --- /dev/null +++ b/components/connection/src/lib.rs @@ -0,0 +1,848 @@ +//! The mechanics of a connection to a Valkey server, independent of the transport carrying it. +//! +//! A connection runs background tasks for as long as it is open, in the task of the call to +//! `connection.new` that created it: +//! +//! - the writer writes queued commands to the requests stream, in order +//! - the reader reads the replies stream, handing each reply to the oldest command waiting for one, +//! each message to the subscriptions holding its target, and each other push message, or value +//! the server sent unprompted, to every reader of dead letters +//! - the supervisor starts the work methods ask for that outlives them, e.g. keep-alive, since sync +//! methods have no task of their own to spawn it into +//! +//! Dropping the connection drops its queue of commands, the writer then closes the requests stream +//! and the transport closes, which ends the reader. A subscription keeps the connection open. + +// the test harness provides a main +#![cfg_attr(not(test), no_main)] + +#[cfg(feature = "pubsub")] +mod pubsub; +#[cfg(not(feature = "pubsub"))] +#[path = "pubsub_disabled.rs"] +mod pubsub; + +use std::cell::RefCell; +use std::collections::VecDeque; +use std::future::{Future, poll_fn}; +use std::pin::Pin; +use std::rc::{Rc, Weak}; + +use componentized_valkey::types::{RespValue, StringOrBytes}; +use exports::componentized::valkey::connection::{ + ConnectOpts, Connection, DurationMilliseconds, ErrorCode, Guest, GuestConnection, Subscription, +}; +use futures_channel::{mpsc, oneshot}; +use futures_core::Stream; +use wasi::clocks::monotonic_clock::wait_for; +use wit_bindgen::{FutureReader, FutureWriter, StreamReader, StreamWriter, spawn_local}; + +/// The library the connection reports to the server, see CLIENT SETINFO. +const LIB_NAME: &str = "componentized:valkey"; +/// The version the connection reports to the server, that of the package. +const LIB_VER: &str = env!("CARGO_PKG_VERSION"); + +/// A word of a command, as sent, UTF-8 or not. +type Word = Vec; + +/// How many values a reader may fall behind before its stream is closed, and how many events a +/// subscription keeps while no stream is open. +const PUSH_BUFFER: usize = 256; + +struct Component; + +impl Guest for Component { + type Connection = ConnectionImpl; + type Subscription = pubsub::SubscriptionImpl; +} + +/// Where the reply to a command goes. +enum Reply { + /// To the caller waiting for it. + Call(oneshot::Sender>), + /// Nowhere, the command was sent without waiting for its reply. + Discard, + /// A subscription's SUBSCRIBE, UNSUBSCRIBE or the like, see `pubsub::Pending`. + #[cfg_attr(not(feature = "pubsub"), allow(dead_code))] + Pubsub(pubsub::Pending), + /// RESET, whose reply ends the subscriptions the server dropped. + Reset(oneshot::Sender>), + /// The HELLO after RESET, the writer waits for its reply before sending another command, and + /// the connection closes when it fails. + Rehello { + proceed: oneshot::Sender, + done: oneshot::Sender>, + }, +} + +/// A command for the writer, with a signal for once it is written. +struct Request { + command: RespValue, + written: Option>, + /// Whether to go on writing after this command, e.g. once the HELLO after RESET succeeded, + /// otherwise the writer stops, and the commands after it are never sent. + barrier: Option>, +} + +/// Work for the supervisor to start. +enum Task { + /// Run the future in a task of its own. + Spawn(Pin>>), + /// Keep the connection alive, until the keep-alive is set again. + KeepAlive { + generation: u64, + interval: DurationMilliseconds, + }, +} + +/// State shared by a connection and its background tasks. +#[derive(Default)] +struct State { + /// Where each reply goes, in the order the commands were sent. + pending: VecDeque, + /// Why the connection closed, once it has. + closed: Option, + /// Counts the commands written and the values read, it stays the same while the connection is + /// idle. + activity: u64, + /// The readers of dead letters. + dead_letters: Vec>, + /// The subscriptions, and the channels they hold. + subscriptions: pubsub::Subscriptions, + /// The server's properties from the reply to HELLO. + server_properties: Vec<(String, RespValue)>, + /// The HELLO the connection was established with, repeated after RESET. + hello: Vec, + /// Bumped each time the keep-alive is set, a keep-alive task stops once it is outdated. + keep_alive_generation: u64, + /// Set by the first `subscription`, and never cleared: from then on the connection + /// alone sends SUBSCRIBE and the like, so the subscriptions stay in step with the server. + owns_subscriptions: bool, +} + +/// A connection's queues to its background tasks. +/// +/// Only the connection and its subscriptions hold it, the background tasks hold the state alone +/// or a weak reference, so dropping them drops the queues and ends the tasks. +struct Inner { + state: Rc>, + requests: mpsc::UnboundedSender, + tasks: mpsc::UnboundedSender, +} + +pub struct ConnectionImpl(Rc); + +impl GuestConnection for ConnectionImpl { + async fn new( + replies: StreamReader, + closed: FutureReader>, + opts: Option, + ) -> ( + StreamReader, + FutureReader>, + ) { + let (output, requests) = wit_stream::new(); + // the default is only written if the connection is dropped before it is established + let (connection_writer, connection) = wit_future::new(|| Err(ErrorCode::Closed(None))); + + let state = Rc::new(RefCell::new(State::default())); + let (requests_tx, requests_rx) = mpsc::unbounded(); + let (tasks_tx, tasks_rx) = mpsc::unbounded(); + let inner = Rc::new(Inner { + state: state.clone(), + requests: requests_tx, + tasks: tasks_tx, + }); + + spawn_local(write_requests(state.clone(), requests_rx, output)); + spawn_local(read_replies(state, replies, closed)); + spawn_local(supervise(tasks_rx, Rc::downgrade(&inner))); + spawn_local(establish(inner, opts, connection_writer)); + + (requests, connection) + } + + fn server_properties(&self) -> Vec<(String, RespValue)> { + self.0.state.borrow().server_properties.clone() + } + + async fn exchange(&self, command: Vec) -> Result { + exchange(&self.0, bytes(command)).await + } + + async fn exchange_all( + &self, + commands: Vec>, + ) -> Vec> { + exchange_all(&self.0, commands.into_iter().map(bytes).collect()).await + } + + async fn emit(&self, command: Vec) -> Result<(), ErrorCode> { + emit(&self.0, bytes(command)).await + } + + async fn watch(&self, keys: Vec) -> Result<(), ErrorCode> { + if keys.is_empty() { + return Err(ErrorCode::Client( + "keys requires at least one item".to_string(), + )); + } + let keys = keys.into_iter().map(String::into_bytes); + let command = std::iter::once(b"WATCH".to_vec()).chain(keys).collect(); + ok(exchange(&self.0, command).await?) + } + + async fn unwatch(&self) -> Result<(), ErrorCode> { + ok(exchange(&self.0, words(&["UNWATCH"])).await?) + } + + async fn transaction( + &self, + commands: Vec>, + ) -> Result>, ErrorCode> { + transaction(&self.0, commands.into_iter().map(bytes).collect()).await + } + + fn subscription(&self) -> Result { + pubsub::subscription(&self.0) + } + + async fn reset(&self) -> Result<(), ErrorCode> { + reset(&self.0).await + } + + fn dead_letters(&self) -> StreamReader { + let (writer, reader) = wit_stream::new(); + let (tx, rx) = mpsc::channel(PUSH_BUFFER); + let mut state = self.0.state.borrow_mut(); + // a closed connection drops the writer, closing the stream at once + if state.closed.is_none() + && self + .0 + .tasks + .unbounded_send(Task::Spawn(Box::pin(forward_values(rx, writer)))) + .is_ok() + { + state.dead_letters.push(tx); + } + reader + } + + fn set_keep_alive(&self, interval: Option) { + let generation = { + let mut state = self.0.state.borrow_mut(); + state.keep_alive_generation += 1; + state.keep_alive_generation + }; + // a zero interval would ping whenever the connection is idle, treated as disabled + if let Some(interval) = interval.filter(|interval| interval.px > 0) { + let _ = self.0.tasks.unbounded_send(Task::KeepAlive { + generation, + interval, + }); + } + } +} + +/// RESET, then HELLO again, with no other command between them, closing the connection when HELLO +/// fails. +/// +/// The writer sends no other command until HELLO succeeds, so none runs on the connection RESET +/// leaves: RESP2, the default user, no name. When HELLO fails, e.g. the credentials no longer work, +/// the reader closes the connection, and the commands queued after it fail without being sent. +async fn reset(inner: &Inner) -> Result<(), ErrorCode> { + let mut state = inner.state.borrow_mut(); + if let Some(error) = &state.closed { + return Err(error.clone()); + } + let hello = state.hello.clone(); + let (reset_tx, reset_rx) = oneshot::channel(); + let (proceed_tx, proceed_rx) = oneshot::channel(); + let (hello_tx, hello_rx) = oneshot::channel(); + let requests = [(words(&["RESET"]), None), (hello, Some(proceed_rx))]; + for (command, barrier) in requests { + let command = RespValue::Array( + command + .into_iter() + .map(|word| componentized_valkey::resp::encode(RespValue::BulkString(word))) + .collect(), + ); + inner + .requests + .unbounded_send(Request { + command, + written: None, + barrier, + }) + .map_err(|_| ErrorCode::Closed(None))?; + } + state.pending.push_back(Reply::Reset(reset_tx)); + state.pending.push_back(Reply::Rehello { + proceed: proceed_tx, + done: hello_tx, + }); + drop(state); + // the replies are dropped unsent when the connection closes, the reader fails them first + let reset = reset_rx + .await + .unwrap_or_else(|_| Err(closed_error(&inner.state))); + hello_rx + .await + .unwrap_or_else(|_| Err(closed_error(&inner.state)))?; + reset +} + +/// Queue a command, and where its reply goes, as one step so replies pair with commands in order. +/// A command without a reply takes no place in the order. +fn enqueue( + inner: &Inner, + command: Vec, + reply: Option, + written: Option>, +) -> Result<(), ErrorCode> { + if command.is_empty() { + return Err(ErrorCode::Client( + "a command requires at least one word".to_string(), + )); + } + let mut state = inner.state.borrow_mut(); + if let Some(error) = &state.closed { + return Err(error.clone()); + } + // the words of a command are bulk strings + let command = RespValue::Array( + command + .into_iter() + .map(|word| componentized_valkey::resp::encode(RespValue::BulkString(word))) + .collect(), + ); + inner + .requests + .unbounded_send(Request { + command, + written, + barrier: None, + }) + .map_err(|_| ErrorCode::Closed(None))?; + state.pending.extend(reply); + Ok(()) +} + +async fn exchange(inner: &Inner, command: Vec) -> Result { + reject_managed(inner, &command)?; + if replies_with_pushes(&command) { + return emit(inner, command).await.map(|()| RespValue::Null); + } + request(inner, command).await +} + +/// Send a command and wait for its reply, for the commands the connection sends itself, e.g. HELLO, +/// which callers may not. +async fn request(inner: &Inner, command: Vec) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + enqueue(inner, command, Some(Reply::Call(reply_tx)), None)?; + // the reply is dropped unsent when the connection closes, the reader fails it first + reply_rx + .await + .unwrap_or_else(|_| Err(closed_error(&inner.state))) +} + +/// Queue every command before waiting for any reply, so no other command is sent between them. +async fn exchange_all( + inner: &Inner, + commands: Vec>, +) -> Vec> { + // no await until every command is queued + let queued: Vec>, ErrorCode>> = commands + .into_iter() + .map(|command| { + reject_managed(inner, &command)?; + if replies_with_pushes(&command) { + // no reply, as for `exchange` + return enqueue(inner, command, None, None).map(|()| None); + } + let (reply_tx, reply_rx) = oneshot::channel(); + enqueue(inner, command, Some(Reply::Call(reply_tx)), None).map(|()| Some(reply_rx)) + }) + .collect(); + let mut replies = Vec::with_capacity(queued.len()); + for reply in queued { + replies.push(match reply { + Err(error) => Err(error), + Ok(None) => Ok(RespValue::Null), + // the reply is dropped unsent when the connection closes, the reader fails it first + Ok(Some(reply)) => reply + .await + .unwrap_or_else(|_| Err(closed_error(&inner.state))), + }); + } + replies +} + +/// MULTI, the commands, then EXEC, with no other command between them. +async fn transaction( + inner: &Inner, + commands: Vec>, +) -> Result>, ErrorCode> { + // before MULTI is sent, so an invalid command fails the transaction rather than being left out + if commands.iter().any(Vec::is_empty) { + return Err(ErrorCode::Client( + "a command requires at least one word".to_string(), + )); + } + for command in &commands { + reject_managed(inner, command)?; + } + let batch = std::iter::once(words(&["MULTI"])) + .chain(commands) + .chain(std::iter::once(words(&["EXEC"]))) + .collect(); + let mut replies = exchange_all(inner, batch).await; + let exec = replies.pop().expect("a reply for each command"); + // MULTI's OK and each command's QUEUED, the first command rejected while queuing is a clearer + // error than EXEC's EXECABORT + for reply in replies { + reply?; + } + match exec? { + // a watched key changed + RespValue::Null => Ok(None), + RespValue::Array(replies) => replies + .into_iter() + .map(|reply| decode(&reply)) + .collect::>() + .map(Some), + reply => Err(unexpected(&reply)), + } +} + +async fn emit(inner: &Inner, command: Vec) -> Result<(), ErrorCode> { + reject_managed(inner, &command)?; + let reply = (!replies_with_pushes(&command)).then_some(Reply::Discard); + let (written_tx, written_rx) = oneshot::channel(); + enqueue(inner, command, reply, Some(written_tx))?; + // the signal is dropped unsent when the transport closed first + written_rx.await.map_err(|_| closed_error(&inner.state)) +} + +/// Rejects the commands that would leave the server's state out of step with the connection's: +/// +/// - RESET, which switches the connection to RESP2, see `reset` +/// - HELLO, which may switch the connection to RESP2, or another user, see `server-properties` +/// - CLIENT REPLY, which stops replies from pairing with commands +/// - SUBSCRIBE and the like once the connection owns the subscriptions, see +/// `State::owns_subscriptions`, e.g. an UNSUBSCRIBE would end a subscription without it asking +fn reject_managed(inner: &Inner, command: &[Word]) -> Result<(), ErrorCode> { + let is = |index: usize, word: &str| { + command + .get(index) + .is_some_and(|actual| actual.eq_ignore_ascii_case(word.as_bytes())) + }; + let reason = if is(0, "RESET") { + Some("RESET is rejected, use the connection's reset") + } else if is(0, "HELLO") { + Some( + "HELLO is rejected, the connection sent it when it was established, see its server-properties", + ) + } else if is(0, "CLIENT") && is(1, "REPLY") { + Some("CLIENT REPLY is rejected, every command has a reply, see the connection's emit") + } else { + None + }; + if let Some(reason) = reason { + return Err(ErrorCode::Client(reason.to_string())); + } + if replies_with_pushes(command) && inner.state.borrow().owns_subscriptions { + let name = String::from_utf8_lossy(&command[0]).to_uppercase(); + return Err(ErrorCode::Client(format!( + "{name} is rejected once the connection has a subscription, use the subscription's methods" + ))); + } + Ok(()) +} + +/// Whether the command's replies are push messages, e.g. SUBSCRIBE, rather than a reply. +/// +/// In RESP3 the server confirms each channel subscribed to, or unsubscribed from, with a push +/// message. An error, e.g. for a command missing its channels, is a reply the server sends +/// unprompted. +fn replies_with_pushes(command: &[Word]) -> bool { + const COMMANDS: [&[u8]; 6] = [ + b"SUBSCRIBE", + b"PSUBSCRIBE", + b"SSUBSCRIBE", + b"UNSUBSCRIBE", + b"PUNSUBSCRIBE", + b"SUNSUBSCRIBE", + ]; + command.first().is_some_and(|name| { + COMMANDS + .iter() + .any(|command| name.eq_ignore_ascii_case(command)) + }) +} + +/// The error a command fails with once the connection closed. +fn closed_error(state: &RefCell) -> ErrorCode { + state + .borrow() + .closed + .clone() + .unwrap_or(ErrorCode::Closed(None)) +} + +/// Close the connection, failing every command waiting for a reply, closing the readers of dead +/// letters, and ending the subscriptions. +fn close(state: &RefCell, error: ErrorCode) { + let mut state = state.borrow_mut(); + let error = state.closed.get_or_insert(error).clone(); + for reply in state.pending.drain(..).collect::>() { + match reply { + Reply::Call(reply) => { + let _ = reply.send(Err(error.clone())); + } + Reply::Pubsub(pending) => pending.fail(error.clone()), + Reply::Reset(done) => { + let _ = done.send(Err(error.clone())); + } + Reply::Rehello { proceed, done } => { + let _ = proceed.send(false); + let _ = done.send(Err(error.clone())); + } + Reply::Discard => {} + } + } + state.dead_letters.clear(); + state.subscriptions.close(&error); +} + +/// The next item of a channel. +async fn next(stream: &mut S) -> Option { + poll_fn(|cx| Pin::new(&mut *stream).poll_next(cx)).await +} + +/// The words of a command the connection sends itself, e.g. `words(&["PING"])`. +fn words(words: &[&str]) -> Vec { + words.iter().map(|word| word.as_bytes().to_vec()).collect() +} + +/// The words of a command, taken from strings or bytes as they are, without copying. +fn bytes(command: Vec) -> Vec { + command + .into_iter() + .map(|word| match word { + StringOrBytes::String(text) => text.into_bytes(), + StringOrBytes::Bytes(bytes) => bytes, + }) + .collect() +} + +/// Write each queued command to the requests stream, closing it once the connection is dropped. +async fn write_requests( + state: Rc>, + mut requests: mpsc::UnboundedReceiver, + mut output: StreamWriter, +) { + while let Some(Request { + command, + written, + barrier, + }) = next(&mut requests).await + { + state.borrow_mut().activity += 1; + if output.write_one(command).await.is_some() { + // the transport stopped reading, the reader closes the connection once it ends + break; + } + if let Some(written) = written { + let _ = written.send(()); + } + if let Some(barrier) = barrier + && !barrier.await.unwrap_or(false) + { + // the commands after it are never sent, those waiting to be written fail + requests.close(); + while requests.try_recv().is_ok() {} + // dropping the requests stream closes the transport, the reader closed the connection + break; + } + } +} + +/// Hand each reply to the oldest command waiting for one, each message to its subscriptions, and +/// each other push message, or value the server sent unprompted, to the readers of dead letters, +/// until the transport closes. +async fn read_replies( + state: Rc>, + mut replies: StreamReader, + closed: FutureReader>, +) { + // the error to close the connection with, before the transport closes + let mut closing = None; + while let Some(value) = replies.next().await { + let mut state = state.borrow_mut(); + let state = &mut *state; + state.activity += 1; + let value = match value { + push @ RespValue::Push(_) => { + if pubsub::claim_push(state, &push) { + continue; + } + push + } + value => match state.pending.pop_front() { + Some(Reply::Call(reply)) => { + let _ = reply.send(reply_result(value)); + continue; + } + Some(Reply::Discard) => continue, + Some(Reply::Rehello { proceed, done }) => { + match reply_result(value).and_then(server_properties) { + Ok(properties) => { + state.server_properties = properties; + let _ = proceed.send(true); + let _ = done.send(Ok(())); + } + Err(error) => { + // the connection is not the one it was established with + let _ = proceed.send(false); + let _ = done.send(Err(error.clone())); + closing = Some(error); + break; + } + } + continue; + } + Some(Reply::Reset(done)) => { + let result = match reply_result(value) { + Ok(RespValue::String(reply)) if reply == "RESET" => { + state.subscriptions.reset(); + Ok(()) + } + Ok(reply) => Err(unexpected(&reply)), + Err(error) => Err(error), + }; + let _ = done.send(result); + continue; + } + // confirmed with push messages, a reply in their place is an error + Some(Reply::Pubsub(pending)) => { + let error = match reply_result(value) { + Ok(reply) => unexpected(&reply), + Err(error) => error, + }; + pending.refused(&mut state.subscriptions, error); + continue; + } + // sent unprompted, e.g. the feed of MONITOR + None => value, + }, + }; + // a reader that is full or gone is dropped, rather than holding up replies + state + .dead_letters + .retain_mut(|dead_letters| dead_letters.try_send(value.clone()).is_ok()); + } + if let Some(error) = closing { + // the writer stopped, dropping the requests stream closes the transport + close(&state, error); + return; + } + let reason = match closed.await { + Ok(()) => None, + Err(error) => describe(error), + }; + close(&state, ErrorCode::Closed(reason)); +} + +/// Start the work methods ask for that outlives them. +async fn supervise(mut tasks: mpsc::UnboundedReceiver, inner: Weak) { + while let Some(task) = next(&mut tasks).await { + match task { + Task::Spawn(future) => spawn_local(future), + Task::KeepAlive { + generation, + interval, + } => spawn_local(keep_alive(inner.clone(), generation, interval)), + } + } +} + +/// Forward values to a reader's stream, until the reader or the connection is gone. +async fn forward_values( + mut values: mpsc::Receiver, + mut writer: StreamWriter, +) { + while let Some(value) = next(&mut values).await { + if writer.write_one(value).await.is_some() { + break; + } + } +} + +/// Send PING whenever the connection was idle for `interval`, closing it if PING fails. +async fn keep_alive(inner: Weak, generation: u64, interval: DurationMilliseconds) { + loop { + let Some(seen) = keep_alive_activity(&inner, generation) else { + return; + }; + // a wasi:clocks duration is in nanoseconds + wait_for(interval.px.saturating_mul(1_000_000)).await; + let Some(inner) = inner.upgrade() else { + return; + }; + let idle = { + let state = inner.state.borrow(); + if state.keep_alive_generation != generation || state.closed.is_some() { + return; + } + state.activity == seen && state.pending.is_empty() + }; + if idle { + if let Err(error) = exchange(&inner, words(&["PING"])).await { + close(&inner.state, error); + inner.requests.close_channel(); + return; + } + } + } +} + +/// The connection's activity, `None` once the keep-alive should stop. +fn keep_alive_activity(inner: &Weak, generation: u64) -> Option { + let inner = inner.upgrade()?; + let state = inner.state.borrow(); + (state.keep_alive_generation == generation && state.closed.is_none()).then_some(state.activity) +} + +/// Send HELLO, then hand over the connection, or the error that stopped it. +async fn establish( + inner: Rc, + opts: Option, + connection: FutureWriter>, +) { + let (auth, client_name, keep_alive_interval) = match opts { + Some(opts) => (opts.auth, opts.client_name, opts.keep_alive_interval), + None => (None, None, None), + }; + + let mut hello = words(&["HELLO", "3"]); + if let Some((username, password)) = auth { + hello.extend([ + b"AUTH".to_vec(), + username.into_bytes(), + password.into_bytes(), + ]); + } + if let Some(client_name) = client_name { + hello.extend([b"SETNAME".to_vec(), client_name.into_bytes()]); + } + + inner.state.borrow_mut().hello = hello.clone(); + let (hello_tx, hello_rx) = oneshot::channel(); + // the library, best effort, a server before 7.2 does not know CLIENT SETINFO, its error is + // discarded. It survives RESET, so it is only sent once. + let setinfo = [("LIB-NAME", LIB_NAME), ("LIB-VER", LIB_VER)]; + let queued = enqueue(&inner, hello, Some(Reply::Call(hello_tx)), None).and_then(|()| { + for (attr, value) in setinfo { + let command = words(&["CLIENT", "SETINFO", attr, value]); + enqueue(&inner, command, Some(Reply::Discard), None)?; + } + Ok(()) + }); + let hello = match queued { + // dropped unsent when the connection closes, the reader fails it first + Ok(()) => hello_rx + .await + .unwrap_or_else(|_| Err(closed_error(&inner.state))), + Err(error) => Err(error), + }; + let result = match hello.and_then(server_properties) { + Ok(properties) => { + inner.state.borrow_mut().server_properties = properties; + let established = ConnectionImpl(inner); + established.set_keep_alive(keep_alive_interval); + Ok(Connection::new(established)) + } + // dropping `inner` closes the connection + Err(error) => Err(error), + }; + // the connection is dropped, and closed, when nobody waits for it anymore + let _ = connection.write(result).await; +} + +/// The server's properties from the reply to HELLO, a map of property names to values. +fn server_properties(reply: RespValue) -> Result, ErrorCode> { + let RespValue::Map(entries) = reply else { + return Err(ErrorCode::Client(format!( + "unexpected reply to HELLO: {reply}" + ))); + }; + entries + .into_iter() + .map(|(name, value)| { + let invalid = |name: RespValue| { + ErrorCode::Client(format!( + "unexpected property name in reply to HELLO: {name}" + )) + }; + let name = match decode(&name)? { + RespValue::String(name) => name, + // the bytes come back with the error, for its message + RespValue::BulkString(bytes) => String::from_utf8(bytes) + .map_err(|error| invalid(RespValue::BulkString(error.into_bytes())))?, + name => return Err(invalid(name)), + }; + Ok((name, decode(&value)?)) + }) + .collect() +} + +fn decode(value: &[u8]) -> Result { + componentized_valkey::resp::decode(value).map_err(ErrorCode::Resp) +} + +/// An error value from the server fails the command. +/// A simple string `OK`. +fn ok(reply: RespValue) -> Result<(), ErrorCode> { + match reply { + RespValue::String(text) if text == "OK" => Ok(()), + reply => Err(unexpected(&reply)), + } +} + +fn unexpected(reply: &RespValue) -> ErrorCode { + ErrorCode::Client(format!("unexpected reply: {reply:?}")) +} + +fn reply_result(value: RespValue) -> Result { + match value { + RespValue::Error(message) => Err(ErrorCode::Server(StringOrBytes::String(message))), + // binary safe, like a bulk string + RespValue::BulkError(message) => Err(ErrorCode::Server(StringOrBytes::from_bytes(message))), + value => Ok(value), + } +} + +/// A transport's error as the reason a connection closed. +fn describe(error: ErrorCode) -> Option { + match error { + ErrorCode::Closed(reason) => reason, + ErrorCode::Client(message) | ErrorCode::Resp(message) => Some(message), + ErrorCode::Server(message) => Some(message.to_string()), + ErrorCode::Lagged => Some("a stream fell behind".to_string()), + } +} + +wit_bindgen::generate!({ + // `path` is relative to the manifest, `components/connection/` + path: "../wit", + world: "connection", + // the resp types shared by every component + with: { + "componentized:valkey/types@0.3.0-dev": componentized_valkey::types, + }, + generate_all, +}); + +export!(Component); diff --git a/components/connection/src/pubsub.rs b/components/connection/src/pubsub.rs new file mode 100644 index 0000000..70ccf4c --- /dev/null +++ b/components/connection/src/pubsub.rs @@ -0,0 +1,930 @@ +//! Pub/sub: subscriptions to channels, patterns and shard channels, see `subscription` in the +//! connection's WIT. Left out of builds without the `pubsub` feature, see `pubsub_disabled.rs`. + +use std::collections::{HashMap, VecDeque}; +use std::rc::Rc; + +use componentized_valkey::types::{ + RespValue, StringOrBytes, SubscriptionEvent, SubscriptionMessage, SubscriptionTarget, + UnsubscribeReason, +}; +use futures_channel::{mpsc, oneshot}; +use wit_bindgen::{FutureReader, FutureWriter, StreamReader, StreamWriter}; + +use crate::exports::componentized::valkey::connection::{ + ErrorCode, GuestSubscription, Subscription, +}; +use crate::{ + Inner, PUSH_BUFFER, Reply, State, Task, closed_error, decode, enqueue, next, wit_future, + wit_stream, +}; + +/// A new subscription, setting the ratchet, see `State::owns_subscriptions`. +pub(crate) fn subscription(inner: &Rc) -> Result { + let id = { + let mut state = inner.state.borrow_mut(); + state.owns_subscriptions = true; + let closed = state.closed.clone(); + state.subscriptions.create(closed) + }; + Ok(Subscription::new(SubscriptionImpl(Handle { + inner: inner.clone(), + id, + }))) +} + +/// A subscription's command waiting for the server, confirmed by a push message for each target +/// rather than a reply. +pub(crate) enum Pending { + /// SUBSCRIBE and the like. + Subscribe { + kind: Kind, + /// The targets not confirmed yet. + channels: Vec, + id: u64, + done: oneshot::Sender>, + }, + /// UNSUBSCRIBE and the like. + Unsubscribe { + kind: Kind, + /// The targets not confirmed yet. + channels: Vec, + done: oneshot::Sender>, + }, +} + +impl Pending { + /// A reply arrived in the place of the confirmations, the server refused the command. + pub(crate) fn refused(self, subscriptions: &mut Subscriptions, error: ErrorCode) { + match self { + Pending::Subscribe { + kind, + channels, + id, + done, + } => { + abandon(subscriptions, id, kind, &channels); + let _ = done.send(Err(error)); + } + Pending::Unsubscribe { done, .. } => { + let _ = done.send(Err(error)); + } + } + } + + /// The connection closed. + pub(crate) fn fail(self, error: ErrorCode) { + match self { + Pending::Subscribe { done, .. } | Pending::Unsubscribe { done, .. } => { + let _ = done.send(Err(error)); + } + } + } +} + +pub struct SubscriptionImpl(Handle); + +impl GuestSubscription for SubscriptionImpl { + fn targets(&self) -> Vec { + let state = self.0.inner.state.borrow(); + let Some(sub) = state.subscriptions.subs.get(&self.0.id) else { + return vec![]; + }; + sub.targets + .iter() + .filter(|(_, hold)| *hold == Hold::Active) + .map(|((kind, name), _)| kind.target(name.clone())) + .collect() + } + + async fn subscribe(&self, targets: Vec) -> Result<(), ErrorCode> { + let groups = by_kind(targets)?; + subscribe(&self.0.inner, self.0.id, groups).await + } + + async fn unsubscribe(&self, targets: Option>) -> Result<(), ErrorCode> { + let groups = match targets { + Some(targets) => by_kind(targets)? + .into_iter() + .map(|(kind, names)| (kind, Some(names))) + .collect(), + None => Kind::ALL.into_iter().map(|kind| (kind, None)).collect(), + }; + unsubscribe(&self.0.inner, self.0.id, groups).await + } + + fn events(&self) -> (StreamReader, FutureReader) { + let (writer, stream) = wit_stream::new(); + // the default is only written if the subscription is gone before the stream ends + let (end_writer, end) = wit_future::new(|| Err(ErrorCode::Closed(None))); + let (mut events_tx, events_rx) = mpsc::channel(PUSH_BUFFER); + let (end_tx, end_rx) = oneshot::channel(); + { + let mut state = self.0.inner.state.borrow_mut(); + if let Some(sub) = state.subscriptions.subs.get_mut(&self.0.id) { + // the backlog fits, it is no larger than the channel + for event in sub.backlog.drain(..) { + let _ = events_tx.try_send(event); + } + match &sub.closed { + Some(error) => { + let _ = end_tx.send(Err(error.clone())); + } + None => sub.readers.push(Reader { + events: events_tx, + end: end_tx, + }), + } + } + } + let forward = forward_events(events_rx, end_rx, writer, end_writer); + let _ = self + .0 + .inner + .tasks + .unbounded_send(Task::Spawn(Box::pin(forward))); + (stream, end) + } +} + +/// The kind of a subscription's target, each with its own commands and push messages. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum Kind { + Channel, + Pattern, + Shard, +} + +impl Kind { + const ALL: [Kind; 3] = [Kind::Channel, Kind::Pattern, Kind::Shard]; + + fn subscribe(self) -> &'static str { + match self { + Kind::Channel => "SUBSCRIBE", + Kind::Pattern => "PSUBSCRIBE", + Kind::Shard => "SSUBSCRIBE", + } + } + + fn unsubscribe(self) -> &'static str { + match self { + Kind::Channel => "UNSUBSCRIBE", + Kind::Pattern => "PUNSUBSCRIBE", + Kind::Shard => "SUNSUBSCRIBE", + } + } + + fn target(self, name: String) -> SubscriptionTarget { + match self { + Kind::Channel => SubscriptionTarget::Channel(name), + Kind::Pattern => SubscriptionTarget::Pattern(name), + Kind::Shard => SubscriptionTarget::Shardchannel(name), + } + } +} + +/// The names of the targets of each kind, in the order each kind first appears, failing unless +/// there is at least one target. +fn by_kind(targets: Vec) -> Result)>, ErrorCode> { + if targets.is_empty() { + return Err(ErrorCode::Client( + "targets requires at least one item".to_string(), + )); + } + let mut groups: Vec<(Kind, Vec)> = vec![]; + for target in targets { + let (kind, name) = match target { + SubscriptionTarget::Channel(name) => (Kind::Channel, name), + SubscriptionTarget::Pattern(name) => (Kind::Pattern, name), + SubscriptionTarget::Shardchannel(name) => (Kind::Shard, name), + }; + match groups.iter_mut().find(|(grouped, _)| *grouped == kind) { + Some((_, names)) => names.push(name), + None => groups.push((kind, vec![name])), + } + } + Ok(groups) +} + +/// A channel, pattern or shard channel, by its kind and name. +type Target = (Kind, String); + +/// How a subscription holds a target. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Hold { + /// Waiting for the server to confirm SUBSCRIBE. + Pending, + /// Confirmed, its messages arrive. + Active, + /// Unsubscribing, its messages arrive until the server confirms UNSUBSCRIBE. + Leaving, +} + +/// How a stream of events ended, `ok` once the subscription is dropped. +type Ended = Result<(), ErrorCode>; + +/// A reader of a subscription's events, and where to send how its stream ended. +struct Reader { + events: mpsc::Sender, + end: oneshot::Sender, +} + +#[derive(Default)] +struct Sub { + /// The targets, in the order they were subscribed to. + targets: Vec<(Target, Hold)>, + readers: Vec, + /// How to tell readers whose stream was dropped how the subscription ended. + orphans: Vec>, + /// The events arriving while no stream is open, for the next stream. + backlog: VecDeque, + /// The error that closed the connection, once it has. + closed: Option, +} + +impl Sub { + fn hold(&self, target: &Target) -> Option { + self.targets + .iter() + .find(|(held, _)| held == target) + .map(|(_, hold)| *hold) + } + + fn set_hold(&mut self, target: &Target, hold: Hold) { + match self.targets.iter_mut().find(|(held, _)| held == target) { + Some((_, current)) => *current = hold, + None => self.targets.push((target.clone(), hold)), + } + } + + fn release(&mut self, target: &Target) { + self.targets.retain(|(held, _)| held != target); + } + + /// Fan the event out to every reader, a reader that is full is cut off, one whose stream was + /// dropped is forgotten. + fn emit(&mut self, event: SubscriptionEvent) { + if self.readers.is_empty() { + if self.backlog.len() < PUSH_BUFFER { + self.backlog.push_back(event); + } + return; + } + for mut reader in std::mem::take(&mut self.readers) { + match reader.events.try_send(event.clone()) { + Ok(()) => self.readers.push(reader), + Err(error) if error.is_full() => { + let _ = reader.end.send(Err(ErrorCode::Lagged)); + } + Err(_) => self.orphans.push(reader.end), + } + } + } + + /// End the readers' streams. + fn end(&mut self, ended: Ended) { + for reader in self.readers.drain(..) { + let _ = reader.end.send(ended.clone()); + } + for orphan in self.orphans.drain(..) { + let _ = orphan.send(ended.clone()); + } + } +} + +fn unsubscribed(target: &Target, reason: UnsubscribeReason) -> SubscriptionEvent { + SubscriptionEvent::Unsubscribed((target.0.target(target.1.clone()), reason)) +} + +#[derive(Default)] +pub(crate) struct Subscriptions { + next_id: u64, + subs: HashMap, + /// The subscriptions holding each target, in any way. + holders: HashMap>, +} + +impl Subscriptions { + fn create(&mut self, closed: Option) -> u64 { + let id = self.next_id; + self.next_id += 1; + self.subs.insert( + id, + Sub { + closed, + ..Sub::default() + }, + ); + id + } + + fn hold(&mut self, id: u64, target: &Target, hold: Hold) { + let Some(sub) = self.subs.get_mut(&id) else { + return; + }; + if sub.hold(target).is_none() { + self.holders.entry(target.clone()).or_default().push(id); + } + sub.set_hold(target, hold); + } + + /// The subscription no longer holds the target. + fn release(&mut self, id: u64, target: &Target) { + if let Some(sub) = self.subs.get_mut(&id) { + sub.release(target); + } + if let Some(ids) = self.holders.get_mut(target) { + ids.retain(|holder| *holder != id); + if ids.is_empty() { + self.holders.remove(target); + } + } + } + + /// The subscriptions holding the target in one of the ways. + fn holding(&self, target: &Target, holds: &[Hold]) -> Vec { + self.holders + .get(target) + .into_iter() + .flatten() + .copied() + .filter(|id| { + self.subs + .get(id) + .and_then(|sub| sub.hold(target)) + .is_some_and(|hold| holds.contains(&hold)) + }) + .collect() + } + + fn emit(&mut self, id: u64, event: SubscriptionEvent) { + if let Some(sub) = self.subs.get_mut(&id) { + sub.emit(event); + } + } + + /// The server confirmed SUBSCRIBE for the subscription's target. + fn subscribed(&mut self, id: u64, target: &Target) { + let Some(sub) = self.subs.get_mut(&id) else { + return; + }; + match sub.hold(target) { + Some(Hold::Pending) => sub.set_hold(target, Hold::Active), + // unsubscribed from while waiting, UNSUBSCRIBE follows + Some(Hold::Leaving) => {} + _ => return, + } + sub.emit(SubscriptionEvent::Subscribed( + target.0.target(target.1.clone()), + )); + } + + /// A message for the subscriptions receiving the target's messages, false when none holds it. + fn message(&mut self, target: &Target, message: SubscriptionMessage) -> bool { + if !self.holders.contains_key(target) { + return false; + } + for id in self.holding(target, &[Hold::Active, Hold::Leaving]) { + self.emit(id, SubscriptionEvent::Message(message.clone())); + } + true + } + + /// The server confirmed UNSUBSCRIBE for the target, the subscriptions leaving it let go. + fn unsubscribe_confirmed(&mut self, target: &Target) { + for id in self.holding(target, &[Hold::Leaving]) { + self.release(id, target); + self.emit(id, unsubscribed(target, UnsubscribeReason::Requested)); + } + } + + /// The server unsubscribed from the target on its own, false when none holds it. + fn unsubscribed_by_server(&mut self, target: &Target) -> bool { + if !self.holders.contains_key(target) { + return false; + } + for id in self.holding(target, &[Hold::Active, Hold::Leaving]) { + let leaving = + self.subs.get(&id).and_then(|sub| sub.hold(target)) == Some(Hold::Leaving); + self.release(id, target); + let reason = if leaving { + UnsubscribeReason::Requested + } else { + UnsubscribeReason::ByServer + }; + self.emit(id, unsubscribed(target, reason)); + } + true + } + + /// RESET unsubscribed from every target the server confirmed, without a push message. Those + /// still waiting for the server subscribe after RESET. + pub(crate) fn reset(&mut self) { + let mut reset = vec![]; + for (id, sub) in &self.subs { + for (target, hold) in &sub.targets { + if *hold != Hold::Pending { + reset.push((*id, target.clone())); + } + } + } + for (id, target) in reset { + self.release(id, &target); + self.emit(id, unsubscribed(&target, UnsubscribeReason::Reset)); + } + } + + /// The connection closed, ending every stream. + pub(crate) fn close(&mut self, error: &ErrorCode) { + for sub in self.subs.values_mut() { + sub.end(Err(error.clone())); + sub.closed = Some(error.clone()); + } + self.holders.clear(); + } + + /// The subscription was dropped and unsubscribed, ending its streams. + fn remove(&mut self, id: u64) { + let Some(mut sub) = self.subs.remove(&id) else { + return; + }; + sub.end(Ok(())); + for (target, _) in sub.targets { + if let Some(ids) = self.holders.get_mut(&target) { + ids.retain(|holder| *holder != id); + if ids.is_empty() { + self.holders.remove(&target); + } + } + } + } +} + +/// A subscription, for its resource. +/// +/// Dropping it unsubscribes from its targets, in the background, then ends its streams. +struct Handle { + inner: Rc, + id: u64, +} + +impl Drop for Handle { + fn drop(&mut self) { + let inner = self.inner.clone(); + let id = self.id; + let _ = self + .inner + .tasks + .unbounded_send(Task::Spawn(Box::pin(async move { + let every = Kind::ALL.into_iter().map(|kind| (kind, None)).collect(); + let _ = unsubscribe(&inner, id, every).await; + inner.state.borrow_mut().subscriptions.remove(id); + }))); + } +} + +/// The names given once each, in order. +fn unique(names: Vec) -> Vec { + let mut unique: Vec = vec![]; + for name in names { + if !unique.contains(&name) { + unique.push(name); + } + } + unique +} + +/// A command for one kind of targets, queued and waiting for the server to confirm it. +type Confirmation = oneshot::Receiver>; + +/// Subscribe to the targets, one command for each kind, queued with no other command between +/// them. Every command is sent, the error holds each that failed. +async fn subscribe( + inner: &Inner, + id: u64, + groups: Vec<(Kind, Vec)>, +) -> Result<(), ErrorCode> { + let several = groups.len() > 1; + // no await until every command is queued + let queued: Vec<_> = groups + .into_iter() + .map(|(kind, names)| (kind, queue_subscribe(inner, id, kind, names))) + .collect(); + let mut errors = vec![]; + for (kind, queued) in queued { + let result = match queued { + Ok(None) => Ok(()), + // dropped unsent when the connection closes, the reader fails it first + Ok(Some((names, confirmation))) => { + let result = confirmation + .await + .unwrap_or_else(|_| Err(closed_error(&inner.state))); + if result.is_err() { + abandon( + &mut inner.state.borrow_mut().subscriptions, + id, + kind, + &names, + ); + } + result + } + Err(error) => Err(error), + }; + if let Err(error) = result { + errors.push((kind.subscribe(), error)); + } + } + aggregate(errors, several) +} + +/// Queue SUBSCRIBE for the targets the subscription does not hold yet, none when it holds them all. +fn queue_subscribe( + inner: &Inner, + id: u64, + kind: Kind, + names: Vec, +) -> Result, Confirmation)>, ErrorCode> { + let names: Vec = { + let mut state = inner.state.borrow_mut(); + // before the targets are touched, they stay as they were when the connection closed + if let Some(error) = &state.closed { + return Err(error.clone()); + } + let subscriptions = &mut state.subscriptions; + let Some(sub) = subscriptions.subs.get(&id) else { + return Ok(None); + }; + let names: Vec = unique(names) + .into_iter() + .filter(|name| { + // a target being left is subscribed to again, after its UNSUBSCRIBE + matches!(sub.hold(&(kind, name.clone())), None | Some(Hold::Leaving)) + }) + .collect(); + for name in &names { + subscriptions.hold(id, &(kind, name.clone()), Hold::Pending); + } + names + }; + if names.is_empty() { + return Ok(None); + } + let command = std::iter::once(kind.subscribe().as_bytes().to_vec()) + .chain(names.iter().map(|name| name.as_bytes().to_vec())) + .collect(); + let (done_tx, done_rx) = oneshot::channel(); + let reply = Reply::Pubsub(Pending::Subscribe { + kind, + channels: names.clone(), + id, + done: done_tx, + }); + match enqueue(inner, command, Some(reply), None) { + Ok(()) => Ok(Some((names, done_rx))), + Err(error) => { + abandon( + &mut inner.state.borrow_mut().subscriptions, + id, + kind, + &names, + ); + Err(error) + } + } +} + +/// SUBSCRIBE failed, the subscription lets go of the targets it was waiting for. +fn abandon(subscriptions: &mut Subscriptions, id: u64, kind: Kind, names: &[String]) { + for name in names { + let target = (kind, name.clone()); + let pending = subscriptions + .subs + .get(&id) + .and_then(|sub| sub.hold(&target)) + == Some(Hold::Pending); + if pending { + subscriptions.release(id, &target); + } + } +} + +/// Unsubscribe from the targets, none for every target of the kind, one command for each kind, +/// queued with no other command between them. Every command is sent, the error holds each that +/// failed. +async fn unsubscribe( + inner: &Inner, + id: u64, + groups: Vec<(Kind, Option>)>, +) -> Result<(), ErrorCode> { + let several = groups.len() > 1; + // no await until every command is queued + let queued: Vec<_> = groups + .into_iter() + .map(|(kind, names)| (kind, queue_unsubscribe(inner, id, kind, names))) + .collect(); + let mut errors = vec![]; + for (kind, queued) in queued { + let result = match queued { + Ok(None) => Ok(()), + // dropped unsent when the connection closes, the reader fails it first + Ok(Some(confirmation)) => confirmation + .await + .unwrap_or_else(|_| Err(closed_error(&inner.state))), + Err(error) => Err(error), + }; + if let Err(error) = result { + errors.push((kind.unsubscribe(), error)); + } + } + aggregate(errors, several) +} + +/// Queue UNSUBSCRIBE for the targets no other subscription holds, the subscription lets go of the +/// others at once. None when there is nothing to send. +fn queue_unsubscribe( + inner: &Inner, + id: u64, + kind: Kind, + names: Option>, +) -> Result, ErrorCode> { + let release = { + let mut state = inner.state.borrow_mut(); + // before the targets are touched, they stay as they were when the connection closed + if let Some(error) = &state.closed { + return Err(error.clone()); + } + let subscriptions = &mut state.subscriptions; + let Some(sub) = subscriptions.subs.get(&id) else { + return Ok(None); + }; + let held = |name: &String| { + matches!( + sub.hold(&(kind, name.clone())), + Some(Hold::Pending | Hold::Active) + ) + }; + let names: Vec = match names { + Some(names) => unique(names).into_iter().filter(held).collect(), + None => sub + .targets + .iter() + .filter(|((held_kind, name), _)| *held_kind == kind && held(name)) + .map(|((_, name), _)| name.clone()) + .collect(), + }; + let mut release = vec![]; + for name in names { + let target = (kind, name); + let kept = subscriptions + .holding(&target, &[Hold::Pending, Hold::Active]) + .iter() + .any(|other| *other != id); + if kept { + subscriptions.release(id, &target); + subscriptions.emit(id, unsubscribed(&target, UnsubscribeReason::Requested)); + } else { + subscriptions.hold(id, &target, Hold::Leaving); + release.push(target.1); + } + } + release + }; + if release.is_empty() { + return Ok(None); + } + let command = std::iter::once(kind.unsubscribe().as_bytes().to_vec()) + .chain(release.iter().map(|name| name.as_bytes().to_vec())) + .collect(); + let (done_tx, done_rx) = oneshot::channel(); + let reply = Reply::Pubsub(Pending::Unsubscribe { + kind, + channels: release, + done: done_tx, + }); + enqueue(inner, command, Some(reply), None)?; + Ok(Some(done_rx)) +} + +/// The errors of the commands of a call as one, each prefixed with its command when the call sent +/// several. An error of one kind keeps it, e.g. every command was refused by the server, otherwise +/// it is a client error. +fn aggregate(errors: Vec<(&str, ErrorCode)>, several: bool) -> Result<(), ErrorCode> { + let mut errors = errors.into_iter(); + let Some((command, first)) = errors.next() else { + return Ok(()); + }; + if !several { + return Err(first); + } + let kind = std::mem::discriminant(&first); + let mut same = true; + let mut messages = vec![format!("{command}: {}", describe_error(&first))]; + for (command, error) in errors { + same &= std::mem::discriminant(&error) == kind; + messages.push(format!("{command}: {}", describe_error(&error))); + } + if messages.len() == 1 { + return Err(match first { + ErrorCode::Server(_) => ErrorCode::Server(messages.remove(0).into()), + ErrorCode::Client(_) => ErrorCode::Client(messages.remove(0)), + ErrorCode::Resp(_) => ErrorCode::Resp(messages.remove(0)), + error => error, + }); + } + let message = messages.join("; "); + Err(match first { + // the connection closed, every command failed the same way + error @ (ErrorCode::Closed(_) | ErrorCode::Lagged) if same => error, + ErrorCode::Server(_) if same => ErrorCode::Server(message.into()), + ErrorCode::Resp(_) if same => ErrorCode::Resp(message), + _ => ErrorCode::Client(message), + }) +} + +/// An error for people to read, in an aggregate of errors. +fn describe_error(error: &ErrorCode) -> String { + match error { + ErrorCode::Closed(None) => "connection closed".to_string(), + ErrorCode::Closed(Some(reason)) => format!("connection closed: {reason}"), + ErrorCode::Client(message) | ErrorCode::Resp(message) => message.clone(), + ErrorCode::Server(message) => message.to_string(), + ErrorCode::Lagged => "fell behind".to_string(), + } +} + +/// A push message the connection understands. +enum Push { + /// The server subscribed to the target. + Subscribed(Target), + /// The server unsubscribed from the target, none when nothing was subscribed. + Unsubscribed(Kind, Option), + /// A message, for the target it was subscribed with. + Message(Target, SubscriptionMessage), +} + +fn parse_push(value: &RespValue) -> Option { + let RespValue::Push(items) = value else { + return None; + }; + let mut items = items + .iter() + .map(|item| decode(item).ok()) + .collect::>>()?; + // each item is taken once, rather than cloned + let mut take = |index: usize| { + items + .get_mut(index) + .map(|item| std::mem::replace(item, RespValue::Null)) + }; + let push = match text(take(0))?.as_str() { + "subscribe" => Push::Subscribed((Kind::Channel, text(take(1))?)), + "psubscribe" => Push::Subscribed((Kind::Pattern, text(take(1))?)), + "ssubscribe" => Push::Subscribed((Kind::Shard, text(take(1))?)), + "unsubscribe" => Push::Unsubscribed(Kind::Channel, text(take(1))), + "punsubscribe" => Push::Unsubscribed(Kind::Pattern, text(take(1))), + "sunsubscribe" => Push::Unsubscribed(Kind::Shard, text(take(1))), + "message" => { + let channel = text(take(1))?; + let message = SubscriptionMessage { + payload: payload(take(2))?, + channel: channel.clone(), + target: SubscriptionTarget::Channel(channel.clone()), + }; + Push::Message((Kind::Channel, channel), message) + } + "pmessage" => { + let pattern = text(take(1))?; + let message = SubscriptionMessage { + channel: text(take(2))?, + payload: payload(take(3))?, + target: SubscriptionTarget::Pattern(pattern.clone()), + }; + Push::Message((Kind::Pattern, pattern), message) + } + "smessage" => { + let shardchannel = text(take(1))?; + let message = SubscriptionMessage { + payload: payload(take(2))?, + channel: shardchannel.clone(), + target: SubscriptionTarget::Shardchannel(shardchannel.clone()), + }; + Push::Message((Kind::Shard, shardchannel), message) + } + _ => return None, + }; + Some(push) +} + +/// The text of an item of a push message, a simple or bulk string that is UTF-8, e.g. its kind or +/// a channel. +fn text(item: Option) -> Option { + match item? { + RespValue::String(text) => Some(text), + RespValue::BulkString(bytes) => String::from_utf8(bytes).ok(), + _ => None, + } +} + +/// The payload of a message, a string when it is UTF-8, otherwise its bytes. +fn payload(item: Option) -> Option { + match item? { + // the bytes come back with the error, rather than being cloned to try + RespValue::BulkString(bytes) => Some(match String::from_utf8(bytes) { + Ok(text) => StringOrBytes::String(text), + Err(error) => StringOrBytes::Bytes(error.into_bytes()), + }), + RespValue::String(text) => Some(StringOrBytes::String(text)), + _ => None, + } +} + +/// Hand a push message to the subscriptions, or the command, it belongs to, false when it belongs +/// to none. +pub(crate) fn claim_push(state: &mut State, value: &RespValue) -> bool { + let State { + pending, + subscriptions, + .. + } = state; + match parse_push(value) { + Some(Push::Subscribed(target)) => { + // the confirmations of a SUBSCRIBE arrive in the place of its reply + let Some(Reply::Pubsub(Pending::Subscribe { + kind, channels, id, .. + })) = pending.front_mut() + else { + return false; + }; + if *kind != target.0 { + return false; + } + let Some(index) = channels.iter().position(|name| *name == target.1) else { + return false; + }; + channels.remove(index); + let (id, done) = (*id, channels.is_empty()); + subscriptions.subscribed(id, &target); + if done + && let Some(Reply::Pubsub(Pending::Subscribe { done, .. })) = pending.pop_front() + { + let _ = done.send(Ok(())); + } + true + } + Some(Push::Unsubscribed(kind, Some(name))) => { + let target = (kind, name); + // the confirmations of an UNSUBSCRIBE arrive in the place of its reply, otherwise the + // server unsubscribed on its own + let confirmation = match pending.front_mut() { + Some(Reply::Pubsub(Pending::Unsubscribe { kind, channels, .. })) + if *kind == target.0 => + { + channels + .iter() + .position(|name| *name == target.1) + .map(|index| { + channels.remove(index); + channels.is_empty() + }) + } + _ => None, + }; + match confirmation { + Some(done) => { + subscriptions.unsubscribe_confirmed(&target); + if done + && let Some(Reply::Pubsub(Pending::Unsubscribe { done, .. })) = + pending.pop_front() + { + let _ = done.send(Ok(())); + } + true + } + None => subscriptions.unsubscribed_by_server(&target), + } + } + Some(Push::Message(target, message)) => subscriptions.message(&target, message), + Some(Push::Unsubscribed(_, None)) | None => false, + } +} + +/// Forward a subscription's events to a reader's stream, then close the stream and tell the reader +/// how it ended. +async fn forward_events( + mut events: mpsc::Receiver, + end: oneshot::Receiver, + mut writer: StreamWriter, + end_writer: FutureWriter, +) { + while let Some(event) = next(&mut events).await { + if writer.write_one(event).await.is_some() { + // the stream was dropped, the subscription carries on without it + break; + } + } + drop(events); + drop(writer); + let end = end.await.unwrap_or_else(|_| Err(ErrorCode::Closed(None))); + let _ = end_writer.write(end).await; +} diff --git a/components/connection/src/pubsub_disabled.rs b/components/connection/src/pubsub_disabled.rs new file mode 100644 index 0000000..9594f3a --- /dev/null +++ b/components/connection/src/pubsub_disabled.rs @@ -0,0 +1,79 @@ +//! Pub/sub left out, for builds without the `pubsub` feature, see `pubsub.rs`. +//! +//! The interface stays the same: the subscription resource is exported, but none is ever made, +//! `connection.subscription` fails, and every push message is a dead letter. + +use std::rc::Rc; + +use componentized_valkey::types::{RespValue, SubscriptionEvent, SubscriptionTarget}; +use wit_bindgen::{FutureReader, StreamReader}; + +use crate::exports::componentized::valkey::connection::{ + ErrorCode, GuestSubscription, Subscription, +}; +use crate::{Inner, State}; + +/// Fails, there are no subscriptions in this build. +pub(crate) fn subscription(_inner: &Rc) -> Result { + Err(ErrorCode::Client( + "subscriptions are not supported by this build of the connection, push messages arrive in dead-letters" + .to_string(), + )) +} + +/// A subscription's command waiting for the server, never queued in this build. +pub(crate) enum Pending {} + +impl Pending { + pub(crate) fn refused(self, _subscriptions: &mut Subscriptions, _error: ErrorCode) { + match self {} + } + + pub(crate) fn fail(self, _error: ErrorCode) { + match self {} + } +} + +/// No subscriptions. +#[derive(Default)] +pub(crate) struct Subscriptions; + +impl Subscriptions { + pub(crate) fn reset(&mut self) {} + + pub(crate) fn close(&mut self, _error: &ErrorCode) {} +} + +/// No push message belongs to a subscription, each is a dead letter. +pub(crate) fn claim_push(_state: &mut State, _value: &RespValue) -> bool { + false +} + +/// A subscription, of which this build never makes one. +pub enum SubscriptionImpl {} + +impl GuestSubscription for SubscriptionImpl { + fn targets(&self) -> Vec { + match *self {} + } + + async fn subscribe(&self, _targets: Vec) -> Result<(), ErrorCode> { + match *self {} + } + + async fn unsubscribe( + &self, + _targets: Option>, + ) -> Result<(), ErrorCode> { + match *self {} + } + + fn events( + &self, + ) -> ( + StreamReader, + FutureReader>, + ) { + match *self {} + } +} diff --git a/components/connection/tests/connection.rs b/components/connection/tests/connection.rs new file mode 100644 index 0000000..b915721 --- /dev/null +++ b/components/connection/tests/connection.rs @@ -0,0 +1,1762 @@ +//! Tests for the `connection` component, over a stub transport playing the server. + +use std::time::Duration; + +use test_harness::types::{ + DurationMilliseconds, StringOrBytes, SubscriptionEvent, SubscriptionMessage, + SubscriptionTarget, UnsubscribeReason, +}; +use test_harness::{ + ConnectOpts, ErrorCode, Harness, RespValue, Transport, bulk, command, connect, establish, + handshake, hello_reply, ok, push, receive, resolve, strings, words, +}; + +/// Long enough for the connection to act on what it was given, short enough to keep tests quick. +const SETTLE: Duration = Duration::from_millis(100); + +fn simple(text: &str) -> RespValue { + RespValue::String(text.to_string()) +} + +fn channel(name: &str) -> SubscriptionTarget { + SubscriptionTarget::Channel(name.to_string()) +} + +fn pattern(name: &str) -> SubscriptionTarget { + SubscriptionTarget::Pattern(name.to_string()) +} + +fn shardchannel(name: &str) -> SubscriptionTarget { + SubscriptionTarget::Shardchannel(name.to_string()) +} + +/// A push message confirming a target, e.g. `confirm("subscribe", "news")`. +fn confirm(kind: &str, name: &str) -> RespValue { + push(vec![bulk(kind), bulk(name), RespValue::Integer(1)]) +} + +fn message(payload: &str, channel: &str, target: SubscriptionTarget) -> Option { + Some(SubscriptionEvent::Message(SubscriptionMessage { + payload: StringOrBytes::String(payload.to_string()), + channel: channel.to_string(), + target, + })) +} + +fn unsubscribed( + target: SubscriptionTarget, + reason: UnsubscribeReason, +) -> Option { + Some(SubscriptionEvent::Unsubscribed((target, reason))) +} + +/// The commands the connection wrote, until the server reads nothing more for a while. +async fn requests(transport: &mut Transport) -> Vec> { + let mut requests = vec![]; + while let Ok(Some(request)) = tokio::time::timeout(SETTLE, transport.request()).await { + requests.push(request); + } + requests +} + +#[tokio::test(flavor = "multi_thread")] +async fn establishes_with_hello() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (_transport, connection) = establish(accessor, exports).await?; + let properties = guest.call_server_properties(accessor, connection).await?; + assert_eq!( + properties, + vec![ + ("server".to_string(), bulk("valkey")), + ("version".to_string(), bulk("9.1.2")), + ("proto".to_string(), RespValue::Integer(3)), + ] + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn hello_carries_auth_and_client_name() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let opts = ConnectOpts { + auth: Some(("user".to_string(), "secret".to_string())), + client_name: Some("tests".to_string()), + keep_alive_interval: None, + }; + let (mut transport, connection) = connect(accessor, exports, Some(opts)).await?; + let hello = ["HELLO", "3", "AUTH", "user", "secret", "SETNAME", "tests"]; + handshake(&mut transport, &hello).await; + assert!(resolve(accessor, connection).await?.is_ok()); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn hello_error_fails_to_establish() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let (mut transport, connection) = connect(accessor, exports, None).await?; + transport.request().await; + transport.reply(RespValue::Error("WRONGPASS invalid password".to_string())); + let Err(error) = resolve(accessor, connection).await? else { + panic!("established despite the error reply"); + }; + assert_eq!( + error, + ErrorCode::Server(StringOrBytes::String( + "WRONGPASS invalid password".to_string() + )) + ); + // the CLIENT SETINFO commands queued with HELLO, then the connection is dropped, which + // closes the requests stream + assert_eq!( + transport.request().await, + Some(test_harness::setinfo()[0].clone()) + ); + assert_eq!( + transport.request().await, + Some(test_harness::setinfo()[1].clone()) + ); + assert_eq!(transport.request().await, None); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn closing_before_hello_fails_to_establish() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let (mut transport, connection) = connect(accessor, exports, None).await?; + transport.request().await; + transport.close(Err(ErrorCode::Closed(Some( + "connection refused".to_string(), + )))); + assert_eq!( + resolve(accessor, connection).await?.err(), + Some(ErrorCode::Closed(Some("connection refused".to_string()))) + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn exchanges_are_paired_with_replies_in_order() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let first = guest.call_exchange(accessor, connection, words(&["GET", "a"])); + let second = guest.call_exchange(accessor, connection, words(&["GET", "b"])); + let server = async { + // both are in flight before either is answered + assert_eq!(transport.request().await, Some(command(&["GET", "a"]))); + assert_eq!(transport.request().await, Some(command(&["GET", "b"]))); + transport.reply(bulk("first")); + transport.reply(bulk("second")); + }; + let (first, second, ()) = tokio::join!(first, second, server); + assert_eq!(first?, Ok(bulk("first"))); + assert_eq!(second?, Ok(bulk("second"))); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn binary_words_are_sent_as_given() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let value = vec![0x80, 0x00, b'\r', b'\n', 0xff]; + let command = vec![ + StringOrBytes::String("SET".to_string()), + StringOrBytes::Bytes(b"key".to_vec()), + StringOrBytes::Bytes(value.clone()), + ]; + let call = guest.call_exchange(accessor, connection, command); + let server = async { + assert_eq!( + transport.request().await, + Some(vec![bulk("SET"), bulk("key"), RespValue::BulkString(value)]) + ); + transport.reply(ok()); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(ok())); + + // the commands the connection manages are recognized as bytes too + let reset = vec![StringOrBytes::Bytes(b"reset".to_vec())]; + let result = guest.call_emit(accessor, connection, reset).await?; + assert_eq!( + result, + Err(ErrorCode::Client( + "RESET is rejected, use the connection's reset".to_string() + )) + ); + // a name that is not UTF-8 is sent for the server to refuse + let call = + guest.call_exchange(accessor, connection, vec![StringOrBytes::Bytes(vec![0xff])]); + let server = async { + assert_eq!( + transport.request().await, + Some(vec![RespValue::BulkString(vec![0xff])]) + ); + transport.reply(RespValue::Error("ERR unknown command".to_string())); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!( + result?, + Err(ErrorCode::Server(StringOrBytes::String( + "ERR unknown command".to_string() + ))) + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn error_replies_fail_the_exchange() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let call = guest.call_exchange(accessor, connection, words(&["INCR", "text"])); + let server = async { + transport.request().await; + transport.reply(RespValue::Error("ERR value is not an integer".to_string())); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!( + result?, + Err(ErrorCode::Server(StringOrBytes::String( + "ERR value is not an integer".to_string() + ))) + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn bulk_errors_may_be_binary() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + for (reply, expected) in [ + // binary safe, like a bulk string + ( + b"ERR \x80".to_vec(), + StringOrBytes::Bytes(b"ERR \x80".to_vec()), + ), + ( + b"ERR text".to_vec(), + StringOrBytes::String("ERR text".to_string()), + ), + ] { + let call = guest.call_exchange(accessor, connection, words(&["PING"])); + let server = async { + transport.request().await; + transport.reply(RespValue::BulkError(reply)); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Err(ErrorCode::Server(expected))); + } + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn emit_discards_its_reply() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + guest + .call_emit(accessor, connection, words(&["SET", "a", "1"])) + .await??; + let call = guest.call_exchange(accessor, connection, words(&["GET", "a"])); + let server = async { + assert_eq!(transport.request().await, Some(command(&["SET", "a", "1"]))); + assert_eq!(transport.request().await, Some(command(&["GET", "a"]))); + // the reply to SET is discarded, even an error + transport.reply(RespValue::Error("ERR discarded".to_string())); + transport.reply(bulk("1")); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(bulk("1"))); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn dead_letters_go_to_every_reader() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let first = guest.call_dead_letters(accessor, connection).await?; + let second = guest.call_dead_letters(accessor, connection).await?; + let mut first = receive(accessor, first)?; + let mut second = receive(accessor, second)?; + + // a message no subscription owns + let message = push(vec![bulk("message"), bulk("news"), bulk("hello")]); + let call = guest.call_exchange(accessor, connection, words(&["PING"])); + let server = async { + transport.request().await; + // a push message arriving ahead of a reply is not mistaken for it + transport.reply(message.clone()); + transport.reply(simple("PONG")); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(simple("PONG"))); + assert_eq!(first.recv().await, Some(message.clone())); + assert_eq!(second.recv().await, Some(message)); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn unprompted_values_are_dead_letters() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let dead_letters = guest.call_dead_letters(accessor, connection).await?; + let mut dead_letters = receive(accessor, dead_letters)?; + + let call = guest.call_exchange(accessor, connection, words(&["MONITOR"])); + let server = async { + transport.request().await; + transport.reply(ok()); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(ok())); + + // the feed of MONITOR, simple strings with no command waiting for them + let feed = simple(r#"1760190000.123456 [0 127.0.0.1:52144] "ping""#); + transport.reply(feed.clone()); + assert_eq!(dead_letters.recv().await, Some(feed)); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn slow_dead_letter_readers_are_closed() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + // a reader that never reads + let _slow = guest.call_dead_letters(accessor, connection).await?; + for n in 0..1000 { + transport.reply(push(vec![ + bulk("message"), + bulk("news"), + bulk(&n.to_string()), + ])); + } + // replies are not held up behind the reader + let call = guest.call_exchange(accessor, connection, words(&["PING"])); + let server = async { + transport.request().await; + transport.reply(simple("PONG")); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(simple("PONG"))); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn closing_fails_waiting_and_later_exchanges() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let reset = ErrorCode::Closed(Some("connection reset".to_string())); + let call = guest.call_exchange(accessor, connection, words(&["GET", "a"])); + let server = async { + transport.request().await; + transport.close(Err(reset.clone())); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Err(reset.clone())); + let later = guest + .call_exchange(accessor, connection, words(&["GET", "a"])) + .await?; + assert_eq!(later, Err(reset)); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn keep_alive_pings_an_idle_connection() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let interval = DurationMilliseconds { px: 20 }; + guest + .call_set_keep_alive(accessor, connection, Some(interval)) + .await?; + assert_eq!(transport.request().await, Some(command(&["PING"]))); + transport.reply(simple("PONG")); + assert_eq!(transport.request().await, Some(command(&["PING"]))); + transport.reply(simple("PONG")); + + // disabled again, no more pings + guest + .call_set_keep_alive(accessor, connection, None) + .await?; + let quiet = tokio::time::timeout(SETTLE, transport.request()).await; + assert!(quiet.is_err(), "pinged after keep-alive was disabled"); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn dropping_the_connection_closes_the_requests() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + let (mut transport, connection) = harness + .run(async |accessor, exports| establish(accessor, exports).await) + .await?; + harness.drop_resource(connection).await?; + // the guest closes the requests stream once its tasks run again + harness + .run(async |_accessor, _exports| { + assert_eq!(transport.request().await, None); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn commands_the_connection_manages_are_rejected() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let rejected = |message: &str| Err(ErrorCode::Client(message.to_string())); + let reset = "RESET is rejected, use the connection's reset"; + let hello = "HELLO is rejected, the connection sent it when it was established, see its server-properties"; + let reply = "CLIENT REPLY is rejected, every command has a reply, see the connection's emit"; + for (command, message) in [ + (words(&["reset"]), reset), + (words(&["HELLO", "2"]), hello), + (words(&["client", "reply", "off"]), reply), + ] { + let result = guest.call_exchange(accessor, connection, command).await?; + assert_eq!(result, rejected(message)); + } + let result = guest + .call_transaction(accessor, connection, vec![words(&["RESET"])]) + .await?; + assert_eq!(result.err(), Some(ErrorCode::Client(reset.to_string()))); + + // subscribing with exchange is allowed until the connection has a subscription + let result = guest + .call_exchange(accessor, connection, words(&["SUBSCRIBE", "raw"])) + .await?; + assert_eq!(result, Ok(RespValue::Null)); + let _subscription = guest.call_subscription(accessor, connection).await??; + let result = guest + .call_exchange(accessor, connection, words(&["unsubscribe"])) + .await?; + assert_eq!( + result, + rejected( + "UNSUBSCRIBE is rejected once the connection has a subscription, use the subscription's methods" + ) + ); + // only the SUBSCRIBE from before the subscription was sent + assert_eq!(requests(&mut transport).await, vec![command(&["SUBSCRIBE", "raw"])]); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn subscriptions_are_confirmed_by_push_messages() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let subscriptions = exports.subscription(); + let (mut transport, connection) = establish(accessor, exports).await?; + let subscription = guest.call_subscription(accessor, connection).await??; + let (events, _ended) = subscriptions.call_events(accessor, subscription).await?; + let mut events = receive(accessor, events)?; + let dead_letters = guest.call_dead_letters(accessor, connection).await?; + let mut dead_letters = receive(accessor, dead_letters)?; + + let call = subscriptions.call_subscribe( + accessor, + subscription, + vec![channel("news"), channel("sports")], + ); + let server = async { + assert_eq!( + transport.request().await, + Some(command(&["SUBSCRIBE", "news", "sports"])) + ); + transport.reply(push(vec![ + bulk("subscribe"), + bulk("news"), + RespValue::Integer(1), + ])); + transport.reply(push(vec![ + bulk("subscribe"), + bulk("sports"), + RespValue::Integer(2), + ])); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(())); + assert_eq!( + events.recv().await, + Some(SubscriptionEvent::Subscribed(channel("news"))) + ); + assert_eq!( + events.recv().await, + Some(SubscriptionEvent::Subscribed(channel("sports"))) + ); + assert_eq!( + subscriptions.call_targets(accessor, subscription).await?, + vec![channel("news"), channel("sports")] + ); + + // a message for a target goes to the subscription, others are dead letters + transport.reply(push(vec![bulk("message"), bulk("news"), bulk("hello")])); + let unowned = push(vec![bulk("message"), bulk("weather"), bulk("rain")]); + transport.reply(unowned.clone()); + assert_eq!( + events.recv().await, + Some(SubscriptionEvent::Message(SubscriptionMessage { + payload: StringOrBytes::String("hello".to_string()), + channel: "news".to_string(), + target: channel("news"), + })) + ); + assert_eq!(dead_letters.recv().await, Some(unowned)); + + let call = + subscriptions.call_unsubscribe(accessor, subscription, Some(vec![channel("news")])); + let server = async { + assert_eq!( + transport.request().await, + Some(command(&["UNSUBSCRIBE", "news"])) + ); + transport.reply(push(vec![ + bulk("unsubscribe"), + bulk("news"), + RespValue::Integer(1), + ])); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(())); + assert_eq!( + events.recv().await, + Some(SubscriptionEvent::Unsubscribed(( + channel("news"), + UnsubscribeReason::Requested + ))) + ); + assert_eq!( + subscriptions.call_targets(accessor, subscription).await?, + vec![channel("sports")] + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn binary_messages_are_delivered() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let subscriptions = exports.subscription(); + let (mut transport, connection) = establish(accessor, exports).await?; + let subscription = guest.call_subscription(accessor, connection).await??; + let (events, _) = subscriptions.call_events(accessor, subscription).await?; + let mut events = receive(accessor, events)?; + let dead_letters = guest.call_dead_letters(accessor, connection).await?; + let mut dead_letters = receive(accessor, dead_letters)?; + let call = subscriptions.call_subscribe(accessor, subscription, vec![channel("news")]); + let server = async { + transport.request().await; + transport.reply(confirm("subscribe", "news")); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(())); + assert_eq!( + events.recv().await, + Some(SubscriptionEvent::Subscribed(channel("news"))) + ); + + // not UTF-8, the bytes + let payload = vec![0x80, 0x00, b'\r', b'\n', 0xff]; + transport.reply(push(vec![ + bulk("message"), + bulk("news"), + RespValue::BulkString(payload.clone()), + ])); + assert_eq!( + events.recv().await, + Some(SubscriptionEvent::Message(SubscriptionMessage { + payload: StringOrBytes::Bytes(payload), + channel: "news".to_string(), + target: channel("news"), + })) + ); + // UTF-8, even with control characters and beyond ASCII, a string + transport.reply(push(vec![ + bulk("message"), + bulk("news"), + RespValue::BulkString("a\r\n€".as_bytes().to_vec()), + ])); + assert_eq!( + events.recv().await, + message("a\r\n€", "news", channel("news")) + ); + assert!( + tokio::time::timeout(SETTLE, dead_letters.recv()) + .await + .is_err(), + "a dead letter" + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn binary_property_names_fail_to_establish() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let (mut transport, connection) = connect(accessor, exports, None).await?; + for _ in 0..3 { + transport.request().await; + } + transport.reply(test_harness::map(vec![( + RespValue::BulkString(vec![0x80]), + bulk("valkey"), + )])); + assert_eq!( + resolve(accessor, connection).await?.err(), + Some(ErrorCode::Client( + r#"unexpected property name in reply to HELLO: bulk-string"# + .to_string() + )) + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn error_replies_fail_a_subscribe() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let subscriptions = exports.subscription(); + let (mut transport, connection) = establish(accessor, exports).await?; + let subscription = guest.call_subscription(accessor, connection).await??; + let subscribe = + subscriptions.call_subscribe(accessor, subscription, vec![channel("secret")]); + let get = guest.call_exchange(accessor, connection, words(&["GET", "a"])); + let server = async { + transport.request().await; + transport.request().await; + // in the place of the confirmation, then the reply to GET + transport.reply(RespValue::Error( + "NOPERM No permissions to access a channel".to_string(), + )); + transport.reply(bulk("1")); + }; + let (subscribed, got, ()) = tokio::join!(subscribe, get, server); + assert_eq!( + subscribed?, + Err(ErrorCode::Server(StringOrBytes::String( + "NOPERM No permissions to access a channel".to_string() + ))) + ); + assert_eq!(got?, Ok(bulk("1"))); + assert_eq!( + subscriptions.call_targets(accessor, subscription).await?, + Vec::::new() + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn dropping_a_subscription_unsubscribes() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + let (mut transport, subscription) = harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let subscriptions = exports.subscription(); + let (mut transport, connection) = establish(accessor, exports).await?; + let subscription = guest.call_subscription(accessor, connection).await??; + let call = subscriptions.call_subscribe(accessor, subscription, vec![channel("news")]); + let server = async { + transport.request().await; + transport.reply(push(vec![ + bulk("subscribe"), + bulk("news"), + RespValue::Integer(1), + ])); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(())); + Ok((transport, subscription)) + }) + .await?; + harness.drop_resource(subscription).await?; + harness + .run(async |_accessor, _exports| { + assert_eq!( + transport.request().await, + Some(command(&["UNSUBSCRIBE", "news"])) + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn reset_holds_commands_until_hello_succeeds() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let reset = guest.call_reset(accessor, connection); + let get = guest.call_exchange(accessor, connection, words(&["GET", "a"])); + let server = async { + assert_eq!(transport.request().await, Some(command(&["RESET"]))); + assert_eq!(transport.request().await, Some(command(&["HELLO", "3"]))); + // GET is not sent while the connection is on RESP2, without its user + assert!( + tokio::time::timeout(SETTLE, transport.request()) + .await + .is_err() + ); + transport.reply(simple("RESET")); + transport.reply(hello_reply()); + assert_eq!(transport.request().await, Some(command(&["GET", "a"]))); + transport.reply(bulk("1")); + }; + let (reset, get, ()) = tokio::join!(reset, get, server); + assert_eq!(reset?, Ok(())); + assert_eq!(get?, Ok(bulk("1"))); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn reset_closes_the_connection_when_hello_fails() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let wrongpass = ErrorCode::Server(StringOrBytes::String( + "WRONGPASS invalid username-password pair".to_string(), + )); + let reset = guest.call_reset(accessor, connection); + let get = guest.call_exchange(accessor, connection, words(&["GET", "a"])); + let server = async { + assert_eq!(transport.request().await, Some(command(&["RESET"]))); + assert_eq!(transport.request().await, Some(command(&["HELLO", "3"]))); + transport.reply(simple("RESET")); + transport.reply(RespValue::Error( + "WRONGPASS invalid username-password pair".to_string(), + )); + // GET was never sent, the connection closed its requests stream + assert_eq!(transport.request().await, None); + }; + let (reset, get, ()) = tokio::join!(reset, get, server); + assert_eq!(reset?, Err(wrongpass.clone())); + assert_eq!(get?, Err(wrongpass.clone())); + let later = guest + .call_exchange(accessor, connection, words(&["PING"])) + .await?; + assert_eq!(later, Err(wrongpass)); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn exchange_all_sends_the_commands_together() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let call = guest.call_exchange_all( + accessor, + connection, + vec![words(&["SET", "a", "1"]), vec![], words(&["GET", "a"])], + ); + let server = async { + assert_eq!(transport.request().await, Some(command(&["SET", "a", "1"]))); + assert_eq!(transport.request().await, Some(command(&["GET", "a"]))); + transport.reply(RespValue::Error("ERR oops".to_string())); + transport.reply(bulk("1")); + }; + let (results, ()) = tokio::join!(call, server); + assert_eq!( + results?, + vec![ + Err(ErrorCode::Server(StringOrBytes::String( + "ERR oops".to_string() + ))), + // an empty command fails in its place, the others are sent + Err(ErrorCode::Client( + "a command requires at least one word".to_string() + )), + Ok(bulk("1")), + ] + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn transactions_wrap_the_commands_in_multi_and_exec() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let queued = || simple("QUEUED"); + + // committed, a command that fails while running is an error value in its place + let call = guest.call_transaction( + accessor, + connection, + vec![ + vec![ + StringOrBytes::String("SET".to_string()), + StringOrBytes::String("a".to_string()), + StringOrBytes::Bytes(vec![0x80, 0xff]), + ], + words(&["INCR", "text"]), + ], + ); + let server = async { + assert_eq!(transport.request().await, Some(command(&["MULTI"]))); + assert_eq!( + transport.request().await, + Some(vec![ + bulk("SET"), + bulk("a"), + RespValue::BulkString(vec![0x80, 0xff]) + ]) + ); + assert_eq!(transport.request().await, Some(command(&["INCR", "text"]))); + assert_eq!(transport.request().await, Some(command(&["EXEC"]))); + transport.reply(ok()); + transport.reply(queued()); + transport.reply(queued()); + transport.reply(RespValue::Array(vec![ + test_harness::encode(ok()), + test_harness::encode(RespValue::Error("ERR not an integer".to_string())), + ])); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!( + result?, + Ok(Some(vec![ + ok(), + RespValue::Error("ERR not an integer".to_string()) + ])) + ); + + // a watched key changed + let call = + guest.call_transaction(accessor, connection, vec![words(&["SET", "a", "2"])]); + let server = async { + for _ in 0..3 { + transport.request().await; + } + transport.reply(ok()); + transport.reply(queued()); + transport.reply(RespValue::Null); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(None)); + + // a command rejected while queuing fails the transaction with its own error + let call = guest.call_transaction(accessor, connection, vec![words(&["NOPE"])]); + let server = async { + for _ in 0..3 { + transport.request().await; + } + transport.reply(ok()); + transport.reply(RespValue::Error("ERR unknown command 'NOPE'".to_string())); + transport.reply(RespValue::Error( + "EXECABORT Transaction discarded because of previous errors.".to_string(), + )); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!( + result?, + Err(ErrorCode::Server(StringOrBytes::String( + "ERR unknown command 'NOPE'".to_string() + ))) + ); + + // an empty command fails before MULTI is sent + let result = guest + .call_transaction(accessor, connection, vec![words(&["GET", "a"]), vec![]]) + .await?; + assert_eq!( + result, + Err(ErrorCode::Client( + "a command requires at least one word".to_string() + )) + ); + assert_eq!(requests(&mut transport).await, Vec::>::new()); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn watch_and_unwatch() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let call = guest.call_watch(accessor, connection, strings(&["a", "b"])); + let server = async { + assert_eq!( + transport.request().await, + Some(command(&["WATCH", "a", "b"])) + ); + transport.reply(ok()); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(())); + + let call = guest.call_unwatch(accessor, connection); + let server = async { + assert_eq!(transport.request().await, Some(command(&["UNWATCH"]))); + transport.reply(ok()); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(())); + + let result = guest.call_watch(accessor, connection, vec![]).await?; + assert_eq!( + result, + Err(ErrorCode::Client( + "keys requires at least one item".to_string() + )) + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn mixed_kinds_are_subscribed_with_a_command_each() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let subscriptions = exports.subscription(); + let (mut transport, connection) = establish(accessor, exports).await?; + let subscription = guest.call_subscription(accessor, connection).await??; + let (events, _ended) = subscriptions.call_events(accessor, subscription).await?; + let mut events = receive(accessor, events)?; + + let targets = vec![ + channel("news"), + pattern("n*"), + shardchannel("orders"), + channel("sports"), + ]; + let call = subscriptions.call_subscribe(accessor, subscription, targets); + let server = async { + assert_eq!( + transport.request().await, + Some(command(&["SUBSCRIBE", "news", "sports"])) + ); + assert_eq!( + transport.request().await, + Some(command(&["PSUBSCRIBE", "n*"])) + ); + assert_eq!( + transport.request().await, + Some(command(&["SSUBSCRIBE", "orders"])) + ); + transport.reply(confirm("subscribe", "news")); + transport.reply(confirm("subscribe", "sports")); + // every command is sent, one failing does not stop the others + transport.reply(RespValue::Error("NOPERM no patterns".to_string())); + transport.reply(confirm("ssubscribe", "orders")); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!( + result?, + Err(ErrorCode::Server(StringOrBytes::String( + "PSUBSCRIBE: NOPERM no patterns".to_string() + ))) + ); + for target in [channel("news"), channel("sports"), shardchannel("orders")] { + assert_eq!( + events.recv().await, + Some(SubscriptionEvent::Subscribed(target)) + ); + } + assert_eq!( + subscriptions.call_targets(accessor, subscription).await?, + vec![channel("news"), channel("sports"), shardchannel("orders")] + ); + + // the targets given back to unsubscribe, a command for each kind + let targets = subscriptions.call_targets(accessor, subscription).await?; + let call = subscriptions.call_unsubscribe(accessor, subscription, Some(targets)); + let server = async { + assert_eq!( + transport.request().await, + Some(command(&["UNSUBSCRIBE", "news", "sports"])) + ); + assert_eq!( + transport.request().await, + Some(command(&["SUNSUBSCRIBE", "orders"])) + ); + transport.reply(confirm("unsubscribe", "news")); + transport.reply(confirm("unsubscribe", "sports")); + transport.reply(confirm("sunsubscribe", "orders")); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(())); + assert_eq!( + subscriptions.call_targets(accessor, subscription).await?, + Vec::::new() + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn overlapping_subscriptions_share_the_servers() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let subscriptions = exports.subscription(); + let (mut transport, connection) = establish(accessor, exports).await?; + let first = guest.call_subscription(accessor, connection).await??; + let second = guest.call_subscription(accessor, connection).await??; + let (first_events, _) = subscriptions.call_events(accessor, first).await?; + let mut first_events = receive(accessor, first_events)?; + let (second_events, _) = subscriptions.call_events(accessor, second).await?; + let mut second_events = receive(accessor, second_events)?; + + for subscription in [first, second] { + let call = + subscriptions.call_subscribe(accessor, subscription, vec![channel("news")]); + let server = async { + assert_eq!( + transport.request().await, + Some(command(&["SUBSCRIBE", "news"])) + ); + transport.reply(confirm("subscribe", "news")); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(())); + } + transport.reply(push(vec![bulk("message"), bulk("news"), bulk("both")])); + for events in [&mut first_events, &mut second_events] { + assert_eq!( + events.recv().await, + Some(SubscriptionEvent::Subscribed(channel("news"))) + ); + assert_eq!( + events.recv().await, + message("both", "news", channel("news")) + ); + } + + // the other subscription keeps the channel, nothing is sent + let result = subscriptions + .call_unsubscribe(accessor, first, Some(vec![channel("news")])) + .await?; + assert_eq!(result, Ok(())); + assert_eq!( + first_events.recv().await, + unsubscribed(channel("news"), UnsubscribeReason::Requested) + ); + assert_eq!(requests(&mut transport).await, Vec::>::new()); + transport.reply(push(vec![bulk("message"), bulk("news"), bulk("second")])); + assert_eq!( + second_events.recv().await, + message("second", "news", channel("news")) + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn pattern_messages_carry_the_channel() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let subscriptions = exports.subscription(); + let (mut transport, connection) = establish(accessor, exports).await?; + let subscription = guest.call_subscription(accessor, connection).await??; + let (events, _) = subscriptions.call_events(accessor, subscription).await?; + let mut events = receive(accessor, events)?; + let call = subscriptions.call_subscribe(accessor, subscription, vec![pattern("n*")]); + let server = async { + transport.request().await; + transport.reply(confirm("psubscribe", "n*")); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(())); + transport.reply(push(vec![ + bulk("pmessage"), + bulk("n*"), + bulk("news"), + bulk("hello"), + ])); + assert_eq!( + events.recv().await, + Some(SubscriptionEvent::Subscribed(pattern("n*"))) + ); + assert_eq!(events.recv().await, message("hello", "news", pattern("n*"))); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn targets_end_by_the_server_reset_and_closing() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let subscriptions = exports.subscription(); + let (mut transport, connection) = establish(accessor, exports).await?; + let subscription = guest.call_subscription(accessor, connection).await??; + let (events, ended) = subscriptions.call_events(accessor, subscription).await?; + let mut events = receive(accessor, events)?; + let targets = vec![shardchannel("orders"), channel("news")]; + let call = subscriptions.call_subscribe(accessor, subscription, targets); + let server = async { + transport.request().await; + transport.request().await; + transport.reply(confirm("ssubscribe", "orders")); + transport.reply(confirm("subscribe", "news")); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(())); + events.recv().await; + events.recv().await; + + // the slot of the shard channel moved, the server unsubscribed on its own + transport.reply(confirm("sunsubscribe", "orders")); + assert_eq!( + events.recv().await, + unsubscribed(shardchannel("orders"), UnsubscribeReason::ByServer) + ); + + // RESET unsubscribes from every target, the subscription stays open + let call = guest.call_reset(accessor, connection); + let server = async { + transport.request().await; + transport.request().await; + transport.reply(simple("RESET")); + transport.reply(hello_reply()); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(())); + assert_eq!( + events.recv().await, + unsubscribed(channel("news"), UnsubscribeReason::Reset) + ); + + // closing the connection closes the stream with the error + let reason = ErrorCode::Closed(Some("connection reset".to_string())); + transport.close(Err(reason.clone())); + assert_eq!(events.recv().await, None); + assert_eq!(resolve(accessor, ended).await?, Err(reason)); + Ok(()) + }) + .await +} + +/// The tests against a Valkey server, ignored unless run with `make test-valkey`, which runs one in +/// a container and only runs the ignored tests in `with_valkey` modules. They connect with +/// the connector of `test-tcp`, and leave nothing behind on the server. +mod with_valkey { + use test_harness::{Ctx, Exports}; + use wasmtime::component::{Accessor, ResourceAny}; + + use super::*; + + async fn connect(accessor: &Accessor, exports: &Exports) -> wasmtime::Result { + exports + .connector() + .call_connect(accessor) + .await? + .map_err(|error| wasmtime::format_err!("failed to connect: {error:?}")) + } + + async fn exchange( + accessor: &Accessor, + exports: &Exports, + connection: ResourceAny, + command: &[&str], + ) -> wasmtime::Result> { + exports + .connection() + .call_exchange(accessor, connection, words(command)) + .await + } + + /// PUBSUB NUMSUB of one channel. + async fn numsub( + accessor: &Accessor, + exports: &Exports, + connection: ResourceAny, + channel: &str, + ) -> wasmtime::Result { + let reply = exchange( + accessor, + exports, + connection, + &["PUBSUB", "NUMSUB", channel], + ) + .await? + .map_err(|error| wasmtime::format_err!("{error:?}"))?; + let RespValue::Array(mut items) = reply else { + wasmtime::bail!("unexpected {reply:?}"); + }; + test_harness::decode(items.remove(1)).map_err(|error| wasmtime::format_err!("{error}")) + } + + #[tokio::test(flavor = "multi_thread")] + #[ignore = "needs a Valkey server, see make test-valkey"] + async fn subscriptions_against_valkey() -> wasmtime::Result<()> { + let mut harness = Harness::with_valkey("test-tcp", &[]).await?; + harness + .run(async |accessor, exports| { + let subscriptions = exports.subscription(); + let connection = connect(accessor, exports).await?; + let publisher = connect(accessor, exports).await?; + let subscription = exports + .connection() + .call_subscription(accessor, connection) + .await??; + let (events, ended) = subscriptions.call_events(accessor, subscription).await?; + let mut events = receive(accessor, events)?; + + let targets = vec![ + channel("t:conn:a"), + pattern("t:conn:p*"), + shardchannel("t:conn:s"), + ]; + let result = subscriptions + .call_subscribe(accessor, subscription, targets.clone()) + .await?; + assert_eq!(result, Ok(())); + for target in &targets { + assert_eq!( + events.recv().await, + Some(SubscriptionEvent::Subscribed(target.clone())) + ); + } + + exchange( + accessor, + exports, + publisher, + &["PUBLISH", "t:conn:a", "one"], + ) + .await??; + assert_eq!( + events.recv().await, + message("one", "t:conn:a", channel("t:conn:a")) + ); + exchange( + accessor, + exports, + publisher, + &["PUBLISH", "t:conn:p1", "two"], + ) + .await??; + assert_eq!( + events.recv().await, + message("two", "t:conn:p1", pattern("t:conn:p*")) + ); + exchange( + accessor, + exports, + publisher, + &["SPUBLISH", "t:conn:s", "three"], + ) + .await??; + assert_eq!( + events.recv().await, + message("three", "t:conn:s", shardchannel("t:conn:s")) + ); + + // another subscription to the same channel shares the server's subscription + let other = exports + .connection() + .call_subscription(accessor, connection) + .await??; + subscriptions + .call_subscribe(accessor, other, vec![channel("t:conn:a")]) + .await??; + assert_eq!( + numsub(accessor, exports, publisher, "t:conn:a").await?, + RespValue::Integer(1) + ); + subscriptions + .call_unsubscribe(accessor, subscription, None) + .await??; + for target in &targets { + assert_eq!( + events.recv().await, + unsubscribed(target.clone(), UnsubscribeReason::Requested) + ); + } + assert_eq!( + numsub(accessor, exports, publisher, "t:conn:a").await?, + RespValue::Integer(1), + "kept for the other subscription" + ); + subscriptions + .call_unsubscribe(accessor, other, None) + .await??; + assert_eq!( + numsub(accessor, exports, publisher, "t:conn:a").await?, + RespValue::Integer(0) + ); + + // the stream stays open until the subscription is dropped + assert!( + tokio::time::timeout(SETTLE, events.recv()).await.is_err(), + "the stream closed" + ); + drop(ended); + Ok(()) + }) + .await + } + + #[tokio::test(flavor = "multi_thread")] + #[ignore = "needs a Valkey server, see make test-valkey"] + async fn binary_values_against_valkey() -> wasmtime::Result<()> { + let mut harness = Harness::with_valkey("test-tcp", &[]).await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let subscriptions = exports.subscription(); + let connection = connect(accessor, exports).await?; + let publisher = connect(accessor, exports).await?; + let binary = vec![0x80, 0x00, b'\r', b'\n', 0xff]; + let key = vec![b't', b':', 0xfe]; + let word = |bytes: &[u8]| StringOrBytes::Bytes(bytes.to_vec()); + + let set = vec![ + StringOrBytes::String("SET".to_string()), + word(&key), + word(&binary), + ]; + guest.call_exchange(accessor, publisher, set).await??; + let get = vec![StringOrBytes::String("GET".to_string()), word(&key)]; + assert_eq!( + guest.call_exchange(accessor, publisher, get).await?, + Ok(RespValue::BulkString(binary.clone())) + ); + let del = vec![StringOrBytes::String("DEL".to_string()), word(&key)]; + guest.call_exchange(accessor, publisher, del).await??; + + // a binary message, published and delivered as given + let subscription = guest.call_subscription(accessor, connection).await??; + let (events, _) = subscriptions.call_events(accessor, subscription).await?; + let mut events = receive(accessor, events)?; + subscriptions + .call_subscribe(accessor, subscription, vec![channel("t:conn:bin")]) + .await??; + events.recv().await; + let publish = vec![ + StringOrBytes::String("PUBLISH".to_string()), + StringOrBytes::String("t:conn:bin".to_string()), + word(&binary), + ]; + guest.call_exchange(accessor, publisher, publish).await??; + assert_eq!( + events.recv().await, + Some(SubscriptionEvent::Message(SubscriptionMessage { + payload: StringOrBytes::Bytes(binary), + channel: "t:conn:bin".to_string(), + target: channel("t:conn:bin"), + })) + ); + Ok(()) + }) + .await + } + + #[tokio::test(flavor = "multi_thread")] + #[ignore = "needs a Valkey server, see make test-valkey"] + async fn partial_subscribe_failure_against_valkey() -> wasmtime::Result<()> { + let mut harness = Harness::with_valkey("test-tcp", &[]).await?; + harness + .run(async |accessor, exports| { + let subscriptions = exports.subscription(); + let admin = connect(accessor, exports).await?; + let user = [ + "ACL", + "SETUSER", + "t:conn-limited", + "on", + "nopass", + "resetchannels", + "&t:conn:ok", + "+@all", + "~*", + ]; + exchange(accessor, exports, admin, &user).await??; + let result = async { + let connection = connect(accessor, exports).await?; + let auth = ["AUTH", "t:conn-limited", "any"]; + exchange(accessor, exports, connection, &auth).await??; + let subscription = exports + .connection() + .call_subscription(accessor, connection) + .await??; + let targets = vec![ + pattern("t:conn:no*"), + channel("t:conn:ok"), + shardchannel("t:conn:no"), + ]; + let result = subscriptions + .call_subscribe(accessor, subscription, targets) + .await?; + let Err(ErrorCode::Server(StringOrBytes::String(message))) = result else { + wasmtime::bail!("expected a server error, got {result:?}"); + }; + assert!( + message.starts_with("PSUBSCRIBE: NOPERM") + && message.contains("; SSUBSCRIBE: NOPERM"), + "{message}" + ); + assert_eq!( + subscriptions.call_targets(accessor, subscription).await?, + vec![channel("t:conn:ok")] + ); + Ok(()) + } + .await; + exchange( + accessor, + exports, + admin, + &["ACL", "DELUSER", "t:conn-limited"], + ) + .await??; + result + }) + .await + } + + #[tokio::test(flavor = "multi_thread")] + #[ignore = "needs a Valkey server, see make test-valkey"] + async fn reset_against_valkey() -> wasmtime::Result<()> { + let mut harness = Harness::with_valkey("test-tcp", &[]).await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let subscriptions = exports.subscription(); + let connection = connect(accessor, exports).await?; + exchange(accessor, exports, connection, &["SELECT", "1"]).await??; + exchange( + accessor, + exports, + connection, + &["SET", "t:conn:reset", "db1"], + ) + .await??; + let subscription = guest.call_subscription(accessor, connection).await??; + let (events, _ended) = subscriptions.call_events(accessor, subscription).await?; + let mut events = receive(accessor, events)?; + subscriptions + .call_subscribe(accessor, subscription, vec![channel("t:conn:r")]) + .await??; + events.recv().await; + + guest.call_reset(accessor, connection).await??; + assert_eq!( + events.recv().await, + unsubscribed(channel("t:conn:r"), UnsubscribeReason::Reset) + ); + assert_eq!( + exchange(accessor, exports, connection, &["GET", "t:conn:reset"]).await?, + Ok(RespValue::Null), + "reset selects database 0" + ); + let properties = guest.call_server_properties(accessor, connection).await?; + assert!(properties.contains(&("proto".to_string(), RespValue::Integer(3)))); + let info = exchange(accessor, exports, connection, &["CLIENT", "INFO"]).await??; + let RespValue::VerbatimString((_, info)) = info else { + wasmtime::bail!("unexpected {info:?}"); + }; + assert!(info.contains("lib-name=componentized:valkey"), "{info}"); + + // the subscription is usable after the reset + subscriptions + .call_subscribe(accessor, subscription, vec![channel("t:conn:r")]) + .await??; + assert_eq!( + events.recv().await, + Some(SubscriptionEvent::Subscribed(channel("t:conn:r"))) + ); + + exchange(accessor, exports, connection, &["SELECT", "1"]).await??; + exchange(accessor, exports, connection, &["DEL", "t:conn:reset"]).await??; + Ok(()) + }) + .await + } + + #[tokio::test(flavor = "multi_thread")] + #[ignore = "needs a Valkey server, see make test-valkey"] + async fn expired_credentials_close_on_reset_against_valkey() -> wasmtime::Result<()> { + let mut setup = Harness::with_valkey("test-tcp", &[]).await?; + let user = [ + "ACL", + "SETUSER", + "t:conn-expire", + "on", + ">old", + "~*", + "&*", + "+@all", + ]; + setup + .run(async |accessor, exports| { + let admin = connect(accessor, exports).await?; + exchange(accessor, exports, admin, &user).await??; + Ok(()) + }) + .await?; + let config = [("username", "t:conn-expire"), ("password", "old")]; + let mut harness = Harness::with_valkey("test-tcp", &config).await?; + let result = harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let connection = connect(accessor, exports).await?; + let other = connect(accessor, exports).await?; + // connections already authenticated stay so + let expire = ["ACL", "SETUSER", "t:conn-expire", "resetpass", ">new"]; + exchange(accessor, exports, other, &expire).await??; + let reset = guest.call_reset(accessor, connection); + let set = guest.call_exchange( + accessor, + connection, + words(&["SET", "t:conn:leak", "1"]), + ); + let (reset, set) = tokio::join!(reset, set); + let wrongpass = |result: &Result<(), ErrorCode>| { + matches!(result, Err(ErrorCode::Server(StringOrBytes::String(message))) if message.starts_with("WRONGPASS")) + }; + assert!(wrongpass(&reset?), "the reset fails"); + assert!(wrongpass(&set?.map(|_| ())), "the command after it fails"); + assert_eq!( + exchange(accessor, exports, other, &["GET", "t:conn:leak"]).await?, + Ok(RespValue::Null), + "and never ran" + ); + Ok(()) + }) + .await; + setup + .run(async |accessor, exports| { + let admin = connect(accessor, exports).await?; + exchange( + accessor, + exports, + admin, + &["ACL", "DELUSER", "t:conn-expire"], + ) + .await??; + Ok(()) + }) + .await?; + result + } + + #[tokio::test(flavor = "multi_thread")] + #[ignore = "needs a Valkey server, see make test-valkey"] + async fn killed_connections_keep_their_targets_against_valkey() -> wasmtime::Result<()> { + let mut harness = Harness::with_valkey("test-tcp", &[]).await?; + harness + .run(async |accessor, exports| { + let subscriptions = exports.subscription(); + let connection = connect(accessor, exports).await?; + let admin = connect(accessor, exports).await?; + let RespValue::Integer(id) = + exchange(accessor, exports, connection, &["CLIENT", "ID"]).await?? + else { + wasmtime::bail!("unexpected CLIENT ID"); + }; + let subscription = exports + .connection() + .call_subscription(accessor, connection) + .await??; + let (events, ended) = subscriptions.call_events(accessor, subscription).await?; + let mut events = receive(accessor, events)?; + let targets = vec![channel("t:conn:k"), pattern("t:conn:k*")]; + subscriptions + .call_subscribe(accessor, subscription, targets.clone()) + .await??; + let kill = ["CLIENT", "KILL", "ID", &id.to_string()]; + exchange(accessor, exports, admin, &kill).await??; + while events.recv().await.is_some() {} + assert!(matches!( + resolve(accessor, ended).await?, + Err(ErrorCode::Closed(_)) + )); + assert_eq!( + subscriptions.call_targets(accessor, subscription).await?, + targets, + "the confirmed targets are still listed" + ); + let result = subscriptions + .call_subscribe(accessor, subscription, vec![channel("t:conn:k2")]) + .await?; + assert!(matches!(result, Err(ErrorCode::Closed(_)))); + Ok(()) + }) + .await + } +} + +/// The tests of `connection-minimal`, the same component built without pub/sub. +mod minimal { + use super::*; + + #[tokio::test(flavor = "multi_thread")] + async fn subscriptions_are_not_supported() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection-minimal").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let result = guest.call_subscription(accessor, connection).await?; + assert_eq!( + result.err(), + Some(ErrorCode::Client( + "subscriptions are not supported by this build of the connection, push messages arrive in dead-letters" + .to_string() + )) + ); + + // SUBSCRIBE and the like may be sent with exchange, every push message is a dead + // letter + let dead_letters = guest.call_dead_letters(accessor, connection).await?; + let mut dead_letters = receive(accessor, dead_letters)?; + let result = guest + .call_exchange(accessor, connection, words(&["SUBSCRIBE", "news"])) + .await?; + assert_eq!(result, Ok(RespValue::Null)); + assert_eq!( + transport.request().await, + Some(command(&["SUBSCRIBE", "news"])) + ); + let confirmation = confirm("subscribe", "news"); + let message = push(vec![bulk("message"), bulk("news"), bulk("hello")]); + transport.reply(confirmation.clone()); + transport.reply(message.clone()); + assert_eq!(dead_letters.recv().await, Some(confirmation)); + assert_eq!(dead_letters.recv().await, Some(message)); + Ok(()) + }) + .await + } + + #[tokio::test(flavor = "multi_thread")] + async fn exchanges_and_resets() -> wasmtime::Result<()> { + let mut harness = Harness::new("connection-minimal").await?; + harness + .run(async |accessor, exports| { + let guest = exports.connection(); + let (mut transport, connection) = establish(accessor, exports).await?; + let call = guest.call_exchange(accessor, connection, words(&["GET", "a"])); + let server = async { + assert_eq!(transport.request().await, Some(command(&["GET", "a"]))); + transport.reply(bulk("1")); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(bulk("1"))); + + let call = guest.call_reset(accessor, connection); + let server = async { + assert_eq!(transport.request().await, Some(command(&["RESET"]))); + assert_eq!(transport.request().await, Some(command(&["HELLO", "3"]))); + transport.reply(simple("RESET")); + transport.reply(hello_reply()); + }; + let (result, ()) = tokio::join!(call, server); + assert_eq!(result?, Ok(())); + Ok(()) + }) + .await + } +} diff --git a/components/dep-cli/README.md b/components/dep-cli/README.md deleted file mode 100644 index f31f01b..0000000 --- a/components/dep-cli/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# dep-cli - -The Valkey CLI before the ops client is composed in, see [`cli`](../cli/). diff --git a/components/dep-cli/src/lib.rs b/components/dep-cli/src/lib.rs deleted file mode 100644 index 84cd6b4..0000000 --- a/components/dep-cli/src/lib.rs +++ /dev/null @@ -1,774 +0,0 @@ -use clap::{ArgAction, Args, Parser, Subcommand}; -use componentized::valkey::{ - resp::{self, Value}, - store::{connect, Error, HelloOpts, HrandfieldOpts, HscanOpts}, -}; -use exports::wasi::cli::run::Guest; -use std::fmt; -use wasi::cli::{environment::get_arguments, stderr, stdout, types::ErrorCode}; -use wit_bindgen::{FutureReader, StreamReader}; - -/// Prints a line to stdout, like `std::println!`, which has no stdout on wasm32-unknown-unknown. -/// The write is awaited, so it is only usable in an async function. -macro_rules! println { - () => { - $crate::write_to(stdout::write_via_stream, String::from("\n")).await - }; - ($($arg:tt)*) => { - $crate::write_to(stdout::write_via_stream, format!("{}\n", format_args!($($arg)*))).await - }; -} - -/// Prints a line to stderr, like `std::eprintln!`, which has no stderr on wasm32-unknown-unknown. -/// The write is awaited, so it is only usable in an async function. -macro_rules! eprintln { - () => { - $crate::write_to(stderr::write_via_stream, String::from("\n")).await - }; - ($($arg:tt)*) => { - $crate::write_to(stderr::write_via_stream, format!("{}\n", format_args!($($arg)*))).await - }; -} - -/// Writes the value to the target, panics when it can't be written, like `std::println!`. -async fn write_to( - write_via_stream: fn(StreamReader) -> FutureReader>, - value: String, -) { - let (mut writer, reader) = wit_stream::new(); - let result = write_via_stream(reader); - writer.write_all(value.into_bytes()).await; - // the host finishes writing once the writable end is dropped - drop(writer); - if let Err(e) = result.await { - panic!("failed printing: {e:?}"); - } -} - -impl Guest for Cli { - async fn run() -> Result<(), ()> { - match exec().await { - Err(e) => { - eprintln!("Error: {e}"); - Err(()) - } - _ => Ok(()), - } - } -} - -#[derive(Parser)] -#[command(name = "cli", version, about, long_about = None)] -struct Cli { - /// Host or ip address hosting the Valkey service - #[arg(long, default_value = "127.0.0.1")] - host: String, - - /// Port hosting the Valkey service - #[arg(long, default_value = "6379")] - port: u16, - - /// RESP Protocol version - #[arg( - long, - // TODO change default to 3 - default_value = "2", - value_parser = clap::builder::PossibleValuesParser::new(["2", "3"]), - )] - proto_ver: String, - - /// Authentication username - #[arg(short, long, default_value = "default")] - username: String, - - /// Authentication password - #[arg(short, long)] - password: Option, - - /// Client name - #[arg(long)] - client_name: Option, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Send a raw command - SEND { - #[arg()] - cmd: Vec, - }, - - /// Access Control List commands - ACL(ACLArgs), - /// Removes the specified keys - DEL { - /// Key to delete - #[arg()] - key: String, - }, - /// Returns if key exists - EXISTS { - /// Key to check - #[arg()] - key: String, - }, - /// Get the value of key - GET { - /// Key to get - #[arg()] - key: String, - }, - /// Removes the specified fields from the hash stored at key - HDEL { - /// Key for hash - #[arg()] - key: String, - - /// Field to delete - #[arg()] - field: String, - }, - /// Current server and connection properties - HELLO, - /// Returns if the field exists in the hash stored at key - HEXISTS { - /// Key for hash - #[arg()] - key: String, - - /// Field to check - #[arg()] - field: String, - }, - /// Returns the value associated with field in the hash stored at key - HGET { - /// Key for hash - #[arg()] - key: String, - - /// Field to get - #[arg()] - field: String, - }, - /// Returns all fields and values of the hash stored at key - HGETALL { - /// Key for hash - #[arg()] - key: String, - }, - /// Increments the number stored at field in the hash stored at key by increment - HINCRBY { - /// Key for hash - #[arg()] - key: String, - - /// Field to increment - #[arg()] - field: String, - - /// Amount to increment by - #[arg(allow_hyphen_values = true)] - increment: i64, - }, - /// Increments the number stored at field in the hash stored at key by increment - HINCRBYFLOAT { - /// Key for hash - #[arg()] - key: String, - - /// Field to increment - #[arg()] - field: String, - - /// Amount to increment by - #[arg(allow_hyphen_values = true)] - increment: f64, - }, - /// Returns all field names in the hash stored at key - HKEYS { - /// Key for hash - #[arg()] - key: String, - }, - /// Returns the number of fields contained in the hash stored at key - HLEN { - /// Key for hash - #[arg()] - key: String, - }, - /// Returns the values associated with the specified fields in the hash stored at key - HMGET { - /// Key for hash - #[arg()] - key: String, - - /// Fields to get - #[arg()] - fields: Vec, - }, - /// Sets the specified fields to their respective values in the hash stored at key - HMSET { - /// Key for hash - #[arg()] - key: String, - - /// Field-value pairs to set - #[arg(value_name = "FIELD VALUE", required = true)] - fields: Vec, - }, - /// Random field from the hash value stored at key - HRANDFIELD { - /// Key for hash - #[arg()] - key: String, - - /// Number of fields to return - #[arg(short, long, default_value = "1")] - count: Option, - - /// Return the corresponding values along with the keys in the hash table - #[arg(long, action = ArgAction::SetTrue)] - with_values: Option, - }, - /// Incrementally iterate over a collection of fields in a hash stored at key - HSCAN { - /// Key for hash - #[arg()] - key: String, - - /// Cursor to resume a previous scan - #[arg()] - cursor: Option, - - /// Only iterate elements matching a given glob-style pattern - #[arg(short, long)] - match_: Option, - - /// Amount of work that should be done at every call in order to retrieve elements from the collection - #[arg(short, long)] - count: Option, - - /// Return only the keys in the hash table without their corresponding values - #[arg(long, action = ArgAction::SetTrue)] - no_values: Option, - }, - /// Sets the specified field to a value in the hash stored at key - HSET { - /// Key for hash - #[arg()] - key: String, - - /// Field to set - #[arg()] - field: String, - - /// Value to set - #[arg()] - value: String, - }, - /// Sets field in the hash stored at key to value, only if field does not yet exist - HSETNX { - /// Key for hash - #[arg()] - key: String, - - /// Field to set - #[arg()] - field: String, - - /// Value to set - #[arg()] - value: String, - }, - /// Returns the string length of the value associated with field in the hash stored at key - HSTRLEN { - /// Key for hash - #[arg()] - key: String, - - /// Field to check - #[arg()] - field: String, - }, - /// Returns all values in the hash stored at key - HVALS { - /// Key for hash - #[arg()] - key: String, - }, - /// Increments the number stored at key by one - INCR { - /// Key to increment - #[arg()] - key: String, - }, - /// Increments the number stored at key by increment - INCRBY { - /// Key to increment - #[arg()] - key: String, - - /// Amount to increment by - #[arg(allow_hyphen_values = true)] - increment: i64, - }, - /// Returns all keys matching pattern - KEYS { - /// Pattern for matching keys - #[arg()] - pattern: String, - }, - /// Posts a message to the given channel - PUBLISH { - /// Channel where message should be sent - #[arg()] - channel: String, - - /// Message to send - #[arg()] - message: String, - }, - /// Set key to hold the string value - SET { - /// Key to set - #[arg()] - key: String, - - /// Value to set - #[arg()] - value: String, - }, -} - -#[derive(Args)] -struct ACLArgs { - #[command(subcommand)] - command: ACLCommands, -} - -#[derive(Subcommand)] -enum ACLCommands { - /// Deletes the specified ACL user - DELUSER { - /// User to delete - #[arg()] - username: String, - }, - /// Generates a long and strong password - GENPASS, - /// Create an ACL user with the specified rules or modify the rules of an existing user - SETUSER { - /// User to set - #[arg()] - username: String, - - /// ACL rules to set - #[arg()] - rules: Vec, - }, -} - -async fn exec() -> Result<(), Error> { - let cli = Cli::parse_from(get_arguments().iter()); - - let opts = HelloOpts { - proto_ver: Some(cli.proto_ver.to_string()), - auth: match cli.password { - Some(password) => Some((cli.username, password)), - None => None, - }, - client_name: cli.client_name, - }; - let connection = connect(cli.host, cli.port, Some(opts)).await?; - - match &cli.command { - Commands::SEND { cmd } => { - let cmd: Vec = cmd - .iter() - .map(|c| Value::BulkString(c.to_string())) - .collect(); - let response = connection.send(cmd).await?; - println!("{response}"); - } - Commands::ACL(aclargs) => match &aclargs.command { - ACLCommands::DELUSER { username } => { - connection.acl_deluser(username.to_string()).await?; - println!("Deleted user {username}"); - } - ACLCommands::GENPASS => { - let pass = connection.acl_genpass().await?; - println!("{pass}"); - } - ACLCommands::SETUSER { username, rules } => { - connection - .acl_setuser(username.to_string(), rules.to_vec()) - .await?; - println!("Set user {username}"); - } - }, - Commands::DEL { key } => { - connection.del(key.to_string()).await?; - println!("Deleted {key}"); - } - Commands::EXISTS { key } => match connection.exists(key.to_string()).await? { - true => println!("true"), - false => println!("false"), - }, - Commands::GET { key } => match connection.get(key.to_string()).await? { - Some(value) => println!("{}", value), - None => println!(""), - }, - Commands::HDEL { key, field } => { - connection.hdel(key.to_string(), field.to_string()).await?; - println!("Deleted {field}"); - } - Commands::HELLO => { - for (key, value) in connection.hello(None).await? { - match value { - Value::Null => println!("{key}: "), - Value::String(value) => println!("{key}: {value}"), - Value::Integer(value) => println!("{key}: {value}"), - Value::BulkString(value) => println!("{key}: {value}"), - Value::Array(items) => { - if items.is_empty() { - println!("{key}: "); - continue; - } - println!("{key}:"); - for item in items { - match resp::decode(&item).map_err(|e| Error::Resp(e))? { - Value::Null => println!("- "), - Value::String(item) => println!("- {item}"), - Value::Integer(item) => println!("- {item}"), - Value::BulkString(item) => println!("- {item}"), - item => { - println!("- {}", indent(&item.to_string(), " ")) - } - } - } - } - value => println!("{key}:\n {}", indent(&value.to_string(), " ")), - } - } - } - Commands::HEXISTS { key, field } => match connection - .hexists(key.to_string(), field.to_string()) - .await? - { - true => println!("true"), - false => println!("false"), - }, - Commands::HGET { key, field } => { - match connection.hget(key.to_string(), field.to_string()).await? { - Some(value) => println!("{}", value), - None => println!(""), - } - } - Commands::HGETALL { key } => { - let fields = connection.hgetall(key.to_string()).await?; - if fields.len() == 0 { - println!(""); - } - for (key, value) in fields { - println!("- {key}: {value}"); - } - } - Commands::HINCRBY { - key, - field, - increment, - } => { - let value = connection - .hincrby(key.to_string(), field.to_string(), *increment) - .await?; - println!("{}", value); - } - Commands::HINCRBYFLOAT { - key, - field, - increment, - } => { - let value = connection - .hincrbyfloat(key.to_string(), field.to_string(), *increment) - .await?; - println!("{}", value); - } - Commands::HKEYS { key } => { - for key in connection.hkeys(key.to_string()).await? { - println!("- {key}"); - } - } - Commands::HLEN { key } => { - let len = connection.hlen(key.to_string()).await?; - println!("{len}"); - } - Commands::HMGET { key, fields } => { - let fields = connection - .hmget(key.to_string().to_string(), fields.to_vec()) - .await?; - for field in fields { - match field { - None => println!("- "), - Some(value) => println!("- {value}"), - } - } - } - Commands::HMSET { key, fields } => { - if fields.len() % 2 != 0 { - Err(Error::Client( - "An equal number of FIELDs and VALUEs are required".to_string(), - ))?; - } - let fields: Vec<(String, String)> = fields - .chunks(2) - .map(|c| (c[0].clone(), c[1].clone())) - .collect(); - connection.hmset(key.to_string(), fields.clone()).await?; - println!("Set {} field(s)", fields.len()); - } - Commands::HRANDFIELD { - key, - count, - with_values, - } => { - let opts = HrandfieldOpts { - count: *count, - with_values: *with_values, - }; - let fields = connection.hrandfield(key.to_string(), Some(opts)).await?; - match fields { - None => println!(""), - Some(fields) => { - for (field, value) in fields { - match value { - None => println!("- {field}"), - Some(value) => println!("- {field}: {value}"), - } - } - } - } - } - Commands::HSCAN { - key, - cursor, - match_, - count, - no_values, - } => { - let opts = &HscanOpts { - match_: match_.clone(), - count: *count, - no_values: *no_values, - }; - let (cursor, fields) = connection - .hscan(key.to_string(), cursor.clone(), Some(opts.clone())) - .await?; - if let Some(cursor) = cursor { - println!("(cursor) {cursor}"); - } - if fields.len() == 0 { - println!(""); - } - for (field, value) in fields { - match value { - None => println!("- {field}"), - Some(value) => println!("- {field}: {value}"), - } - } - } - Commands::HSET { key, field, value } => { - connection - .hset(key.to_string(), field.to_string(), value.to_string()) - .await?; - println!("Set {field}"); - } - Commands::HSETNX { key, field, value } => { - match connection - .hsetnx(key.to_string(), field.to_string(), value.to_string()) - .await? - { - false => println!("Field already set"), - true => println!("Set {field}"), - }; - } - Commands::HSTRLEN { key, field } => { - let len = connection - .hstrlen(key.to_string(), field.to_string()) - .await?; - println!("{len}"); - } - Commands::HVALS { key } => { - let values = connection.hvals(key.to_string()).await?; - if values.len() == 0 { - println!(""); - } - for value in values { - println!("- {value}"); - } - } - Commands::INCR { key } => { - let value = connection.incr(key.to_string()).await?; - println!("{}", value); - } - Commands::INCRBY { key, increment } => { - let value = connection - .incrby(key.to_string(), increment.clone()) - .await?; - println!("{}", value); - } - Commands::KEYS { pattern } => { - for key in connection.keys(pattern.to_string()).await? { - println!("{key}"); - } - } - Commands::PUBLISH { channel, message } => { - let value = connection - .publish(channel.to_string(), message.to_string()) - .await?; - println!("{}", value); - } - Commands::SET { key, value } => { - connection.set(key.to_string(), value.to_string()).await?; - println!("Set {key}"); - } - } - - Ok(()) -} - -/// Indents each line after the first, for multi-line values printed after a prefix. -fn indent(value: &str, padding: &str) -> String { - value.replace('\n', &format!("\n{padding}")) -} - -impl fmt::Display for Value { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Value::String(val) => write!(f, "{}", val), - Value::Error(val) => write!(f, "(error) {val}"), - Value::Integer(val) => write!(f, "(integer) {}", val), - Value::BulkString(val) => write!(f, "\"{}\"", val), - Value::Array(val) => { - if val.len() == 0 { - write!(f, "(empty array)")?; - } - let i_max_width = val.len().to_string().len(); - for (i, val) in val.iter().enumerate() { - let val = resp::decode(val).map_err(|_| fmt::Error)?.to_string(); - let prefix = format!("{:i_max_width$}) ", i + 1); - let mut padding = String::new(); - for _ in 0..prefix.len() { - padding.push(' ') - } - if i != 0 { - write!(f, "\n")?; - } - for (j, line) in val.lines().enumerate() { - match j { - 0 => write!(f, "{prefix}{line}")?, - _ => write!(f, "\n{padding}{line}")?, - } - } - } - Ok(()) - } - Value::Null => write!(f, "(nil)"), - Value::Boolean(val) => match val { - false => write!(f, "(false)"), - true => write!(f, "(true)"), - }, - Value::Double(val) => write!(f, "(double) {val}"), - Value::BigNumber(val) => write!(f, "(number) {val}"), - Value::BulkError(val) => write!(f, "(error) {val}"), - Value::VerbatimString((_encoding, _val)) => { - // TODO - write!(f, "{self:?}") - } - Value::Map(val) => { - if val.len() == 0 { - write!(f, "(empty map)")?; - } - let i_max_width = val.len().to_string().len(); - for (i, (key, val)) in val.iter().enumerate() { - let key = resp::decode(key).map_err(|_| fmt::Error)?.to_string(); - let val = resp::decode(val).map_err(|_| fmt::Error)?.to_string(); - // TODO handle multi-line keys - let prefix = format!("{:i_max_width$}# {key} => ", i + 1); - let mut padding = String::new(); - for _ in 0..prefix.len() { - padding.push(' ') - } - if i != 0 { - write!(f, "\n")?; - } - for (j, line) in val.lines().enumerate() { - match j { - 0 => write!(f, "{prefix}{line}")?, - _ => write!(f, "\n{padding}{line}")?, - } - } - } - Ok(()) - } - Value::Set(val) => { - if val.len() == 0 { - write!(f, "(empty set)")?; - } - let i_max_width = val.len().to_string().len(); - for (i, val) in val.iter().enumerate() { - let val = resp::decode(val).map_err(|_| fmt::Error)?.to_string(); - let prefix = format!("{:i_max_width$}~ ", i + 1); - let mut padding = String::new(); - for _ in 0..prefix.len() { - padding.push(' ') - } - if i != 0 { - write!(f, "\n")?; - } - for (j, line) in val.lines().enumerate() { - match j { - 0 => write!(f, "{prefix}{line}")?, - _ => write!(f, "\n{padding}{line}")?, - } - } - } - Ok(()) - } - Value::Push(val) => { - if val.len() == 0 { - write!(f, "(empty push)")?; - } - let i_max_width = val.len().to_string().len(); - for (i, val) in val.iter().enumerate() { - let val = resp::decode(val).map_err(|_| fmt::Error)?.to_string(); - let prefix = format!("{:i_max_width$}) ", i + 1); - let mut padding = String::new(); - for _ in 0..prefix.len() { - padding.push(' ') - } - if i != 0 { - write!(f, "\n")?; - } - for (j, line) in val.lines().enumerate() { - match j { - 0 => write!(f, "{prefix}{line}")?, - _ => write!(f, "\n{padding}{line}")?, - } - } - } - Ok(()) - } - } - } -} - -wit_bindgen::generate!({ - world: "cli", - path: "../wit", - generate_all -}); - -export!(Cli); diff --git a/components/dep-componentized-logging-to-stderr/README.md b/components/dep-componentized-logging-to-stderr/README.md new file mode 100644 index 0000000..e8400a4 --- /dev/null +++ b/components/dep-componentized-logging-to-stderr/README.md @@ -0,0 +1,3 @@ +# dep-componentized-logging-to-stderr + +From [componentized logging](https://github.com/componentized/logging): [`to-stderr`](https://github.com/componentized/logging/tree/main/components/to-stderr), wasi:logging written to stderr, pulled from `ghcr.io/componentized/logging/to-stderr:0.3.1`. diff --git a/components/dep-componentized-logging-to-stderr/dep-componentized-logging-to-stderr.wkg b/components/dep-componentized-logging-to-stderr/dep-componentized-logging-to-stderr.wkg new file mode 100644 index 0000000..99d9410 --- /dev/null +++ b/components/dep-componentized-logging-to-stderr/dep-componentized-logging-to-stderr.wkg @@ -0,0 +1,2 @@ +ghcr.io/componentized/logging/to-stderr:0.3.1@sha256:f5bb0d492dc653fd90f78db78c6c6dcc4cf5f35115446bbe001c1ab34e09a1d8 +ghcr.io/componentized/logging/to-stderr:0.3.1_debug@sha256:bb0e846507776b8b9c1646c37ea4cd83c2f1dc4b01c670ed5ec51e1a7a059954 diff --git a/components/dep-componentized-sockets-gate/README.md b/components/dep-componentized-sockets-gate/README.md new file mode 100644 index 0000000..b459da7 --- /dev/null +++ b/components/dep-componentized-sockets-gate/README.md @@ -0,0 +1,3 @@ +# dep-componentized-sockets-gate + +From [componentized sockets](https://github.com/componentized/sockets): [`gate`](https://github.com/componentized/sockets/tree/main/components/gate), gates wasi:sockets tcp and udp sockets, and ip-name-lookup, consulting a latch before each operation, pulled from `ghcr.io/componentized/sockets/gate:0.1.0`. diff --git a/components/dep-componentized-sockets-gate/dep-componentized-sockets-gate.wkg b/components/dep-componentized-sockets-gate/dep-componentized-sockets-gate.wkg new file mode 100644 index 0000000..99de749 --- /dev/null +++ b/components/dep-componentized-sockets-gate/dep-componentized-sockets-gate.wkg @@ -0,0 +1,2 @@ +ghcr.io/componentized/sockets/gate:0.1.0@sha256:91be0efd2190305e0e0ff44bd824c2426986b510ae78fcecc0bf60fe23660237 +ghcr.io/componentized/sockets/gate:0.1.0_debug@sha256:205770707c75c041746d237626a6acedd686181a49a724b66b0b64a9c841e71b diff --git a/components/dep-componentized-sockets-latch-cidr-egress/README.md b/components/dep-componentized-sockets-latch-cidr-egress/README.md new file mode 100644 index 0000000..7abbdd0 --- /dev/null +++ b/components/dep-componentized-sockets-latch-cidr-egress/README.md @@ -0,0 +1,3 @@ +# dep-componentized-sockets-latch-cidr-egress + +From [componentized sockets](https://github.com/componentized/sockets): [`latch-cidr-egress`](https://github.com/componentized/sockets/tree/main/components/latch-cidr-egress), grants or denies outbound traffic by remote address and port ranges, configured with wasi:config, pulled from `ghcr.io/componentized/sockets/latch-cidr-egress:0.1.0`. diff --git a/components/dep-componentized-sockets-latch-cidr-egress/dep-componentized-sockets-latch-cidr-egress.wkg b/components/dep-componentized-sockets-latch-cidr-egress/dep-componentized-sockets-latch-cidr-egress.wkg new file mode 100644 index 0000000..6518ba6 --- /dev/null +++ b/components/dep-componentized-sockets-latch-cidr-egress/dep-componentized-sockets-latch-cidr-egress.wkg @@ -0,0 +1,2 @@ +ghcr.io/componentized/sockets/latch-cidr-egress:0.1.0@sha256:82e111dd0c7a75e7344e4d3d89778da5adf19d4b9c2ca15265c6c2254a2aeec5 +ghcr.io/componentized/sockets/latch-cidr-egress:0.1.0_debug@sha256:4330630236965724141f7fabe34ead6b0039abf87f6f3c31d41e651f0c3e6ad3 diff --git a/components/dep-componentized-sockets-latch-deny-bind/README.md b/components/dep-componentized-sockets-latch-deny-bind/README.md new file mode 100644 index 0000000..70ead51 --- /dev/null +++ b/components/dep-componentized-sockets-latch-deny-bind/README.md @@ -0,0 +1,3 @@ +# dep-componentized-sockets-latch-deny-bind + +From [componentized sockets](https://github.com/componentized/sockets): [`latch-deny-bind`](https://github.com/componentized/sockets/tree/main/components/latch-deny-bind), denies explicitly binding sockets to a local address, pulled from `ghcr.io/componentized/sockets/latch-deny-bind:0.1.0`. diff --git a/components/dep-componentized-sockets-latch-deny-bind/dep-componentized-sockets-latch-deny-bind.wkg b/components/dep-componentized-sockets-latch-deny-bind/dep-componentized-sockets-latch-deny-bind.wkg new file mode 100644 index 0000000..5c7495a --- /dev/null +++ b/components/dep-componentized-sockets-latch-deny-bind/dep-componentized-sockets-latch-deny-bind.wkg @@ -0,0 +1,2 @@ +ghcr.io/componentized/sockets/latch-deny-bind:0.1.0@sha256:51a11baab568e01ff1319971b7d1ec7a23f89c1d79b77fb9024ee34da7f5e578 +ghcr.io/componentized/sockets/latch-deny-bind:0.1.0_debug@sha256:b6851cf0ab2b5ca5d3cbc5b43b6eab5bef3b9d64d920035e3fa25daf62994611 diff --git a/components/dep-componentized-sockets-latch-deny-connect-unless-lookup-address/README.md b/components/dep-componentized-sockets-latch-deny-connect-unless-lookup-address/README.md new file mode 100644 index 0000000..1cbd0cd --- /dev/null +++ b/components/dep-componentized-sockets-latch-deny-connect-unless-lookup-address/README.md @@ -0,0 +1,3 @@ +# dep-componentized-sockets-latch-deny-connect-unless-lookup-address + +From [componentized sockets](https://github.com/componentized/sockets): [`latch-deny-connect-unless-lookup-address`](https://github.com/componentized/sockets/tree/main/components/latch-deny-connect-unless-lookup-address), denies connecting to addresses an allowed ip-name lookup did not return, pulled from `ghcr.io/componentized/sockets/latch-deny-connect-unless-lookup-address:0.1.0`. diff --git a/components/dep-componentized-sockets-latch-deny-connect-unless-lookup-address/dep-componentized-sockets-latch-deny-connect-unless-lookup-address.wkg b/components/dep-componentized-sockets-latch-deny-connect-unless-lookup-address/dep-componentized-sockets-latch-deny-connect-unless-lookup-address.wkg new file mode 100644 index 0000000..83f5849 --- /dev/null +++ b/components/dep-componentized-sockets-latch-deny-connect-unless-lookup-address/dep-componentized-sockets-latch-deny-connect-unless-lookup-address.wkg @@ -0,0 +1,2 @@ +ghcr.io/componentized/sockets/latch-deny-connect-unless-lookup-address:0.1.0@sha256:cb2ac10d0dfe34e5665f4ac86fa64ebb66d494e867dc4f3cc85d6a6f90a72bb7 +ghcr.io/componentized/sockets/latch-deny-connect-unless-lookup-address:0.1.0_debug@sha256:a259ec4f884c967f1a65198b9a8cdb61d8c4c3482c8ff6cf274e1ff835ef5238 diff --git a/components/dep-componentized-sockets-latch-deny-udp/README.md b/components/dep-componentized-sockets-latch-deny-udp/README.md new file mode 100644 index 0000000..72ce368 --- /dev/null +++ b/components/dep-componentized-sockets-latch-deny-udp/README.md @@ -0,0 +1,3 @@ +# dep-componentized-sockets-latch-deny-udp + +From [componentized sockets](https://github.com/componentized/sockets): [`latch-deny-udp`](https://github.com/componentized/sockets/tree/main/components/latch-deny-udp), denies all udp operations, pulled from `ghcr.io/componentized/sockets/latch-deny-udp:0.1.0`. diff --git a/components/dep-componentized-sockets-latch-deny-udp/dep-componentized-sockets-latch-deny-udp.wkg b/components/dep-componentized-sockets-latch-deny-udp/dep-componentized-sockets-latch-deny-udp.wkg new file mode 100644 index 0000000..fca2993 --- /dev/null +++ b/components/dep-componentized-sockets-latch-deny-udp/dep-componentized-sockets-latch-deny-udp.wkg @@ -0,0 +1,2 @@ +ghcr.io/componentized/sockets/latch-deny-udp:0.1.0@sha256:9e80506dc0ab0f4f4bfa90b2b64c531c265941b40806de7a15fd99e582ccab0f +ghcr.io/componentized/sockets/latch-deny-udp:0.1.0_debug@sha256:1e3145f51ee9545de1aad8ea9e9cdc68d881ba5f5323dfb10605281a572e172a diff --git a/components/dep-componentized-sockets-latch-ip-name-lookup-glob/README.md b/components/dep-componentized-sockets-latch-ip-name-lookup-glob/README.md new file mode 100644 index 0000000..fe157b1 --- /dev/null +++ b/components/dep-componentized-sockets-latch-ip-name-lookup-glob/README.md @@ -0,0 +1,3 @@ +# dep-componentized-sockets-latch-ip-name-lookup-glob + +From [componentized sockets](https://github.com/componentized/sockets): [`latch-ip-name-lookup-glob`](https://github.com/componentized/sockets/tree/main/components/latch-ip-name-lookup-glob), grants or denies ip-name lookups by host name glob patterns, configured with wasi:config, pulled from `ghcr.io/componentized/sockets/latch-ip-name-lookup-glob:0.1.0`. diff --git a/components/dep-componentized-sockets-latch-ip-name-lookup-glob/dep-componentized-sockets-latch-ip-name-lookup-glob.wkg b/components/dep-componentized-sockets-latch-ip-name-lookup-glob/dep-componentized-sockets-latch-ip-name-lookup-glob.wkg new file mode 100644 index 0000000..2666786 --- /dev/null +++ b/components/dep-componentized-sockets-latch-ip-name-lookup-glob/dep-componentized-sockets-latch-ip-name-lookup-glob.wkg @@ -0,0 +1,2 @@ +ghcr.io/componentized/sockets/latch-ip-name-lookup-glob:0.1.0@sha256:88eb4ad40292aabbf39984930739d6204b0e262d20503d783856d303ea501a55 +ghcr.io/componentized/sockets/latch-ip-name-lookup-glob:0.1.0_debug@sha256:8cfc4356143038bcd6e9fea77b10d65b886ef7072c0b3f9c8fbaf09d336de2e9 diff --git a/components/dep-componentized-sockets-latch-n5/README.md b/components/dep-componentized-sockets-latch-n5/README.md new file mode 100644 index 0000000..af43b06 --- /dev/null +++ b/components/dep-componentized-sockets-latch-n5/README.md @@ -0,0 +1,3 @@ +# dep-componentized-sockets-latch-n5 + +From [componentized sockets](https://github.com/componentized/sockets): [`latch-n5`](https://github.com/componentized/sockets/tree/main/components/latch-n5), aggregates five latches, any of them can deny an operation, pulled from `ghcr.io/componentized/sockets/latch-n5:0.1.0`. diff --git a/components/dep-componentized-sockets-latch-n5/dep-componentized-sockets-latch-n5.wkg b/components/dep-componentized-sockets-latch-n5/dep-componentized-sockets-latch-n5.wkg new file mode 100644 index 0000000..810d08c --- /dev/null +++ b/components/dep-componentized-sockets-latch-n5/dep-componentized-sockets-latch-n5.wkg @@ -0,0 +1,2 @@ +ghcr.io/componentized/sockets/latch-n5:0.1.0@sha256:b4b14d3a82b477c46f9642237831f21a5f53ee41727f7228d7c1ce94e68c1e58 +ghcr.io/componentized/sockets/latch-n5:0.1.0_debug@sha256:1bf2947ef40c18f578b49aac2afb100a870c585a77d5d4fc679e78ffbc63e93d diff --git a/components/internal-cli/Cargo.toml b/components/internal-cli/Cargo.toml new file mode 100644 index 0000000..70f7960 --- /dev/null +++ b/components/internal-cli/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "internal-cli" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +futures-channel = { workspace = true } +futures-core = { workspace = true } +componentized-valkey = { workspace = true } +wit-bindgen = { workspace = true } diff --git a/components/internal-cli/README.md b/components/internal-cli/README.md new file mode 100644 index 0000000..db20bb0 --- /dev/null +++ b/components/internal-cli/README.md @@ -0,0 +1,9 @@ +# internal-cli + +The Valkey CLI before the client components are composed in, see [`cli`](../cli/). + +Sends its arguments as a command over a connection from a `connector` and prints the reply. A command without a reply, like `SUBSCRIBE`, prints the push messages that arrive until interrupted or the connection closes. + +`UNSUBSCRIBE`, `PUNSUBSCRIBE` and `SUNSUBSCRIBE` print their confirmations and exit. `MONITOR` prints its feed until interrupted or the connection closes. + +Without arguments, each line of stdin is sent as a command over one connection, kept open until stdin closes, printing replies and push messages as they arrive. Words are split on whitespace, quotes keep a word with whitespace together, e.g. `set greeting "hello world"`. diff --git a/components/internal-cli/src/lib.rs b/components/internal-cli/src/lib.rs new file mode 100644 index 0000000..ec6ff06 --- /dev/null +++ b/components/internal-cli/src/lib.rs @@ -0,0 +1,667 @@ +//! A cli sending commands to Valkey, printing the replies. +//! +//! With arguments, they are sent as one command. A command without a reply, e.g. SUBSCRIBE, prints +//! the push messages that arrive until the cli is interrupted or the connection closes. +//! +//! Without arguments, each line of stdin is sent as a command over one connection, kept open until +//! stdin closes. Push messages are printed as they arrive. + +use std::future::{Future, poll_fn}; +use std::pin::{Pin, pin}; +use std::task::Poll; + +use componentized::valkey::connection::{Connection, ErrorCode}; +use componentized::valkey::connector; +use componentized_valkey::types::{RespNestedValue, RespValue, StringOrBytes}; +use exports::wasi::cli::run::Guest; +use futures_channel::mpsc; +use futures_core::Stream; +use wasi::cli::{environment, stderr, stdin, stdout, types::ErrorCode as CliErrorCode}; +use wit_bindgen::{FutureReader, StreamReader, StreamResult, StreamWriter}; + +const USAGE: &str = "\ +Usage: cli [COMMAND [ARG]...] + +Sends the command to Valkey and prints the reply. A command without a reply, e.g. SUBSCRIBE, +prints the push messages that arrive until interrupted. + +Without a command, each line of stdin is sent as a command, until stdin closes. Words are split on +whitespace, quote a word to include whitespace, e.g. \"hello world\", in double quotes escapes like +\\n and \\x41 are expanded. + +The server is configured with wasi:config, see the tcp-connector component."; + +/// Commands without a reply, the server confirms each channel with a push message. +const SUBSCRIBE: [&str; 3] = ["SUBSCRIBE", "PSUBSCRIBE", "SSUBSCRIBE"]; +const UNSUBSCRIBE: [&str; 3] = ["UNSUBSCRIBE", "PUNSUBSCRIBE", "SUNSUBSCRIBE"]; + +struct Cli; + +impl Guest for Cli { + async fn run() -> Result<(), ()> { + let command: Vec = environment::get_arguments() + .into_iter() + .skip(1) + .map(StringOrBytes::String) + .collect(); + if let [StringOrBytes::String(flag)] = command.as_slice() + && (flag == "-h" || flag == "--help") + { + print(stdout::write_via_stream, USAGE).await; + return Ok(()); + } + + let mut stdout = Output::new(stdout::write_via_stream); + let result = if command.is_empty() { + session(&mut stdout).await + } else { + exec(command, &mut stdout).await + }; + stdout.close().await; + if let Err(error) = result { + print(stderr::write_via_stream, &render_error(error)).await; + return Err(()); + } + Ok(()) + } +} + +/// Sends the command, printing its reply, or the push messages a command without one causes. +async fn exec(command: Vec, out: &mut Output) -> Result<(), ErrorCode> { + let connection = connector::connect().await?; + // before the command is sent, so none of the push messages it causes are missed + let mut pushes = connection.dead_letters(); + let name = name(&command); + let channels = command.len() - 1; + + if SUBSCRIBE.contains(&name.as_str()) { + connection.emit(command).await?; + print_pushes(&mut pushes, out, None).await + } else if UNSUBSCRIBE.contains(&name.as_str()) { + // a push message for each channel, or one when no channel is given, as a new connection is + // not subscribed to any + connection.emit(command).await?; + print_pushes(&mut pushes, out, Some(channels.max(1))).await + } else if name == "RESET" && channels == 0 { + // RESET is the connection's to send, it then repeats its handshake + connection.reset().await?; + out.line("RESET").await; + Ok(()) + } else if name == "MONITOR" { + // the feed follows the reply, values sent unprompted that the connection treats as push + // messages + let reply = connection.exchange(command).await?; + out.line(&render(reply)).await; + print_pushes(&mut pushes, out, None).await + } else { + let reply = connection.exchange(command).await?; + out.line(&render(reply)).await; + Ok(()) + } +} + +/// Prints push messages, up to `limit` of them, failing once they stop. +async fn print_pushes( + pushes: &mut StreamReader, + out: &mut Output, + limit: Option, +) -> Result<(), ErrorCode> { + for _ in 0..limit.unwrap_or(usize::MAX) { + match pushes.next().await { + // sent unprompted, e.g. for a SUBSCRIBE missing its channels + Some(RespValue::Error(message)) => { + return Err(ErrorCode::Server(StringOrBytes::String(message))); + } + Some(RespValue::BulkError(message)) => { + return Err(ErrorCode::Server(StringOrBytes::from_bytes(message))); + } + Some(push) => out.line(&render(push)).await, + None => { + return Err(ErrorCode::Client( + "push messages stopped, the connection closed or they were not read fast enough" + .to_string(), + )); + } + } + } + Ok(()) +} + +/// Sends each line of stdin as a command, printing the replies, and push messages as they arrive, +/// until stdin closes. +async fn session(out: &mut Output) -> Result<(), ErrorCode> { + let connection = connector::connect().await?; + let pushes = connection.dead_letters(); + let (stdin, _stdin_result) = stdin::read_via_stream(); + + // replies and push messages arrive independently, both are printed by one writer + let (lines_tx, lines_rx) = mpsc::unbounded(); + let mut commands = Some(Box::pin(send_lines(&connection, stdin, lines_tx.clone()))); + let mut forwarding = Some(Box::pin(forward_pushes(pushes, lines_tx))); + let mut writing = pin!(write_lines(lines_rx, out)); + let mut result = Ok(()); + poll_fn(|cx| { + let sent = match &mut commands { + Some(commands) => commands.as_mut().poll(cx), + None => Poll::Pending, + }; + if let Poll::Ready(sent) = sent { + result = sent; + // dropping the senders lets the writer finish + commands = None; + forwarding = None; + } + if let Some(pushes) = &mut forwarding + && pushes.as_mut().poll(cx).is_ready() + { + forwarding = None; + } + writing.as_mut().poll(cx) + }) + .await; + result +} + +async fn send_lines( + connection: &Connection, + stdin: StreamReader, + lines: mpsc::UnboundedSender, +) -> Result<(), ErrorCode> { + let mut input = Lines::new(stdin); + while let Some(line) = input.next().await { + let words: Vec = match split(&line) { + Ok(words) => words.into_iter().map(StringOrBytes::Bytes).collect(), + Err(message) => { + let _ = lines.unbounded_send(format!("(error) {message}")); + continue; + } + }; + if words.is_empty() { + continue; + } + let name = name(&words); + if SUBSCRIBE.contains(&name.as_str()) || UNSUBSCRIBE.contains(&name.as_str()) { + // no reply, the push messages confirming it are printed as they arrive + connection.emit(words).await?; + continue; + } + let reply = if name == "RESET" && words.len() == 1 { + // RESET is the connection's to send, it then repeats its handshake + connection + .reset() + .await + .map(|()| RespValue::String("RESET".to_string())) + } else { + connection.exchange(words).await + }; + let text = match reply { + Ok(reply) => render(reply), + // the command failed, not the session + Err(ErrorCode::Server(message)) => format!("(error) {message}"), + Err(ErrorCode::Client(message)) => format!("(error) {message}"), + Err(error) => return Err(error), + }; + let _ = lines.unbounded_send(text); + } + Ok(()) +} + +async fn forward_pushes(mut pushes: StreamReader, lines: mpsc::UnboundedSender) { + while let Some(push) = pushes.next().await { + let _ = lines.unbounded_send(render(push)); + } +} + +/// Prints lines until every sender is gone. +async fn write_lines(mut lines: mpsc::UnboundedReceiver, out: &mut Output) { + while let Some(line) = poll_fn(|cx| Pin::new(&mut lines).poll_next(cx)).await { + out.line(&line).await; + } +} + +/// The lines of a stream of bytes. +struct Lines { + stream: StreamReader, + buffer: Vec, + closed: bool, +} + +impl Lines { + fn new(stream: StreamReader) -> Self { + Lines { + stream, + buffer: Vec::new(), + closed: false, + } + } + + /// The next line, without its line ending, or none once the stream closed. + async fn next(&mut self) -> Option { + loop { + if let Some(end) = self.buffer.iter().position(|byte| *byte == b'\n') { + let line: Vec = self.buffer.drain(..=end).collect(); + let line = String::from_utf8_lossy(&line); + return Some(line.trim_end_matches(['\n', '\r']).to_string()); + } + if self.closed { + // the last line, without a line ending + let line = std::mem::take(&mut self.buffer); + return (!line.is_empty()).then(|| String::from_utf8_lossy(&line).into_owned()); + } + let mut buffer = std::mem::take(&mut self.buffer); + buffer.reserve(4096); + let (result, buffer) = self.stream.read(buffer).await; + self.buffer = buffer; + if !matches!(result, StreamResult::Complete(_)) { + self.closed = true; + } + } + } +} + +/// Splits a line into words, like valkey-cli. +/// +/// Words are separated by whitespace. A double quoted word may contain whitespace and escapes, e.g. +/// `\"`, `\n` and `\x41`, a single quoted word is taken as is, except for `\'`. A `\x` escape is a +/// byte, so a word may be binary, e.g. `"\x80\xff"`. +fn split(line: &str) -> Result>, String> { + let mut words = vec![]; + let mut chars = line.chars().peekable(); + loop { + while chars.next_if(|c| c.is_whitespace()).is_some() {} + let Some(&first) = chars.peek() else { + return Ok(words); + }; + let mut word = vec![]; + if first == '"' || first == '\'' { + chars.next(); + loop { + match (chars.next(), first) { + (None, _) => return Err("unbalanced quotes".to_string()), + (Some(c), quote) if c == quote => break, + (Some('\\'), '"') => unescape(&mut chars, &mut word)?, + (Some('\\'), '\'') if chars.peek() == Some(&'\'') => { + push(&mut word, chars.next().unwrap_or_default()) + } + (Some(c), _) => push(&mut word, c), + } + } + if chars.peek().is_some_and(|c| !c.is_whitespace()) { + return Err("closing quote must be followed by a space".to_string()); + } + } else { + while let Some(c) = chars.next_if(|c| !c.is_whitespace()) { + push(&mut word, c); + } + } + words.push(word); + } +} + +/// Appends the character, as UTF-8. +fn push(word: &mut Vec, c: char) { + word.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()); +} + +/// Appends the escape in a double quoted word, after its `\`. +fn unescape(chars: &mut impl Iterator, word: &mut Vec) -> Result<(), String> { + match chars.next() { + Some('n') => word.push(b'\n'), + Some('r') => word.push(b'\r'), + Some('t') => word.push(b'\t'), + Some('b') => word.push(0x08), + Some('a') => word.push(0x07), + Some('x') => { + let hex: String = chars.take(2).collect(); + match u8::from_str_radix(&hex, 16) { + Ok(byte) if hex.len() == 2 => word.push(byte), + _ => return Err(format!("unsupported escape: \\x{hex}")), + } + } + Some(c) => push(word, c), + None => return Err("unbalanced quotes".to_string()), + } + Ok(()) +} + +/// The name of a command, to tell those that need handling apart, e.g. `SUBSCRIBE`. +fn name(command: &[StringOrBytes]) -> String { + match command.first() { + Some(StringOrBytes::String(name)) => name.to_ascii_uppercase(), + Some(StringOrBytes::Bytes(name)) => String::from_utf8_lossy(name).to_ascii_uppercase(), + None => String::new(), + } +} + +/// A stream to stdout or stderr. +struct Output { + writer: StreamWriter, + written: FutureReader>, +} + +impl Output { + fn new( + write_via_stream: fn(StreamReader) -> FutureReader>, + ) -> Self { + let (writer, reader) = wit_stream::new(); + let written = write_via_stream(reader); + Output { writer, written } + } + + async fn line(&mut self, text: &str) { + self.writer + .write_all(format!("{text}\n").into_bytes()) + .await; + } + + async fn close(self) { + // the host finishes writing once the writable end is dropped + drop(self.writer); + // nowhere left to report a failure to print + let _ = self.written.await; + } +} + +/// Prints a line to a new stream to stdout or stderr. +async fn print( + write_via_stream: fn(StreamReader) -> FutureReader>, + text: &str, +) { + let mut output = Output::new(write_via_stream); + output.line(text).await; + output.close().await; +} + +fn render_error(error: ErrorCode) -> String { + match error { + ErrorCode::Server(message) => format!("(error) {message}"), + ErrorCode::Closed(None) => "connection closed".to_string(), + ErrorCode::Closed(Some(reason)) => format!("connection closed: {reason}"), + ErrorCode::Client(message) => message, + ErrorCode::Resp(message) => format!("invalid RESP: {message}"), + ErrorCode::Lagged => "fell behind, values were lost".to_string(), + } +} + +/// Renders a value for people to read, in the style of valkey-cli. +fn render(value: RespValue) -> String { + match value { + RespValue::String(text) => text, + RespValue::Error(message) => format!("(error) {message}"), + RespValue::BulkError(message) => format!("(error) {}", StringOrBytes::from_bytes(message)), + RespValue::Integer(value) => format!("(integer) {value}"), + // quoted, bytes outside printable ASCII escaped as valkey-cli does, binary or not + RespValue::BulkString(bytes) => format!("\"{}\"", bytes.escape_ascii()), + RespValue::Null => "(nil)".to_string(), + RespValue::Boolean(value) => format!("({value})"), + RespValue::Double(value) => format!("(double) {value}"), + RespValue::BigNumber(value) => format!("(big number) {value}"), + // text to show as is, e.g. from INFO, the format is `txt` or `mkd` + RespValue::VerbatimString((_format, text)) => text, + RespValue::Array(items) | RespValue::Push(items) => { + numbered(items.into_iter().map(render_nested).collect(), ')', "array") + } + RespValue::Set(items) => { + numbered(items.into_iter().map(render_nested).collect(), '~', "set") + } + RespValue::Map(entries) => numbered( + entries + .into_iter() + .map(|(key, value)| { + indent( + &format!("{} => ", render_nested(key)), + &render_nested(value), + ) + }) + .collect(), + '#', + "map", + ), + } +} + +fn render_nested(value: RespNestedValue) -> String { + match componentized_valkey::resp::decode(&value) { + Ok(value) => render(value), + Err(error) => format!("(invalid) {error}"), + } +} + +/// Numbers each item, e.g. `1) `, the numbers right aligned. +fn numbered(items: Vec, marker: char, kind: &str) -> String { + if items.is_empty() { + return format!("(empty {kind})"); + } + let width = items.len().to_string().len(); + items + .iter() + .enumerate() + .map(|(i, item)| indent(&format!("{:>width$}{marker} ", i + 1), item)) + .collect::>() + .join("\n") +} + +/// Prefixes the text, lining its other lines up under the first. +fn indent(prefix: &str, text: &str) -> String { + let last_line = prefix.rsplit('\n').next().unwrap_or_default(); + let padding = format!("\n{}", " ".repeat(last_line.chars().count())); + format!("{prefix}{}", text.replace('\n', &padding)) +} + +wit_bindgen::generate!({ + // `path` is relative to the manifest, `components/internal-cli/` + path: "../wit", + world: "cli", + // the resp types shared by every component + with: { + "componentized:valkey/types@0.3.0-dev": componentized_valkey::types, + }, + generate_all, +}); + +export!(Cli); + +#[cfg(test)] +mod tests { + use super::*; + + fn nested(value: RespValue) -> RespNestedValue { + componentized_valkey::resp::encode(value) + } + + fn bulk(text: &str) -> RespValue { + RespValue::BulkString(text.as_bytes().to_vec()) + } + + /// The words a line splits into, as bytes. + fn split_words(words: &[&str]) -> Result>, String> { + Ok(words.iter().map(|word| word.as_bytes().to_vec()).collect()) + } + + #[test] + fn split_on_whitespace() { + assert_eq!( + split(" SET key\tvalue "), + split_words(&["SET", "key", "value"]) + ); + assert_eq!(split(""), Ok(vec![])); + assert_eq!(split(" "), Ok(vec![])); + } + + #[test] + fn split_double_quotes() { + assert_eq!( + split(r#"SET key "hello world" "a\"b" "\n\r\t\b\a" "\x41\x7a" "\q" "€""#), + split_words(&[ + "SET", + "key", + "hello world", + "a\"b", + "\n\r\t\u{8}\u{7}", + "Az", + "q", + "€", + ]) + ); + assert_eq!(split(r#""""#), Ok(vec![vec![]])); + } + + #[test] + fn split_binary_escapes() { + // a `\x` escape is a byte, UTF-8 or not + assert_eq!( + split(r#"SET key "\x80\x00\xff""#), + Ok(vec![ + b"SET".to_vec(), + b"key".to_vec(), + vec![0x80, 0x00, 0xff] + ]) + ); + } + + #[test] + fn split_single_quotes() { + assert_eq!( + split(r#"'hello world' 'it\'s' 'a\nb'"#), + split_words(&["hello world", "it's", "a\\nb"]) + ); + } + + #[test] + fn split_errors() { + assert_eq!(split(r#"SET "key"#), Err("unbalanced quotes".to_string())); + assert_eq!(split("'key"), Err("unbalanced quotes".to_string())); + assert_eq!(split(r#""key\"#), Err("unbalanced quotes".to_string())); + assert_eq!( + split(r#""key"value"#), + Err("closing quote must be followed by a space".to_string()) + ); + assert_eq!( + split(r#""\xzz""#), + Err("unsupported escape: \\xzz".to_string()) + ); + assert_eq!( + split(r#""\x4""#), + Err("unsupported escape: \\x4\"".to_string()) + ); + } + + #[test] + fn render_scalars() { + assert_eq!(render(RespValue::String("OK".to_string())), "OK"); + assert_eq!( + render(RespValue::Error("ERR oops".to_string())), + "(error) ERR oops" + ); + assert_eq!( + render(RespValue::BulkError(b"SYNTAX".to_vec())), + "(error) SYNTAX" + ); + assert_eq!(render(RespValue::Integer(-1)), "(integer) -1"); + assert_eq!(render(bulk("a\"b\n")), r#""a\"b\n""#); + assert_eq!(render(RespValue::Null), "(nil)"); + assert_eq!(render(RespValue::Boolean(true)), "(true)"); + assert_eq!(render(RespValue::Double(1.5)), "(double) 1.5"); + assert_eq!( + render(RespValue::BigNumber("12345678901234567890".to_string())), + "(big number) 12345678901234567890" + ); + assert_eq!( + render(RespValue::VerbatimString(( + "txt".to_string(), + "line 1\nline 2".to_string() + ))), + "line 1\nline 2" + ); + } + + #[test] + fn render_bulk_strings_like_valkey_cli() { + // bytes outside printable ASCII are escaped, like valkey-cli, binary or not + assert_eq!( + render(RespValue::BulkString(vec![0x80, b'a', 0x00])), + r#""\x80a\x00""# + ); + assert_eq!(render(bulk("€")), r#""\xe2\x82\xac""#); + assert_eq!(render(bulk("tab\tquote\"")), r#""tab\tquote\"""#); + } + + #[test] + fn render_aggregates() { + assert_eq!(render(RespValue::Array(vec![])), "(empty array)"); + assert_eq!(render(RespValue::Set(vec![])), "(empty set)"); + assert_eq!(render(RespValue::Map(vec![])), "(empty map)"); + assert_eq!( + render(RespValue::Array(vec![ + nested(bulk("a")), + nested(RespValue::Integer(1)) + ])), + "1) \"a\"\n2) (integer) 1" + ); + assert_eq!(render(RespValue::Set(vec![nested(bulk("a"))])), "1~ \"a\""); + assert_eq!( + render(RespValue::Push(vec![ + nested(bulk("message")), + nested(bulk("news")) + ])), + "1) \"message\"\n2) \"news\"" + ); + assert_eq!( + render(RespValue::Map(vec![( + nested(bulk("proto")), + nested(RespValue::Integer(3)) + )])), + "1# \"proto\" => (integer) 3" + ); + } + + #[test] + fn render_nested_aggregates_line_up() { + let inner = RespValue::Array(vec![nested(bulk("x")), nested(bulk("y"))]); + assert_eq!( + render(RespValue::Array(vec![nested(bulk("a")), nested(inner)])), + "1) \"a\"\n2) 1) \"x\"\n 2) \"y\"" + ); + let entry = RespValue::Array(vec![nested(bulk("x")), nested(bulk("y"))]); + assert_eq!( + render(RespValue::Map(vec![(nested(bulk("k")), nested(entry))])), + "1# \"k\" => 1) \"x\"\n 2) \"y\"" + ); + } + + #[test] + fn render_numbers_right_aligned() { + let items = (0..10).map(|n| nested(RespValue::Integer(n))).collect(); + let rendered = render(RespValue::Array(items)); + let lines: Vec<&str> = rendered.lines().collect(); + assert_eq!(lines[0], " 1) (integer) 0"); + assert_eq!(lines[9], "10) (integer) 9"); + } + + #[test] + fn render_invalid_nested_values() { + assert!(render(RespValue::Array(vec![b"?".to_vec()])).starts_with("1) (invalid) ")); + } + + #[test] + fn render_errors() { + assert_eq!( + render_error(ErrorCode::Server("ERR oops".into())), + "(error) ERR oops" + ); + assert_eq!(render_error(ErrorCode::Closed(None)), "connection closed"); + assert_eq!( + render_error(ErrorCode::Closed(Some("reset".to_string()))), + "connection closed: reset" + ); + assert_eq!(render_error(ErrorCode::Client("bad".to_string())), "bad"); + assert_eq!( + render_error(ErrorCode::Resp("bad".to_string())), + "invalid RESP: bad" + ); + assert_eq!( + render_error(ErrorCode::Lagged), + "fell behind, values were lost" + ); + } +} diff --git a/components/dep-cli/Cargo.toml b/components/internal-tcp-connector-egress-config/Cargo.toml similarity index 60% rename from components/dep-cli/Cargo.toml rename to components/internal-tcp-connector-egress-config/Cargo.toml index c2b0dca..2c93fdb 100644 --- a/components/dep-cli/Cargo.toml +++ b/components/internal-tcp-connector-egress-config/Cargo.toml @@ -1,12 +1,11 @@ [package] -name = "dep-cli" +name = "internal-tcp-connector-egress-config" version = "0.1.0" -edition = "2021" +edition = "2024" license = "Apache-2.0" [lib] crate-type = ["cdylib"] [dependencies] -clap = { version = "4.6.1", features = ["derive"] } wit-bindgen = { workspace = true } diff --git a/components/internal-tcp-connector-egress-config/README.md b/components/internal-tcp-connector-egress-config/README.md new file mode 100644 index 0000000..e381a30 --- /dev/null +++ b/components/internal-tcp-connector-egress-config/README.md @@ -0,0 +1,11 @@ +# internal-tcp-connector-egress-config + +The config of a [`latch-cidr-egress`](https://github.com/componentized/sockets/tree/main/components/latch-cidr-egress), from the config of a [`tcp-connector`](../tcp-connector/): outbound traffic is denied, except to the server's `port`, on any address. + +| Connector config | Latch config | +| --- | --- | +| `port=6380` | `default=deny`, `defer=:6380` | +| unset | `default=deny`, `defer=:6379`, the connector's default | +| not a port number | `default=deny` | + +See [`tcp-connector-latch`](../tcp-connector-latch/). diff --git a/components/internal-tcp-connector-egress-config/src/lib.rs b/components/internal-tcp-connector-egress-config/src/lib.rs new file mode 100644 index 0000000..bd19c2f --- /dev/null +++ b/components/internal-tcp-connector-egress-config/src/lib.rs @@ -0,0 +1,78 @@ +//! The config of a `latch-cidr-egress`, from the config of a `tcp-connector`: outbound traffic is +//! denied, except to the server's port. + +use componentized::valkey::tcp_connector_constants as keys; +use exports::wasi::config::store::{Error, Guest}; +use wasi::config::store; + +struct Component; + +impl Guest for Component { + fn get(key: String) -> Result, Error> { + Ok(config()? + .into_iter() + .find_map(|(name, value)| (name == key).then_some(value))) + } + + fn get_all() -> Result, Error> { + config() + } +} + +fn config() -> Result, Error> { + let port = store::get(&keys::port_key()).map_err(|error| match error { + store::Error::Upstream(message) => Error::Upstream(message), + store::Error::Io(message) => Error::Io(message), + })?; + let port = match port { + Some(port) => port.parse::().ok(), + None => Some(keys::port_default()), + }; + Ok(cidr_egress_config(port)) +} + +/// Deny every port, but the server's, on any address. Without a port, one that is not a port +/// number and fails the connector, every port is denied. +fn cidr_egress_config(port: Option) -> Vec<(String, String)> { + let mut config = vec![("default".to_string(), "deny".to_string())]; + if let Some(port) = port { + // a port without an address matches every address + // addresses are guarded by latch-deny-connect-unless-lookup-address + config.push(("defer".to_string(), format!(":{port}"))); + } + config +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(entries: &[(&str, &str)]) -> Vec<(String, String)> { + entries + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() + } + + #[test] + fn defers_the_port() { + assert_eq!( + cidr_egress_config(Some(6380)), + config(&[("default", "deny"), ("defer", ":6380")]) + ); + } + + #[test] + fn denies_everything_without_a_port() { + assert_eq!(cidr_egress_config(None), config(&[("default", "deny")])); + } +} + +wit_bindgen::generate!({ + // `path` is relative to the manifest, `components/internal-tcp-connector-egress-config/` + path: "../wit", + world: "tcp-connector-config", + generate_all, +}); + +export!(Component); diff --git a/components/internal-tcp-connector-lookup-config/Cargo.toml b/components/internal-tcp-connector-lookup-config/Cargo.toml new file mode 100644 index 0000000..a0d6def --- /dev/null +++ b/components/internal-tcp-connector-lookup-config/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "internal-tcp-connector-lookup-config" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = { workspace = true } diff --git a/components/internal-tcp-connector-lookup-config/README.md b/components/internal-tcp-connector-lookup-config/README.md new file mode 100644 index 0000000..65a5ce3 --- /dev/null +++ b/components/internal-tcp-connector-lookup-config/README.md @@ -0,0 +1,12 @@ +# internal-tcp-connector-lookup-config + +The config of a [`latch-ip-name-lookup-glob`](https://github.com/componentized/sockets/tree/main/components/latch-ip-name-lookup-glob), from the config of a [`tcp-connector`](../tcp-connector/): name lookups are denied, except for the server's `hostname`. + +| Connector config | Latch config | +| --- | --- | +| `hostname=valkey.example.com` | `default=deny`, `defer=valkey.example.com` | +| `hostname=127.0.0.1`, an IP address | `default=deny`, `defer=127.0.0.1` | +| unset | `default=deny`, `defer=127.0.0.1`, the connector's default | +| `hostname=*.example.com`, with glob characters | `default=deny` | + +The [`tcp`](../tcp/) component looks up an IP address too, wasi:sockets returns it as is, so the address is allowed like a resolved hostname. See [`tcp-connector-latch`](../tcp-connector-latch/). diff --git a/components/internal-tcp-connector-lookup-config/src/lib.rs b/components/internal-tcp-connector-lookup-config/src/lib.rs new file mode 100644 index 0000000..8f0945d --- /dev/null +++ b/components/internal-tcp-connector-lookup-config/src/lib.rs @@ -0,0 +1,90 @@ +//! The config of a `latch-ip-name-lookup-glob`, from the config of a `tcp-connector`: name lookups +//! are denied, except for the server's hostname. + +use componentized::valkey::tcp_connector_constants as keys; +use exports::wasi::config::store::{Error, Guest}; +use wasi::config::store; + +struct Component; + +impl Guest for Component { + fn get(key: String) -> Result, Error> { + Ok(config()? + .into_iter() + .find_map(|(name, value)| (name == key).then_some(value))) + } + + fn get_all() -> Result, Error> { + config() + } +} + +fn config() -> Result, Error> { + let hostname = store::get(&keys::hostname_key()).map_err(|error| match error { + store::Error::Upstream(message) => Error::Upstream(message), + store::Error::Io(message) => Error::Io(message), + })?; + Ok(lookup_glob_config( + hostname.unwrap_or_else(keys::hostname_default), + )) +} + +/// Deny every name, but the hostname, an IP address too, which the `tcp` component looks up as +/// well. A hostname with glob characters would match other names, it is no hostname, so every +/// name is denied. +fn lookup_glob_config(hostname: String) -> Vec<(String, String)> { + let mut config = vec![("default".to_string(), "deny".to_string())]; + if !hostname.contains(['*', '?', '[', ']']) { + config.push(("defer".to_string(), hostname)); + } + config +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(entries: &[(&str, &str)]) -> Vec<(String, String)> { + entries + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() + } + + #[test] + fn defers_the_hostname() { + assert_eq!( + lookup_glob_config("valkey.example.com".to_string()), + config(&[("default", "deny"), ("defer", "valkey.example.com")]) + ); + } + + #[test] + fn defers_an_ip_address() { + assert_eq!( + lookup_glob_config("127.0.0.1".to_string()), + config(&[("default", "deny"), ("defer", "127.0.0.1")]) + ); + assert_eq!( + lookup_glob_config("::1".to_string()), + config(&[("default", "deny"), ("defer", "::1")]) + ); + } + + #[test] + fn denies_everything_for_a_pattern() { + let deny = config(&[("default", "deny")]); + assert_eq!(lookup_glob_config("*.example.com".to_string()), deny); + assert_eq!(lookup_glob_config("valkey?.example.com".to_string()), deny); + assert_eq!(lookup_glob_config("[a-z].example.com".to_string()), deny); + } +} + +wit_bindgen::generate!({ + // `path` is relative to the manifest, `components/internal-tcp-connector-lookup-config/` + path: "../wit", + world: "tcp-connector-config", + generate_all, +}); + +export!(Component); diff --git a/components/ops/README.md b/components/ops/README.md deleted file mode 100644 index 5277783..0000000 --- a/components/ops/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# ops - -Client library for interacting with a Valkey server, and RESP messages. diff --git a/components/ops/src/lib.rs b/components/ops/src/lib.rs deleted file mode 100644 index 43612f0..0000000 --- a/components/ops/src/lib.rs +++ /dev/null @@ -1,1380 +0,0 @@ -#![no_main] - -use exports::componentized::valkey::resp::{ - Error as RespError, Guest as RespGuest, NestedValue, Value, -}; -use exports::componentized::valkey::store::{ - Connection, Duration, Error, ExpireMode, ExpireResult, Guest as StoreGuest, GuestConnection, - HelloOpts, HrandfieldOpts, HscanOpts, Instant, -}; -use resp::{decode, encode}; -use std::cell::RefCell; -use std::net::IpAddr; -use std::vec; -use wasi::sockets::ip_name_lookup::{resolve_addresses, ErrorCode as LookupErrorCode}; -use wasi::sockets::types::{ - ErrorCode, IpAddress, IpAddressFamily, IpSocketAddress, Ipv4SocketAddress, Ipv6SocketAddress, - TcpSocket, -}; -use wit_bindgen::{FutureReader, StreamReader, StreamResult, StreamWriter}; - -pub mod resp; - -#[derive(Debug, Clone)] -struct ValkeyOps; - -impl ValkeyOps { - async fn open(address: IpSocketAddress) -> Result { - let socket = match address { - IpSocketAddress::Ipv4(_) => TcpSocket::create(IpAddressFamily::Ipv4)?, - IpSocketAddress::Ipv6(_) => TcpSocket::create(IpAddressFamily::Ipv6)?, - }; - socket.connect(address).await?; - - // send and receive may each be called once per socket, the streams are kept for the life - // of the connection - let (output, send_stream) = wit_stream::new(); - let sent = socket.send(send_stream); - let (input, received) = socket.receive(); - - Ok(ValkeyConnection { - streams: RefCell::new(Some((output, input))), - _sent: sent, - _received: received, - _socket: socket, - }) - } - - async fn resolve_ip_socket_addresses( - host: &str, - port: u16, - ) -> Result, Error> { - let ip_addresses = match host.parse() { - Ok(IpAddr::V4(addr)) => { - // host is an ipv4 address - let octets = addr.octets(); - vec![IpAddress::Ipv4(( - octets[0], octets[1], octets[2], octets[3], - ))] - } - Ok(IpAddr::V6(addr)) => { - // host is an ipv6 address - let segments = addr.segments(); - vec![IpAddress::Ipv6(( - segments[0], - segments[1], - segments[2], - segments[3], - segments[4], - segments[5], - segments[6], - segments[7], - ))] - } - Err(_) => { - // resolve as a hostname - resolve_addresses(host.to_string()).await? - } - }; - - // convert resolved ip address into socket address - let socket_addresses = ip_addresses - .into_iter() - .map(|ip_address| match ip_address { - IpAddress::Ipv4(address) => { - IpSocketAddress::Ipv4(Ipv4SocketAddress { address, port }) - } - IpAddress::Ipv6(address) => { - let flow_info = 0; - let scope_id = 0; - IpSocketAddress::Ipv6(Ipv6SocketAddress { - port, - flow_info, - address, - scope_id, - }) - } - }) - .collect(); - Ok(socket_addresses) - } -} - -impl StoreGuest for ValkeyOps { - type Connection = ValkeyConnection; - - async fn connect( - host: String, - port: u16, - opts: Option, - ) -> Result { - for addr in Self::resolve_ip_socket_addresses(&host, port).await? { - if let Ok(conn) = Self::open(addr).await { - // check the connection is alive - if conn.hello(opts.clone()).await.is_ok() { - // it's alive - return Ok(Connection::new(conn)); - } - } - } - Err(Error::Client(format!("unable to connect to {host}:{port}"))) - } -} - -struct ValkeyConnection { - /// The streams writing to and reading from the socket, taken while a command is in flight, - /// a connection handles one command at a time. - streams: RefCell, StreamReader)>>, - _sent: FutureReader>, - _received: FutureReader>, - _socket: TcpSocket, -} - -impl ValkeyConnection { - async fn exchange( - output: &mut StreamWriter, - input: &mut StreamReader, - request: Vec, - ) -> Result, Error> { - if !output.write_all(request).await.is_empty() { - Err(Error::Client("Stream Closed".to_string()))? - } - - // TODO handle responses spanning multiple windows - match input.read(Vec::with_capacity(1024)).await { - (StreamResult::Complete(_), response) => Ok(response), - (StreamResult::Dropped, _) => Err(Error::Client("Stream Closed".to_string())), - (StreamResult::Cancelled, _) => Err(Error::Client("Stream Cancelled".to_string())), - } - } -} - -impl GuestConnection for ValkeyConnection { - async fn send(&self, command: Vec) -> Result { - let request = encode(Value::Array( - command.into_iter().map(|c| c.into()).collect(), - )); - - let (mut output, mut input) = self - .streams - .borrow_mut() - .take() - .ok_or_else(|| Error::Client("Connection busy with another command".to_string()))?; - let response = Self::exchange(&mut output, &mut input, request).await; - *self.streams.borrow_mut() = Some((output, input)); - - decode(response?).map(|r| r.into()) - } - - async fn acl_deluser(&self, username: String) -> Result<(), Error> { - // https://valkey.io/commands/acl-deluser/ - // ACL DELUSER username [ username ... ] - - let response = self - .send(vec![ - Value::BulkString("ACL".to_string()), - Value::BulkString("DELUSER".to_string()), - Value::BulkString(username), - ]) - .await?; - match response { - Value::Integer(_) => Ok(()), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn acl_genpass(&self) -> Result { - // https://valkey.io/commands/acl-genpass/ - // ACL GENPASS [ bits ] - - let response = self - .send(vec![ - Value::BulkString("ACL".to_string()), - Value::BulkString("GENPASS".to_string()), - ]) - .await?; - match response { - Value::BulkString(pass) => Ok(pass), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn acl_setuser(&self, username: String, rules: Vec) -> Result<(), Error> { - // https://valkey.io/commands/acl-setuser/ - // ACL SETUSER username [ rule ] [ [ rule ] ... ] - - let mut command = vec![ - Value::BulkString("ACL".to_string()), - Value::BulkString("SETUSER".to_string()), - Value::BulkString(username), - ]; - for rule in rules { - command.push(Value::BulkString(rule)); - } - let response = self.send(command).await?; - match response { - Value::String(msg) => match msg.as_str() { - "OK" => Ok(()), - msg => Err(Error::Client(format!("Not OK: {msg}")))?, - }, - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn auth(&self, username: String, password: String) -> Result<(), Error> { - // https://valkey.io/commands/auth/ - // AUTH [ username ] password - - let response = self - .send(vec![ - Value::BulkString("AUTH".to_string()), - Value::BulkString(username), - Value::BulkString(password), - ]) - .await?; - match response { - Value::String(msg) => match msg.as_str() { - "OK" => Ok(()), - msg => Err(Error::Client(format!("Not OK: {msg}")))?, - }, - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn del(&self, key: String) -> Result<(), Error> { - // https://valkey.io/commands/del/ - // DEL key [ key ... ] - - // TODO handle multiple keys - let response = self - .send(vec![ - Value::BulkString("DEL".to_string()), - Value::BulkString(key), - ]) - .await?; - match response { - Value::Integer(_) => Ok(()), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn exists(&self, key: String) -> Result { - // https://valkey.io/commands/exists/ - // EXISTS key [ key ... ] - - // TODO handle multiple keys - let response = self - .send(vec![ - Value::BulkString("EXISTS".to_string()), - Value::BulkString(key), - ]) - .await?; - match response { - Value::Integer(0) => Ok(false), - Value::Integer(1) => Ok(true), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - #[allow(async_fn_in_trait)] - async fn expire( - &self, - key: String, - after: Duration, - mode: Option, - ) -> Result { - // https://valkey.io/commands/exire/ - // EXPIRE key seconds [ NX | XX | GT | LT ] - - let mut cmd = vec![ - Value::BulkString("EXPIRE".to_string()), - Value::BulkString(key), - Value::Integer((after / 1000) as i64), - ]; - if let Some(mode) = mode { - cmd.push(mode.into()); - } - - let response = self.send(cmd).await?; - match response { - Value::Integer(code) => match code { - 0 => Ok(ExpireResult::ConditionNotMet), - 1 => Ok(ExpireResult::Applied), - _ => Err(Error::Client(format!("Unknown reply: {code}")))?, - }, - Value::Null => Err(Error::Client("Operation aborted".to_string()))?, - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - #[allow(async_fn_in_trait)] - async fn expireat( - &self, - key: String, - after: Instant, - mode: Option, - ) -> Result { - // https://valkey.io/commands/exireat/ - // EXPIREAT key unix-time-seconds [ NX | XX | GT | LT ] - - let mut cmd = vec![ - Value::BulkString("EXPIRE".to_string()), - Value::BulkString(key), - Value::Integer(after.seconds), - ]; - if let Some(mode) = mode { - cmd.push(mode.into()); - } - - let response = self.send(cmd).await?; - match response { - Value::Integer(code) => match code { - 0 => Ok(ExpireResult::ConditionNotMet), - 1 => Ok(ExpireResult::Applied), - _ => Err(Error::Client(format!("Unknown reply: {code}")))?, - }, - Value::Null => Err(Error::Client("Operation aborted".to_string()))?, - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn get(&self, key: String) -> Result, Error> { - // https://valkey.io/commands/get/ - // GET key - - let response = self - .send(vec![ - Value::BulkString("GET".to_string()), - Value::BulkString(key), - ]) - .await?; - match response { - Value::BulkString(value) => Ok(Some(value)), - Value::Null => Ok(None), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hdel(&self, key: String, field: String) -> Result<(), Error> { - // https://valkey.io/commands/hdel/ - // HDEL key field [ field ... ] - - let response = self - .send(vec![ - Value::BulkString("HDEL".to_string()), - Value::BulkString(key), - Value::BulkString(field), - ]) - .await?; - match response { - Value::Integer(_) => Ok(()), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hello(&self, opts: Option) -> Result, Error> { - // https://valkey.io/commands/hello/ - // HELLO [ protover [ AUTH username password ] [ SETNAME clientname ] ] - - let mut cmd = vec![Value::BulkString("HELLO".to_string())]; - if let Some(opts) = opts { - let has_proto = opts.proto_ver.is_none(); - if let Some(proto_ver) = opts.proto_ver { - cmd.push(Value::BulkString(proto_ver)); - } - if let Some((username, password)) = opts.auth { - if has_proto { - Err(Error::Client( - "proto-ver must be specified to use auth".to_string(), - ))? - } - cmd.push(Value::BulkString("AUTH".to_string())); - cmd.push(Value::BulkString(username)); - cmd.push(Value::BulkString(password)); - } - if let Some(client_name) = opts.client_name { - if has_proto { - Err(Error::Client( - "proto-ver must be specified to use client-name".to_string(), - ))? - } - cmd.push(Value::BulkString("SETNAME".to_string())); - cmd.push(Value::BulkString(client_name)); - } - } - let response = self.send(cmd).await?; - match response { - // convert RESP2 array - Value::Array(items) => { - let mut hello: Vec<(String, Value)> = vec![]; - for item in items.chunks(2) { - let key = item[0].clone().into(); - let value = item[1].clone().into(); - let key = match key { - Value::BulkString(key) => key, - key => Err(Error::Client(format!("Unexpected key type: {:?}", key)))?, - }; - hello.push((key, value)) - } - Ok(hello) - } - // RESP3 - Value::Map(items) => { - let mut hello: Vec<(String, Value)> = vec![]; - for (key, value) in items { - let key = match key.into() { - Value::BulkString(key) => key, - key => Err(Error::Client(format!("Unexpected key type: {:?}", key)))?, - }; - hello.push((key, value.into())) - } - Ok(hello) - } - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hexists(&self, key: String, field: String) -> Result { - // https://valkey.io/commands/hexists/ - // HEXISTS key field - - let response = self - .send(vec![ - Value::BulkString("HEXISTS".to_string()), - Value::BulkString(key), - Value::BulkString(field), - ]) - .await?; - match response { - Value::Integer(0) => Ok(false), - Value::Integer(1) => Ok(true), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - #[allow(async_fn_in_trait)] - async fn hexpire( - &self, - key: String, - after: Duration, - mode: Option, - fields: Vec, - ) -> Result, Error> { - // https://valkey.io/commands/hexire/ - // HEXPIRE key seconds [ NX | XX | GT | LT ] FIELDS numfields field [ field ... ] - - let mut cmd = vec![ - Value::BulkString("HEXPIRE".to_string()), - Value::BulkString(key), - Value::Integer((after / 1000) as i64), - ]; - if let Some(mode) = mode { - cmd.push(mode.into()); - } - cmd.push(Value::BulkString("FIELDS".to_string())); - cmd.push(Value::Integer(fields.len() as i64)); - for field in fields { - cmd.push(Value::BulkString(field)); - } - - let response = self.send(cmd).await?; - match response { - Value::Array(codes) => { - let mut replies = vec![]; - for code in codes { - let code = code.into(); - match code { - Value::Integer(code) => { - replies.push(match code { - -2 => ExpireResult::DoesNotExist, - 0 => ExpireResult::ConditionNotMet, - 1 => ExpireResult::Applied, - 2 => ExpireResult::ZeroSeconds, - _ => Err(Error::Client(format!("Unknown reply: {code}")))?, - }); - } - value => Err(Error::Client(format!( - "Unexpected array item type: {:?}", - value - )))?, - } - } - Ok(replies) - } - Value::Null => Err(Error::Client("Operation aborted".to_string()))?, - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - #[allow(async_fn_in_trait)] - async fn hexpireat( - &self, - key: String, - after: Instant, - mode: Option, - fields: Vec, - ) -> Result, Error> { - // https://valkey.io/commands/hexireat/ - // HEXPIREAT key unix-time-seconds [ NX | XX | GT | LT ] FIELDS numfields field [ field ... ] - - let mut cmd = vec![ - Value::BulkString("HEXPIREAT".to_string()), - Value::BulkString(key), - Value::Integer(after.seconds), - ]; - if let Some(mode) = mode { - cmd.push(mode.into()); - } - cmd.push(Value::BulkString("FIELDS".to_string())); - cmd.push(Value::Integer(fields.len() as i64)); - for field in fields { - cmd.push(Value::BulkString(field)); - } - - let response = self.send(cmd).await?; - match response { - Value::Array(codes) => { - let mut replies = vec![]; - for code in codes { - let code = code.into(); - match code { - Value::Integer(code) => { - replies.push(match code { - -2 => ExpireResult::DoesNotExist, - 0 => ExpireResult::ConditionNotMet, - 1 => ExpireResult::Applied, - 2 => ExpireResult::ZeroSeconds, - _ => Err(Error::Client(format!("Unknown reply: {code}")))?, - }); - } - value => Err(Error::Client(format!( - "Unexpected array item type: {:?}", - value - )))?, - } - } - Ok(replies) - } - Value::Null => Err(Error::Client("Operation aborted".to_string()))?, - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hget(&self, key: String, field: String) -> Result, Error> { - // https://valkey.io/commands/hget/ - // HGET key field - - let response = self - .send(vec![ - Value::BulkString("HGET".to_string()), - Value::BulkString(key), - Value::BulkString(field), - ]) - .await?; - match response { - Value::BulkString(value) => Ok(Some(value)), - Value::Null => Ok(None), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hgetall(&self, key: String) -> Result, Error> { - // https://valkey.io/commands/hgetall/ - // HGETALL key - - let response = self - .send(vec![ - Value::BulkString("HGETALL".to_string()), - Value::BulkString(key), - ]) - .await?; - match response { - // RESP2 - Value::Array(items) => { - let mut fields = vec![]; - for item in items.chunks(2) { - let key = match item[0].clone().into() { - Value::BulkString(key) => key, - key => Err(Error::Client(format!("Unexpected key type: {:?}", key)))?, - }; - let value = match item[1].clone().into() { - Value::BulkString(value) => value, - value => Err(Error::Client(format!("Unexpected value type: {:?}", value)))?, - }; - fields.push((key, value)); - } - Ok(fields) - } - // RESP3 - Value::Map(items) => { - let mut fields = vec![]; - for (key, value) in items { - let key = match key.into() { - Value::BulkString(key) => key, - key => Err(Error::Client(format!("Unexpected key type: {:?}", key)))?, - }; - let value = match value.into() { - Value::BulkString(value) => value, - value => Err(Error::Client(format!("Unexpected value type: {:?}", value)))?, - }; - fields.push((key, value)); - } - Ok(fields) - } - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hincrby(&self, key: String, field: String, increment: i64) -> Result { - // https://valkey.io/commands/hincrby/ - // HINCRBY key field increment - - let response = self - .send(vec![ - Value::BulkString("HINCRBY".to_string()), - Value::BulkString(key), - Value::BulkString(field), - Value::BulkString(increment.to_string()), - ]) - .await?; - match response { - Value::Integer(value) => Ok(value), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hincrbyfloat( - &self, - key: String, - field: String, - increment: f64, - ) -> Result { - // https://valkey.io/commands/hincrbyfloat/ - // HINCRBYFLOAT key field increment - - let response = self - .send(vec![ - Value::BulkString("HINCRBYFLOAT".to_string()), - Value::BulkString(key), - Value::BulkString(field), - Value::BulkString(increment.to_string()), - ]) - .await?; - match response { - Value::BulkString(value) => Ok(value), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hkeys(&self, key: String) -> Result, Error> { - // https://valkey.io/commands/hkeys/ - // HKEYS key - - let response = self - .send(vec![ - Value::BulkString("HKEYS".to_string()), - Value::BulkString(key), - ]) - .await?; - match response { - Value::Array(values) => { - let mut keys = vec![]; - for value in values { - let value = value.into(); - match value { - Value::BulkString(key) => keys.push(key), - value => Err(Error::Client(format!( - "Unexpected array item type: {:?}", - value - )))?, - } - } - Ok(keys) - } - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hlen(&self, key: String) -> Result { - // https://valkey.io/commands/hlen/ - // HLEN key - - let response = self - .send(vec![ - Value::BulkString("HLEN".to_string()), - Value::BulkString(key), - ]) - .await?; - match response { - Value::Integer(value) => Ok(value as u64), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hmget(&self, key: String, fields: Vec) -> Result>, Error> { - // https://valkey.io/commands/hmget/ - // HMGET key field [ field ... ] - - let mut cmd = vec![ - Value::BulkString("HMGET".to_string()), - Value::BulkString(key), - ]; - for field in fields { - cmd.push(Value::BulkString(field)); - } - let response = self.send(cmd).await?; - match response { - Value::Array(items) => { - let mut values = vec![]; - for item in items { - match item.into() { - Value::BulkString(val) => values.push(Some(val)), - Value::Null => values.push(None), - item => Err(Error::Client(format!( - "Unexpected array item type: {:?}", - item - )))?, - } - } - Ok(values) - } - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hmset(&self, key: String, fields: Vec<(String, String)>) -> Result<(), Error> { - // https://valkey.io/commands/hmset/ - // HMSET key field value [ field value ... ] - - let mut cmd = vec![ - Value::BulkString("HMSET".to_string()), - Value::BulkString(key), - ]; - for (field, value) in fields { - cmd.push(Value::BulkString(field)); - cmd.push(Value::BulkString(value)); - } - let response = self.send(cmd).await?; - match response { - Value::String(msg) => match msg.as_str() { - "OK" => Ok(()), - msg => Err(Error::Client(format!("Not OK: {msg}")))?, - }, - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hrandfield( - &self, - key: String, - opts: Option, - ) -> Result)>>, Error> { - // https://valkey.io/commands/hrandfield/ - // HRANDFIELD key [ count [ WITHVALUES ] ] - - let mut cmd = vec![ - Value::BulkString("HRANDFIELD".to_string()), - Value::BulkString(key), - ]; - if let Some(opts) = opts { - if let Some(count) = opts.count { - cmd.push(Value::BulkString(count.to_string())); - } - if let Some(with_values) = opts.with_values { - if with_values { - if opts.count.is_none() { - Err(Error::Client( - "count must be specified to use with-values".to_string(), - ))? - } - cmd.push(Value::BulkString("WITHVALUES".to_string())); - } - } - } - let response = self.send(cmd).await?; - match response { - Value::BulkString(value) => Ok(Some(vec![(value, None)])), - Value::Array(items) => match items.len() { - 0 => Ok(None), - _ => { - let mut foo = vec![]; - match opts { - Some(HrandfieldOpts { - count: _, - with_values: Some(true), - }) => { - for item in items.chunks(2) { - let key = match item[0].clone().into() { - Value::BulkString(key) => key, - key => Err(Error::Client(format!( - "Unexpected key type: {:?}", - key - )))?, - }; - let value = match item[1].clone().into() { - Value::BulkString(value) => value, - value => Err(Error::Client(format!( - "Unexpected value type: {:?}", - value - )))?, - }; - foo.push((key, Some(value))); - } - } - _ => { - for key in items { - let key = match key.into() { - Value::BulkString(key) => key, - key => Err(Error::Client(format!( - "Unexpected key type: {:?}", - key - )))?, - }; - foo.push((key, None)); - } - } - } - Ok(Some(foo)) - } - }, - Value::Null => Ok(None), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hscan( - &self, - key: String, - cursor: Option, - opts: Option, - ) -> Result<(Option, Vec<(String, Option)>), Error> { - // https://valkey.io/commands/hscan/ - // HSCAN key cursor [ MATCH pattern ] [ COUNT count ] [ NOVALUES ] - - let mut cmd = vec![ - Value::BulkString("HSCAN".to_string()), - Value::BulkString(key), - Value::BulkString(cursor.unwrap_or("0".to_string())), - ]; - if let Some(opts) = opts.clone() { - if let Some(match_) = opts.match_ { - cmd.push(Value::BulkString("MATCH".to_string())); - cmd.push(Value::BulkString(match_)); - } - if let Some(count) = opts.count { - cmd.push(Value::BulkString("COUNT".to_string())); - cmd.push(Value::BulkString(count.to_string())); - } - if let Some(no_values) = opts.no_values { - if no_values { - cmd.push(Value::BulkString("NOVALUES".to_string())); - } - } - } - let response = self.send(cmd).await?; - match response { - Value::Array(items) => { - let cursor = match items[0].clone().into() { - Value::BulkString(cursor) => cursor, - cursor => Err(Error::Client(format!( - "Unexpected cursor type: {:?}", - cursor - )))?, - }; - let elements = match items[1].clone().into() { - Value::Array(elements) => elements, - elements => Err(Error::Client(format!( - "Unexpected elements type: {:?}", - elements - )))?, - }; - let mut fields = vec![]; - match opts { - Some(HscanOpts { - match_: _, - count: _, - no_values: Some(true), - }) => { - for field in elements { - match field.clone().into() { - Value::BulkString(field) => fields.push((field, None)), - field => Err(Error::Client(format!( - "Unexpected field type: {:?}", - field - )))?, - } - } - } - _ => { - for item in elements.chunks(2) { - let key = match item[0].clone().into() { - Value::BulkString(key) => key, - key => { - Err(Error::Client(format!("Unexpected key type: {:?}", key)))? - } - }; - let value = match item[1].clone().into() { - Value::BulkString(value) => value, - value => Err(Error::Client(format!( - "Unexpected value type: {:?}", - value - )))?, - }; - fields.push((key, Some(value))); - } - } - }; - - let cursor = match cursor.as_str() { - "0" => None, - _ => Some(cursor), - }; - - Ok((cursor, fields)) - } - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hset(&self, key: String, field: String, value: String) -> Result<(), Error> { - // https://valkey.io/commands/hset/ - // HSET key field value [ field value ... ] - - let response = self - .send(vec![ - Value::BulkString("HSET".to_string()), - Value::BulkString(key), - Value::BulkString(field), - Value::BulkString(value), - ]) - .await?; - match response { - Value::Integer(_) => Ok(()), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hsetnx(&self, key: String, field: String, value: String) -> Result { - // https://valkey.io/commands/hsetnx/ - // HSETNX key field value - - let response = self - .send(vec![ - Value::BulkString("HSETNX".to_string()), - Value::BulkString(key), - Value::BulkString(field), - Value::BulkString(value), - ]) - .await?; - match response { - Value::Integer(0) => Ok(false), - Value::Integer(1) => Ok(true), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hstrlen(&self, key: String, field: String) -> Result { - // https://valkey.io/commands/hstrlen/ - // HSTRLEN key field - - let response = self - .send(vec![ - Value::BulkString("HSTRLEN".to_string()), - Value::BulkString(key), - Value::BulkString(field), - ]) - .await?; - match response { - Value::Integer(len) => Ok(len as u64), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn hvals(&self, key: String) -> Result, Error> { - // https://valkey.io/commands/hvals/ - // HVALS key - - let response = self - .send(vec![ - Value::BulkString("HVALS".to_string()), - Value::BulkString(key), - ]) - .await?; - match response { - Value::Array(items) => { - let mut fields = vec![]; - for item in items { - match item.into() { - Value::BulkString(field) => fields.push(field), - field => Err(Error::Client(format!("Unexpected field type: {:?}", field)))?, - } - } - Ok(fields) - } - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn incr(&self, key: String) -> Result { - // https://valkey.io/commands/incr/ - // INCR key - - let response = self - .send(vec![ - Value::BulkString("INCR".to_string()), - Value::BulkString(key), - ]) - .await?; - match response { - Value::Integer(value) => Ok(value), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn incrby(&self, key: String, increment: i64) -> Result { - // https://valkey.io/commands/incrby/ - // INCRBY key increment - - let response = self - .send(vec![ - Value::BulkString("INCRBY".to_string()), - Value::BulkString(key), - Value::BulkString(increment.to_string()), - ]) - .await?; - match response { - Value::Integer(value) => Ok(value), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn keys(&self, pattern: String) -> Result, Error> { - // https://valkey.io/commands/keys/ - // KEYS pattern - - let response = self - .send(vec![ - Value::BulkString("KEYS".to_string()), - Value::BulkString(pattern), - ]) - .await?; - match response { - Value::Array(values) => { - let mut keys = vec![]; - for value in values { - let value = value.into(); - match value { - Value::BulkString(key) => keys.push(key), - value => Err(Error::Client(format!( - "Unexpected array item type: {:?}", - value - )))?, - } - } - Ok(keys) - } - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn ping(&self) -> Result<(), Error> { - // https://valkey.io/commands/ping/ - // PING [ message ] - - // TODO support command options - let response = self - .send(vec![Value::BulkString("PING".to_string())]) - .await?; - match response { - Value::String(msg) => match msg.as_str() { - "PONG" => Ok(()), - msg => Err(Error::Client(format!("Not PONG: {msg}")))?, - }, - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn quit(&self) -> Result<(), Error> { - // https://valkey.io/commands/quit/ - // QUIT - - let response = self - .send(vec![Value::BulkString("QUIT".to_string())]) - .await?; - match response { - Value::String(msg) => match msg.as_str() { - "OK" => Ok(()), - msg => Err(Error::Client(format!("Not OK: {msg}")))?, - }, - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn publish(&self, channel: String, message: String) -> Result { - // https://valkey.io/commands/publish/ - // PUBLISH channel message - - let response = self - .send(vec![ - Value::BulkString("PUBLISH".to_string()), - Value::BulkString(channel), - Value::BulkString(message), - ]) - .await?; - match response { - Value::Integer(value) => Ok(value), - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } - - async fn set(&self, key: String, value: String) -> Result<(), Error> { - // https://valkey.io/commands/set/ - // SET key value - // [ NX | XX | IFEQ comparison-value ] - // [ GET ] - // [ EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL ] - - // TODO support command options - let response = self - .send(vec![ - Value::BulkString("SET".to_string()), - Value::BulkString(key), - Value::BulkString(value), - ]) - .await?; - match response { - Value::String(msg) => match msg.as_str() { - "OK" => Ok(()), - msg => Err(Error::Client(format!("Not OK: {msg}")))?, - }, - Value::Null => Err(Error::Client("Operation aborted".to_string()))?, - Value::Error(err) => Err(Error::Valkey(err))?, - response => Err(Error::Client(format!( - "Unexpected response type: {:?}", - response - )))?, - } - } -} - -impl From for Error { - fn from(e: ErrorCode) -> Self { - match e { - ErrorCode::AccessDenied => Self::Client("Network AccessDenied".to_string()), - ErrorCode::NotSupported => Self::Client("Network NotSupported".to_string()), - ErrorCode::InvalidArgument => Self::Client("Network InvalidArgument".to_string()), - ErrorCode::OutOfMemory => Self::Client("Network OutOfMemory".to_string()), - ErrorCode::Timeout => Self::Client("Network Timeout".to_string()), - ErrorCode::InvalidState => Self::Client("Network InvalidState".to_string()), - ErrorCode::AddressNotBindable => Self::Client("Network AddressNotBindable".to_string()), - ErrorCode::AddressInUse => Self::Client("Network AddressInUse".to_string()), - ErrorCode::RemoteUnreachable => Self::Client("Network RemoteUnreachable".to_string()), - ErrorCode::ConnectionRefused => Self::Client("Network ConnectionRefused".to_string()), - ErrorCode::ConnectionBroken => Self::Client("Network ConnectionBroken".to_string()), - ErrorCode::ConnectionReset => Self::Client("Network ConnectionReset".to_string()), - ErrorCode::ConnectionAborted => Self::Client("Network ConnectionAborted".to_string()), - ErrorCode::DatagramTooLarge => Self::Client("Network DatagramTooLarge".to_string()), - ErrorCode::Other(None) => Self::Client("Network Other".to_string()), - ErrorCode::Other(Some(message)) => Self::Client(format!("Network Other: {message}")), - } - } -} - -impl From for Error { - fn from(e: LookupErrorCode) -> Self { - match e { - LookupErrorCode::AccessDenied => Self::Client("Network AccessDenied".to_string()), - LookupErrorCode::InvalidArgument => Self::Client("Network InvalidArgument".to_string()), - LookupErrorCode::NameUnresolvable => { - Self::Client("Network NameUnresolvable".to_string()) - } - LookupErrorCode::TemporaryResolverFailure => { - Self::Client("Network TemporaryResolverFailure".to_string()) - } - LookupErrorCode::PermanentResolverFailure => { - Self::Client("Network PermanentResolverFailure".to_string()) - } - LookupErrorCode::Other(None) => Self::Client("Network Other".to_string()), - LookupErrorCode::Other(Some(message)) => { - Self::Client(format!("Network Other: {message}")) - } - } - } -} - -impl RespGuest for ValkeyOps { - fn decode(data: Vec) -> Result { - decode(data).map_err(|e| e.to_string()) - } - - fn encode(value: Value) -> Result, RespError> { - Ok(encode(value.into())) - } -} - -impl From for NestedValue { - fn from(value: Value) -> Self { - ValkeyOps::encode(value).expect("values must encode cleanly") - } -} - -impl From for Value { - fn from(value: NestedValue) -> Self { - ValkeyOps::decode(value).expect("nested values must decode cleanly") - } -} - -impl From for Value { - fn from(mode: ExpireMode) -> Self { - match mode { - ExpireMode::Nx => Value::BulkString("NX".to_string()), - ExpireMode::Xx => Value::BulkString("XX".to_string()), - ExpireMode::Gt => Value::BulkString("GT".to_string()), - ExpireMode::Lt => Value::BulkString("LT".to_string()), - } - } -} - -wit_bindgen::generate!({ - world: "ops", - path: "../wit", - generate_all -}); - -export!(ValkeyOps); diff --git a/components/ops/src/resp/LICENSE-APACHE.txt b/components/ops/src/resp/LICENSE-APACHE.txt deleted file mode 100644 index 16fe87b..0000000 --- a/components/ops/src/resp/LICENSE-APACHE.txt +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/components/ops/src/resp/LICENSE.txt b/components/ops/src/resp/LICENSE.txt deleted file mode 100644 index 7bd5ff0..0000000 --- a/components/ops/src/resp/LICENSE.txt +++ /dev/null @@ -1,5 +0,0 @@ -MIT/Apache-2.0 - -Copyright (c) 2016-2022 IORust Developers - -See LICENSE-APACHE and LICENSE-MIT. diff --git a/components/ops/src/resp/README.md b/components/ops/src/resp/README.md deleted file mode 100644 index 8e551b3..0000000 --- a/components/ops/src/resp/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# resp - -Code from https://github.com/iorust/resp/tree/v1.0.3 diff --git a/components/ops/src/resp/mod.rs b/components/ops/src/resp/mod.rs deleted file mode 100644 index ba7ba4f..0000000 --- a/components/ops/src/resp/mod.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! RESP serialize - -use crate::exports::componentized::valkey::resp::{NestedValue, Value}; -use crate::exports::componentized::valkey::store::Error; -use core::f64; -use std::io::{BufRead, BufReader, Read}; -use std::string::String; -use std::vec::Vec; - -/// up to 512 MB in length -const RESP_MAX_SIZE: i64 = 512 * 1024 * 1024; -const CRLF_BYTES: &'static [u8] = b"\r\n"; - -/// Encodes RESP value to RESP binary buffer. -/// # Examples -/// ``` -/// # use self::resp::{Value, encode}; -/// let val = Value::String("OK".to_string()); -/// assert_eq!(encode(&val), vec![43, 79, 75, 13, 10]); -/// ``` -pub fn encode(value: Value) -> Vec { - let mut res: Vec = Vec::new(); - buf_encode(value, &mut res); - res -} - -#[inline] -fn buf_encode(value: Value, buf: &mut Vec) { - match value { - Value::Null => { - buf.push(b'_'); - buf.extend_from_slice(CRLF_BYTES); - } - Value::String(val) => { - buf.push(b'+'); - buf.extend_from_slice(val.as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - } - Value::Error(val) => { - buf.push(b'-'); - buf.extend_from_slice(val.as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - } - Value::Integer(val) => { - buf.push(b':'); - buf.extend_from_slice(val.to_string().as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - } - Value::BulkString(val) => { - buf.push(b'$'); - buf.extend_from_slice(val.len().to_string().as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - buf.extend_from_slice(val.as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - } - Value::Array(val) => { - buf.push(b'*'); - buf.extend_from_slice(val.len().to_string().as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - for item in val { - buf_encode(item.into(), buf); - } - } - Value::Boolean(ref val) => { - buf.push(b'#'); - match val { - true => buf.push(b't'), - false => buf.push(b'f'), - } - buf.extend_from_slice(CRLF_BYTES); - } - Value::Double(ref val) => { - buf.push(b','); - buf.extend_from_slice(val.to_string().to_lowercase().as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - } - Value::BigNumber(ref val) => { - buf.push(b'('); - buf.extend_from_slice(val.as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - } - Value::BulkError(ref val) => { - buf.push(b'!'); - buf.extend_from_slice(val.len().to_string().as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - buf.extend_from_slice(val.as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - } - Value::VerbatimString((ref encoding, ref value)) => { - let val = format!("{encoding}:{value}"); - buf.push(b'='); - buf.extend_from_slice(val.len().to_string().as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - buf.extend_from_slice(val.as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - } - Value::Map(val) => { - buf.push(b'%'); - buf.extend_from_slice(val.len().to_string().as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - for (key, value) in val { - buf_encode(key.into(), buf); - buf_encode(value.into(), buf); - } - } - Value::Set(val) => { - buf.push(b'~'); - buf.extend_from_slice(val.len().to_string().as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - for item in val { - buf_encode(item.into(), buf); - } - } - Value::Push(val) => { - buf.push(b'>'); - buf.extend_from_slice(val.len().to_string().as_bytes()); - buf.extend_from_slice(CRLF_BYTES); - for item in val { - buf_encode(item.into(), buf); - } - } - } -} - -pub fn decode(value: Vec) -> Result { - Decoder::new(BufReader::new(value.as_slice())).decode() -} - -/// A streaming RESP Decoder. -#[derive(Debug)] -pub struct Decoder { - reader: BufReader, -} - -impl Decoder { - /// Creates a Decoder instance with given BufReader for decoding the RESP buffers. - /// # Examples - /// ``` - /// # use std::io::BufReader; - /// # use self::resp::{Decoder, Value}; - /// - /// let value = Value::BulkString("Hello".to_string()); - /// let buf = value.encode(); - /// let mut decoder = Decoder::new(BufReader::new(buf.as_slice())); - /// assert_eq!(decoder.decode().unwrap(), Value::BulkString("Hello".to_string())); - /// ``` - pub fn new(reader: BufReader) -> Self { - Decoder { reader: reader } - } - - /// It will read buffers from the inner BufReader, decode it to a Value. - pub fn decode(&mut self) -> Result { - let mut res: Vec = Vec::new(); - self.reader - .read_until(b'\n', &mut res) - .map_err(|e| Error::Resp(e.to_string()))?; - - let len = res.len(); - if len == 0 { - Err(Error::Resp("unexpected EOF".to_string()))? - } - if len < 3 { - Err(Error::Resp(format!("too short: {}", len)))? - } - if !is_crlf(res[len - 2], res[len - 1]) { - Err(Error::Resp(format!("invalid CRLF: {:?}", res)))? - } - - let bytes = res[1..len - 2].as_ref(); - match res[0] { - // Value::String - b'+' => parse_string(bytes).map(Value::String), - // Value::Error - b'-' => parse_string(bytes).map(Value::Error), - // Value::Integer - b':' => parse_integer(bytes).map(Value::Integer), - // Value::BulkString - b'$' => { - let int = parse_integer(bytes)?; - if int == -1 { - // Null bulk string, special case for RESP2 - return Ok(Value::Null); - } - if int < -1 || int >= RESP_MAX_SIZE { - Err(Error::Resp(format!("invalid bulk string length: {}", int)))? - } - - let mut buf: Vec = Vec::new(); - let int = int as usize; - buf.resize(int + 2, 0); - self.reader - .read_exact(buf.as_mut_slice()) - .map_err(|e| Error::Resp(e.to_string()))?; - if !is_crlf(buf[int], buf[int + 1]) { - Err(Error::Resp(format!("invalid CRLF: {:?}", buf)))? - } - buf.truncate(int); - parse_string(buf.as_slice()).map(Value::BulkString) - } - // Value::Array - b'*' => { - let int = parse_integer(bytes)?; - if int == -1 { - // Null array, special case for RESP2 - return Ok(Value::Null); - } - if int < -1 || int >= RESP_MAX_SIZE { - Err(Error::Resp(format!("invalid array length: {}", int)))? - } - - let mut array: Vec = Vec::with_capacity(int as usize); - for _ in 0..int { - // TODO avoid full decoding just to re-encode - let val = self.decode()?; - array.push(val.into()); - } - Ok(Value::Array(array)) - } - // Value::Null - b'_' => Ok(Value::Null), - // Value::Boolean - b'#' => match bytes[0] { - b'f' => Ok(Value::Boolean(false)), - b't' => Ok(Value::Boolean(true)), - _ => Err(Error::Resp(format!("invalid RESP boolean: {:?}", bytes))), - }, - // Value::Double - b',' => parse_double(bytes).map(Value::Double), - // Value::BigNumber - b'(' => parse_string(bytes).map(Value::BigNumber), - // Value::BulkError - b'!' => { - let int = parse_integer(bytes)?; - if int < 0 || int >= RESP_MAX_SIZE { - Err(Error::Resp(format!("invalid bulk error length: {}", int)))? - } - - let mut buf: Vec = Vec::new(); - let int = int as usize; - buf.resize(int + 2, 0); - self.reader - .read_exact(buf.as_mut_slice()) - .map_err(|e| Error::Resp(e.to_string()))?; - if !is_crlf(buf[int], buf[int + 1]) { - Err(Error::Resp(format!("invalid CRLF: {:?}", buf)))? - } - buf.truncate(int); - parse_string(buf.as_slice()).map(Value::BulkError) - } - // Value::VerbatimString - b'=' => { - let int = parse_integer(bytes)?; - if int < 0 || int >= RESP_MAX_SIZE { - Err(Error::Resp(format!( - "invalid verbatim string length: {}", - int - )))? - } - - let mut buf: Vec = Vec::new(); - let int = int as usize; - buf.resize(int + 2, 0); - self.reader - .read_exact(buf.as_mut_slice()) - .map_err(|e| Error::Resp(e.to_string()))?; - if !is_crlf(buf[int], buf[int + 1]) { - Err(Error::Resp(format!("invalid CRLF: {:?}", buf)))? - } - buf.truncate(int); - match parse_string(buf.as_slice()) { - Err(err) => Err(err)?, - Ok(str) => { - let parts: Vec<&str> = str.splitn(2, ':').collect(); - if parts.len() != 2 { - Err(Error::Resp( - "invalid verbatim string, missing encoding".to_string(), - ))? - } - Ok(Value::VerbatimString(( - parts[0].to_string(), - parts[1].to_string(), - ))) - } - } - } - // Value::Map - b'%' => { - let int = parse_integer(bytes)?; - if int < 0 || int >= RESP_MAX_SIZE { - Err(Error::Resp(format!("invalid array length: {}", int)))? - } - - let mut map: Vec<(NestedValue, NestedValue)> = Vec::with_capacity(int as usize); - for _ in 0..int { - // TODO avoid full decoding just to re-encode - let key = self.decode()?; - let val = self.decode()?; - map.push((key.into(), val.into())); - } - Ok(Value::Map(map)) - } - // Value::Set - b'~' => { - let int = parse_integer(bytes)?; - if int < 0 || int >= RESP_MAX_SIZE { - Err(Error::Resp(format!("invalid array length: {}", int)))? - } - - let mut set: Vec = Vec::with_capacity(int as usize); - for _ in 0..int { - // TODO avoid full decoding just to re-encode - let val = self.decode()?; - set.push(val.into()); - } - Ok(Value::Set(set)) - } - // Value::Push - b'>' => { - let int = parse_integer(bytes)?; - if int < 0 || int >= RESP_MAX_SIZE { - Err(Error::Resp(format!("invalid array length: {}", int)))? - } - - let mut push: Vec = Vec::with_capacity(int as usize); - for _ in 0..int { - // TODO avoid full decoding just to re-encode - let val = self.decode()?; - push.push(val.into()); - } - Ok(Value::Push(push)) - } - - prefix => Err(Error::Resp(format!("invalid RESP type: {:?}", prefix))), - } - } -} - -#[inline] -fn is_crlf(a: u8, b: u8) -> bool { - a == b'\r' && b == b'\n' -} - -#[inline] -fn parse_string(bytes: &[u8]) -> Result { - String::from_utf8(bytes.to_vec()).map_err(|err| Error::Resp(err.to_string())) -} - -#[inline] -fn parse_integer(bytes: &[u8]) -> Result { - let str_integer = parse_string(bytes)?; - (str_integer.parse::()).map_err(|err| Error::Resp(err.to_string())) -} - -#[inline] -fn parse_double(bytes: &[u8]) -> Result { - let str_double = parse_string(bytes)?; - (str_double.parse::()).map_err(|err| Error::Resp(err.to_string())) -} diff --git a/components/sample-http-incrementor/Cargo.toml b/components/sample-http-incrementor/Cargo.toml index 4bda6c8..d93f7e9 100644 --- a/components/sample-http-incrementor/Cargo.toml +++ b/components/sample-http-incrementor/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "sample-http-incrementor" version = "0.1.0" -edition = "2021" +edition = "2024" license = "Apache-2.0" [lib] diff --git a/components/sample-http-incrementor/README.md b/components/sample-http-incrementor/README.md index edcb89b..ba351fc 100644 --- a/components/sample-http-incrementor/README.md +++ b/components/sample-http-incrementor/README.md @@ -1,6 +1,6 @@ # Sample HTTP incrementor -Sample component that exposes wasi:http incrementing a counter based on the http path. +Sample component that exposes `wasi:http` incrementing a counter based on the http path. Compose with the valkey client: @@ -12,7 +12,7 @@ wac plug sample-http-incrementor.wasm --plug client.wasm -o valkey-http-incremen Start a Valkey server: ```sh -docker run --rm -p 6379:6379 valkey/valkey:8 +docker run --rm -p 6379:6379 valkey/valkey:9 ``` Start the incrementor: @@ -21,6 +21,8 @@ Start the incrementor: wasmtime serve -Sconfig -Sinherit-network -Sallow-ip-name-lookup -Scli valkey-http-incrementor.wasm ``` +The client connects to `127.0.0.1:6379` unless configured otherwise, e.g. `-Sconfig-var=hostname=valkey.example.com -Sconfig-var=port=6380`, see [`tcp-connector`](../tcp-connector/) for the settings, and [`as-keyvalue`](../as-keyvalue/) for `key-prefix`. + Increment all the things: ```sh diff --git a/components/sample-http-incrementor/src/lib.rs b/components/sample-http-incrementor/src/lib.rs index e2efb03..7d74522 100644 --- a/components/sample-http-incrementor/src/lib.rs +++ b/components/sample-http-incrementor/src/lib.rs @@ -1,5 +1,3 @@ -#![no_main] - use exports::wasi::http::handler::Guest; use wasi::http::types::{ErrorCode, Fields, Request, Response}; use wasmcloud::keyvalue::{atomics, store, types::Error}; diff --git a/components/tcp-connector-constants/README.md b/components/tcp-connector-constants/README.md new file mode 100644 index 0000000..ab93883 --- /dev/null +++ b/components/tcp-connector-constants/README.md @@ -0,0 +1,7 @@ +# tcp-connector-constants + +The keys of the `tcp-connector` config and their defaults, as constants. + +Exports `componentized:valkey/tcp-connector-constants`, for the [`tcp-connector`](../tcp-connector/) and the components that derive their config from it, like [`internal-tcp-connector-lookup-config`](../internal-tcp-connector-lookup-config/) and [`internal-tcp-connector-egress-config`](../internal-tcp-connector-egress-config/). + +Generated by [constants](https://github.com/componentized/constants) from the `@value` of each function in [`tcp-connector-constants.wit`](../../wit/tcp-connector-constants.wit). diff --git a/components/tcp-connector-constants/wit/tcp-connector-constants.constants.wit b/components/tcp-connector-constants/wit/tcp-connector-constants.constants.wit new file mode 100644 index 0000000..7fd9d2d --- /dev/null +++ b/components/tcp-connector-constants/wit/tcp-connector-constants.constants.wit @@ -0,0 +1,5 @@ +package componentized:valkey-constants; + +world tcp-connector-constants { + export componentized:valkey/tcp-connector-constants@0.3.0-dev; +} diff --git a/components/tcp-connector-constants/wkg.lock b/components/tcp-connector-constants/wkg.lock new file mode 100644 index 0000000..2ad4aa8 --- /dev/null +++ b/components/tcp-connector-constants/wkg.lock @@ -0,0 +1,4 @@ +# This file is automatically generated. +# It is not intended for manual editing. +version = 1 +packages = [] diff --git a/components/tcp-connector-constants/wkg.toml b/components/tcp-connector-constants/wkg.toml new file mode 100644 index 0000000..d9669bf --- /dev/null +++ b/components/tcp-connector-constants/wkg.toml @@ -0,0 +1,2 @@ +[overrides] +"componentized:valkey" = { path = "../../wit" } diff --git a/components/tcp-connector-latch/README.md b/components/tcp-connector-latch/README.md new file mode 100644 index 0000000..52c4763 --- /dev/null +++ b/components/tcp-connector-latch/README.md @@ -0,0 +1,51 @@ +# tcp-connector-latch + +A `componentized:sockets` latch limiting network access to the Valkey server in the `tcp-connector` config, denying every other socket. + +A [componentized:sockets](https://github.com/componentized/sockets) [`gate`](../dep-componentized-sockets-gate/) asks the latch before each socket operation, and denies the operation if the latch does. The connector's config is read with wasi:config, see [`tcp-connector`](../tcp-connector/). + +Put the components that connect with the `tcp-connector` behind a gate with this latch, e.g. the [`tcp`](../tcp/) component. They can connect to the configured `hostname`, on the configured `port`, over TCP, and nothing else. + +The latch is a [`latch-n5`](../dep-componentized-sockets-latch-n5/), which denies an operation if any of these latches denies it: + +| Latch | Denies | +| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| [`latch-deny-udp`](../dep-componentized-sockets-latch-deny-udp/) | all UDP, Valkey only uses TCP | +| [`latch-deny-bind`](../dep-componentized-sockets-latch-deny-bind/) | binding a socket to a local address, e.g. to listen for incoming connections, the client initiates all connections | +| [`latch-deny-connect-unless-lookup-address`](../dep-componentized-sockets-latch-deny-connect-unless-lookup-address/) | connections to an IP address, unless an allowed lookup returned it | +| [`latch-ip-name-lookup-glob`](../dep-componentized-sockets-latch-ip-name-lookup-glob/) | name lookups, except for the server's runtime configured `hostname` | +| [`latch-cidr-egress`](../dep-componentized-sockets-latch-cidr-egress/) | connections, except to the server's runtime configured `port` | + +The lookup and egress latches are configured from the connector's own config, with the connector's keys and defaults from [`tcp-connector-constants`](../tcp-connector-constants/): + +- [`internal-tcp-connector-lookup-config`](../internal-tcp-connector-lookup-config/): `hostname=valkey.example.com` becomes `default=deny` and `defer=valkey.example.com` +- [`internal-tcp-connector-egress-config`](../internal-tcp-connector-egress-config/): `port=6380` becomes `default=deny` and `defer=:6380`, any address on that port + +The server is configured in one place, and the latch follows it. + +A `hostname` that is an IP address, like the connector's default of `127.0.0.1`, is allowed too, to that address only. The `tcp` component looks up an IP address as well, wasi:sockets returns it as is, so the lookup of `127.0.0.1` is allowed and vouches for the address `127.0.0.1`. Another address, or a name that resolves to the same address, is still denied. + +The [`cli`](../cli/) is behind a gate with this latch, see also its test, [`test-tcp-connector-latch`](../test-tcp-connector-latch/). + +## Interfaces + +Imports: + +- `wasi:config/store@0.2.0-rc.1`: the connector's config, `hostname` and `port` +- `wasi:logging/logging@0.1.0-draft`: logs an invalid config +- `wasi:sockets/types@0.3.0`: the socket types of the operations being authorized + +Exports: + +- `componentized:sockets/latch@0.1.0`: the latch, for a gate + +The sockets components need the component model's `implements` and `threading` features, `-Wcomponent-model-implements -Wcomponent-model-threading`. + +## Custom latches + +The latches an environment needs and their config can differ. Attempting to use this latch without establishing the connection through the `tcp-connector` will likely fail. + +Client that need to open their own connections should build a custom latch from the same pieces, see [`tcp-connector-latch.wac`](./tcp-connector-latch.wac), and the other [latches](https://github.com/componentized/sockets#latches), or implement one by exporting the `componentized:sockets/latch` interface. For example: + +- `latch-cidr-egress` can also limit the addresses of the server's network, e.g. `defer=10.0.0.0/8:6379`. +- `latch-dry-run` and `latch-trace` let you roll out a latch before enforcing it. diff --git a/components/tcp-connector-latch/tcp-connector-latch.wac b/components/tcp-connector-latch/tcp-connector-latch.wac new file mode 100644 index 0000000..c8c8a7f --- /dev/null +++ b/components/tcp-connector-latch/tcp-connector-latch.wac @@ -0,0 +1,49 @@ +package componentized:valkey; + +// the latch, denying an operation when any of these latches does, wasi:logging and wasi:config +// are imported + +// the keys of the tcp-connector's config, and their defaults +let tcp-connector-constants = new local:tcp-connector-constants { + ... +}; +let lookup-config = new local:internal-tcp-connector-lookup-config { + "componentized:valkey/tcp-connector-constants@0.3.0-dev": tcp-connector-constants["componentized:valkey/tcp-connector-constants@0.3.0-dev"], + ... +}; +// lookups are denied, except for the server's hostname +let lookup-glob = new local:dep-componentized-sockets-latch-ip-name-lookup-glob { + "wasi:config/store@0.2.0-rc.1": lookup-config["wasi:config/store@0.2.0-rc.1"], + ... +}; +// connections are denied, except to the addresses of an allowed lookup +let connect = new local:dep-componentized-sockets-latch-deny-connect-unless-lookup-address { + ... +}; +let egress-config = new local:internal-tcp-connector-egress-config { + "componentized:valkey/tcp-connector-constants@0.3.0-dev": tcp-connector-constants["componentized:valkey/tcp-connector-constants@0.3.0-dev"], + ... +}; +// outbound traffic is denied, except to the server's port +let cidr-egress = new local:dep-componentized-sockets-latch-cidr-egress { + "wasi:config/store@0.2.0-rc.1": egress-config["wasi:config/store@0.2.0-rc.1"], + ... +}; +let udp = new local:dep-componentized-sockets-latch-deny-udp { + ... +}; +let bind = new local:dep-componentized-sockets-latch-deny-bind { + ... +}; +// asked in order until one denies, so the cheapest first, by component size as a proxy for the +// cost of asking, each still observes every final decision +let latch = new local:dep-componentized-sockets-latch-n5 { + latch0: udp["componentized:sockets/latch@0.1.0"], + latch1: bind["componentized:sockets/latch@0.1.0"], + latch2: connect["componentized:sockets/latch@0.1.0"], + latch3: lookup-glob["componentized:sockets/latch@0.1.0"], + latch4: cidr-egress["componentized:sockets/latch@0.1.0"], + ... +}; + +export latch["componentized:sockets/latch@0.1.0"]; diff --git a/components/tcp-connector/Cargo.toml b/components/tcp-connector/Cargo.toml new file mode 100644 index 0000000..8bc4138 --- /dev/null +++ b/components/tcp-connector/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tcp-connector" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +componentized-valkey = { workspace = true } +wit-bindgen = { workspace = true } + +[dev-dependencies] +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/tcp-connector/README.md b/components/tcp-connector/README.md new file mode 100644 index 0000000..fe35b13 --- /dev/null +++ b/components/tcp-connector/README.md @@ -0,0 +1,16 @@ +# tcp-connector + +A connector for connections over TCP, to the server configured with `wasi:config`. + +| Key | Default | | +| --- | --- | --- | +| `hostname` | `127.0.0.1` | hostname or IP address of the server | +| `port` | `6379` | port of the server | +| `username` | `default` | user to authenticate as, with `password` | +| `password` | | authenticates when set | +| `client-name` | | name of the connection, see `CLIENT SETNAME` | +| `keep-alive-interval-ms` | | sends `PING` after this many milliseconds idle, disabled when unset or `0` | + +The keys and their defaults are constants, from [`tcp-connector-constants`](../tcp-connector-constants/), for the components that derive their config from the connector's. + +Compose with [`tcp`](../tcp/), e.g. to provide connections to [`as-keyvalue`](../as-keyvalue/). diff --git a/components/tcp-connector/src/lib.rs b/components/tcp-connector/src/lib.rs new file mode 100644 index 0000000..da198fd --- /dev/null +++ b/components/tcp-connector/src/lib.rs @@ -0,0 +1,75 @@ +//! A connector for connections over TCP, to the server configured with wasi:config. + +// the test harness provides a main +#![cfg_attr(not(test), no_main)] + +use componentized::valkey::connection::{ConnectOpts, Connection, DurationMilliseconds, ErrorCode}; +use componentized::valkey::tcp; +// the keys of the config and their defaults +use componentized::valkey::tcp_connector_constants as keys; +use exports::componentized::valkey::connector::Guest; +use wasi::config::store as config; + +struct Component; + +impl Guest for Component { + async fn connect() -> Result { + let hostname = get(&keys::hostname_key())?.unwrap_or_else(keys::hostname_default); + let port = match get(&keys::port_key())? { + Some(port) => port.parse().map_err(|_| { + ErrorCode::Client(format!( + "{} must be a port number: {port}", + keys::port_key() + )) + })?, + None => keys::port_default(), + }; + let auth = match get(&keys::password_key())? { + Some(password) => { + let username = get(&keys::username_key())?.unwrap_or_else(keys::username_default); + Some((username, password)) + } + None => None, + }; + let keep_alive_interval = match get(&keys::keep_alive_interval_ms_key())? { + Some(interval) => { + let px = interval.parse().map_err(|_| { + ErrorCode::Client(format!( + "{} must be a number of milliseconds: {interval}", + keys::keep_alive_interval_ms_key() + )) + })?; + Some(DurationMilliseconds { px }) + } + None => None, + }; + let opts = ConnectOpts { + auth, + client_name: get(&keys::client_name_key())?, + keep_alive_interval, + }; + tcp::connect(hostname, port, Some(opts)).await + } +} + +fn get(key: &str) -> Result, ErrorCode> { + config::get(key).map_err(|error| match error { + config::Error::Upstream(message) => { + ErrorCode::Client(format!("config upstream: {message}")) + } + config::Error::Io(message) => ErrorCode::Client(format!("config io: {message}")), + }) +} + +wit_bindgen::generate!({ + // `path` is relative to the manifest, `components/tcp-connector/` + path: "../wit", + world: "tcp-connector", + // the resp types shared by every component + with: { + "componentized:valkey/types@0.3.0-dev": componentized_valkey::types, + }, + generate_all, +}); + +export!(Component); diff --git a/components/tcp-connector/tests/connector.rs b/components/tcp-connector/tests/connector.rs new file mode 100644 index 0000000..e04fc8b --- /dev/null +++ b/components/tcp-connector/tests/connector.rs @@ -0,0 +1,117 @@ +//! Tests for the `tcp-connector` component, composed with `tcp` and `connection` as `test-tcp`, +//! configured with `wasi:config`, against a server played by the test over TCP. + +use test_harness::{Client, ErrorCode, Harness, Server, command, hello_reply, ok, setinfo}; + +/// Answer the handshake of a connection, expecting `hello`. +async fn handshake(client: &mut Client, hello: &[&str]) { + assert_eq!(client.request().await, Some(command(hello))); + for setinfo in setinfo() { + assert_eq!(client.request().await, Some(setinfo)); + } + client.reply(hello_reply()).await; + for _ in setinfo() { + client.reply(ok()).await; + } +} + +/// Connect with the configuration, answering the handshake with `hello` expected. +async fn connects_with(config: &[(&str, &str)], hello: &[&str]) -> wasmtime::Result<()> { + let server = Server::bind().await?; + let port = server.port().to_string(); + let mut config = config.to_vec(); + config.push(("port", &port)); + let mut harness = Harness::with_config("test-tcp", &config).await?; + harness + .run(async |accessor, exports| { + let connect = exports.connector().call_connect(accessor); + let serve = async { + let mut client = server.accept().await.expect("accept"); + handshake(&mut client, hello).await; + client + }; + let (connection, _client) = tokio::join!(connect, serve); + assert!(connection?.is_ok()); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn connects_to_the_configured_server() -> wasmtime::Result<()> { + connects_with(&[("hostname", "localhost")], &["HELLO", "3"]).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn authenticates_and_names_the_connection() -> wasmtime::Result<()> { + let config = [ + ("username", "user"), + ("password", "secret"), + ("client-name", "tests"), + ]; + let hello = ["HELLO", "3", "AUTH", "user", "secret", "SETNAME", "tests"]; + connects_with(&config, &hello).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_password_alone_is_for_the_default_user() -> wasmtime::Result<()> { + let hello = ["HELLO", "3", "AUTH", "default", "secret"]; + connects_with(&[("password", "secret")], &hello).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn keep_alive_pings_an_idle_connection() -> wasmtime::Result<()> { + let server = Server::bind().await?; + let port = server.port().to_string(); + let config = [("port", port.as_str()), ("keep-alive-interval-ms", "20")]; + let mut harness = Harness::with_config("test-tcp", &config).await?; + harness + .run(async |accessor, exports| { + let connect = exports.connector().call_connect(accessor); + let serve = async { + let mut client = server.accept().await.expect("accept"); + handshake(&mut client, &["HELLO", "3"]).await; + client + }; + let (connection, mut client) = tokio::join!(connect, serve); + assert!(connection?.is_ok()); + assert_eq!(client.request().await, Some(command(&["PING"]))); + Ok(()) + }) + .await +} + +/// The error connecting with an invalid configuration, nothing is sent to a server. +async fn fails_with(config: &[(&str, &str)]) -> wasmtime::Result { + let mut harness = Harness::with_config("test-tcp", config).await?; + harness + .run(async |accessor, exports| { + let result = exports.connector().call_connect(accessor).await?; + Ok(result.err().expect("an error")) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalid_configuration_fails_to_connect() -> wasmtime::Result<()> { + assert_eq!( + fails_with(&[("port", "http")]).await?, + ErrorCode::Client("port must be a port number: http".to_string()) + ); + assert_eq!( + fails_with(&[("keep-alive-interval-ms", "soon")]).await?, + ErrorCode::Client( + "keep-alive-interval-ms must be a number of milliseconds: soon".to_string() + ) + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn unreachable_servers_fail_to_connect() -> wasmtime::Result<()> { + // a port nothing listens on + let port = Server::bind().await?.port().to_string(); + let error = fails_with(&[("port", &port)]).await?; + assert!(matches!(error, ErrorCode::Closed(Some(_))), "{error:?}"); + Ok(()) +} diff --git a/components/tcp/Cargo.toml b/components/tcp/Cargo.toml new file mode 100644 index 0000000..5fc71bd --- /dev/null +++ b/components/tcp/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "tcp" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +futures-channel = { workspace = true } +futures-core = { workspace = true } +componentized-valkey = { workspace = true } +wit-bindgen = { workspace = true, features = ["async-spawn", "inter-task-wakeup"] } + +[dev-dependencies] +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/tcp/README.md b/components/tcp/README.md new file mode 100644 index 0000000..7ef56dc --- /dev/null +++ b/components/tcp/README.md @@ -0,0 +1,7 @@ +# tcp + +Connections to a Valkey server over `wasi:sockets` TCP. + +Frames the bytes the socket carries into RESP values in each direction, and establishes a [`connection`](../connection/) over them. Compose with `connection`. + +The host is always looked up with wasi:sockets/ip-name-lookup, an IP address too, which wasi:sockets returns as is. So each address a socket connects to was returned by a lookup, e.g. for the [`tcp-connector-latch`](../tcp-connector-latch/). diff --git a/components/tcp/src/lib.rs b/components/tcp/src/lib.rs new file mode 100644 index 0000000..3a971e3 --- /dev/null +++ b/components/tcp/src/lib.rs @@ -0,0 +1,293 @@ +//! Connections to a Valkey server over wasi:sockets TCP. +//! +//! Opening a transport connects the socket and starts two background tasks, in the task of the +//! call that opened it: +//! +//! - the receive pump frames the bytes the socket receives into RESP values +//! - the send pump encodes the RESP values handed to `send` into bytes for the socket +//! +//! `send` and `receive` are sync methods, which have no task of their own to start background +//! work in, so they only hand over the streams the pumps already work with, and leave resolving +//! the futures of repeated calls to a third task. The pumps keep the socket open until both +//! directions are done, so a connection keeps its transport alive. + +// the test harness provides a main +#![cfg_attr(not(test), no_main)] + +use std::cell::RefCell; +use std::future::poll_fn; +use std::pin::Pin; +use std::rc::Rc; + +use componentized::valkey::connection::{ConnectOpts, Connection, ErrorCode}; +use componentized_valkey::types::RespValue; +use exports::componentized::valkey::tcp::{Guest, GuestTransport, Transport}; +use futures_channel::{mpsc, oneshot}; +use futures_core::Stream; +use wasi::sockets::ip_name_lookup::{ErrorCode as LookupErrorCode, resolve_addresses}; +use wasi::sockets::types::{ + ErrorCode as SocketErrorCode, IpAddress, IpAddressFamily, IpSocketAddress, Ipv4SocketAddress, + Ipv6SocketAddress, TcpSocket, +}; +use wit_bindgen::{ + FutureReader, FutureWriter, StreamReader, StreamResult, StreamWriter, spawn_local, +}; + +/// How many bytes to read from the socket at a time. +const READ_SIZE: usize = 4096; + +struct Component; + +impl Guest for Component { + type Transport = TransportImpl; + + async fn connect( + host: String, + port: u16, + opts: Option, + ) -> Result { + let transport = open(&host, port).await?; + let (replies, closed) = transport.receive(); + let (requests, connection) = Connection::new(replies, closed, opts).await; + // the connection resolves with the send pump's errors, the result of sending is not needed + let _sent = transport.send(requests); + connection.await + } +} + +/// The streams of an open socket, handed over once each. +pub struct TransportImpl { + requests: RefCell< + Option<( + oneshot::Sender>, + FutureReader>, + )>, + >, + replies: RefCell, FutureReader>)>>, + /// Futures for the resolver to fail, a sync method can't write them itself. + failures: mpsc::UnboundedSender<(FutureWriter>, &'static str)>, +} + +impl TransportImpl { + /// A future the resolver fails with `message`. + fn failed(&self, message: &'static str) -> FutureReader> { + let (writer, reader) = wit_future::new(|| Err(ErrorCode::Closed(None))); + // a transport whose resolver stopped drops the writer, resolving it to the default + let _ = self.failures.unbounded_send((writer, message)); + reader + } +} + +impl GuestTransport for TransportImpl { + async fn open(host: String, port: u16) -> Result { + Ok(Transport::new(open(&host, port).await?)) + } + + fn send(&self, requests: StreamReader) -> FutureReader> { + match self.requests.borrow_mut().take() { + Some((pump, sent)) => { + // the pump only stops waiting for the requests once the socket is closed + let _ = pump.send(requests); + sent + } + None => self.failed("send may be called at most once"), + } + } + + fn receive(&self) -> (StreamReader, FutureReader>) { + self.replies.borrow_mut().take().unwrap_or_else(|| { + let (_, replies) = wit_stream::new(); + (replies, self.failed("receive may be called at most once")) + }) + } +} + +/// Fail each future handed over, until the transport is dropped. +async fn resolve_failures( + mut failures: mpsc::UnboundedReceiver<(FutureWriter>, &'static str)>, +) { + while let Some((writer, message)) = poll_fn(|cx| Pin::new(&mut failures).poll_next(cx)).await { + let _ = writer + .write(Err(ErrorCode::Client(message.to_string()))) + .await; + } +} + +/// Connect a socket to `host` and start its pumps. +async fn open(host: &str, port: u16) -> Result { + let socket = Rc::new(connect_socket(host, port).await?); + + let (bytes_out, bytes_out_reader) = wit_stream::new(); + let socket_sent = socket.send(bytes_out_reader); + let (bytes_in, socket_received) = socket.receive(); + + let (replies_writer, replies) = wit_stream::new(); + // the defaults are only written if a pump stops without a result, the socket closed under it + let (closed_writer, closed) = wit_future::new(|| Err(ErrorCode::Closed(None))); + let (sent_writer, sent) = wit_future::new(|| Err(ErrorCode::Closed(None))); + let (requests_tx, requests_rx) = oneshot::channel(); + let (failures_tx, failures_rx) = mpsc::unbounded(); + + spawn_local(receive_pump( + socket.clone(), + bytes_in, + socket_received, + replies_writer, + closed_writer, + )); + spawn_local(send_pump( + socket, + requests_rx, + bytes_out, + socket_sent, + sent_writer, + )); + spawn_local(resolve_failures(failures_rx)); + + Ok(TransportImpl { + requests: RefCell::new(Some((requests_tx, sent))), + replies: RefCell::new(Some((replies, closed))), + failures: failures_tx, + }) +} + +/// Frame the bytes the socket receives into values, until the socket closes or the reader of the +/// values is gone. +async fn receive_pump( + _socket: Rc, + mut bytes: StreamReader, + received: FutureReader>, + mut replies: StreamWriter, + closed: FutureWriter>, +) { + // bytes read from the socket that are not part of a whole value yet + let mut buffered: Vec = Vec::new(); + let result = 'pump: loop { + // hand over each whole value buffered, a value may span several reads, then drop them from + // the buffer at once + let mut start = 0; + loop { + match componentized_valkey::resp::frame_len(&buffered[start..]) { + Ok(Some(len)) => { + let frame = &buffered[start..start + len]; + start += len; + match componentized_valkey::resp::decode(frame) { + Ok(value) => { + if replies.write_one(value).await.is_some() { + // nobody reads the values anymore + break 'pump Ok(()); + } + } + Err(error) => break 'pump Err(ErrorCode::Resp(error)), + } + } + Ok(None) => break, + Err(error) => break 'pump Err(ErrorCode::Resp(error)), + } + } + buffered.drain(..start); + match bytes.read(Vec::with_capacity(READ_SIZE)).await { + (StreamResult::Complete(_), chunk) => buffered.extend_from_slice(&chunk), + (StreamResult::Dropped | StreamResult::Cancelled, _) => { + // the socket closed, its future says whether it closed gracefully + break received.await.map_err(socket_error); + } + } + }; + // closing the values ends the connection's reader, which then waits for the result + drop(replies); + let _ = closed.write(result).await; +} + +/// Encode the values handed to `send` into bytes for the socket, until the values end, which +/// half-closes the socket. +async fn send_pump( + _socket: Rc, + requests: oneshot::Receiver>, + mut bytes: StreamWriter, + socket_sent: FutureReader>, + sent: FutureWriter>, +) { + // a transport dropped without sending half-closes the socket + let Ok(mut requests) = requests.await else { + return; + }; + while let Some(value) = requests.next().await { + if !bytes + .write_all(componentized_valkey::resp::encode(value)) + .await + .is_empty() + { + // the socket stopped sending + break; + } + } + drop(bytes); + let result = socket_sent.await.map_err(socket_error); + let _ = sent.write(result).await; +} + +/// Connect a socket to the first address of `host` that accepts it. +async fn connect_socket(host: &str, port: u16) -> Result { + let mut last_error = None; + for address in resolve(host, port).await? { + let family = match address { + IpSocketAddress::Ipv4(_) => IpAddressFamily::Ipv4, + IpSocketAddress::Ipv6(_) => IpAddressFamily::Ipv6, + }; + let socket = TcpSocket::create(family).map_err(socket_error)?; + match socket.connect(address).await { + Ok(()) => return Ok(socket), + Err(error) => last_error = Some(error), + } + } + let reason = match last_error { + Some(error) => format!("unable to connect to {host}:{port}: {error:?}"), + None => format!("unable to connect to {host}:{port}: no addresses"), + }; + Err(ErrorCode::Closed(Some(reason))) +} + +/// The socket addresses of `host`, an IP address or a hostname. +/// +/// An IP address is resolved too, wasi:sockets returns it as is, so the lookup vouches for each +/// address a socket connects to, e.g. for a gate that only allows connecting to looked up +/// addresses. +async fn resolve(host: &str, port: u16) -> Result, ErrorCode> { + let addresses = resolve_addresses(host.to_string()) + .await + .map_err(|error| lookup_error(host, error))?; + Ok(addresses + .into_iter() + .map(|address| match address { + IpAddress::Ipv4(address) => IpSocketAddress::Ipv4(Ipv4SocketAddress { port, address }), + IpAddress::Ipv6(address) => IpSocketAddress::Ipv6(Ipv6SocketAddress { + port, + flow_info: 0, + address, + scope_id: 0, + }), + }) + .collect()) +} + +fn socket_error(error: SocketErrorCode) -> ErrorCode { + ErrorCode::Closed(Some(format!("{error:?}"))) +} + +fn lookup_error(host: &str, error: LookupErrorCode) -> ErrorCode { + ErrorCode::Closed(Some(format!("unable to resolve {host}: {error:?}"))) +} + +wit_bindgen::generate!({ + // `path` is relative to the manifest, `components/tcp/` + path: "../wit", + world: "tcp", + // the resp types shared by every component + with: { + "componentized:valkey/types@0.3.0-dev": componentized_valkey::types, + }, + generate_all, +}); + +export!(Component); diff --git a/components/tcp/tests/tcp.rs b/components/tcp/tests/tcp.rs new file mode 100644 index 0000000..a3d5345 --- /dev/null +++ b/components/tcp/tests/tcp.rs @@ -0,0 +1,269 @@ +//! Tests for the `tcp` component, composed with `connection` as `test-tcp`, against a server +//! played by the test over TCP. + +use test_harness::{ + Client, Ctx, ErrorCode, Exports, Harness, RespValue, Server, bulk, command, hello_reply, ok, + resolve, setinfo, words, +}; +use wasmtime::component::{Accessor, ResourceAny}; + +/// Answer the handshake of a connection: HELLO, then the CLIENT SETINFO commands. +async fn handshake(client: &mut Client) { + assert_eq!(client.request().await, Some(command(&["HELLO", "3"]))); + for setinfo in setinfo() { + assert_eq!(client.request().await, Some(setinfo)); + } + client.reply(hello_reply()).await; + for _ in setinfo() { + client.reply(ok()).await; + } +} + +/// Connect to the server and answer the handshake, returning the server's side and the connection. +async fn establish( + accessor: &Accessor, + exports: &Exports, + server: &Server, + host: &str, +) -> wasmtime::Result<(Client, ResourceAny)> { + let connect = exports + .tcp() + .call_connect(accessor, host.to_string(), server.port(), None); + let serve = async { + let mut client = server.accept().await.expect("accept"); + handshake(&mut client).await; + client + }; + let (connection, client) = tokio::join!(connect, serve); + let connection = connection?.map_err(|error| wasmtime::format_err!("{error:?}"))?; + Ok((client, connection)) +} + +#[tokio::test(flavor = "multi_thread")] +async fn connects_and_exchanges() -> wasmtime::Result<()> { + let server = Server::bind().await?; + let mut harness = Harness::new("test-tcp").await?; + harness + .run(async |accessor, exports| { + let (mut client, connection) = + establish(accessor, exports, &server, "127.0.0.1").await?; + let guest = exports.connection(); + let call = guest.call_exchange(accessor, connection, words(&["PING"])); + let serve = async { + assert_eq!(client.request().await, Some(command(&["PING"]))); + client.reply(RespValue::String("PONG".to_string())).await; + }; + let (result, ()) = tokio::join!(call, serve); + assert_eq!(result?, Ok(RespValue::String("PONG".to_string()))); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn resolves_hostnames() -> wasmtime::Result<()> { + let server = Server::bind().await?; + let mut harness = Harness::new("test-tcp").await?; + harness + .run(async |accessor, exports| { + // localhost may resolve to ::1 first, which nothing listens on, the next address is tried + establish(accessor, exports, &server, "localhost").await?; + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn replies_may_span_many_reads() -> wasmtime::Result<()> { + let server = Server::bind().await?; + let mut harness = Harness::new("test-tcp").await?; + harness + .run(async |accessor, exports| { + let (mut client, connection) = + establish(accessor, exports, &server, "127.0.0.1").await?; + let value: String = (0..200_000) + .map(|n| char::from(b'a' + (n % 26) as u8)) + .collect(); + let guest = exports.connection(); + let call = guest.call_exchange(accessor, connection, words(&["GET", "big"])); + let serve = async { + client.request().await; + let reply = test_harness::encode(bulk(&value)); + for chunk in reply.chunks(1000) { + client.write(chunk).await; + tokio::task::yield_now().await; + } + }; + let (result, ()) = tokio::join!(call, serve); + assert_eq!(result?, Ok(bulk(&value))); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn replies_may_share_a_read() -> wasmtime::Result<()> { + let server = Server::bind().await?; + let mut harness = Harness::new("test-tcp").await?; + harness + .run(async |accessor, exports| { + let (mut client, connection) = + establish(accessor, exports, &server, "127.0.0.1").await?; + let guest = exports.connection(); + let first = guest.call_exchange(accessor, connection, words(&["GET", "a"])); + let second = guest.call_exchange(accessor, connection, words(&["GET", "b"])); + let serve = async { + client.request().await; + client.request().await; + let mut replies = test_harness::encode(bulk("first")); + replies.extend(test_harness::encode(bulk("second"))); + client.write(&replies).await; + }; + let (first, second, ()) = tokio::join!(first, second, serve); + assert_eq!(first?, Ok(bulk("first"))); + assert_eq!(second?, Ok(bulk("second"))); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn server_closing_fails_exchanges() -> wasmtime::Result<()> { + let server = Server::bind().await?; + let mut harness = Harness::new("test-tcp").await?; + harness + .run(async |accessor, exports| { + let (client, connection) = establish(accessor, exports, &server, "127.0.0.1").await?; + client.close().await; + let result = exports + .connection() + .call_exchange(accessor, connection, words(&["PING"])) + .await?; + assert!( + matches!(result, Err(ErrorCode::Closed(_))), + "expected a closed error, got {result:?}" + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn refused_connections_fail_to_connect() -> wasmtime::Result<()> { + // a port nothing listens on + let port = Server::bind().await?.port(); + let mut harness = Harness::new("test-tcp").await?; + harness + .run(async |accessor, exports| { + let result = exports + .tcp() + .call_connect(accessor, "127.0.0.1".to_string(), port, None) + .await?; + let Err(ErrorCode::Closed(Some(reason))) = result else { + panic!("expected a closed error, got {result:?}"); + }; + assert!( + reason.starts_with("unable to connect to 127.0.0.1"), + "{reason}" + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn transports_wire_to_connections() -> wasmtime::Result<()> { + let server = Server::bind().await?; + let mut harness = Harness::new("test-tcp").await?; + harness + .run(async |accessor, exports| { + let transport = exports.tcp().transport(); + let open = transport.call_open(accessor, "127.0.0.1".to_string(), server.port()); + let (transport_handle, client) = + tokio::join!(open, async { server.accept().await.expect("accept") }); + let transport_handle = + transport_handle?.map_err(|error| wasmtime::format_err!("{error:?}"))?; + let mut client = client; + + let (replies, closed) = transport.call_receive(accessor, transport_handle).await?; + let (requests, connection) = exports + .connection() + .call_new(accessor, replies, closed, None) + .await?; + let sent = transport + .call_send(accessor, transport_handle, requests) + .await?; + drop(sent); + + let serve = handshake(&mut client); + let (connection, ()) = tokio::join!(resolve(accessor, connection), serve); + assert!(connection?.is_ok()); + + // each stream is handed over once + let (_, again) = transport.call_receive(accessor, transport_handle).await?; + assert!(matches!( + resolve(accessor, again).await?, + Err(ErrorCode::Client(_)) + )); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn dropping_the_connection_closes_the_socket() -> wasmtime::Result<()> { + let server = Server::bind().await?; + let mut harness = Harness::new("test-tcp").await?; + let (mut client, connection) = harness + .run(async |accessor, exports| establish(accessor, exports, &server, "127.0.0.1").await) + .await?; + harness.drop_resource(connection).await?; + harness + .run(async |_accessor, _exports| { + assert_eq!(client.request().await, None); + Ok(()) + }) + .await +} + +/// The tests against a Valkey server, ignored unless run with `make test-valkey`, which runs one in +/// a container and only runs the ignored tests in `with_valkey` modules. +mod with_valkey { + use super::*; + + #[tokio::test(flavor = "multi_thread")] + #[ignore = "needs a Valkey server, see make test-valkey"] + async fn against_valkey() -> wasmtime::Result<()> { + let (host, port) = test_harness::valkey_server(); + let mut harness = Harness::new("test-tcp").await?; + harness + .run(async |accessor, exports| { + let connection = exports + .tcp() + .call_connect(accessor, host.clone(), port, None) + .await? + .map_err(|error| wasmtime::format_err!("{error:?}"))?; + let guest = exports.connection(); + let properties = guest.call_server_properties(accessor, connection).await?; + assert!(properties.contains(&("proto".to_string(), RespValue::Integer(3)))); + + let value: String = (0..100_000) + .map(|n| char::from(b'a' + (n % 26) as u8)) + .collect(); + let set = guest + .call_exchange(accessor, connection, words(&["SET", "tcp-test", &value])) + .await?; + assert_eq!(set, Ok(RespValue::String("OK".to_string()))); + let get = guest + .call_exchange(accessor, connection, words(&["GET", "tcp-test"])) + .await?; + assert_eq!(get, Ok(bulk(&value))); + let del = guest + .call_exchange(accessor, connection, words(&["DEL", "tcp-test"])) + .await?; + assert_eq!(del, Ok(RespValue::Integer(1))); + Ok(()) + }) + .await + } +} diff --git a/components/test-commands-suite/Cargo.toml b/components/test-commands-suite/Cargo.toml new file mode 100644 index 0000000..ca5d8e5 --- /dev/null +++ b/components/test-commands-suite/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "test-commands-suite" +version.workspace = true +edition = "2024" +license = "Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +componentized-valkey = { workspace = true } +wit-bindgen = { workspace = true } diff --git a/components/test-commands-suite/README.md b/components/test-commands-suite/README.md new file mode 100644 index 0000000..4f7f5ee --- /dev/null +++ b/components/test-commands-suite/README.md @@ -0,0 +1,5 @@ +# test-commands-suite + +The tests of the [`commands`](../commands/) component, a component that plays the server over the streams of a [`connection`](../connection/), checking the command each function sends and how it converts the reply. Composed as [`test-commands`](../test-commands/), and run by `cargo test -p commands`. + +Tests named `with-valkey ...` run against a Valkey server on localhost:6379, with `make test-valkey`. diff --git a/components/test-commands-suite/src/lib.rs b/components/test-commands-suite/src/lib.rs new file mode 100644 index 0000000..9082069 --- /dev/null +++ b/components/test-commands-suite/src/lib.rs @@ -0,0 +1,950 @@ +//! The tests of the `commands` component, run inside a component by the test harness. +//! +//! Each test plays the server over the streams of a connection it establishes with +//! `connection.new`: it reads the command a function sends, answers it, and checks how the function +//! converts the reply. The tests named `with-valkey ...` run against a Valkey server instead, from +//! the connector. + +use std::future::{Future, poll_fn}; +use std::pin::pin; +use std::task::Poll; + +use componentized::valkey::commands; +use componentized::valkey::connection::{Connection, ErrorCode}; +use componentized::valkey::connector; +use componentized_valkey::types::{ + AclRule, AclUserRule, Bytes, Cursor, DurationMilliseconds, DurationSeconds, + FunctionRestorePolicy, HexpireResult, RespValue, RestoreTtl, StringOrBytes, TtlMode, + UnixTimeMilliseconds, UnixTimeSeconds, +}; +use exports::componentized::valkey_components::test_suite::Guest; +use wit_bindgen::rt::Map; +use wit_bindgen::{FutureWriter, StreamReader, StreamWriter}; + +struct Suite; + +/// The tests, by name, and the function running each. +macro_rules! suite { + ($($name:literal => $test:ident),* $(,)?) => { + const TESTS: &[&str] = &[$($name),*]; + + async fn run_test(name: &str) -> Result<(), String> { + match name { + $($name => $test().await,)* + _ => Err(format!("no test named {name:?}")), + } + } + }; +} + +suite! { + "string commands" => string_commands, + "keyspace commands" => keyspace_commands, + "hash commands" => hash_commands, + "binary values" => binary_values, + "dump and restore commands" => dump_and_restore_commands, + "connection commands" => connection_commands, + "admin and pubsub commands" => admin_and_pubsub_commands, + "invalid arguments fail before anything is sent" => invalid_arguments, + "unexpected replies fail the command" => unexpected_replies, + "error replies fail the command" => error_replies, + "with-valkey commands" => with_valkey, +} + +impl Guest for Suite { + fn tests() -> Vec { + TESTS.iter().map(|name| name.to_string()).collect() + } + + async fn run(name: String) -> Result<(), String> { + run_test(&name).await + } +} + +/// Fails the test unless the values are equal, with where and both values. +macro_rules! check_eq { + ($left:expr, $right:expr $(,)?) => {{ + let (left, right) = (&$left, &$right); + if left != right { + return Err(format!( + "{}:{}: {:?} != {:?}", + file!(), + line!(), + left, + right + )); + } + }}; +} + +/// The words of a command, e.g. `words(&["GET", "key"])`. +fn words(words: &[&str]) -> Vec { + words.iter().map(|word| word.to_string()).collect() +} + +fn bwords(words: &[&str]) -> Vec { + words.iter().map(|word| word.as_bytes().to_vec()).collect() +} + +fn s(value: &str) -> String { + value.to_string() +} + +/// A value that is text. +fn v(value: &str) -> StringOrBytes { + value.into() +} + +/// Bytes that are not UTF-8, a value that is not text. +const BINARY: &[u8] = b"\x80\xff\x00value"; + +fn bulk(value: &str) -> RespValue { + RespValue::BulkString(value.as_bytes().to_vec()) +} + +fn ok() -> RespValue { + RespValue::String("OK".to_string()) +} + +fn array(items: Vec) -> RespValue { + RespValue::Array( + items + .into_iter() + .map(componentized_valkey::resp::encode) + .collect(), + ) +} + +fn map(entries: Vec<(RespValue, RespValue)>) -> RespValue { + RespValue::Map( + entries + .into_iter() + .map(|(key, value)| { + ( + componentized_valkey::resp::encode(key), + componentized_valkey::resp::encode(value), + ) + }) + .collect(), + ) +} + +/// Poll both futures, `a` first each time, until both are ready. +async fn both(a: A, b: B) -> (A::Output, B::Output) { + let mut a = pin!(a); + let mut b = pin!(b); + let (mut a_output, mut b_output) = (None, None); + poll_fn(|cx| { + if a_output.is_none() + && let Poll::Ready(output) = a.as_mut().poll(cx) + { + a_output = Some(output); + } + if b_output.is_none() + && let Poll::Ready(output) = b.as_mut().poll(cx) + { + b_output = Some(output); + } + if a_output.is_some() && b_output.is_some() { + Poll::Ready(()) + } else { + Poll::Pending + } + }) + .await; + (a_output.expect("ready"), b_output.expect("ready")) +} + +/// The server's side of a connection, played by the test. +struct Server { + requests: StreamReader, + replies: StreamWriter, + /// Resolved to `ok` when the server is dropped, the transport closed gracefully. + _closed: FutureWriter>, +} + +impl Server { + /// The words of the next command the connection sent. + async fn request(&mut self) -> Result, String> { + let request = self.requests.next().await; + let words = match request { + Some(RespValue::Array(words)) => words, + Some(request) => return Err(format!("expected a command, got {request}")), + None => return Err("expected a command, the requests closed".to_string()), + }; + words + .into_iter() + .map(|word| match componentized_valkey::resp::decode(&word) { + Ok(RespValue::BulkString(word)) => Ok(word), + Ok(word) => Err(format!("expected a bulk-string, got {word}")), + Err(err) => Err(format!("expected a bulk-string, got {err}")), + }) + .collect() + } + + async fn reply(&mut self, value: RespValue) -> Result<(), String> { + match self.replies.write_one(value).await { + None => Ok(()), + Some(_) => Err("the connection stopped reading".to_string()), + } + } +} + +/// A connection over streams the server is played on, once it answered the handshake. +async fn establish() -> Result<(Server, Connection), String> { + let (replies, replies_reader) = wit_stream::new(); + let (closed, closed_reader) = wit_future::new(|| Ok(())); + let (requests, connection) = Connection::new(replies_reader, closed_reader, None).await; + let mut server = Server { + requests, + replies, + _closed: closed, + }; + check_eq!(server.request().await?, bwords(&["HELLO", "3"])); + let version = env!("CARGO_PKG_VERSION"); + let setinfo = [ + bwords(&["CLIENT", "SETINFO", "LIB-NAME", "componentized:valkey"]), + bwords(&["CLIENT", "SETINFO", "LIB-VER", version]), + ]; + for expected in &setinfo { + check_eq!(&server.request().await?, expected); + } + let hello = map(vec![ + (bulk("server"), bulk("valkey")), + (bulk("version"), bulk("9.1.2")), + (bulk("proto"), RespValue::Integer(3)), + ]); + server.reply(hello).await?; + for _ in &setinfo { + server.reply(ok()).await?; + } + let connection = connection + .await + .map_err(|error| format!("failed to establish: {error:?}"))?; + Ok((server, connection)) +} + +/// Answer the next command with `reply` while `call` waits for it, returning the call's result and +/// the command sent. +async fn round_trip( + server: &mut Server, + reply: RespValue, + call: impl Future, +) -> Result<(T, Vec), String> { + let serve = async { + let request = server.request().await?; + server.reply(reply).await?; + Ok::<_, String>(request) + }; + let (result, request) = both(call, serve).await; + Ok((result, request?)) +} + +async fn string_commands() -> Result<(), String> { + let (mut server, conn) = establish().await?; + + let (result, sent) = + round_trip(&mut server, bulk("value"), commands::get(&conn, s("key"))).await?; + check_eq!(sent, bwords(&["GET", "key"])); + check_eq!(result, Ok(Some(v("value")))); + + let (result, _) = round_trip( + &mut server, + RespValue::Null, + commands::get(&conn, s("missing")), + ) + .await?; + check_eq!(result, Ok(None)); + + let (result, sent) = round_trip( + &mut server, + ok(), + commands::set(&conn, s("key"), v("value"), None, None), + ) + .await?; + check_eq!(sent, bwords(&["SET", "key", "value"])); + check_eq!(result, Ok(true)); + + // null when the precondition is not met + let (result, sent) = round_trip( + &mut server, + RespValue::Null, + commands::set( + &conn, + s("key"), + v("value"), + Some(componentized_valkey::types::SetPrecondition::Nx), + Some(componentized_valkey::types::SetTtl::Ex(DurationSeconds { + ex: 10, + })), + ), + ) + .await?; + check_eq!(sent, bwords(&["SET", "key", "value", "NX", "EX", "10"])); + check_eq!(result, Ok(false)); + + let (result, sent) = round_trip( + &mut server, + RespValue::Integer(1), + commands::incr(&conn, s("count")), + ) + .await?; + check_eq!(sent, bwords(&["INCR", "count"])); + check_eq!(result, Ok(1)); + + let (result, sent) = round_trip( + &mut server, + RespValue::Integer(-4), + commands::incrby(&conn, s("count"), -5), + ) + .await?; + check_eq!(sent, bwords(&["INCRBY", "count", "-5"])); + check_eq!(result, Ok(-4)); + Ok(()) +} + +async fn keyspace_commands() -> Result<(), String> { + let (mut server, conn) = establish().await?; + + let (result, sent) = round_trip( + &mut server, + RespValue::Integer(1), + commands::del(&conn, words(&["a", "b"])), + ) + .await?; + check_eq!(sent, bwords(&["DEL", "a", "b"])); + check_eq!(result, Ok(1)); + + let (result, sent) = round_trip( + &mut server, + RespValue::Integer(0), + commands::exists(&conn, words(&["key"])), + ) + .await?; + check_eq!(sent, bwords(&["EXISTS", "key"])); + check_eq!(result, Ok(0)); + + let (result, sent) = round_trip( + &mut server, + RespValue::Integer(1), + commands::expire( + &conn, + s("key"), + DurationSeconds { ex: 91 }, + Some(TtlMode::Nx), + ), + ) + .await?; + check_eq!(sent, bwords(&["EXPIRE", "key", "91", "NX"])); + check_eq!(result, Ok(true)); + + let (result, sent) = round_trip( + &mut server, + RespValue::Integer(0), + commands::expireat( + &conn, + s("key"), + UnixTimeSeconds { + exat: 1_900_000_000, + }, + None, + ), + ) + .await?; + check_eq!(sent, bwords(&["EXPIREAT", "key", "1900000000"])); + check_eq!(result, Ok(false)); + + let (result, sent) = round_trip( + &mut server, + array(vec![bulk("a"), bulk("b")]), + commands::keys(&conn, s("*")), + ) + .await?; + check_eq!(sent, bwords(&["KEYS", "*"])); + check_eq!(result, Ok(words(&["a", "b"]))); + + // TYPE replies `none` for a key that does not exist + let (result, sent) = round_trip( + &mut server, + RespValue::String(s("none")), + commands::type_(&conn, s("missing")), + ) + .await?; + check_eq!(sent, bwords(&["TYPE", "missing"])); + check_eq!(result, Ok(None)); + Ok(()) +} + +async fn hash_commands() -> Result<(), String> { + let (mut server, conn) = establish().await?; + let key = || s("hash"); + + let (result, sent) = round_trip( + &mut server, + RespValue::Integer(2), + commands::hset(&conn, key(), vec![(s("f"), v("v")), (s("g"), v("w"))]), + ) + .await?; + check_eq!(sent, bwords(&["HSET", "hash", "f", "v", "g", "w"])); + check_eq!(result, Ok(2)); + + let (result, sent) = + round_trip(&mut server, bulk("v"), commands::hget(&conn, key(), s("f"))).await?; + check_eq!(sent, bwords(&["HGET", "hash", "f"])); + check_eq!(result, Ok(Some(v("v")))); + + // RESP3 replies to HGETALL with a map + let (result, sent) = round_trip( + &mut server, + map(vec![(bulk("f"), bulk("v")), (bulk("g"), bulk("w"))]), + commands::hgetall(&conn, key()), + ) + .await?; + check_eq!(sent, bwords(&["HGETALL", "hash"])); + check_eq!(result, Ok(Map::from([(s("f"), v("v")), (s("g"), v("w"))]))); + + let (result, _) = round_trip( + &mut server, + RespValue::Integer(1), + commands::hsetnx(&conn, key(), s("f"), v("v")), + ) + .await?; + check_eq!(result, Ok(true)); + + let (result, _) = round_trip( + &mut server, + RespValue::Integer(0), + commands::hexists(&conn, key(), s("x")), + ) + .await?; + check_eq!(result, Ok(false)); + + let (result, sent) = round_trip( + &mut server, + array(vec![bulk("v"), RespValue::Null]), + commands::hmget(&conn, key(), words(&["f", "x"])), + ) + .await?; + check_eq!(sent, bwords(&["HMGET", "hash", "f", "x"])); + check_eq!(result, Ok(vec![Some(v("v")), None])); + + let (result, sent) = round_trip( + &mut server, + RespValue::Integer(6), + commands::hincrby(&conn, key(), s("n"), 5), + ) + .await?; + check_eq!(sent, bwords(&["HINCRBY", "hash", "n", "5"])); + check_eq!(result, Ok(6)); + + // a double sent as a bulk string, even with RESP3 + let (result, sent) = round_trip( + &mut server, + bulk("2.5"), + commands::hincrbyfloat(&conn, key(), s("n"), 1.5), + ) + .await?; + check_eq!(sent, bwords(&["HINCRBYFLOAT", "hash", "n", "1.5"])); + check_eq!(result, Ok(2.5)); + + let (result, sent) = round_trip( + &mut server, + array(vec![RespValue::Integer(1), RespValue::Integer(-2)]), + commands::hexpire( + &conn, + key(), + DurationSeconds { ex: 60 }, + None, + words(&["f", "x"]), + ), + ) + .await?; + check_eq!( + sent, + bwords(&["HEXPIRE", "hash", "60", "FIELDS", "2", "f", "x"]) + ); + check_eq!( + result, + Ok(vec![HexpireResult::Applied, HexpireResult::DoesNotExist]) + ); + + // without a count, the field itself, or null + let (result, sent) = round_trip( + &mut server, + bulk("f"), + commands::hrandfield(&conn, key(), None), + ) + .await?; + check_eq!(sent, bwords(&["HRANDFIELD", "hash"])); + check_eq!(result, Ok(words(&["f"]))); + + // RESP3 replies with values as [field, value] pairs + let (result, sent) = round_trip( + &mut server, + array(vec![ + array(vec![bulk("f"), bulk("v")]), + array(vec![bulk("g"), bulk("w")]), + ]), + commands::hrandfield_withvalues(&conn, key(), 2), + ) + .await?; + check_eq!(sent, bwords(&["HRANDFIELD", "hash", "2", "WITHVALUES"])); + check_eq!(result, Ok(vec![(s("f"), v("v")), (s("g"), v("w"))])); + + let (result, sent) = round_trip( + &mut server, + array(vec![bulk("17"), array(vec![bulk("f"), bulk("v")])]), + commands::hscan(&conn, key(), Cursor::Start, Some(s("f*")), Some(10)), + ) + .await?; + check_eq!( + sent, + bwords(&["HSCAN", "hash", "0", "MATCH", "f*", "COUNT", "10"]) + ); + check_eq!( + result, + Ok((Some(Cursor::Resume(s("17"))), Map::from([(s("f"), v("v"))]))) + ); + + // the last page has a cursor of 0 + let (result, sent) = round_trip( + &mut server, + array(vec![bulk("0"), array(vec![bulk("g")])]), + commands::hscan_novalues(&conn, key(), Cursor::Resume(s("17")), None, None), + ) + .await?; + check_eq!(sent, bwords(&["HSCAN", "hash", "17", "NOVALUES"])); + check_eq!(result, Ok((None, words(&["g"])))); + + let (result, sent) = round_trip( + &mut server, + RespValue::Integer(1), + commands::hdel(&conn, key(), words(&["f"])), + ) + .await?; + check_eq!(sent, bwords(&["HDEL", "hash", "f"])); + check_eq!(result, Ok(1)); + Ok(()) +} + +async fn binary_values() -> Result<(), String> { + let (mut server, conn) = establish().await?; + let binary = || StringOrBytes::Bytes(BINARY.to_vec()); + + // sent as is + let (result, sent) = round_trip( + &mut server, + ok(), + commands::set(&conn, s("key"), binary(), None, None), + ) + .await?; + check_eq!( + sent, + vec![b"SET".to_vec(), b"key".to_vec(), BINARY.to_vec()] + ); + check_eq!(result, Ok(true)); + + // a reply that is not UTF-8 is bytes, one that is is a string + let (result, _) = round_trip( + &mut server, + RespValue::BulkString(BINARY.to_vec()), + commands::get(&conn, s("key")), + ) + .await?; + check_eq!(result, Ok(Some(binary()))); + + let (result, _) = round_trip( + &mut server, + RespValue::BulkString(b"text".to_vec()), + commands::get(&conn, s("key")), + ) + .await?; + check_eq!(result, Ok(Some(v("text")))); + + let (result, _) = round_trip( + &mut server, + map(vec![ + (bulk("f"), RespValue::BulkString(BINARY.to_vec())), + (bulk("g"), bulk("w")), + ]), + commands::hgetall(&conn, s("hash")), + ) + .await?; + check_eq!( + result, + Ok(Map::from([(s("f"), binary()), (s("g"), v("w"))])) + ); + + // a field, a string, that is not UTF-8 fails the command + let (result, _) = round_trip( + &mut server, + map(vec![(RespValue::BulkString(BINARY.to_vec()), bulk("v"))]), + commands::hgetall(&conn, s("hash")), + ) + .await?; + check_eq!(result.is_err(), true); + Ok(()) +} + +async fn dump_and_restore_commands() -> Result<(), String> { + let (mut server, conn) = establish().await?; + + let (result, sent) = round_trip( + &mut server, + RespValue::BulkString(BINARY.to_vec()), + commands::dump(&conn, s("key")), + ) + .await?; + check_eq!(sent, bwords(&["DUMP", "key"])); + check_eq!(result, Ok(Some(BINARY.to_vec()))); + + let (result, _) = round_trip( + &mut server, + RespValue::Null, + commands::dump(&conn, s("missing")), + ) + .await?; + check_eq!(result, Ok(None)); + + // without a ttl, 0 + let (result, sent) = round_trip( + &mut server, + ok(), + commands::restore(&conn, s("key"), None, BINARY.to_vec(), false, None, None), + ) + .await?; + check_eq!( + sent, + vec![ + b"RESTORE".to_vec(), + b"key".to_vec(), + b"0".to_vec(), + BINARY.to_vec() + ] + ); + check_eq!(result, Ok(())); + + let (result, sent) = round_trip( + &mut server, + ok(), + commands::restore( + &conn, + s("key"), + Some(RestoreTtl::ExpireIn(DurationMilliseconds { px: 1500 })), + BINARY.to_vec(), + true, + Some(DurationSeconds { ex: 10 }), + Some(5), + ), + ) + .await?; + let mut expected = bwords(&["RESTORE", "key", "1500"]); + expected.push(BINARY.to_vec()); + expected.extend(bwords(&["REPLACE", "IDLETIME", "10", "FREQ", "5"])); + check_eq!(sent, expected); + check_eq!(result, Ok(())); + + // an expiration at a time is ABSTTL + let (result, sent) = round_trip( + &mut server, + ok(), + commands::restore_asking( + &conn, + s("key"), + Some(RestoreTtl::ExpireAt(UnixTimeMilliseconds { + pxat: 1_900_000_000_000, + })), + BINARY.to_vec(), + false, + None, + None, + ), + ) + .await?; + let mut expected = bwords(&["RESTORE-ASKING", "key", "1900000000000"]); + expected.push(BINARY.to_vec()); + expected.extend(bwords(&["ABSTTL"])); + check_eq!(sent, expected); + check_eq!(result, Ok(())); + + let (result, sent) = round_trip( + &mut server, + RespValue::BulkString(BINARY.to_vec()), + commands::function_dump(&conn), + ) + .await?; + check_eq!(sent, bwords(&["FUNCTION", "DUMP"])); + check_eq!(result, Ok(BINARY.to_vec())); + + let (result, sent) = round_trip( + &mut server, + ok(), + commands::function_restore(&conn, BINARY.to_vec(), Some(FunctionRestorePolicy::Replace)), + ) + .await?; + let mut expected = bwords(&["FUNCTION", "RESTORE"]); + expected.push(BINARY.to_vec()); + expected.extend(bwords(&["REPLACE"])); + check_eq!(sent, expected); + check_eq!(result, Ok(())); + Ok(()) +} + +async fn connection_commands() -> Result<(), String> { + let (mut server, conn) = establish().await?; + + let (result, sent) = round_trip( + &mut server, + RespValue::String(s("PONG")), + commands::ping(&conn, None), + ) + .await?; + check_eq!(sent, bwords(&["PING"])); + check_eq!(result, Ok(())); + + let (result, sent) = round_trip( + &mut server, + ok(), + commands::auth(&conn, Some(s("user")), s("secret")), + ) + .await?; + check_eq!(sent, bwords(&["AUTH", "user", "secret"])); + check_eq!(result, Ok(())); + + let (result, sent) = round_trip( + &mut server, + ok(), + commands::client_setname(&conn, s("tests")), + ) + .await?; + check_eq!(sent, bwords(&["CLIENT", "SETNAME", "tests"])); + check_eq!(result, Ok(())); + Ok(()) +} + +async fn admin_and_pubsub_commands() -> Result<(), String> { + let (mut server, conn) = establish().await?; + + let rules = vec![ + AclRule::User(AclUserRule::On), + AclRule::User(AclUserRule::Password(s("secret"))), + ]; + let (result, sent) = round_trip( + &mut server, + ok(), + commands::acl_setuser(&conn, s("user"), Some(rules)), + ) + .await?; + check_eq!(sent, bwords(&["ACL", "SETUSER", "user", "on", ">secret"])); + check_eq!(result, Ok(())); + + let pass = "dd721260bfe1b3d9601e7fbab36de6d04e2e67b0ef1c53de59d45950db0dd3cc"; + let (result, sent) = + round_trip(&mut server, bulk(pass), commands::acl_genpass(&conn, None)).await?; + check_eq!(sent, bwords(&["ACL", "GENPASS"])); + check_eq!(result, Ok(s(pass))); + + let (result, sent) = round_trip( + &mut server, + RespValue::Integer(1), + commands::acl_deluser(&conn, words(&["user"])), + ) + .await?; + check_eq!(sent, bwords(&["ACL", "DELUSER", "user"])); + check_eq!(result, Ok(1)); + + let (result, sent) = round_trip( + &mut server, + RespValue::Integer(2), + commands::publish(&conn, s("news"), v("hello")), + ) + .await?; + check_eq!(sent, bwords(&["PUBLISH", "news", "hello"])); + check_eq!(result, Ok(2)); + Ok(()) +} + +async fn invalid_arguments() -> Result<(), String> { + let (mut server, conn) = establish().await?; + check_eq!( + commands::del(&conn, vec![]).await, + Err(ErrorCode::Client(s("keys requires at least one item"))) + ); + check_eq!( + commands::scan(&conn, Cursor::Resume(s("0")), None, None, None).await, + Err(ErrorCode::Client(s( + "resume requires the cursor a scan returned, use start to begin a scan" + ))) + ); + // nothing was sent, the next command is the first the server sees + let (_, sent) = round_trip( + &mut server, + RespValue::String(s("PONG")), + commands::ping(&conn, None), + ) + .await?; + check_eq!(sent, bwords(&["PING"])); + Ok(()) +} + +async fn unexpected_replies() -> Result<(), String> { + let (mut server, conn) = establish().await?; + let (result, _) = round_trip( + &mut server, + RespValue::Integer(7), + commands::get(&conn, s("key")), + ) + .await?; + check_eq!( + result, + Err(ErrorCode::Client(s("unexpected reply: integer<7>"))) + ); + Ok(()) +} + +async fn error_replies() -> Result<(), String> { + let (mut server, conn) = establish().await?; + let (result, _) = round_trip( + &mut server, + RespValue::Error(s( + "WRONGTYPE Operation against a key holding the wrong kind of value", + )), + commands::incr(&conn, s("key")), + ) + .await?; + check_eq!( + result, + Err(ErrorCode::Server( + "WRONGTYPE Operation against a key holding the wrong kind of value".into() + )) + ); + Ok(()) +} + +/// Against a Valkey server, from the connector. +async fn with_valkey() -> Result<(), String> { + let conn = connector::connect() + .await + .map_err(|error| format!("failed to connect: {error:?}"))?; + let check = |result: Result<(), ErrorCode>| result.map_err(|error| format!("{error:?}")); + let key = || s("commands-test:string"); + + check(commands::ping(&conn, None).await)?; + check_eq!( + commands::set(&conn, key(), v("1"), None, None).await, + Ok(true) + ); + check_eq!(commands::get(&conn, key()).await, Ok(Some(v("1")))); + check_eq!(commands::incr(&conn, key()).await, Ok(2)); + check_eq!(commands::incrby(&conn, key(), 3).await, Ok(5)); + check_eq!(commands::exists(&conn, vec![key()]).await, Ok(1)); + check_eq!( + commands::expire(&conn, key(), DurationSeconds { ex: 60 }, None).await, + Ok(true) + ); + check_eq!( + commands::keys(&conn, s("commands-test:str*")).await, + Ok(vec![key()]) + ); + check_eq!(commands::del(&conn, vec![key()]).await, Ok(1)); + check_eq!(commands::get(&conn, key()).await, Ok(None)); + + let key = || s("commands-test:hash"); + check_eq!( + commands::hset(&conn, key(), vec![(s("f"), v("v")), (s("g"), v("w"))]).await, + Ok(2) + ); + check_eq!( + commands::hgetall(&conn, key()).await, + Ok(Map::from([(s("f"), v("v")), (s("g"), v("w"))])) + ); + check_eq!(commands::hlen(&conn, key()).await, Ok(2)); + check_eq!( + commands::hmget(&conn, key(), words(&["f", "x"])).await, + Ok(vec![Some(v("v")), None]) + ); + let fields = commands::hrandfield_withvalues(&conn, key(), 2) + .await + .map_err(|error| format!("{error:?}"))?; + check_eq!(fields.len(), 2); + check_eq!( + commands::hscan(&conn, key(), Cursor::Start, None, None).await, + Ok((None, Map::from([(s("f"), v("v")), (s("g"), v("w"))]))) + ); + check_eq!( + commands::hincrbyfloat(&conn, key(), s("n"), 1.5).await, + Ok(1.5) + ); + check_eq!( + commands::hexpire( + &conn, + key(), + DurationSeconds { ex: 60 }, + None, + vec!["f".to_string(), "x".to_string()] + ) + .await, + Ok(vec![HexpireResult::Applied, HexpireResult::DoesNotExist]) + ); + check_eq!(commands::del(&conn, vec![key()]).await, Ok(1)); + + // binary values round trip + let key = || s("commands-test:binary"); + let binary = || StringOrBytes::Bytes(BINARY.to_vec()); + check_eq!( + commands::set(&conn, key(), binary(), None, None).await, + Ok(true) + ); + check_eq!(commands::get(&conn, key()).await, Ok(Some(binary()))); + + // the serialized value restores the key, with its value + let serialized = commands::dump(&conn, key()) + .await + .map_err(|error| format!("{error:?}"))? + .ok_or("expected the key to dump")?; + check_eq!( + commands::restore(&conn, key(), None, serialized.clone(), false, None, None) + .await + .is_err(), + true + ); + let ttl = RestoreTtl::ExpireIn(DurationMilliseconds { px: 60_000 }); + check_eq!( + commands::restore(&conn, key(), Some(ttl), serialized, true, None, None).await, + Ok(()) + ); + check_eq!(commands::get(&conn, key()).await, Ok(Some(binary()))); + check_eq!(commands::del(&conn, vec![key()]).await, Ok(1)); + + // the serialized libraries restore, appending none when there are none + let serialized = commands::function_dump(&conn) + .await + .map_err(|error| format!("{error:?}"))?; + check_eq!( + commands::function_restore(&conn, serialized, Some(FunctionRestorePolicy::Append)).await, + Ok(()) + ); + + check_eq!( + commands::publish(&conn, s("commands-test"), v("hello")).await, + Ok(0) + ); + let pass = commands::acl_genpass(&conn, None) + .await + .map_err(|error| format!("{error:?}"))?; + check_eq!(pass.len(), 64); + Ok(()) +} + +wit_bindgen::generate!({ + // `path` is relative to the manifest, `components/test-commands-suite/` + path: "../wit", + world: "test-commands", + // the resp types shared by every component + with: { + "componentized:valkey/types@0.3.0-dev": componentized_valkey::types, + }, + generate_all, +}); + +export!(Suite); diff --git a/components/test-commands/README.md b/components/test-commands/README.md new file mode 100644 index 0000000..94b4eed --- /dev/null +++ b/components/test-commands/README.md @@ -0,0 +1,3 @@ +# test-commands + +[`test-commands-suite`](../test-commands-suite/) composed with [`commands`](../commands/), [`connection`](../connection/), and a [`tcp-connector`](../tcp-connector/) for the tests against a Valkey server, to test `commands`. diff --git a/components/test-commands/test-commands.wac b/components/test-commands/test-commands.wac new file mode 100644 index 0000000..da48b96 --- /dev/null +++ b/components/test-commands/test-commands.wac @@ -0,0 +1,31 @@ +package componentized:valkey; + +let connection = new local:connection { + ... +}; +let commands = new local:commands { + "componentized:valkey/connection@0.3.0-dev": connection.connection, + ... +}; +let tcp = new local:tcp { + "componentized:valkey/connection@0.3.0-dev": connection.connection, + ... +}; +// the keys of the tcp-connector's config, and their defaults +let tcp-connector-constants = new local:tcp-connector-constants { + ... +}; +let connector = new local:tcp-connector { + "componentized:valkey/tcp-connector-constants@0.3.0-dev": tcp-connector-constants["componentized:valkey/tcp-connector-constants@0.3.0-dev"], + "componentized:valkey/connection@0.3.0-dev": connection.connection, + "componentized:valkey/tcp@0.3.0-dev": tcp.tcp, + ... +}; +let tests = new local:test-commands-suite { + "componentized:valkey/connection@0.3.0-dev": connection.connection, + "componentized:valkey/connector@0.3.0-dev": connector.connector, + "componentized:valkey/commands@0.3.0-dev": commands.commands, + ... +}; + +export tests...; diff --git a/components/ops/Cargo.toml b/components/test-stub-sockets/Cargo.toml similarity index 75% rename from components/ops/Cargo.toml rename to components/test-stub-sockets/Cargo.toml index 0925829..89857e9 100644 --- a/components/ops/Cargo.toml +++ b/components/test-stub-sockets/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "ops" +name = "test-stub-sockets" version = "0.1.0" -edition = "2021" +edition = "2024" license = "Apache-2.0" [lib] diff --git a/components/test-stub-sockets/README.md b/components/test-stub-sockets/README.md new file mode 100644 index 0000000..5764fbd --- /dev/null +++ b/components/test-stub-sockets/README.md @@ -0,0 +1,5 @@ +# test-stub-sockets + +`wasi:sockets` without a network, for tests of what a gate allows. + +Every socket operation succeeds, so an error can only come from a gate in front of it. Names resolve to fixed addresses: an IP address to itself, `localhost` to `127.0.0.1` and `::1`, any other name to `192.0.2.1`. Connections carry no data and listening sockets accept none. See [`test-tcp-connector-latch`](../test-tcp-connector-latch/). diff --git a/components/test-stub-sockets/src/lib.rs b/components/test-stub-sockets/src/lib.rs new file mode 100644 index 0000000..178d1ab --- /dev/null +++ b/components/test-stub-sockets/src/lib.rs @@ -0,0 +1,277 @@ +//! wasi:sockets without a network, for tests of what a gate allows: every operation succeeds, so an +//! error can only come from the gate. +//! +//! Names resolve to fixed addresses: an IP address to itself, `localhost` to `127.0.0.1` and `::1`, +//! any other name to `192.0.2.1`, an address for documentation. + +use std::cell::{Cell, RefCell}; +use std::net::IpAddr; + +use exports::wasi::sockets::ip_name_lookup::{self, ErrorCode as LookupErrorCode}; +use exports::wasi::sockets::types::{ + self, Duration, ErrorCode, GuestTcpSocket, GuestUdpSocket, IpAddress, IpAddressFamily, + IpSocketAddress, Ipv4SocketAddress, TcpSocket, UdpSocket, +}; +use wit_bindgen::{FutureReader, StreamReader}; + +/// The address names other than `localhost` resolve to. +const NAME_ADDRESS: IpAddress = IpAddress::Ipv4((192, 0, 2, 1)); + +struct Component; + +impl ip_name_lookup::Guest for Component { + async fn resolve_addresses(name: String) -> Result, LookupErrorCode> { + Ok(match name.parse::() { + Ok(address) => vec![ip_address(address)], + Err(_) if name == "localhost" => vec![ + IpAddress::Ipv4((127, 0, 0, 1)), + IpAddress::Ipv6((0, 0, 0, 0, 0, 0, 0, 1)), + ], + Err(_) => vec![NAME_ADDRESS], + }) + } +} + +fn ip_address(address: IpAddr) -> IpAddress { + match address { + IpAddr::V4(address) => { + let [a, b, c, d] = address.octets(); + IpAddress::Ipv4((a, b, c, d)) + } + IpAddr::V6(address) => { + let [a, b, c, d, e, f, g, h] = address.segments(); + IpAddress::Ipv6((a, b, c, d, e, f, g, h)) + } + } +} + +/// The address of a socket that was not bound, any address and port. +fn unspecified() -> IpSocketAddress { + IpSocketAddress::Ipv4(Ipv4SocketAddress { + port: 0, + address: (0, 0, 0, 0), + }) +} + +impl types::Guest for Component { + type TcpSocket = Tcp; + type UdpSocket = Udp; +} + +/// A socket that remembers its addresses, and succeeds at everything else. +struct Tcp { + family: IpAddressFamily, + local: Cell>, + remote: Cell>, + listening: Cell, +} + +impl GuestTcpSocket for Tcp { + fn create(address_family: IpAddressFamily) -> Result { + Ok(TcpSocket::new(Tcp { + family: address_family, + local: Cell::new(None), + remote: Cell::new(None), + listening: Cell::new(false), + })) + } + + fn bind(&self, local_address: IpSocketAddress) -> Result<(), ErrorCode> { + self.local.set(Some(local_address)); + Ok(()) + } + + async fn connect(&self, remote_address: IpSocketAddress) -> Result<(), ErrorCode> { + self.remote.set(Some(remote_address)); + Ok(()) + } + + /// No connections arrive. + fn listen(&self) -> Result, ErrorCode> { + self.listening.set(true); + let (_, connections) = wit_stream::new(); + Ok(connections) + } + + /// The data is dropped. + fn send(&self, _data: StreamReader) -> FutureReader> { + let (_, sent) = wit_future::new(|| Ok(())); + sent + } + + /// No data arrives. + fn receive(&self) -> (StreamReader, FutureReader>) { + let (_, data) = wit_stream::new(); + let (_, received) = wit_future::new(|| Ok(())); + (data, received) + } + + fn get_local_address(&self) -> Result { + Ok(self.local.get().unwrap_or_else(unspecified)) + } + + fn get_remote_address(&self) -> Result { + self.remote.get().ok_or(ErrorCode::InvalidState) + } + + fn get_is_listening(&self) -> bool { + self.listening.get() + } + + fn get_address_family(&self) -> IpAddressFamily { + self.family + } + + fn set_listen_backlog_size(&self, _value: u64) -> Result<(), ErrorCode> { + Ok(()) + } + + fn get_keep_alive_enabled(&self) -> Result { + Ok(false) + } + + fn set_keep_alive_enabled(&self, _value: bool) -> Result<(), ErrorCode> { + Ok(()) + } + + fn get_keep_alive_idle_time(&self) -> Result { + Ok(0) + } + + fn set_keep_alive_idle_time(&self, _value: Duration) -> Result<(), ErrorCode> { + Ok(()) + } + + fn get_keep_alive_interval(&self) -> Result { + Ok(0) + } + + fn set_keep_alive_interval(&self, _value: Duration) -> Result<(), ErrorCode> { + Ok(()) + } + + fn get_keep_alive_count(&self) -> Result { + Ok(0) + } + + fn set_keep_alive_count(&self, _value: u32) -> Result<(), ErrorCode> { + Ok(()) + } + + fn get_hop_limit(&self) -> Result { + Ok(64) + } + + fn set_hop_limit(&self, _value: u8) -> Result<(), ErrorCode> { + Ok(()) + } + + fn get_receive_buffer_size(&self) -> Result { + Ok(0) + } + + fn set_receive_buffer_size(&self, _value: u64) -> Result<(), ErrorCode> { + Ok(()) + } + + fn get_send_buffer_size(&self) -> Result { + Ok(0) + } + + fn set_send_buffer_size(&self, _value: u64) -> Result<(), ErrorCode> { + Ok(()) + } +} + +/// A socket that remembers its addresses, and succeeds at everything else. +struct Udp { + family: IpAddressFamily, + local: Cell>, + remote: RefCell>, +} + +impl GuestUdpSocket for Udp { + fn create(address_family: IpAddressFamily) -> Result { + Ok(UdpSocket::new(Udp { + family: address_family, + local: Cell::new(None), + remote: RefCell::new(None), + })) + } + + fn bind(&self, local_address: IpSocketAddress) -> Result<(), ErrorCode> { + self.local.set(Some(local_address)); + Ok(()) + } + + fn connect(&self, remote_address: IpSocketAddress) -> Result<(), ErrorCode> { + self.remote.replace(Some(remote_address)); + Ok(()) + } + + fn disconnect(&self) -> Result<(), ErrorCode> { + self.remote.replace(None); + Ok(()) + } + + /// The datagram is dropped. + async fn send( + &self, + _data: Vec, + _remote_address: Option, + ) -> Result<(), ErrorCode> { + Ok(()) + } + + /// No datagram arrives, there is no network to wait on. + async fn receive(&self) -> Result<(Vec, IpSocketAddress), ErrorCode> { + Err(ErrorCode::Other(Some( + "no datagrams without a network".to_string(), + ))) + } + + fn get_local_address(&self) -> Result { + Ok(self.local.get().unwrap_or_else(unspecified)) + } + + fn get_remote_address(&self) -> Result { + self.remote.borrow().ok_or(ErrorCode::InvalidState) + } + + fn get_address_family(&self) -> IpAddressFamily { + self.family + } + + fn get_unicast_hop_limit(&self) -> Result { + Ok(64) + } + + fn set_unicast_hop_limit(&self, _value: u8) -> Result<(), ErrorCode> { + Ok(()) + } + + fn get_receive_buffer_size(&self) -> Result { + Ok(0) + } + + fn set_receive_buffer_size(&self, _value: u64) -> Result<(), ErrorCode> { + Ok(()) + } + + fn get_send_buffer_size(&self) -> Result { + Ok(0) + } + + fn set_send_buffer_size(&self, _value: u64) -> Result<(), ErrorCode> { + Ok(()) + } +} + +wit_bindgen::generate!({ + // `path` is relative to the manifest, `components/test-stub-sockets/` + path: "../wit", + world: "test-stub-sockets", + generate_all, +}); + +export!(Component); diff --git a/components/test-tcp-connector-latch-suite/Cargo.toml b/components/test-tcp-connector-latch-suite/Cargo.toml new file mode 100644 index 0000000..42464e9 --- /dev/null +++ b/components/test-tcp-connector-latch-suite/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "test-tcp-connector-latch-suite" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = { workspace = true } diff --git a/components/test-tcp-connector-latch-suite/README.md b/components/test-tcp-connector-latch-suite/README.md new file mode 100644 index 0000000..0a3c3ab --- /dev/null +++ b/components/test-tcp-connector-latch-suite/README.md @@ -0,0 +1,5 @@ +# test-tcp-connector-latch-suite + +The tests of the [`tcp-connector-latch`](../tcp-connector-latch/), run inside a component behind a gate with the latch. + +Each test looks up names, connects and binds with wasi:sockets, expecting each operation either allowed or denied with `access-denied`. The tests are named for the connector config they expect, `by hostname`, `by address` and `by default`. Composed as [`test-tcp-connector-latch`](../test-tcp-connector-latch/). diff --git a/components/test-tcp-connector-latch-suite/src/lib.rs b/components/test-tcp-connector-latch-suite/src/lib.rs new file mode 100644 index 0000000..494dacb --- /dev/null +++ b/components/test-tcp-connector-latch-suite/src/lib.rs @@ -0,0 +1,253 @@ +//! The tests of the `tcp-connector-latch`, run inside a component behind a gate with the latch, +//! over `test-stub-sockets`, wasi:sockets without a network. Every socket operation of the stub +//! succeeds, so a test that fails with `access-denied` was denied by the gate, and one that +//! succeeds was allowed. +//! +//! The tests are named for the connector config they expect, set by the test harness: +//! +//! - `by hostname ...`: `hostname=valkey.test`, `port=6380` +//! - `by address ...`: `hostname=127.0.0.1`, `port=6380` +//! - `by default ...`: neither, the connector's defaults, `127.0.0.1` and `6379` +//! +//! The stub resolves names other than `localhost` to `192.0.2.1`. + +use exports::componentized::valkey_components::test_suite::Guest; +use wasi::sockets::ip_name_lookup::{ErrorCode as LookupErrorCode, resolve_addresses}; +use wasi::sockets::types::{ + ErrorCode, IpAddress, IpAddressFamily, IpSocketAddress, Ipv4SocketAddress, Ipv6SocketAddress, + TcpSocket, UdpSocket, +}; + +struct Suite; + +/// The tests, by name, and the function running each. +macro_rules! suite { + ($($name:literal => $test:ident),* $(,)?) => { + const TESTS: &[&str] = &[$($name),*]; + + async fn run_test(name: &str) -> Result<(), String> { + match name { + $($name => $test().await,)* + _ => Err(format!("no test named {name:?}")), + } + } + }; +} + +suite! { + "by hostname looks up the hostname" => hostname_looks_up_hostname, + "by hostname connects to its addresses on the port" => hostname_connects, + "by hostname denies another port" => hostname_denies_another_port, + "by hostname denies looking up another name" => hostname_denies_another_name, + "by hostname denies looking up its address" => hostname_denies_looking_up_its_address, + "by hostname denies an address no lookup returned" => hostname_denies_address_without_lookup, + "by hostname denies binding" => denies_binding, + "by hostname denies udp" => denies_udp, + "by address looks up the address" => address_looks_up_address, + "by address connects to it on the port" => address_connects, + "by address denies another port" => address_denies_another_port, + "by address denies looking up a name" => address_denies_a_name, + "by address denies another address" => address_denies_another_address, + "by default connects to the default address and port" => default_connects, + "by default denies another port" => default_denies_another_port, +} + +impl Guest for Suite { + fn tests() -> Vec { + TESTS.iter().map(|name| name.to_string()).collect() + } + + async fn run(name: String) -> Result<(), String> { + run_test(&name).await + } +} + +const HOSTNAME: &str = "valkey.test"; +/// The address the stub resolves names to. +const HOSTNAME_ADDRESS: IpAddress = IpAddress::Ipv4((192, 0, 2, 1)); +const LOOPBACK: IpAddress = IpAddress::Ipv4((127, 0, 0, 1)); +const PORT: u16 = 6380; +const DEFAULT_PORT: u16 = 6379; + +async fn lookup(name: &str) -> Result, LookupErrorCode> { + resolve_addresses(name.to_string()).await +} + +async fn connect(address: IpAddress, port: u16) -> Result<(), ErrorCode> { + let address = socket_address(address, port); + let family = match address { + IpSocketAddress::Ipv4(_) => IpAddressFamily::Ipv4, + IpSocketAddress::Ipv6(_) => IpAddressFamily::Ipv6, + }; + TcpSocket::create(family)?.connect(address).await +} + +fn socket_address(address: IpAddress, port: u16) -> IpSocketAddress { + match address { + IpAddress::Ipv4(address) => IpSocketAddress::Ipv4(Ipv4SocketAddress { port, address }), + IpAddress::Ipv6(address) => IpSocketAddress::Ipv6(Ipv6SocketAddress { + port, + flow_info: 0, + address, + scope_id: 0, + }), + } +} + +/// Fails unless the lookup was allowed, returning the addresses. +async fn allowed_lookup(name: &str) -> Result, String> { + lookup(name) + .await + .map_err(|error| format!("looking up {name} failed with {error:?}, expected it allowed")) +} + +/// Fails unless the gate denied the lookup. +async fn denied_lookup(name: &str) -> Result<(), String> { + match lookup(name).await { + Err(LookupErrorCode::AccessDenied) => Ok(()), + Err(error) => Err(format!( + "looking up {name} failed with {error:?}, expected access-denied" + )), + Ok(addresses) => Err(format!( + "looking up {name} returned {addresses:?}, expected access-denied" + )), + } +} + +/// Fails unless the connection was allowed. +async fn allowed_connect(address: IpAddress, port: u16) -> Result<(), String> { + connect(address, port).await.map_err(|error| { + format!("connecting to {address:?}:{port} failed with {error:?}, expected it allowed") + }) +} + +/// Fails unless the gate denied the connection. +async fn denied_connect(address: IpAddress, port: u16) -> Result<(), String> { + match connect(address, port).await { + Err(ErrorCode::AccessDenied) => Ok(()), + Err(error) => Err(format!( + "connecting to {address:?}:{port} failed with {error:?}, expected access-denied" + )), + Ok(()) => Err(format!( + "connected to {address:?}:{port}, expected access-denied" + )), + } +} + +async fn hostname_looks_up_hostname() -> Result<(), String> { + let addresses = allowed_lookup(HOSTNAME).await?; + if addresses != [HOSTNAME_ADDRESS] { + return Err(format!("looking up {HOSTNAME} returned {addresses:?}")); + } + Ok(()) +} + +async fn hostname_connects() -> Result<(), String> { + for address in allowed_lookup(HOSTNAME).await? { + allowed_connect(address, PORT).await?; + } + Ok(()) +} + +async fn hostname_denies_another_port() -> Result<(), String> { + for address in allowed_lookup(HOSTNAME).await? { + denied_connect(address, DEFAULT_PORT).await?; + denied_connect(address, 22).await?; + } + Ok(()) +} + +async fn hostname_denies_another_name() -> Result<(), String> { + denied_lookup("example.com").await?; + denied_lookup("localhost").await?; + // a pattern is not a name + denied_lookup("*.test").await +} + +async fn hostname_denies_looking_up_its_address() -> Result<(), String> { + denied_lookup("192.0.2.1").await +} + +/// Connecting by hand, to an address no allowed lookup returned. +async fn hostname_denies_address_without_lookup() -> Result<(), String> { + denied_connect(IpAddress::Ipv4((198, 51, 100, 1)), PORT).await?; + denied_connect(LOOPBACK, PORT).await +} + +async fn denies_binding() -> Result<(), String> { + let socket = TcpSocket::create(IpAddressFamily::Ipv4) + .map_err(|error| format!("creating a socket failed with {error:?}"))?; + let local = socket_address(IpAddress::Ipv4((0, 0, 0, 0)), PORT); + match socket.bind(local) { + Err(ErrorCode::AccessDenied) => Ok(()), + Err(error) => Err(format!( + "binding failed with {error:?}, expected access-denied" + )), + Ok(()) => Err("bound, expected access-denied".to_string()), + } +} + +async fn denies_udp() -> Result<(), String> { + match UdpSocket::create(IpAddressFamily::Ipv4) { + Err(ErrorCode::AccessDenied) => Ok(()), + Err(error) => Err(format!( + "creating a udp socket failed with {error:?}, expected access-denied" + )), + Ok(_) => Err("created a udp socket, expected access-denied".to_string()), + } +} + +async fn address_looks_up_address() -> Result<(), String> { + let addresses = allowed_lookup("127.0.0.1").await?; + if addresses != [LOOPBACK] { + return Err(format!("looking up 127.0.0.1 returned {addresses:?}")); + } + Ok(()) +} + +async fn address_connects() -> Result<(), String> { + for address in allowed_lookup("127.0.0.1").await? { + allowed_connect(address, PORT).await?; + } + Ok(()) +} + +async fn address_denies_another_port() -> Result<(), String> { + allowed_lookup("127.0.0.1").await?; + denied_connect(LOOPBACK, DEFAULT_PORT).await +} + +async fn address_denies_a_name() -> Result<(), String> { + // localhost resolves to the same address, but is not the configured hostname + denied_lookup("localhost").await?; + denied_lookup(HOSTNAME).await +} + +async fn address_denies_another_address() -> Result<(), String> { + allowed_lookup("127.0.0.1").await?; + denied_connect(IpAddress::Ipv4((127, 0, 0, 2)), PORT).await?; + denied_connect(IpAddress::Ipv6((0, 0, 0, 0, 0, 0, 0, 1)), PORT).await +} + +async fn default_connects() -> Result<(), String> { + for address in allowed_lookup("127.0.0.1").await? { + allowed_connect(address, DEFAULT_PORT).await?; + } + Ok(()) +} + +async fn default_denies_another_port() -> Result<(), String> { + allowed_lookup("127.0.0.1").await?; + denied_connect(LOOPBACK, PORT).await +} + +wit_bindgen::generate!({ + // `path` is relative to the manifest, `components/test-tcp-connector-latch-suite/` + path: "../wit", + world: "test-tcp-connector-latch-suite", + // to compare the addresses a lookup returned + additional_derives: [PartialEq], + generate_all, +}); + +export!(Suite); diff --git a/components/test-tcp-connector-latch/Cargo.toml b/components/test-tcp-connector-latch/Cargo.toml new file mode 100644 index 0000000..8c090c7 --- /dev/null +++ b/components/test-tcp-connector-latch/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "test-tcp-connector-latch-tests" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +# only tests, the component is composed from test-tcp-connector-latch.wac + +[dev-dependencies] +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/test-tcp-connector-latch/README.md b/components/test-tcp-connector-latch/README.md new file mode 100644 index 0000000..227fc21 --- /dev/null +++ b/components/test-tcp-connector-latch/README.md @@ -0,0 +1,15 @@ +# test-tcp-connector-latch + +The tests of the [`tcp-connector-latch`](../tcp-connector-latch/), behind a [`gate`](../dep-componentized-sockets-gate/), over [`test-stub-sockets`](../test-stub-sockets/), wasi:sockets without a network. + +[`test-tcp-connector-latch-suite`](../test-tcp-connector-latch-suite/) gets its wasi:sockets from the gate. Every operation of the stub succeeds, so an operation that fails with `access-denied` was denied by the latch, and one that succeeds was allowed, with no server, DNS or firewall in the way. + +[`tests/latch.rs`](./tests/latch.rs) runs the suite with the test harness, once for each connector config, each in a new instance whose latch has seen no lookups: + +| Config | Allowed | Denied | +| --- | --- | --- | +| `hostname=valkey.test`, `port=6380` | looking up `valkey.test`, connecting to its address on `6380` | other ports, other names, looking up its address, addresses no lookup returned, binding, UDP | +| `hostname=127.0.0.1`, `port=6380` | looking up `127.0.0.1`, connecting to it on `6380` | other ports, names, even `localhost`, other addresses | +| neither, the connector's defaults | looking up `127.0.0.1`, connecting to it on `6379` | other ports | + +Run by `cargo test`, and `make test`. diff --git a/components/test-tcp-connector-latch/test-tcp-connector-latch.wac b/components/test-tcp-connector-latch/test-tcp-connector-latch.wac new file mode 100644 index 0000000..552e36b --- /dev/null +++ b/components/test-tcp-connector-latch/test-tcp-connector-latch.wac @@ -0,0 +1,34 @@ +package componentized:valkey-test; + +// wasi:sockets without a network, every operation succeeds, so only the gate can deny one +let sockets = new local:test-stub-sockets { + ... +}; + +let logging = new local:dep-componentized-logging-to-stderr { + ... +}; + +// the latch under test, reading the connector config from the host +let latch = new local:tcp-connector-latch { + "wasi:sockets/types@0.3.0": sockets["wasi:sockets/types@0.3.0"], + "wasi:logging/logging@0.1.0-draft": logging["wasi:logging/logging@0.1.0-draft"], + ... +}; + +let gate = new local:dep-componentized-sockets-gate { + "wasi:sockets/types@0.3.0": sockets["wasi:sockets/types@0.3.0"], + "wasi:sockets/ip-name-lookup@0.3.0": sockets["wasi:sockets/ip-name-lookup@0.3.0"], + "componentized:sockets/latch@0.1.0": latch["componentized:sockets/latch@0.1.0"], + "wasi:logging/logging@0.1.0-draft": logging["wasi:logging/logging@0.1.0-draft"], + ... +}; + +// the tests, their wasi:sockets from the gate +let suite = new local:test-tcp-connector-latch-suite { + "wasi:sockets/types@0.3.0": gate["wasi:sockets/types@0.3.0"], + "wasi:sockets/ip-name-lookup@0.3.0": gate["wasi:sockets/ip-name-lookup@0.3.0"], + ... +}; + +export suite...; diff --git a/components/test-tcp-connector-latch/tests/latch.rs b/components/test-tcp-connector-latch/tests/latch.rs new file mode 100644 index 0000000..7ca751d --- /dev/null +++ b/components/test-tcp-connector-latch/tests/latch.rs @@ -0,0 +1,27 @@ +//! The tests of the `tcp-connector-latch`, run inside `test-tcp-connector-latch-suite`, behind a +//! gate with the latch, over `test-stub-sockets`, composed as `test-tcp-connector-latch`. Each +//! config the suite's tests expect runs in a new instance, with a latch that has seen no lookups. + +use test_harness::{Harness, run_suite}; + +const COMPONENT: &str = "test-tcp-connector-latch"; + +#[tokio::test(flavor = "multi_thread")] +async fn by_hostname() -> wasmtime::Result<()> { + let config = [("hostname", "valkey.test"), ("port", "6380")]; + let harness = Harness::with_config(COMPONENT, &config).await?; + run_suite(harness, |test| test.starts_with("by hostname ")).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn by_address() -> wasmtime::Result<()> { + let config = [("hostname", "127.0.0.1"), ("port", "6380")]; + let harness = Harness::with_config(COMPONENT, &config).await?; + run_suite(harness, |test| test.starts_with("by address ")).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn by_default() -> wasmtime::Result<()> { + let harness = Harness::new(COMPONENT).await?; + run_suite(harness, |test| test.starts_with("by default ")).await +} diff --git a/components/test-tcp/README.md b/components/test-tcp/README.md new file mode 100644 index 0000000..339d956 --- /dev/null +++ b/components/test-tcp/README.md @@ -0,0 +1,3 @@ +# test-tcp + +[`tcp`](../tcp/) composed with [`connection`](../connection/) and a [`tcp-connector`](../tcp-connector/), to test `tcp` and `tcp-connector` against a server played by the test. diff --git a/components/test-tcp/test-tcp.wac b/components/test-tcp/test-tcp.wac new file mode 100644 index 0000000..3fe30a5 --- /dev/null +++ b/components/test-tcp/test-tcp.wac @@ -0,0 +1,23 @@ +package componentized:valkey; + +let connection = new local:connection { + ... +}; +let tcp = new local:tcp { + "componentized:valkey/connection@0.3.0-dev": connection.connection, + ... +}; +// the keys of the tcp-connector's config, and their defaults +let tcp-connector-constants = new local:tcp-connector-constants { + ... +}; +let connector = new local:tcp-connector { + "componentized:valkey/tcp-connector-constants@0.3.0-dev": tcp-connector-constants["componentized:valkey/tcp-connector-constants@0.3.0-dev"], + "componentized:valkey/connection@0.3.0-dev": connection.connection, + "componentized:valkey/tcp@0.3.0-dev": tcp.tcp, + ... +}; + +export connection.connection; +export tcp.tcp; +export connector.connector; diff --git a/components/wit/worlds.wit b/components/wit/worlds.wit index 895a998..e3a0e1f 100644 --- a/components/wit/worlds.wit +++ b/components/wit/worlds.wit @@ -1,19 +1,42 @@ package componentized:valkey-components; -world cli { - include wasi:cli/imports@0.3.0; - import componentized:valkey/store@0.3.0-dev; - export wasi:cli/run@0.3.0; +/// The mechanics of a connection, independent of the transport. +world connection { + import wasi:clocks/monotonic-clock@0.3.0; + export componentized:valkey/connection@0.3.0-dev; +} + +world commands { + import componentized:valkey/connection@0.3.0-dev; + export componentized:valkey/commands@0.3.0-dev; +} + +/// Connections over wasi:sockets TCP. +world tcp { + import wasi:sockets/types@0.3.0; + import wasi:sockets/ip-name-lookup@0.3.0; + import componentized:valkey/connection@0.3.0-dev; + export componentized:valkey/tcp@0.3.0-dev; } -world ops { - include wasi:sockets/imports@0.3.0; - export componentized:valkey/resp@0.3.0-dev; - export componentized:valkey/store@0.3.0-dev; +/// A connector for connections over TCP, to the server configured with wasi:config. +world tcp-connector { + import wasi:config/store@0.2.0-rc.1; + import componentized:valkey/tcp-connector-constants@0.3.0-dev; + import componentized:valkey/tcp@0.3.0-dev; + export componentized:valkey/connector@0.3.0-dev; } +world tcp-connector-config { + import wasi:config/store@0.2.0-rc.1; + import componentized:valkey/tcp-connector-constants@0.3.0-dev; + export wasi:config/store@0.2.0-rc.1; +} + + world as-keyvalue { - import componentized:valkey/store@0.3.0-dev; + import componentized:valkey/connector@0.3.0-dev; + import componentized:valkey/commands@0.3.0-dev; import wasi:config/store@0.2.0-rc.1; export wasmcloud:keyvalue/types@0.2.0; export wasmcloud:keyvalue/store@0.2.0; @@ -26,3 +49,52 @@ world sample-http-incrementor { import wasmcloud:keyvalue/atomics@0.2.0; export wasi:http/handler@0.3.0; } + +/// A cli sending its arguments as a command over a connection from the connector. +world cli { + include wasi:cli/imports@0.3.0; + import componentized:valkey/connector@0.3.0-dev; + export wasi:cli/run@0.3.0; +} + +/// Opens a TCP connection to a host and port, and reports whether it could, e.g. to show what a +/// gate allows. +world tcp-probe { + import wasi:sockets/types@0.3.0; + import wasi:sockets/ip-name-lookup@0.3.0; + import wasi:cli/environment@0.3.0; + import wasi:cli/stdout@0.3.0; + import wasi:cli/stderr@0.3.0; + export wasi:cli/run@0.3.0; +} + +/// wasi:sockets without a network, for tests of what a gate allows. +world test-stub-sockets { + export wasi:sockets/types@0.3.0; + export wasi:sockets/ip-name-lookup@0.3.0; +} + +/// The tests of the `tcp-connector-latch`, through a gate, over wasi:sockets without a network. +world test-tcp-connector-latch-suite { + import wasi:sockets/types@0.3.0; + import wasi:sockets/ip-name-lookup@0.3.0; + export test-suite; +} + +/// A suite of tests run inside a component, by the test harness, `crates/test-harness`. +interface test-suite { + /// The names of the tests. Those that need a Valkey server on localhost:6379 start with + /// `with-valkey `. + tests: func() -> list; + + /// Runs the named test, failing with why. + run: async func(name: string) -> result<_, string>; +} + +/// The tests of the commands, playing the server over the streams of a connection. +world test-commands { + import componentized:valkey/connection@0.3.0-dev; + import componentized:valkey/connector@0.3.0-dev; + import componentized:valkey/commands@0.3.0-dev; + export test-suite; +} diff --git a/crates/componentized-valkey/Cargo.toml b/crates/componentized-valkey/Cargo.toml new file mode 100644 index 0000000..7f63e8c --- /dev/null +++ b/crates/componentized-valkey/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "componentized-valkey" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Rust types and conversions for the componentized:valkey WIT package: the shared types, a RESP codec, and the conversions between commands and replies." +publish = false + +[dependencies] +wit-bindgen = { workspace = true } diff --git a/crates/componentized-valkey/LICENSE b/crates/componentized-valkey/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/crates/componentized-valkey/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/components/ops/src/resp/LICENSE-MIT.txt b/crates/componentized-valkey/NOTICE similarity index 66% rename from components/ops/src/resp/LICENSE-MIT.txt rename to crates/componentized-valkey/NOTICE index d1a0869..481f2c4 100644 --- a/components/ops/src/resp/LICENSE-MIT.txt +++ b/crates/componentized-valkey/NOTICE @@ -1,3 +1,16 @@ +Componentized valkey +Copyright 2025 the original author or authors. + +This product may include a number of subcomponents with separate copyright notices and license terms. +Your use of these subcomponents is subject to the terms and conditions of the subcomponent's license, +as noted in the LICENSE file. + +------------------------------------------------------------------------------- + +This product includes a RESP codec adapted from resp v1.0.3 +(https://github.com/iorust/resp/tree/v1.0.3), in src/resp.rs, +used under the MIT license: + Copyright (c) 2016-2022 IORust Developers Permission is hereby granted, free of charge, to any diff --git a/crates/componentized-valkey/README.md b/crates/componentized-valkey/README.md new file mode 100644 index 0000000..d83dfaa --- /dev/null +++ b/crates/componentized-valkey/README.md @@ -0,0 +1,11 @@ +# componentized-valkey + +Rust support for the `componentized:valkey` WIT package: the shared types, a RESP codec, and the +conversions between Rust values and commands and replies. + +## License + +Licensed under the Apache License, Version 2.0, see [LICENSE](./LICENSE). + +The RESP codec, `src/resp.rs`, is adapted from https://github.com/iorust/resp/tree/v1.0.3, used +under the MIT license, see [NOTICE](./NOTICE). diff --git a/crates/componentized-valkey/src/command.rs b/crates/componentized-valkey/src/command.rs new file mode 100644 index 0000000..90c39e0 --- /dev/null +++ b/crates/componentized-valkey/src/command.rs @@ -0,0 +1,288 @@ +//! Converting Rust values into the words of a command, the impls for Rust's own types and the +//! helpers the commands share. The impls for the package's types are in [`crate::commands`]. + +use crate::types::{ErrorCode, StringOrBytes}; + +/// The words of a command, each a string, or bytes, e.g. a binary value. +pub type Words = Vec; + +/// Converts a Rust type into the words of a command, e.g. `Option` into no word, or one. +/// +/// The impls compose, a tuple is the words of each of its items in order, so a command is written +/// as one value, e.g. `("SET", key, value, precondition, ttl)`. +/// +/// A value the command does not accept, e.g. an empty list where it requires an item, fails with a +/// client error, and the command is not sent. +pub trait IntoCommand { + fn into_command(self) -> Result; +} + +/// A value as given, UTF-8 or not. +impl IntoCommand for StringOrBytes { + fn into_command(self) -> Result { + Ok(vec![self]) + } +} + +impl IntoCommand for &str { + fn into_command(self) -> Result { + Ok(vec![self.into()]) + } +} + +impl IntoCommand for String { + fn into_command(self) -> Result { + Ok(vec![self.into()]) + } +} + +impl IntoCommand for &String { + fn into_command(self) -> Result { + Ok(vec![self.as_str().into()]) + } +} + +/// Numbers, without a unit, see `DurationSeconds` and the other records for times. +macro_rules! into_command_numbers { + ($($number:ty),*) => { + $( + impl IntoCommand for $number { + fn into_command(self) -> Result { + Ok(vec![self.to_string().into()]) + } + } + )* + }; +} + +into_command_numbers!(i32, i64, u8, u16, u32, u64, usize, f64); + +/// No word for none. +impl IntoCommand for Option { + fn into_command(self) -> Result { + self.map_or(Ok(vec![]), IntoCommand::into_command) + } +} + +impl IntoCommand for Vec { + fn into_command(self) -> Result { + let mut command = vec![]; + for item in self { + command.extend(item.into_command()?); + } + Ok(command) + } +} + +impl IntoCommand for [T; N] { + fn into_command(self) -> Result { + let mut command = vec![]; + for item in self { + command.extend(item.into_command()?); + } + Ok(command) + } +} + +/// The words of each item, in order. +macro_rules! into_command_tuples { + ($(($($item:ident),+)),*) => { + $( + impl<$($item: IntoCommand),+> IntoCommand for ($($item,)+) { + #[allow(non_snake_case)] + fn into_command(self) -> Result { + let ($($item,)+) = self; + let mut command = vec![]; + $(command.extend($item.into_command()?);)+ + Ok(command) + } + } + )* + }; +} + +into_command_tuples!( + (A), + (A, B), + (A, B, C), + (A, B, C, D), + (A, B, C, D, E), + (A, B, C, D, E, F), + (A, B, C, D, E, F, G), + (A, B, C, D, E, F, G, H), + (A, B, C, D, E, F, G, H, I), + (A, B, C, D, E, F, G, H, I, J) +); + +/// A word that is present when true, e.g. `Flag("WITHMATCHLEN", with_match_len)`. +/// +/// A `bool` has no impl of its own, the word it stands for depends on the command. +pub struct Flag(pub &'static str, pub bool); + +impl IntoCommand for Flag { + fn into_command(self) -> Result { + let Flag(word, present) = self; + Ok(if present { vec![word.into()] } else { vec![] }) + } +} + +/// A value preceded by its name, e.g. `COUNT count`, no words for none. +pub struct NamedOption(pub &'static str, pub Option); + +impl IntoCommand for NamedOption { + fn into_command(self) -> Result { + let NamedOption(name, value) = self; + match value { + Some(_) => (name, value).into_command(), + None => Ok(vec![]), + } + } +} + +/// A list the command requires at least one item of, e.g. the keys of DEL, named for the error when +/// it is empty. +pub struct NonEmpty(&'static str, Vec); + +impl NonEmpty { + pub fn new(name: &'static str, items: Vec) -> Self { + NonEmpty(name, items) + } + + /// A list the command allows to be left out, none rather than empty. + pub fn optional(name: &'static str, items: Option>) -> Option { + items.map(|items| NonEmpty(name, items)) + } + + pub fn len(&self) -> usize { + self.1.len() + } +} + +impl IntoCommand for NonEmpty { + fn into_command(self) -> Result { + let NonEmpty(name, items) = self; + if items.is_empty() { + return Err(ErrorCode::Client(format!( + "{name} requires at least one item" + ))); + } + items.into_command() + } +} + +/// The items preceded by how many there are, e.g. the keys of ZINTER, or the keys and values of +/// MSETEX, a pair counts as one. +pub struct NumKeys(pub NonEmpty); + +impl IntoCommand for NumKeys { + fn into_command(self) -> Result { + (self.0.len(), self.0).into_command() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{words, words_error}; + + #[test] + fn strings() { + assert_eq!(words("GET"), ["GET"]); + assert_eq!(words("key".to_string()), ["key"]); + assert_eq!(words(&"key".to_string()), ["key"]); + } + + #[test] + fn numbers() { + assert_eq!( + words((-1i32, -2i64, 3u8, 4u16, 5u32, 6u64, 7usize)), + ["-1", "-2", "3", "4", "5", "6", "7"] + ); + assert_eq!(words((1.5f64, 2f64, -0.25f64)), ["1.5", "2", "-0.25"]); + } + + #[test] + fn option() { + assert_eq!(words(None::<&str>), Vec::::new()); + assert_eq!(words(Some("value")), ["value"]); + } + + #[test] + fn lists() { + assert_eq!(words(vec!["a", "b"]), ["a", "b"]); + assert_eq!(words(Vec::<&str>::new()), Vec::::new()); + assert_eq!(words(["a", "b"]), ["a", "b"]); + } + + #[test] + fn tuples() { + assert_eq!( + words(("SET", "key", "value", None::<&str>, Some(("EX", 10u64)))), + ["SET", "key", "value", "EX", "10"] + ); + assert_eq!(words(vec![("field", "value")]), ["field", "value"]); + } + + #[test] + fn an_error_fails_the_whole_command() { + let command = ("DEL", NonEmpty::new("keys", Vec::::new())); + assert_eq!(words_error(command), "keys requires at least one item"); + } + + #[test] + fn flag() { + assert_eq!(words(Flag("FORCE", true)), ["FORCE"]); + assert_eq!(words(Flag("FORCE", false)), Vec::::new()); + } + + #[test] + fn named_option() { + assert_eq!(words(NamedOption("COUNT", Some(5i64))), ["COUNT", "5"]); + assert_eq!( + words(NamedOption("COUNT", None::)), + Vec::::new() + ); + } + + #[test] + fn non_empty() { + assert_eq!(words(NonEmpty::new("keys", vec!["a", "b"])), ["a", "b"]); + assert_eq!( + words_error(NonEmpty::new("keys", Vec::::new())), + "keys requires at least one item" + ); + } + + #[test] + fn non_empty_optional() { + assert_eq!( + words(NonEmpty::optional("args", None::>)), + Vec::::new() + ); + assert_eq!(words(NonEmpty::optional("args", Some(vec!["a"]))), ["a"]); + assert_eq!( + words_error(NonEmpty::optional("args", Some(Vec::::new()))), + "args requires at least one item" + ); + } + + #[test] + fn num_keys() { + assert_eq!( + words(NumKeys(NonEmpty::new("keys", vec!["a", "b"]))), + ["2", "a", "b"] + ); + // a pair counts as one + assert_eq!( + words(NumKeys(NonEmpty::new( + "keys", + vec![("k1", "v1"), ("k2", "v2")] + ))), + ["2", "k1", "v1", "k2", "v2"] + ); + assert_eq!( + words_error(NumKeys(NonEmpty::new("keys", Vec::::new()))), + "keys requires at least one item" + ); + } +} diff --git a/crates/componentized-valkey/src/commands/admin.rs b/crates/componentized-valkey/src/commands/admin.rs new file mode 100644 index 0000000..a7a318f --- /dev/null +++ b/crates/componentized-valkey/src/commands/admin.rs @@ -0,0 +1,1563 @@ +//! The admin commands: ACL, CLIENT, CLUSTER, COMMANDLOG, FAILOVER, LATENCY, MODULE, ROLE, SHUTDOWN +//! and the like. + +use crate::command::{Flag, IntoCommand, NonEmpty, Words}; +use crate::types::{ + AclChannelRule, AclCommandRule, AclDbRule, AclDryrunResult, AclKeyRule, AclListEntry, + AclLogEntry, AclRule, AclSelectorRule, AclUserRule, BgsaveOperation, ClientFilter, + ClientPauseMode, ClientType, ClientUnblockType, ClusterBumpepochResult, ClusterFailoverMode, + ClusterGetslotmigrationsEntry, ClusterGetslotmigrationsOperation, ClusterMigrateslotsGroup, + ClusterResetMode, ClusterSetslotState, CommandlogEntry, CommandlogType, ErrorCode, + FailoverTarget, Integer, LatencyHistogramEntry, LatencyLatestEntry, ModuleListEntry, RespValue, + RolePrimary, RolePrimaryReplica, RoleReplica, RoleReply, ShutdownSave, +}; +use crate::value::{Fields, FlatPairs, FromValue, items, parse_string}; + +use wit_bindgen::rt::Map; + +impl IntoCommand for AclRule { + fn into_command(self) -> Result { + match self { + AclRule::Keys(rule) => rule.into_command(), + AclRule::Channel(rule) => rule.into_command(), + AclRule::Command(rule) => rule.into_command(), + AclRule::Db(rule) => rule.into_command(), + AclRule::User(rule) => rule.into_command(), + AclRule::Other(rule) => rule.into_command(), + } + } +} + +impl IntoCommand for AclSelectorRule { + fn into_command(self) -> Result { + match self { + AclSelectorRule::Keys(rule) => rule.into_command(), + AclSelectorRule::Channel(rule) => rule.into_command(), + AclSelectorRule::Command(rule) => rule.into_command(), + AclSelectorRule::Db(rule) => rule.into_command(), + AclSelectorRule::Other(rule) => rule.into_command(), + } + } +} + +impl IntoCommand for AclKeyRule { + fn into_command(self) -> Result { + match self { + AclKeyRule::Add(pattern) => format!("~{pattern}"), + AclKeyRule::AddRead(pattern) => format!("%R~{pattern}"), + AclKeyRule::AddWrite(pattern) => format!("%W~{pattern}"), + AclKeyRule::AddReadWrite(pattern) => format!("%RW~{pattern}"), + AclKeyRule::All => "allkeys".to_string(), + AclKeyRule::Reset => "resetkeys".to_string(), + } + .into_command() + } +} + +impl IntoCommand for AclChannelRule { + fn into_command(self) -> Result { + match self { + AclChannelRule::Add(pattern) => format!("&{pattern}"), + AclChannelRule::All => "allchannels".to_string(), + AclChannelRule::Reset => "resetchannels".to_string(), + } + .into_command() + } +} + +impl IntoCommand for AclCommandRule { + fn into_command(self) -> Result { + match self { + AclCommandRule::Add(command) => format!("+{command}"), + AclCommandRule::AddCategory(category) => format!("+@{category}"), + AclCommandRule::AddFirstArg((command, arg)) => format!("+{command}|{arg}"), + AclCommandRule::All => "allcommands".to_string(), + AclCommandRule::Remove(command) => format!("-{command}"), + AclCommandRule::RemoveCategory(category) => format!("-@{category}"), + AclCommandRule::Reset => "nocommands".to_string(), + } + .into_command() + } +} + +/// `db=` requires at least one database. +impl IntoCommand for AclDbRule { + fn into_command(self) -> Result { + match self { + AclDbRule::Db(dbs) if dbs.is_empty() => { + return Err(ErrorCode::Client( + "db requires at least one item".to_string(), + )); + } + AclDbRule::Db(dbs) => format!( + "db={}", + dbs.iter() + .map(|db| db.to_string()) + .collect::>() + .join(",") + ), + AclDbRule::All => "alldbs".to_string(), + AclDbRule::Reset => "resetdbs".to_string(), + } + .into_command() + } +} + +impl IntoCommand for AclUserRule { + fn into_command(self) -> Result { + match self { + AclUserRule::On => "on".into_command(), + AclUserRule::Off => "off".into_command(), + AclUserRule::Nopass => "nopass".into_command(), + AclUserRule::SanitizePayload => "sanitize-payload".into_command(), + AclUserRule::SkipSanitizePayload => "skip-sanitize-payload".into_command(), + AclUserRule::Password(password) => format!(">{password}").into_command(), + AclUserRule::HashedPassword(hash) => format!("#{hash}").into_command(), + AclUserRule::RemovePassword(password) => format!("<{password}").into_command(), + AclUserRule::RemoveHashedPassword(hash) => format!("!{hash}").into_command(), + AclUserRule::Selector(rules) => ("(", rules, ")").into_command(), + AclUserRule::Clearselectors => "clearselectors".into_command(), + AclUserRule::Reset => "reset".into_command(), + } + } +} + +/// `OK` when the user may run the command, otherwise why not. +impl FromValue for AclDryrunResult { + fn from_value(value: RespValue) -> Result { + match value { + RespValue::String(reply) if reply == "OK" => Ok(AclDryrunResult::Allowed), + value => String::from_value(value).map(AclDryrunResult::Denied), + } + } +} + +/// The rules of ACL GETUSER: the flags, the hashed passwords, the root selector's rules, then the +/// selectors. +pub struct AclUserRules(pub Vec); + +impl FromValue for AclUserRules { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + let flags: Vec = fields.required("flags")?; + let passwords: Vec = fields.required("passwords")?; + let mut rules: Vec = flags.iter().map(|flag| rule(flag)).collect(); + rules.extend( + passwords + .into_iter() + .map(|hash| AclRule::User(AclUserRule::HashedPassword(hash))), + ); + rules.extend(selector_rules(&mut fields)?.into_iter().map(root_rule)); + let selectors: Vec = fields.required("selectors")?; + for mut selector in selectors { + let selector = selector_rules(&mut selector)?; + rules.push(AclRule::User(AclUserRule::Selector(selector))); + } + Ok(AclUserRules(rules)) + } +} + +/// The rules of a selector, from its `keys`, `channels`, `databases` and `commands` fields, each +/// rules separated by spaces. +/// +/// The channels are preceded by `resetchannels`, as ACL LIST writes them, so the server's +/// `acl-pubsub-default` does not add to them. +fn selector_rules(fields: &mut Fields) -> Result, ErrorCode> { + let keys: String = fields.required("keys")?; + let channels: String = fields.required("channels")?; + // since Valkey 9.1 + let databases: Option = fields.optional("databases")?; + let commands: String = fields.required("commands")?; + let mut rules: Vec = keys.split_whitespace().map(key_rule).collect(); + rules.push(AclSelectorRule::Channel(AclChannelRule::Reset)); + rules.extend(channels.split_whitespace().map(channel_rule)); + rules.extend( + databases + .iter() + .flat_map(|databases| databases.split_whitespace().map(db_rule)), + ); + rules.extend(commands.split_whitespace().map(command_rule)); + Ok(rules) +} + +fn key_rule(rule: &str) -> AclSelectorRule { + let key_rule = match rule { + "allkeys" => AclKeyRule::All, + "resetkeys" => AclKeyRule::Reset, + _ => { + if let Some(pattern) = rule.strip_prefix("%RW~") { + AclKeyRule::AddReadWrite(pattern.to_string()) + } else if let Some(pattern) = rule.strip_prefix("%R~") { + AclKeyRule::AddRead(pattern.to_string()) + } else if let Some(pattern) = rule.strip_prefix("%W~") { + AclKeyRule::AddWrite(pattern.to_string()) + } else if let Some(pattern) = rule.strip_prefix('~') { + AclKeyRule::Add(pattern.to_string()) + } else { + return AclSelectorRule::Other(rule.to_string()); + } + } + }; + AclSelectorRule::Keys(key_rule) +} + +fn channel_rule(rule: &str) -> AclSelectorRule { + let channel_rule = match rule { + "allchannels" => AclChannelRule::All, + "resetchannels" => AclChannelRule::Reset, + _ => match rule.strip_prefix('&') { + Some(pattern) => AclChannelRule::Add(pattern.to_string()), + None => return AclSelectorRule::Other(rule.to_string()), + }, + }; + AclSelectorRule::Channel(channel_rule) +} + +fn db_rule(rule: &str) -> AclSelectorRule { + let db_rule = match rule { + "alldbs" => AclDbRule::All, + "resetdbs" => AclDbRule::Reset, + _ => { + let dbs = rule + .strip_prefix("db=") + .map(|dbs| dbs.split(',').map(str::parse).collect::>()); + match dbs { + Some(Ok(dbs)) => AclDbRule::Db(dbs), + _ => return AclSelectorRule::Other(rule.to_string()), + } + } + }; + AclSelectorRule::Db(db_rule) +} + +/// `+config|get` is a subcommand, or a first argument, either way it is sent back as is. +fn command_rule(rule: &str) -> AclSelectorRule { + let command_rule = match rule { + "allcommands" | "+@all" => AclCommandRule::All, + "nocommands" | "-@all" => AclCommandRule::Reset, + _ => { + if let Some(category) = rule.strip_prefix("+@") { + AclCommandRule::AddCategory(category.to_string()) + } else if let Some(category) = rule.strip_prefix("-@") { + AclCommandRule::RemoveCategory(category.to_string()) + } else if let Some(command) = rule.strip_prefix('+') { + AclCommandRule::Add(command.to_string()) + } else if let Some(command) = rule.strip_prefix('-') { + AclCommandRule::Remove(command.to_string()) + } else { + return AclSelectorRule::Other(rule.to_string()); + } + } + }; + AclSelectorRule::Command(command_rule) +} + +/// The lines of ACL LIST, each `user ...`, with selectors as `( ...)`. +pub struct AclList(pub Vec); + +impl FromValue for AclList { + fn from_value(value: RespValue) -> Result { + let lines: Vec = FromValue::from_value(value)?; + lines + .iter() + .map(|line| acl_list_entry(line)) + .collect::>() + .map(AclList) + } +} + +fn acl_list_entry(line: &str) -> Result { + let invalid = || ErrorCode::Client(format!("unexpected ACL LIST line: {line:?}")); + let mut words = line + .strip_prefix("user ") + .ok_or_else(invalid)? + .split_whitespace(); + let username = words.next().ok_or_else(invalid)?.to_string(); + let mut rules = vec![]; + while let Some(word) = words.next() { + if !word.starts_with('(') { + rules.push(rule(word)); + continue; + } + // a selector, its rules up to the word that closes it + let mut selector = vec![word]; + while !selector.last().is_some_and(|word| word.ends_with(')')) { + selector.push(words.next().ok_or_else(invalid)?); + } + let selector = selector.join(" "); + let selector = selector + .strip_prefix('(') + .and_then(|selector| selector.strip_suffix(')')) + .ok_or_else(invalid)?; + let selector = selector.split_whitespace().map(selector_rule).collect(); + rules.push(AclRule::User(AclUserRule::Selector(selector))); + } + Ok(AclListEntry { username, rules }) +} + +/// A rule of the user or its root selector, e.g. `on`, `#` or `~app:*`. +fn rule(word: &str) -> AclRule { + let user_rule = match word { + "on" => AclUserRule::On, + "off" => AclUserRule::Off, + "nopass" => AclUserRule::Nopass, + "sanitize-payload" => AclUserRule::SanitizePayload, + "skip-sanitize-payload" => AclUserRule::SkipSanitizePayload, + "clearselectors" => AclUserRule::Clearselectors, + "reset" => AclUserRule::Reset, + _ => { + if let Some(hash) = word.strip_prefix('#') { + AclUserRule::HashedPassword(hash.to_string()) + } else if let Some(hash) = word.strip_prefix('!') { + AclUserRule::RemoveHashedPassword(hash.to_string()) + } else if let Some(password) = word.strip_prefix('>') { + AclUserRule::Password(password.to_string()) + } else if let Some(password) = word.strip_prefix('<') { + AclUserRule::RemovePassword(password.to_string()) + } else { + return root_rule(selector_rule(word)); + } + } + }; + AclRule::User(user_rule) +} + +/// A rule of a selector, e.g. `~app:*`, `&news:*`, `db=0` or `+get`. +fn selector_rule(word: &str) -> AclSelectorRule { + match word { + "allkeys" | "resetkeys" => key_rule(word), + "allchannels" | "resetchannels" => channel_rule(word), + "alldbs" | "resetdbs" => db_rule(word), + "allcommands" | "nocommands" => command_rule(word), + _ if word.starts_with('~') || word.starts_with('%') => key_rule(word), + _ if word.starts_with('&') => channel_rule(word), + _ if word.starts_with("db=") => db_rule(word), + _ if word.starts_with('+') || word.starts_with('-') => command_rule(word), + _ => AclSelectorRule::Other(word.to_string()), + } +} + +/// A rule of the root selector. +fn root_rule(rule: AclSelectorRule) -> AclRule { + match rule { + AclSelectorRule::Keys(rule) => AclRule::Keys(rule), + AclSelectorRule::Channel(rule) => AclRule::Channel(rule), + AclSelectorRule::Command(rule) => AclRule::Command(rule), + AclSelectorRule::Db(rule) => AclRule::Db(rule), + AclSelectorRule::Other(rule) => AclRule::Other(rule), + } +} + +impl FromValue for AclLogEntry { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + count: fields.required("count")?, + reason: fields.required("reason")?, + context: fields.required("context")?, + object: fields.required("object")?, + username: fields.required("username")?, + age_seconds: fields.required("age-seconds")?, + client_info: fields.required("client-info")?, + // since Valkey 7.2 + entry_id: fields.optional("entry-id")?, + timestamp_created: fields.optional("timestamp-created")?, + timestamp_last_updated: fields.optional("timestamp-last-updated")?, + }) + } +} + +impl IntoCommand for BgsaveOperation { + fn into_command(self) -> Result { + match self { + BgsaveOperation::Schedule => "SCHEDULE", + BgsaveOperation::Cancel => "CANCEL", + } + .into_command() + } +} + +/// `master` rather than `primary`, which servers before Valkey 8.0 do not know. +impl IntoCommand for ClientType { + fn into_command(self) -> Result { + match self { + ClientType::Normal => "normal", + ClientType::Primary => "master", + ClientType::Replica => "replica", + ClientType::Pubsub => "pubsub", + } + .into_command() + } +} + +impl IntoCommand for ClientFilter { + fn into_command(self) -> Result { + match self { + ClientFilter::Id(ids) => ("ID", NonEmpty::new("id", ids)).into_command(), + ClientFilter::Type(client_type) => ("TYPE", client_type).into_command(), + ClientFilter::User(user) => ("USER", user).into_command(), + ClientFilter::Addr(addr) => ("ADDR", addr).into_command(), + ClientFilter::Laddr(laddr) => ("LADDR", laddr).into_command(), + ClientFilter::Skipme(skip) => { + ("SKIPME", if skip { "YES" } else { "NO" }).into_command() + } + ClientFilter::Maxage(age) => ("MAXAGE", age).into_command(), + ClientFilter::Name(name) => ("NAME", name).into_command(), + ClientFilter::Idle(idle) => ("IDLE", idle).into_command(), + ClientFilter::Flags(flags) => ("FLAGS", flags).into_command(), + ClientFilter::LibName(name) => ("LIB-NAME", name).into_command(), + ClientFilter::LibVer(version) => ("LIB-VER", version).into_command(), + ClientFilter::Db(db) => ("DB", db).into_command(), + ClientFilter::Capa(capability) => ("CAPA", capability).into_command(), + ClientFilter::Ip(ip) => ("IP", ip).into_command(), + ClientFilter::NotId(ids) => ("NOT-ID", NonEmpty::new("not-id", ids)).into_command(), + ClientFilter::NotType(client_type) => ("NOT-TYPE", client_type).into_command(), + ClientFilter::NotUser(user) => ("NOT-USER", user).into_command(), + ClientFilter::NotAddr(addr) => ("NOT-ADDR", addr).into_command(), + ClientFilter::NotLaddr(laddr) => ("NOT-LADDR", laddr).into_command(), + ClientFilter::NotName(name) => ("NOT-NAME", name).into_command(), + ClientFilter::NotFlags(flags) => ("NOT-FLAGS", flags).into_command(), + ClientFilter::NotLibName(name) => ("NOT-LIB-NAME", name).into_command(), + ClientFilter::NotLibVer(version) => ("NOT-LIB-VER", version).into_command(), + ClientFilter::NotDb(db) => ("NOT-DB", db).into_command(), + ClientFilter::NotCapa(capability) => ("NOT-CAPA", capability).into_command(), + ClientFilter::NotIp(ip) => ("NOT-IP", ip).into_command(), + } + } +} + +/// The lines of CLIENT LIST, each of the fields of a connection, `name=value` separated by spaces. +#[derive(Debug)] +pub struct ClientList(pub Vec>); + +impl FromValue for ClientList { + fn from_value(value: RespValue) -> Result { + let text = String::from_value(value)?; + let clients = text + .lines() + .filter(|line| !line.is_empty()) + .map(|line| { + line.split(' ') + .filter_map(|field| field.split_once('=')) + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect() + }) + .collect(); + Ok(ClientList(clients)) + } +} + +impl IntoCommand for ClientPauseMode { + fn into_command(self) -> Result { + match self { + ClientPauseMode::Write => "WRITE", + ClientPauseMode::All => "ALL", + } + .into_command() + } +} + +impl IntoCommand for ClientUnblockType { + fn into_command(self) -> Result { + match self { + ClientUnblockType::Timeout => "TIMEOUT", + ClientUnblockType::Error => "ERROR", + } + .into_command() + } +} + +/// `BUMPED epoch` or `STILL epoch`. +impl FromValue for ClusterBumpepochResult { + fn from_value(value: RespValue) -> Result { + let reply = String::from_value(value)?; + let invalid = || ErrorCode::Client(format!("unexpected reply: {reply:?}")); + let (outcome, epoch) = reply.split_once(' ').ok_or_else(invalid)?; + let epoch = epoch.parse().map_err(|_| invalid())?; + match outcome { + "BUMPED" => Ok(ClusterBumpepochResult::Bumped(epoch)), + "STILL" => Ok(ClusterBumpepochResult::Still(epoch)), + _ => Err(invalid()), + } + } +} + +impl IntoCommand for ClusterFailoverMode { + fn into_command(self) -> Result { + match self { + ClusterFailoverMode::Force => "FORCE", + ClusterFailoverMode::Takeover => "TAKEOVER", + } + .into_command() + } +} + +impl FromValue for ClusterGetslotmigrationsOperation { + fn from_value(value: RespValue) -> Result { + match String::from_value(value)?.as_str() { + "IMPORT" => Ok(ClusterGetslotmigrationsOperation::Import), + "EXPORT" => Ok(ClusterGetslotmigrationsOperation::Export), + operation => Err(ErrorCode::Client(format!( + "unexpected operation: {operation:?}" + ))), + } + } +} + +/// Ranges of slots, `start-end start-end ...`. +pub struct SlotRanges(pub Vec<(u16, u16)>); + +impl FromValue for SlotRanges { + fn from_value(value: RespValue) -> Result { + let ranges = String::from_value(value)?; + let invalid = || ErrorCode::Client(format!("unexpected slot ranges: {ranges:?}")); + ranges + .split_whitespace() + .map(|range| { + let (start, end) = range.split_once('-').ok_or_else(invalid)?; + Ok(( + start.parse().map_err(|_| invalid())?, + end.parse().map_err(|_| invalid())?, + )) + }) + .collect::>() + .map(SlotRanges) + } +} + +impl FromValue for ClusterGetslotmigrationsEntry { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + let slot_ranges: SlotRanges = fields.required("slot_ranges")?; + Ok(Self { + link_name: fields.required("link_name")?, + operation: fields.required("operation")?, + slot_ranges: slot_ranges.0, + target_node: fields.required("target_node")?, + source_node: fields.required("source_node")?, + create_time: fields.required("create_time")?, + last_update_time: fields.required("last_update_time")?, + last_ack_time: fields.required("last_ack_time")?, + state: fields.required("state")?, + message: fields.required("message")?, + cow_size: fields.required("cow_size")?, + }) + } +} + +/// `SLOTSRANGE start-slot end-slot [ start-slot end-slot ... ] NODE node-id [ AUTH username password ]` +impl IntoCommand for ClusterMigrateslotsGroup { + fn into_command(self) -> Result { + ( + "SLOTSRANGE", + NonEmpty::new("ranges", self.ranges), + "NODE", + self.node_id, + self.auth + .map(|(username, password)| ("AUTH", username, password)), + ) + .into_command() + } +} + +impl IntoCommand for ClusterResetMode { + fn into_command(self) -> Result { + match self { + ClusterResetMode::Hard => "HARD", + ClusterResetMode::Soft => "SOFT", + } + .into_command() + } +} + +impl IntoCommand for ClusterSetslotState { + fn into_command(self) -> Result { + match self { + ClusterSetslotState::Importing(node_id) => ("IMPORTING", node_id).into_command(), + ClusterSetslotState::Migrating(node_id) => ("MIGRATING", node_id).into_command(), + ClusterSetslotState::Node(node_id) => ("NODE", node_id).into_command(), + ClusterSetslotState::Stable => "STABLE".into_command(), + } + } +} + +impl IntoCommand for CommandlogType { + fn into_command(self) -> Result { + match self { + CommandlogType::Slow => "slow", + CommandlogType::LargeRequest => "large-request", + CommandlogType::LargeReply => "large-reply", + } + .into_command() + } +} + +/// `[id, timestamp, value, [argument ...], client-address, client-name]`, of SLOWLOG GET and +/// COMMANDLOG GET. +impl FromValue for CommandlogEntry { + fn from_value(value: RespValue) -> Result { + match <[RespValue; 6]>::try_from(items(value)?) { + Ok([id, timestamp, value, arguments, client_address, client_name]) => Ok(Self { + id: FromValue::from_value(id)?, + timestamp: FromValue::from_value(timestamp)?, + value: FromValue::from_value(value)?, + arguments: FromValue::from_value(arguments)?, + client_address: FromValue::from_value(client_address)?, + client_name: FromValue::from_value(client_name)?, + }), + Err(items) => Err(ErrorCode::Client(format!( + "unexpected number of items, expected 6: {}", + items.len() + ))), + } + } +} + +/// `TO host port [ FORCE ]` +impl IntoCommand for FailoverTarget { + fn into_command(self) -> Result { + ("TO", self.host, self.port, Flag("FORCE", self.force)).into_command() + } +} + +impl FromValue for LatencyHistogramEntry { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + // a map of each bucket to its cumulative count + let histogram: FlatPairs = fields.required("histogram_usec")?; + Ok(Self { + calls: fields.required("calls")?, + histogram_usec: histogram.0, + }) + } +} + +/// `[event, timestamp, latest, max, sum, count]` +impl FromValue for LatencyLatestEntry { + fn from_value(value: RespValue) -> Result { + match <[RespValue; 6]>::try_from(items(value)?) { + Ok([event, timestamp, latest, max, sum, count]) => Ok(Self { + event: FromValue::from_value(event)?, + timestamp: FromValue::from_value(timestamp)?, + latest: FromValue::from_value(latest)?, + max: FromValue::from_value(max)?, + sum: FromValue::from_value(sum)?, + count: FromValue::from_value(count)?, + }), + Err(items) => Err(ErrorCode::Client(format!( + "unexpected number of items, expected 6: {}", + items.len() + ))), + } + } +} + +impl FromValue for ModuleListEntry { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + name: fields.required("name")?, + ver: fields.required("ver")?, + path: fields.required("path")?, + args: fields.required("args")?, + }) + } +} + +/// `[ip, port, offset]`, the port and offset are strings. +impl FromValue for RolePrimaryReplica { + fn from_value(value: RespValue) -> Result { + let (ip, port, offset) = FromValue::from_value(value)?; + Ok(Self { + ip, + port: parse_string(port)?, + offset: parse_string(offset)?, + }) + } +} + +/// `["master", offset, [replica ...]]`, `["slave", host, port, state, offset]` or +/// `["sentinel", [primary ...]]`. +impl FromValue for RoleReply { + fn from_value(value: RespValue) -> Result { + let mut items = items(value)?.into_iter(); + let role = String::from_value(items.next().unwrap_or(RespValue::Null))?; + let mut next = || { + items + .next() + .ok_or_else(|| ErrorCode::Client(format!("too few items for the role {role:?}"))) + }; + match role.as_str() { + "master" => Ok(RoleReply::Primary(RolePrimary { + offset: FromValue::from_value(next()?)?, + replicas: FromValue::from_value(next()?)?, + })), + "slave" => Ok(RoleReply::Replica(RoleReplica { + primary_host: FromValue::from_value(next()?)?, + primary_port: u64::from_value(next()?)? + .try_into() + .map_err(|_| ErrorCode::Client("unexpected port".to_string()))?, + state: FromValue::from_value(next()?)?, + offset: FromValue::from_value(next()?)?, + })), + "sentinel" => Ok(RoleReply::Sentinel(FromValue::from_value(next()?)?)), + role => Err(ErrorCode::Client(format!("unexpected role: {role:?}"))), + } + } +} + +impl IntoCommand for ShutdownSave { + fn into_command(self) -> Result { + match self { + ShutdownSave::Nosave => "NOSAVE", + ShutdownSave::Save => "SAVE", + } + .into_command() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{ + array, bulk, bulks, double, fields, from, from_error, int, ok, simple, words, words_error, + }; + use crate::types::{ + DurationMilliseconds, DurationSeconds, UnixTimeMilliseconds, UnixTimeSeconds, + }; + + fn key(rule: AclKeyRule) -> AclSelectorRule { + AclSelectorRule::Keys(rule) + } + + fn channel(rule: AclChannelRule) -> AclSelectorRule { + AclSelectorRule::Channel(rule) + } + + fn command(rule: AclCommandRule) -> AclSelectorRule { + AclSelectorRule::Command(rule) + } + + fn user(rule: AclUserRule) -> AclRule { + AclRule::User(rule) + } + + #[test] + fn acl_key_rules() { + assert_eq!( + words(vec![ + AclKeyRule::Add("a:*".to_string()), + AclKeyRule::AddRead("r:*".to_string()), + AclKeyRule::AddWrite("w:*".to_string()), + AclKeyRule::AddReadWrite("rw:*".to_string()), + AclKeyRule::All, + AclKeyRule::Reset, + ]), + [ + "~a:*", + "%R~r:*", + "%W~w:*", + "%RW~rw:*", + "allkeys", + "resetkeys" + ] + ); + } + + #[test] + fn acl_channel_rules() { + assert_eq!( + words(vec![ + AclChannelRule::Add("news:*".to_string()), + AclChannelRule::All, + AclChannelRule::Reset, + ]), + ["&news:*", "allchannels", "resetchannels"] + ); + } + + #[test] + fn acl_command_rules() { + assert_eq!( + words(vec![ + AclCommandRule::Add("get".to_string()), + AclCommandRule::AddCategory("read".to_string()), + AclCommandRule::AddFirstArg(("config".to_string(), "get".to_string())), + AclCommandRule::All, + AclCommandRule::Remove("del".to_string()), + AclCommandRule::RemoveCategory("dangerous".to_string()), + AclCommandRule::Reset, + ]), + [ + "+get", + "+@read", + "+config|get", + "allcommands", + "-del", + "-@dangerous", + "nocommands" + ] + ); + } + + #[test] + fn acl_db_rules() { + assert_eq!( + words(vec![ + AclDbRule::Db(vec![0, 2]), + AclDbRule::All, + AclDbRule::Reset + ]), + ["db=0,2", "alldbs", "resetdbs"] + ); + assert_eq!( + words_error(AclDbRule::Db(vec![])), + "db requires at least one item" + ); + } + + #[test] + fn acl_user_rules() { + assert_eq!( + words(vec![ + AclUserRule::On, + AclUserRule::Off, + AclUserRule::Nopass, + AclUserRule::SanitizePayload, + AclUserRule::SkipSanitizePayload, + AclUserRule::Password("secret".to_string()), + AclUserRule::HashedPassword("abc".to_string()), + AclUserRule::RemovePassword("old".to_string()), + AclUserRule::RemoveHashedPassword("def".to_string()), + AclUserRule::Clearselectors, + AclUserRule::Reset, + ]), + [ + "on", + "off", + "nopass", + "sanitize-payload", + "skip-sanitize-payload", + ">secret", + "#abc", + "(ok()), AclDryrunResult::Allowed); + assert_eq!( + from::(bulk("User app has no permissions to run the 'set' command")), + AclDryrunResult::Denied( + "User app has no permissions to run the 'set' command".to_string() + ) + ); + } + + #[test] + fn acl_getuser() { + let value = fields(vec![ + ("flags", bulks(&["on", "sanitize-payload"])), + ("passwords", bulks(&["abc"])), + ("commands", bulk("+@read -debug")), + ("keys", bulk("~app:* %R~ro:*")), + ("channels", bulk("&news:*")), + ("databases", bulk("db=0,1")), + ( + "selectors", + array(vec![fields(vec![ + ("commands", bulk("+get")), + ("keys", bulk("%W~w:*")), + ("channels", bulk("")), + ])]), + ), + ]); + assert_eq!( + from::(value).0, + [ + user(AclUserRule::On), + user(AclUserRule::SanitizePayload), + user(AclUserRule::HashedPassword("abc".to_string())), + AclRule::Keys(AclKeyRule::Add("app:*".to_string())), + AclRule::Keys(AclKeyRule::AddRead("ro:*".to_string())), + AclRule::Channel(AclChannelRule::Reset), + AclRule::Channel(AclChannelRule::Add("news:*".to_string())), + AclRule::Db(AclDbRule::Db(vec![0, 1])), + AclRule::Command(AclCommandRule::AddCategory("read".to_string())), + AclRule::Command(AclCommandRule::Remove("debug".to_string())), + user(AclUserRule::Selector(vec![ + key(AclKeyRule::AddWrite("w:*".to_string())), + channel(AclChannelRule::Reset), + command(AclCommandRule::Add("get".to_string())), + ])), + ] + ); + } + + #[test] + fn acl_getuser_missing_field() { + let value = fields(vec![("flags", bulks(&["on"]))]); + assert_eq!( + from_error::(value), + "missing field: passwords" + ); + } + + #[test] + fn acl_list() { + let value = bulks(&[ + "user default on nopass sanitize-payload ~* &* +@all", + "user app off #abc resetchannels db=3 -@all +get (%R~x resetchannels +get) (~y +set)", + ]); + let entries = from::(value).0; + assert_eq!( + entries, + [ + AclListEntry { + username: "default".to_string(), + rules: vec![ + user(AclUserRule::On), + user(AclUserRule::Nopass), + user(AclUserRule::SanitizePayload), + AclRule::Keys(AclKeyRule::Add("*".to_string())), + AclRule::Channel(AclChannelRule::Add("*".to_string())), + AclRule::Command(AclCommandRule::All), + ], + }, + AclListEntry { + username: "app".to_string(), + rules: vec![ + user(AclUserRule::Off), + user(AclUserRule::HashedPassword("abc".to_string())), + AclRule::Channel(AclChannelRule::Reset), + AclRule::Db(AclDbRule::Db(vec![3])), + AclRule::Command(AclCommandRule::Reset), + AclRule::Command(AclCommandRule::Add("get".to_string())), + user(AclUserRule::Selector(vec![ + key(AclKeyRule::AddRead("x".to_string())), + channel(AclChannelRule::Reset), + command(AclCommandRule::Add("get".to_string())), + ])), + user(AclUserRule::Selector(vec![ + key(AclKeyRule::Add("y".to_string())), + command(AclCommandRule::Add("set".to_string())), + ])), + ], + }, + ] + ); + } + + #[test] + fn acl_list_unknown_rules() { + let entries = from::(bulks(&["user app on ?newer db=x"])).0; + assert_eq!( + entries[0].rules, + [ + user(AclUserRule::On), + AclRule::Other("?newer".to_string()), + AclRule::Other("db=x".to_string()), + ] + ); + } + + #[test] + fn acl_list_invalid() { + assert_eq!( + from_error::(bulks(&["bogus"])), + r#"unexpected ACL LIST line: "bogus""# + ); + assert_eq!( + from_error::(bulks(&["user app (~x"])), + r#"unexpected ACL LIST line: "user app (~x""# + ); + } + + #[test] + fn acl_list_round_trip() { + let line = + "user app on #abc ~app:* resetchannels &news:* -@all +get (%R~x resetchannels +get)"; + let entries = from::(bulks(&[line])).0; + let rules = entries.into_iter().next().unwrap().rules; + // selectors are sent as words between parentheses + assert_eq!( + words(rules) + .iter() + .map(|word| String::from_utf8_lossy(word.as_bytes())) + .collect::>() + .join(" "), + "on #abc ~app:* resetchannels &news:* nocommands +get ( %R~x resetchannels +get )" + ); + } + + #[test] + fn acl_log_entry() { + let mut entry = vec![ + ("count", int(1)), + ("reason", bulk("command")), + ("context", bulk("toplevel")), + ("object", bulk("get")), + ("username", bulk("app")), + ("age-seconds", double(1.5)), + ("client-info", bulk("id=3 addr=127.0.0.1:5000")), + ]; + let expected = AclLogEntry { + count: 1, + reason: "command".to_string(), + context: "toplevel".to_string(), + object: "get".to_string(), + username: "app".to_string(), + age_seconds: 1.5, + client_info: "id=3 addr=127.0.0.1:5000".to_string(), + entry_id: None, + timestamp_created: None, + timestamp_last_updated: None, + }; + // before Valkey 7.2 + assert_eq!(from::(fields(entry.clone())), expected); + entry.extend([ + ("entry-id", int(7)), + ("timestamp-created", int(1_700_000_000_000)), + ("timestamp-last-updated", int(1_700_000_000_500)), + ]); + assert_eq!( + from::(fields(entry)), + AclLogEntry { + entry_id: Some(7), + timestamp_created: Some(UnixTimeMilliseconds { + pxat: 1_700_000_000_000 + }), + timestamp_last_updated: Some(UnixTimeMilliseconds { + pxat: 1_700_000_000_500 + }), + ..expected + } + ); + } + + #[test] + fn keywords() { + assert_eq!( + words([BgsaveOperation::Schedule, BgsaveOperation::Cancel]), + ["SCHEDULE", "CANCEL"] + ); + assert_eq!( + words([ + ClientType::Normal, + ClientType::Primary, + ClientType::Replica, + ClientType::Pubsub + ]), + ["normal", "master", "replica", "pubsub"] + ); + assert_eq!( + words([ClientPauseMode::Write, ClientPauseMode::All]), + ["WRITE", "ALL"] + ); + assert_eq!( + words([ClientUnblockType::Timeout, ClientUnblockType::Error]), + ["TIMEOUT", "ERROR"] + ); + assert_eq!( + words([ClusterFailoverMode::Force, ClusterFailoverMode::Takeover]), + ["FORCE", "TAKEOVER"] + ); + assert_eq!( + words([ClusterResetMode::Hard, ClusterResetMode::Soft]), + ["HARD", "SOFT"] + ); + assert_eq!( + words([ + CommandlogType::Slow, + CommandlogType::LargeRequest, + CommandlogType::LargeReply + ]), + ["slow", "large-request", "large-reply"] + ); + assert_eq!( + words([ShutdownSave::Nosave, ShutdownSave::Save]), + ["NOSAVE", "SAVE"] + ); + } + + #[test] + fn client_filter() { + let filters = vec![ + ClientFilter::Id(vec![1, 2]), + ClientFilter::Type(ClientType::Primary), + ClientFilter::User("app".to_string()), + ClientFilter::Addr("127.0.0.1:5000".to_string()), + ClientFilter::Laddr("127.0.0.1:6379".to_string()), + ClientFilter::Skipme(false), + ClientFilter::Maxage(DurationSeconds { ex: 60 }), + ClientFilter::Name("worker".to_string()), + ClientFilter::Idle(DurationSeconds { ex: 5 }), + ClientFilter::Flags("N".to_string()), + ClientFilter::LibName("lib".to_string()), + ClientFilter::LibVer("1.0".to_string()), + ClientFilter::Db(1), + ClientFilter::Capa("redirect".to_string()), + ClientFilter::Ip("127.0.0.1".to_string()), + ]; + assert_eq!( + words(filters), + [ + "ID", + "1", + "2", + "TYPE", + "master", + "USER", + "app", + "ADDR", + "127.0.0.1:5000", + "LADDR", + "127.0.0.1:6379", + "SKIPME", + "NO", + "MAXAGE", + "60", + "NAME", + "worker", + "IDLE", + "5", + "FLAGS", + "N", + "LIB-NAME", + "lib", + "LIB-VER", + "1.0", + "DB", + "1", + "CAPA", + "redirect", + "IP", + "127.0.0.1" + ] + ); + assert_eq!(words(ClientFilter::Skipme(true)), ["SKIPME", "YES"]); + assert_eq!( + words_error(ClientFilter::Id(vec![])), + "id requires at least one item" + ); + } + + #[test] + fn client_filter_negated() { + let filters = vec![ + ClientFilter::NotId(vec![3]), + ClientFilter::NotType(ClientType::Pubsub), + ClientFilter::NotUser("app".to_string()), + ClientFilter::NotAddr("a".to_string()), + ClientFilter::NotLaddr("l".to_string()), + ClientFilter::NotName("n".to_string()), + ClientFilter::NotFlags("f".to_string()), + ClientFilter::NotLibName("ln".to_string()), + ClientFilter::NotLibVer("lv".to_string()), + ClientFilter::NotDb(2), + ClientFilter::NotCapa("c".to_string()), + ClientFilter::NotIp("i".to_string()), + ]; + assert_eq!( + words(filters), + [ + "NOT-ID", + "3", + "NOT-TYPE", + "pubsub", + "NOT-USER", + "app", + "NOT-ADDR", + "a", + "NOT-LADDR", + "l", + "NOT-NAME", + "n", + "NOT-FLAGS", + "f", + "NOT-LIB-NAME", + "ln", + "NOT-LIB-VER", + "lv", + "NOT-DB", + "2", + "NOT-CAPA", + "c", + "NOT-IP", + "i" + ] + ); + assert_eq!( + words_error(ClientFilter::NotId(vec![])), + "not-id requires at least one item" + ); + } + + #[test] + fn client_list() { + let value = bulk("id=3 addr=127.0.0.1:5000 name=\nid=4 addr=127.0.0.1:5001 name=worker\n"); + let clients = from::(value).0; + let client = |id: &str, addr: &str, name: &str| -> Map { + [("id", id), ("addr", addr), ("name", name)] + .into_iter() + .map(|(field, value)| (field.to_string(), value.to_string())) + .collect() + }; + assert_eq!( + clients, + [ + client("3", "127.0.0.1:5000", ""), + client("4", "127.0.0.1:5001", "worker") + ] + ); + assert!(from::(bulk("")).0.is_empty()); + } + + #[test] + fn cluster_bumpepoch_result() { + assert_eq!( + from::(simple("BUMPED 5")), + ClusterBumpepochResult::Bumped(5) + ); + assert_eq!( + from::(simple("STILL 3")), + ClusterBumpepochResult::Still(3) + ); + assert_eq!( + from_error::(simple("STILL")), + r#"unexpected reply: "STILL""# + ); + assert_eq!( + from_error::(simple("OTHER 1")), + r#"unexpected reply: "OTHER 1""# + ); + } + + #[test] + fn cluster_getslotmigrations_entry() { + let value = fields(vec![ + ("link_name", bulk("abc")), + ("operation", bulk("EXPORT")), + ("slot_ranges", bulk("0-100 200-200")), + ("target_node", bulk("target")), + ("source_node", bulk("source")), + ("create_time", int(1_700_000_000)), + ("last_update_time", int(1_700_000_001)), + ("last_ack_time", int(1_700_000_002)), + ("state", bulk("success")), + ("message", bulk("")), + ("cow_size", int(0)), + ]); + assert_eq!( + from::(value), + ClusterGetslotmigrationsEntry { + link_name: "abc".to_string(), + operation: ClusterGetslotmigrationsOperation::Export, + slot_ranges: vec![(0, 100), (200, 200)], + target_node: "target".to_string(), + source_node: "source".to_string(), + create_time: UnixTimeSeconds { + exat: 1_700_000_000 + }, + last_update_time: UnixTimeSeconds { + exat: 1_700_000_001 + }, + last_ack_time: UnixTimeSeconds { + exat: 1_700_000_002 + }, + state: "success".to_string(), + message: String::new(), + cow_size: 0, + } + ); + } + + #[test] + fn cluster_getslotmigrations_operation() { + assert_eq!( + from::(bulk("IMPORT")), + ClusterGetslotmigrationsOperation::Import + ); + assert_eq!( + from_error::(bulk("MOVE")), + r#"unexpected operation: "MOVE""# + ); + } + + #[test] + fn slot_ranges() { + assert_eq!(from::(bulk("")).0, []); + assert_eq!( + from_error::(bulk("0-100 200")), + r#"unexpected slot ranges: "0-100 200""# + ); + assert_eq!( + from_error::(bulk("0-x")), + r#"unexpected slot ranges: "0-x""# + ); + } + + #[test] + fn cluster_migrateslots_group() { + let group = ClusterMigrateslotsGroup { + ranges: vec![(0, 100), (200, 300)], + node_id: "node".to_string(), + auth: Some(("user".to_string(), "secret".to_string())), + }; + assert_eq!( + words(group), + [ + "SLOTSRANGE", + "0", + "100", + "200", + "300", + "NODE", + "node", + "AUTH", + "user", + "secret" + ] + ); + let group = ClusterMigrateslotsGroup { + ranges: vec![], + node_id: "node".to_string(), + auth: None, + }; + assert_eq!(words_error(group), "ranges requires at least one item"); + } + + #[test] + fn cluster_setslot_state() { + assert_eq!( + words(vec![ + ClusterSetslotState::Importing("a".to_string()), + ClusterSetslotState::Migrating("b".to_string()), + ClusterSetslotState::Node("c".to_string()), + ClusterSetslotState::Stable, + ]), + ["IMPORTING", "a", "MIGRATING", "b", "NODE", "c", "STABLE"] + ); + } + + #[test] + fn commandlog_entry() { + let value = array(vec![ + int(1), + int(1_700_000_000), + int(15), + bulks(&["GET", "key"]), + bulk("127.0.0.1:5000"), + bulk(""), + ]); + assert_eq!( + from::(value), + CommandlogEntry { + id: 1, + timestamp: UnixTimeSeconds { + exat: 1_700_000_000 + }, + value: 15, + arguments: vec!["GET".to_string(), "key".to_string()], + client_address: "127.0.0.1:5000".to_string(), + client_name: String::new(), + } + ); + assert_eq!( + from_error::(array(vec![int(1)])), + "unexpected number of items, expected 6: 1" + ); + } + + #[test] + fn failover_target() { + let target = |force| FailoverTarget { + host: "replica".to_string(), + port: 6380, + force, + }; + assert_eq!(words(target(true)), ["TO", "replica", "6380", "FORCE"]); + assert_eq!(words(target(false)), ["TO", "replica", "6380"]); + } + + #[test] + fn latency_histogram_entry() { + let value = fields(vec![ + ("calls", int(3)), + ( + "histogram_usec", + crate::test_util::map(vec![(int(1), int(2)), (int(4), int(3))]), + ), + ]); + assert_eq!( + from::(value), + LatencyHistogramEntry { + calls: 3, + histogram_usec: vec![(1, 2), (4, 3)], + } + ); + } + + #[test] + fn latency_latest_entry() { + let value = array(vec![ + bulk("command"), + int(1_700_000_000), + int(250), + int(500), + int(1000), + int(4), + ]); + assert_eq!( + from::(value), + LatencyLatestEntry { + event: "command".to_string(), + timestamp: UnixTimeSeconds { + exat: 1_700_000_000 + }, + latest: DurationMilliseconds { px: 250 }, + max: DurationMilliseconds { px: 500 }, + sum: DurationMilliseconds { px: 1000 }, + count: 4, + } + ); + assert_eq!( + from_error::(array(vec![])), + "unexpected number of items, expected 6: 0" + ); + } + + #[test] + fn module_list_entry() { + let value = fields(vec![ + ("name", bulk("lua")), + ("ver", int(1)), + ("path", bulk("lua")), + ("args", array(vec![])), + ]); + assert_eq!( + from::(value), + ModuleListEntry { + name: "lua".to_string(), + ver: 1, + path: "lua".to_string(), + args: vec![], + } + ); + } + + #[test] + fn role_primary() { + let value = array(vec![ + bulk("master"), + int(3129659), + array(vec![bulks(&["127.0.0.1", "9001", "3129242"])]), + ]); + assert_eq!( + from::(value), + RoleReply::Primary(RolePrimary { + offset: 3129659, + replicas: vec![RolePrimaryReplica { + ip: "127.0.0.1".to_string(), + port: 9001, + offset: 3129242, + }], + }) + ); + } + + #[test] + fn role_replica() { + let value = array(vec![ + bulk("slave"), + bulk("127.0.0.1"), + int(9000), + bulk("connected"), + int(3167038), + ]); + assert_eq!( + from::(value), + RoleReply::Replica(RoleReplica { + primary_host: "127.0.0.1".to_string(), + primary_port: 9000, + state: "connected".to_string(), + offset: 3167038, + }) + ); + } + + #[test] + fn role_sentinel() { + let value = array(vec![bulk("sentinel"), bulks(&["primary"])]); + assert_eq!( + from::(value), + RoleReply::Sentinel(vec!["primary".to_string()]) + ); + } + + #[test] + fn role_invalid() { + assert_eq!( + from_error::(array(vec![bulk("master"), int(1)])), + r#"too few items for the role "master""# + ); + assert_eq!( + from_error::(array(vec![bulk("leader")])), + r#"unexpected role: "leader""# + ); + assert_eq!( + from_error::(array(vec![ + bulk("slave"), + bulk("h"), + int(70000), + bulk("connected"), + int(1), + ])), + "unexpected port" + ); + assert_eq!( + from_error::(bulks(&["127.0.0.1", "port", "1"])), + r#"unexpected number: "port""# + ); + } +} diff --git a/crates/componentized-valkey/src/commands/bitmap.rs b/crates/componentized-valkey/src/commands/bitmap.rs new file mode 100644 index 0000000..9a9984f --- /dev/null +++ b/crates/componentized-valkey/src/commands/bitmap.rs @@ -0,0 +1,197 @@ +//! The bitmap commands: BITCOUNT, BITFIELD, BITPOS, SETBIT and the like. + +use crate::command::{IntoCommand, Words}; +use crate::types::{ + BitRange, BitfieldEncoding, BitfieldGet, BitfieldIncrby, BitfieldOffset, BitfieldOperation, + BitfieldOverflow, BitfieldSet, ErrorCode, +}; + +/// `start [ end [ BIT ] ]`, BYTE is the default, and left out for servers before it existed. +impl IntoCommand for BitRange { + fn into_command(self) -> Result { + match self { + BitRange::From(start) => start.into_command(), + BitRange::Bytes((start, end)) => (start, end).into_command(), + BitRange::Bits((start, end)) => (start, end, "BIT").into_command(), + } + } +} + +/// `i1` to `i64`, `u1` to `u63`, other widths fail. +impl IntoCommand for BitfieldEncoding { + fn into_command(self) -> Result { + match self { + BitfieldEncoding::Signed(width @ 1..=64) => format!("i{width}").into_command(), + BitfieldEncoding::Unsigned(width @ 1..=63) => format!("u{width}").into_command(), + BitfieldEncoding::Signed(width) => Err(ErrorCode::Client(format!( + "a signed bitfield is 1 to 64 bits wide, not {width}" + ))), + BitfieldEncoding::Unsigned(width) => Err(ErrorCode::Client(format!( + "an unsigned bitfield is 1 to 63 bits wide, not {width}" + ))), + } + } +} + +/// The offset in bits, or `#` and the index of a field of the encoding's width. +impl IntoCommand for BitfieldOffset { + fn into_command(self) -> Result { + match self { + BitfieldOffset::Bit(offset) => offset.into_command(), + BitfieldOffset::Field(field) => format!("#{field}").into_command(), + } + } +} + +impl IntoCommand for BitfieldOverflow { + fn into_command(self) -> Result { + let overflow = match self { + BitfieldOverflow::Wrap => "WRAP", + BitfieldOverflow::Sat => "SAT", + BitfieldOverflow::Fail => "FAIL", + }; + ("OVERFLOW", overflow).into_command() + } +} + +/// Also `BitfieldRoGet`, the same type. +impl IntoCommand for BitfieldGet { + fn into_command(self) -> Result { + ("GET", self.encoding, self.offset).into_command() + } +} + +impl IntoCommand for BitfieldSet { + fn into_command(self) -> Result { + ("SET", self.encoding, self.offset, self.value).into_command() + } +} + +impl IntoCommand for BitfieldIncrby { + fn into_command(self) -> Result { + ("INCRBY", self.encoding, self.offset, self.increment).into_command() + } +} + +/// A write is preceded by its overflow mode, when given. +impl IntoCommand for BitfieldOperation { + fn into_command(self) -> Result { + match self { + BitfieldOperation::Get(get) => get.into_command(), + BitfieldOperation::Set((overflow, set)) => (overflow, set).into_command(), + BitfieldOperation::Incrby((overflow, incrby)) => (overflow, incrby).into_command(), + } + } +} + +/// A bit, `1` or `0`, e.g. for SETBIT. +pub struct Bit(pub bool); + +impl IntoCommand for Bit { + fn into_command(self) -> Result { + if self.0 { "1" } else { "0" }.into_command() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{words, words_error}; + + #[test] + fn bit_range() { + assert_eq!(words(BitRange::From(5)), ["5"]); + assert_eq!(words(BitRange::Bytes((0, -1))), ["0", "-1"]); + assert_eq!(words(BitRange::Bits((1, 8))), ["1", "8", "BIT"]); + } + + #[test] + fn bitfield_encoding() { + assert_eq!( + words([ + BitfieldEncoding::Signed(1), + BitfieldEncoding::Signed(64), + BitfieldEncoding::Unsigned(1), + BitfieldEncoding::Unsigned(63), + ]), + ["i1", "i64", "u1", "u63"] + ); + assert_eq!( + words_error(BitfieldEncoding::Signed(0)), + "a signed bitfield is 1 to 64 bits wide, not 0" + ); + assert_eq!( + words_error(BitfieldEncoding::Signed(65)), + "a signed bitfield is 1 to 64 bits wide, not 65" + ); + assert_eq!( + words_error(BitfieldEncoding::Unsigned(64)), + "an unsigned bitfield is 1 to 63 bits wide, not 64" + ); + } + + #[test] + fn bitfield_offset() { + assert_eq!(words(BitfieldOffset::Bit(100)), ["100"]); + assert_eq!(words(BitfieldOffset::Field(2)), ["#2"]); + } + + #[test] + fn bitfield_overflow() { + assert_eq!( + words([ + BitfieldOverflow::Wrap, + BitfieldOverflow::Sat, + BitfieldOverflow::Fail + ]), + ["OVERFLOW", "WRAP", "OVERFLOW", "SAT", "OVERFLOW", "FAIL"] + ); + } + + #[test] + fn bitfield_operations() { + let get = BitfieldGet { + encoding: BitfieldEncoding::Unsigned(8), + offset: BitfieldOffset::Bit(0), + }; + let set = BitfieldSet { + encoding: BitfieldEncoding::Signed(8), + offset: BitfieldOffset::Field(1), + value: -5, + }; + let incrby = BitfieldIncrby { + encoding: BitfieldEncoding::Unsigned(4), + offset: BitfieldOffset::Bit(16), + increment: 3, + }; + assert_eq!(words(get.clone()), ["GET", "u8", "0"]); + assert_eq!( + words(vec![ + BitfieldOperation::Get(get), + BitfieldOperation::Set((Some(BitfieldOverflow::Sat), set)), + BitfieldOperation::Incrby((None, incrby)), + ]), + [ + "GET", "u8", "0", "OVERFLOW", "SAT", "SET", "i8", "#1", "-5", "INCRBY", "u4", "16", + "3" + ] + ); + } + + #[test] + fn bitfield_invalid_encoding() { + let get = BitfieldGet { + encoding: BitfieldEncoding::Unsigned(64), + offset: BitfieldOffset::Bit(0), + }; + assert_eq!( + words_error(BitfieldOperation::Get(get)), + "an unsigned bitfield is 1 to 63 bits wide, not 64" + ); + } + + #[test] + fn bit() { + assert_eq!(words([Bit(true), Bit(false)]), ["1", "0"]); + } +} diff --git a/crates/componentized-valkey/src/commands/connection.rs b/crates/componentized-valkey/src/commands/connection.rs new file mode 100644 index 0000000..8121b99 --- /dev/null +++ b/crates/componentized-valkey/src/commands/connection.rs @@ -0,0 +1,731 @@ +//! The connection commands: CLIENT of the connection itself, and COMMAND. + +use crate::command::{Flag, IntoCommand, NonEmpty, Words}; +use crate::types::{ + ClientSetinfoAttr, ClientTrackingMode, ClientTrackingOptions, ClientTrackinginfoRedirect, + ClientTrackinginfoReply, CommandDocsArgument, CommandDocsArgumentType, CommandDocsReply, + CommandInfoReply, CommandKeySpec, CommandKeySpecBeginSearch, CommandKeySpecFindKeys, + CommandKeySpecKeynum, CommandKeySpecKeyword, CommandKeySpecRange, CommandListFilter, ErrorCode, + Integer, RespValue, +}; +use crate::value::{Fields, FlatPairs, FromValue, entries, items}; + +impl IntoCommand for ClientSetinfoAttr { + fn into_command(self) -> Result { + match self { + ClientSetinfoAttr::LibName(name) => ("LIB-NAME", name).into_command(), + ClientSetinfoAttr::LibVer(version) => ("LIB-VER", version).into_command(), + } + } +} + +impl IntoCommand for ClientTrackingMode { + fn into_command(self) -> Result { + match self { + ClientTrackingMode::Optin => "OPTIN", + ClientTrackingMode::Optout => "OPTOUT", + } + .into_command() + } +} + +/// `[ PREFIX prefix [ PREFIX prefix ... ] ] [ BCAST ] [ OPTIN | OPTOUT ] [ NOLOOP ]` +impl IntoCommand for ClientTrackingOptions { + fn into_command(self) -> Result { + // PREFIX before each prefix + let prefixes = self.prefixes.map(|prefixes| { + let prefixes = prefixes.into_iter().map(|prefix| ("PREFIX", prefix)); + prefixes.collect::>() + }); + ( + NonEmpty::optional("prefixes", prefixes), + Flag("BCAST", self.bcast), + self.mode, + Flag("NOLOOP", self.noloop), + ) + .into_command() + } +} + +/// `-1` when tracking is off, `0` when not redirected, otherwise the ID of the connection. +impl FromValue for ClientTrackinginfoRedirect { + fn from_value(value: RespValue) -> Result { + match Integer::from_value(value)? { + -1 => Ok(ClientTrackinginfoRedirect::NotTracking), + 0 => Ok(ClientTrackinginfoRedirect::NotRedirected), + id if id > 0 => Ok(ClientTrackinginfoRedirect::Redirected(id)), + code => Err(ErrorCode::Client(format!("unexpected reply: {code}"))), + } + } +} + +impl FromValue for ClientTrackinginfoReply { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + flags: fields.required("flags")?, + redirect: fields.required("redirect")?, + prefixes: fields.required("prefixes")?, + }) + } +} + +/// `FILTERBY 〈 MODULE module-name | ACLCAT category | PATTERN pattern 〉` +impl IntoCommand for CommandListFilter { + fn into_command(self) -> Result { + let (filter, value) = match self { + CommandListFilter::Module(name) => ("MODULE", name), + CommandListFilter::Aclcat(category) => ("ACLCAT", category), + CommandListFilter::Pattern(pattern) => ("PATTERN", pattern), + }; + ("FILTERBY", filter, value).into_command() + } +} + +/// `ON` or `OFF`, e.g. for CLIENT NO-TOUCH. +pub struct OnOff(pub bool); + +impl IntoCommand for OnOff { + fn into_command(self) -> Result { + if self.0 { "ON" } else { "OFF" }.into_command() + } +} + +/// The entries of COMMAND and COMMAND INFO, each followed by its subcommands, nulls, for commands +/// that do not exist, left out. +pub struct CommandInfoList(pub Vec); + +impl FromValue for CommandInfoList { + fn from_value(value: RespValue) -> Result { + let mut commands = vec![]; + for command in items(value)? { + command_info(command, &mut commands)?; + } + Ok(CommandInfoList(commands)) + } +} + +/// `[name, arity, flags, first-key, last-key, step, acl-categories, tips, key-specs, subcommands]`, +/// the ACL categories since 6.0, the rest since 7.0. +fn command_info(value: RespValue, commands: &mut Vec) -> Result<(), ErrorCode> { + if value == RespValue::Null { + return Ok(()); + } + let mut items = items(value)?.into_iter(); + let mut next = || { + items + .next() + .ok_or_else(|| ErrorCode::Client("too few items for a command".to_string())) + }; + let info = CommandInfoReply { + name: FromValue::from_value(next()?)?, + arity: FromValue::from_value(next()?)?, + flags: FromValue::from_value(next()?)?, + first_key: FromValue::from_value(next()?)?, + last_key: FromValue::from_value(next()?)?, + step: FromValue::from_value(next()?)?, + acl_categories: next().map_or(Ok(vec![]), FromValue::from_value)?, + tips: next().map_or(Ok(vec![]), FromValue::from_value)?, + key_specs: next().map_or(Ok(vec![]), FromValue::from_value)?, + }; + let subcommands: Vec = next().map_or(Ok(vec![]), FromValue::from_value)?; + commands.push(info); + for subcommand in subcommands { + command_info(subcommand, commands)?; + } + Ok(()) +} + +impl FromValue for CommandKeySpec { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + let mut begin_search: Fields = fields.required("begin_search")?; + let mut find_keys: Fields = fields.required("find_keys")?; + Ok(Self { + notes: fields.optional("notes")?, + flags: fields.required("flags")?, + begin_search: begin_search_spec(&mut begin_search)?, + find_keys: find_keys_spec(&mut find_keys)?, + }) + } +} + +/// `{type, spec}`, a type this interface does not know is `unknown`. +fn begin_search_spec(fields: &mut Fields) -> Result { + let kind: String = fields.required("type")?; + let mut spec: Fields = fields.required("spec")?; + Ok(match kind.as_str() { + "index" => CommandKeySpecBeginSearch::Index(spec.required("index")?), + "keyword" => CommandKeySpecBeginSearch::Keyword(CommandKeySpecKeyword { + keyword: spec.required("keyword")?, + startfrom: spec.required("startfrom")?, + }), + _ => CommandKeySpecBeginSearch::Unknown, + }) +} + +/// `{type, spec}`, a type this interface does not know is `unknown`. +fn find_keys_spec(fields: &mut Fields) -> Result { + let kind: String = fields.required("type")?; + let mut spec: Fields = fields.required("spec")?; + Ok(match kind.as_str() { + "range" => CommandKeySpecFindKeys::Range(CommandKeySpecRange { + lastkey: spec.required("lastkey")?, + keystep: spec.required("keystep")?, + limit: spec.required("limit")?, + }), + "keynum" => CommandKeySpecFindKeys::Keynum(CommandKeySpecKeynum { + keynumidx: spec.required("keynumidx")?, + firstkey: spec.required("firstkey")?, + keystep: spec.required("keystep")?, + }), + _ => CommandKeySpecFindKeys::Unknown, + }) +} + +/// The documentation of COMMAND DOCS, a map by name, each followed by its subcommands. +pub struct CommandDocsList(pub Vec); + +impl FromValue for CommandDocsList { + fn from_value(value: RespValue) -> Result { + let mut commands = vec![]; + for (name, doc) in entries::(value)? { + command_doc(name, doc, &mut commands)?; + } + Ok(CommandDocsList(commands)) + } +} + +fn command_doc( + name: String, + mut fields: Fields, + commands: &mut Vec, +) -> Result<(), ErrorCode> { + let mut arguments = vec![]; + let nested: Option> = fields.optional("arguments")?; + command_arguments(nested.unwrap_or_default(), None, &mut arguments)?; + commands.push(CommandDocsReply { + name, + summary: fields.required("summary")?, + since: fields.required("since")?, + group: fields.required("group")?, + complexity: fields.optional("complexity")?, + module: fields.optional("module")?, + doc_flags: fields.optional("doc_flags")?.unwrap_or_default(), + deprecated_since: fields.optional("deprecated_since")?, + replaced_by: fields.optional("replaced_by")?, + history: fields.optional("history")?.unwrap_or_default(), + arguments, + }); + let subcommands: Option> = fields + .optional::>("subcommands")? + .map(|subcommands| subcommands.0); + for (name, doc) in subcommands.unwrap_or_default() { + command_doc(name, doc, commands)?; + } + Ok(()) +} + +/// The arguments, depth first, each followed by those it contains, which point to it as their +/// parent. +fn command_arguments( + nested: Vec, + parent: Option, + arguments: &mut Vec, +) -> Result<(), ErrorCode> { + for mut fields in nested { + let index = u32::try_from(arguments.len()) + .map_err(|_| ErrorCode::Client("too many arguments".to_string()))?; + let kind: String = fields.required("type")?; + arguments.push(CommandDocsArgument { + parent, + name: fields.required("name")?, + type_: argument_type(kind), + display_text: fields.optional("display_text")?, + key_spec_index: fields.optional("key_spec_index")?, + token: fields.optional("token")?, + summary: fields.optional("summary")?, + since: fields.optional("since")?, + deprecated_since: fields.optional("deprecated_since")?, + flags: fields.optional("flags")?.unwrap_or_default(), + }); + let contained: Option> = fields.optional("arguments")?; + command_arguments(contained.unwrap_or_default(), Some(index), arguments)?; + } + Ok(()) +} + +fn argument_type(kind: String) -> CommandDocsArgumentType { + match kind.as_str() { + "string" => CommandDocsArgumentType::String, + "integer" => CommandDocsArgumentType::Integer, + "double" => CommandDocsArgumentType::Double, + "key" => CommandDocsArgumentType::Key, + "pattern" => CommandDocsArgumentType::Pattern, + "unix-time" => CommandDocsArgumentType::UnixTime, + "pure-token" => CommandDocsArgumentType::PureToken, + "oneof" => CommandDocsArgumentType::Oneof, + "block" => CommandDocsArgumentType::Block, + _ => CommandDocsArgumentType::Other(kind), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{ + array, bulk, bulks, fields, from, from_error, int, map, words, words_error, + }; + + #[test] + fn client_setinfo_attr() { + assert_eq!( + words(ClientSetinfoAttr::LibName("lib".to_string())), + ["LIB-NAME", "lib"] + ); + assert_eq!( + words(ClientSetinfoAttr::LibVer("1.0".to_string())), + ["LIB-VER", "1.0"] + ); + } + + #[test] + fn client_tracking_options() { + let options = ClientTrackingOptions { + prefixes: Some(vec!["a:".to_string(), "b:".to_string()]), + bcast: true, + mode: Some(ClientTrackingMode::Optin), + noloop: true, + }; + assert_eq!( + words(options), + ["PREFIX", "a:", "PREFIX", "b:", "BCAST", "OPTIN", "NOLOOP"] + ); + let options = ClientTrackingOptions { + prefixes: None, + bcast: false, + mode: Some(ClientTrackingMode::Optout), + noloop: false, + }; + assert_eq!(words(options), ["OPTOUT"]); + let options = ClientTrackingOptions { + prefixes: Some(vec![]), + bcast: true, + mode: None, + noloop: false, + }; + assert_eq!(words_error(options), "prefixes requires at least one item"); + } + + #[test] + fn client_trackinginfo_redirect() { + assert_eq!( + from::(int(-1)), + ClientTrackinginfoRedirect::NotTracking + ); + assert_eq!( + from::(int(0)), + ClientTrackinginfoRedirect::NotRedirected + ); + assert_eq!( + from::(int(7)), + ClientTrackinginfoRedirect::Redirected(7) + ); + assert_eq!( + from_error::(int(-2)), + "unexpected reply: -2" + ); + } + + #[test] + fn client_trackinginfo_reply() { + let value = fields(vec![ + ("flags", bulks(&["on", "bcast"])), + ("redirect", int(0)), + ("prefixes", bulks(&["a:"])), + ]); + assert_eq!( + from::(value), + ClientTrackinginfoReply { + flags: vec!["on".to_string(), "bcast".to_string()], + redirect: ClientTrackinginfoRedirect::NotRedirected, + prefixes: vec!["a:".to_string()], + } + ); + } + + #[test] + fn command_list_filter() { + assert_eq!( + words(CommandListFilter::Module("search".to_string())), + ["FILTERBY", "MODULE", "search"] + ); + assert_eq!( + words(CommandListFilter::Aclcat("read".to_string())), + ["FILTERBY", "ACLCAT", "read"] + ); + assert_eq!( + words(CommandListFilter::Pattern("x*".to_string())), + ["FILTERBY", "PATTERN", "x*"] + ); + } + + #[test] + fn on_off() { + assert_eq!(words([OnOff(true), OnOff(false)]), ["ON", "OFF"]); + } + + fn get_key_spec() -> RespValue { + fields(vec![ + ("flags", bulks(&["RO", "access"])), + ( + "begin_search", + fields(vec![ + ("type", bulk("index")), + ("spec", fields(vec![("index", int(1))])), + ]), + ), + ( + "find_keys", + fields(vec![ + ("type", bulk("range")), + ( + "spec", + fields(vec![ + ("lastkey", int(0)), + ("keystep", int(1)), + ("limit", int(0)), + ]), + ), + ]), + ), + ]) + } + + fn command_info_value( + name: &str, + key_specs: Vec, + subcommands: Vec, + ) -> RespValue { + array(vec![ + bulk(name), + int(2), + bulks(&["readonly"]), + int(1), + int(1), + int(1), + bulks(&["@read"]), + array(vec![]), + array(key_specs), + array(subcommands), + ]) + } + + fn command_info_reply(name: &str, key_specs: Vec) -> CommandInfoReply { + CommandInfoReply { + name: name.to_string(), + arity: 2, + flags: vec!["readonly".to_string()], + first_key: 1, + last_key: 1, + step: 1, + acl_categories: vec!["@read".to_string()], + tips: vec![], + key_specs, + } + } + + #[test] + fn command_info_list() { + let value = array(vec![ + command_info_value("get", vec![get_key_spec()], vec![]), + // a command that does not exist + RespValue::Null, + command_info_value( + "config", + vec![], + vec![command_info_value("config|get", vec![], vec![])], + ), + ]); + let get_key_spec = CommandKeySpec { + notes: None, + flags: vec!["RO".to_string(), "access".to_string()], + begin_search: CommandKeySpecBeginSearch::Index(1), + find_keys: CommandKeySpecFindKeys::Range(CommandKeySpecRange { + lastkey: 0, + keystep: 1, + limit: 0, + }), + }; + assert_eq!( + from::(value).0, + [ + command_info_reply("get", vec![get_key_spec]), + command_info_reply("config", vec![]), + command_info_reply("config|get", vec![]), + ] + ); + } + + #[test] + fn command_info_before_valkey_7() { + let value = array(vec![array(vec![ + bulk("get"), + int(2), + bulks(&["readonly"]), + int(1), + int(1), + int(1), + ])]); + assert_eq!( + from::(value).0, + [CommandInfoReply { + acl_categories: vec![], + ..command_info_reply("get", vec![]) + }] + ); + assert_eq!( + from_error::(array(vec![array(vec![bulk("get")])])), + "too few items for a command" + ); + } + + #[test] + fn command_key_spec_kinds() { + let value = fields(vec![ + ("notes", bulk("a note")), + ("flags", bulks(&["RW"])), + ( + "begin_search", + fields(vec![ + ("type", bulk("keyword")), + ( + "spec", + fields(vec![("keyword", bulk("STREAMS")), ("startfrom", int(1))]), + ), + ]), + ), + ( + "find_keys", + fields(vec![ + ("type", bulk("keynum")), + ( + "spec", + fields(vec![ + ("keynumidx", int(0)), + ("firstkey", int(1)), + ("keystep", int(1)), + ]), + ), + ]), + ), + ]); + assert_eq!( + from::(value), + CommandKeySpec { + notes: Some("a note".to_string()), + flags: vec!["RW".to_string()], + begin_search: CommandKeySpecBeginSearch::Keyword(CommandKeySpecKeyword { + keyword: "STREAMS".to_string(), + startfrom: 1, + }), + find_keys: CommandKeySpecFindKeys::Keynum(CommandKeySpecKeynum { + keynumidx: 0, + firstkey: 1, + keystep: 1, + }), + } + ); + } + + #[test] + fn command_key_spec_unknown_kinds() { + let unknown = || fields(vec![("type", bulk("newer")), ("spec", fields(vec![]))]); + let value = fields(vec![ + ("flags", array(vec![])), + ("begin_search", unknown()), + ("find_keys", unknown()), + ]); + assert_eq!( + from::(value), + CommandKeySpec { + notes: None, + flags: vec![], + begin_search: CommandKeySpecBeginSearch::Unknown, + find_keys: CommandKeySpecFindKeys::Unknown, + } + ); + } + + fn doc(name: &str) -> CommandDocsReply { + CommandDocsReply { + name: name.to_string(), + summary: format!("about {name}"), + since: "1.0.0".to_string(), + group: "server".to_string(), + complexity: None, + module: None, + doc_flags: vec![], + deprecated_since: None, + replaced_by: None, + history: vec![], + arguments: vec![], + } + } + + fn argument( + parent: Option, + name: &str, + type_: CommandDocsArgumentType, + ) -> CommandDocsArgument { + CommandDocsArgument { + parent, + name: name.to_string(), + type_, + display_text: None, + key_spec_index: None, + token: None, + summary: None, + since: None, + deprecated_since: None, + flags: vec![], + } + } + + #[test] + fn command_docs_list() { + let value = map(vec![ + ( + bulk("set"), + fields(vec![ + ("summary", bulk("about set")), + ("since", bulk("1.0.0")), + ("group", bulk("server")), + ("complexity", bulk("O(1)")), + ( + "history", + array(vec![bulks(&[ + "2.6.12", + "Added the EX, PX, NX and XX options.", + ])]), + ), + ( + "arguments", + array(vec![ + fields(vec![ + ("name", bulk("key")), + ("type", bulk("key")), + ("display_text", bulk("key")), + ("key_spec_index", int(0)), + ]), + fields(vec![ + ("name", bulk("condition")), + ("type", bulk("oneof")), + ("since", bulk("2.6.12")), + ("flags", bulks(&["optional"])), + ( + "arguments", + array(vec![fields(vec![ + ("name", bulk("nx")), + ("type", bulk("pure-token")), + ("token", bulk("NX")), + ])]), + ), + ]), + fields(vec![("name", bulk("newer")), ("type", bulk("vector"))]), + ]), + ), + ]), + ), + ( + bulk("config"), + fields(vec![ + ("summary", bulk("about config")), + ("since", bulk("1.0.0")), + ("group", bulk("server")), + ("doc_flags", bulks(&["syscmd"])), + ( + "subcommands", + map(vec![( + bulk("config|get"), + fields(vec![ + ("summary", bulk("about config|get")), + ("since", bulk("1.0.0")), + ("group", bulk("server")), + ("deprecated_since", bulk("9.0.0")), + ("replaced_by", bulk("`OTHER`")), + ]), + )]), + ), + ]), + ), + ]); + assert_eq!( + from::(value).0, + [ + CommandDocsReply { + complexity: Some("O(1)".to_string()), + history: vec![( + "2.6.12".to_string(), + "Added the EX, PX, NX and XX options.".to_string() + )], + arguments: vec![ + CommandDocsArgument { + display_text: Some("key".to_string()), + key_spec_index: Some(0), + ..argument(None, "key", CommandDocsArgumentType::Key) + }, + CommandDocsArgument { + since: Some("2.6.12".to_string()), + flags: vec!["optional".to_string()], + ..argument(None, "condition", CommandDocsArgumentType::Oneof) + }, + CommandDocsArgument { + token: Some("NX".to_string()), + ..argument(Some(1), "nx", CommandDocsArgumentType::PureToken) + }, + argument( + None, + "newer", + CommandDocsArgumentType::Other("vector".to_string()) + ), + ], + ..doc("set") + }, + CommandDocsReply { + doc_flags: vec!["syscmd".to_string()], + ..doc("config") + }, + CommandDocsReply { + deprecated_since: Some("9.0.0".to_string()), + replaced_by: Some("`OTHER`".to_string()), + ..doc("config|get") + }, + ] + ); + } + + #[test] + fn command_docs_argument_types() { + let kinds = [ + ("string", CommandDocsArgumentType::String), + ("integer", CommandDocsArgumentType::Integer), + ("double", CommandDocsArgumentType::Double), + ("key", CommandDocsArgumentType::Key), + ("pattern", CommandDocsArgumentType::Pattern), + ("unix-time", CommandDocsArgumentType::UnixTime), + ("pure-token", CommandDocsArgumentType::PureToken), + ("oneof", CommandDocsArgumentType::Oneof), + ("block", CommandDocsArgumentType::Block), + ]; + for (kind, expected) in kinds { + assert_eq!(argument_type(kind.to_string()), expected); + } + } + + #[test] + fn command_docs_missing_field() { + let value = map(vec![(bulk("get"), fields(vec![("summary", bulk("s"))]))]); + assert_eq!(from_error::(value), "missing field: since"); + } +} diff --git a/crates/componentized-valkey/src/commands/geo.rs b/crates/componentized-valkey/src/commands/geo.rs new file mode 100644 index 0000000..954c04d --- /dev/null +++ b/crates/componentized-valkey/src/commands/geo.rs @@ -0,0 +1,269 @@ +//! The geo commands: GEOADD, GEODIST, GEOPOS, GEOSEARCH and the like. + +use crate::command::{Flag, IntoCommand, NonEmpty, NumKeys, Words}; +use crate::types::{ + Double, ErrorCode, GeoCoordinates, GeoUnit, GeoaddCondition, GeosearchCount, GeosearchFrom, + GeosearchShape, GeosearchWithResult, RespValue, StringOrBytes, +}; +use crate::value::{FromValue, items, unexpected}; + +/// `longitude latitude` +impl IntoCommand for GeoCoordinates { + fn into_command(self) -> Result { + (self.longitude, self.latitude).into_command() + } +} + +/// `[longitude, latitude]` +impl FromValue for GeoCoordinates { + fn from_value(value: RespValue) -> Result { + let (longitude, latitude) = FromValue::from_value(value)?; + Ok(GeoCoordinates { + longitude, + latitude, + }) + } +} + +impl IntoCommand for GeoUnit { + fn into_command(self) -> Result { + match self { + GeoUnit::M => "m", + GeoUnit::Km => "km", + GeoUnit::Ft => "ft", + GeoUnit::Mi => "mi", + } + .into_command() + } +} + +impl IntoCommand for GeoaddCondition { + fn into_command(self) -> Result { + match self { + GeoaddCondition::Nx => "NX", + GeoaddCondition::Xx => "XX", + } + .into_command() + } +} + +impl IntoCommand for GeosearchFrom { + fn into_command(self) -> Result { + match self { + GeosearchFrom::Member(member) => ("FROMMEMBER", member).into_command(), + GeosearchFrom::Lonlat(coordinates) => ("FROMLONLAT", coordinates).into_command(), + } + } +} + +/// The center, when the shape has one, then the shape. Also `GeosearchstoreShape`, the same type. +impl IntoCommand for GeosearchShape { + fn into_command(self) -> Result { + match self { + GeosearchShape::Radius(circle) => { + (circle.from, "BYRADIUS", circle.radius, circle.unit).into_command() + } + GeosearchShape::Box(rectangle) => ( + rectangle.from, + "BYBOX", + rectangle.width, + rectangle.height, + rectangle.unit, + ) + .into_command(), + GeosearchShape::Polygon(vertices) => { + ("BYPOLYGON", NumKeys(NonEmpty::new("vertices", vertices))).into_command() + } + } + } +} + +/// `COUNT count [ ANY ]`, also `GeosearchstoreCount`, the same type. +impl IntoCommand for GeosearchCount { + fn into_command(self) -> Result { + ("COUNT", self.count, Flag("ANY", self.any)).into_command() + } +} + +/// The member, or with any of WITHDIST, WITHHASH and WITHCOORD, an array of the member then the +/// details, in that order, told apart by their types. +impl FromValue for GeosearchWithResult { + fn from_value(value: RespValue) -> Result { + let mut result = GeosearchWithResult { + member: StringOrBytes::String(String::new()), + distance: None, + hash: None, + coordinates: None, + }; + let items = match value { + RespValue::Array(_) => items(value)?, + // without details, the member alone + value => vec![value], + }; + let mut items = items.into_iter(); + result.member = StringOrBytes::from_value(items.next().unwrap_or(RespValue::Null))?; + for item in items { + match item { + // a bulk string, as RESP3 has no other way to send a fixed precision double + RespValue::BulkString(_) => result.distance = Some(Double::from_value(item)?), + RespValue::Integer(hash) => result.hash = Some(hash), + RespValue::Array(_) => result.coordinates = Some(GeoCoordinates::from_value(item)?), + item => return Err(unexpected(&item)), + } + } + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{array, bulk, bulks, from, from_error, int, words, words_error}; + use crate::types::{GeosearchBox, GeosearchRadius}; + + fn coordinates(longitude: f64, latitude: f64) -> GeoCoordinates { + GeoCoordinates { + longitude, + latitude, + } + } + + #[test] + fn geo_coordinates() { + assert_eq!( + words(coordinates(13.361389, 38.115556)), + ["13.361389", "38.115556"] + ); + assert_eq!( + from::(bulks(&["13.5", "38.25"])), + coordinates(13.5, 38.25) + ); + assert_eq!( + from_error::(bulks(&["13.5"])), + "unexpected number of items, expected 2: 1" + ); + } + + #[test] + fn keywords() { + assert_eq!( + words([GeoUnit::M, GeoUnit::Km, GeoUnit::Ft, GeoUnit::Mi]), + ["m", "km", "ft", "mi"] + ); + assert_eq!( + words([GeoaddCondition::Nx, GeoaddCondition::Xx]), + ["NX", "XX"] + ); + } + + #[test] + fn geosearch_from() { + assert_eq!( + words(GeosearchFrom::Member("Palermo".into())), + ["FROMMEMBER", "Palermo"] + ); + assert_eq!( + words(GeosearchFrom::Lonlat(coordinates(15.0, 37.0))), + ["FROMLONLAT", "15", "37"] + ); + } + + #[test] + fn geosearch_shape() { + let radius = GeosearchShape::Radius(GeosearchRadius { + from: GeosearchFrom::Member("Palermo".into()), + radius: 200.5, + unit: GeoUnit::Km, + }); + assert_eq!( + words(radius), + ["FROMMEMBER", "Palermo", "BYRADIUS", "200.5", "km"] + ); + let rectangle = GeosearchShape::Box(GeosearchBox { + from: GeosearchFrom::Lonlat(coordinates(15.0, 37.0)), + width: 400.0, + height: 300.0, + unit: GeoUnit::Mi, + }); + assert_eq!( + words(rectangle), + ["FROMLONLAT", "15", "37", "BYBOX", "400", "300", "mi"] + ); + let polygon = GeosearchShape::Polygon(vec![ + coordinates(1.0, 2.0), + coordinates(3.0, 4.0), + coordinates(5.0, 6.0), + ]); + assert_eq!( + words(polygon), + ["BYPOLYGON", "3", "1", "2", "3", "4", "5", "6"] + ); + assert_eq!( + words_error(GeosearchShape::Polygon(vec![])), + "vertices requires at least one item" + ); + } + + #[test] + fn geosearch_count() { + assert_eq!( + words(GeosearchCount { + count: 5, + any: true + }), + ["COUNT", "5", "ANY"] + ); + assert_eq!( + words(GeosearchCount { + count: 5, + any: false + }), + ["COUNT", "5"] + ); + } + + #[test] + fn geosearch_with_result() { + assert_eq!( + from::(bulk("Palermo")), + GeosearchWithResult { + member: "Palermo".into(), + distance: None, + hash: None, + coordinates: None, + } + ); + let value = array(vec![ + bulk("Palermo"), + bulk("190.4424"), + int(3479099956230698), + bulks(&["13.5", "38.25"]), + ]); + assert_eq!( + from::(value), + GeosearchWithResult { + member: "Palermo".into(), + distance: Some(190.4424), + hash: Some(3479099956230698), + coordinates: Some(coordinates(13.5, 38.25)), + } + ); + let value = array(vec![bulk("Palermo"), int(3479099956230698)]); + assert_eq!( + from::(value), + GeosearchWithResult { + member: "Palermo".into(), + distance: None, + hash: Some(3479099956230698), + coordinates: None, + } + ); + assert_eq!( + from_error::(array(vec![ + bulk("Palermo"), + RespValue::Boolean(true) + ])), + "unexpected reply: boolean" + ); + } +} diff --git a/crates/componentized-valkey/src/commands/hash.rs b/crates/componentized-valkey/src/commands/hash.rs new file mode 100644 index 0000000..62e56c8 --- /dev/null +++ b/crates/componentized-valkey/src/commands/hash.rs @@ -0,0 +1,125 @@ +//! The hash commands: HEXPIRE, HPERSIST, HSETEX and the like. + +use crate::command::{IntoCommand, NonEmpty, NumKeys, Words}; +use crate::types::{ + ErrorCode, HexpireResult, HpersistResult, HsetexCondition, HsetexFieldCondition, Integer, + RespValue, +}; +use crate::value::FromValue; + +/// `FIELDS numfields field [ field ... ]`, e.g. for HEXPIRE, a field and its value counts as one. +pub struct Fields(pub NonEmpty); + +impl IntoCommand for Fields { + fn into_command(self) -> Result { + ("FIELDS", NumKeys(self.0)).into_command() + } +} + +/// Also `HexpireatResult`, `HpexpireResult` and `HpexpireatResult`, the same type. +impl FromValue for HexpireResult { + fn from_value(value: RespValue) -> Result { + match Integer::from_value(value)? { + -2 => Ok(Self::DoesNotExist), + 0 => Ok(Self::ConditionNotMet), + 1 => Ok(Self::Applied), + 2 => Ok(Self::ZeroSeconds), + code => Err(ErrorCode::Client(format!("unexpected reply: {code}"))), + } + } +} + +impl FromValue for HpersistResult { + fn from_value(value: RespValue) -> Result { + match Integer::from_value(value)? { + -2 => Ok(Self::DoesNotExist), + -1 => Ok(Self::NoExpiration), + 1 => Ok(Self::Removed), + code => Err(ErrorCode::Client(format!("unexpected reply: {code}"))), + } + } +} + +impl IntoCommand for HsetexCondition { + fn into_command(self) -> Result { + match self { + HsetexCondition::Nx => "NX", + HsetexCondition::Xx => "XX", + } + .into_command() + } +} + +impl IntoCommand for HsetexFieldCondition { + fn into_command(self) -> Result { + match self { + HsetexFieldCondition::Fnx => "FNX", + HsetexFieldCondition::Fxx => "FXX", + } + .into_command() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{from, from_error, int, words, words_error}; + + #[test] + fn fields() { + assert_eq!( + words(Fields(NonEmpty::new("fields", vec!["a", "b"]))), + ["FIELDS", "2", "a", "b"] + ); + // a field and its value counts as one + assert_eq!( + words(Fields(NonEmpty::new( + "fields", + vec![("f1", "v1"), ("f2", "v2")] + ))), + ["FIELDS", "2", "f1", "v1", "f2", "v2"] + ); + assert_eq!( + words_error(Fields(NonEmpty::new("fields", Vec::::new()))), + "fields requires at least one item" + ); + } + + #[test] + fn hexpire_result() { + assert_eq!(from::(int(-2)), HexpireResult::DoesNotExist); + assert_eq!( + from::(int(0)), + HexpireResult::ConditionNotMet + ); + assert_eq!(from::(int(1)), HexpireResult::Applied); + assert_eq!(from::(int(2)), HexpireResult::ZeroSeconds); + assert_eq!(from_error::(int(-1)), "unexpected reply: -1"); + } + + #[test] + fn hpersist_result() { + assert_eq!( + from::(int(-2)), + HpersistResult::DoesNotExist + ); + assert_eq!( + from::(int(-1)), + HpersistResult::NoExpiration + ); + assert_eq!(from::(int(1)), HpersistResult::Removed); + assert_eq!(from_error::(int(0)), "unexpected reply: 0"); + } + + #[test] + fn conditions() { + assert_eq!( + words([HsetexCondition::Nx, HsetexCondition::Xx]), + ["NX", "XX"] + ); + assert_eq!( + words([HsetexFieldCondition::Fnx, HsetexFieldCondition::Fxx]), + ["FNX", "FXX"] + ); + } +} diff --git a/crates/componentized-valkey/src/commands/keyspace.rs b/crates/componentized-valkey/src/commands/keyspace.rs new file mode 100644 index 0000000..8760662 --- /dev/null +++ b/crates/componentized-valkey/src/commands/keyspace.rs @@ -0,0 +1,196 @@ +//! The keyspace commands: MIGRATE, SCAN, SORT, TYPE and the like. + +use crate::command::{Flag, IntoCommand, NamedOption, NonEmpty, Words}; +use crate::types::{ErrorCode, KeyType, MigrateAuth, RespValue, SortOpts}; +use crate::value::{FromValue, unexpected}; + +/// The name of the type, e.g. for SCAN's TYPE. +impl IntoCommand for KeyType { + fn into_command(self) -> Result { + match self { + KeyType::String => "string".to_string(), + KeyType::List => "list".to_string(), + KeyType::Set => "set".to_string(), + KeyType::Zset => "zset".to_string(), + KeyType::Hash => "hash".to_string(), + KeyType::Stream => "stream".to_string(), + KeyType::Other(name) => name, + } + .into_command() + } +} + +/// The type of a key, by name, none for `none`, a key that does not exist. +pub struct OptionalKeyType(pub Option); + +impl FromValue for OptionalKeyType { + fn from_value(value: RespValue) -> Result { + // `none` rather than null, though the reply schema has null + let name: Option = FromValue::from_value(value)?; + Ok(OptionalKeyType(match name.as_deref() { + None | Some("none") => None, + Some("string") => Some(KeyType::String), + Some("list") => Some(KeyType::List), + Some("set") => Some(KeyType::Set), + Some("zset") => Some(KeyType::Zset), + Some("hash") => Some(KeyType::Hash), + Some("stream") => Some(KeyType::Stream), + Some(_) => name.map(KeyType::Other), + })) + } +} + +impl IntoCommand for MigrateAuth { + fn into_command(self) -> Result { + match self { + MigrateAuth::Password(password) => ("AUTH", password).into_command(), + MigrateAuth::UsernamePassword((username, password)) => { + ("AUTH2", username, password).into_command() + } + } + } +} + +/// `OK` when keys were moved, `NOKEY` when none of them exist. +pub struct Migrated(pub bool); + +impl FromValue for Migrated { + fn from_value(value: RespValue) -> Result { + match &value { + RespValue::String(reply) if reply == "OK" => Ok(Migrated(true)), + RespValue::String(reply) if reply == "NOKEY" => Ok(Migrated(false)), + _ => Err(unexpected(&value)), + } + } +} + +/// `[ BY pattern ] [ LIMIT offset count ] [ GET pattern ... ] [ ASC | DESC ] [ ALPHA ]`, also +/// `SortRoOpts`, the same type. +impl IntoCommand for SortOpts { + fn into_command(self) -> Result { + // GET before each pattern + let get = self.get.map(|patterns| { + let patterns = patterns.into_iter().map(|pattern| ("GET", pattern)); + patterns.collect::>() + }); + ( + NamedOption("BY", self.by), + self.limit, + NonEmpty::optional("get", get), + self.order, + Flag("ALPHA", self.alpha), + ) + .into_command() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{bulk, from, from_error, ok, simple, words, words_error}; + use crate::types::{Limit, SortOrder}; + + #[test] + fn key_type() { + assert_eq!( + words(vec![ + KeyType::String, + KeyType::List, + KeyType::Set, + KeyType::Zset, + KeyType::Hash, + KeyType::Stream, + KeyType::Other("ReJSON-RL".to_string()), + ]), + [ + "string", + "list", + "set", + "zset", + "hash", + "stream", + "ReJSON-RL" + ] + ); + } + + #[test] + fn optional_key_type() { + let names = [ + ("string", KeyType::String), + ("list", KeyType::List), + ("set", KeyType::Set), + ("zset", KeyType::Zset), + ("hash", KeyType::Hash), + ("stream", KeyType::Stream), + ("ReJSON-RL", KeyType::Other("ReJSON-RL".to_string())), + ]; + for (name, expected) in names { + assert_eq!(from::(simple(name)).0, Some(expected)); + } + assert_eq!(from::(simple("none")).0, None); + assert_eq!(from::(RespValue::Null).0, None); + } + + #[test] + fn migrate_auth() { + assert_eq!( + words(MigrateAuth::Password("secret".to_string())), + ["AUTH", "secret"] + ); + assert_eq!( + words(MigrateAuth::UsernamePassword(( + "user".to_string(), + "secret".to_string() + ))), + ["AUTH2", "user", "secret"] + ); + } + + #[test] + fn migrated() { + assert!(from::(ok()).0); + assert!(!from::(simple("NOKEY")).0); + assert_eq!( + from_error::(bulk("OK")), + r#"unexpected reply: bulk-string<"OK">"# + ); + } + + #[test] + fn sort_opts() { + let opts = SortOpts { + by: Some("weight_*".to_string()), + limit: Some(Limit { + offset: 0, + count: 10, + }), + get: Some(vec!["#".to_string(), "object_*".to_string()]), + order: Some(SortOrder::Desc), + alpha: true, + }; + assert_eq!( + words(opts), + [ + "BY", "weight_*", "LIMIT", "0", "10", "GET", "#", "GET", "object_*", "DESC", + "ALPHA" + ] + ); + let opts = SortOpts { + by: None, + limit: None, + get: None, + order: None, + alpha: false, + }; + assert_eq!(words(opts), Vec::::new()); + let opts = SortOpts { + by: None, + limit: None, + get: Some(vec![]), + order: None, + alpha: false, + }; + assert_eq!(words_error(opts), "get requires at least one item"); + } +} diff --git a/crates/componentized-valkey/src/commands/list.rs b/crates/componentized-valkey/src/commands/list.rs new file mode 100644 index 0000000..67d5963 --- /dev/null +++ b/crates/componentized-valkey/src/commands/list.rs @@ -0,0 +1,61 @@ +//! The list commands: LINSERT, LMOVE, LMPOP and the like. + +use crate::command::{IntoCommand, Words}; +use crate::types::{ErrorCode, Integer, LinsertPosition, LinsertResult, ListSide, RespValue}; +use crate::value::FromValue; + +impl IntoCommand for ListSide { + fn into_command(self) -> Result { + match self { + ListSide::Left => "LEFT", + ListSide::Right => "RIGHT", + } + .into_command() + } +} + +impl IntoCommand for LinsertPosition { + fn into_command(self) -> Result { + match self { + LinsertPosition::Before => "BEFORE", + LinsertPosition::After => "AFTER", + } + .into_command() + } +} + +/// The length of the list after the insert, `0` when the key does not exist, `-1` when the pivot +/// was not found. +impl FromValue for LinsertResult { + fn from_value(value: RespValue) -> Result { + match Integer::from_value(value)? { + -1 => Ok(Self::PivotNotFound), + 0 => Ok(Self::NoSuchKey), + length if length > 0 => Ok(Self::Inserted(length)), + code => Err(ErrorCode::Client(format!("unexpected reply: {code}"))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{from, from_error, int, words}; + + #[test] + fn keywords() { + assert_eq!(words([ListSide::Left, ListSide::Right]), ["LEFT", "RIGHT"]); + assert_eq!( + words([LinsertPosition::Before, LinsertPosition::After]), + ["BEFORE", "AFTER"] + ); + } + + #[test] + fn linsert_result() { + assert_eq!(from::(int(-1)), LinsertResult::PivotNotFound); + assert_eq!(from::(int(0)), LinsertResult::NoSuchKey); + assert_eq!(from::(int(4)), LinsertResult::Inserted(4)); + assert_eq!(from_error::(int(-2)), "unexpected reply: -2"); + } +} diff --git a/crates/componentized-valkey/src/commands/mod.rs b/crates/componentized-valkey/src/commands/mod.rs new file mode 100644 index 0000000..b452557 --- /dev/null +++ b/crates/componentized-valkey/src/commands/mod.rs @@ -0,0 +1,16 @@ +//! The conversions of the package's types, [`IntoCommand`](crate::IntoCommand) for those in a +//! command and [`FromValue`](crate::FromValue) for those in a reply, by the commands they are for, +//! as the sections of the `types` interface. + +pub mod admin; +pub mod bitmap; +pub mod connection; +pub mod geo; +pub mod hash; +pub mod keyspace; +pub mod list; +pub mod scripting; +pub mod shared; +pub mod sortedset; +pub mod stream; +pub mod string; diff --git a/crates/componentized-valkey/src/commands/scripting.rs b/crates/componentized-valkey/src/commands/scripting.rs new file mode 100644 index 0000000..ab08c1a --- /dev/null +++ b/crates/componentized-valkey/src/commands/scripting.rs @@ -0,0 +1,245 @@ +//! The scripting commands: EVAL, FCALL, FUNCTION and SCRIPT, and the FLUSH modes they share. + +use crate::command::{IntoCommand, NonEmpty, Words}; +use crate::types::{ + ErrorCode, FlushMode, FunctionListFunction, FunctionListLibrary, FunctionRestorePolicy, + FunctionStatsEngine, FunctionStatsReply, FunctionStatsRunning, RespValue, StringOrBytes, +}; +use crate::value::{Fields, FromValue}; + +impl IntoCommand for FlushMode { + fn into_command(self) -> Result { + match self { + FlushMode::Async => "ASYNC", + FlushMode::Sync => "SYNC", + } + .into_command() + } +} + +impl IntoCommand for FunctionRestorePolicy { + fn into_command(self) -> Result { + match self { + FunctionRestorePolicy::Flush => "FLUSH", + FunctionRestorePolicy::Append => "APPEND", + FunctionRestorePolicy::Replace => "REPLACE", + } + .into_command() + } +} + +/// `numkeys [ key ... ] [ arg ... ]`, e.g. for EVAL, `0` without keys. +pub struct KeysAndArgs(pub Option>, pub Option>); + +impl IntoCommand for KeysAndArgs { + fn into_command(self) -> Result { + let KeysAndArgs(keys, args) = self; + let numkeys = keys.as_ref().map_or(0, Vec::len); + ( + numkeys, + NonEmpty::optional("keys", keys), + NonEmpty::optional("args", args), + ) + .into_command() + } +} + +impl FromValue for FunctionListFunction { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + name: fields.required("name")?, + description: fields.required("description")?, + flags: fields.required("flags")?, + }) + } +} + +impl FromValue for FunctionListLibrary { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + library_name: fields.required("library_name")?, + engine: fields.required("engine")?, + functions: fields.required("functions")?, + // only with WITHCODE + library_code: fields.optional("library_code")?, + }) + } +} + +impl FromValue for FunctionStatsRunning { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + name: fields.required("name")?, + command: fields.required("command")?, + duration: fields.required("duration_ms")?, + }) + } +} + +impl FromValue for FunctionStatsEngine { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + libraries_count: fields.required("libraries_count")?, + functions_count: fields.required("functions_count")?, + }) + } +} + +impl FromValue for FunctionStatsReply { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + running_script: fields.required("running_script")?, + engines: fields.required("engines")?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{array, bulk, bulks, fields, from, from_error, int, words, words_error}; + use crate::types::DurationMilliseconds; + + #[test] + fn function_restore_policy() { + assert_eq!( + words([ + FunctionRestorePolicy::Flush, + FunctionRestorePolicy::Append, + FunctionRestorePolicy::Replace, + ]), + ["FLUSH", "APPEND", "REPLACE"] + ); + } + + #[test] + fn flush_mode() { + assert_eq!( + words([FlushMode::Async, FlushMode::Sync]), + ["ASYNC", "SYNC"] + ); + } + + #[test] + fn keys_and_args() { + assert_eq!(words(KeysAndArgs(None, None)), ["0"]); + assert_eq!( + words(KeysAndArgs( + Some(vec!["a".to_string(), "b".to_string()]), + Some(vec!["x".into()]) + )), + ["2", "a", "b", "x"] + ); + assert_eq!(words(KeysAndArgs(None, Some(vec!["x".into()]))), ["0", "x"]); + assert_eq!( + words_error(KeysAndArgs(Some(vec![]), None)), + "keys requires at least one item" + ); + assert_eq!( + words_error(KeysAndArgs(None, Some(vec![]))), + "args requires at least one item" + ); + } + + #[test] + fn function_list_library() { + let function = |name: &str, description: RespValue| { + fields(vec![ + ("name", bulk(name)), + ("description", description), + ("flags", bulks(&["no-writes"])), + ]) + }; + let value = fields(vec![ + ("library_name", bulk("mylib")), + ("engine", bulk("LUA")), + ( + "functions", + array(vec![ + function("first", bulk("the first")), + function("second", RespValue::Null), + ]), + ), + ]); + let expected = FunctionListLibrary { + library_name: "mylib".to_string(), + engine: "LUA".to_string(), + functions: vec![ + FunctionListFunction { + name: "first".to_string(), + description: Some("the first".to_string()), + flags: vec!["no-writes".to_string()], + }, + FunctionListFunction { + name: "second".to_string(), + description: None, + flags: vec!["no-writes".to_string()], + }, + ], + library_code: None, + }; + assert_eq!(from::(value), expected); + // WITHCODE + let value = fields(vec![ + ("library_name", bulk("mylib")), + ("engine", bulk("LUA")), + ("functions", array(vec![])), + ("library_code", bulk("#!lua name=mylib")), + ]); + assert_eq!( + from::(value), + FunctionListLibrary { + functions: vec![], + library_code: Some("#!lua name=mylib".to_string()), + ..expected + } + ); + } + + #[test] + fn function_stats_reply() { + let engines = fields(vec![( + "LUA", + fields(vec![ + ("libraries_count", int(2)), + ("functions_count", int(3)), + ]), + )]); + let value = fields(vec![ + ("running_script", RespValue::Null), + ("engines", engines.clone()), + ]); + let stats = from::(value); + assert_eq!(stats.running_script, None); + assert_eq!( + stats.engines.get("LUA"), + Some(&FunctionStatsEngine { + libraries_count: 2, + functions_count: 3, + }) + ); + let running = fields(vec![ + ("name", bulk("slow")), + ("command", bulks(&["FCALL", "slow", "0"])), + ("duration_ms", int(1500)), + ]); + let value = fields(vec![("running_script", running), ("engines", engines)]); + assert_eq!( + from::(value).running_script, + Some(FunctionStatsRunning { + name: "slow".to_string(), + command: vec!["FCALL".to_string(), "slow".to_string(), "0".to_string()], + duration: DurationMilliseconds { px: 1500 }, + }) + ); + assert_eq!( + from_error::(fields(vec![("engines", fields(vec![]))])), + "missing field: running_script" + ); + } +} diff --git a/crates/componentized-valkey/src/commands/shared.rs b/crates/componentized-valkey/src/commands/shared.rs new file mode 100644 index 0000000..fc9cc49 --- /dev/null +++ b/crates/componentized-valkey/src/commands/shared.rs @@ -0,0 +1,305 @@ +//! Shared by the commands: times, cursors, the expiration of keys and fields, limits and sort order. + +use crate::command::{IntoCommand, Words}; +use crate::types::{ + Cursor, DurationMilliseconds, DurationSeconds, ErrorCode, ExpiretimeReply, Integer, Limit, + PexpiretimeReply, PttlReply, RespValue, SortOrder, TtlMode, TtlReply, UnixTimeMilliseconds, + UnixTimeSeconds, +}; +use crate::value::FromValue; + +/// Whole seconds, e.g. for EX. +impl IntoCommand for DurationSeconds { + fn into_command(self) -> Result { + self.ex.into_command() + } +} + +/// Whole seconds, never negative. +impl FromValue for DurationSeconds { + fn from_value(value: RespValue) -> Result { + u64::from_value(value).map(|ex| DurationSeconds { ex }) + } +} + +/// Whole milliseconds, e.g. for PX. +impl IntoCommand for DurationMilliseconds { + fn into_command(self) -> Result { + self.px.into_command() + } +} + +/// Whole milliseconds, never negative. +impl FromValue for DurationMilliseconds { + fn from_value(value: RespValue) -> Result { + u64::from_value(value).map(|px| DurationMilliseconds { px }) + } +} + +/// Whole seconds since the Unix epoch, e.g. for EXAT. +impl IntoCommand for UnixTimeSeconds { + fn into_command(self) -> Result { + self.exat.into_command() + } +} + +/// Whole seconds since the Unix epoch. +impl FromValue for UnixTimeSeconds { + fn from_value(value: RespValue) -> Result { + Integer::from_value(value).map(|exat| UnixTimeSeconds { exat }) + } +} + +/// Whole milliseconds since the Unix epoch, e.g. for PXAT. +impl IntoCommand for UnixTimeMilliseconds { + fn into_command(self) -> Result { + self.pxat.into_command() + } +} + +/// Whole milliseconds since the Unix epoch. +impl FromValue for UnixTimeMilliseconds { + fn from_value(value: RespValue) -> Result { + Integer::from_value(value).map(|pxat| UnixTimeMilliseconds { pxat }) + } +} + +/// Milliseconds as seconds with a fraction, e.g. the timeout of BZPOPMIN, `1500` as `1.500`. +pub struct DecimalSeconds(pub DurationMilliseconds); + +impl IntoCommand for DecimalSeconds { + fn into_command(self) -> Result { + let milliseconds = self.0.px; + format!("{}.{:03}", milliseconds / 1_000, milliseconds % 1_000).into_command() + } +} + +/// `0` to start, failing to resume from `0`, which would restart the scan. +impl IntoCommand for Cursor { + fn into_command(self) -> Result { + match self { + Cursor::Start => "0".into_command(), + Cursor::Resume(cursor) if cursor == "0" => Err(ErrorCode::Client( + "resume requires the cursor a scan returned, use start to begin a scan".to_string(), + )), + Cursor::Resume(cursor) => cursor.into_command(), + } + } +} + +/// The cursor a scan returns to resume from, none once the scan is complete, `0`. +pub struct NextCursor(pub Option); + +impl FromValue for NextCursor { + fn from_value(value: RespValue) -> Result { + let cursor = String::from_value(value)?; + Ok(NextCursor( + (cursor != "0").then_some(Cursor::Resume(cursor)), + )) + } +} + +impl IntoCommand for TtlMode { + fn into_command(self) -> Result { + match self { + TtlMode::Nx => "NX", + TtlMode::Xx => "XX", + TtlMode::Gt => "GT", + TtlMode::Lt => "LT", + } + .into_command() + } +} + +/// The codes of TTL, HTTL and the like, `-2` when the key or field does not exist, `-1` when it has +/// no expiration, otherwise the time. +macro_rules! from_value_ttl_replies { + ($($variant:ident($time:ident) => $case:ident),*) => { + $( + impl FromValue for $variant { + fn from_value(value: RespValue) -> Result { + match value { + RespValue::Integer(-2) => Ok($variant::DoesNotExist), + RespValue::Integer(-1) => Ok($variant::NoExpiration), + value => $time::from_value(value).map($variant::$case), + } + } + } + )* + }; +} + +from_value_ttl_replies!( + TtlReply(DurationSeconds) => ExpiresIn, + PttlReply(DurationMilliseconds) => ExpiresIn, + ExpiretimeReply(UnixTimeSeconds) => ExpiresAt, + PexpiretimeReply(UnixTimeMilliseconds) => ExpiresAt +); + +/// `LIMIT offset count` +impl IntoCommand for Limit { + fn into_command(self) -> Result { + ("LIMIT", self.offset, self.count).into_command() + } +} + +impl IntoCommand for SortOrder { + fn into_command(self) -> Result { + match self { + SortOrder::Asc => "ASC", + SortOrder::Desc => "DESC", + } + .into_command() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{bulk, from, from_error, int, words, words_error}; + + #[test] + fn durations() { + assert_eq!(words(DurationSeconds { ex: 10 }), ["10"]); + assert_eq!(words(DurationMilliseconds { px: 1500 }), ["1500"]); + assert_eq!(from::(int(10)), DurationSeconds { ex: 10 }); + assert_eq!( + from::(int(1500)), + DurationMilliseconds { px: 1500 } + ); + assert_eq!( + from_error::(int(-1)), + "unexpected negative reply: -1" + ); + assert_eq!( + from_error::(int(-1)), + "unexpected negative reply: -1" + ); + } + + #[test] + fn unix_times() { + assert_eq!( + words(UnixTimeSeconds { + exat: 1_700_000_000 + }), + ["1700000000"] + ); + assert_eq!( + words(UnixTimeMilliseconds { + pxat: 1_700_000_000_123 + }), + ["1700000000123"] + ); + assert_eq!( + from::(int(1_700_000_000)), + UnixTimeSeconds { + exat: 1_700_000_000 + } + ); + assert_eq!( + from::(int(1_700_000_000_123)), + UnixTimeMilliseconds { + pxat: 1_700_000_000_123 + } + ); + } + + #[test] + fn decimal_seconds() { + assert_eq!( + words(DecimalSeconds(DurationMilliseconds { px: 1500 })), + ["1.500"] + ); + assert_eq!( + words(DecimalSeconds(DurationMilliseconds { px: 5 })), + ["0.005"] + ); + assert_eq!( + words(DecimalSeconds(DurationMilliseconds { px: 0 })), + ["0.000"] + ); + assert_eq!( + words(DecimalSeconds(DurationMilliseconds { px: 61_010 })), + ["61.010"] + ); + } + + #[test] + fn cursor() { + assert_eq!(words(Cursor::Start), ["0"]); + assert_eq!(words(Cursor::Resume("17".to_string())), ["17"]); + assert_eq!( + words_error(Cursor::Resume("0".to_string())), + "resume requires the cursor a scan returned, use start to begin a scan" + ); + } + + #[test] + fn next_cursor() { + assert_eq!(from::(bulk("0")).0, None); + assert_eq!( + from::(bulk("17")).0, + Some(Cursor::Resume("17".to_string())) + ); + } + + #[test] + fn ttl_mode() { + assert_eq!( + words([TtlMode::Nx, TtlMode::Xx, TtlMode::Gt, TtlMode::Lt]), + ["NX", "XX", "GT", "LT"] + ); + } + + #[test] + fn ttl_replies() { + assert_eq!(from::(int(-2)), TtlReply::DoesNotExist); + assert_eq!(from::(int(-1)), TtlReply::NoExpiration); + assert_eq!( + from::(int(30)), + TtlReply::ExpiresIn(DurationSeconds { ex: 30 }) + ); + assert_eq!( + from_error::(int(-3)), + "unexpected negative reply: -3" + ); + assert_eq!(from::(int(-2)), PttlReply::DoesNotExist); + assert_eq!( + from::(int(1500)), + PttlReply::ExpiresIn(DurationMilliseconds { px: 1500 }) + ); + assert_eq!( + from::(int(-1)), + ExpiretimeReply::NoExpiration + ); + assert_eq!( + from::(int(1_700_000_000)), + ExpiretimeReply::ExpiresAt(UnixTimeSeconds { + exat: 1_700_000_000 + }) + ); + assert_eq!( + from::(int(1_700_000_000_123)), + PexpiretimeReply::ExpiresAt(UnixTimeMilliseconds { + pxat: 1_700_000_000_123 + }) + ); + } + + #[test] + fn limit() { + assert_eq!( + words(Limit { + offset: 0, + count: 10 + }), + ["LIMIT", "0", "10"] + ); + } + + #[test] + fn sort_order() { + assert_eq!(words([SortOrder::Asc, SortOrder::Desc]), ["ASC", "DESC"]); + } +} diff --git a/crates/componentized-valkey/src/commands/sortedset.rs b/crates/componentized-valkey/src/commands/sortedset.rs new file mode 100644 index 0000000..67ad851 --- /dev/null +++ b/crates/componentized-valkey/src/commands/sortedset.rs @@ -0,0 +1,260 @@ +//! The sorted set commands: ZADD, ZINTER, ZMPOP, ZRANGE, ZUNION and the like. + +use crate::command::{Flag, IntoCommand, Words}; +use crate::types::{ + Double, ErrorCode, LexBound, Limit, RespValue, ScoreBound, StringOrBytes, ZaddComparison, + ZaddCondition, ZmpopEnd, ZrangeBy, ZsetAggregate, +}; +use crate::value::{FromValue, items}; + +/// The start and stop of a range of scores, as given, the start is the maximum with REV. +pub struct ScoreRange { + pub start: ScoreBound, + pub stop: ScoreBound, + pub rev: bool, +} + +impl IntoCommand for ScoreRange { + fn into_command(self) -> Result { + let bound = |bound, unbounded: &str| match bound { + ScoreBound::Inclusive(score) => score.to_string().into(), + ScoreBound::Exclusive(score) => format!("({score}").into(), + ScoreBound::Unbounded => unbounded.into(), + }; + let (low, high) = ("-inf", "+inf"); + let (start, stop) = if self.rev { (high, low) } else { (low, high) }; + Ok(vec![bound(self.start, start), bound(self.stop, stop)]) + } +} + +/// The start and stop of a range of members, as given, the start is the maximum with REV. +pub struct LexRange { + pub start: LexBound, + pub stop: LexBound, + pub rev: bool, +} + +impl IntoCommand for LexRange { + fn into_command(self) -> Result { + // a member may be bytes, the prefix keeps it so + let bound = |bound, unbounded: &str| match bound { + LexBound::Inclusive(member) => StringOrBytes::prefixed(member, "["), + LexBound::Exclusive(member) => StringOrBytes::prefixed(member, "("), + LexBound::Unbounded => unbounded.into(), + }; + let (low, high) = ("-", "+"); + let (start, stop) = if self.rev { (high, low) } else { (low, high) }; + Ok(vec![bound(self.start, start), bound(self.stop, stop)]) + } +} + +/// ZRANGE and ZRANGESTORE, `start stop [ BYSCORE | BYLEX ] [ REV ] [ LIMIT offset count ]`. +pub struct Zrange { + pub range: ZrangeBy, + pub rev: bool, + pub limit: Option, +} + +impl IntoCommand for Zrange { + fn into_command(self) -> Result { + let Zrange { range, rev, limit } = self; + let (range, by) = match range { + ZrangeBy::Rank((start, stop)) => ((start, stop).into_command()?, None), + ZrangeBy::Score((start, stop)) => ( + ScoreRange { start, stop, rev }.into_command()?, + Some("BYSCORE"), + ), + ZrangeBy::Lex((start, stop)) => { + (LexRange { start, stop, rev }.into_command()?, Some("BYLEX")) + } + }; + (range, by, Flag("REV", rev), limit).into_command() + } +} + +impl IntoCommand for ZsetAggregate { + fn into_command(self) -> Result { + let aggregate = match self { + ZsetAggregate::Sum => "SUM", + ZsetAggregate::Min => "MIN", + ZsetAggregate::Max => "MAX", + }; + ("AGGREGATE", aggregate).into_command() + } +} + +/// Also `BzmpopEnd`, the same type. +impl IntoCommand for ZmpopEnd { + fn into_command(self) -> Result { + match self { + ZmpopEnd::Min => "MIN", + ZmpopEnd::Max => "MAX", + } + .into_command() + } +} + +impl IntoCommand for ZaddCondition { + fn into_command(self) -> Result { + match self { + ZaddCondition::Nx => "NX", + ZaddCondition::Xx => "XX", + } + .into_command() + } +} + +impl IntoCommand for ZaddComparison { + fn into_command(self) -> Result { + match self { + ZaddComparison::Gt => "GT", + ZaddComparison::Lt => "LT", + } + .into_command() + } +} + +/// Members and their scores, e.g. from ZPOPMIN, which replies with one pair, or none, without a +/// count, and a list of pairs with one. +pub struct ScoredMembers(pub Vec<(StringOrBytes, Double)>); + +impl FromValue for ScoredMembers { + fn from_value(value: RespValue) -> Result { + let items = items(value)?; + match items.first() { + None => Ok(ScoredMembers(vec![])), + Some(RespValue::Array(_)) => items + .into_iter() + .map(FromValue::from_value) + .collect::>() + .map(ScoredMembers), + Some(_) => match <[RespValue; 2]>::try_from(items) { + Ok([member, score]) => Ok(ScoredMembers(vec![( + StringOrBytes::from_value(member)?, + Double::from_value(score)?, + )])), + Err(items) => Err(ErrorCode::Client(format!( + "unexpected number of items, expected a member and its score: {}", + items.len() + ))), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{array, bulk, bulks, double, from, from_error, words}; + + #[test] + fn score_range() { + let range = |start, stop, rev| ScoreRange { start, stop, rev }; + assert_eq!( + words(range( + ScoreBound::Inclusive(1.0), + ScoreBound::Exclusive(2.5), + false + )), + ["1", "(2.5"] + ); + assert_eq!( + words(range(ScoreBound::Unbounded, ScoreBound::Unbounded, false)), + ["-inf", "+inf"] + ); + // with REV, the start is the maximum + assert_eq!( + words(range(ScoreBound::Unbounded, ScoreBound::Unbounded, true)), + ["+inf", "-inf"] + ); + } + + #[test] + fn lex_range() { + let range = |start, stop, rev| LexRange { start, stop, rev }; + assert_eq!( + words(range( + LexBound::Inclusive("a".into()), + LexBound::Exclusive("c".into()), + false + )), + ["[a", "(c"] + ); + assert_eq!( + words(range(LexBound::Unbounded, LexBound::Unbounded, false)), + ["-", "+"] + ); + assert_eq!( + words(range(LexBound::Unbounded, LexBound::Unbounded, true)), + ["+", "-"] + ); + } + + #[test] + fn zrange() { + let by_rank = Zrange { + range: ZrangeBy::Rank((0, -1)), + rev: false, + limit: None, + }; + assert_eq!(words(by_rank), ["0", "-1"]); + let by_score = Zrange { + range: ZrangeBy::Score((ScoreBound::Unbounded, ScoreBound::Exclusive(1.0))), + rev: true, + limit: Some(Limit { + offset: 0, + count: 5, + }), + }; + assert_eq!( + words(by_score), + ["+inf", "(1", "BYSCORE", "REV", "LIMIT", "0", "5"] + ); + let by_lex = Zrange { + range: ZrangeBy::Lex((LexBound::Inclusive("a".into()), LexBound::Unbounded)), + rev: false, + limit: None, + }; + assert_eq!(words(by_lex), ["[a", "+", "BYLEX"]); + } + + #[test] + fn keywords() { + assert_eq!( + words([ZsetAggregate::Sum, ZsetAggregate::Min, ZsetAggregate::Max]), + ["AGGREGATE", "SUM", "AGGREGATE", "MIN", "AGGREGATE", "MAX"] + ); + assert_eq!(words([ZmpopEnd::Min, ZmpopEnd::Max]), ["MIN", "MAX"]); + assert_eq!(words([ZaddCondition::Nx, ZaddCondition::Xx]), ["NX", "XX"]); + assert_eq!( + words([ZaddComparison::Gt, ZaddComparison::Lt]), + ["GT", "LT"] + ); + } + + #[test] + fn scored_members() { + assert_eq!(from::(array(vec![])).0, []); + // one pair, flat + assert_eq!( + from::(array(vec![bulk("a"), double(1.5)])).0, + [(StringOrBytes::from("a"), 1.5)] + ); + // a list of pairs, the scores as doubles or, with RESP2, bulk strings + assert_eq!( + from::(array(vec![ + array(vec![bulk("a"), double(1.0)]), + bulks(&["b", "2"]), + ])) + .0, + [ + (StringOrBytes::from("a"), 1.0), + (StringOrBytes::from("b"), 2.0) + ] + ); + assert_eq!( + from_error::(bulks(&["a", "1", "b"])), + "unexpected number of items, expected a member and its score: 3" + ); + } +} diff --git a/crates/componentized-valkey/src/commands/stream.rs b/crates/componentized-valkey/src/commands/stream.rs new file mode 100644 index 0000000..9cfd81f --- /dev/null +++ b/crates/componentized-valkey/src/commands/stream.rs @@ -0,0 +1,510 @@ +//! The stream commands: XADD, XAUTOCLAIM, XCLAIM, XINFO, XPENDING, XTRIM and the like. + +use crate::command::{Flag, IntoCommand, NamedOption, Words}; +use crate::types::{ + DurationMilliseconds, ErrorCode, RespValue, StreamId, StreamTrim, StreamTrimEfficiency, + UnixTimeMilliseconds, XclaimOpts, XinfoConsumersEntry, XinfoGroupsEntry, XinfoStreamReply, + XinfoStreamReplyFull, XinfoStreamReplyFullConsumer, XinfoStreamReplyFullGroup, + XpendingExtendedEntry, XpendingSummary, +}; +use crate::value::{Fields, FromValue, OrNever}; + +impl IntoCommand for StreamTrim { + fn into_command(self) -> Result { + //〈 MAXLEN | MINID 〉 [ = | ~ ] threshold [ LIMIT count ] + match self { + StreamTrim::Maxlen(trim) => ( + "MAXLEN", + trim.efficiency, + trim.threshold, + NamedOption("LIMIT", trim.limit), + ) + .into_command(), + StreamTrim::Minid(trim) => ( + "MINID", + trim.efficiency, + trim.threshold, + NamedOption("LIMIT", trim.limit), + ) + .into_command(), + } + } +} + +impl IntoCommand for StreamTrimEfficiency { + fn into_command(self) -> Result { + // = | ~ + (match self { + StreamTrimEfficiency::Exact => "=", + StreamTrimEfficiency::MearlyExact => "~", + }) + .into_command() + } +} + +impl IntoCommand for XclaimOpts { + fn into_command(self) -> Result { + // [ IDLE ms ] [ TIME unix-time-milliseconds ] [ RETRYCOUNT count ] [ FORCE ] [ LASTID lastid ] + ( + NamedOption("IDLE", self.idle), + NamedOption("TIME", self.time), + NamedOption("RETRYCOUNT", self.retrycount), + Flag("FORCE", self.force), + NamedOption("LASTID", self.lastid), + ) + .into_command() + } +} + +/// A stream ID, or `0-0` for none, e.g. the start of the next XAUTOCLAIM, `0-0` once the scan is +/// complete. +pub struct OrZeroId(pub Option); + +pub const ZERO_ID: &str = "0-0"; + +/// `0-0` for none, e.g. to start XAUTOCLAIM from the beginning. +impl IntoCommand for OrZeroId { + fn into_command(self) -> Result { + self.0.unwrap_or_else(|| ZERO_ID.to_string()).into_command() + } +} + +impl FromValue for OrZeroId { + fn from_value(value: RespValue) -> Result { + let id = StreamId::from_value(value)?; + Ok(OrZeroId((id != ZERO_ID).then_some(id))) + } +} + +/// `[count, lowest-id, highest-id, [[consumer, count]...]]`, the IDs and consumers are null when +/// nothing is pending, and each consumer's count is a bulk string. +impl FromValue for XpendingSummary { + fn from_value(value: RespValue) -> Result { + let (count, lowest_id, highest_id, consumers): (_, _, _, Option>) = + FromValue::from_value(value)?; + let consumers = consumers + .unwrap_or_default() + .into_iter() + .map(|(consumer, count)| { + let count = count.parse().map_err(|_| { + ErrorCode::Client(format!( + "unexpected pending count, not an integer: {count:?}" + )) + })?; + Ok((consumer, count)) + }) + .collect::>()?; + Ok(Self { + count, + lowest_id, + highest_id, + consumers, + }) + } +} + +/// `[id, consumer, milliseconds-since-delivery, delivery-count]`. +impl FromValue for XpendingExtendedEntry { + fn from_value(value: RespValue) -> Result { + let (id, name, since_delivery, delivery_count) = FromValue::from_value(value)?; + Ok(Self { + id, + name, + since_delivery, + delivery_count, + }) + } +} + +impl FromValue for XinfoConsumersEntry { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + name: fields.required("name")?, + pending: fields.required("pending")?, + idle: fields.required("idle")?, + inactive: fields + .required::>("inactive")? + .0, + }) + } +} + +impl FromValue for XinfoGroupsEntry { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + name: fields.required("name")?, + consumers: fields.required("consumers")?, + pending: fields.required("pending")?, + last_delivered_id: fields.required("last-delivered-id")?, + entries_read: fields.required("entries-read")?, + lag: fields.required("lag")?, + }) + } +} + +impl FromValue for XinfoStreamReply { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + length: fields.required("length")?, + radix_tree_keys: fields.required("radix-tree-keys")?, + radix_tree_nodes: fields.required("radix-tree-nodes")?, + groups: fields.required("groups")?, + last_generated_id: fields.required::("last-generated-id")?.0, + max_deleted_entry_id: fields.required::("max-deleted-entry-id")?.0, + entries_added: fields.required("entries-added")?, + first_entry: fields.required("first-entry")?, + last_entry: fields.required("last-entry")?, + }) + } +} + +impl FromValue for XinfoStreamReplyFull { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + length: fields.required("length")?, + radix_tree_keys: fields.required("radix-tree-keys")?, + radix_tree_nodes: fields.required("radix-tree-nodes")?, + last_generated_id: fields.required::("last-generated-id")?.0, + max_deleted_entry_id: fields.required::("max-deleted-entry-id")?.0, + entries_added: fields.required("entries-added")?, + entries: fields.required("entries")?, + groups: fields.required("groups")?, + }) + } +} + +impl FromValue for XinfoStreamReplyFullGroup { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + name: fields.required("name")?, + last_delivered_id: fields.required("last-delivered-id")?, + entries_read: fields.required("entries-read")?, + lag: fields.required("lag")?, + pel_count: fields.required("pel-count")?, + pending: fields.required("pending")?, + consumers: fields.required("consumers")?, + }) + } +} + +impl FromValue for XinfoStreamReplyFullConsumer { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + name: fields.required("name")?, + seen_time: fields.required("seen-time")?, + active_time: fields + .required::>("active-time")? + .0, + pel_count: fields.required("pel-count")?, + pending: fields.required("pending")?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{array, bulk, bulks, fields, from, from_error, int, words}; + use crate::types::{StreamTrimMaxlen, StreamTrimMinid}; + + use crate::types::StringOrBytes; + use wit_bindgen::rt::Map; + + fn entry(id: &str, field: &str, value: &str) -> (StreamId, Map) { + ( + id.to_string(), + [(field.to_string(), StringOrBytes::from(value))] + .into_iter() + .collect(), + ) + } + + fn entry_value(id: &str, field: &str, value: &str) -> RespValue { + array(vec![bulk(id), bulks(&[field, value])]) + } + + #[test] + fn stream_trim() { + let maxlen = StreamTrim::Maxlen(StreamTrimMaxlen { + efficiency: Some(StreamTrimEfficiency::MearlyExact), + threshold: 1000, + limit: Some(10), + }); + assert_eq!(words(maxlen), ["MAXLEN", "~", "1000", "LIMIT", "10"]); + let minid = StreamTrim::Minid(StreamTrimMinid { + efficiency: Some(StreamTrimEfficiency::Exact), + threshold: "1-0".to_string(), + limit: None, + }); + assert_eq!(words(minid), ["MINID", "=", "1-0"]); + let minid = StreamTrim::Minid(StreamTrimMinid { + efficiency: None, + threshold: "1-0".to_string(), + limit: None, + }); + assert_eq!(words(minid), ["MINID", "1-0"]); + } + + #[test] + fn xclaim_opts() { + let opts = XclaimOpts { + idle: Some(DurationMilliseconds { px: 5000 }), + time: Some(UnixTimeMilliseconds { + pxat: 1_700_000_000_000, + }), + retrycount: Some(2), + force: true, + lastid: Some("1-0".to_string()), + }; + assert_eq!( + words(opts), + [ + "IDLE", + "5000", + "TIME", + "1700000000000", + "RETRYCOUNT", + "2", + "FORCE", + "LASTID", + "1-0" + ] + ); + let opts = XclaimOpts { + idle: None, + time: None, + retrycount: None, + force: false, + lastid: None, + }; + assert_eq!(words(opts), Vec::::new()); + } + + #[test] + fn or_zero_id() { + assert_eq!(words(OrZeroId(None)), ["0-0"]); + assert_eq!(words(OrZeroId(Some("1-1".to_string()))), ["1-1"]); + assert_eq!(from::(bulk("0-0")).0, None); + assert_eq!(from::(bulk("1-1")).0, Some("1-1".to_string())); + } + + #[test] + fn xpending_summary() { + let value = array(vec![ + int(3), + bulk("1-0"), + bulk("3-0"), + array(vec![bulks(&["alice", "2"]), bulks(&["bob", "1"])]), + ]); + assert_eq!( + from::(value), + XpendingSummary { + count: 3, + lowest_id: Some("1-0".to_string()), + highest_id: Some("3-0".to_string()), + consumers: vec![("alice".to_string(), 2), ("bob".to_string(), 1)], + } + ); + // nothing pending + let value = array(vec![ + int(0), + RespValue::Null, + RespValue::Null, + RespValue::Null, + ]); + assert_eq!( + from::(value), + XpendingSummary { + count: 0, + lowest_id: None, + highest_id: None, + consumers: vec![], + } + ); + let value = array(vec![ + int(1), + bulk("1-0"), + bulk("1-0"), + array(vec![bulks(&["alice", "many"])]), + ]); + assert_eq!( + from_error::(value), + r#"unexpected pending count, not an integer: "many""# + ); + } + + #[test] + fn xpending_extended_entry() { + let value = array(vec![bulk("1-0"), bulk("alice"), int(5000), int(2)]); + assert_eq!( + from::(value), + XpendingExtendedEntry { + id: "1-0".to_string(), + name: "alice".to_string(), + since_delivery: DurationMilliseconds { px: 5000 }, + delivery_count: 2, + } + ); + } + + #[test] + fn xinfo_consumers_entry() { + let consumer = |inactive| { + fields(vec![ + ("name", bulk("alice")), + ("pending", int(1)), + ("idle", int(100)), + ("inactive", inactive), + ]) + }; + let expected = XinfoConsumersEntry { + name: "alice".to_string(), + pending: 1, + idle: DurationMilliseconds { px: 100 }, + inactive: Some(DurationMilliseconds { px: 50 }), + }; + assert_eq!(from::(consumer(int(50))), expected); + // never active + assert_eq!( + from::(consumer(int(-1))), + XinfoConsumersEntry { + inactive: None, + ..expected + } + ); + } + + #[test] + fn xinfo_groups_entry() { + let value = fields(vec![ + ("name", bulk("group")), + ("consumers", int(2)), + ("pending", int(3)), + ("last-delivered-id", bulk("5-0")), + ("entries-read", RespValue::Null), + ("lag", int(1)), + ]); + assert_eq!( + from::(value), + XinfoGroupsEntry { + name: "group".to_string(), + consumers: 2, + pending: 3, + last_delivered_id: "5-0".to_string(), + entries_read: None, + lag: Some(1), + } + ); + } + + #[test] + fn xinfo_stream_reply() { + let value = fields(vec![ + ("length", int(2)), + ("radix-tree-keys", int(1)), + ("radix-tree-nodes", int(2)), + ("groups", int(1)), + ("last-generated-id", bulk("2-0")), + ("max-deleted-entry-id", bulk("0-0")), + ("entries-added", int(2)), + ("recorded-first-entry-id", bulk("1-0")), + ("first-entry", entry_value("1-0", "a", "1")), + ("last-entry", RespValue::Null), + ]); + assert_eq!( + from::(value), + XinfoStreamReply { + length: 2, + radix_tree_keys: 1, + radix_tree_nodes: 2, + groups: 1, + last_generated_id: Some("2-0".to_string()), + max_deleted_entry_id: None, + entries_added: 2, + first_entry: Some(entry("1-0", "a", "1")), + last_entry: None, + } + ); + } + + #[test] + fn xinfo_stream_reply_full() { + let consumer = fields(vec![ + ("name", bulk("alice")), + ("seen-time", int(1_700_000_000_000)), + ("active-time", int(-1)), + ("pel-count", int(1)), + ( + "pending", + array(vec![array(vec![ + bulk("1-0"), + int(1_700_000_000_000), + int(1), + ])]), + ), + ]); + let group = fields(vec![ + ("name", bulk("group")), + ("last-delivered-id", bulk("1-0")), + ("entries-read", int(1)), + ("lag", int(0)), + ("pel-count", int(1)), + ( + "pending", + array(vec![array(vec![ + bulk("1-0"), + bulk("alice"), + int(1_700_000_000_000), + int(1), + ])]), + ), + ("consumers", array(vec![consumer])), + ]); + let value = fields(vec![ + ("length", int(1)), + ("radix-tree-keys", int(1)), + ("radix-tree-nodes", int(2)), + ("last-generated-id", bulk("1-0")), + ("max-deleted-entry-id", bulk("0-0")), + ("entries-added", int(1)), + ("entries", array(vec![entry_value("1-0", "a", "1")])), + ("groups", array(vec![group])), + ]); + let time = UnixTimeMilliseconds { + pxat: 1_700_000_000_000, + }; + assert_eq!( + from::(value), + XinfoStreamReplyFull { + length: 1, + radix_tree_keys: 1, + radix_tree_nodes: 2, + last_generated_id: Some("1-0".to_string()), + max_deleted_entry_id: None, + entries_added: 1, + entries: vec![entry("1-0", "a", "1")], + groups: vec![XinfoStreamReplyFullGroup { + name: "group".to_string(), + last_delivered_id: "1-0".to_string(), + entries_read: Some(1), + lag: Some(0), + pel_count: 1, + pending: vec![("1-0".to_string(), "alice".to_string(), time, 1)], + consumers: vec![XinfoStreamReplyFullConsumer { + name: "alice".to_string(), + seen_time: time, + active_time: None, + pel_count: 1, + pending: vec![("1-0".to_string(), time, 1)], + }], + }], + } + ); + } +} diff --git a/crates/componentized-valkey/src/commands/string.rs b/crates/componentized-valkey/src/commands/string.rs new file mode 100644 index 0000000..7632c84 --- /dev/null +++ b/crates/componentized-valkey/src/commands/string.rs @@ -0,0 +1,214 @@ +//! The string commands: GETEX, LCS, MSETEX, SET and the like. + +use crate::command::{IntoCommand, Words}; +use crate::types::{ + ErrorCode, GetexTtl, LcsIdxMatch, LcsIdxReply, MsetexPrecondition, RespValue, SetPrecondition, + SetTtl, +}; +use crate::value::{Fields, FromValue, items}; + +/// Also `HgetexTtl`, the same type. +impl IntoCommand for GetexTtl { + fn into_command(self) -> Result { + match self { + GetexTtl::Ex(duration) => ("EX", duration).into_command(), + GetexTtl::Px(duration) => ("PX", duration).into_command(), + GetexTtl::Exat(unix_time) => ("EXAT", unix_time).into_command(), + GetexTtl::Pxat(unix_time) => ("PXAT", unix_time).into_command(), + GetexTtl::Persist => "PERSIST".into_command(), + } + } +} + +/// A map of the matches and the length of the longest common subsequence. +impl FromValue for LcsIdxReply { + fn from_value(value: RespValue) -> Result { + let mut fields = Fields::from_value(value)?; + Ok(Self { + matches: fields.required("matches")?, + len: fields.required("len")?, + }) + } +} + +/// The inclusive ranges of a match in each key and, with `WITHMATCHLEN`, its length. +impl FromValue for LcsIdxMatch { + fn from_value(value: RespValue) -> Result { + let mut items = items(value)?.into_iter(); + let (Some(key1_range), Some(key2_range), len, None) = + (items.next(), items.next(), items.next(), items.next()) + else { + return Err(ErrorCode::Client( + "unexpected LCS match, expected 2 or 3 items".to_string(), + )); + }; + Ok(Self { + key1_range: FromValue::from_value(key1_range)?, + key2_range: FromValue::from_value(key2_range)?, + len: len.map(FromValue::from_value).transpose()?, + }) + } +} + +impl IntoCommand for MsetexPrecondition { + fn into_command(self) -> Result { + match self { + MsetexPrecondition::Nx => "NX".into_command(), + MsetexPrecondition::Xx => "XX".into_command(), + } + } +} + +impl IntoCommand for SetPrecondition { + fn into_command(self) -> Result { + match self { + SetPrecondition::Nx => "NX".into_command(), + SetPrecondition::Xx => "XX".into_command(), + SetPrecondition::Ifeq(value) => ("IFEQ", value).into_command(), + SetPrecondition::Ifne(value) => ("IFNE", value).into_command(), + } + } +} + +/// Also `MsetexTtl` and `HsetexTtl`, the same type. +impl IntoCommand for SetTtl { + fn into_command(self) -> Result { + match self { + SetTtl::Ex(duration) => ("EX", duration).into_command(), + SetTtl::Px(duration) => ("PX", duration).into_command(), + SetTtl::Exat(unix_time) => ("EXAT", unix_time).into_command(), + SetTtl::Pxat(unix_time) => ("PXAT", unix_time).into_command(), + SetTtl::Keepttl => "KEEPTTL".into_command(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{array, fields, from, from_error, int, words}; + use crate::types::{ + DurationMilliseconds, DurationSeconds, UnixTimeMilliseconds, UnixTimeSeconds, + }; + + #[test] + fn getex_ttl() { + assert_eq!( + words(vec![ + GetexTtl::Ex(DurationSeconds { ex: 10 }), + GetexTtl::Px(DurationMilliseconds { px: 1500 }), + GetexTtl::Exat(UnixTimeSeconds { + exat: 1_700_000_000 + }), + GetexTtl::Pxat(UnixTimeMilliseconds { + pxat: 1_700_000_000_123 + }), + GetexTtl::Persist, + ]), + [ + "EX", + "10", + "PX", + "1500", + "EXAT", + "1700000000", + "PXAT", + "1700000000123", + "PERSIST" + ] + ); + } + + #[test] + fn set_ttl() { + assert_eq!( + words(vec![ + SetTtl::Ex(DurationSeconds { ex: 10 }), + SetTtl::Px(DurationMilliseconds { px: 1500 }), + SetTtl::Exat(UnixTimeSeconds { + exat: 1_700_000_000 + }), + SetTtl::Pxat(UnixTimeMilliseconds { + pxat: 1_700_000_000_123 + }), + SetTtl::Keepttl, + ]), + [ + "EX", + "10", + "PX", + "1500", + "EXAT", + "1700000000", + "PXAT", + "1700000000123", + "KEEPTTL" + ] + ); + } + + #[test] + fn preconditions() { + assert_eq!( + words(vec![ + SetPrecondition::Nx, + SetPrecondition::Xx, + SetPrecondition::Ifeq("old".into()), + SetPrecondition::Ifne("other".into()), + ]), + ["NX", "XX", "IFEQ", "old", "IFNE", "other"] + ); + assert_eq!( + words(vec![MsetexPrecondition::Nx, MsetexPrecondition::Xx]), + ["NX", "XX"] + ); + } + + fn range(start: i64, end: i64) -> RespValue { + array(vec![int(start), int(end)]) + } + + #[test] + fn lcs_idx_reply() { + let value = fields(vec![ + ( + "matches", + array(vec![ + array(vec![range(4, 7), range(5, 8), int(4)]), + array(vec![range(2, 3), range(0, 1)]), + ]), + ), + ("len", int(6)), + ]); + assert_eq!( + from::(value), + LcsIdxReply { + matches: vec![ + LcsIdxMatch { + key1_range: (4, 7), + key2_range: (5, 8), + len: Some(4), + }, + LcsIdxMatch { + key1_range: (2, 3), + key2_range: (0, 1), + len: None, + }, + ], + len: 6, + } + ); + } + + #[test] + fn lcs_idx_match_invalid() { + assert_eq!( + from_error::(array(vec![range(4, 7)])), + "unexpected LCS match, expected 2 or 3 items" + ); + assert_eq!( + from_error::(array(vec![range(4, 7), range(5, 8), int(4), int(1)])), + "unexpected LCS match, expected 2 or 3 items" + ); + } +} diff --git a/crates/componentized-valkey/src/lib.rs b/crates/componentized-valkey/src/lib.rs new file mode 100644 index 0000000..bfddc84 --- /dev/null +++ b/crates/componentized-valkey/src/lib.rs @@ -0,0 +1,50 @@ +//! Rust support for the `componentized:valkey` WIT package, for components that use or implement +//! its interfaces. +//! +//! - [`types`]: the package's `types` interface, shared by every component's bindings. +//! - [`resp`]: RESP serialization of a [`types::RespValue`], to and from bytes. +//! - [`command`]: [`IntoCommand`], converting Rust values into the words of a command. +//! - [`value`]: [`FromValue`], converting a reply into Rust values. +//! - [`commands`]: both conversions of the package's types, by the commands they are for. +//! +//! A component's bindings use these types for the package's types interface, so every component +//! shares them, e.g. +//! +//! ```text +//! wit_bindgen::generate!({ +//! with: { "componentized:valkey/types@0.3.0-dev": componentized_valkey::types }, +//! ... +//! }); +//! ``` + +mod bindings { + wit_bindgen::generate!({ + // `path` is relative to the manifest, `crates/componentized-valkey/` + path: "../../wit", + inline: " + package componentized:valkey-crate; + + world types { + import componentized:valkey/types@0.3.0-dev; + } + ", + world: "componentized:valkey-crate/types", + additional_derives: [PartialEq], + generate_all, + }); +} + +/// The `componentized:valkey/types` interface. +pub use bindings::componentized::valkey::types; + +pub mod command; +pub mod commands; +pub mod resp; +mod string_or_bytes; +pub mod value; + +#[cfg(test)] +mod test_util; + +pub use command::IntoCommand; +pub use value::FromValue; diff --git a/crates/componentized-valkey/src/resp.rs b/crates/componentized-valkey/src/resp.rs new file mode 100644 index 0000000..cbd303b --- /dev/null +++ b/crates/componentized-valkey/src/resp.rs @@ -0,0 +1,576 @@ +//! RESP serialization, from . + +use crate::types::{RespError as ErrorCode, RespNestedValue, RespValue}; + +/// up to 512 MB in length +const RESP_MAX_SIZE: i64 = 512 * 1024 * 1024; +const CRLF_BYTES: &'static [u8] = b"\r\n"; + +/// Encodes RESP value to RESP binary buffer. +/// # Examples +/// ``` +/// # use componentized_valkey::resp::encode; +/// # use componentized_valkey::types::RespValue; +/// let val = RespValue::String("OK".to_string()); +/// assert_eq!(encode(val), b"+OK\r\n"); +/// ``` +pub fn encode(value: RespValue) -> Vec { + let mut res: Vec = Vec::new(); + buf_encode(value, &mut res); + res +} + +#[inline] +fn buf_encode(value: RespValue, buf: &mut Vec) { + match value { + RespValue::Null => { + buf.push(b'_'); + buf.extend_from_slice(CRLF_BYTES); + } + RespValue::String(val) => { + buf.push(b'+'); + buf.extend_from_slice(val.as_bytes()); + buf.extend_from_slice(CRLF_BYTES); + } + RespValue::Error(val) => { + buf.push(b'-'); + buf.extend_from_slice(val.as_bytes()); + buf.extend_from_slice(CRLF_BYTES); + } + RespValue::Integer(val) => { + buf.push(b':'); + buf.extend_from_slice(val.to_string().as_bytes()); + buf.extend_from_slice(CRLF_BYTES); + } + RespValue::BulkString(val) => { + buf.push(b'$'); + buf.extend_from_slice(val.len().to_string().as_bytes()); + buf.extend_from_slice(CRLF_BYTES); + buf.extend(val); + buf.extend_from_slice(CRLF_BYTES); + } + RespValue::Array(val) => { + buf.push(b'*'); + buf.extend_from_slice(val.len().to_string().as_bytes()); + buf.extend_from_slice(CRLF_BYTES); + for item in val { + buf.extend_from_slice(&item); + } + } + RespValue::Boolean(ref val) => { + buf.push(b'#'); + match val { + true => buf.push(b't'), + false => buf.push(b'f'), + } + buf.extend_from_slice(CRLF_BYTES); + } + RespValue::Double(ref val) => { + buf.push(b','); + buf.extend_from_slice(val.to_string().to_lowercase().as_bytes()); + buf.extend_from_slice(CRLF_BYTES); + } + RespValue::BigNumber(ref val) => { + buf.push(b'('); + buf.extend_from_slice(val.as_bytes()); + buf.extend_from_slice(CRLF_BYTES); + } + RespValue::BulkError(ref val) => { + buf.push(b'!'); + buf.extend_from_slice(val.len().to_string().as_bytes()); + buf.extend_from_slice(CRLF_BYTES); + buf.extend_from_slice(val); + buf.extend_from_slice(CRLF_BYTES); + } + RespValue::VerbatimString((ref encoding, ref value)) => { + let val = format!("{encoding}:{value}"); + buf.push(b'='); + buf.extend_from_slice(val.len().to_string().as_bytes()); + buf.extend_from_slice(CRLF_BYTES); + buf.extend_from_slice(val.as_bytes()); + buf.extend_from_slice(CRLF_BYTES); + } + RespValue::Map(val) => { + buf.push(b'%'); + buf.extend_from_slice(val.len().to_string().as_bytes()); + buf.extend_from_slice(CRLF_BYTES); + for (key, value) in val { + buf.extend_from_slice(&key); + buf.extend_from_slice(&value); + } + } + RespValue::Set(val) => { + buf.push(b'~'); + buf.extend_from_slice(val.len().to_string().as_bytes()); + buf.extend_from_slice(CRLF_BYTES); + for item in val { + buf.extend_from_slice(&item); + } + } + RespValue::Push(val) => { + buf.push(b'>'); + buf.extend_from_slice(val.len().to_string().as_bytes()); + buf.extend_from_slice(CRLF_BYTES); + for item in val { + buf.extend_from_slice(&item); + } + } + } +} + +/// Decodes the RESP value `value` holds, which must be exactly one whole value. +/// +/// The elements of an aggregate, e.g. an array, are kept as the RESP they were received as, they +/// are only checked to be whole values. +/// +/// # Examples +/// ``` +/// # use componentized_valkey::resp::{decode, encode}; +/// # use componentized_valkey::types::RespValue; +/// let value = RespValue::BulkString(b"Hello".to_vec()); +/// assert_eq!(decode(&encode(value.clone())), Ok(value)); +/// ``` +pub fn decode(value: &[u8]) -> Result { + match frame_end(value, 0)? { + Some(end) if end == value.len() => decode_frame(value), + Some(end) => Err(format!( + "unexpected bytes after the RESP value: {}", + value.len() - end + )), + None => Err("incomplete RESP value".to_string()), + } +} + +/// The length of the first complete RESP value in `buf`, or `None` when the value is incomplete +/// and more bytes are needed. Only the framing is checked, the value is not decoded. +pub fn frame_len(buf: &[u8]) -> Result, ErrorCode> { + frame_end(buf, 0) +} + +/// The end of the RESP value starting at `start`, or `None` when it is incomplete. +fn frame_end(buf: &[u8], start: usize) -> Result, ErrorCode> { + let Some(prefix) = buf.get(start) else { + return Ok(None); + }; + // the header line, after the type prefix up to the CRLF + let Some(crlf) = buf[start..].windows(2).position(|w| w == CRLF_BYTES) else { + return Ok(None); + }; + let header = &buf[start + 1..start + crlf]; + let end = start + crlf + 2; + + let length = |min: i64| -> Result { + let int = parse_integer(header)?; + if int < min || int >= RESP_MAX_SIZE { + Err(format!("invalid length: {}", int))? + } + Ok(int) + }; + let children = |end: usize, count: i64| -> Result, ErrorCode> { + let mut end = end; + for _ in 0..count { + match frame_end(buf, end)? { + Some(child_end) => end = child_end, + None => return Ok(None), + } + } + Ok(Some(end)) + }; + + match prefix { + // simple values end with their header line + b'+' | b'-' | b':' | b'_' | b'#' | b',' | b'(' => Ok(Some(end)), + // blob values are followed by their length in bytes and a CRLF, a bulk string may be + // null with a length of -1 in RESP2 + b'$' | b'!' | b'=' => { + let int = length(if *prefix == b'$' { -1 } else { 0 })?; + if int == -1 { + return Ok(Some(end)); + } + let end = end + int as usize + 2; + match buf.get(end - 2..end) { + Some(CRLF_BYTES) => Ok(Some(end)), + Some(terminator) => Err(format!("invalid CRLF after blob: {terminator:?}")), + None => Ok(None), + } + } + // aggregates are followed by their elements, an array may be null with a length of -1 in + // RESP2 + b'*' | b'~' | b'>' => { + let int = length(if *prefix == b'*' { -1 } else { 0 })?; + if int == -1 { + return Ok(Some(end)); + } + children(end, int) + } + // maps are followed by a key and a value for each entry + b'%' => children(end, length(0)? * 2), + prefix => Err(format!("invalid RESP type: {:?}", prefix)), + } +} + +/// Decodes the whole RESP value at the start of `buf`, its framing already checked by `frame_end`. +fn decode_frame(buf: &[u8]) -> Result { + let crlf = buf + .windows(2) + .position(|w| w == CRLF_BYTES) + .ok_or("missing CRLF")?; + let header = &buf[1..crlf]; + let body = crlf + 2; + + // the bytes of a blob value, after its length line + let blob = || -> Result<&[u8], ErrorCode> { + let len = parse_integer(header)? as usize; + Ok(&buf[body..body + len]) + }; + // the elements of an aggregate value, each kept as its RESP + let elements = |count: i64| -> Result, ErrorCode> { + let mut start = body; + let mut elements = Vec::with_capacity(count as usize); + for _ in 0..count { + let end = frame_end(buf, start)?.ok_or("incomplete RESP value")?; + elements.push(buf[start..end].to_vec()); + start = end; + } + Ok(elements) + }; + + match buf[0] { + b'+' => parse_string(header).map(RespValue::String), + b'-' => parse_string(header).map(RespValue::Error), + b':' => parse_integer(header).map(RespValue::Integer), + b'_' => Ok(RespValue::Null), + b'#' => match header { + b"t" => Ok(RespValue::Boolean(true)), + b"f" => Ok(RespValue::Boolean(false)), + _ => Err(format!("invalid RESP boolean: {header:?}")), + }, + b',' => parse_double(header).map(RespValue::Double), + b'(' => parse_string(header).map(RespValue::BigNumber), + b'$' => match parse_integer(header)? { + // a null bulk string, RESP2's null + -1 => Ok(RespValue::Null), + _ => Ok(RespValue::BulkString(blob()?.to_vec())), + }, + b'!' => Ok(RespValue::BulkError(blob()?.to_vec())), + b'=' => { + let value = parse_string(blob()?)?; + let Some((encoding, value)) = value.split_once(':') else { + return Err("invalid verbatim string, missing encoding".to_string()); + }; + Ok(RespValue::VerbatimString(( + encoding.to_string(), + value.to_string(), + ))) + } + b'*' => match parse_integer(header)? { + // a null array, RESP2's null + -1 => Ok(RespValue::Null), + count => elements(count).map(RespValue::Array), + }, + b'~' => elements(parse_integer(header)?).map(RespValue::Set), + b'>' => elements(parse_integer(header)?).map(RespValue::Push), + b'%' => { + let mut entries = elements(parse_integer(header)? * 2)?.into_iter(); + let mut map = vec![]; + while let (Some(key), Some(value)) = (entries.next(), entries.next()) { + map.push((key, value)); + } + Ok(RespValue::Map(map)) + } + prefix => Err(format!("invalid RESP type: {prefix:?}")), + } +} + +/// RESP values for people to read, e.g. in errors, each with its type, e.g. `integer<1>` and +/// `array, null>`. A bulk string that is not UTF-8 is shown escaped, e.g. +/// `bulk-string`. +impl std::fmt::Display for RespValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::String(value) => write!(f, "string<{value:?}>"), + Self::Error(value) => write!(f, "error<{value:?}>"), + Self::Integer(value) => write!(f, "integer<{value}>"), + Self::BulkString(value) => match std::str::from_utf8(value) { + Ok(text) => write!(f, "bulk-string<{text:?}>"), + Err(_) => write!(f, "bulk-string", value.escape_ascii()), + }, + Self::Array(items) => write_items(f, "array", items), + Self::Null => f.write_str("null"), + Self::Boolean(value) => write!(f, "boolean<{value}>"), + Self::Double(value) => write!(f, "double<{value}>"), + Self::BigNumber(value) => write!(f, "big-number<{value}>"), + Self::BulkError(value) => match std::str::from_utf8(value) { + Ok(text) => write!(f, "bulk-error<{text:?}>"), + Err(_) => write!(f, "bulk-error", value.escape_ascii()), + }, + Self::VerbatimString((format, value)) => { + write!(f, "verbatim-string<{format}, {value:?}>") + } + Self::Map(entries) => { + f.write_str("map<")?; + for (index, (key, value)) in entries.iter().enumerate() { + if index > 0 { + f.write_str(", ")?; + } + write_nested(f, key)?; + f.write_str(": ")?; + write_nested(f, value)?; + } + f.write_str(">") + } + Self::Set(items) => write_items(f, "set", items), + Self::Push(items) => write_items(f, "push", items), + } + } +} + +/// The nested values of an aggregate, e.g. `array, null>`. +fn write_items( + f: &mut std::fmt::Formatter<'_>, + kind: &str, + items: &[RespNestedValue], +) -> std::fmt::Result { + write!(f, "{kind}<")?; + for (index, item) in items.iter().enumerate() { + if index > 0 { + f.write_str(", ")?; + } + write_nested(f, item)?; + } + f.write_str(">") +} + +fn write_nested(f: &mut std::fmt::Formatter<'_>, value: &[u8]) -> std::fmt::Result { + match decode(value) { + Ok(value) => write!(f, "{value}"), + Err(_) => f.write_str("--invalid-resp--"), + } +} + +#[inline] +fn parse_string(bytes: &[u8]) -> Result { + String::from_utf8(bytes.to_vec()).map_err(|err| err.to_string()) +} + +#[inline] +fn parse_integer(bytes: &[u8]) -> Result { + let str_integer = parse_string(bytes)?; + (str_integer.parse::()).map_err(|err| err.to_string()) +} + +#[inline] +fn parse_double(bytes: &[u8]) -> Result { + let str_double = parse_string(bytes)?; + (str_double.parse::()).map_err(|err| err.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_len_complete_values() { + let cases: &[&[u8]] = &[ + b"+OK\r\n", + b"-ERR oops\r\n", + b":42\r\n", + b"_\r\n", + b"#t\r\n", + b",1.5\r\n", + b"(123\r\n", + b"$5\r\nhello\r\n", + b"$0\r\n\r\n", + b"$-1\r\n", + b"!3\r\nerr\r\n", + b"=7\r\ntxt:abc\r\n", + b"*0\r\n", + b"*-1\r\n", + b"*2\r\n$1\r\na\r\n:1\r\n", + b"%1\r\n+key\r\n*1\r\n$2\r\n\r\n\r\n", + b"~2\r\n+a\r\n+b\r\n", + b">1\r\n+msg\r\n", + ]; + for value in cases { + assert_eq!(frame_len(value).unwrap(), Some(value.len()), "{value:?}"); + } + } + + #[test] + fn frame_len_incomplete_values() { + let value: &[u8] = b"*3\r\n$5\r\nhello\r\n%1\r\n+k\r\n$3\r\nval\r\n:7\r\n"; + for len in 0..value.len() { + assert_eq!(frame_len(&value[..len]).unwrap(), None, "{len}"); + } + assert_eq!(frame_len(value).unwrap(), Some(value.len())); + } + + #[test] + fn frame_len_ignores_following_bytes() { + assert_eq!(frame_len(b"+OK\r\n:1\r\n").unwrap(), Some(5)); + // a bulk string's content may contain CRLF + assert_eq!(frame_len(b"$4\r\n\r\n\r\n\r\n+next\r\n").unwrap(), Some(10)); + } + + #[test] + fn frame_len_invalid_values() { + assert!(frame_len(b"?nope\r\n").is_err()); + assert!(frame_len(b"$-2\r\n").is_err()); + assert!(frame_len(b"%-1\r\n").is_err()); + assert!(frame_len(b"*x\r\n").is_err()); + } + + #[test] + fn decode_round_trips() { + let values = vec![ + RespValue::String("OK".to_string()), + RespValue::Error("ERR oops".to_string()), + RespValue::Integer(-42), + RespValue::BulkString(b"a\r\nb".to_vec()), + RespValue::Null, + RespValue::Boolean(false), + RespValue::Double(1.5), + RespValue::BigNumber("12345678901234567890".to_string()), + RespValue::BulkError(b"SYNTAX invalid".to_vec()), + RespValue::BulkError(b"ERR \x80\r\n".to_vec()), + RespValue::VerbatimString(("txt".to_string(), "a:b".to_string())), + RespValue::Array(vec![encode(RespValue::Integer(1)), encode(RespValue::Null)]), + RespValue::Set(vec![encode(RespValue::String("a".to_string()))]), + RespValue::Push(vec![encode(RespValue::String("message".to_string()))]), + RespValue::Map(vec![( + encode(RespValue::String("key".to_string())), + encode(RespValue::Array(vec![encode(RespValue::Integer(1))])), + )]), + ]; + for value in values { + assert_eq!(decode(&encode(value.clone())), Ok(value)); + } + } + + #[test] + fn decode_keeps_elements_as_received() { + // a nested array, kept as its RESP rather than decoded and encoded again + let decoded = decode(b"*2\r\n*1\r\n:1\r\n$1\r\na\r\n"); + assert_eq!( + decoded, + Ok(RespValue::Array(vec![ + b"*1\r\n:1\r\n".to_vec(), + b"$1\r\na\r\n".to_vec() + ])) + ); + } + + #[test] + fn decode_resp2_nulls() { + assert_eq!(decode(b"$-1\r\n"), Ok(RespValue::Null)); + assert_eq!(decode(b"*-1\r\n"), Ok(RespValue::Null)); + } + + #[test] + fn decode_needs_exactly_one_value() { + assert!(decode(b"+OK\r\n:1\r\n").is_err()); + assert!(decode(b"*2\r\n:1\r\n").is_err()); + assert!(decode(&[]).is_err()); + } + + #[test] + fn decode_invalid_values() { + assert!(decode(b"$3\r\nabcde\r\n").is_err()); + assert!(decode(b"#x\r\n").is_err()); + assert!(decode(b"=3\r\ntxt\r\n").is_err()); + assert!(decode(b":one\r\n").is_err()); + } + + #[test] + fn frame_len_matches_encode() { + let value = RespValue::Array(vec![ + encode(RespValue::BulkString(b"a\r\nb".to_vec())), + encode(RespValue::Integer(-3)), + ]); + let encoded = encode(value); + assert_eq!(frame_len(&encoded).unwrap(), Some(encoded.len())); + } + + #[test] + fn decode_binary_bulk_strings() { + let value = RespValue::BulkString(vec![0x80, 0x00, b'\r', b'\n', 0xff]); + assert_eq!(decode(&encode(value.clone())), Ok(value)); + } + + #[test] + fn display_scalars() { + let display = |value: RespValue| value.to_string(); + assert_eq!( + display(RespValue::String("OK".to_string())), + r#"string<"OK">"# + ); + assert_eq!( + display(RespValue::Error("ERR x".to_string())), + r#"error<"ERR x">"# + ); + assert_eq!(display(RespValue::Integer(-1)), "integer<-1>"); + assert_eq!( + display(RespValue::BulkString(b"a\"b".to_vec())), + r#"bulk-string<"a\"b">"# + ); + // bytes that are not UTF-8 are escaped + assert_eq!( + display(RespValue::BulkString(vec![0x80, b'a'])), + r#"bulk-string"# + ); + assert_eq!(display(RespValue::Null), "null"); + assert_eq!(display(RespValue::Boolean(true)), "boolean"); + assert_eq!(display(RespValue::Double(1.5)), "double<1.5>"); + assert_eq!( + display(RespValue::BigNumber("1".to_string())), + "big-number<1>" + ); + assert_eq!( + display(RespValue::BulkError(b"SYNTAX".to_vec())), + r#"bulk-error<"SYNTAX">"# + ); + assert_eq!( + display(RespValue::BulkError(b"ERR \x80".to_vec())), + r#"bulk-error"# + ); + assert_eq!( + display(RespValue::VerbatimString(( + "txt".to_string(), + "hi".to_string() + ))), + r#"verbatim-string"# + ); + } + + #[test] + fn display_aggregates() { + let bulk = |text: &str| encode(RespValue::BulkString(text.as_bytes().to_vec())); + let int = |value: i64| encode(RespValue::Integer(value)); + assert_eq!(RespValue::Array(vec![]).to_string(), "array<>"); + assert_eq!( + RespValue::Array(vec![bulk("a"), int(1)]).to_string(), + r#"array, integer<1>>"# + ); + let nested = encode(RespValue::Array(vec![int(1), int(2)])); + assert_eq!( + RespValue::Array(vec![nested]).to_string(), + "array, integer<2>>>" + ); + assert_eq!( + RespValue::Map(vec![(bulk("k"), int(1)), (bulk("j"), int(2))]).to_string(), + r#"map: integer<1>, bulk-string<"j">: integer<2>>"# + ); + assert_eq!( + RespValue::Set(vec![bulk("a")]).to_string(), + r#"set>"# + ); + assert_eq!( + RespValue::Push(vec![bulk("message"), bulk("news")]).to_string(), + r#"push, bulk-string<"news">>"# + ); + assert_eq!( + RespValue::Array(vec![b"?".to_vec()]).to_string(), + "array<--invalid-resp-->" + ); + } +} diff --git a/crates/componentized-valkey/src/string_or_bytes.rs b/crates/componentized-valkey/src/string_or_bytes.rs new file mode 100644 index 0000000..a2ec469 --- /dev/null +++ b/crates/componentized-valkey/src/string_or_bytes.rs @@ -0,0 +1,122 @@ +//! Conversions of `string-or-bytes`, the type of the values Valkey keeps as given, UTF-8 or not. + +use crate::types::StringOrBytes; + +impl StringOrBytes { + /// A value from bytes, a string when they are UTF-8, without copying them. + pub fn from_bytes(bytes: Vec) -> Self { + match String::from_utf8(bytes) { + Ok(text) => StringOrBytes::String(text), + Err(error) => StringOrBytes::Bytes(error.into_bytes()), + } + } + + /// The bytes of the value, UTF-8 for a string. + pub fn as_bytes(&self) -> &[u8] { + match self { + StringOrBytes::String(text) => text.as_bytes(), + StringOrBytes::Bytes(bytes) => bytes, + } + } + + /// The bytes of the value, UTF-8 for a string, without copying them. + pub fn into_bytes(self) -> Vec { + match self { + StringOrBytes::String(text) => text.into_bytes(), + StringOrBytes::Bytes(bytes) => bytes, + } + } + + /// The value with a prefix, e.g. `[` for an inclusive bound of ZRANGE BYLEX, of the same kind. + pub fn prefixed(self, prefix: &str) -> Self { + match self { + StringOrBytes::String(text) => StringOrBytes::String(format!("{prefix}{text}")), + StringOrBytes::Bytes(bytes) => { + StringOrBytes::Bytes([prefix.as_bytes(), &bytes].concat()) + } + } + } +} + +/// The text of a string, bytes escaped as in a Rust byte string literal, e.g. `\x80` and `\r`, for +/// people to read, e.g. in an error message. +impl std::fmt::Display for StringOrBytes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StringOrBytes::String(text) => f.write_str(text), + StringOrBytes::Bytes(bytes) => write!(f, "{}", bytes.escape_ascii()), + } + } +} + +impl From for StringOrBytes { + fn from(text: String) -> Self { + StringOrBytes::String(text) + } +} + +impl From<&str> for StringOrBytes { + fn from(text: &str) -> Self { + StringOrBytes::String(text.to_string()) + } +} + +/// Equal to a string when it is the same text, e.g. a word of a command in a test. +impl PartialEq for StringOrBytes { + fn eq(&self, other: &str) -> bool { + matches!(self, StringOrBytes::String(text) if text == other) + } +} + +impl PartialEq<&str> for StringOrBytes { + fn eq(&self, other: &&str) -> bool { + self == *other + } +} + +impl PartialEq for StringOrBytes { + fn eq(&self, other: &String) -> bool { + self == other.as_str() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + assert_eq!(StringOrBytes::from("ERR oops").to_string(), "ERR oops"); + assert_eq!( + StringOrBytes::Bytes(b"ERR \x80\r\n".to_vec()).to_string(), + r"ERR \x80\r\n" + ); + } + + #[test] + fn from_bytes() { + assert_eq!( + StringOrBytes::from_bytes(b"text".to_vec()), + StringOrBytes::String("text".to_string()) + ); + assert_eq!( + StringOrBytes::from_bytes(vec![0x80]), + StringOrBytes::Bytes(vec![0x80]) + ); + } + + #[test] + fn prefixed() { + assert_eq!(StringOrBytes::from("a").prefixed("["), "[a"); + assert_eq!( + StringOrBytes::Bytes(vec![0x80]).prefixed("("), + StringOrBytes::Bytes(vec![b'(', 0x80]) + ); + } + + #[test] + fn bytes() { + assert_eq!(StringOrBytes::from("a").as_bytes(), b"a"); + assert_eq!(StringOrBytes::Bytes(vec![0x80]).into_bytes(), vec![0x80]); + } +} diff --git a/crates/componentized-valkey/src/test_util.rs b/crates/componentized-valkey/src/test_util.rs new file mode 100644 index 0000000..1a95389 --- /dev/null +++ b/crates/componentized-valkey/src/test_util.rs @@ -0,0 +1,79 @@ +//! Helpers for the tests of the conversions, to build replies and check the outcomes. + +use crate::resp::encode; +use crate::types::{ErrorCode, RespValue}; +use crate::{FromValue, IntoCommand}; + +/// The words of a command that converts. +pub fn words(command: impl IntoCommand) -> crate::command::Words { + command.into_command().expect("the words of the command") +} + +/// The message of the client error a command fails to convert with. +pub fn words_error(command: impl IntoCommand) -> String { + match command.into_command() { + Err(ErrorCode::Client(message)) => message, + result => panic!("expected a client error: {result:?}"), + } +} + +/// The value of a reply that converts. +pub fn from(value: RespValue) -> T { + T::from_value(value).expect("the value of the reply") +} + +/// The message of the client error a reply fails to convert with. +pub fn from_error(value: RespValue) -> String { + match T::from_value(value) { + Err(ErrorCode::Client(message)) => message, + Err(error) => panic!("expected a client error: {error:?}"), + Ok(_) => panic!("expected a client error"), + } +} + +/// The simple string `OK`. +pub fn ok() -> RespValue { + simple("OK") +} + +pub fn simple(text: &str) -> RespValue { + RespValue::String(text.to_string()) +} + +pub fn bulk(text: &str) -> RespValue { + RespValue::BulkString(text.as_bytes().to_vec()) +} + +pub fn int(value: i64) -> RespValue { + RespValue::Integer(value) +} + +pub fn double(value: f64) -> RespValue { + RespValue::Double(value) +} + +pub fn array(items: Vec) -> RespValue { + RespValue::Array(items.into_iter().map(encode).collect()) +} + +pub fn map(entries: Vec<(RespValue, RespValue)>) -> RespValue { + RespValue::Map( + entries + .into_iter() + .map(|(key, value)| (encode(key), encode(value))) + .collect(), + ) +} + +/// A map of fields by name, e.g. a record in a RESP3 reply. +pub fn fields(entries: Vec<(&str, RespValue)>) -> RespValue { + map(entries + .into_iter() + .map(|(name, value)| (bulk(name), value)) + .collect()) +} + +/// An array of bulk strings. +pub fn bulks(items: &[&str]) -> RespValue { + array(items.iter().map(|item| bulk(item)).collect()) +} diff --git a/crates/componentized-valkey/src/value.rs b/crates/componentized-valkey/src/value.rs new file mode 100644 index 0000000..5cd2a6a --- /dev/null +++ b/crates/componentized-valkey/src/value.rs @@ -0,0 +1,430 @@ +//! Converting a reply into Rust values, the impls for Rust's own types and the helpers the commands +//! share. The impls for the package's types are in [`crate::commands`]. + +use crate::types::{Double, ErrorCode, Integer, RespNestedValue, RespValue, StringOrBytes}; + +use wit_bindgen::rt::Map; + +/// Converts a reply into a Rust type, e.g. `Option` for a bulk string or null. +pub trait FromValue: Sized { + fn from_value(value: RespValue) -> Result; +} + +/// The reply itself, e.g. for a field of mixed types. +impl FromValue for RespValue { + fn from_value(value: RespValue) -> Result { + Ok(value) + } +} + +/// A simple string `OK`. +impl FromValue for () { + fn from_value(value: RespValue) -> Result { + match &value { + RespValue::String(reply) if reply == "OK" => Ok(()), + _ => Err(unexpected(&value)), + } + } +} + +impl FromValue for Integer { + fn from_value(value: RespValue) -> Result { + match value { + RespValue::Integer(value) => Ok(value), + value => Err(unexpected(&value)), + } + } +} + +/// A double, or a bulk string holding one, e.g. the reply of INCRBYFLOAT, even with RESP3. +impl FromValue for Double { + fn from_value(value: RespValue) -> Result { + match value { + RespValue::Double(value) => Ok(value), + RespValue::BulkString(bytes) => std::str::from_utf8(&bytes) + .ok() + .and_then(|text| text.parse().ok()) + .ok_or_else(|| { + let reply = RespValue::BulkString(bytes); + ErrorCode::Client(format!("unexpected reply, not a double: {reply}")) + }), + value => Err(unexpected(&value)), + } + } +} + +/// A non-negative integer. +impl FromValue for u64 { + fn from_value(value: RespValue) -> Result { + let value = Integer::from_value(value)?; + u64::try_from(value) + .map_err(|_| ErrorCode::Client(format!("unexpected negative reply: {value}"))) + } +} + +/// An integer `0` or `1`, or a boolean. +impl FromValue for bool { + fn from_value(value: RespValue) -> Result { + match value { + RespValue::Integer(0) => Ok(false), + RespValue::Integer(1) => Ok(true), + RespValue::Boolean(value) => Ok(value), + value => Err(unexpected(&value)), + } + } +} + +/// A bulk, simple or verbatim string. +impl FromValue for String { + fn from_value(value: RespValue) -> Result { + match value { + RespValue::BulkString(bytes) => String::from_utf8(bytes).map_err(|error| { + let reply = RespValue::BulkString(error.into_bytes()); + ErrorCode::Client(format!("unexpected reply, not UTF-8: {reply}")) + }), + RespValue::String(value) | RespValue::VerbatimString((_, value)) => Ok(value), + value => Err(unexpected(&value)), + } + } +} + +/// A bulk, simple or verbatim string, a string when it is UTF-8, otherwise its bytes, e.g. a value +/// of GET. +impl FromValue for StringOrBytes { + fn from_value(value: RespValue) -> Result { + match value { + RespValue::BulkString(bytes) => Ok(StringOrBytes::from_bytes(bytes)), + RespValue::String(text) | RespValue::VerbatimString((_, text)) => { + Ok(StringOrBytes::String(text)) + } + value => Err(unexpected(&value)), + } + } +} + +/// The bytes of a bulk string, binary by nature, e.g. the payload of DUMP. +#[derive(Debug, PartialEq)] +pub struct Binary(pub Vec); + +impl FromValue for Binary { + fn from_value(value: RespValue) -> Result { + match value { + RespValue::BulkString(bytes) => Ok(Binary(bytes)), + value => Err(unexpected(&value)), + } + } +} + +/// Null, or a value. +impl FromValue for Option { + fn from_value(value: RespValue) -> Result { + match value { + RespValue::Null => Ok(None), + value => T::from_value(value).map(Some), + } + } +} + +/// An array, or a set. +impl FromValue for Vec { + fn from_value(value: RespValue) -> Result { + items(value)?.into_iter().map(T::from_value).collect() + } +} + +/// An array of exactly as many items as the tuple. +macro_rules! from_value_tuples { + ($(($len:literal; $($item:ident),+)),*) => { + $( + impl<$($item: FromValue),+> FromValue for ($($item,)+) { + #[allow(non_snake_case)] + fn from_value(value: RespValue) -> Result { + match <[RespValue; $len]>::try_from(items(value)?) { + Ok([$($item),+]) => Ok(($($item::from_value($item)?,)+)), + Err(items) => Err(ErrorCode::Client(format!( + "unexpected number of items, expected {}: {}", + $len, + items.len() + ))), + } + } + } + )* + }; +} + +from_value_tuples!((2; A, B), (3; A, B, C), (4; A, B, C, D)); + +/// A map, or a flat array of keys and values. +impl FromValue for Map { + fn from_value(value: RespValue) -> Result { + Ok(entries(value)?.into_iter().collect()) + } +} + +/// The fields of a map, by name, e.g. to build a record. +/// +/// Fields that are not taken are ignored, so newer servers may add fields. +pub struct Fields(Vec<(String, RespValue)>); + +impl FromValue for Fields { + fn from_value(value: RespValue) -> Result { + entries(value).map(Fields) + } +} + +impl Fields { + /// Take the named field, if present. + pub fn optional(&mut self, name: &str) -> Result, ErrorCode> { + match self.0.iter().position(|(key, _)| key == name) { + Some(index) => T::from_value(self.0.swap_remove(index).1).map(Some), + None => Ok(None), + } + } + + /// Take the named field, failing if it is missing. + pub fn required(&mut self, name: &str) -> Result { + self.optional(name)? + .ok_or_else(|| ErrorCode::Client(format!("missing field: {name}"))) + } +} + +/// A value, or `-1` for none, e.g. the `active-time` of a consumer that has never been active. +pub struct OrNever(pub Option); + +impl FromValue for OrNever { + fn from_value(value: RespValue) -> Result { + match value { + RespValue::Integer(-1) => Ok(OrNever(None)), + value => T::from_value(value).map(|value| OrNever(Some(value))), + } + } +} + +/// Pairs in a flat array, `[key, value, key, value...]`, e.g. the members and scores of ZSCAN. +pub struct FlatPairs(pub Vec<(K, V)>); + +impl FromValue for FlatPairs { + fn from_value(value: RespValue) -> Result { + entries(value).map(FlatPairs) + } +} + +/// A number sent as a string, e.g. the port of a replica in ROLE. +pub(crate) fn parse_string(value: RespValue) -> Result { + let text = String::from_value(value)?; + text.parse() + .map_err(|_| ErrorCode::Client(format!("unexpected number: {text:?}"))) +} + +pub(crate) fn unexpected(reply: &RespValue) -> ErrorCode { + ErrorCode::Client(format!("unexpected reply: {reply}")) +} + +fn decode(value: RespNestedValue) -> Result { + crate::resp::decode(&value).map_err(ErrorCode::Resp) +} + +/// The nested values of an array, or a set. +pub(crate) fn items(reply: RespValue) -> Result, ErrorCode> { + match reply { + RespValue::Array(items) | RespValue::Set(items) => items.into_iter().map(decode).collect(), + reply => Err(unexpected(&reply)), + } +} + +/// The entries of a map, or of a flat array of keys and values. +pub fn entries(reply: RespValue) -> Result, ErrorCode> { + let entry = |key, value| Ok((K::from_value(decode(key)?)?, V::from_value(decode(value)?)?)); + match reply { + RespValue::Map(entries) => entries + .into_iter() + .map(|(key, value)| entry(key, value)) + .collect(), + RespValue::Array(items) => { + if items.len() % 2 != 0 { + return Err(ErrorCode::Client(format!( + "unexpected odd number of keys and values: {}", + items.len() + ))); + } + let mut items = items.into_iter(); + let mut entries = vec![]; + while let (Some(key), Some(value)) = (items.next(), items.next()) { + entries.push(entry(key, value)?); + } + Ok(entries) + } + reply => Err(unexpected(&reply)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{array, bulk, from, from_error, int, map, ok, simple}; + + #[test] + fn value() { + assert_eq!(from::(int(1)), int(1)); + } + + #[test] + fn unit() { + from::<()>(ok()); + assert_eq!( + from_error::<()>(simple("QUEUED")), + r#"unexpected reply: string<"QUEUED">"# + ); + } + + #[test] + fn integer() { + assert_eq!(from::(int(-5)), -5); + assert_eq!( + from_error::(bulk("5")), + r#"unexpected reply: bulk-string<"5">"# + ); + } + + #[test] + fn double() { + assert_eq!(from::(RespValue::Double(1.5)), 1.5); + assert_eq!(from::(bulk("2.5")), 2.5); + assert_eq!( + from_error::(bulk("two")), + r#"unexpected reply, not a double: bulk-string<"two">"# + ); + assert_eq!( + from_error::(RespValue::BulkString(vec![0x80])), + r#"unexpected reply, not a double: bulk-string"# + ); + } + + #[test] + fn unsigned() { + assert_eq!(from::(int(3)), 3); + assert_eq!(from_error::(int(-1)), "unexpected negative reply: -1"); + } + + #[test] + fn boolean() { + assert!(!from::(int(0))); + assert!(from::(int(1))); + assert!(from::(RespValue::Boolean(true))); + assert_eq!(from_error::(int(2)), "unexpected reply: integer<2>"); + } + + #[test] + fn string() { + assert_eq!(from::(bulk("bulk")), "bulk"); + assert_eq!(from::(simple("simple")), "simple"); + let verbatim = RespValue::VerbatimString(("txt".to_string(), "verbatim".to_string())); + assert_eq!(from::(verbatim), "verbatim"); + assert_eq!(from_error::(int(1)), "unexpected reply: integer<1>"); + assert_eq!( + from_error::(RespValue::BulkString(vec![0x80])), + r#"unexpected reply, not UTF-8: bulk-string"# + ); + } + + #[test] + fn option() { + assert_eq!(from::>(RespValue::Null), None); + assert_eq!(from::>(bulk("a")), Some("a".to_string())); + } + + #[test] + fn list() { + assert_eq!( + from::>(array(vec![bulk("a"), bulk("b")])), + ["a", "b"] + ); + let set = RespValue::Set(vec![crate::resp::encode(bulk("a"))]); + assert_eq!(from::>(set), ["a"]); + assert_eq!( + from_error::>(bulk("a")), + r#"unexpected reply: bulk-string<"a">"# + ); + } + + #[test] + fn tuples() { + assert_eq!( + from::<(String, Integer)>(array(vec![bulk("a"), int(1)])), + ("a".to_string(), 1) + ); + assert_eq!( + from::<(Integer, Integer, Integer, Integer)>(array(vec![ + int(1), + int(2), + int(3), + int(4) + ])), + (1, 2, 3, 4) + ); + assert_eq!( + from_error::<(String, Integer)>(array(vec![bulk("a")])), + "unexpected number of items, expected 2: 1" + ); + } + + #[test] + fn maps() { + let expected: Map = [("a".to_string(), 1)].into_iter().collect(); + // RESP3 + assert_eq!( + from::>(map(vec![(bulk("a"), int(1))])), + expected + ); + // RESP2, a flat array + assert_eq!( + from::>(array(vec![bulk("a"), int(1)])), + expected + ); + assert_eq!( + from_error::>(array(vec![bulk("a"), int(1), bulk("b")])), + "unexpected odd number of keys and values: 3" + ); + } + + #[test] + fn fields() { + let value = map(vec![ + (bulk("name"), bulk("a")), + (bulk("count"), int(2)), + (bulk("newer"), int(3)), + ]); + let mut fields = from::(value); + assert_eq!(fields.required::("name").unwrap(), "a"); + assert_eq!(fields.optional::("count").unwrap(), Some(2)); + assert_eq!(fields.optional::("missing").unwrap(), None); + match fields.required::("missing") { + Err(ErrorCode::Client(message)) => assert_eq!(message, "missing field: missing"), + result => panic!("expected a client error: {result:?}"), + } + } + + #[test] + fn or_never() { + assert_eq!(from::>(int(-1)).0, None); + assert_eq!(from::>(int(5)).0, Some(5)); + } + + #[test] + fn flat_pairs() { + let pairs = + from::>(array(vec![bulk("b"), int(2), bulk("a"), int(1)])); + // in the order of the reply + assert_eq!(pairs.0, [("b".to_string(), 2), ("a".to_string(), 1)]); + } + + #[test] + fn number_in_a_string() { + assert_eq!(parse_string::(bulk("6379")).unwrap(), 6379); + match parse_string::(bulk("port")) { + Err(ErrorCode::Client(message)) => assert_eq!(message, r#"unexpected number: "port""#), + result => panic!("expected a client error: {result:?}"), + } + } +} diff --git a/crates/test-harness/Cargo.toml b/crates/test-harness/Cargo.toml new file mode 100644 index 0000000..952e449 --- /dev/null +++ b/crates/test-harness/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "test-harness" +version.workspace = true +edition = "2024" +license = "Apache-2.0" +publish = false + +[dependencies] +componentized-valkey = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } +wasmtime-wasi = { workspace = true } diff --git a/crates/test-harness/src/lib.rs b/crates/test-harness/src/lib.rs new file mode 100644 index 0000000..3b0ff18 --- /dev/null +++ b/crates/test-harness/src/lib.rs @@ -0,0 +1,667 @@ +//! Test harness for the valkey components. +//! +//! A [`Harness`] instantiates a component (`target/components/*/*.wasm`) with wasmtime. No Valkey +//! server is needed, the test plays the server: +//! +//! - over a stub [`Transport`], reading the commands a connection writes, and deciding when each +//! reply, push message or close arrives +//! - over TCP with a [`Server`], for components that open sockets, e.g. `tcp` + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::process::Command; +use std::sync::Mutex; +use std::task::{Context, Poll}; + +use tokio::sync::{mpsc, oneshot}; +use wasmtime::component::{ + Accessor, Component, Destination, FutureConsumer, FutureReader, HasSelf, Lift, Linker, + ResourceAny, ResourceTable, Source, StreamConsumer, StreamProducer, StreamReader, StreamResult, +}; +use wasmtime::error::Context as _; +use wasmtime::{Config, Engine, Result, Store, StoreContextMut, bail, format_err}; +use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; + +pub use bindings::componentized::valkey::types::{ + self, ConnectOpts, ErrorCode, RespNestedValue, RespValue, StringOrBytes, +}; + +mod server; + +pub use server::{Client, Server}; + +pub mod bindings { + wasmtime::component::bindgen!({ + path: "../../components/wit", + inline: " + package componentized:test-harness; + + world harness { + import componentized:valkey/types@0.3.0-dev; + import wasi:config/store@0.2.0-rc.1; + export componentized:valkey/connection@0.3.0-dev; + export componentized:valkey/tcp@0.3.0-dev; + export componentized:valkey/connector@0.3.0-dev; + export componentized:valkey-components/test-suite; + export wasmcloud:keyvalue/types@0.2.0; + export wasmcloud:keyvalue/store@0.2.0; + export wasmcloud:keyvalue/atomics@0.2.0; + export wasmcloud:keyvalue/batch@0.2.0; + } + ", + world: "componentized:test-harness/harness", + exports: { default: async | store }, + additional_derives: [PartialEq], + }); +} + +/// Encode a value as RESP, e.g. to nest it in an aggregate. +pub fn encode(value: RespValue) -> RespNestedValue { + componentized_valkey::resp::encode(to_codec(value)) +} + +/// Decode a nested value. +pub fn decode(value: RespNestedValue) -> Result { + componentized_valkey::resp::decode(&value).map(from_codec) +} + +// a value is not recursive, nested values are encoded RESP, so each conversion is one match +fn to_codec(value: RespValue) -> componentized_valkey::types::RespValue { + use componentized_valkey::types::RespValue as V; + match value { + RespValue::String(value) => V::String(value), + RespValue::Error(value) => V::Error(value), + RespValue::Integer(value) => V::Integer(value), + RespValue::BulkString(value) => V::BulkString(value), + RespValue::Array(value) => V::Array(value), + RespValue::Null => V::Null, + RespValue::Boolean(value) => V::Boolean(value), + RespValue::Double(value) => V::Double(value), + RespValue::BigNumber(value) => V::BigNumber(value), + RespValue::BulkError(value) => V::BulkError(value), + RespValue::VerbatimString(value) => V::VerbatimString(value), + RespValue::Map(value) => V::Map(value), + RespValue::Set(value) => V::Set(value), + RespValue::Push(value) => V::Push(value), + } +} + +fn from_codec(value: componentized_valkey::types::RespValue) -> RespValue { + use componentized_valkey::types::RespValue as V; + match value { + V::String(value) => RespValue::String(value), + V::Error(value) => RespValue::Error(value), + V::Integer(value) => RespValue::Integer(value), + V::BulkString(value) => RespValue::BulkString(value), + V::Array(value) => RespValue::Array(value), + V::Null => RespValue::Null, + V::Boolean(value) => RespValue::Boolean(value), + V::Double(value) => RespValue::Double(value), + V::BigNumber(value) => RespValue::BigNumber(value), + V::BulkError(value) => RespValue::BulkError(value), + V::VerbatimString(value) => RespValue::VerbatimString(value), + V::Map(value) => RespValue::Map(value), + V::Set(value) => RespValue::Set(value), + V::Push(value) => RespValue::Push(value), + } +} + +pub type Connection<'a> = bindings::exports::componentized::valkey::connection::GuestConnection<'a>; +pub type Subscription<'a> = + bindings::exports::componentized::valkey::connection::GuestSubscription<'a>; +pub type Tcp = bindings::exports::componentized::valkey::tcp::Guest; +pub type Connector = bindings::exports::componentized::valkey::connector::Guest; +/// A suite of tests run inside a component, e.g. those of the commands, see [`run_suite`]. +pub type TestSuite = bindings::exports::componentized::valkey_components::test_suite::Guest; +pub use bindings::exports::wasmcloud::keyvalue; + +/// A bulk string, the type commands are sent as. +pub fn bulk(value: &str) -> RespValue { + RespValue::BulkString(value.as_bytes().to_vec()) +} + +/// The words of a command to send, e.g. `words(&["GET", "key"])`, see `connection.exchange`. +pub fn words(words: &[&str]) -> Vec { + words + .iter() + .map(|word| StringOrBytes::String(word.to_string())) + .collect() +} + +/// Strings, e.g. the keys of `connection.watch`, or the words of a command of a transaction. +pub fn strings(words: &[&str]) -> Vec { + words.iter().map(|word| word.to_string()).collect() +} + +/// A command as the server receives it, e.g. `command(&["GET", "key"])`. +pub fn command(words: &[&str]) -> Vec { + words.iter().map(|word| bulk(word)).collect() +} + +/// A map reply, e.g. to HELLO. +pub fn map(entries: Vec<(RespValue, RespValue)>) -> RespValue { + RespValue::Map( + entries + .into_iter() + .map(|(key, value)| (encode(key), encode(value))) + .collect(), + ) +} + +/// A push message, e.g. a pub/sub message. +pub fn push(items: Vec) -> RespValue { + RespValue::Push(items.into_iter().map(encode).collect()) +} + +/// A simple string `OK`. +pub fn ok() -> RespValue { + RespValue::String("OK".to_string()) +} + +/// The server's properties in the canned reply to HELLO. +pub fn hello_reply() -> RespValue { + map(vec![ + (bulk("server"), bulk("valkey")), + (bulk("version"), bulk("9.1.2")), + (bulk("proto"), RespValue::Integer(3)), + ]) +} + +/// The stub transport and server of a connection. +pub struct Transport { + requests: mpsc::UnboundedReceiver, + replies: Option>, + closed: Option>>, +} + +impl Transport { + /// The next command the connection wrote, as its words, `None` once it closed the requests + /// stream. + pub async fn request(&mut self) -> Option> { + let request = self.requests.recv().await?; + let RespValue::Array(words) = request else { + panic!("a command is an array, got {request:?}"); + }; + Some( + words + .into_iter() + .map(|word| decode(word).expect("encoded word")) + .collect(), + ) + } + + /// Send a value to the connection, a reply or a push message. + pub fn reply(&self, value: RespValue) { + let replies = self.replies.as_ref().expect("transport is open"); + replies.send(value).expect("connection reads replies"); + } + + /// Close the transport, gracefully with `Ok(())`, or with the error. + pub fn close(&mut self, result: Result<(), ErrorCode>) { + self.replies.take(); + if let Some(closed) = self.closed.take() { + let _ = closed.send(result); + } + } +} + +/// Start a connection over a stub transport, returning the transport and the connection the +/// guest resolves once it is established. +pub async fn connect( + accessor: &Accessor, + exports: &Exports, + opts: Option, +) -> Result<(Transport, FutureReader>)> { + let (replies_tx, replies_rx) = mpsc::unbounded_channel(); + let (closed_tx, closed_rx) = oneshot::channel(); + let replies = accessor.with(|store| StreamReader::new(store, ChannelProducer(replies_rx)))?; + let closed = accessor.with(|store| { + FutureReader::new(store, async move { + // a transport dropped without closing it closed gracefully + Ok::<_, wasmtime::Error>(closed_rx.await.unwrap_or(Ok(()))) + }) + })?; + let (requests, connection) = exports + .connection() + .call_new(accessor, replies, closed, opts) + .await?; + let (requests_tx, requests_rx) = mpsc::unbounded_channel(); + accessor.with(|store| requests.pipe(store, ChannelConsumer(requests_tx)))?; + Ok(( + Transport { + requests: requests_rx, + replies: Some(replies_tx), + closed: Some(closed_tx), + }, + connection, + )) +} + +/// The commands the connection sends when it is established, after HELLO, to report the library. +pub fn setinfo() -> [Vec; 2] { + [ + command(&["CLIENT", "SETINFO", "LIB-NAME", "componentized:valkey"]), + command(&["CLIENT", "SETINFO", "LIB-VER", env!("CARGO_PKG_VERSION")]), + ] +} + +/// Answer the handshake of a connection: HELLO with [`hello_reply`], then the CLIENT SETINFO +/// commands with OK. +pub async fn handshake(transport: &mut Transport, hello: &[&str]) { + assert_eq!(transport.request().await, Some(command(hello))); + for setinfo in setinfo() { + assert_eq!(transport.request().await, Some(setinfo)); + } + transport.reply(hello_reply()); + for _ in setinfo() { + transport.reply(ok()); + } +} + +/// Start a connection and answer its handshake, see [`handshake`], returning the transport and the +/// established connection. +pub async fn establish( + accessor: &Accessor, + exports: &Exports, +) -> Result<(Transport, ResourceAny)> { + let (mut transport, connection) = connect(accessor, exports, None).await?; + handshake(&mut transport, &["HELLO", "3"]).await; + let connection = resolve(accessor, connection) + .await? + .map_err(|error| format_err!("failed to establish the connection: {error:?}"))?; + Ok((transport, connection)) +} + +/// Wait for the value of a guest future. +pub async fn resolve( + accessor: &Accessor, + future: FutureReader, +) -> Result { + let (tx, rx) = oneshot::channel(); + accessor.with(|store| future.pipe(store, OneshotConsumer(Some(tx))))?; + rx.await + .map_err(|_| format_err!("future closed without a value")) +} + +/// Read every item of a guest stream into a channel, until it closes. +pub fn receive( + accessor: &Accessor, + stream: StreamReader, +) -> Result> { + let (tx, rx) = mpsc::unbounded_channel(); + accessor.with(|store| stream.pipe(store, ChannelConsumer(tx)))?; + Ok(rx) +} + +/// Produces the items sent to the channel, until every sender is dropped. +struct ChannelProducer(mpsc::UnboundedReceiver); + +impl StreamProducer for ChannelProducer { + type Item = T; + type Buffer = Option; + + fn poll_produce<'a>( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + _store: StoreContextMut<'a, D>, + mut destination: Destination<'a, T, Option>, + finish: bool, + ) -> Poll> { + match self.get_mut().0.poll_recv(cx) { + Poll::Ready(Some(item)) => { + destination.set_buffer(Some(item)); + Poll::Ready(Ok(StreamResult::Completed)) + } + Poll::Ready(None) => Poll::Ready(Ok(StreamResult::Dropped)), + Poll::Pending if finish => Poll::Ready(Ok(StreamResult::Cancelled)), + Poll::Pending => Poll::Pending, + } + } +} + +struct OneshotConsumer(Option>); + +impl FutureConsumer for OneshotConsumer { + type Item = T; + + fn poll_consume( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + store: StoreContextMut, + mut source: Source<'_, T>, + _finish: bool, + ) -> Poll> { + let mut item = None; + source.read(store, &mut item)?; + if let (Some(item), Some(tx)) = (item, self.get_mut().0.take()) { + // the receiver is only dropped when the test stopped waiting + let _ = tx.send(item); + } + Poll::Ready(Ok(())) + } +} + +struct ChannelConsumer(mpsc::UnboundedSender); + +impl StreamConsumer for ChannelConsumer { + type Item = T; + + fn poll_consume( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + store: StoreContextMut, + mut source: Source<'_, T>, + _finish: bool, + ) -> Poll> { + let mut item = None; + source.read(store, &mut item)?; + if let Some(item) = item { + if self.0.send(item).is_err() { + return Poll::Ready(Ok(StreamResult::Dropped)); + } + } + Poll::Ready(Ok(StreamResult::Completed)) + } +} + +/// Root of the workspace, where the Makefile lives. +fn workspace_dir() -> PathBuf { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + dir.canonicalize().unwrap_or(dir) +} + +/// Path containing the built component. +pub fn component_path(name: &str) -> PathBuf { + workspace_dir().join(format!("target/components/{name}/{name}.wasm")) +} + +/// Rebuild the named component in `target/components/` with make, unless it was already built +/// by this process. +fn ensure_built(name: &str) -> Result<()> { + static BUILT: Mutex>> = Mutex::new(None); + + // hold the lock while building so concurrent tests don't run make over each other + let mut built = BUILT.lock().unwrap_or_else(|err| err.into_inner()); + let built = built.get_or_insert_with(HashSet::new); + if built.contains(name) { + return Ok(()); + } + + // the Makefile's targets are relative to the workspace, make matches them textually + let target = format!("target/components/{name}/{name}.wasm"); + let output = Command::new("make") + .arg("-C") + .arg(workspace_dir()) + .arg(&target) + .output() + .context("failed to run make")?; + if !output.status.success() { + bail!( + "failed to build {target}:\n{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + built.insert(name.to_string()); + Ok(()) +} + +pub struct Ctx { + wasi: WasiCtx, + table: ResourceTable, + /// The values of `wasi:config/store`. + config: Vec<(String, String)>, +} + +impl bindings::wasi::config::store::Host for Ctx { + fn get(&mut self, key: String) -> Result, bindings::wasi::config::store::Error> { + Ok(self + .config + .iter() + .rev() + .find(|(k, _)| *k == key) + .map(|(_, v)| v.clone())) + } + + fn get_all(&mut self) -> Result, bindings::wasi::config::store::Error> { + Ok(self.config.clone()) + } +} + +impl WasiView for Ctx { + fn ctx(&mut self) -> WasiCtxView<'_> { + WasiCtxView { + ctx: &mut self.wasi, + table: &mut self.table, + } + } +} + +/// The interfaces a component exports. +pub struct Exports { + connection: Option, + tcp: Option, + connector: Option, + test_suite: Option, + keyvalue_store: Option, + keyvalue_types: Option, + keyvalue_atomics: Option, + keyvalue_batch: Option, +} + +impl Exports { + pub fn keyvalue_store(&self) -> &keyvalue::store::Guest { + self.keyvalue_store + .as_ref() + .expect("component exports wasmcloud:keyvalue/store") + } + + pub fn keyvalue_types(&self) -> &keyvalue::types::Guest { + self.keyvalue_types + .as_ref() + .expect("component exports wasmcloud:keyvalue/types") + } + + pub fn keyvalue_atomics(&self) -> &keyvalue::atomics::Guest { + self.keyvalue_atomics + .as_ref() + .expect("component exports wasmcloud:keyvalue/atomics") + } + + pub fn keyvalue_batch(&self) -> &keyvalue::batch::Guest { + self.keyvalue_batch + .as_ref() + .expect("component exports wasmcloud:keyvalue/batch") + } + + pub fn test_suite(&self) -> &TestSuite { + self.test_suite + .as_ref() + .expect("component exports componentized:valkey-components/test-suite") + } + + pub fn connector(&self) -> &Connector { + self.connector + .as_ref() + .expect("component exports componentized:valkey/connector") + } + + pub fn tcp(&self) -> &Tcp { + self.tcp + .as_ref() + .expect("component exports componentized:valkey/tcp") + } + + pub fn connection(&self) -> Connection<'_> { + self.connection + .as_ref() + .expect("component exports componentized:valkey/connection") + .connection() + } + + pub fn subscription(&self) -> Subscription<'_> { + self.connection + .as_ref() + .expect("component exports componentized:valkey/connection") + .subscription() + } +} + +/// An instantiated component, see [`Harness::run`]. +pub struct Harness { + store: Store, + exports: Exports, +} + +impl Harness { + /// Build and instantiate the named component, e.g. `connection`. + pub async fn new(name: &str) -> Result { + Self::with_config(name, &[]).await + } + + /// Build and instantiate the named component, with the values of `wasi:config/store`. + pub async fn with_config(name: &str, config_values: &[(&str, &str)]) -> Result { + ensure_built(name)?; + + let mut config = Config::new(); + config.wasm_component_model_map(true); + // for the sockets gate and latches + config.wasm_component_model_implements(true); + config.wasm_component_model_threading(true); + let engine = Engine::new(&config)?; + let component = Component::from_file(&engine, component_path(name)) + .with_context(|| format!("failed to load {name}"))?; + + let mut linker = Linker::new(&engine); + wasmtime_wasi::p3::add_to_linker(&mut linker)?; + bindings::wasi::config::store::add_to_linker::<_, HasSelf>(&mut linker, |ctx| ctx)?; + + let mut store = Store::new( + &engine, + Ctx { + wasi: WasiCtxBuilder::new() + .inherit_stdio() + // for components that open sockets, the tests' servers listen on localhost + .inherit_network() + .allow_tcp(true) + .allow_ip_name_lookup(true) + .build(), + table: ResourceTable::new(), + config: config_values + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + }, + ); + let instance_pre = linker.instantiate_pre(&component)?; + let instance = instance_pre + .instantiate_async(&mut store) + .await + .with_context(|| format!("failed to instantiate {name}"))?; + let exports = Exports { + connection: bindings::exports::componentized::valkey::connection::GuestIndices::new( + &instance_pre, + ) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), + tcp: bindings::exports::componentized::valkey::tcp::GuestIndices::new(&instance_pre) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), + connector: bindings::exports::componentized::valkey::connector::GuestIndices::new( + &instance_pre, + ) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), + test_suite: + bindings::exports::componentized::valkey_components::test_suite::GuestIndices::new( + &instance_pre, + ) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), + keyvalue_store: keyvalue::store::GuestIndices::new(&instance_pre) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), + keyvalue_types: keyvalue::types::GuestIndices::new(&instance_pre) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), + keyvalue_atomics: keyvalue::atomics::GuestIndices::new(&instance_pre) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), + keyvalue_batch: keyvalue::batch::GuestIndices::new(&instance_pre) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), + }; + Ok(Self { store, exports }) + } + + /// Build and instantiate the named component, its connector configured for the Valkey server + /// of the `with_valkey` tests, see [`valkey_server`], with the values of `wasi:config`. + pub async fn with_valkey(name: &str, config_values: &[(&str, &str)]) -> Result { + let (host, port) = valkey_server(); + let port = port.to_string(); + let mut values = vec![("hostname", host.as_str()), ("port", port.as_str())]; + values.extend_from_slice(config_values); + Self::with_config(name, &values).await + } + + /// Drop a resource the component exports, running its destructor. + /// + /// Between runs, the store is only free to drop a resource while no run is in progress. + pub async fn drop_resource(&mut self, resource: ResourceAny) -> Result<()> { + resource.resource_drop_async(&mut self.store).await + } + + /// Run a test body against the component's exports. + pub async fn run( + &mut self, + f: impl AsyncFnOnce(&Accessor, &Exports) -> Result + Send, + ) -> Result { + let exports = &self.exports; + self.store + .run_concurrent(async move |accessor| f(accessor, exports).await) + .await? + } +} + +/// The Valkey server of the `with_valkey` tests, from `VALKEY_HOST` and `VALKEY_PORT`, by default +/// 127.0.0.1:6379. `make test-valkey` runs one in a container, and sets them. +pub fn valkey_server() -> (String, u16) { + let host = std::env::var("VALKEY_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port = std::env::var("VALKEY_PORT") + .ok() + .and_then(|port| port.parse().ok()) + .unwrap_or(6379); + (host, port) +} + +/// Run the tests of the suite the component exports that `filter` selects, each in turn, failing +/// with every test that failed. +pub async fn run_suite( + mut harness: Harness, + filter: impl Fn(&str) -> bool + Send + Sync, +) -> Result<()> { + let failures = harness + .run(async |accessor, exports| { + let suite = exports.test_suite(); + let mut failures = vec![]; + for test in suite.call_tests(accessor).await? { + if !filter(&test) { + continue; + } + match suite.call_run(accessor, test.clone()).await? { + Ok(()) => println!("ok - {test}"), + Err(why) => { + println!("FAILED - {test}: {why}"); + failures.push(format!("{test}: {why}")); + } + } + } + Ok(failures) + }) + .await?; + if !failures.is_empty() { + bail!("{} failed:\n{}", failures.len(), failures.join("\n")); + } + Ok(()) +} diff --git a/crates/test-harness/src/server.rs b/crates/test-harness/src/server.rs new file mode 100644 index 0000000..fd1e8e9 --- /dev/null +++ b/crates/test-harness/src/server.rs @@ -0,0 +1,84 @@ +//! A server over TCP, played by the test. + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +use crate::{RespValue, decode, encode}; + +/// Listens on localhost for the component's connections. +pub struct Server { + listener: TcpListener, +} + +impl Server { + pub async fn bind() -> std::io::Result { + Ok(Self { + listener: TcpListener::bind("127.0.0.1:0").await?, + }) + } + + pub fn port(&self) -> u16 { + self.listener.local_addr().expect("bound").port() + } + + /// The next connection to the server. + pub async fn accept(&self) -> std::io::Result { + let (stream, _) = self.listener.accept().await?; + Ok(Client { + stream, + buffered: vec![], + }) + } +} + +/// A connection to the [`Server`], from the component. +pub struct Client { + stream: TcpStream, + /// Bytes read that are not part of a whole command yet. + buffered: Vec, +} + +impl Client { + /// The next command the client sent, as its words, `None` once it closed its side. + pub async fn request(&mut self) -> Option> { + loop { + if let Some(len) = + componentized_valkey::resp::frame_len(&self.buffered).expect("valid RESP") + { + let rest = self.buffered.split_off(len); + let frame = std::mem::replace(&mut self.buffered, rest); + let RespValue::Array(words) = decode(frame).expect("valid RESP") else { + panic!("a command is an array"); + }; + return Some( + words + .into_iter() + .map(|word| decode(word).expect("encoded word")) + .collect(), + ); + } + let mut chunk = [0; 4096]; + let read = self.stream.read(&mut chunk).await.expect("read"); + if read == 0 { + return None; + } + self.buffered.extend_from_slice(&chunk[..read]); + } + } + + /// Send a value to the client. + pub async fn reply(&mut self, value: RespValue) { + self.write(&encode(value)).await; + } + + /// Send raw bytes to the client, e.g. part of a value. + pub async fn write(&mut self, bytes: &[u8]) { + self.stream.write_all(bytes).await.expect("write"); + self.stream.flush().await.expect("flush"); + } + + /// Close the connection. + pub async fn close(mut self) { + let _ = self.stream.shutdown().await; + } +} diff --git a/make/components.mk b/make/components.mk index 012e5b8..a82da73 100644 --- a/make/components.mk +++ b/make/components.mk @@ -1,10 +1,14 @@ COMPONENTS_DIR := target/components +# the WIT packages of components/wit/deps, encoded where `wac compose` looks for a package a +# composition names without instantiating it, e.g. `import stderr: wasi:cli/stderr@0.3.0;` +WAC_DEPS_DIR := target/wac-deps + COMPONENTS = $(sort $(foreach file,$(wildcard $(addprefix components/*/,wit/*.constants.wit *.properties *.wac *.wkg Cargo.toml)),$(word 2,$(subst /, ,$(file))))) .PHONY: clean-components clean-components: clean-wit - rm -rf ${COMPONENTS_DIR} + rm -rf ${COMPONENTS_DIR} ${WAC_DEPS_DIR} .PHONY: components components: ${COMPONENTS_DIR}/interface.wasm $(foreach component,$(COMPONENTS),${COMPONENTS_DIR}/$(component)/$(component).wasm ${COMPONENTS_DIR}/$(component)/$(component).debug.wasm) @@ -35,11 +39,11 @@ else ifneq ($(wildcard components/$1/$1.wac),) # the local packages the composition instantiates, e.g. `new local:latch-n2 { ... }` WAC_DEPS_$1 := $$(shell grep -v '^\s*//' components/$1/$1.wac | grep -oE 'local:[a-z0-9-]+' | sed 's/^local://' | sort -u) -${COMPONENTS_DIR}/$1/$1.wasm: components/$1/$1.wac $$(foreach component,$$(WAC_DEPS_$1),$${COMPONENTS_DIR}/$$(component)/$$(component).wasm) ${COMPONENTS_DIR}/$1/README.md | $(call tool,wac-cli) - $(WAC) compose $$(foreach component,$$(WAC_DEPS_$1),-d local:$$(component)=$${COMPONENTS_DIR}/$$(component)/$$(component).wasm) -o ${COMPONENTS_DIR}/$1/$1.wasm components/$1/$1.wac +${COMPONENTS_DIR}/$1/$1.wasm: components/$1/$1.wac $$(foreach component,$$(WAC_DEPS_$1),$${COMPONENTS_DIR}/$$(component)/$$(component).wasm) ${COMPONENTS_DIR}/$1/README.md ${WAC_DEPS_DIR} | $(call tool,wac-cli) + $(WAC) compose --deps-dir ${WAC_DEPS_DIR} $$(foreach component,$$(WAC_DEPS_$1),-d local:$$(component)=$${COMPONENTS_DIR}/$$(component)/$$(component).wasm) -o ${COMPONENTS_DIR}/$1/$1.wasm components/$1/$1.wac -${COMPONENTS_DIR}/$1/$1.debug.wasm: components/$1/$1.wac $$(foreach component,$$(WAC_DEPS_$1),$${COMPONENTS_DIR}/$$(component)/$$(component).debug.wasm) ${COMPONENTS_DIR}/$1/README.md | $(call tool,wac-cli) - $(WAC) compose $$(foreach component,$$(WAC_DEPS_$1),-d local:$$(component)=$${COMPONENTS_DIR}/$$(component)/$$(component).debug.wasm) -o ${COMPONENTS_DIR}/$1/$1.debug.wasm components/$1/$1.wac +${COMPONENTS_DIR}/$1/$1.debug.wasm: components/$1/$1.wac $$(foreach component,$$(WAC_DEPS_$1),$${COMPONENTS_DIR}/$$(component)/$$(component).debug.wasm) ${COMPONENTS_DIR}/$1/README.md ${WAC_DEPS_DIR} | $(call tool,wac-cli) + $(WAC) compose --deps-dir ${WAC_DEPS_DIR} $$(foreach component,$$(WAC_DEPS_$1),-d local:$$(component)=$${COMPONENTS_DIR}/$$(component)/$$(component).debug.wasm) -o ${COMPONENTS_DIR}/$1/$1.debug.wasm components/$1/$1.wac else ifneq ($(wildcard components/$1/$1.wkg),) @@ -57,8 +61,8 @@ ${COMPONENTS_DIR}/$1/$1.wasm: Cargo.toml Cargo.lock components/wit/deps $(shell $(WASM_TOOLS) component new target/wasm32-unknown-unknown/release/$(subst -,_,$1).wasm -o ${COMPONENTS_DIR}/$1/$1.wasm ${COMPONENTS_DIR}/$1/$1.debug.wasm: Cargo.toml Cargo.lock components/wit/deps $(shell find components/$1 -type f) $(shell find crates -type f 2> /dev/null) ${COMPONENTS_DIR}/$1/README.md | $(call tool,wasm-tools) - cargo build --target wasm32-unknown-unknown -p $1 - $(WASM_TOOLS) component new target/wasm32-unknown-unknown/debug/$(subst -,_,$1).wasm -o ${COMPONENTS_DIR}/$1/$1.debug.wasm + cargo build -p $1 --target wasm32-unknown-unknown --profile component-debug + $(WASM_TOOLS) component new target/wasm32-unknown-unknown/component-debug/$(subst -,_,$1).wasm -o ${COMPONENTS_DIR}/$1/$1.debug.wasm endif @@ -70,6 +74,9 @@ endef $(foreach component,$(COMPONENTS),$(eval $(call BUILD_COMPONENT,$(component)))) +${WAC_DEPS_DIR}: components/wit/deps scripts/wac-deps.sh | $(call tool,wasm-tools) + WASM_TOOLS=$(WASM_TOOLS) scripts/wac-deps.sh components/wit/deps ${WAC_DEPS_DIR} + ${COMPONENTS_DIR}/interface.wasm: wit/deps README.md | $(call tool,wkg) @mkdir -p ${COMPONENTS_DIR} $(WKG) build -o ${COMPONENTS_DIR}/interface.wasm diff --git a/make/publish.mk b/make/publish.mk index 2b3a472..87c2324 100644 --- a/make/publish.mk +++ b/make/publish.mk @@ -3,8 +3,10 @@ SIGN ?= true # append each published file and its image to this file, e.g. `gate.wasm ghcr.io/componentized/http/gate:0.1.0@sha256:...` PUBLISH_LOG ?= -# the files that can be published, e.g. gate.wasm, published from target/components/gate/gate.wasm -PUBLISH_FILES := interface.wasm $(foreach component,$(filter-out dep-% test-%,$(COMPONENTS)),$(component).wasm $(component).debug.wasm) +# the files that can be published, e.g. gate.wasm, published from target/components/gate/gate.wasm. +# Components are not published when they are pulled from elsewhere, `dep-`, built here for use by +# other components, `internal-`, or tests, `test-`. +PUBLISH_FILES := interface.wasm $(foreach component,$(filter-out dep-% internal-% test-%,$(COMPONENTS)),$(component).wasm $(component).debug.wasm) .PHONY: publish ## Publish each component in the target/components directory publish: $(addprefix publish-,$(PUBLISH_FILES)) diff --git a/scripts/wac-deps.sh b/scripts/wac-deps.sh new file mode 100755 index 0000000..7e10af8 --- /dev/null +++ b/scripts/wac-deps.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +# Encode each WIT package of a wit/deps directory where `wac compose --deps-dir` looks for it, so a +# composition can name the interfaces of a package it does not instantiate, e.g. +# `import stderr: wasi:cli/stderr@0.3.0;`. +# +# WASM_TOOLS= scripts/wac-deps.sh +# +# wac looks for a versioned package at `//.wasm`, e.g. +# +# wit/deps/wasi-cli-0.3.0 -> /wasi/cli/0.3.0.wasm +# +# Each package is encoded with the other packages as its dependencies. + +set -euo pipefail + +deps=$1 +out=$2 +wasm_tools=${WASM_TOOLS:-wasm-tools} + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +rm -rf "$out" +mkdir -p "$out" + +for package in "$deps"/*/; do + package=${package%/} + rm -rf "$work/package" + mkdir -p "$work/package/deps" + cp "$package"/*.wit "$work/package/" + for dep in "$deps"/*/; do + dep=${dep%/} + [[ "$dep" == "$package" ]] || cp -R "$dep" "$work/package/deps/" + done + "$wasm_tools" component wit "$work/package" --wasm -o "$work/package.wasm" + + # e.g. `package wasi:cli@0.3.0;` + id=$("$wasm_tools" component wit "$work/package.wasm" | sed -n 's/^package \(.*\);$/\1/p' | head -n 1) + namespace=${id%%:*} + name=${id#*:} + name=${name%%@*} + version=${id#*@} + mkdir -p "$out/$namespace/$name" + cp "$work/package.wasm" "$out/$namespace/$name/$version.wasm" +done diff --git a/tools/Cargo.toml b/tools/Cargo.toml index 65632c5..fdf58f1 100644 --- a/tools/Cargo.toml +++ b/tools/Cargo.toml @@ -15,12 +15,14 @@ path = "lib.rs" # the binaries each tool installs, the Makefile runs each by path from a variable named after it, # e.g. `$(WASM_TOOLS)` for `wasm-tools` [package.metadata.bins] +componentized-constants-cli = ["constants"] wac-cli = ["wac"] wasm-tools = ["wasm-tools"] wasmtime-cli = ["wasmtime"] wkg = ["wkg"] [dependencies] +componentized-constants-cli = "=0.1.1" wac-cli = "=0.12.0" wasm-tools = "=1.261.0" wasmtime-cli = "=49.0.2" diff --git a/wit/commands.wit b/wit/commands.wit new file mode 100644 index 0000000..8d6339b --- /dev/null +++ b/wit/commands.wit @@ -0,0 +1,2711 @@ +/// Typed functions for Valkey's commands, over the mechanics of a connection. +/// +/// Each function sends one command and converts its reply. The docs of a function cover what +/// differs from the raw command, see the linked Valkey docs for what the command does. Commands +/// that change the state of the connection itself are on the connection, e.g. SUBSCRIBE, MULTI and +/// EXEC, WATCH, RESET and HELLO. +/// +/// The types are those of the `types` interface, used here under names that may change, e.g. +/// renamed to avoid clashing with a function for a new command. Refer to a type through `types` +/// for a name that does not change. +/// +/// The functions follow these idioms: +/// +/// - Arguments are in the order of the command's words. +/// - A list argument must have at least one item, as the command requires, an empty list fails with +/// a client error before anything is sent. A list the command allows to be left out is an +/// option, none rather than empty. +/// - An optional argument is an option, a flag is a bool, e.g. `replace` for `REPLACE`, and a +/// choice of keywords is an enum or a variant, e.g. `list-side` for `LEFT | RIGHT`. +/// - Durations and points in time are records named for their unit, e.g. `duration-seconds` and +/// `unix-time-milliseconds`. +/// - Keys, fields, patterns and names are `string`. Values, members and payloads are +/// `string-or-bytes`, a reply that is not UTF-8 is `bytes`. Values that are binary by nature, like +/// the serialized values of DUMP and RESTORE, are `bytes`. A reply where a `string` is expected +/// that is not UTF-8 fails with a client error. +/// - A null reply is none, e.g. GET of a key that does not exist. +/// - An integer reply that is `0` or `1` is a bool, e.g. whether HSETNX set the field. +/// - An integer reply with sentinel values is a variant or an enum with a case for each, e.g. +/// `ttl-reply` for TTL's `-2` and `-1`, or none when it stands for nothing, e.g. BITPOS's `-1`. +/// - A reply whose shape an option changes, e.g. with `WITHSCORES`, has a function of its own for +/// that form, e.g. `zrange-withscores`, its docs naming the other forms. +/// - A scan starts with the `start` cursor and resumes with the one it returned, none once it is +/// complete. +/// - An error reply fails with a `server` error. Arguments the client rejects, and replies it cannot +/// convert, fail with a `client` error. +/// - A command that blocks, e.g. BLPOP, or changes the state of the connection, e.g. SELECT, +/// affects every caller sharing the connection, see the command's docs. +interface commands { + @since(version = 0.3.0-dev) + use connection.{connection}; + @since(version = 0.3.0-dev) + use types.{ + double, error-code, integer, resp-value, duration-seconds, duration-milliseconds, + unix-time-seconds, unix-time-milliseconds, cursor, ttl-mode, hexpire-result, + hpexpireat-result, hpexpire-result, hexpireat-result, ttl-reply, httl-reply, pttl-reply, + hpttl-reply, expiretime-reply, hexpiretime-reply, pexpiretime-reply, hpexpiretime-reply, + acl-dryrun-result, acl-log-entry, bgsave-operation, client-type, client-filter, + client-pause-mode, client-unblock-type, cluster-bumpepoch-result, cluster-failover-mode, + cluster-getslotmigrations-operation, cluster-getslotmigrations-entry, + cluster-migrateslots-group, cluster-reset-mode, cluster-setslot-state, commandlog-type, + commandlog-entry, slowlog-entry, failover-target, latency-histogram-entry, + latency-latest-entry, module-list-entry, role-primary-replica, role-primary, role-replica, + role-reply, shutdown-flags, shutdown-save, acl-list-entry, acl-rule, acl-selector-rule, + acl-key-rule, acl-channel-rule, acl-command-rule, acl-db-rule, acl-user-rule, bit-range, + bitfield-encoding, bitfield-offset, bitfield-overflow, bitfield-get, bitfield-ro-get, + bitfield-set, bitfield-incrby, bitfield-operation, bitop-operation, + command-key-spec-begin-search, command-key-spec-keyword, command-key-spec-find-keys, + command-key-spec-range, command-key-spec-keynum, command-key-spec, command-info-reply, + command-reply, command-docs-argument-type, command-docs-argument, command-docs-reply, + client-setinfo-attr, client-tracking-mode, client-tracking-options, + client-trackinginfo-redirect, client-trackinginfo-reply, command-list-filter, + geo-coordinates, geo-unit, geoadd-condition, geosearch-from, geosearch-radius, + geosearch-box, geosearch-shape, geosearchstore-shape, geosearch-count, geosearchstore-count, + geosearch-with-flags, geosearch-with-result, hpersist-result, hsetex-condition, + hsetex-field-condition, key-type, migrate-flags, migrate-auth, list-side, linsert-position, + linsert-result, flush-mode, function-list-function, function-list-library, + function-stats-running, function-stats-engine, function-stats-reply, limit, sort-opts, + sort-ro-opts, sort-order, score-bound, lex-bound, zmpop-end, bzmpop-end, zset-aggregate, + zrange-by, zrangestore-by, zrange-withscores-by, zadd-condition, zadd-comparison, stream-id, + stream-trim, stream-trim-maxlen, stream-trim-minid, stream-trim-efficiency, xclaim-opts, + xinfo-consumers-entry, xinfo-groups-entry, xinfo-stream-reply, xinfo-stream-reply-full, + xinfo-stream-reply-full-group, xinfo-stream-reply-full-consumer, xpending-summary, + xpending-extended-entry, getex-ttl, hgetex-ttl, lcs-idx-reply, lcs-idx-match, + msetex-precondition, msetex-ttl, set-precondition, set-ttl, hsetex-ttl, bytes, + string-or-bytes, restore-ttl, function-restore-policy + }; + + /// Lists the ACL categories, or the commands inside a category. + /// + /// ACL CAT [ category ] + /// https://valkey.io/commands/acl-cat/ + @since(version = 0.3.0-dev) + acl-cat: async func(connection: borrow, category: option) -> result, error-code>; + + /// Deletes ACL users, and terminates their connections. + /// + /// ACL DELUSER username [ username ... ] + /// https://valkey.io/commands/acl-deluser/ + @since(version = 0.3.0-dev) + acl-deluser: async func(connection: borrow, usernames: list) -> result; + + /// Simulates the execution of a command by a user, without executing the command. + /// + /// ACL DRYRUN username command [ arg [ arg ... ] ] + /// https://valkey.io/commands/acl-dryrun/ + @since(version = 0.3.0-dev) + acl-dryrun: async func(connection: borrow, username: string, command: list) -> result; + + /// Generates a pseudorandom, secure password that can be used to identify ACL users. + /// + /// ACL GENPASS [ bits ] + /// https://valkey.io/commands/acl-genpass/ + @since(version = 0.3.0-dev) + acl-genpass: async func(connection: borrow, bits: option) -> result; + + /// Lists the ACL rules of a user. + /// + /// None when the user does not exist. The rules are as the server normalized them, e.g. + /// `allkeys` is `~*`. Applied with `acl-setuser`, after `reset`, they recreate the user. + /// + /// ACL GETUSER username + /// https://valkey.io/commands/acl-getuser/ + @since(version = 0.3.0-dev) + acl-getuser: async func(connection: borrow, username: string) -> result>, error-code>; + + /// Dumps the effective rules in ACL file format. + /// + /// ACL LIST + /// https://valkey.io/commands/acl-list/ + @since(version = 0.3.0-dev) + acl-list: async func(connection: borrow) -> result, error-code>; + + /// Reloads the rules from the configured ACL file. + /// + /// ACL LOAD + /// https://valkey.io/commands/acl-load/ + @since(version = 0.3.0-dev) + acl-load: async func(connection: borrow) -> result<_, error-code>; + + /// Lists recent security events generated due to ACL rules. + /// + /// See also `acl-log-reset`. + /// + /// ACL LOG [ count ] + /// https://valkey.io/commands/acl-log/ + @since(version = 0.3.0-dev) + acl-log: async func(connection: borrow, count: option) -> result, error-code>; + + /// Lists recent security events generated due to ACL rules. + /// + /// The `RESET` form, clearing the security events. + /// + /// See also `acl-log`. + /// + /// ACL LOG RESET + /// https://valkey.io/commands/acl-log/ + @since(version = 0.3.0-dev) + acl-log-reset: async func(connection: borrow) -> result<_, error-code>; + + /// Saves the effective ACL rules in the configured ACL file. + /// + /// ACL SAVE + /// https://valkey.io/commands/acl-save/ + @since(version = 0.3.0-dev) + acl-save: async func(connection: borrow) -> result<_, error-code>; + + /// Creates and modifies an ACL user and its rules. + /// + /// ACL SETUSER username [ rule ] [ [ rule ] ... ] + /// https://valkey.io/commands/acl-setuser/ + @since(version = 0.3.0-dev) + acl-setuser: async func(connection: borrow, username: string, rules: option>) -> result<_, error-code>; + + /// Lists all ACL users. + /// + /// ACL USERS + /// https://valkey.io/commands/acl-users/ + @since(version = 0.3.0-dev) + acl-users: async func(connection: borrow) -> result, error-code>; + + /// Returns the authenticated username of the current connection. + /// + /// ACL WHOAMI + /// https://valkey.io/commands/acl-whoami/ + @since(version = 0.3.0-dev) + acl-whoami: async func(connection: borrow) -> result; + + /// Asynchronously rewrites the append-only file to disk. + /// + /// BGREWRITEAOF + /// https://valkey.io/commands/bgrewriteaof/ + @since(version = 0.3.0-dev) + bgrewriteaof: async func(connection: borrow) -> result; + + /// Asynchronously saves the database(s) to disk. + /// + /// BGSAVE [ SCHEDULE | CANCEL ] + /// https://valkey.io/commands/bgsave/ + @since(version = 0.3.0-dev) + bgsave: async func(connection: borrow, operation: option) -> result; + + /// Terminates open connections. + /// + /// CLIENT KILL filter [ filter ... ] + /// https://valkey.io/commands/client-kill/ + @since(version = 0.3.0-dev) + client-kill: async func(connection: borrow, filters: list) -> result; + + /// Lists open connections. + /// + /// Each connection is a map of its fields, parsed from the server's text, e.g. `id`, `addr` and + /// `name`. + /// + /// CLIENT LIST [ filter [ filter ... ] ] + /// https://valkey.io/commands/client-list/ + @since(version = 0.3.0-dev) + client-list: async func(connection: borrow, filters: option>) -> result>, error-code>; + + /// Sets the client eviction mode of the connection. + /// + /// It applies to the connection, so to every caller sharing it. + /// + /// CLIENT NO-EVICT 〈 ON | OFF 〉 + /// https://valkey.io/commands/client-no-evict/ + @since(version = 0.3.0-dev) + client-no-evict: async func(connection: borrow, enabled: bool) -> result<_, error-code>; + + /// Suspends commands processing. + /// + /// CLIENT PAUSE timeout [ WRITE | ALL ] + /// https://valkey.io/commands/client-pause/ + @since(version = 0.3.0-dev) + client-pause: async func(connection: borrow, timeout: duration-milliseconds, mode: option) -> result<_, error-code>; + + /// Unblocks a client blocked by a blocking command from a different connection. + /// + /// CLIENT UNBLOCK client-id [ TIMEOUT | ERROR ] + /// https://valkey.io/commands/client-unblock/ + @since(version = 0.3.0-dev) + client-unblock: async func(connection: borrow, client-id: integer, unblock-type: option) -> result; + + /// Resumes processing commands from paused clients. + /// + /// CLIENT UNPAUSE + /// https://valkey.io/commands/client-unpause/ + @since(version = 0.3.0-dev) + client-unpause: async func(connection: borrow) -> result<_, error-code>; + + /// Assigns new hash slots to a node. + /// + /// CLUSTER ADDSLOTS slot [ slot ... ] + /// https://valkey.io/commands/cluster-addslots/ + @since(version = 0.3.0-dev) + cluster-addslots: async func(connection: borrow, slots: list) -> result<_, error-code>; + + /// Assigns new hash slot ranges to a node. + /// + /// CLUSTER ADDSLOTSRANGE start-slot end-slot [ start-slot end-slot ... ] + /// https://valkey.io/commands/cluster-addslotsrange/ + @since(version = 0.3.0-dev) + cluster-addslotsrange: async func(connection: borrow, ranges: list>) -> result<_, error-code>; + + /// Advances the cluster config epoch. + /// + /// CLUSTER BUMPEPOCH + /// https://valkey.io/commands/cluster-bumpepoch/ + @since(version = 0.3.0-dev) + cluster-bumpepoch: async func(connection: borrow) -> result; + + /// Cancel all current ongoing slot migration operations. + /// + /// CLUSTER CANCELSLOTMIGRATIONS + /// https://valkey.io/commands/cluster-cancelslotmigrations/ + @since(version = 0.3.0-dev) + cluster-cancelslotmigrations: async func(connection: borrow) -> result<_, error-code>; + + /// Returns the number of active failure reports for a node. + /// + /// CLUSTER COUNT-FAILURE-REPORTS node-id + /// https://valkey.io/commands/cluster-count-failure-reports/ + @since(version = 0.3.0-dev) + cluster-count-failure-reports: async func(connection: borrow, node-id: string) -> result; + + /// Sets hash slots as unbound for a node. + /// + /// CLUSTER DELSLOTS slot [ slot ... ] + /// https://valkey.io/commands/cluster-delslots/ + @since(version = 0.3.0-dev) + cluster-delslots: async func(connection: borrow, slots: list) -> result<_, error-code>; + + /// Sets hash slot ranges as unbound for a node. + /// + /// CLUSTER DELSLOTSRANGE start-slot end-slot [ start-slot end-slot ... ] + /// https://valkey.io/commands/cluster-delslotsrange/ + @since(version = 0.3.0-dev) + cluster-delslotsrange: async func(connection: borrow, ranges: list>) -> result<_, error-code>; + + /// Forces a replica to perform a manual failover of its primary. + /// + /// CLUSTER FAILOVER [ FORCE | TAKEOVER ] + /// https://valkey.io/commands/cluster-failover/ + @since(version = 0.3.0-dev) + cluster-failover: async func(connection: borrow, mode: option) -> result<_, error-code>; + + /// Deletes all slots information from a node. + /// + /// CLUSTER FLUSHSLOTS + /// https://valkey.io/commands/cluster-flushslots/ + @since(version = 0.3.0-dev) + cluster-flushslots: async func(connection: borrow) -> result<_, error-code>; + + /// Removes a node from the nodes table. + /// + /// CLUSTER FORGET node-id + /// https://valkey.io/commands/cluster-forget/ + @since(version = 0.3.0-dev) + cluster-forget: async func(connection: borrow, node-id: string) -> result<_, error-code>; + + /// Gets the status of ongoing and recently finished slot import and export operations. + /// + /// CLUSTER GETSLOTMIGRATIONS + /// https://valkey.io/commands/cluster-getslotmigrations/ + @since(version = 0.3.0-dev) + cluster-getslotmigrations: async func(connection: borrow) -> result, error-code>; + + /// Forces a node to handshake with another node. + /// + /// CLUSTER MEET ip port [ cluster-bus-port ] + /// https://valkey.io/commands/cluster-meet/ + @since(version = 0.3.0-dev) + cluster-meet: async func(connection: borrow, ip: string, port: u16, cluster-bus-port: option) -> result<_, error-code>; + + /// Migrate the given slots from this node to the specified nodes. + /// + /// CLUSTER MIGRATESLOTS SLOTSRANGE start-slot end-slot [ start-slot end-slot ... ] NODE node-id [ AUTH username password ] [ SLOTSRANGE ... ] + /// https://valkey.io/commands/cluster-migrateslots/ + @since(version = 0.3.0-dev) + cluster-migrateslots: async func(connection: borrow, migrations: list) -> result<_, error-code>; + + /// Lists the replica nodes of a primary node. + /// + /// CLUSTER REPLICAS node-id + /// https://valkey.io/commands/cluster-replicas/ + @since(version = 0.3.0-dev) + cluster-replicas: async func(connection: borrow, node-id: string) -> result, error-code>; + + /// Configures a node as replica of a primary node or detaches a replica from its primary. + /// + /// None detaches the node from its primary, `NO ONE`. + /// + /// CLUSTER REPLICATE 〈 node-id | NO ONE 〉 + /// https://valkey.io/commands/cluster-replicate/ + @since(version = 0.3.0-dev) + cluster-replicate: async func(connection: borrow, node-id: option) -> result<_, error-code>; + + /// Resets a node. + /// + /// CLUSTER RESET [ HARD | SOFT ] + /// https://valkey.io/commands/cluster-reset/ + @since(version = 0.3.0-dev) + cluster-reset: async func(connection: borrow, mode: option) -> result<_, error-code>; + + /// Forces a node to save the cluster configuration to disk. + /// + /// CLUSTER SAVECONFIG + /// https://valkey.io/commands/cluster-saveconfig/ + @since(version = 0.3.0-dev) + cluster-saveconfig: async func(connection: borrow) -> result<_, error-code>; + + /// Sets the configuration epoch for a new node. + /// + /// CLUSTER SET-CONFIG-EPOCH config-epoch + /// https://valkey.io/commands/cluster-set-config-epoch/ + @since(version = 0.3.0-dev) + cluster-set-config-epoch: async func(connection: borrow, config-epoch: integer) -> result<_, error-code>; + + /// Binds a hash slot to a node. + /// + /// CLUSTER SETSLOT slot 〈 IMPORTING node-id | MIGRATING node-id | NODE node-id | STABLE 〉 [ TIMEOUT timeout ] + /// https://valkey.io/commands/cluster-setslot/ + @since(version = 0.3.0-dev) + cluster-setslot: async func(connection: borrow, slot: u16, state: cluster-setslot-state, timeout: option) -> result<_, error-code>; + + /// Returns the specified command log's entries. + /// + /// COMMANDLOG GET count 〈 slow | large-request | large-reply 〉 + /// https://valkey.io/commands/commandlog-get/ + @since(version = 0.3.0-dev) + commandlog-get: async func(connection: borrow, count: integer, %type: commandlog-type) -> result, error-code>; + + /// Returns the number of entries in the specified type of command log. + /// + /// COMMANDLOG LEN 〈 slow | large-request | large-reply 〉 + /// https://valkey.io/commands/commandlog-len/ + @since(version = 0.3.0-dev) + commandlog-len: async func(connection: borrow, %type: commandlog-type) -> result; + + /// Clears all entries from the specified type of command log. + /// + /// COMMANDLOG RESET 〈 slow | large-request | large-reply 〉 + /// https://valkey.io/commands/commandlog-reset/ + @since(version = 0.3.0-dev) + commandlog-reset: async func(connection: borrow, %type: commandlog-type) -> result<_, error-code>; + + /// Returns the effective values of configuration parameters. + /// + /// CONFIG GET parameter [ parameter ... ] + /// https://valkey.io/commands/config-get/ + @since(version = 0.3.0-dev) + config-get: async func(connection: borrow, parameters: list) -> result, error-code>; + + /// Resets the server's statistics. + /// + /// CONFIG RESETSTAT + /// https://valkey.io/commands/config-resetstat/ + @since(version = 0.3.0-dev) + config-resetstat: async func(connection: borrow) -> result<_, error-code>; + + /// Persists the effective configuration to file. + /// + /// CONFIG REWRITE + /// https://valkey.io/commands/config-rewrite/ + @since(version = 0.3.0-dev) + config-rewrite: async func(connection: borrow) -> result<_, error-code>; + + /// Sets configuration parameters in-flight. + /// + /// CONFIG SET parameter value [ parameter value ... ] + /// https://valkey.io/commands/config-set/ + @since(version = 0.3.0-dev) + config-set: async func(connection: borrow, parameters-and-values: list>) -> result<_, error-code>; + + /// Starts a coordinated failover from a server to one of its replicas. + /// + /// FAILOVER [ TO host port [ FORCE ] ] [ ABORT ] [ TIMEOUT milliseconds ] + /// https://valkey.io/commands/failover/ + @since(version = 0.3.0-dev) + failover: async func(connection: borrow, target: option, abort: bool, timeout: option) -> result<_, error-code>; + + /// Returns the Unix timestamp of the last successful save to disk. + /// + /// LASTSAVE + /// https://valkey.io/commands/lastsave/ + @since(version = 0.3.0-dev) + lastsave: async func(connection: borrow) -> result; + + /// Returns a human-readable latency analysis report. + /// + /// LATENCY DOCTOR + /// https://valkey.io/commands/latency-doctor/ + @since(version = 0.3.0-dev) + latency-doctor: async func(connection: borrow) -> result; + + /// Returns a latency graph for an event. + /// + /// LATENCY GRAPH event + /// https://valkey.io/commands/latency-graph/ + @since(version = 0.3.0-dev) + latency-graph: async func(connection: borrow, event: string) -> result; + + /// Returns the cumulative distribution of latencies of a subset or all commands. + /// + /// LATENCY HISTOGRAM [ command [ command ... ] ] + /// https://valkey.io/commands/latency-histogram/ + @since(version = 0.3.0-dev) + latency-histogram: async func(connection: borrow, commands: option>) -> result, error-code>; + + /// Returns timestamp-latency samples for an event. + /// + /// LATENCY HISTORY event + /// https://valkey.io/commands/latency-history/ + @since(version = 0.3.0-dev) + latency-history: async func(connection: borrow, event: string) -> result>, error-code>; + + /// Returns the latest latency samples for all events. + /// + /// LATENCY LATEST + /// https://valkey.io/commands/latency-latest/ + @since(version = 0.3.0-dev) + latency-latest: async func(connection: borrow) -> result, error-code>; + + /// Resets the latency data for one or more events. + /// + /// LATENCY RESET [ event [ event ... ] ] + /// https://valkey.io/commands/latency-reset/ + @since(version = 0.3.0-dev) + latency-reset: async func(connection: borrow, events: option>) -> result; + + /// Returns all loaded modules. + /// + /// MODULE LIST + /// https://valkey.io/commands/module-list/ + @since(version = 0.3.0-dev) + module-list: async func(connection: borrow) -> result, error-code>; + + /// Loads a module. + /// + /// MODULE LOAD path [ arg [ arg ... ] ] + /// https://valkey.io/commands/module-load/ + @since(version = 0.3.0-dev) + module-load: async func(connection: borrow, path: string, args: option>) -> result<_, error-code>; + + /// Loads a module using extended parameters. + /// + /// MODULE LOADEX path [ CONFIG name value [ CONFIG name value ... ] ] [ ARGS args [ args ... ] ] + /// https://valkey.io/commands/module-loadex/ + @since(version = 0.3.0-dev) + module-loadex: async func(connection: borrow, path: string, configs: option>>, args: option>) -> result<_, error-code>; + + /// Unloads a module. + /// + /// MODULE UNLOAD name + /// https://valkey.io/commands/module-unload/ + @since(version = 0.3.0-dev) + module-unload: async func(connection: borrow, name: string) -> result<_, error-code>; + + /// Configures a server as replica of another, or promotes it to a primary. + /// + /// None makes the server a primary, `NO ONE`. + /// + /// REPLICAOF 〈 host port | NO ONE 〉 + /// https://valkey.io/commands/replicaof/ + @since(version = 0.3.0-dev) + replicaof: async func(connection: borrow, primary: option>) -> result; + + /// Returns the replication role. + /// + /// ROLE + /// https://valkey.io/commands/role/ + @since(version = 0.3.0-dev) + role: async func(connection: borrow) -> result; + + /// Synchronously saves the database(s) to disk. + /// + /// SAVE + /// https://valkey.io/commands/save/ + @since(version = 0.3.0-dev) + save: async func(connection: borrow) -> result<_, error-code>; + + /// Synchronously saves the database(s) to disk and shuts down the server. + /// + /// The server closes the connection once it stops, so a shutdown that succeeds fails the call + /// with a closed error. + /// + /// See also `shutdown-abort`. + /// + /// SHUTDOWN [ NOSAVE | SAVE ] [ NOW ] [ FORCE ] [ SAFE ] [ FAILOVER ] + /// https://valkey.io/commands/shutdown/ + @since(version = 0.3.0-dev) + shutdown: async func(connection: borrow, save: option, modifiers: shutdown-flags) -> result<_, error-code>; + + /// Synchronously saves the database(s) to disk and shuts down the server. + /// + /// The `ABORT` form, aborting a shutdown in progress. + /// + /// See also `shutdown`. + /// + /// SHUTDOWN ABORT + /// https://valkey.io/commands/shutdown/ + @since(version = 0.3.0-dev) + shutdown-abort: async func(connection: borrow) -> result<_, error-code>; + + /// Returns the slow log's entries. + /// + /// SLOWLOG GET [ count ] + /// https://valkey.io/commands/slowlog-get/ + @since(version = 0.3.0-dev) + slowlog-get: async func(connection: borrow, count: option) -> result, error-code>; + + /// Returns the number of entries in the slow log. + /// + /// SLOWLOG LEN + /// https://valkey.io/commands/slowlog-len/ + @since(version = 0.3.0-dev) + slowlog-len: async func(connection: borrow) -> result; + + /// Clears all entries from the slow log. + /// + /// SLOWLOG RESET + /// https://valkey.io/commands/slowlog-reset/ + @since(version = 0.3.0-dev) + slowlog-reset: async func(connection: borrow) -> result<_, error-code>; + + /// Counts the number of set bits (population counting) in a string. + /// + /// BITCOUNT key [ start [ end [ BYTE | BIT ] ] ] + /// https://valkey.io/commands/bitcount/ + @since(version = 0.3.0-dev) + bitcount: async func(connection: borrow, key: string, range: option) -> result; + + /// Performs arbitrary bitfield integer operations on strings. + /// + /// None for a write that failed with `OVERFLOW FAIL`. + /// + /// BITFIELD key [ GET encoding offset | [ OVERFLOW WRAP | SAT | FAIL ] 〈 SET encoding offset value | INCRBY encoding offset increment 〉 [ GET encoding offset | [ OVERFLOW WRAP | SAT | FAIL ] 〈 SET encoding offset value | INCRBY encoding offset increment 〉 ... ] ] + /// https://valkey.io/commands/bitfield/ + @since(version = 0.3.0-dev) + bitfield: async func(connection: borrow, key: string, operations: option>) -> result>, error-code>; + + /// Performs arbitrary read-only bitfield integer operations on strings. + /// + /// BITFIELD_RO key [ GET encoding offset [ GET encoding offset ... ] ] + /// https://valkey.io/commands/bitfield_ro/ + @since(version = 0.3.0-dev) + bitfield-ro: async func(connection: borrow, key: string, gets: option>) -> result, error-code>; + + /// Performs bitwise operations on multiple strings, and stores the result. + /// + /// BITOP 〈 AND | OR | XOR | NOT 〉 destkey key [ key ... ] + /// https://valkey.io/commands/bitop/ + @since(version = 0.3.0-dev) + bitop: async func(connection: borrow, destination: string, operation: bitop-operation) -> result; + + /// Finds the first set (1) or clear (0) bit in a string. + /// + /// None when no bit matches, the server's `-1`. + /// + /// BITPOS key bit [ start [ end [ BYTE | BIT ] ] ] + /// https://valkey.io/commands/bitpos/ + @since(version = 0.3.0-dev) + bitpos: async func(connection: borrow, key: string, bit: bool, range: option) -> result, error-code>; + + /// Returns a bit value by offset. + /// + /// GETBIT key offset + /// https://valkey.io/commands/getbit/ + @since(version = 0.3.0-dev) + getbit: async func(connection: borrow, key: string, offset: u64) -> result; + + /// Sets or clears the bit at offset of the string value. + /// + /// SETBIT key offset value + /// https://valkey.io/commands/setbit/ + @since(version = 0.3.0-dev) + setbit: async func(connection: borrow, key: string, offset: u64, value: bool) -> result; + + /// Signals that a cluster client is following an -ASK redirect. + /// + /// It applies to the next command on the connection, which may be another caller's when the + /// connection is shared. Use a connection of your own. + /// + /// ASKING + /// https://valkey.io/commands/asking/ + @since(version = 0.3.0-dev) + asking: async func(connection: borrow) -> result<_, error-code>; + + /// Authenticates the connection. + /// + /// AUTH [ username ] password + /// https://valkey.io/commands/auth/ + @since(version = 0.3.0-dev) + auth: async func(connection: borrow, username: option, password: string) -> result<_, error-code>; + + /// Instructs the server whether to track the keys in the next request. + /// + /// It applies to the next command on the connection, which may be another caller's when the + /// connection is shared. Use a connection of your own. + /// + /// CLIENT CACHING 〈 YES | NO 〉 + /// https://valkey.io/commands/client-caching/ + @since(version = 0.3.0-dev) + client-caching: async func(connection: borrow, enabled: bool) -> result<_, error-code>; + + /// A client claims its capability. + /// + /// CLIENT CAPA capability [ capability ... ] + /// https://valkey.io/commands/client-capa/ + @since(version = 0.3.0-dev) + client-capa: async func(connection: borrow, capabilities: list) -> result<_, error-code>; + + /// Returns the name of the connection. + /// + /// CLIENT GETNAME + /// https://valkey.io/commands/client-getname/ + @since(version = 0.3.0-dev) + client-getname: async func(connection: borrow) -> result, error-code>; + + /// Returns the unique client ID of the connection. + /// + /// CLIENT ID + /// https://valkey.io/commands/client-id/ + @since(version = 0.3.0-dev) + client-id: async func(connection: borrow) -> result; + + /// Marks this client as an import source when the server is in import mode. + /// + /// CLIENT IMPORT-SOURCE 〈 ON | OFF 〉 + /// https://valkey.io/commands/client-import-source/ + @since(version = 0.3.0-dev) + client-import-source: async func(connection: borrow, enabled: bool) -> result<_, error-code>; + + /// Returns information about the connection. + /// + /// A map of the fields, parsed from the server's text, e.g. `id`, `addr` and `name`. + /// + /// CLIENT INFO + /// https://valkey.io/commands/client-info/ + @since(version = 0.3.0-dev) + client-info: async func(connection: borrow) -> result, error-code>; + + /// Controls whether commands sent by the client affect the LRU/LFU of accessed keys. + /// + /// It applies to the connection, so to every caller sharing it. + /// + /// CLIENT NO-TOUCH 〈 ON | OFF 〉 + /// https://valkey.io/commands/client-no-touch/ + @since(version = 0.3.0-dev) + client-no-touch: async func(connection: borrow, enabled: bool) -> result<_, error-code>; + + /// Sets information specific to the client or connection. + /// + /// CLIENT SETINFO 〈 LIB-NAME libname | LIB-VER libver 〉 + /// https://valkey.io/commands/client-setinfo/ + @since(version = 0.3.0-dev) + client-setinfo: async func(connection: borrow, attr: client-setinfo-attr) -> result<_, error-code>; + + /// Sets the connection name. + /// + /// CLIENT SETNAME connection-name + /// https://valkey.io/commands/client-setname/ + @since(version = 0.3.0-dev) + client-setname: async func(connection: borrow, connection-name: string) -> result<_, error-code>; + + /// Controls server-assisted client-side caching for the connection. + /// + /// Invalidations arrive as push messages, see `connection.dead-letters`. It applies to the + /// connection, so to every caller sharing it. + /// + /// CLIENT TRACKING 〈 ON | OFF 〉 [ PREFIX prefix [ PREFIX prefix ... ] ] [ BCAST ] [ OPTIN ] [ OPTOUT ] [ NOLOOP ] + /// https://valkey.io/commands/client-tracking/ + @since(version = 0.3.0-dev) + client-tracking: async func(connection: borrow, options: option) -> result<_, error-code>; + + /// Returns information about server-assisted client-side caching for the connection. + /// + /// CLIENT TRACKINGINFO + /// https://valkey.io/commands/client-trackinginfo/ + @since(version = 0.3.0-dev) + client-trackinginfo: async func(connection: borrow) -> result; + + /// Returns detailed information about all commands. + /// + /// COMMAND + /// https://valkey.io/commands/command/ + @since(version = 0.3.0-dev) + command: async func(connection: borrow) -> result, error-code>; + + /// Returns a count of commands. + /// + /// COMMAND COUNT + /// https://valkey.io/commands/command-count/ + @since(version = 0.3.0-dev) + command-count: async func(connection: borrow) -> result; + + /// Returns documentary information about one, multiple or all commands. + /// + /// A container is followed by its subcommands. Commands that do not exist are left out. + /// + /// COMMAND DOCS [ command-name [ command-name ... ] ] + /// https://valkey.io/commands/command-docs/ + @since(version = 0.3.0-dev) + command-docs: async func(connection: borrow, command-names: option>) -> result, error-code>; + + /// Extracts the key names from an arbitrary command. + /// + /// COMMAND GETKEYS command [ arg [ arg ... ] ] + /// https://valkey.io/commands/command-getkeys/ + @since(version = 0.3.0-dev) + command-getkeys: async func(connection: borrow, command: list) -> result, error-code>; + + /// Extracts the key names and access flags for an arbitrary command. + /// + /// COMMAND GETKEYSANDFLAGS command [ arg [ arg ... ] ] + /// https://valkey.io/commands/command-getkeysandflags/ + @since(version = 0.3.0-dev) + command-getkeysandflags: async func(connection: borrow, command: list) -> result>>, error-code>; + + /// Returns information about one, multiple or all commands. + /// + /// A container is followed by its subcommands. Commands that do not exist are left out. + /// + /// COMMAND INFO [ command-name [ command-name ... ] ] + /// https://valkey.io/commands/command-info/ + @since(version = 0.3.0-dev) + command-info: async func(connection: borrow, command-names: option>) -> result, error-code>; + + /// Returns a list of command names. + /// + /// COMMAND LIST [ FILTERBY 〈 MODULE module-name | ACLCAT category | PATTERN pattern 〉 ] + /// https://valkey.io/commands/command-list/ + @since(version = 0.3.0-dev) + command-list: async func(connection: borrow, filter: option) -> result, error-code>; + + /// Returns the given string. + /// + /// ECHO message + /// https://valkey.io/commands/echo/ + @since(version = 0.3.0-dev) + echo: async func(connection: borrow, message: string-or-bytes) -> result; + + /// Returns the server's liveliness response. + /// + /// PING [ message ] + /// https://valkey.io/commands/ping/ + @since(version = 0.3.0-dev) + ping: async func(connection: borrow, message: option) -> result<_, error-code>; + + /// Enables read-only queries for a connection to a Valkey replica node. + /// + /// It applies to the connection, so to every caller sharing it. + /// + /// READONLY + /// https://valkey.io/commands/readonly/ + @since(version = 0.3.0-dev) + readonly: async func(connection: borrow) -> result<_, error-code>; + + /// Enables read-write queries for a connection to a Valkey replica node. + /// + /// It applies to the connection, so to every caller sharing it. + /// + /// READWRITE + /// https://valkey.io/commands/readwrite/ + @since(version = 0.3.0-dev) + readwrite: async func(connection: borrow) -> result<_, error-code>; + + /// Changes the selected database. + /// + /// It applies to the connection, so to every caller sharing it. + /// + /// SELECT index + /// https://valkey.io/commands/select/ + @since(version = 0.3.0-dev) + select: async func(connection: borrow, index: integer) -> result<_, error-code>; + + /// Blocks until the asynchronous replication of all preceding write commands sent by the connection is completed. + /// + /// The connection waits too, replies to commands sent after it on the connection wait behind + /// it. Use a connection of your own. + /// + /// WAIT numreplicas timeout + /// https://valkey.io/commands/wait/ + @since(version = 0.3.0-dev) + wait: async func(connection: borrow, numreplicas: integer, timeout: duration-milliseconds) -> result; + + /// Blocks until all of the preceding write commands sent by the connection are written to the append-only file of the primary and/or replicas. + /// + /// The connection waits too, replies to commands sent after it on the connection wait behind + /// it. Use a connection of your own. + /// + /// WAITAOF numlocal numreplicas timeout + /// https://valkey.io/commands/waitaof/ + @since(version = 0.3.0-dev) + waitaof: async func(connection: borrow, numlocal: integer, numreplicas: integer, timeout: duration-milliseconds) -> result, error-code>; + + /// Adds one or more members to a geospatial index. + /// + /// GEOADD key [ NX | XX ] [ CH ] longitude latitude member [ longitude latitude member ... ] + /// https://valkey.io/commands/geoadd/ + @since(version = 0.3.0-dev) + geoadd: async func(connection: borrow, key: string, condition: option, changed: bool, coordinates-and-members: list>) -> result; + + /// Returns the distance between two members of a geospatial index. + /// + /// GEODIST key member1 member2 [ m | km | ft | mi ] + /// https://valkey.io/commands/geodist/ + @since(version = 0.3.0-dev) + geodist: async func(connection: borrow, key: string, member1: string-or-bytes, member2: string-or-bytes, unit: option) -> result, error-code>; + + /// Returns members from a geospatial index as geohash strings. + /// + /// GEOHASH key [ member [ member ... ] ] + /// https://valkey.io/commands/geohash/ + @since(version = 0.3.0-dev) + geohash: async func(connection: borrow, key: string, members: option>) -> result>, error-code>; + + /// Returns the longitude and latitude of members from a geospatial index. + /// + /// GEOPOS key [ member [ member ... ] ] + /// https://valkey.io/commands/geopos/ + @since(version = 0.3.0-dev) + geopos: async func(connection: borrow, key: string, members: option>) -> result>, error-code>; + + /// Queries a geospatial index for members inside an area of a box, circle, or a polygon. + /// + /// See also `geosearch-with`. + /// + /// GEOSEARCH key [ FROMMEMBER member | FROMLONLAT longitude latitude ] 〈 BYRADIUS radius 〈 m | km | ft | mi 〉 | BYBOX width height 〈 m | km | ft | mi 〉 | BYPOLYGON num-vertices longitude latitude [ longitude latitude ... ] 〉 [ ASC | DESC ] [ COUNT count [ ANY ] ] + /// https://valkey.io/commands/geosearch/ + @since(version = 0.3.0-dev) + geosearch: async func(connection: borrow, key: string, shape: geosearch-shape, order: option, count: option) -> result, error-code>; + + /// Queries a geospatial index for members inside an area of a box, circle, or a polygon. + /// + /// Returns each member with the details asked for, `WITHCOORD`, `WITHDIST` and `WITHHASH`. + /// + /// See also `geosearch`. + /// + /// GEOSEARCH key [ FROMMEMBER member | FROMLONLAT longitude latitude ] 〈 BYRADIUS radius 〈 m | km | ft | mi 〉 | BYBOX width height 〈 m | km | ft | mi 〉 | BYPOLYGON num-vertices longitude latitude [ longitude latitude ... ] 〉 [ ASC | DESC ] [ COUNT count [ ANY ] ] [ WITHCOORD ] [ WITHDIST ] [ WITHHASH ] + /// https://valkey.io/commands/geosearch/ + @since(version = 0.3.0-dev) + geosearch-with: async func(connection: borrow, key: string, shape: geosearch-shape, order: option, count: option, details: geosearch-with-flags) -> result, error-code>; + + /// Queries a geospatial index for members inside an area of a box, a circle, or a polygon, optionally stores the result. + /// + /// GEOSEARCHSTORE destination source [ FROMMEMBER member | FROMLONLAT longitude latitude ] 〈 BYRADIUS radius 〈 m | km | ft | mi 〉 | BYBOX width height 〈 m | km | ft | mi 〉 | BYPOLYGON num-vertices longitude latitude [ longitude latitude ... ] 〉 [ ASC | DESC ] [ COUNT count [ ANY ] ] [ STOREDIST ] + /// https://valkey.io/commands/geosearchstore/ + @since(version = 0.3.0-dev) + geosearchstore: async func(connection: borrow, destination: string, source: string, shape: geosearchstore-shape, order: option, count: option, storedist: bool) -> result; + + /// Deletes one or more fields and their values from a hash. + /// + /// HDEL key field [ field ... ] + /// https://valkey.io/commands/hdel/ + @since(version = 0.3.0-dev) + hdel: async func(connection: borrow, key: string, fields: list) -> result; + + /// Determines whether a field exists in a hash. + /// + /// HEXISTS key field + /// https://valkey.io/commands/hexists/ + @since(version = 0.3.0-dev) + hexists: async func(connection: borrow, key: string, field: string) -> result; + + /// Sets the expiration time of one or more hash fields in seconds. + /// + /// HEXPIRE key seconds [ NX | XX | GT | LT ] FIELDS numfields field [ field ... ] + /// https://valkey.io/commands/hexpire/ + @since(version = 0.3.0-dev) + hexpire: async func(connection: borrow, key: string, seconds: duration-seconds, mode: option, fields: list) -> result, error-code>; + + /// Sets the expiration time of one or more hash fields to a Unix timestamp. + /// + /// HEXPIREAT key unix-time-seconds [ NX | XX | GT | LT ] FIELDS numfields field [ field ... ] + /// https://valkey.io/commands/hexpireat/ + @since(version = 0.3.0-dev) + hexpireat: async func(connection: borrow, key: string, seconds: unix-time-seconds, mode: option, fields: list) -> result, error-code>; + + /// Returns Unix timestamps in seconds since the epoch at which the given key's field(s) will expire. + /// + /// HEXPIRETIME key FIELDS numfields field [ field ... ] + /// https://valkey.io/commands/hexpiretime/ + @since(version = 0.3.0-dev) + hexpiretime: async func(connection: borrow, key: string, fields: list) -> result, error-code>; + + /// Returns the value of a field in a hash. + /// + /// HGET key field + /// https://valkey.io/commands/hget/ + @since(version = 0.3.0-dev) + hget: async func(connection: borrow, key: string, field: string) -> result, error-code>; + + /// Returns all fields and values in a hash. + /// + /// HGETALL key + /// https://valkey.io/commands/hgetall/ + @since(version = 0.3.0-dev) + hgetall: async func(connection: borrow, key: string) -> result, error-code>; + + /// Returns the values of one or more fields and deletes them from a hash. + /// + /// HGETDEL key FIELDS numfields field [ field ... ] + /// https://valkey.io/commands/hgetdel/ + @since(version = 0.3.0-dev) + hgetdel: async func(connection: borrow, key: string, fields: list) -> result>, error-code>; + + /// Gets the value of one or more fields of a given hash key, and optionally sets their expiration time or time-to-live (TTL). + /// + /// HGETEX key [ EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | PERSIST ] FIELDS numfields field [ field ... ] + /// https://valkey.io/commands/hgetex/ + @since(version = 0.3.0-dev) + hgetex: async func(connection: borrow, key: string, ttl: option, fields: list) -> result>, error-code>; + + /// Increments the integer value of a field in a hash by a number. + /// + /// HINCRBY key field increment + /// https://valkey.io/commands/hincrby/ + @since(version = 0.3.0-dev) + hincrby: async func(connection: borrow, key: string, field: string, increment: integer) -> result; + + /// Increments the floating point value of a field by a number. + /// + /// HINCRBYFLOAT key field increment + /// https://valkey.io/commands/hincrbyfloat/ + @since(version = 0.3.0-dev) + hincrbyfloat: async func(connection: borrow, key: string, field: string, increment: double) -> result; + + /// Returns all fields in a hash. + /// + /// HKEYS key + /// https://valkey.io/commands/hkeys/ + @since(version = 0.3.0-dev) + hkeys: async func(connection: borrow, key: string) -> result, error-code>; + + /// Returns the number of fields in a hash. + /// + /// HLEN key + /// https://valkey.io/commands/hlen/ + @since(version = 0.3.0-dev) + hlen: async func(connection: borrow, key: string) -> result; + + /// Returns the values of all fields in a hash. + /// + /// HMGET key field [ field ... ] + /// https://valkey.io/commands/hmget/ + @since(version = 0.3.0-dev) + hmget: async func(connection: borrow, key: string, fields: list) -> result>, error-code>; + + /// Sets the values of multiple fields. + /// + /// HMSET key field value [ field value ... ] + /// https://valkey.io/commands/hmset/ + @since(version = 0.3.0-dev) + hmset: async func(connection: borrow, key: string, field-and-values: list>) -> result<_, error-code>; + + /// Remove the existing expiration on a hash key's field(s). + /// + /// HPERSIST key FIELDS numfields field [ field ... ] + /// https://valkey.io/commands/hpersist/ + @since(version = 0.3.0-dev) + hpersist: async func(connection: borrow, key: string, fields: list) -> result, error-code>; + + /// Sets the expiration time of one or more hash fields in milliseconds. + /// + /// HPEXPIRE key milliseconds [ NX | XX | GT | LT ] FIELDS numfields field [ field ... ] + /// https://valkey.io/commands/hpexpire/ + @since(version = 0.3.0-dev) + hpexpire: async func(connection: borrow, key: string, milliseconds: duration-milliseconds, mode: option, fields: list) -> result, error-code>; + + /// Sets the expiration time of one or more hash fields to a Unix timestamp in milliseconds. + /// + /// HPEXPIREAT key unix-time-milliseconds [ NX | XX | GT | LT ] FIELDS numfields field [ field ... ] + /// https://valkey.io/commands/hpexpireat/ + @since(version = 0.3.0-dev) + hpexpireat: async func(connection: borrow, key: string, milliseconds: unix-time-milliseconds, mode: option, fields: list) -> result, error-code>; + + /// Returns the Unix timestamp in milliseconds since Unix epoch at which the given key's field(s) will expire. + /// + /// HPEXPIRETIME key FIELDS numfields field [ field ... ] + /// https://valkey.io/commands/hpexpiretime/ + @since(version = 0.3.0-dev) + hpexpiretime: async func(connection: borrow, key: string, fields: list) -> result, error-code>; + + /// Returns the remaining time to live in milliseconds of a hash key's field(s) that have an associated expiration. + /// + /// HPTTL key FIELDS numfields field [ field ... ] + /// https://valkey.io/commands/hpttl/ + @since(version = 0.3.0-dev) + hpttl: async func(connection: borrow, key: string, fields: list) -> result, error-code>; + + /// Returns one or more random fields from a hash. + /// + /// Without a count, at most one field. + /// + /// See also `hrandfield-withvalues`. + /// + /// HRANDFIELD key [ count ] + /// https://valkey.io/commands/hrandfield/ + @since(version = 0.3.0-dev) + hrandfield: async func(connection: borrow, key: string, count: option) -> result, error-code>; + + /// Returns one or more random fields from a hash. + /// + /// The `WITHVALUES` form, the fields and their values. + /// + /// See also `hrandfield`. + /// + /// HRANDFIELD key count WITHVALUES + /// https://valkey.io/commands/hrandfield/ + @since(version = 0.3.0-dev) + hrandfield-withvalues: async func(connection: borrow, key: string, count: integer) -> result>, error-code>; + + /// Iterates over fields and values of a hash. + /// + /// See also `hscan-novalues`. + /// + /// HSCAN key cursor [ MATCH pattern ] [ COUNT count ] + /// https://valkey.io/commands/hscan/ + @since(version = 0.3.0-dev) + hscan: async func(connection: borrow, key: string, cursor: cursor, match: option, count: option) -> result, map>, error-code>; + + /// Iterates over fields and values of a hash. + /// + /// The `NOVALUES` form, the fields only. + /// + /// See also `hscan`. + /// + /// HSCAN key cursor [ MATCH pattern ] [ COUNT count ] NOVALUES + /// https://valkey.io/commands/hscan/ + @since(version = 0.3.0-dev) + hscan-novalues: async func(connection: borrow, key: string, cursor: cursor, match: option, count: option) -> result, list>, error-code>; + + /// Creates or modifies the value of a field in a hash. + /// + /// HSET key field value [ field value ... ] + /// https://valkey.io/commands/hset/ + @since(version = 0.3.0-dev) + hset: async func(connection: borrow, key: string, field-and-values: list>) -> result; + + /// Sets the value of one or more fields of a given hash key, and optionally sets their expiration time. + /// + /// HSETEX key [ NX | XX ] [ FNX | FXX ] [ EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL ] FIELDS numfields field value [ field value ... ] + /// https://valkey.io/commands/hsetex/ + @since(version = 0.3.0-dev) + hsetex: async func(connection: borrow, key: string, condition: option, field-condition: option, ttl: option, field-and-values: list>) -> result; + + /// Sets the value of a field in a hash only when the field doesn't exist. + /// + /// HSETNX key field value + /// https://valkey.io/commands/hsetnx/ + @since(version = 0.3.0-dev) + hsetnx: async func(connection: borrow, key: string, field: string, value: string-or-bytes) -> result; + + /// Returns the length of the value of a field. + /// + /// HSTRLEN key field + /// https://valkey.io/commands/hstrlen/ + @since(version = 0.3.0-dev) + hstrlen: async func(connection: borrow, key: string, field: string) -> result; + + /// Returns the remaining time to live (in seconds) of a hash key's field(s) that have an associated expiration. + /// + /// HTTL key FIELDS numfields field [ field ... ] + /// https://valkey.io/commands/httl/ + @since(version = 0.3.0-dev) + httl: async func(connection: borrow, key: string, fields: list) -> result, error-code>; + + /// Returns all values in a hash. + /// + /// HVALS key + /// https://valkey.io/commands/hvals/ + @since(version = 0.3.0-dev) + hvals: async func(connection: borrow, key: string) -> result, error-code>; + + /// Adds elements to a HyperLogLog key. + /// + /// PFADD key [ element [ element ... ] ] + /// https://valkey.io/commands/pfadd/ + @since(version = 0.3.0-dev) + pfadd: async func(connection: borrow, key: string, elements: option>) -> result; + + /// Returns the approximated cardinality of the set(s) observed by the HyperLogLog key(s). + /// + /// PFCOUNT key [ key ... ] + /// https://valkey.io/commands/pfcount/ + @since(version = 0.3.0-dev) + pfcount: async func(connection: borrow, keys: list) -> result; + + /// Merges one or more HyperLogLog values into a single key. + /// + /// PFMERGE destkey [ sourcekey [ sourcekey ... ] ] + /// https://valkey.io/commands/pfmerge/ + @since(version = 0.3.0-dev) + pfmerge: async func(connection: borrow, destination: string, sources: option>) -> result<_, error-code>; + + /// Removes all keys from the target slot. + /// + /// CLUSTER FLUSHSLOT slot [ ASYNC | SYNC ] + /// https://valkey.io/commands/cluster-flushslot/ + @since(version = 0.3.0-dev) + cluster-flushslot: async func(connection: borrow, slot: u16, mode: option) -> result<_, error-code>; + + /// Iterates over the keys in the cluster. + /// + /// The cursor carries the slot, for clients to route each call to the node that owns it. + /// + /// CLUSTERSCAN cursor [ MATCH match-pattern ] [ COUNT count ] [ TYPE type ] [ SLOT slot ] + /// https://valkey.io/commands/clusterscan/ + @since(version = 0.3.0-dev) + clusterscan: async func(connection: borrow, cursor: cursor, match: option, count: option, %type: option, slot: option) -> result, list>, error-code>; + + /// Copies the value of a key to a new key. + /// + /// COPY source destination [ DB destination-db ] [ REPLACE ] + /// https://valkey.io/commands/copy/ + @since(version = 0.3.0-dev) + copy: async func(connection: borrow, source: string, destination: string, destination-db: option, replace: bool) -> result; + + /// Returns the number of keys in the database. + /// + /// DBSIZE + /// https://valkey.io/commands/dbsize/ + @since(version = 0.3.0-dev) + dbsize: async func(connection: borrow) -> result; + + /// Deletes one or more keys. + /// + /// DEL key [ key ... ] + /// https://valkey.io/commands/del/ + @since(version = 0.3.0-dev) + del: async func(connection: borrow, keys: list) -> result; + + /// Returns a serialized representation of the value stored at a key. + /// + /// DUMP key + /// https://valkey.io/commands/dump/ + @since(version = 0.3.0-dev) + dump: async func(connection: borrow, key: string) -> result, error-code>; + + /// Determines whether one or more keys exist. + /// + /// EXISTS key [ key ... ] + /// https://valkey.io/commands/exists/ + @since(version = 0.3.0-dev) + exists: async func(connection: borrow, keys: list) -> result; + + /// Sets the expiration time of a key in seconds. + /// + /// EXPIRE key seconds [ NX | XX | GT | LT ] + /// https://valkey.io/commands/expire/ + @since(version = 0.3.0-dev) + expire: async func(connection: borrow, key: string, seconds: duration-seconds, mode: option) -> result; + + /// Sets the expiration time of a key to a Unix timestamp. + /// + /// EXPIREAT key unix-time-seconds [ NX | XX | GT | LT ] + /// https://valkey.io/commands/expireat/ + @since(version = 0.3.0-dev) + expireat: async func(connection: borrow, key: string, seconds: unix-time-seconds, mode: option) -> result; + + /// Returns the expiration time of a key as a Unix timestamp. + /// + /// EXPIRETIME key + /// https://valkey.io/commands/expiretime/ + @since(version = 0.3.0-dev) + expiretime: async func(connection: borrow, key: string) -> result; + + /// Removes all keys from all databases. + /// + /// FLUSHALL [ ASYNC | SYNC ] + /// https://valkey.io/commands/flushall/ + @since(version = 0.3.0-dev) + flushall: async func(connection: borrow, mode: option) -> result<_, error-code>; + + /// Removes all keys from the current database. + /// + /// FLUSHDB [ ASYNC | SYNC ] + /// https://valkey.io/commands/flushdb/ + @since(version = 0.3.0-dev) + flushdb: async func(connection: borrow, mode: option) -> result<_, error-code>; + + /// Returns all key names that match a pattern. + /// + /// KEYS pattern + /// https://valkey.io/commands/keys/ + @since(version = 0.3.0-dev) + keys: async func(connection: borrow, pattern: string) -> result, error-code>; + + /// Atomically transfers a key from one instance to another. + /// + /// False when none of the keys exist, the server's `NOKEY`. + /// + /// MIGRATE host port "" destination-db timeout [ COPY ] [ REPLACE ] [ AUTH password | AUTH2 username password ] KEYS key [ key ... ] + /// https://valkey.io/commands/migrate/ + @since(version = 0.3.0-dev) + migrate: async func(connection: borrow, host: string, port: u16, destination-db: integer, timeout: duration-milliseconds, options: migrate-flags, auth: option, keys: list) -> result; + + /// Moves a key to another database. + /// + /// MOVE key db [ REPLACE ] + /// https://valkey.io/commands/move/ + @since(version = 0.3.0-dev) + move: async func(connection: borrow, key: string, db: integer, replace: bool) -> result; + + /// Returns the internal encoding of an object. + /// + /// OBJECT ENCODING key + /// https://valkey.io/commands/object-encoding/ + @since(version = 0.3.0-dev) + object-encoding: async func(connection: borrow, key: string) -> result, error-code>; + + /// Returns the logarithmic access frequency counter of an object. + /// + /// OBJECT FREQ key + /// https://valkey.io/commands/object-freq/ + @since(version = 0.3.0-dev) + object-freq: async func(connection: borrow, key: string) -> result, error-code>; + + /// Returns the time since the last access to an object. + /// + /// OBJECT IDLETIME key + /// https://valkey.io/commands/object-idletime/ + @since(version = 0.3.0-dev) + object-idletime: async func(connection: borrow, key: string) -> result, error-code>; + + /// Returns the reference count of a value of a key. + /// + /// OBJECT REFCOUNT key + /// https://valkey.io/commands/object-refcount/ + @since(version = 0.3.0-dev) + object-refcount: async func(connection: borrow, key: string) -> result, error-code>; + + /// Removes the expiration time of a key. + /// + /// PERSIST key + /// https://valkey.io/commands/persist/ + @since(version = 0.3.0-dev) + persist: async func(connection: borrow, key: string) -> result; + + /// Sets the expiration time of a key in milliseconds. + /// + /// PEXPIRE key milliseconds [ NX | XX | GT | LT ] + /// https://valkey.io/commands/pexpire/ + @since(version = 0.3.0-dev) + pexpire: async func(connection: borrow, key: string, milliseconds: duration-milliseconds, mode: option) -> result; + + /// Sets the expiration time of a key to a Unix milliseconds timestamp. + /// + /// PEXPIREAT key unix-time-milliseconds [ NX | XX | GT | LT ] + /// https://valkey.io/commands/pexpireat/ + @since(version = 0.3.0-dev) + pexpireat: async func(connection: borrow, key: string, milliseconds: unix-time-milliseconds, mode: option) -> result; + + /// Returns the expiration time of a key as a Unix milliseconds timestamp. + /// + /// PEXPIRETIME key + /// https://valkey.io/commands/pexpiretime/ + @since(version = 0.3.0-dev) + pexpiretime: async func(connection: borrow, key: string) -> result; + + /// Returns the expiration time in milliseconds of a key. + /// + /// PTTL key + /// https://valkey.io/commands/pttl/ + @since(version = 0.3.0-dev) + pttl: async func(connection: borrow, key: string) -> result; + + /// Returns a random key name from the database. + /// + /// RANDOMKEY + /// https://valkey.io/commands/randomkey/ + @since(version = 0.3.0-dev) + randomkey: async func(connection: borrow) -> result, error-code>; + + /// Renames a key and overwrites the destination. + /// + /// RENAME key newkey + /// https://valkey.io/commands/rename/ + @since(version = 0.3.0-dev) + rename: async func(connection: borrow, key: string, newkey: string) -> result<_, error-code>; + + /// Renames a key only when the target key name doesn't exist. + /// + /// RENAMENX key newkey + /// https://valkey.io/commands/renamenx/ + @since(version = 0.3.0-dev) + renamenx: async func(connection: borrow, key: string, newkey: string) -> result; + + /// Creates a key from the serialized representation of a value. + /// + /// A `ttl` of none restores the key without an expiration, `0`, `expire-at` adds `ABSTTL`. + /// + /// RESTORE key ttl serialized-value [ REPLACE ] [ ABSTTL ] [ IDLETIME seconds ] [ FREQ frequency ] + /// https://valkey.io/commands/restore/ + @since(version = 0.3.0-dev) + restore: async func(connection: borrow, key: string, ttl: option, serialized-value: bytes, replace: bool, idletime: option, freq: option) -> result<_, error-code>; + + /// An internal command for migrating keys in a cluster. + /// + /// A `ttl` of none restores the key without an expiration, `0`, `expire-at` adds `ABSTTL`. + /// + /// RESTORE-ASKING key ttl serialized-value [ REPLACE ] [ ABSTTL ] [ IDLETIME seconds ] [ FREQ frequency ] + /// https://valkey.io/commands/restore-asking/ + @since(version = 0.3.0-dev) + restore-asking: async func(connection: borrow, key: string, ttl: option, serialized-value: bytes, replace: bool, idletime: option, freq: option) -> result<_, error-code>; + + /// Iterates over the key names in the database. + /// + /// SCAN cursor [ MATCH pattern ] [ COUNT count ] [ TYPE type ] + /// https://valkey.io/commands/scan/ + @since(version = 0.3.0-dev) + scan: async func(connection: borrow, cursor: cursor, match: option, count: option, %type: option) -> result, list>, error-code>; + + /// Swaps two databases. + /// + /// SWAPDB index1 index2 + /// https://valkey.io/commands/swapdb/ + @since(version = 0.3.0-dev) + swapdb: async func(connection: borrow, index1: integer, index2: integer) -> result<_, error-code>; + + /// Returns the number of existing keys out of those specified after updating the time they were last accessed. + /// + /// TOUCH key [ key ... ] + /// https://valkey.io/commands/touch/ + @since(version = 0.3.0-dev) + touch: async func(connection: borrow, keys: list) -> result; + + /// Returns the expiration time in seconds of a key. + /// + /// TTL key + /// https://valkey.io/commands/ttl/ + @since(version = 0.3.0-dev) + ttl: async func(connection: borrow, key: string) -> result; + + /// Determines the type of value stored at a key. + /// + /// None when the key does not exist, the server's `none`. + /// + /// TYPE key + /// https://valkey.io/commands/type/ + @since(version = 0.3.0-dev) + %type: async func(connection: borrow, key: string) -> result, error-code>; + + /// Asynchronously deletes one or more keys. + /// + /// UNLINK key [ key ... ] + /// https://valkey.io/commands/unlink/ + @since(version = 0.3.0-dev) + unlink: async func(connection: borrow, keys: list) -> result; + + /// Pops an element from a list, pushes it to another list and returns it. + /// + /// Returns none when the timeout passed. The connection waits too, replies to commands sent + /// after it on the connection wait behind it. Use a connection of your own. + /// + /// BLMOVE source destination 〈 LEFT | RIGHT 〉 〈 LEFT | RIGHT 〉 timeout + /// https://valkey.io/commands/blmove/ + @since(version = 0.3.0-dev) + blmove: async func(connection: borrow, source: string, destination: string, wherefrom: list-side, whereto: list-side, timeout: duration-milliseconds) -> result, error-code>; + + /// Pops the first element from one of multiple lists. + /// + /// Returns none when the timeout passed. The connection waits too, replies to commands sent + /// after it on the connection wait behind it. Use a connection of your own. + /// + /// BLMPOP timeout numkeys key [ key ... ] 〈 LEFT | RIGHT 〉 [ COUNT count ] + /// https://valkey.io/commands/blmpop/ + @since(version = 0.3.0-dev) + blmpop: async func(connection: borrow, timeout: duration-milliseconds, keys: list, end: list-side, count: option) -> result>>, error-code>; + + /// Removes and returns the first element in a list. + /// + /// Returns none when the timeout passed. The connection waits too, replies to commands sent + /// after it on the connection wait behind it. Use a connection of your own. + /// + /// BLPOP key [ key ... ] timeout + /// https://valkey.io/commands/blpop/ + @since(version = 0.3.0-dev) + blpop: async func(connection: borrow, keys: list, timeout: duration-milliseconds) -> result>, error-code>; + + /// Removes and returns the last element in a list. + /// + /// Returns none when the timeout passed. The connection waits too, replies to commands sent + /// after it on the connection wait behind it. Use a connection of your own. + /// + /// BRPOP key [ key ... ] timeout + /// https://valkey.io/commands/brpop/ + @since(version = 0.3.0-dev) + brpop: async func(connection: borrow, keys: list, timeout: duration-milliseconds) -> result>, error-code>; + + /// Pops an element from a list, pushes it to another list and returns it. + /// + /// Returns none when the timeout passed. The connection waits too, replies to commands sent + /// after it on the connection wait behind it. Use a connection of your own. + /// + /// BRPOPLPUSH source destination timeout + /// https://valkey.io/commands/brpoplpush/ + @since(version = 0.3.0-dev) + brpoplpush: async func(connection: borrow, source: string, destination: string, timeout: duration-milliseconds) -> result, error-code>; + + /// Returns an element from a list by its index. + /// + /// LINDEX key index + /// https://valkey.io/commands/lindex/ + @since(version = 0.3.0-dev) + lindex: async func(connection: borrow, key: string, index: integer) -> result, error-code>; + + /// Inserts an element before or after another element in a list. + /// + /// LINSERT key 〈 BEFORE | AFTER 〉 pivot element + /// https://valkey.io/commands/linsert/ + @since(version = 0.3.0-dev) + linsert: async func(connection: borrow, key: string, position: linsert-position, pivot: string-or-bytes, element: string-or-bytes) -> result; + + /// Returns the length of a list. + /// + /// LLEN key + /// https://valkey.io/commands/llen/ + @since(version = 0.3.0-dev) + llen: async func(connection: borrow, key: string) -> result; + + /// Returns an element after popping it from one list and pushing it to another. + /// + /// LMOVE source destination 〈 LEFT | RIGHT 〉 〈 LEFT | RIGHT 〉 + /// https://valkey.io/commands/lmove/ + @since(version = 0.3.0-dev) + lmove: async func(connection: borrow, source: string, destination: string, wherefrom: list-side, whereto: list-side) -> result, error-code>; + + /// Returns multiple elements from a list after removing them. + /// + /// LMPOP numkeys key [ key ... ] 〈 LEFT | RIGHT 〉 [ COUNT count ] + /// https://valkey.io/commands/lmpop/ + @since(version = 0.3.0-dev) + lmpop: async func(connection: borrow, keys: list, end: list-side, count: option) -> result>>, error-code>; + + /// Returns and removes one or more elements from the beginning of a list. + /// + /// Without a count, at most one element. + /// + /// LPOP key [ count ] + /// https://valkey.io/commands/lpop/ + @since(version = 0.3.0-dev) + lpop: async func(connection: borrow, key: string, count: option) -> result, error-code>; + + /// Returns the index of matching elements in a list. + /// + /// Without a count, at most one position. + /// + /// LPOS key element [ RANK rank ] [ COUNT num-matches ] [ MAXLEN len ] + /// https://valkey.io/commands/lpos/ + @since(version = 0.3.0-dev) + lpos: async func(connection: borrow, key: string, element: string-or-bytes, rank: option, count: option, maxlen: option) -> result, error-code>; + + /// Prepends one or more elements to a list. + /// + /// LPUSH key element [ element ... ] + /// https://valkey.io/commands/lpush/ + @since(version = 0.3.0-dev) + lpush: async func(connection: borrow, key: string, elements: list) -> result; + + /// Prepends one or more elements to a list only when the list exists. + /// + /// LPUSHX key element [ element ... ] + /// https://valkey.io/commands/lpushx/ + @since(version = 0.3.0-dev) + lpushx: async func(connection: borrow, key: string, elements: list) -> result; + + /// Returns a range of elements from a list. + /// + /// LRANGE key start stop + /// https://valkey.io/commands/lrange/ + @since(version = 0.3.0-dev) + lrange: async func(connection: borrow, key: string, start: integer, stop: integer) -> result, error-code>; + + /// Removes elements from a list. + /// + /// LREM key count element + /// https://valkey.io/commands/lrem/ + @since(version = 0.3.0-dev) + lrem: async func(connection: borrow, key: string, count: integer, element: string-or-bytes) -> result; + + /// Sets the value of an element in a list by its index. + /// + /// LSET key index element + /// https://valkey.io/commands/lset/ + @since(version = 0.3.0-dev) + lset: async func(connection: borrow, key: string, index: integer, element: string-or-bytes) -> result<_, error-code>; + + /// Removes elements from both ends of a list. + /// + /// LTRIM key start stop + /// https://valkey.io/commands/ltrim/ + @since(version = 0.3.0-dev) + ltrim: async func(connection: borrow, key: string, start: integer, stop: integer) -> result<_, error-code>; + + /// Returns and removes one or more elements from the end of a list. + /// + /// Without a count, at most one element. + /// + /// RPOP key [ count ] + /// https://valkey.io/commands/rpop/ + @since(version = 0.3.0-dev) + rpop: async func(connection: borrow, key: string, count: option) -> result, error-code>; + + /// Returns the last element of a list after removing and pushing it to another list. + /// + /// RPOPLPUSH source destination + /// https://valkey.io/commands/rpoplpush/ + @since(version = 0.3.0-dev) + rpoplpush: async func(connection: borrow, source: string, destination: string) -> result, error-code>; + + /// Appends one or more elements to a list. + /// + /// RPUSH key element [ element ... ] + /// https://valkey.io/commands/rpush/ + @since(version = 0.3.0-dev) + rpush: async func(connection: borrow, key: string, elements: list) -> result; + + /// Appends one or more elements to a list only when the list exists. + /// + /// RPUSHX key element [ element ... ] + /// https://valkey.io/commands/rpushx/ + @since(version = 0.3.0-dev) + rpushx: async func(connection: borrow, key: string, elements: list) -> result; + + /// Posts a message to a channel. + /// + /// PUBLISH channel message + /// https://valkey.io/commands/publish/ + @since(version = 0.3.0-dev) + publish: async func(connection: borrow, channel: string, message: string-or-bytes) -> result; + + /// Returns the active channels. + /// + /// PUBSUB CHANNELS [ pattern ] + /// https://valkey.io/commands/pubsub-channels/ + @since(version = 0.3.0-dev) + pubsub-channels: async func(connection: borrow, pattern: option) -> result, error-code>; + + /// Returns a count of unique pattern subscriptions. + /// + /// PUBSUB NUMPAT + /// https://valkey.io/commands/pubsub-numpat/ + @since(version = 0.3.0-dev) + pubsub-numpat: async func(connection: borrow) -> result; + + /// Returns a count of subscribers to channels. + /// + /// PUBSUB NUMSUB [ channel ] [ [ channel ] ... ] + /// https://valkey.io/commands/pubsub-numsub/ + @since(version = 0.3.0-dev) + pubsub-numsub: async func(connection: borrow, channels: option>) -> result, error-code>; + + /// Returns the active shard channels. + /// + /// PUBSUB SHARDCHANNELS [ pattern ] + /// https://valkey.io/commands/pubsub-shardchannels/ + @since(version = 0.3.0-dev) + pubsub-shardchannels: async func(connection: borrow, pattern: option) -> result, error-code>; + + /// Returns the count of subscribers of shard channels. + /// + /// PUBSUB SHARDNUMSUB [ shardchannel [ shardchannel ... ] ] + /// https://valkey.io/commands/pubsub-shardnumsub/ + @since(version = 0.3.0-dev) + pubsub-shardnumsub: async func(connection: borrow, shardchannels: option>) -> result, error-code>; + + /// Posts a message to a shard channel. + /// + /// SPUBLISH shardchannel message + /// https://valkey.io/commands/spublish/ + @since(version = 0.3.0-dev) + spublish: async func(connection: borrow, shardchannel: string, message: string-or-bytes) -> result; + + /// Executes a server-side Lua script. + /// + /// An error the script returns fails the call. + /// + /// EVAL script numkeys [ key [ key ... ] ] [ arg [ arg ... ] ] + /// https://valkey.io/commands/eval/ + @since(version = 0.3.0-dev) + eval: async func(connection: borrow, script: string, keys: option>, args: option>) -> result; + + /// Executes a read-only server-side Lua script. + /// + /// An error the script returns fails the call. + /// + /// EVAL_RO script numkeys [ key [ key ... ] ] [ arg [ arg ... ] ] + /// https://valkey.io/commands/eval_ro/ + @since(version = 0.3.0-dev) + eval-ro: async func(connection: borrow, script: string, keys: option>, args: option>) -> result; + + /// Executes a server-side Lua script by SHA1 digest. + /// + /// An error the script returns fails the call. + /// + /// EVALSHA sha1 numkeys [ key [ key ... ] ] [ arg [ arg ... ] ] + /// https://valkey.io/commands/evalsha/ + @since(version = 0.3.0-dev) + evalsha: async func(connection: borrow, sha1: string, keys: option>, args: option>) -> result; + + /// Executes a read-only server-side Lua script by SHA1 digest. + /// + /// An error the script returns fails the call. + /// + /// EVALSHA_RO sha1 numkeys [ key [ key ... ] ] [ arg [ arg ... ] ] + /// https://valkey.io/commands/evalsha_ro/ + @since(version = 0.3.0-dev) + evalsha-ro: async func(connection: borrow, sha1: string, keys: option>, args: option>) -> result; + + /// Invokes a function. + /// + /// An error the function returns fails the call. + /// + /// FCALL function numkeys [ key [ key ... ] ] [ arg [ arg ... ] ] + /// https://valkey.io/commands/fcall/ + @since(version = 0.3.0-dev) + fcall: async func(connection: borrow, function: string, keys: option>, args: option>) -> result; + + /// Invokes a read-only function. + /// + /// An error the function returns fails the call. + /// + /// FCALL_RO function numkeys [ key [ key ... ] ] [ arg [ arg ... ] ] + /// https://valkey.io/commands/fcall_ro/ + @since(version = 0.3.0-dev) + fcall-ro: async func(connection: borrow, function: string, keys: option>, args: option>) -> result; + + /// Deletes a library and its functions. + /// + /// FUNCTION DELETE library-name + /// https://valkey.io/commands/function-delete/ + @since(version = 0.3.0-dev) + function-delete: async func(connection: borrow, library-name: string) -> result<_, error-code>; + + /// Dumps all libraries into a serialized binary payload. + /// + /// FUNCTION DUMP + /// https://valkey.io/commands/function-dump/ + @since(version = 0.3.0-dev) + function-dump: async func(connection: borrow) -> result; + + /// Deletes all libraries and functions. + /// + /// FUNCTION FLUSH [ ASYNC | SYNC ] + /// https://valkey.io/commands/function-flush/ + @since(version = 0.3.0-dev) + function-flush: async func(connection: borrow, mode: option) -> result<_, error-code>; + + /// Terminates a function during execution. + /// + /// FUNCTION KILL + /// https://valkey.io/commands/function-kill/ + @since(version = 0.3.0-dev) + function-kill: async func(connection: borrow) -> result<_, error-code>; + + /// Returns information about all libraries. + /// + /// FUNCTION LIST [ LIBRARYNAME library-name-pattern ] [ WITHCODE ] + /// https://valkey.io/commands/function-list/ + @since(version = 0.3.0-dev) + function-list: async func(connection: borrow, library-name-pattern: option, withcode: bool) -> result, error-code>; + + /// Creates a library. + /// + /// FUNCTION LOAD [ REPLACE ] function-code + /// https://valkey.io/commands/function-load/ + @since(version = 0.3.0-dev) + function-load: async func(connection: borrow, replace: bool, function-code: string) -> result; + + /// Restores all libraries from a payload. + /// + /// FUNCTION RESTORE serialized-value [ FLUSH | APPEND | REPLACE ] + /// https://valkey.io/commands/function-restore/ + @since(version = 0.3.0-dev) + function-restore: async func(connection: borrow, serialized-value: bytes, policy: option) -> result<_, error-code>; + + /// Returns information about a function during execution. + /// + /// FUNCTION STATS + /// https://valkey.io/commands/function-stats/ + @since(version = 0.3.0-dev) + function-stats: async func(connection: borrow) -> result; + + /// Determines whether server-side Lua scripts exist in the script cache. + /// + /// SCRIPT EXISTS sha1 [ sha1 ... ] + /// https://valkey.io/commands/script-exists/ + @since(version = 0.3.0-dev) + script-exists: async func(connection: borrow, sha1s: list) -> result, error-code>; + + /// Removes all server-side Lua scripts from the script cache. + /// + /// SCRIPT FLUSH [ ASYNC | SYNC ] + /// https://valkey.io/commands/script-flush/ + @since(version = 0.3.0-dev) + script-flush: async func(connection: borrow, mode: option) -> result<_, error-code>; + + /// Terminates a server-side Lua script during execution. + /// + /// SCRIPT KILL + /// https://valkey.io/commands/script-kill/ + @since(version = 0.3.0-dev) + script-kill: async func(connection: borrow) -> result<_, error-code>; + + /// Loads a server-side Lua script to the script cache. + /// + /// SCRIPT LOAD script + /// https://valkey.io/commands/script-load/ + @since(version = 0.3.0-dev) + script-load: async func(connection: borrow, script: string) -> result; + + /// Show server-side Lua script in the script cache. + /// + /// SCRIPT SHOW sha1 + /// https://valkey.io/commands/script-show/ + @since(version = 0.3.0-dev) + script-show: async func(connection: borrow, sha1: string) -> result; + + /// Adds one or more members to a set. + /// + /// SADD key member [ member ... ] + /// https://valkey.io/commands/sadd/ + @since(version = 0.3.0-dev) + sadd: async func(connection: borrow, key: string, members: list) -> result; + + /// Returns the number of members in a set. + /// + /// SCARD key + /// https://valkey.io/commands/scard/ + @since(version = 0.3.0-dev) + scard: async func(connection: borrow, key: string) -> result; + + /// Returns the difference of multiple sets. + /// + /// SDIFF key [ key ... ] + /// https://valkey.io/commands/sdiff/ + @since(version = 0.3.0-dev) + sdiff: async func(connection: borrow, keys: list) -> result, error-code>; + + /// Stores the difference of multiple sets in a key. + /// + /// SDIFFSTORE destination key [ key ... ] + /// https://valkey.io/commands/sdiffstore/ + @since(version = 0.3.0-dev) + sdiffstore: async func(connection: borrow, destination: string, keys: list) -> result; + + /// Returns the intersect of multiple sets. + /// + /// SINTER key [ key ... ] + /// https://valkey.io/commands/sinter/ + @since(version = 0.3.0-dev) + sinter: async func(connection: borrow, keys: list) -> result, error-code>; + + /// Returns the number of members of the intersect of multiple sets. + /// + /// SINTERCARD numkeys key [ key ... ] [ LIMIT limit ] + /// https://valkey.io/commands/sintercard/ + @since(version = 0.3.0-dev) + sintercard: async func(connection: borrow, keys: list, limit: option) -> result; + + /// Stores the intersect of multiple sets in a key. + /// + /// SINTERSTORE destination key [ key ... ] + /// https://valkey.io/commands/sinterstore/ + @since(version = 0.3.0-dev) + sinterstore: async func(connection: borrow, destination: string, keys: list) -> result; + + /// Determines whether a member belongs to a set. + /// + /// False when the key does not exist. + /// + /// See also `sismember-xx`. + /// + /// SISMEMBER key member + /// https://valkey.io/commands/sismember/ + @since(version = 0.3.0-dev) + sismember: async func(connection: borrow, key: string, member: string-or-bytes) -> result; + + /// Determines whether a member belongs to a set. + /// + /// The `XX` form, none when the key does not exist, rather than false. + /// + /// See also `sismember`. + /// + /// SISMEMBER key member XX + /// https://valkey.io/commands/sismember/ + @since(version = 0.3.0-dev) + sismember-xx: async func(connection: borrow, key: string, member: string-or-bytes) -> result, error-code>; + + /// Returns all members of a set. + /// + /// SMEMBERS key + /// https://valkey.io/commands/smembers/ + @since(version = 0.3.0-dev) + smembers: async func(connection: borrow, key: string) -> result, error-code>; + + /// Determines whether multiple members belong to a set. + /// + /// SMISMEMBER key member [ member ... ] + /// https://valkey.io/commands/smismember/ + @since(version = 0.3.0-dev) + smismember: async func(connection: borrow, key: string, members: list) -> result, error-code>; + + /// Moves a member from one set to another. + /// + /// SMOVE source destination member + /// https://valkey.io/commands/smove/ + @since(version = 0.3.0-dev) + smove: async func(connection: borrow, source: string, destination: string, member: string-or-bytes) -> result; + + /// Returns one or more random members from a set after removing them. + /// + /// Without a count, at most one member. + /// + /// SPOP key [ count ] + /// https://valkey.io/commands/spop/ + @since(version = 0.3.0-dev) + spop: async func(connection: borrow, key: string, count: option) -> result, error-code>; + + /// Gets one or multiple random members from a set. + /// + /// Without a count, at most one member. + /// + /// SRANDMEMBER key [ count ] + /// https://valkey.io/commands/srandmember/ + @since(version = 0.3.0-dev) + srandmember: async func(connection: borrow, key: string, count: option) -> result, error-code>; + + /// Removes one or more members from a set. + /// + /// SREM key member [ member ... ] + /// https://valkey.io/commands/srem/ + @since(version = 0.3.0-dev) + srem: async func(connection: borrow, key: string, members: list) -> result; + + /// Iterates over members of a set. + /// + /// SSCAN key cursor [ MATCH pattern ] [ COUNT count ] + /// https://valkey.io/commands/sscan/ + @since(version = 0.3.0-dev) + sscan: async func(connection: borrow, key: string, cursor: cursor, match: option, count: option) -> result, list>, error-code>; + + /// Returns the union of multiple sets. + /// + /// SUNION key [ key ... ] + /// https://valkey.io/commands/sunion/ + @since(version = 0.3.0-dev) + sunion: async func(connection: borrow, keys: list) -> result, error-code>; + + /// Stores the union of multiple sets in a key. + /// + /// SUNIONSTORE destination key [ key ... ] + /// https://valkey.io/commands/sunionstore/ + @since(version = 0.3.0-dev) + sunionstore: async func(connection: borrow, destination: string, keys: list) -> result; + + /// Sorts the elements in a list, a set, or a sorted set, optionally storing the result. + /// + /// Each row is the element, or with `get`, the value of each pattern, none when its key does + /// not exist. + /// + /// See also `sort-store`. + /// + /// SORT key [ BY pattern ] [ LIMIT offset count ] [ GET pattern ... ] [ ASC | DESC ] [ ALPHA ] + /// https://valkey.io/commands/sort/ + @since(version = 0.3.0-dev) + sort: async func(connection: borrow, key: string, opts: option) -> result>>, error-code>; + + /// Sorts the elements in a list, a set, or a sorted set, optionally storing the result. + /// + /// The `STORE` form, storing the elements as a list, returning how many. + /// + /// See also `sort`. + /// + /// SORT key [ BY pattern ] [ LIMIT offset count ] [ GET pattern ... ] [ ASC | DESC ] [ ALPHA ] STORE destination + /// https://valkey.io/commands/sort/ + @since(version = 0.3.0-dev) + sort-store: async func(connection: borrow, key: string, opts: option, destination: string) -> result; + + /// Returns the sorted elements of a list, a set, or a sorted set. + /// + /// Each row is the element, or with `get`, the value of each pattern, none when its key does + /// not exist. + /// + /// SORT_RO key [ BY pattern ] [ LIMIT offset count ] [ GET pattern ... ] [ ASC | DESC ] [ ALPHA ] + /// https://valkey.io/commands/sort_ro/ + @since(version = 0.3.0-dev) + sort-ro: async func(connection: borrow, key: string, opts: option) -> result>>, error-code>; + + /// Removes and returns a member by score from one or more sorted sets. + /// + /// Returns none when the timeout passed. The connection waits too, replies to commands sent + /// after it on the connection wait behind it. Use a connection of your own. + /// + /// BZMPOP timeout numkeys key [ key ... ] 〈 MIN | MAX 〉 [ COUNT count ] + /// https://valkey.io/commands/bzmpop/ + @since(version = 0.3.0-dev) + bzmpop: async func(connection: borrow, timeout: duration-milliseconds, keys: list, end: bzmpop-end, count: option) -> result>>>, error-code>; + + /// Removes and returns the member with the highest score from one or more sorted sets. + /// + /// Returns none when the timeout passed. The connection waits too, replies to commands sent + /// after it on the connection wait behind it. Use a connection of your own. + /// + /// BZPOPMAX key [ key ... ] timeout + /// https://valkey.io/commands/bzpopmax/ + @since(version = 0.3.0-dev) + bzpopmax: async func(connection: borrow, keys: list, timeout: duration-milliseconds) -> result>, error-code>; + + /// Removes and returns the member with the lowest score from one or more sorted sets. + /// + /// Returns none when the timeout passed. The connection waits too, replies to commands sent + /// after it on the connection wait behind it. Use a connection of your own. + /// + /// BZPOPMIN key [ key ... ] timeout + /// https://valkey.io/commands/bzpopmin/ + @since(version = 0.3.0-dev) + bzpopmin: async func(connection: borrow, keys: list, timeout: duration-milliseconds) -> result>, error-code>; + + /// Adds one or more members to a sorted set, or updates their scores. + /// + /// See also `zadd-incr`. + /// + /// ZADD key [ NX | XX ] [ GT | LT ] [ CH ] score member [ score member ... ] + /// https://valkey.io/commands/zadd/ + @since(version = 0.3.0-dev) + zadd: async func(connection: borrow, key: string, condition: option, comparison: option, changed: bool, score-and-members: list>) -> result, error-code>; + + /// Adds one or more members to a sorted set, or updates their scores. + /// + /// The `INCR` form, returning the new score, none when the condition or comparison prevented + /// it. + /// + /// See also `zadd`. + /// + /// ZADD key [ NX | XX ] [ GT | LT ] INCR score member + /// https://valkey.io/commands/zadd/ + @since(version = 0.3.0-dev) + zadd-incr: async func(connection: borrow, key: string, condition: option, comparison: option, increment: double, member: string-or-bytes) -> result, error-code>; + + /// Returns the number of members in a sorted set. + /// + /// ZCARD key + /// https://valkey.io/commands/zcard/ + @since(version = 0.3.0-dev) + zcard: async func(connection: borrow, key: string) -> result; + + /// Returns the count of members in a sorted set that have scores within a range. + /// + /// ZCOUNT key min max + /// https://valkey.io/commands/zcount/ + @since(version = 0.3.0-dev) + zcount: async func(connection: borrow, key: string, min: score-bound, max: score-bound) -> result; + + /// Returns the difference between multiple sorted sets. + /// + /// See also `zdiff-withscores`. + /// + /// ZDIFF numkeys key [ key ... ] + /// https://valkey.io/commands/zdiff/ + @since(version = 0.3.0-dev) + zdiff: async func(connection: borrow, keys: list) -> result, error-code>; + + /// Returns the difference between multiple sorted sets. + /// + /// The `WITHSCORES` form, the members and their scores. + /// + /// See also `zdiff`. + /// + /// ZDIFF numkeys key [ key ... ] WITHSCORES + /// https://valkey.io/commands/zdiff/ + @since(version = 0.3.0-dev) + zdiff-withscores: async func(connection: borrow, keys: list) -> result>, error-code>; + + /// Stores the difference of multiple sorted sets in a key. + /// + /// ZDIFFSTORE destination numkeys key [ key ... ] + /// https://valkey.io/commands/zdiffstore/ + @since(version = 0.3.0-dev) + zdiffstore: async func(connection: borrow, destination: string, keys: list) -> result; + + /// Increments the score of a member in a sorted set. + /// + /// ZINCRBY key increment member + /// https://valkey.io/commands/zincrby/ + @since(version = 0.3.0-dev) + zincrby: async func(connection: borrow, key: string, increment: double, member: string-or-bytes) -> result; + + /// Returns the intersect of multiple sorted sets. + /// + /// See also `zinter-withscores`. + /// + /// ZINTER numkeys key [ key ... ] [ WEIGHTS weight [ weight ... ] ] [ AGGREGATE SUM | MIN | MAX ] + /// https://valkey.io/commands/zinter/ + @since(version = 0.3.0-dev) + zinter: async func(connection: borrow, keys: list, weights: option>, aggregate: option) -> result, error-code>; + + /// Returns the intersect of multiple sorted sets. + /// + /// The `WITHSCORES` form, the members and their combined scores. + /// + /// See also `zinter`. + /// + /// ZINTER numkeys key [ key ... ] [ WEIGHTS weight [ weight ... ] ] [ AGGREGATE SUM | MIN | MAX ] WITHSCORES + /// https://valkey.io/commands/zinter/ + @since(version = 0.3.0-dev) + zinter-withscores: async func(connection: borrow, keys: list, weights: option>, aggregate: option) -> result>, error-code>; + + /// Returns the number of members of the intersect of multiple sorted sets. + /// + /// ZINTERCARD numkeys key [ key ... ] [ LIMIT limit ] + /// https://valkey.io/commands/zintercard/ + @since(version = 0.3.0-dev) + zintercard: async func(connection: borrow, keys: list, limit: option) -> result; + + /// Stores the intersect of multiple sorted sets in a key. + /// + /// ZINTERSTORE destination numkeys key [ key ... ] [ WEIGHTS weight [ weight ... ] ] [ AGGREGATE SUM | MIN | MAX ] + /// https://valkey.io/commands/zinterstore/ + @since(version = 0.3.0-dev) + zinterstore: async func(connection: borrow, destination: string, keys: list, weights: option>, aggregate: option) -> result; + + /// Returns the number of members in a sorted set within a lexicographical range. + /// + /// ZLEXCOUNT key min max + /// https://valkey.io/commands/zlexcount/ + @since(version = 0.3.0-dev) + zlexcount: async func(connection: borrow, key: string, min: lex-bound, max: lex-bound) -> result; + + /// Returns the highest- or lowest-scoring members from one or more sorted sets after removing them. + /// + /// ZMPOP numkeys key [ key ... ] 〈 MIN | MAX 〉 [ COUNT count ] + /// https://valkey.io/commands/zmpop/ + @since(version = 0.3.0-dev) + zmpop: async func(connection: borrow, keys: list, end: zmpop-end, count: option) -> result>>>, error-code>; + + /// Returns the score of one or more members in a sorted set. + /// + /// ZMSCORE key member [ member ... ] + /// https://valkey.io/commands/zmscore/ + @since(version = 0.3.0-dev) + zmscore: async func(connection: borrow, key: string, members: list) -> result>, error-code>; + + /// Returns the highest-scoring members from a sorted set after removing them. + /// + /// Without a count, at most one member. + /// + /// ZPOPMAX key [ count ] + /// https://valkey.io/commands/zpopmax/ + @since(version = 0.3.0-dev) + zpopmax: async func(connection: borrow, key: string, count: option) -> result>, error-code>; + + /// Returns the lowest-scoring members from a sorted set after removing them. + /// + /// Without a count, at most one member. + /// + /// ZPOPMIN key [ count ] + /// https://valkey.io/commands/zpopmin/ + @since(version = 0.3.0-dev) + zpopmin: async func(connection: borrow, key: string, count: option) -> result>, error-code>; + + /// Returns one or more random members from a sorted set. + /// + /// Without a count, at most one member. + /// + /// See also `zrandmember-withscores`. + /// + /// ZRANDMEMBER key [ count ] + /// https://valkey.io/commands/zrandmember/ + @since(version = 0.3.0-dev) + zrandmember: async func(connection: borrow, key: string, count: option) -> result, error-code>; + + /// Returns one or more random members from a sorted set. + /// + /// The `WITHSCORES` form, the members and their scores. + /// + /// See also `zrandmember`. + /// + /// ZRANDMEMBER key count WITHSCORES + /// https://valkey.io/commands/zrandmember/ + @since(version = 0.3.0-dev) + zrandmember-withscores: async func(connection: borrow, key: string, count: integer) -> result>, error-code>; + + /// Returns members in a sorted set within a range of indexes. + /// + /// Also covers ZRANGEBYLEX, ZRANGEBYSCORE, ZREVRANGE, ZREVRANGEBYLEX and ZREVRANGEBYSCORE. A + /// limit fails the command unless the range is by score or by lex. With `xx`, returns none when + /// the key does not exist. + /// + /// See also `zrange-withscores`. + /// + /// ZRANGE key start stop [ BYSCORE | BYLEX ] [ REV ] [ LIMIT offset count ] [ XX ] + /// https://valkey.io/commands/zrange/ + @since(version = 0.3.0-dev) + zrange: async func(connection: borrow, key: string, range: zrange-by, rev: bool, limit: option, xx: bool) -> result>, error-code>; + + /// Returns members in a sorted set within a range of indexes. + /// + /// The `WITHSCORES` form, the members and their scores. A limit fails the command unless the + /// range is by score. With `xx`, returns none when the key does not exist. + /// + /// See also `zrange`. + /// + /// ZRANGE key start stop [ BYSCORE ] [ REV ] [ LIMIT offset count ] WITHSCORES [ XX ] + /// https://valkey.io/commands/zrange/ + @since(version = 0.3.0-dev) + zrange-withscores: async func(connection: borrow, key: string, range: zrange-withscores-by, rev: bool, limit: option, xx: bool) -> result>>, error-code>; + + /// Stores a range of members from sorted set in a key. + /// + /// A limit fails the command unless the range is by score or by lex. + /// + /// ZRANGESTORE dst src min max [ BYSCORE | BYLEX ] [ REV ] [ LIMIT offset count ] + /// https://valkey.io/commands/zrangestore/ + @since(version = 0.3.0-dev) + zrangestore: async func(connection: borrow, destination: string, source: string, range: zrangestore-by, rev: bool, limit: option) -> result; + + /// Returns the index of a member in a sorted set ordered by ascending scores. + /// + /// See also `zrank-withscore`. + /// + /// ZRANK key member + /// https://valkey.io/commands/zrank/ + @since(version = 0.3.0-dev) + zrank: async func(connection: borrow, key: string, member: string-or-bytes) -> result, error-code>; + + /// Returns the index of a member in a sorted set ordered by ascending scores. + /// + /// The `WITHSCORE` form, the position and the score. + /// + /// See also `zrank`. + /// + /// ZRANK key member WITHSCORE + /// https://valkey.io/commands/zrank/ + @since(version = 0.3.0-dev) + zrank-withscore: async func(connection: borrow, key: string, member: string-or-bytes) -> result>, error-code>; + + /// Removes one or more members from a sorted set. + /// + /// ZREM key member [ member ... ] + /// https://valkey.io/commands/zrem/ + @since(version = 0.3.0-dev) + zrem: async func(connection: borrow, key: string, members: list) -> result; + + /// Removes members in a sorted set within a lexicographical range. + /// + /// ZREMRANGEBYLEX key min max + /// https://valkey.io/commands/zremrangebylex/ + @since(version = 0.3.0-dev) + zremrangebylex: async func(connection: borrow, key: string, min: lex-bound, max: lex-bound) -> result; + + /// Removes members in a sorted set within a range of indexes. + /// + /// ZREMRANGEBYRANK key start stop + /// https://valkey.io/commands/zremrangebyrank/ + @since(version = 0.3.0-dev) + zremrangebyrank: async func(connection: borrow, key: string, start: integer, stop: integer) -> result; + + /// Removes members in a sorted set within a range of scores. + /// + /// ZREMRANGEBYSCORE key min max + /// https://valkey.io/commands/zremrangebyscore/ + @since(version = 0.3.0-dev) + zremrangebyscore: async func(connection: borrow, key: string, min: score-bound, max: score-bound) -> result; + + /// Returns the index of a member in a sorted set ordered by descending scores. + /// + /// See also `zrevrank-withscore`. + /// + /// ZREVRANK key member + /// https://valkey.io/commands/zrevrank/ + @since(version = 0.3.0-dev) + zrevrank: async func(connection: borrow, key: string, member: string-or-bytes) -> result, error-code>; + + /// Returns the index of a member in a sorted set ordered by descending scores. + /// + /// The `WITHSCORE` form, the position and the score. + /// + /// See also `zrevrank`. + /// + /// ZREVRANK key member WITHSCORE + /// https://valkey.io/commands/zrevrank/ + @since(version = 0.3.0-dev) + zrevrank-withscore: async func(connection: borrow, key: string, member: string-or-bytes) -> result>, error-code>; + + /// Iterates over members and scores of a sorted set. + /// + /// See also `zscan-noscores`. + /// + /// ZSCAN key cursor [ MATCH pattern ] [ COUNT count ] + /// https://valkey.io/commands/zscan/ + @since(version = 0.3.0-dev) + zscan: async func(connection: borrow, key: string, cursor: cursor, match: option, count: option) -> result, list>>, error-code>; + + /// Iterates over members and scores of a sorted set. + /// + /// The `NOSCORES` form, the members only. + /// + /// See also `zscan`. + /// + /// ZSCAN key cursor [ MATCH pattern ] [ COUNT count ] NOSCORES + /// https://valkey.io/commands/zscan/ + @since(version = 0.3.0-dev) + zscan-noscores: async func(connection: borrow, key: string, cursor: cursor, match: option, count: option) -> result, list>, error-code>; + + /// Returns the score of a member in a sorted set. + /// + /// ZSCORE key member + /// https://valkey.io/commands/zscore/ + @since(version = 0.3.0-dev) + zscore: async func(connection: borrow, key: string, member: string-or-bytes) -> result, error-code>; + + /// Returns the union of multiple sorted sets. + /// + /// See also `zunion-withscores`. + /// + /// ZUNION numkeys key [ key ... ] [ WEIGHTS weight [ weight ... ] ] [ AGGREGATE SUM | MIN | MAX ] + /// https://valkey.io/commands/zunion/ + @since(version = 0.3.0-dev) + zunion: async func(connection: borrow, keys: list, weights: option>, aggregate: option) -> result, error-code>; + + /// Returns the union of multiple sorted sets. + /// + /// The `WITHSCORES` form, the members and their combined scores. + /// + /// See also `zunion`. + /// + /// ZUNION numkeys key [ key ... ] [ WEIGHTS weight [ weight ... ] ] [ AGGREGATE SUM | MIN | MAX ] WITHSCORES + /// https://valkey.io/commands/zunion/ + @since(version = 0.3.0-dev) + zunion-withscores: async func(connection: borrow, keys: list, weights: option>, aggregate: option) -> result>, error-code>; + + /// Stores the union of multiple sorted sets in a key. + /// + /// ZUNIONSTORE destination numkeys key [ key ... ] [ WEIGHTS weight [ weight ... ] ] [ AGGREGATE SUM | MIN | MAX ] + /// https://valkey.io/commands/zunionstore/ + @since(version = 0.3.0-dev) + zunionstore: async func(connection: borrow, destination: string, keys: list, weights: option>, aggregate: option) -> result; + + /// Returns the number of messages that were successfully acknowledged by the consumer group member of a stream. + /// + /// XACK key group ID [ ID ... ] + /// https://valkey.io/commands/xack/ + @since(version = 0.3.0-dev) + xack: async func(connection: borrow, key: string, group: string, ids: list) -> result; + + /// Appends a new message to a stream. + /// + /// XADD key [ NOMKSTREAM ] [ 〈 MAXLEN | MINID 〉 [ = | ~ ] threshold [ LIMIT count ] ] 〈 * | id 〉 field value [ field value ... ] + /// https://valkey.io/commands/xadd/ + @since(version = 0.3.0-dev) + xadd: async func(connection: borrow, key: string, nomkstream: bool, trim: option, id: stream-id, field-and-values: list>) -> result, error-code>; + + /// Changes, or acquires, ownership of messages in a consumer group, as if the messages were delivered to a consumer group member. + /// + /// A start of none scans from the beginning, `0-0`. The next start is returned, none once the + /// scan is complete. + /// + /// See also `xautoclaim-justid`. + /// + /// XAUTOCLAIM key group consumer min-idle-time start [ COUNT count ] + /// https://valkey.io/commands/xautoclaim/ + @since(version = 0.3.0-dev) + xautoclaim: async func(connection: borrow, key: string, group: string, consumer: string, min-idle-time: duration-milliseconds, start: option, count: option) -> result, list>>, list>, error-code>; + + /// Changes, or acquires, ownership of messages in a consumer group, as if the messages were delivered to a consumer group member. + /// + /// The `JUSTID` form, the IDs only. A start of none scans from the beginning, `0-0`. The next + /// start is returned, none once the scan is complete. + /// + /// See also `xautoclaim`. + /// + /// XAUTOCLAIM key group consumer min-idle-time start [ COUNT count ] JUSTID + /// https://valkey.io/commands/xautoclaim/ + @since(version = 0.3.0-dev) + xautoclaim-justid: async func(connection: borrow, key: string, group: string, consumer: string, min-idle-time: duration-milliseconds, start: option, count: option) -> result, list, list>, error-code>; + + /// Changes, or acquires, ownership of a message in a consumer group, as if the message was delivered to a consumer group member. + /// + /// See also `xclaim-justid`. + /// + /// XCLAIM key group consumer min-idle-time ID [ ID ... ] [ IDLE ms ] [ TIME unix-time-milliseconds ] [ RETRYCOUNT count ] [ FORCE ] [ LASTID lastid ] + /// https://valkey.io/commands/xclaim/ + @since(version = 0.3.0-dev) + xclaim: async func(connection: borrow, key: string, group: string, consumer: string, min-idle-time: duration-milliseconds, ids: list, opts: option) -> result>>, error-code>; + + /// Changes, or acquires, ownership of a message in a consumer group, as if the message was delivered to a consumer group member. + /// + /// The `JUSTID` form, the IDs only. + /// + /// See also `xclaim`. + /// + /// XCLAIM key group consumer min-idle-time ID [ ID ... ] [ IDLE ms ] [ TIME unix-time-milliseconds ] [ RETRYCOUNT count ] [ FORCE ] JUSTID [ LASTID lastid ] + /// https://valkey.io/commands/xclaim/ + @since(version = 0.3.0-dev) + xclaim-justid: async func(connection: borrow, key: string, group: string, consumer: string, min-idle-time: duration-milliseconds, ids: list, opts: option) -> result, error-code>; + + /// Returns the number of messages after removing them from a stream. + /// + /// XDEL key ID [ ID ... ] + /// https://valkey.io/commands/xdel/ + @since(version = 0.3.0-dev) + xdel: async func(connection: borrow, key: string, ids: list) -> result; + + /// Creates a consumer group. + /// + /// XGROUP CREATE key group 〈 id | $ 〉 [ MKSTREAM ] [ ENTRIESREAD entriesread ] + /// https://valkey.io/commands/xgroup-create/ + @since(version = 0.3.0-dev) + xgroup-create: async func(connection: borrow, key: string, group: string, id: stream-id, mkstream: bool, entriesread: option) -> result<_, error-code>; + + /// Creates a consumer in a consumer group. + /// + /// XGROUP CREATECONSUMER key group consumer + /// https://valkey.io/commands/xgroup-createconsumer/ + @since(version = 0.3.0-dev) + xgroup-createconsumer: async func(connection: borrow, key: string, group: string, consumer: string) -> result; + + /// Deletes a consumer from a consumer group. + /// + /// XGROUP DELCONSUMER key group consumer + /// https://valkey.io/commands/xgroup-delconsumer/ + @since(version = 0.3.0-dev) + xgroup-delconsumer: async func(connection: borrow, key: string, group: string, consumer: string) -> result; + + /// Destroys a consumer group. + /// + /// XGROUP DESTROY key group + /// https://valkey.io/commands/xgroup-destroy/ + @since(version = 0.3.0-dev) + xgroup-destroy: async func(connection: borrow, key: string, group: string) -> result; + + /// Sets the last-delivered ID of a consumer group. + /// + /// XGROUP SETID key group 〈 id | $ 〉 [ ENTRIESREAD entriesread ] + /// https://valkey.io/commands/xgroup-setid/ + @since(version = 0.3.0-dev) + xgroup-setid: async func(connection: borrow, key: string, group: string, id: stream-id, entriesread: option) -> result<_, error-code>; + + /// Returns a list of the consumers in a consumer group. + /// + /// XINFO CONSUMERS key group + /// https://valkey.io/commands/xinfo-consumers/ + @since(version = 0.3.0-dev) + xinfo-consumers: async func(connection: borrow, key: string, group: string) -> result, error-code>; + + /// Returns a list of the consumer groups of a stream. + /// + /// XINFO GROUPS key + /// https://valkey.io/commands/xinfo-groups/ + @since(version = 0.3.0-dev) + xinfo-groups: async func(connection: borrow, key: string) -> result, error-code>; + + /// Returns information about a stream. + /// + /// See also `xinfo-stream-full`. + /// + /// XINFO STREAM key + /// https://valkey.io/commands/xinfo-stream/ + @since(version = 0.3.0-dev) + xinfo-stream: async func(connection: borrow, key: string) -> result; + + /// Returns information about a stream. + /// + /// The `FULL` form, with the entries, groups and consumers. + /// + /// See also `xinfo-stream`. + /// + /// XINFO STREAM key FULL [ COUNT count ] + /// https://valkey.io/commands/xinfo-stream/ + @since(version = 0.3.0-dev) + xinfo-stream-full: async func(connection: borrow, key: string, count: option) -> result; + + /// Returns the number of messages in a stream. + /// + /// XLEN key + /// https://valkey.io/commands/xlen/ + @since(version = 0.3.0-dev) + xlen: async func(connection: borrow, key: string) -> result; + + /// Returns the information and entries from a stream consumer group's pending entries list. + /// + /// The summary form. + /// + /// See also `xpending-extended`. + /// + /// XPENDING key group + /// https://valkey.io/commands/xpending/ + @since(version = 0.3.0-dev) + xpending: async func(connection: borrow, key: string, group: string) -> result; + + /// Returns the information and entries from a stream consumer group's pending entries list. + /// + /// The extended form, the pending entries in the range. + /// + /// See also `xpending`. + /// + /// XPENDING key group [ IDLE min-idle-time ] start end count [ consumer ] + /// https://valkey.io/commands/xpending/ + @since(version = 0.3.0-dev) + xpending-extended: async func(connection: borrow, key: string, group: string, min-idle-time: option, start: stream-id, end: stream-id, count: integer, consumer: option) -> result, error-code>; + + /// Returns the messages from a stream within a range of IDs. + /// + /// XRANGE key start end [ COUNT count ] + /// https://valkey.io/commands/xrange/ + @since(version = 0.3.0-dev) + xrange: async func(connection: borrow, key: string, start: stream-id, end: stream-id, count: option) -> result>>, error-code>; + + /// Returns messages from multiple streams with IDs greater than the ones requested. + /// + /// With `block`, returns none when the timeout passed. The connection waits too, replies to + /// commands sent after it on the connection wait behind it. Use a connection of your own. + /// + /// XREAD [ COUNT count ] [ BLOCK milliseconds ] STREAMS key [ key ... ] ID [ ID ... ] + /// https://valkey.io/commands/xread/ + @since(version = 0.3.0-dev) + xread: async func(connection: borrow, count: option, block: option, keys: list, ids: list) -> result>>>>, error-code>; + + /// Returns new or historical messages from a stream for a consumer in a group. + /// + /// The fields of an entry are none when it was deleted after it was delivered, e.g. when + /// reading the pending entries with the ID `0`. + /// + /// With `block`, returns none when the timeout passed. The connection waits too, replies to + /// commands sent after it on the connection wait behind it. Use a connection of your own. + /// + /// XREADGROUP GROUP group consumer [ COUNT count ] [ BLOCK milliseconds ] [ NOACK ] STREAMS key [ key ... ] ID [ ID ... ] + /// https://valkey.io/commands/xreadgroup/ + @since(version = 0.3.0-dev) + xreadgroup: async func(connection: borrow, group: string, consumer: string, count: option, block: option, noack: bool, keys: list, ids: list) -> result>>>>>, error-code>; + + /// Returns the messages from a stream within a range of IDs in reverse order. + /// + /// XREVRANGE key end start [ COUNT count ] + /// https://valkey.io/commands/xrevrange/ + @since(version = 0.3.0-dev) + xrevrange: async func(connection: borrow, key: string, end: stream-id, start: stream-id, count: option) -> result>>, error-code>; + + /// An internal command for replicating stream values. + /// + /// XSETID key last-id [ ENTRIESADDED entries-added ] [ MAXDELETEDID max-deleted-id ] + /// https://valkey.io/commands/xsetid/ + @since(version = 0.3.0-dev) + xsetid: async func(connection: borrow, key: string, last-id: stream-id, entries-added: option, max-deleted-id: option) -> result<_, error-code>; + + /// Deletes messages from the beginning of a stream. + /// + /// XTRIM key 〈 MAXLEN | MINID 〉 [ = | ~ ] threshold [ LIMIT count ] + /// https://valkey.io/commands/xtrim/ + @since(version = 0.3.0-dev) + xtrim: async func(connection: borrow, key: string, trim: stream-trim) -> result; + + /// Appends a string to the value of a key. + /// + /// APPEND key value + /// https://valkey.io/commands/append/ + @since(version = 0.3.0-dev) + append: async func(connection: borrow, key: string, value: string-or-bytes) -> result; + + /// Decrements the integer value of a key by one. + /// + /// DECR key + /// https://valkey.io/commands/decr/ + @since(version = 0.3.0-dev) + decr: async func(connection: borrow, key: string) -> result; + + /// Decrements the integer value of a key by a number. + /// + /// DECRBY key decrement + /// https://valkey.io/commands/decrby/ + @since(version = 0.3.0-dev) + decrby: async func(connection: borrow, key: string, decrement: integer) -> result; + + /// Delete key if value matches string. + /// + /// DELIFEQ key value + /// https://valkey.io/commands/delifeq/ + @since(version = 0.3.0-dev) + delifeq: async func(connection: borrow, key: string, value: string-or-bytes) -> result; + + /// Returns the string value of a key. + /// + /// GET key + /// https://valkey.io/commands/get/ + @since(version = 0.3.0-dev) + get: async func(connection: borrow, key: string) -> result, error-code>; + + /// Returns the string value of a key after deleting the key. + /// + /// GETDEL key + /// https://valkey.io/commands/getdel/ + @since(version = 0.3.0-dev) + getdel: async func(connection: borrow, key: string) -> result, error-code>; + + /// Returns the string value of a key after setting its expiration time. + /// + /// GETEX key [ EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | PERSIST ] + /// https://valkey.io/commands/getex/ + @since(version = 0.3.0-dev) + getex: async func(connection: borrow, key: string, ttl: option) -> result, error-code>; + + /// Returns a substring of the string stored at a key. + /// + /// GETRANGE key start end + /// https://valkey.io/commands/getrange/ + @since(version = 0.3.0-dev) + getrange: async func(connection: borrow, key: string, start: integer, end: integer) -> result; + + /// Returns the previous string value of a key after setting it to a new value. + /// + /// GETSET key value + /// https://valkey.io/commands/getset/ + @since(version = 0.3.0-dev) + getset: async func(connection: borrow, key: string, value: string-or-bytes) -> result, error-code>; + + /// Increments the integer value of a key by one. + /// + /// INCR key + /// https://valkey.io/commands/incr/ + @since(version = 0.3.0-dev) + incr: async func(connection: borrow, key: string) -> result; + + /// Increments the integer value of a key by a number. + /// + /// INCRBY key increment + /// https://valkey.io/commands/incrby/ + @since(version = 0.3.0-dev) + incrby: async func(connection: borrow, key: string, increment: integer) -> result; + + /// Increments the floating point value of a key by a number. + /// + /// INCRBYFLOAT key increment + /// https://valkey.io/commands/incrbyfloat/ + @since(version = 0.3.0-dev) + incrbyfloat: async func(connection: borrow, key: string, increment: double) -> result; + + /// Finds the longest common substring. + /// + /// See also `lcs-len` and `lcs-idx`. + /// + /// LCS key1 key2 + /// https://valkey.io/commands/lcs/ + @since(version = 0.3.0-dev) + lcs: async func(connection: borrow, key1: string, key2: string) -> result; + + /// Finds the longest common substring. + /// + /// The `LEN` form, the length only. + /// + /// See also `lcs` and `lcs-idx`. + /// + /// LCS key1 key2 LEN + /// https://valkey.io/commands/lcs/ + @since(version = 0.3.0-dev) + lcs-len: async func(connection: borrow, key1: string, key2: string) -> result; + + /// Finds the longest common substring. + /// + /// The `IDX` form, the ranges of the matches. + /// + /// See also `lcs` and `lcs-len`. + /// + /// LCS key1 key2 IDX [ MINMATCHLEN min-match-len ] [ WITHMATCHLEN ] + /// https://valkey.io/commands/lcs/ + @since(version = 0.3.0-dev) + lcs-idx: async func(connection: borrow, key1: string, key2: string, min-match-len: option, with-match-len: bool ) -> result; + + /// Atomically returns the string values of one or more keys. + /// + /// MGET key [ key ... ] + /// https://valkey.io/commands/mget/ + @since(version = 0.3.0-dev) + mget: async func(connection: borrow, keys: list) -> result>, error-code>; + + /// Atomically creates or modifies the string values of one or more keys. + /// + /// MSET key value [ key value ... ] + /// https://valkey.io/commands/mset/ + @since(version = 0.3.0-dev) + mset: async func(connection: borrow, keys-and-values: list>) -> result<_, error-code>; + + /// Atomically creates or modifies the string values of one or more keys, and optionally set their expiration. + /// + /// MSETEX numkeys key value [ key value ... ] [ NX | XX ] [ EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL ] + /// https://valkey.io/commands/msetex/ + @since(version = 0.3.0-dev) + msetex: async func(connection: borrow, keys-and-values: list>, precondition: option, ttl: option) -> result; + + /// Atomically modifies the string values of one or more keys only when all keys don't exist. + /// + /// MSETNX key value [ key value ... ] + /// https://valkey.io/commands/msetnx/ + @since(version = 0.3.0-dev) + msetnx: async func(connection: borrow, keys-and-values: list>) -> result; + + /// Sets both string value and expiration time in milliseconds of a key. + /// + /// PSETEX key milliseconds value + /// https://valkey.io/commands/psetex/ + @since(version = 0.3.0-dev) + psetex: async func(connection: borrow, key: string, milliseconds: duration-milliseconds, value: string-or-bytes) -> result<_, error-code>; + + /// Sets the string value of a key, ignoring its type. + /// + /// Returns false when the precondition is not met, the key is unchanged. + /// + /// See also `set-get`. + /// + /// SET key value [ NX | XX | IFEQ comparison-value | IFNE comparison-not-equal ] [ EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL ] + /// https://valkey.io/commands/set/ + @since(version = 0.3.0-dev) + set: async func(connection: borrow, key: string, value: string-or-bytes, precondition: option, ttl: option) -> result; + + /// Sets the string value of a key, ignoring its type. + /// + /// The `GET` form, returning the previous value, none when the key did not exist. + /// + /// See also `set`. + /// + /// SET key value [ NX | XX | IFEQ comparison-value | IFNE comparison-not-equal ] GET [ EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL ] + /// https://valkey.io/commands/set/ + @since(version = 0.3.0-dev) + set-get: async func(connection: borrow, key: string, value: string-or-bytes, precondition: option, ttl: option) -> result, error-code>; + + /// Sets the string value and expiration time of a key. + /// + /// SETEX key seconds value + /// https://valkey.io/commands/setex/ + @since(version = 0.3.0-dev) + setex: async func(connection: borrow, key: string, seconds: duration-seconds, value: string-or-bytes) -> result<_, error-code>; + + /// Sets the string value of a key only when the key doesn't exist. + /// + /// SETNX key value + /// https://valkey.io/commands/setnx/ + @since(version = 0.3.0-dev) + setnx: async func(connection: borrow, key: string, value: string-or-bytes) -> result; + + /// Overwrites a part of a string value with another by an offset. + /// + /// SETRANGE key offset value + /// https://valkey.io/commands/setrange/ + @since(version = 0.3.0-dev) + setrange: async func(connection: borrow, key: string, offset: u32, value: string-or-bytes) -> result; + + /// Returns the length of a string value. + /// + /// STRLEN key + /// https://valkey.io/commands/strlen/ + @since(version = 0.3.0-dev) + strlen: async func(connection: borrow, key: string) -> result; + + /// Returns a substring from a string value. + /// + /// SUBSTR key start end + /// https://valkey.io/commands/substr/ + @since(version = 0.3.0-dev) + substr: async func(connection: borrow, key: string, start: integer, end: integer) -> result; + +} diff --git a/wit/connection.wit b/wit/connection.wit new file mode 100644 index 0000000..a27712a --- /dev/null +++ b/wit/connection.wit @@ -0,0 +1,253 @@ +/// RESP request/reply over a pair of value streams, independent of the transport carrying them. +/// +/// A transport and a connection are wired together with their streams: take the replies the +/// transport receives, establish the connection with them, hand the requests the connection +/// returns to the transport to send, then await the connection. See `tcp.connect` for a transport +/// that does this for its caller. +/// +/// The connection is the mechanics of talking to a server: sending commands and pairing replies +/// with them, subscriptions, push messages, and transactions. Higher level, typed operations are +/// functions in the `commands` interface that borrow the connection. +interface connection { + @since(version = 0.3.0-dev) + use types.{connect-opts, duration-milliseconds, error-code, resp-value, string-or-bytes, subscription-event, subscription-target}; + + @since(version = 0.3.0-dev) + resource connection { + /// Establishes a connection over a transport. + /// + /// `replies` are the values the transport receives from the server, and `closed` resolves + /// when the transport closes, to `ok` for a graceful close or to the error otherwise, see + /// `tcp.transport.receive`. Returns the requests for the transport to write to the server, + /// in order, and the connection once it is established. + /// + /// The connection sends `HELLO 3`, with the `auth` and `client-name` of `opts`, as its first + /// command. This pins the connection to RESP3 and confirms the server can be reached. The + /// future resolves to the connection once HELLO succeeds, or to the error that stopped it. + /// The rest of `opts` is applied before the connection is returned. + /// + /// It then reports the library to the server, `CLIENT SETINFO LIB-NAME componentized:valkey` + /// and `LIB-VER` with the version of the package, ignoring an error from a server that does + /// not know CLIENT SETINFO. They survive `reset`, `client-setinfo` replaces them. + /// + /// Async so the connection keeps working between commands after it returns, e.g. to read + /// push messages and to send keep-alive pings. + @since(version = 0.3.0-dev) + new: static async func(replies: stream, closed: future>, opts: option) -> tuple, future>>; + + /// The server's properties from the reply to HELLO, e.g. `server`, `version` and `mode`. + @since(version = 0.3.0-dev) + server-properties: func() -> list>; + + /// Sends a command and waits for its reply. + /// + /// Several commands may be in flight at once, replies are paired with commands in the order + /// the commands were sent. + /// + /// For SUBSCRIBE, PSUBSCRIBE, SSUBSCRIBE and their UNSUBSCRIBE counterparts, prefer a + /// `subscription`, which owns its targets' messages. Once the connection has one, it alone + /// sends these commands, and they fail with a client error before anything is sent, here, + /// with `exchange-all`, `emit` and `transaction`. + /// + /// RESET, HELLO and CLIENT REPLY always fail with a client error. See `reset` and + /// `server-properties`, CLIENT REPLY would stop replies from pairing with commands, see + /// `emit`. + /// + /// A command has at least one word, an empty command fails with a client error. + @since(version = 0.3.0-dev) + exchange: async func(command: list) -> result; + + /// Sends the commands one after another, with no other command sent between them, and waits + /// for every reply. + /// + /// Each command has its own result, in order, as if each was sent with `exchange`. Commands + /// sharing a connection may otherwise be interleaved with other callers' commands, which + /// matters for a sequence that must not be interrupted, e.g. MULTI, its commands and EXEC. + @since(version = 0.3.0-dev) + exchange-all: async func(commands: list>) -> list>; + + /// Sends a command without waiting for its reply, resolving once the command is written. + /// + /// The server still replies, the reply is discarded when it arrives and an error it reports + /// is not returned. An implementation may instead ask the server not to reply, e.g. with + /// `CLIENT REPLY SKIP`. + /// + /// A command has at least one word, an empty command fails with a client error. + @since(version = 0.3.0-dev) + emit: async func(command: list) -> result<_, error-code>; + + /// Marks the keys to watch, EXEC aborts the next transaction when any of them changed. + /// + /// Watched keys belong to the connection, and EXEC, DISCARD or UNWATCH from any caller sharing + /// it clears them. Use a connection of your own for WATCH, the reads it guards, and the + /// transaction. + /// + /// WATCH key [ key ... ] + /// https://valkey.io/commands/watch/ + /// + /// At least one key, no keys fails with a client error. + @since(version = 0.3.0-dev) + watch: async func(keys: list) -> result<_, error-code>; + + /// Forgets all the watched keys. + /// + /// UNWATCH + /// https://valkey.io/commands/unwatch/ + @since(version = 0.3.0-dev) + unwatch: async func() -> result<_, error-code>; + + /// Runs the commands as a transaction, MULTI, each command, then EXEC, with no other command + /// sent between them, see `exchange-all`. + /// + /// Returns none when a watched key changed and the transaction was aborted, otherwise the + /// reply of each command in order. A command that fails while running is an error value in + /// its place, the others still ran. A command rejected while queuing, e.g. an unknown + /// command, fails the transaction with that error, none of the commands ran. + /// + /// MULTI and EXEC are only sent as part of a transaction, the commands between them reply + /// QUEUED rather than their reply. + /// + /// MULTI, EXEC + /// https://valkey.io/commands/multi/ + /// https://valkey.io/commands/exec/ + @since(version = 0.3.0-dev) + transaction: async func(commands: list>) -> result>, error-code>; + + /// A new subscription, subscribed to nothing yet, see the subscription's `subscribe`. + /// + /// From the first subscription, the connection owns its subscriptions: SUBSCRIBE, + /// PSUBSCRIBE, SSUBSCRIBE and their UNSUBSCRIBE counterparts sent with `exchange` and the like + /// fail with a client error, so the server's subscriptions stay in step with the + /// connection's. Subscriptions made with `exchange` before are unsupported. They may keep + /// delivering to `dead-letters`, the exact behavior is undocumented. + /// + /// Fails with a client error when the connection component was built without pub/sub, + /// e.g. `connection-minimal`. Its push messages are then all dead letters, and SUBSCRIBE and + /// the like may still be sent with `exchange`. + @since(version = 0.3.0-dev) + subscription: func() -> result; + + /// The values nothing else claims, kept apart from replies. + /// + /// Push messages that no subscription owns, e.g. client tracking invalidations, messages + /// and confirmations of SUBSCRIBE sent with `exchange`, or kinds of push messages from a + /// newer server. Values the server sends unprompted, when no command is waiting for a + /// reply, are included, e.g. the feed of MONITOR, which are not RESP push values. + /// + /// Each call returns a new stream that receives every value arriving after the call, + /// values arriving while no stream is open are dropped. A stream that falls too far behind + /// is closed rather than holding up replies, the values it missed are lost. + @since(version = 0.3.0-dev) + dead-letters: func() -> stream; + + /// Resets the connection to the state of a new one, then repeats the handshake of `new`. + /// + /// RESET discards a transaction in progress, forgets the watched keys, unsubscribes from + /// every channel, turns off client tracking and the like, selects database 0, and forgets + /// the client name and the authenticated user. It also switches the connection to RESP2, + /// so it is followed at once by `HELLO 3` with the `auth` and `client-name` the connection + /// was established with, with no other command sent between them. `server-properties` + /// then holds the new reply to HELLO. + /// + /// The subscriptions are unsubscribed from every target, each with the reason `reset`, and + /// stay open for new targets. + /// + /// No other command is sent until the handshake succeeds, so none runs on the connection + /// RESET leaves. When the handshake fails, e.g. the credentials expired since the connection + /// was established, the connection closes with its error, and the commands queued after + /// the reset fail with it without being sent. Establish a new connection. + /// + /// RESET + /// https://valkey.io/commands/reset/ + @since(version = 0.3.0-dev) + reset: async func() -> result<_, error-code>; + + /// Sends PING when no command has been sent and no reply is outstanding for `interval`, + /// `none` disables it. A PING that fails closes the connection. Disabled by default. + /// + /// Unlike TCP keep-alive, a PING counts as activity for the server's idle client timeout, + /// and works over any transport. + @since(version = 0.3.0-dev) + set-keep-alive: func(interval: option); + } + + /// The messages of a set of channels, patterns of channels and shard channels, that changes + /// as targets are subscribed to and unsubscribed from, see `connection.subscription`. + /// + /// Subscriptions sharing a connection may overlap, the server stays subscribed to a target while + /// any subscription has it. The subscription stays open until it is dropped, with or without + /// targets or streams, then it unsubscribes from its targets. It keeps its connection open. + @since(version = 0.3.0-dev) + resource subscription { + /// Subscribes to the targets, resolving once the server confirms each of them, see + /// `subscription-event.subscribed`. Targets already subscribed to are left as they are. The + /// server unsubscribes from a shard channel on its own when its slot moves to another node. + /// + /// Each kind of target is subscribed to with its own command, queued with no other command + /// between them. Every command is sent, even when another fails, and those that succeed + /// take effect, see `subscription-event.subscribed` and `targets`. The error then holds the + /// error of each command that failed, e.g. `PSUBSCRIBE: NOPERM ...`. A command is accepted + /// or refused as a whole, targets of one kind are subscribed to all or none. + /// + /// SUBSCRIBE channel [ channel ... ] + /// PSUBSCRIBE pattern [ pattern ... ] + /// SSUBSCRIBE shardchannel [ shardchannel ... ] + /// https://valkey.io/commands/subscribe/ + /// https://valkey.io/commands/psubscribe/ + /// https://valkey.io/commands/ssubscribe/ + /// + /// At least one target, no targets fails with a client error. + @since(version = 0.3.0-dev) + subscribe: async func(targets: list) -> result<_, error-code>; + + /// The events arriving after the call: targets subscribed to and unsubscribed from, and + /// messages, in the order they happened. + /// + /// Each call returns a new stream, every open stream receives every event. Events arriving + /// while no stream is open are kept for the next stream, up to 256, later ones are dropped. + /// Dropping a stream does not unsubscribe, call `events` again for another. + /// + /// Pub/sub delivers each message at most once, to all listeners. A stream receives + /// no events from before it opened, beyond those kept while no stream was open, and does + /// not start with the current targets, see `targets`. Messages published while nothing + /// listens are lost, e.g. before a target is subscribed to, after the connection closed, or + /// past what a lagging stream holds. For delivery that is guaranteed, use a Valkey stream, + /// see XADD and XREADGROUP. + /// + /// The stream closes once the subscription is dropped, the future then resolves to `ok`, + /// or to the error that closed the connection. A stream that falls too far behind is + /// closed rather than holding up the connection, its future resolves to `lagged`, and the + /// events it missed, messages and changes of targets alike, are lost. The future resolves + /// after the stream delivered what it holds, read the stream to its end first. + @since(version = 0.3.0-dev) + events: func() -> tuple, future>>; + + /// The targets the server confirmed, in the order they were subscribed to, until they are + /// unsubscribed from. + /// + /// Once the connection closes, the targets confirmed until then are still listed, e.g. to + /// subscribe to them on a new connection. `subscribe` and `unsubscribe` then fail with the + /// error that closed the connection, and leave the targets as they are. + @since(version = 0.3.0-dev) + targets: func() -> list; + + /// Unsubscribes from the targets, none for every target, resolving once each is + /// unsubscribed from, see `subscription-event.unsubscribed`. Targets not subscribed to are + /// left as they are. The server stays subscribed to targets other subscriptions have. + /// + /// Each kind of target is unsubscribed from with its own command, as for `subscribe`, e.g. + /// the `targets` may be given as they are. + /// + /// UNSUBSCRIBE [ channel [ channel ... ] ] + /// PUNSUBSCRIBE [ pattern [ pattern ... ] ] + /// SUNSUBSCRIBE [ shardchannel [ shardchannel ... ] ] + /// https://valkey.io/commands/unsubscribe/ + /// https://valkey.io/commands/punsubscribe/ + /// https://valkey.io/commands/sunsubscribe/ + /// + /// A list of targets has at least one target, otherwise fails with a client error. None + /// unsubscribes from every target. + @since(version = 0.3.0-dev) + unsubscribe: async func(targets: option>) -> result<_, error-code>; + } +} diff --git a/wit/connector.wit b/wit/connector.wit new file mode 100644 index 0000000..a421b09 --- /dev/null +++ b/wit/connector.wit @@ -0,0 +1,16 @@ +/// Establishes connections, with a transport and settings chosen by whoever provides it, e.g. from +/// configuration. +/// +/// A component that needs a connection, but not a say in how it is made, imports a connector, the +/// composition decides which one, e.g. `tcp-connector`. +@since(version = 0.3.0-dev) +interface connector { + @since(version = 0.3.0-dev) + use types.{error-code}; + @since(version = 0.3.0-dev) + use connection.{connection}; + + /// Establishes a new connection. + @since(version = 0.3.0-dev) + connect: async func() -> result; +} diff --git a/wit/resp.wit b/wit/resp.wit deleted file mode 100644 index fcd0d8e..0000000 --- a/wit/resp.wit +++ /dev/null @@ -1,27 +0,0 @@ -interface resp { - type error = string; - - variant value { - %string(string), - error(string), - integer(s64), - bulk-string(string), - array(list), - null, - boolean(bool), - double(f64), - big-number(string), - bulk-error(string), - verbatim-string(tuple), - %map(list>), - set(list), - push(list), - } - - /// Values nested within an aggregate type. Since WIT types are not allowed to depend on - /// themselves, nested values are stored as encoded RESP. - type nested-value = list; - - decode: func(data: list) -> result; - encode: func(value: value) -> result, error>; -} diff --git a/wit/store.wit b/wit/store.wit deleted file mode 100644 index b19cf73..0000000 --- a/wit/store.wit +++ /dev/null @@ -1,94 +0,0 @@ -interface store { - use wasi:clocks/system-clock@0.3.0.{ duration, instant }; - use resp.{error as resp-error, value}; - - variant error { - /// Client errors can represent semantic issues with input/output values, or connection errors communicating with the server - client(string), - /// RESP errors are encoding issues converting values to/from bytes - resp(resp-error), - /// Valkey errors are Error or BulkError values returned directly from the server - valkey(string), - } - - enum expire-mode { - /// Set expiration only when the field has no expiration. - nx, - /// Set expiration only when the field has an existing expiration. - xx, - /// Set expiration only when the new expiration is greater than current one. - gt, - /// Set expiration only when the new expiration is less than current one. - lt, - } - - enum expire-result { - /// -2: key does not exist, or field does not exist in the HASH. - does-not-exist, - /// 0: the specified NX | XX | GT | LT condition has not been met. - condition-not-met, - /// 1: the expiration time was applied. - applied, - /// 2: when called with 0 seconds. - zero-seconds, - } - - record hello-opts { - proto-ver: option, - auth: option>, - client-name: option, - } - - record hrandfield-opts { - count: option, - with-values: option, - } - - record hscan-opts { - match: option, - count: option, - no-values: option, - } - - resource connection { - send: async func(command: list) -> result; - - acl-deluser: async func(username: string) -> result<_, error>; - acl-genpass: async func() -> result; - acl-setuser: async func(username: string, rules: list) -> result<_, error>; - auth: async func(username: string, password: string) -> result<_, error>; - del: async func(key: string) -> result<_, error>; - exists: async func(key: string) -> result; - expire: async func(key: string, seconds: duration, mode: option) -> result; - expireat: async func(key: string, seconds: instant, mode: option) -> result; - get: async func(key: string) -> result, error>; - hdel: async func(key: string, field: string) -> result<_, error>; - hello: async func(opts: option) -> result>, error>; - hexists: async func(key: string, field: string) -> result; - hexpire: async func(key: string, seconds: duration, mode: option, fields: list) -> result, error>; - hexpireat: async func(key: string, seconds: instant, mode: option, fields: list) -> result, error>; - hget: async func(key: string, field: string) -> result, error>; - hgetall: async func(key: string) -> result>, error>; - hincrby: async func(key: string, field: string, increment: s64) -> result; - hincrbyfloat: async func(key: string, field: string, increment: f64) -> result; - hkeys: async func(key: string) -> result, error>; - hlen: async func(key: string) -> result; - hmget: async func(key: string, fields: list) -> result>, error>; - hmset: async func(key: string, fields: list>) -> result<_, error>; - hrandfield: async func(key: string, opts: option) -> result>>>, error>; - hscan: async func(key: string, cursor: option, opts: option) -> result, list>>>, error>; - hset: async func(key: string, field: string, value: string) -> result<_, error>; - hsetnx: async func(key: string, field: string, value: string) -> result; - hstrlen: async func(key: string, field: string) -> result; - hvals: async func(key: string) -> result, error>; - incr: async func(key: string) -> result; - incrby: async func(key: string, increment: s64) -> result; - keys: async func(pattern: string) -> result, error>; - ping: async func() -> result<_, error>; - publish: async func(channel: string, message: string) -> result; - quit: async func() -> result<_, error>; - set: async func(key: string, value: string) -> result<_, error>; - } - - connect: async func(host: string, port: u16, opts: option) -> result; -} diff --git a/wit/tcp-connector-constants.wit b/wit/tcp-connector-constants.wit new file mode 100644 index 0000000..6a67dd7 --- /dev/null +++ b/wit/tcp-connector-constants.wit @@ -0,0 +1,58 @@ +/// The keys of the `tcp-connector`'s wasi:config, and their defaults, for the components that derive +/// their config from it. The values are provided by the `tcp-connector-constants` component. +@since(version = 0.3.0-dev) +interface tcp-connector-constants { + /// The key of the server's hostname or IP address. + /// + /// @value "hostname" + @since(version = 0.3.0-dev) + hostname-key: func() -> string; + + /// The server's hostname or IP address without one. + /// + /// @value "127.0.0.1" + @since(version = 0.3.0-dev) + hostname-default: func() -> string; + + /// The key of the server's port. + /// + /// @value "port" + @since(version = 0.3.0-dev) + port-key: func() -> string; + + /// The server's port without one. + /// + /// @value 6379 + @since(version = 0.3.0-dev) + port-default: func() -> u16; + + /// The key of the user to authenticate as, with a password. + /// + /// @value "username" + @since(version = 0.3.0-dev) + username-key: func() -> string; + + /// The user to authenticate as without one. + /// + /// @value "default" + @since(version = 0.3.0-dev) + username-default: func() -> string; + + /// The key of the password, authenticating when set. + /// + /// @value "password" + @since(version = 0.3.0-dev) + password-key: func() -> string; + + /// The key of the connection's name, see `CLIENT SETNAME`. + /// + /// @value "client-name" + @since(version = 0.3.0-dev) + client-name-key: func() -> string; + + /// The key of the milliseconds idle before a `PING`, disabled when unset or `0`. + /// + /// @value "keep-alive-interval-ms" + @since(version = 0.3.0-dev) + keep-alive-interval-ms-key: func() -> string; +} diff --git a/wit/tcp.wit b/wit/tcp.wit new file mode 100644 index 0000000..c0d441e --- /dev/null +++ b/wit/tcp.wit @@ -0,0 +1,39 @@ +/// A transport for connections over wasi:sockets TCP. +interface tcp { + @since(version = 0.3.0-dev) + use types.{connect-opts, error-code, resp-value}; + @since(version = 0.3.0-dev) + use connection.{connection}; + + /// A TCP connection to a Valkey server, framing the bytes it carries into RESP values in each + /// direction. + @since(version = 0.3.0-dev) + resource transport { + /// Opens a transport to `host`, a hostname or an IP address. Each address the host resolves + /// to is tried in turn. + @since(version = 0.3.0-dev) + open: static async func(host: string, port: u16) -> result; + + /// Writes each value from `requests` to the server, in order. + /// + /// May be called at most once, like `tcp-socket.send`. Closing `requests` half-closes the + /// socket. The future resolves once every value is written, or with the error that stopped + /// writing. + @since(version = 0.3.0-dev) + send: func(requests: stream) -> future>; + + /// The values the server sends, in order. + /// + /// May be called at most once, like `tcp-socket.receive`. The stream closes when the server + /// closes the socket, the future resolves to `ok` for a graceful close or to the error + /// otherwise. + @since(version = 0.3.0-dev) + receive: func() -> tuple, future>>; + } + + /// Opens a transport and establishes a connection over it with `opts`, see `connection.new`. + /// + /// The connection keeps the transport alive. + @since(version = 0.3.0-dev) + connect: async func(host: string, port: u16, opts: option) -> result; +} diff --git a/wit/types.wit b/wit/types.wit new file mode 100644 index 0000000..9f4deae --- /dev/null +++ b/wit/types.wit @@ -0,0 +1,1807 @@ +/// The types of the package's interfaces, their stable public names. +/// +/// The other interfaces use these types under names of their own, which may change, e.g. renamed +/// to avoid clashing with a function for a new command. Refer to a type through this interface for +/// a name that does not change. +interface types { + // resp + + /// An encoding error, converting RESP values to or from bytes. + @since(version = 0.3.0-dev) + type resp-error = string; + + @since(version = 0.3.0-dev) + type integer = s64; + + @since(version = 0.3.0-dev) + type double = f64; + + /// Raw bytes, e.g. a bulk string, which Valkey keeps as given, UTF-8 or not. + @since(version = 0.3.0-dev) + type bytes = list; + + @since(version = 0.3.0-dev) + variant string-or-bytes { + /// utf-8 decoded bytes. + %string(string), + /// bytes that cannot be represented as a utf-8 string. + bytes(bytes), + } + + @since(version = 0.3.0-dev) + variant resp-value { + %string(string), + error(string), + integer(integer), + bulk-string(bytes), + array(list), + null, + boolean(bool), + double(double), + big-number(string), + bulk-error(bytes), + verbatim-string(tuple), + %map(list>), + set(list), + push(list), + } + + /// Values nested within an aggregate type. Since WIT types are not allowed to depend on + /// themselves, nested values are stored as encoded RESP. + @since(version = 0.3.0-dev) + type resp-nested-value = list; + + // connection + + /// The errors of a connection and of every command interface. + @since(version = 0.3.0-dev) + variant error-code { + /// The transport closed while a reply was outstanding, or before the command was sent. Holds + /// the transport's error when it did not close gracefully. + closed(option), + /// Client errors are semantic issues with input/output values, e.g. a reply that does not + /// have the shape the command expects. + client(string), + /// RESP errors are encoding issues converting values to/from bytes. + resp(resp-error), + /// Server errors are Error or BulkError RESP values returned directly from the server. An Error + /// is a string, a BulkError a string when it is UTF-8, bytes otherwise. + server(string-or-bytes), + /// The reader of a stream fell too far behind and its stream was closed, rather than + /// holding up the connection. What the stream missed is lost. + lagged, + } + + /// Options for establishing a connection. + @since(version = 0.3.0-dev) + record connect-opts { + /// The username and password to authenticate with. + auth: option>, + /// The name of the connection, see `CLIENT SETNAME`. + client-name: option, + /// Enables keep-alive pings at this interval, see `connection.set-keep-alive`. + keep-alive-interval: option, + } + + /// What a subscription is subscribed to, see `connection.subscription`. + @since(version = 0.3.0-dev) + variant subscription-target { + /// A channel, see SUBSCRIBE. + channel(string), + /// A pattern of channels, see PSUBSCRIBE. + pattern(string), + /// A shard channel, see SSUBSCRIBE. + shardchannel(string), + } + + /// A message published to a channel. + /// + /// A subscription with overlapping targets, e.g. a channel and a pattern matching it, receives + /// a message once for each target. + @since(version = 0.3.0-dev) + record subscription-message { + /// The message as published, UTF-8 or not. + payload: string-or-bytes, + /// The channel the message was published to: a channel, published with PUBLISH, or a shard + /// channel, published with SPUBLISH. Channels and shard channels are separate, a channel + /// and a shard channel of the same name are not the same. `target` tells them apart, a + /// `shardchannel` target for a shard channel, a `channel` or `pattern` target otherwise. + channel: string, + /// The subscription's target that matched the channel, the channel itself, or a pattern + /// matching it. + target: subscription-target, + } + + /// Why a target is no longer subscribed to. + @since(version = 0.3.0-dev) + enum unsubscribe-reason { + /// By the subscription, e.g. its `unsubscribe`. + requested, + /// By the server on its own, e.g. the slot of a shard channel moved to another node. + by-server, + /// By `connection.reset`. + reset, + } + + /// What a subscription's stream carries, in the order it happened. + @since(version = 0.3.0-dev) + variant subscription-event { + /// The server confirmed the target, its messages may arrive from now on. + subscribed(subscription-target), + /// The target is no longer subscribed to, none of its messages arrive after this. + unsubscribed(tuple), + message(subscription-message), + } + + // shared by the commands + + /// A duration in whole seconds, e.g. `EX seconds`. + @since(version = 0.3.0-dev) + record duration-seconds { + ex: u64, + } + + /// A duration in whole milliseconds, e.g. `PX milliseconds`. + @since(version = 0.3.0-dev) + record duration-milliseconds { + px: u64, + } + + /// A point in time as whole seconds since the Unix epoch, e.g. `EXAT unix-time-seconds`. + @since(version = 0.3.0-dev) + record unix-time-seconds { + exat: s64, + } + + /// A point in time as whole milliseconds since the Unix epoch, e.g. `PXAT + /// unix-time-milliseconds`. + @since(version = 0.3.0-dev) + record unix-time-milliseconds { + pxat: s64, + } + + /// Where an incremental iteration continues from, e.g. with HSCAN. + /// + /// A scan returns the cursor to resume from, always `resume`, none once the scan is complete. + /// Pass it to the next call until then. + @since(version = 0.3.0-dev) + variant cursor { + /// Starts a new scan, the cursor `0`. + start, + /// Continues a scan from the opaque cursor the previous call returned, `0` fails with a client + /// error, use `start`. + resume(string), + } + + /// The condition for setting an expiration, e.g. with EXPIRE or HEXPIRE. + @since(version = 0.3.0-dev) + enum ttl-mode { + /// Set expiration only when the field has no expiration. + nx, + /// Set expiration only when the field has an existing expiration. + xx, + /// Set expiration only when the new expiration is greater than current one. + gt, + /// Set expiration only when the new expiration is less than current one. + lt, + } + + /// The remaining time to live of a key or hash field, to the second, see `ttl` and `httl`. + @since(version = 0.3.0-dev) + variant ttl-reply { + /// -2: the key does not exist, or for a hash field, the field or the hash does not exist. + does-not-exist, + /// -1: the key or field exists, but has no expiration. + no-expiration, + /// The time until it expires. + expires-in(duration-seconds), + } + + /// The remaining time to live of a field, see `httl`. + @since(version = 0.3.0-dev) + type httl-reply = ttl-reply; + + /// The remaining time to live of a key or hash field, to the millisecond, see `pttl` and `hpttl`. + @since(version = 0.3.0-dev) + variant pttl-reply { + /// -2: the key does not exist, or for a hash field, the field or the hash does not exist. + does-not-exist, + /// -1: the key or field exists, but has no expiration. + no-expiration, + /// The time until it expires. + expires-in(duration-milliseconds), + } + + /// The remaining time to live of a field, see `hpttl`. + @since(version = 0.3.0-dev) + type hpttl-reply = pttl-reply; + + /// When a key or hash field expires, to the second, see `expiretime` and `hexpiretime`. + @since(version = 0.3.0-dev) + variant expiretime-reply { + /// -2: the key does not exist, or for a hash field, the field or the hash does not exist. + does-not-exist, + /// -1: the key or field exists, but has no expiration. + no-expiration, + /// The time at which it expires. + expires-at(unix-time-seconds), + } + + /// The expiration time of a field, see `hexpiretime`. + @since(version = 0.3.0-dev) + type hexpiretime-reply = expiretime-reply; + + /// When a key or hash field expires, to the millisecond, see `pexpiretime` and `hpexpiretime`. + @since(version = 0.3.0-dev) + variant pexpiretime-reply { + /// -2: the key does not exist, or for a hash field, the field or the hash does not exist. + does-not-exist, + /// -1: the key or field exists, but has no expiration. + no-expiration, + /// The time at which it expires. + expires-at(unix-time-milliseconds), + } + + /// The expiration time of a field, see `hpexpiretime`. + @since(version = 0.3.0-dev) + type hpexpiretime-reply = pexpiretime-reply; + + /// A range of a sorted result, `LIMIT offset count`. + @since(version = 0.3.0-dev) + record limit { + /// The number of elements skipped. + offset: integer, + /// The maximum number of elements returned, negative for all of them. + count: integer, + } + + @since(version = 0.3.0-dev) + enum sort-order { + /// ascending, smallest to largest. + asc, + /// descending, largest to smallest. + desc, + } + + // admin commands + + /// Whether a user may run a command, see `acl-dryrun`. + @since(version = 0.3.0-dev) + variant acl-dryrun-result { + /// The user may run the command. + allowed, + /// The user may not run the command, and why. + denied(string), + } + + /// A security event, a command or authentication the rules denied, see `acl-log`. + @since(version = 0.3.0-dev) + record acl-log-entry { + /// The number of events like this one, within 60 seconds of each other. + count: integer, + /// What was denied, e.g. `command`, `key`, `channel` or `auth`. + reason: string, + /// Where it was denied, e.g. `toplevel`, `multi` or `lua`. + context: string, + /// The command, key or channel that was denied. + object: string, + username: string, + /// The seconds since the latest of these events. + age-seconds: double, + /// The connection, in the format of CLIENT LIST. + client-info: string, + /// The ID of the entry, since Valkey 7.2. + entry-id: option, + /// When the first of these events happened, since Valkey 7.2. + timestamp-created: option, + /// When the latest of these events happened, since Valkey 7.2. + timestamp-last-updated: option, + } + + /// What BGSAVE does. + @since(version = 0.3.0-dev) + enum bgsave-operation { + /// `SCHEDULE`: saves once the rewrite of the append-only file in progress is done. + schedule, + /// `CANCEL`: cancels the save in progress, or scheduled. + cancel, + } + + /// The type of a connection, for the `client-filter`. + @since(version = 0.3.0-dev) + enum client-type { + /// `normal` + normal, + /// `master`: the connection to the primary of a replica. + primary, + /// `replica` + replica, + /// `pubsub` + pubsub, + } + + /// A filter of connections, for CLIENT KILL and CLIENT LIST. + @since(version = 0.3.0-dev) + variant client-filter { + /// `ID client-id [ client-id ... ]` + id(list), + /// `TYPE` + %type(client-type), + /// `USER username` + user(string), + /// `ADDR ip:port`: the address of the client. + addr(string), + /// `LADDR ip:port`: the address of the server the client connected to. + laddr(string), + /// `SKIPME YES | NO`: whether to skip this connection, yes by default for CLIENT KILL. + skipme(bool), + /// `MAXAGE`: connections older than the time. + maxage(duration-seconds), + /// `NAME` + name(string), + /// `IDLE`: connections idle for at least the time. + idle(duration-seconds), + /// `FLAGS`: connections with all the flags, e.g. `N`. + %flags(string), + /// `LIB-NAME` + lib-name(string), + /// `LIB-VER` + lib-ver(string), + /// `DB` + db(integer), + /// `CAPA`: connections with the capability, e.g. `redirect`. + capa(string), + /// `IP` + ip(string), + /// `NOT-ID client-id [ client-id ... ]` + not-id(list), + /// `NOT-TYPE` + not-type(client-type), + /// `NOT-USER` + not-user(string), + /// `NOT-ADDR` + not-addr(string), + /// `NOT-LADDR` + not-laddr(string), + /// `NOT-NAME` + not-name(string), + /// `NOT-FLAGS` + not-flags(string), + /// `NOT-LIB-NAME` + not-lib-name(string), + /// `NOT-LIB-VER` + not-lib-ver(string), + /// `NOT-DB` + not-db(integer), + /// `NOT-CAPA` + not-capa(string), + /// `NOT-IP` + not-ip(string), + } + + /// Which commands CLIENT PAUSE suspends. + @since(version = 0.3.0-dev) + enum client-pause-mode { + /// `WRITE`: commands that may write. + write, + /// `ALL`: every command, the default. + all, + } + + /// How CLIENT UNBLOCK unblocks a connection. + @since(version = 0.3.0-dev) + enum client-unblock-type { + /// `TIMEOUT`: as if the command timed out, the default. + timeout, + /// `ERROR`: with an `UNBLOCKED` error. + error, + } + + /// The outcome of CLUSTER BUMPEPOCH. + @since(version = 0.3.0-dev) + variant cluster-bumpepoch-result { + /// `BUMPED`: the config epoch was advanced, to this one. + bumped(integer), + /// `STILL`: the config epoch is already the greatest in the cluster, this one. + still(integer), + } + + /// How CLUSTER FAILOVER fails over. + @since(version = 0.3.0-dev) + enum cluster-failover-mode { + /// `FORCE`: without the agreement of the primary, which may be down. + force, + /// `TAKEOVER`: without the agreement of the primary or the rest of the cluster. + takeover, + } + + /// Which way a slot migration moves the slots. + @since(version = 0.3.0-dev) + enum cluster-getslotmigrations-operation { + /// `IMPORT`: to this node. + %import, + /// `EXPORT`: from this node. + %export, + } + + /// A slot migration, see `cluster-getslotmigrations`. + @since(version = 0.3.0-dev) + record cluster-getslotmigrations-entry { + /// The ID of the migration. + link-name: string, + operation: cluster-getslotmigrations-operation, + /// The ranges of slots, start and end both included. + slot-ranges: list>, + /// The ID of the node the slots move to. + target-node: string, + /// The ID of the node the slots move from. + source-node: string, + create-time: unix-time-seconds, + last-update-time: unix-time-seconds, + last-ack-time: unix-time-seconds, + /// The state of the migration, for people to read. + state: string, + /// More details of the state, for people to read. + message: string, + /// The bytes copied on write while forked for the migration. + cow-size: integer, + } + + /// Ranges of slots to migrate to a node, see `cluster-migrateslots`. + @since(version = 0.3.0-dev) + record cluster-migrateslots-group { + /// The ranges of slots, start and end both included. + ranges: list>, + /// The ID of the node to migrate the slots to. + node-id: string, + /// `AUTH username password`: the credentials for the node, since Valkey 9.2. + auth: option>, + } + + /// How CLUSTER RESET resets a node. + @since(version = 0.3.0-dev) + enum cluster-reset-mode { + /// `HARD`: also generates a new node ID and resets the epochs. + hard, + /// `SOFT`: the default. + soft, + } + + /// The state to set a slot to, see `cluster-setslot`. + @since(version = 0.3.0-dev) + variant cluster-setslot-state { + /// `IMPORTING node-id`: importing the slot from the node. + importing(string), + /// `MIGRATING node-id`: migrating the slot to the node. + migrating(string), + /// `NODE node-id`: assigned to the node. + node(string), + /// `STABLE`: neither importing nor migrating. + stable, + } + + /// A command log, see `commandlog-get`. + @since(version = 0.3.0-dev) + enum commandlog-type { + /// `slow`: commands that took longer than `commandlog-execution-slower-than`. + slow, + /// `large-request`: commands larger than `commandlog-request-larger-than`. + large-request, + /// `large-reply`: replies larger than `commandlog-reply-larger-than`. + large-reply, + } + + /// An entry of the slow log or a command log. + @since(version = 0.3.0-dev) + record commandlog-entry { + /// The ID of the entry, unique until the server restarts. + id: integer, + /// When the command ran. + timestamp: unix-time-seconds, + /// The microseconds the command took, or for the large logs, the bytes of the request or + /// reply. + value: integer, + /// The command and its arguments, long ones shortened. + arguments: list, + /// The client's address, `ip:port`. + client-address: string, + /// The client's name, see CLIENT SETNAME, empty when it has none. + client-name: string, + } + + /// An entry of the slow log, see `slowlog-get`. + @since(version = 0.3.0-dev) + type slowlog-entry = commandlog-entry; + + /// Where FAILOVER fails over to. + @since(version = 0.3.0-dev) + record failover-target { + host: string, + port: u16, + /// `FORCE`: fails over at the timeout, even before the replica has caught up. + force: bool, + } + + /// The latency histogram of a command, see `latency-histogram`. + @since(version = 0.3.0-dev) + record latency-histogram-entry { + /// The number of times the command ran. + calls: integer, + /// The cumulative counts of the calls that took up to each number of microseconds, a power + /// of 2. + histogram-usec: list>, + } + + /// The latest sample of the latency of an event, see `latency-latest`. + @since(version = 0.3.0-dev) + record latency-latest-entry { + event: string, + /// When the latest sample was taken. + timestamp: unix-time-seconds, + latest: duration-milliseconds, + /// The highest latency since the server started, or the event was reset. + max: duration-milliseconds, + /// The sum of the latencies of the samples. + sum: duration-milliseconds, + /// The number of samples. + count: integer, + } + + /// A loaded module, see `module-list`. + @since(version = 0.3.0-dev) + record module-list-entry { + name: string, + /// The module's version. + ver: integer, + /// The path the module was loaded from. + path: string, + /// The arguments the module was loaded with. + args: list, + } + + /// A replica of a primary, see `role`. + @since(version = 0.3.0-dev) + record role-primary-replica { + ip: string, + port: u16, + /// The replication offset the replica last acknowledged. + offset: integer, + } + + /// The replication state of a primary, see `role`. + @since(version = 0.3.0-dev) + record role-primary { + /// The primary's replication offset. + offset: integer, + replicas: list, + } + + /// The replication state of a replica, see `role`. + @since(version = 0.3.0-dev) + record role-replica { + primary-host: string, + primary-port: u16, + /// The state of replication, e.g. `connected`, `connecting` or `sync`. + state: string, + /// The replication offset of the data received from the primary, -1 when not connected. + offset: integer, + } + + /// The server's role in replication, see `role`. + @since(version = 0.3.0-dev) + variant role-reply { + /// `master` + primary(role-primary), + /// `slave` + replica(role-replica), + /// `sentinel`: the names of the primaries it monitors. + sentinel(list), + } + + /// The modifiers of SHUTDOWN. + @since(version = 0.3.0-dev) + flags shutdown-flags { + /// `NOW`: does not wait for lagging replicas. + now, + /// `FORCE`: ignores errors that would prevent the shutdown, e.g. failing to save. + force, + /// `SAFE`: refuses to shut down while it would lose the cluster's quorum of primaries. + safe, + /// `FAILOVER`: fails over to a replica first. + failover, + } + + /// Whether SHUTDOWN saves to disk. + @since(version = 0.3.0-dev) + enum shutdown-save { + /// `NOSAVE`: does not save, even with save points configured. + nosave, + /// `SAVE`: saves, even without save points configured. + save, + } + + /// A user and its rules, see `acl-list`. + @since(version = 0.3.0-dev) + record acl-list-entry { + username: string, + /// The user's rules, as the server normalized them, e.g. `allkeys` is `~*`. Applied with + /// `acl-setuser`, after `reset`, they recreate the user. + rules: list, + } + + /// A rule of an ACL user, see `acl-setuser`. + /// + /// https://valkey.io/topics/acl/ + @since(version = 0.3.0-dev) + variant acl-rule { + /// The keys the user may access. + keys(acl-key-rule), + /// The Pub/Sub channels the user may access. + channel(acl-channel-rule), + /// The commands the user may call. + command(acl-command-rule), + /// The databases the user may access. + db(acl-db-rule), + /// The user itself, e.g. whether it is enabled and its passwords. + user(acl-user-rule), + /// A rule this interface does not know, e.g. from a newer server, sent as is. + other(string), + } + + /// A rule of a selector, a set of permissions matched independently of the user's root permissions, + /// the user may run a command when either permits it. + @since(version = 0.3.0-dev) + variant acl-selector-rule { + /// The keys the selector permits access to. + keys(acl-key-rule), + /// The Pub/Sub channels the selector permits access to. + channel(acl-channel-rule), + /// The commands the selector permits. + command(acl-command-rule), + /// The databases the selector permits access to. + db(acl-db-rule), + /// A rule this interface does not know, e.g. from a newer server, sent as is. + other(string), + } + + /// A rule for the keys a user may access. + @since(version = 0.3.0-dev) + variant acl-key-rule { + /// `~`: allows reading and writing the keys matching the glob-style pattern. + add(string), + /// `%R~`: allows reading the keys matching the glob-style pattern. + add-read(string), + /// `%W~`: allows writing the keys matching the glob-style pattern. + add-write(string), + /// `%RW~`: allows reading and writing the keys matching the glob-style pattern, the + /// same as `add`. + add-read-write(string), + /// `allkeys`: allows access to all keys, an alias for `~*`. + all, + /// `resetkeys`: removes all the allowed key patterns. + reset, + } + + /// A rule for the Pub/Sub channels a user may access. + @since(version = 0.3.0-dev) + variant acl-channel-rule { + /// `&`: allows access to the Pub/Sub channels matching the glob-style pattern. + add(string), + /// `allchannels`: allows access to all Pub/Sub channels, an alias for `&*`. + all, + /// `resetchannels`: removes all the allowed channel patterns, disconnecting the user's Pub/Sub + /// clients that can no longer access their channels. + reset, + } + + /// A rule for the commands a user may call. + @since(version = 0.3.0-dev) + variant acl-command-rule { + /// `+`: allows the command, or a subcommand, e.g. `config|get`. + add(string), + /// `+@`: allows all the commands in the category, e.g. `read`, see ACL CAT for the + /// categories. + add-category(string), + /// `+|`: allows the command only with the given first argument, e.g. `select` + /// with `0`. + add-first-arg(tuple), + /// `allcommands`: allows all commands, including those of modules loaded later, an alias for `+@all`. + all, + /// `-`: disallows the command, or a subcommand. + remove(string), + /// `-@`: disallows all the commands in the category. + remove-category(string), + /// `nocommands`: disallows all commands, an alias for `-@all`. + reset, + } + + /// A rule for the databases a user may access. + @since(version = 0.3.0-dev) + variant acl-db-rule { + /// `db=[,...]`: allows access to the databases with the given IDs. + db(list), + /// `alldbs`: allows access to all databases. + all, + /// `resetdbs`: removes all the allowed databases. + reset, + } + + /// A rule for the user itself. + @since(version = 0.3.0-dev) + variant acl-user-rule { + /// `on`: enables the user, it is possible to authenticate as this user. + on, + /// `off`: disables the user, it is no longer possible to authenticate as this user, connections + /// already authenticated keep working. + off, + /// `nopass`: removes all the user's passwords, any password then authenticates as the user. + nopass, + /// `sanitize-payload`: validates the payloads of the user's RESTORE commands, the default. + sanitize-payload, + /// `skip-sanitize-payload`: skips validating the payloads of the user's RESTORE commands. + skip-sanitize-payload, + /// `>`: adds the password to the user's passwords. + password(string), + /// `#`: adds a password, by its SHA-256 hash as 64 lowercase hex characters, to the user's + /// passwords. + hashed-password(string), + /// `<`: removes the password from the user's passwords. + remove-password(string), + /// `!`: removes a password, by its SHA-256 hash, from the user's passwords. + remove-hashed-password(string), + /// `( ...)`: adds a selector, with the given rules. + selector(list), + /// `clearselectors`: removes all the user's selectors. + clearselectors, + /// `reset`: resets the user to its state when created, disabled, without passwords, keys, + /// channels, commands or selectors. + reset, + } + + // bitmap commands + + /// A range of a string, negative from the end, for BITCOUNT and BITPOS. + @since(version = 0.3.0-dev) + variant bit-range { + /// `start`: the bytes from the start to the end of the string. + %from(integer), + /// `start end`: the bytes from the start to the end, both included. + bytes(tuple), + /// `start end BIT`: the bits from the start to the end, both included. + bits(tuple), + } + + /// The type of an integer field of a string, for BITFIELD. + @since(version = 0.3.0-dev) + variant bitfield-encoding { + /// `i1` to `i64`: a signed integer of the width in bits. + signed(u8), + /// `u1` to `u63`: an unsigned integer of the width in bits. + unsigned(u8), + } + + /// Where an integer field of a string starts, for BITFIELD. + @since(version = 0.3.0-dev) + variant bitfield-offset { + /// The offset in bits. + bit(u64), + /// `#field`: the field of the encoding's width, the offset in bits is the field times the + /// width. + field(u64), + } + + /// What a write does when the field overflows, `OVERFLOW`. + @since(version = 0.3.0-dev) + enum bitfield-overflow { + /// `WRAP`: wraps around, the default. + wrap, + /// `SAT`: saturates at the minimum or maximum. + sat, + /// `FAIL`: does not write, the result is none. + fail, + } + + /// `GET encoding offset`: the value of the field. + @since(version = 0.3.0-dev) + record bitfield-get { + encoding: bitfield-encoding, + offset: bitfield-offset, + } + + /// A GET operation, see `bitfield-ro`. + @since(version = 0.3.0-dev) + type bitfield-ro-get = bitfield-get; + + /// `SET encoding offset value`: sets the field, the result is its previous value. + @since(version = 0.3.0-dev) + record bitfield-set { + encoding: bitfield-encoding, + offset: bitfield-offset, + value: s64, + } + + /// `INCRBY encoding offset increment`: increments the field, the result is its new value. + @since(version = 0.3.0-dev) + record bitfield-incrby { + encoding: bitfield-encoding, + offset: bitfield-offset, + increment: s64, + } + + /// An operation of BITFIELD. + /// + /// A write is preceded by an optional overflow mode, `OVERFLOW WRAP | SAT | FAIL`, for it and + /// the following writes. None keeps the current mode, `WRAP` at the start of the command. + @since(version = 0.3.0-dev) + variant bitfield-operation { + /// `GET encoding offset` + get(bitfield-get), + /// `[ OVERFLOW WRAP | SAT | FAIL ] SET encoding offset value` + set(tuple, bitfield-set>), + /// `[ OVERFLOW WRAP | SAT | FAIL ] INCRBY encoding offset increment` + incrby(tuple, bitfield-incrby>), + } + + /// A bitwise operation of BITOP, and its source keys. + @since(version = 0.3.0-dev) + variant bitop-operation { + /// `AND` + and(list), + /// `OR` + or(list), + /// `XOR` + xor(list), + /// `NOT`: of a single key. + not(string), + } + + // connection commands + + /// How a key spec finds the first key, see `command-key-spec`. + @since(version = 0.3.0-dev) + variant command-key-spec-begin-search { + /// `index`: the argument at the index. + index(integer), + /// `keyword`: the argument after the keyword. + keyword(command-key-spec-keyword), + /// `unknown`: the keys cannot be found from the arguments alone. + unknown, + } + + /// The keyword before the first key, see `command-key-spec-begin-search`. + @since(version = 0.3.0-dev) + record command-key-spec-keyword { + keyword: string, + /// The index of the argument to start searching from, from the end when negative. + startfrom: integer, + } + + /// How a key spec finds the keys after the first one, see `command-key-spec`. + @since(version = 0.3.0-dev) + variant command-key-spec-find-keys { + /// `range`: a range of arguments. + range(command-key-spec-range), + /// `keynum`: the number of keys is an argument. + keynum(command-key-spec-keynum), + /// `unknown`: the keys cannot be found from the arguments alone. + unknown, + } + + /// A range of keys, see `command-key-spec-find-keys`. + @since(version = 0.3.0-dev) + record command-key-spec-range { + /// The index of the last key, relative to the first, from the end when negative. + lastkey: integer, + /// The number of arguments from one key to the next. + keystep: integer, + /// With a `lastkey` of -1, divides the remaining arguments, e.g. 2 for half of them. + limit: integer, + } + + /// A number of keys, see `command-key-spec-find-keys`. + @since(version = 0.3.0-dev) + record command-key-spec-keynum { + /// The index of the argument with the number of keys, relative to the first. + keynumidx: integer, + /// The index of the first key, relative to the first. + firstkey: integer, + /// The number of arguments from one key to the next. + keystep: integer, + } + + /// Where a command's keys are and how it uses them. + @since(version = 0.3.0-dev) + record command-key-spec { + notes: option, + /// How the command uses the keys, e.g. `RW`, `access` and `update`. + %flags: list, + begin-search: command-key-spec-begin-search, + find-keys: command-key-spec-find-keys, + } + + /// A command, see `command` and `command-info`. + @since(version = 0.3.0-dev) + record command-info-reply { + /// The command's name, e.g. `get`, or for a subcommand, `config|get`. + name: string, + /// The number of arguments, the name included, the minimum when negative. + arity: integer, + /// The command's flags, e.g. `readonly` and `fast`. + %flags: list, + /// The index of the first key, 0 for none. + first-key: integer, + /// The index of the last key, from the end when negative. + last-key: integer, + /// The number of arguments from one key to the next. + step: integer, + /// The ACL categories of the command, e.g. `@read`. + acl-categories: list, + /// Hints for clients, e.g. `nondeterministic_output`. + tips: list, + key-specs: list, + } + + /// The details of a command, see `command`. + @since(version = 0.3.0-dev) + type command-reply = command-info-reply; + + /// The type of an argument of a command, see `command-docs-argument`. + @since(version = 0.3.0-dev) + variant command-docs-argument-type { + /// `string` + %string, + /// `integer` + integer, + /// `double` + double, + /// `key`: a key, see `key-spec-index`. + key, + /// `pattern`: a glob-style pattern. + pattern, + /// `unix-time` + unix-time, + /// `pure-token`: the token alone, without a value. + pure-token, + /// `oneof`: one of the arguments it contains. + oneof, + /// `block`: the arguments it contains, together. + block, + /// A type this interface does not know, e.g. from a newer server. + other(string), + } + + /// An argument of a command, see `command-docs`. + @since(version = 0.3.0-dev) + record command-docs-argument { + /// The index of the `oneof` or `block` argument containing this one, in the command's + /// arguments, none at the top level. + parent: option, + name: string, + %type: command-docs-argument-type, + /// The name to show for the argument. + display-text: option, + /// The index of the argument's key spec, for a key. + key-spec-index: option, + /// The token before the argument's value, e.g. `EX`, or for a `pure-token`, the token alone. + token: option, + summary: option, + /// The version that added the argument. + since: option, + deprecated-since: option, + /// The argument's flags, e.g. `optional`, `multiple` and `multiple_token`. + %flags: list, + } + + /// The documentation of a command, see `command-docs`. + @since(version = 0.3.0-dev) + record command-docs-reply { + /// The command's name, e.g. `get`, or for a subcommand, `config|get`. + name: string, + summary: string, + /// The version that added the command. + since: string, + /// The command's group, e.g. `string`. + group: string, + /// The command's time complexity, none for a few commands, e.g. MONITOR. + complexity: option, + /// The module of the command, for a module's command. + module: option, + /// The command's documentation flags, e.g. `deprecated` or `syscmd`. + doc-flags: list, + deprecated-since: option, + /// The command to use instead, for a deprecated command. + replaced-by: option, + /// The changes to the command, the version of each and what changed. + history: list>, + /// The arguments, depth first, those an argument contains after it. + arguments: list, + } + + /// Information about the library of a connection, see `client-setinfo`. + @since(version = 0.3.0-dev) + variant client-setinfo-attr { + /// `LIB-NAME`: the name of the library, e.g. `valkey-wasm`. + lib-name(string), + /// `LIB-VER`: the version of the library. + lib-ver(string), + } + + /// Which keys tracking tracks without CLIENT CACHING. + @since(version = 0.3.0-dev) + enum client-tracking-mode { + /// `OPTIN`: none, only those of commands after CLIENT CACHING YES. + optin, + /// `OPTOUT`: all, except those of commands after CLIENT CACHING NO. + optout, + } + + /// The options of CLIENT TRACKING ON. + @since(version = 0.3.0-dev) + record client-tracking-options { + /// `PREFIX prefix`: with `bcast`, only notifies about keys with the prefixes. + prefixes: option>, + /// `BCAST`: notifies about every change of the keys with the prefixes, read or not. + bcast: bool, + mode: option, + /// `NOLOOP`: does not notify about the keys the connection changed itself. + noloop: bool, + } + + /// Where a connection's tracking notifications are redirected, see `client-trackinginfo`. + /// + /// Redirecting is for RESP2, which the connection does not use, but CLIENT TRACKING REDIRECT may + /// still be sent with `connection.exchange`. + @since(version = 0.3.0-dev) + variant client-trackinginfo-redirect { + /// -1: tracking is not on. + not-tracking, + /// 0: tracking is on, the notifications are not redirected. + not-redirected, + /// The ID of the connection the notifications are redirected to. + redirected(integer), + } + + /// A connection's tracking state, see `client-trackinginfo`. + @since(version = 0.3.0-dev) + record client-trackinginfo-reply { + /// The tracking flags, e.g. `on`, `bcast`, `optin` or `noloop`. + %flags: list, + /// Where the notifications are redirected. + redirect: client-trackinginfo-redirect, + /// The prefixes of BCAST. + prefixes: list, + } + + /// The filter of COMMAND LIST. + @since(version = 0.3.0-dev) + variant command-list-filter { + /// `MODULE module-name`: the commands of the module. + module(string), + /// `ACLCAT category`: the commands in the ACL category. + aclcat(string), + /// `PATTERN pattern`: the commands matching the glob-style pattern. + pattern(string), + } + + // geo commands + + /// A position on the Earth. + /// + /// The exact limits, as specified by EPSG:900913 / EPSG:3785 / OSGEO:41001 + @since(version = 0.3.0-dev) + record geo-coordinates { + /// The longitude, from -180 to 180 degrees. + longitude: double, + /// The latitude, from -85.05112878 to 85.05112878 degrees. + latitude: double, + } + + /// A unit of distance. + @since(version = 0.3.0-dev) + enum geo-unit { + /// `m`: meters. + m, + /// `km`: kilometers. + km, + /// `ft`: feet. + ft, + /// `mi`: miles. + mi, + } + + /// Only add or only update members, GEOADD's `NX` and `XX`. + @since(version = 0.3.0-dev) + enum geoadd-condition { + /// Only add new members, never update existing ones. + nx, + /// Only update existing members, never add new ones. + xx, + } + + /// The center of a search, `FROMMEMBER` or `FROMLONLAT`. + @since(version = 0.3.0-dev) + variant geosearch-from { + /// `FROMMEMBER member`: the position of a member of the index. + member(string-or-bytes), + /// `FROMLONLAT longitude latitude` + lonlat(geo-coordinates), + } + + /// `BYRADIUS`: a circle around the center. + @since(version = 0.3.0-dev) + record geosearch-radius { + %from: geosearch-from, + radius: double, + unit: geo-unit, + } + + /// `BYBOX`: a rectangle around the center. + @since(version = 0.3.0-dev) + record geosearch-box { + %from: geosearch-from, + width: double, + height: double, + unit: geo-unit, + } + + /// The area of a search. + @since(version = 0.3.0-dev) + variant geosearch-shape { + /// `[ FROMMEMBER member | FROMLONLAT longitude latitude ] BYRADIUS radius unit` + radius(geosearch-radius), + /// `[ FROMMEMBER member | FROMLONLAT longitude latitude ] BYBOX width height unit` + box(geosearch-box), + /// `BYPOLYGON num-vertices longitude latitude [ longitude latitude ... ]`: the polygon with + /// the vertices, in order, which has no center. + polygon(list), + } + + /// The area to search, see `geosearchstore`. + @since(version = 0.3.0-dev) + type geosearchstore-shape = geosearch-shape; + + /// At most a count of the members, `COUNT count [ ANY ]`. + @since(version = 0.3.0-dev) + record geosearch-count { + count: integer, + /// `ANY`: returns as soon as enough members are found, rather than the nearest ones. + any: bool, + } + + /// The number of members to store, see `geosearchstore`. + @since(version = 0.3.0-dev) + type geosearchstore-count = geosearch-count; + + /// The details GEOSEARCH returns of each member it found. + @since(version = 0.3.0-dev) + flags geosearch-with-flags { + /// `WITHCOORD`: the position. + withcoord, + /// `WITHDIST`: the distance from the center. + withdist, + /// `WITHHASH`: the geohash, as an integer. + withhash, + } + + /// A member a search found, with the details asked for. + @since(version = 0.3.0-dev) + record geosearch-with-result { + member: string-or-bytes, + /// `WITHDIST`: the distance from the center, in the unit of the shape. + distance: option, + /// `WITHHASH`: the geohash, as an integer. + hash: option, + /// `WITHCOORD`: the position. + coordinates: option, + } + + // hash commands + + /// The outcome of removing the expiration of a hash field. + @since(version = 0.3.0-dev) + enum hpersist-result { + /// -2: the field does not exist, or the hash does not exist. + does-not-exist, + /// -1: the field exists, but has no expiration. + no-expiration, + /// 1: the expiration was removed. + removed, + } + + /// Only set the fields when the hash does, or does not, exist, HSETEX's `NX` and `XX`. + @since(version = 0.3.0-dev) + enum hsetex-condition { + /// Only set the fields when the hash does not exist. + nx, + /// Only set the fields when the hash exists. + xx, + } + + /// Only set the fields when none, or all, of them exist, HSETEX's `FNX` and `FXX`. + @since(version = 0.3.0-dev) + enum hsetex-field-condition { + /// Only set the fields when none of them exist. + fnx, + /// Only set the fields when all of them exist. + fxx, + } + + /// The outcome of setting the expiration of a hash field, e.g. with HEXPIRE. + @since(version = 0.3.0-dev) + enum hexpire-result { + /// -2: key does not exist, or field does not exist in the HASH. + does-not-exist, + /// 0: the specified NX | XX | GT | LT condition has not been met. + condition-not-met, + /// 1: the expiration time was applied. + applied, + /// 2: when called with 0 seconds. + zero-seconds, + } + + /// The result for a field, see `hexpireat`. + @since(version = 0.3.0-dev) + type hexpireat-result = hexpire-result; + + /// The result for a field, see `hpexpire`. + @since(version = 0.3.0-dev) + type hpexpire-result = hexpire-result; + + /// The result for a field, see `hpexpireat`. + @since(version = 0.3.0-dev) + type hpexpireat-result = hexpire-result; + + // keyspace commands + + /// The expiration of a key `restore` creates. + @since(version = 0.3.0-dev) + variant restore-ttl { + /// `ttl`: the key expires after the duration. + expire-in(duration-milliseconds), + /// `ttl` with `ABSTTL`: the key expires at the time. + expire-at(unix-time-milliseconds), + } + + /// The type of the value stored at a key. + @since(version = 0.3.0-dev) + variant key-type { + /// `string` + %string, + /// `list` + %list, + /// `set` + set, + /// `zset`: a sorted set. + zset, + /// `hash` + hash, + /// `stream` + %stream, + /// The type of a module, by name. + other(string), + } + + /// The options of MIGRATE. + @since(version = 0.3.0-dev) + flags migrate-flags { + /// `COPY`: keeps the keys in this instance. + copy, + /// `REPLACE`: replaces keys that exist in the other instance. + replace, + } + + /// The credentials to authenticate to the other instance with, for MIGRATE. + @since(version = 0.3.0-dev) + variant migrate-auth { + /// `AUTH password` + password(string), + /// `AUTH2 username password` + username-password(tuple), + } + + /// How SORT orders and projects the elements of a list, set or sorted set. + @since(version = 0.3.0-dev) + record sort-opts { + /// `BY pattern`: sorts by the values of the keys the pattern names, `*` replaced with each + /// element, or `nosort` to skip sorting. + by: option, + /// `LIMIT offset count`: a range of the sorted elements. + limit: option, + /// `GET pattern`: returns the values of the keys each pattern names instead of the elements, + /// `#` for the element itself. + get: option>, + /// order to sort elements. + order: option, + /// `ALPHA`: sorts lexicographically, rather than numerically. + alpha: bool, + } + + /// The options of the sort, see `sort-ro`. + @since(version = 0.3.0-dev) + type sort-ro-opts = sort-opts; + + // list commands + + /// An end of a list. + @since(version = 0.3.0-dev) + enum list-side { + /// `LEFT`: the head, the first element. + left, + /// `RIGHT`: the tail, the last element. + right, + } + + /// Where LINSERT inserts an element, relative to the pivot. + @since(version = 0.3.0-dev) + enum linsert-position { + /// `BEFORE`: in front of the pivot. + before, + /// `AFTER`: behind the pivot. + after, + } + + /// The outcome of LINSERT. + @since(version = 0.3.0-dev) + variant linsert-result { + /// The element was inserted, the length of the list after it. + inserted(integer), + /// 0: the key does not exist, nothing was inserted. + no-such-key, + /// -1: the pivot is not in the list, nothing was inserted. + pivot-not-found, + } + + // scripting commands + + /// What `function-restore` does with the libraries already loaded. + @since(version = 0.3.0-dev) + enum function-restore-policy { + /// `FLUSH`: deletes them first. + flush, + /// `APPEND`: keeps them, failing when a library of the same name is loaded. + append, + /// `REPLACE`: keeps them, replacing those with a library of the same name. + replace, + } + + /// Whether a flush happens in the background, `ASYNC`, or before replying, `SYNC`. + @since(version = 0.3.0-dev) + enum flush-mode { + /// `ASYNC` + %async, + /// `SYNC` + sync, + } + + /// A function of a library. + @since(version = 0.3.0-dev) + record function-list-function { + name: string, + description: option, + /// The function's flags, e.g. `no-writes`. + %flags: list, + } + + /// A library of functions. + @since(version = 0.3.0-dev) + record function-list-library { + library-name: string, + /// The engine that runs the library, e.g. `LUA`. + engine: string, + functions: list, + /// The library's source code, with `withcode`. + library-code: option, + } + + /// The function that is running. + @since(version = 0.3.0-dev) + record function-stats-running { + name: string, + /// The command and its arguments that called the function. + command: list, + /// How long the function has been running. + duration: duration-milliseconds, + } + + /// The libraries and functions of an engine. + @since(version = 0.3.0-dev) + record function-stats-engine { + libraries-count: integer, + functions-count: integer, + } + + /// The function that is running, and the libraries and functions of each engine. + @since(version = 0.3.0-dev) + record function-stats-reply { + /// None when no function is running. + running-script: option, + /// The statistics of each engine, by name. + engines: map, + } + + // sortedset commands + + /// A bound of a range of scores. + @since(version = 0.3.0-dev) + variant score-bound { + /// The score, included in the range. + inclusive(double), + /// `(score`: the score, excluded from the range. + exclusive(double), + /// `-inf` as the min, `+inf` as the max, the start is the max with `REV`. + unbounded, + } + + /// A bound of a range of members, for members of equal score, ordered lexicographically. + @since(version = 0.3.0-dev) + variant lex-bound { + /// `[member`: the member, included in the range. + inclusive(string-or-bytes), + /// `(member`: the member, excluded from the range. + exclusive(string-or-bytes), + /// `-` as the min, `+` as the max, the start is the max with `REV`. + unbounded, + } + + /// Which end of a sorted set to pop from. + @since(version = 0.3.0-dev) + enum zmpop-end { + /// The members with the lowest scores. + min, + /// The members with the highest scores. + max, + } + + /// The end to pop from, see `bzmpop`. + @since(version = 0.3.0-dev) + type bzmpop-end = zmpop-end; + + /// How scores combine for a member in several sorted sets. + @since(version = 0.3.0-dev) + enum zset-aggregate { + /// The sum of the weighted scores, the default. + sum, + /// The lowest weighted score. + min, + /// The highest weighted score. + max, + } + + /// The range of ZRANGE and ZRANGESTORE, `start` and `stop`, given as they are sent: with `REV`, + /// the higher bound first. + @since(version = 0.3.0-dev) + variant zrange-by { + /// Positions, both included, negative from the end. + rank(tuple), + /// `BYSCORE`: the members with scores in the range. + score(tuple), + /// `BYLEX`: the members in the range, for members of equal score. + lex(tuple), + } + + /// The range of members to store, see `zrangestore`. + @since(version = 0.3.0-dev) + type zrangestore-by = zrange-by; + + /// The range of ZRANGE WITHSCORES, which does not support BYLEX, see `zrange-by`. + @since(version = 0.3.0-dev) + variant zrange-withscores-by { + /// Positions, both included, negative from the end. + rank(tuple), + /// `BYSCORE`: the members with scores in the range. + score(tuple), + } + + /// Only update or only add members, ZADD's `NX` and `XX`. + @since(version = 0.3.0-dev) + enum zadd-condition { + /// Only add new members, never update existing ones. + nx, + /// Only update existing members, never add new ones. + xx, + } + + /// Only update a member's score when the new score is greater or less, ZADD's `GT` and `LT`. + @since(version = 0.3.0-dev) + enum zadd-comparison { + /// Only update when the new score is greater than the current one. + gt, + /// Only update when the new score is less than the current one. + lt, + } + + // stream commands + + /// The ID of a stream entry, `-`, e.g. `1526919030474-55`. + /// + /// Commands that add an entry also accept `*`, for the server to generate the ID. + @since(version = 0.3.0-dev) + type stream-id = string; + + /// How a stream is trimmed, e.g. with XADD or XTRIM. + @since(version = 0.3.0-dev) + variant stream-trim { + /// Evicts entries as long as the stream's length exceeds the specified threshold, where threshold is a positive integer. + maxlen(stream-trim-maxlen), + /// Evicts entries with IDs lower than threshold, where threshold is a stream ID. + minid(stream-trim-minid), + } + + /// Trims the stream to a maximum length, `MAXLEN [ = | ~ ] threshold [ LIMIT count ]`. + @since(version = 0.3.0-dev) + record stream-trim-maxlen { + /// Exact, `=`, when none. + efficiency: option, + /// The maximum number of entries kept. + threshold: integer, + /// The maximum number of entries evicted, only with nearly exact trimming, `0` for no limit. + limit: option, + } + + /// Trims the entries with lower IDs, `MINID [ = | ~ ] threshold [ LIMIT count ]`. + @since(version = 0.3.0-dev) + record stream-trim-minid { + /// Exact, `=`, when none. + efficiency: option, + /// The lowest ID kept. + threshold: stream-id, + /// The maximum number of entries evicted, only with nearly exact trimming, `0` for no limit. + limit: option, + } + + /// How exactly a stream is trimmed. Nearly exact trimming is more efficient, the stream may keep a + /// few entries more than the threshold. + @since(version = 0.3.0-dev) + enum stream-trim-efficiency { + /// `=`: exact trimming + exact, + /// `~`: nearly exact trimming + mearly-exact, + } + + /// Options of XCLAIM. + @since(version = 0.3.0-dev) + record xclaim-opts { + /// `IDLE ms`: sets the idle time of the entries, the time since they were last delivered, instead + /// of resetting it. + idle: option, + /// `TIME unix-time-milliseconds`: sets the idle time of the entries to the time since this instant. + time: option, + /// `RETRYCOUNT count`: sets the delivery count of the entries, instead of incrementing it. + retrycount: option, + /// `FORCE`: claims the entries even when they are not pending for any consumer, as long as they + /// exist in the stream. + force: bool, + /// `LASTID lastid`: sets the ID of the last entry delivered to the group. + lastid: option, + } + + /// A consumer of a group, see `xinfo-consumers`. + @since(version = 0.3.0-dev) + record xinfo-consumers-entry { + /// The consumer's name. + name: string, + /// The number of entries pending for the consumer. + pending: integer, + /// The time since the consumer last attempted an interaction, e.g. XREADGROUP, XCLAIM or + /// XAUTOCLAIM. + idle: duration-milliseconds, + /// The time since the consumer last interacted successfully, e.g. read or claimed entries. + /// None when the consumer has never been active. + inactive: option, + } + + /// A consumer group of a stream, see `xinfo-groups`. + @since(version = 0.3.0-dev) + record xinfo-groups-entry { + /// The group's name. + name: string, + /// The number of consumers in the group. + consumers: integer, + /// The number of entries in the group's pending entries list (PEL), delivered but not acknowledged. + pending: integer, + /// The ID of the last entry delivered to the group's consumers. + last-delivered-id: stream-id, + /// The logical read counter of the last entry delivered to the group's consumers, none when unknown. + entries-read: option, + /// The number of entries in the stream not yet delivered to the group's consumers, none when it + /// cannot be determined. + lag: option, + } + + /// Information about a stream, see `xinfo-stream`. + @since(version = 0.3.0-dev) + record xinfo-stream-reply { + /// The number of entries in the stream. + length: integer, + /// The number of keys in the radix tree storing the stream. + radix-tree-keys: integer, + /// The number of nodes in the radix tree storing the stream. + radix-tree-nodes: integer, + /// The number of consumer groups of the stream. + groups: integer, + /// The ID of the last entry added to the stream, none when no entry was ever added. + last-generated-id: option, + /// The highest ID deleted from the stream, none when no entry was ever deleted. + max-deleted-entry-id: option, + /// The number of entries added to the stream over its lifetime. + entries-added: integer, + /// The ID and fields of the first entry. + /// None when the stream is empty. + first-entry: option>>, + /// The ID and fields of the last entry. + /// None when the stream is empty. + last-entry: option>>, + } + + /// Information about a stream, its entries and consumer groups, see `xinfo-stream-full`. + @since(version = 0.3.0-dev) + record xinfo-stream-reply-full { + /// The number of entries in the stream. + length: integer, + /// The number of keys in the radix tree storing the stream. + radix-tree-keys: integer, + /// The number of nodes in the radix tree storing the stream. + radix-tree-nodes: integer, + /// The ID of the last entry added to the stream, none when no entry was ever added. + last-generated-id: option, + /// The highest ID deleted from the stream, none when no entry was ever deleted. + max-deleted-entry-id: option, + /// The number of entries added to the stream over its lifetime. + entries-added: integer, + /// The ID and fields of the entries, in ascending ID order, up to the count given. + entries: list>>, + /// The consumer groups of the stream. + groups: list, + } + + /// A consumer group of a stream, with its pending entries and consumers. + @since(version = 0.3.0-dev) + record xinfo-stream-reply-full-group { + /// The group's name. + name: string, + /// The ID of the last entry delivered to the group's consumers. + last-delivered-id: stream-id, + /// The logical read counter of the last entry delivered to the group's consumers, none when unknown. + entries-read: option, + /// The number of entries in the stream not yet delivered to the group's consumers, none when it + /// cannot be determined. + lag: option, + /// The number of entries in the group's pending entries list (PEL), delivered but not acknowledged. + pel-count: integer, + /// The group's pending entries, the ID of each, the consumer it was delivered to, when it was + /// last delivered and the number of times it was delivered. + pending: list>, + /// The group's consumers. + consumers: list, + } + + /// A consumer of a group, with its pending entries. + @since(version = 0.3.0-dev) + record xinfo-stream-reply-full-consumer { + /// The consumer's name. + name: string, + /// When the consumer last attempted an interaction, e.g. XREADGROUP, XCLAIM or XAUTOCLAIM. + seen-time: unix-time-milliseconds, + /// When the consumer last interacted successfully, e.g. read or claimed entries. + /// None when the consumer has never been active. + active-time: option, + /// The number of entries pending for the consumer, delivered but not acknowledged. + pel-count: integer, + /// The consumer's pending entries, the ID of each, when it was last delivered and the number of + /// times it was delivered. + pending: list>, + } + + /// The pending entries of a consumer group, see `xpending`. + @since(version = 0.3.0-dev) + record xpending-summary { + /// The number of pending entries. + count: integer, + /// The lowest ID of the pending entries, none when there are none. + lowest-id: option, + /// The highest ID of the pending entries, none when there are none. + highest-id: option, + /// Each consumer with pending entries, and how many it has. + consumers: list>, + } + + /// The pending entries of a consumer group, see `xpending-extended`. + @since(version = 0.3.0-dev) + record xpending-extended-entry { + /// The ID of the message. + id: stream-id, + /// The name of the consumer that fetched the message and has still to acknowledge it. We call it the current owner of the message. + name: string, + /// The number of milliseconds that elapsed since the last time this message was delivered to this consumer. + since-delivery: duration-milliseconds, + /// The number of times this message was delivered. + delivery-count: integer, + } + + // string commands + + /// The expiration to set on a key, see `getex`. + @since(version = 0.3.0-dev) + variant getex-ttl { + /// Set the specified expire time, in seconds. + ex(duration-seconds), + /// Set the specified expire time, in milliseconds. + px(duration-milliseconds), + /// Set the specified Unix time at which the key will expire, in seconds. + exat(unix-time-seconds), + /// Set the specified Unix time at which the key will expire, in milliseconds. + pxat(unix-time-milliseconds), + /// Remove the time to live associated with the key. + persist, + } + + /// The expiration to set on the fields, see `hgetex`. + @since(version = 0.3.0-dev) + type hgetex-ttl = getex-ttl; + + /// The longest common subsequence of two strings, as the ranges matching in each, see `lcs-idx`. + @since(version = 0.3.0-dev) + record lcs-idx-reply { + /// The ranges that match, from the end of the strings to their start. + matches: list, + /// The length of the longest common subsequence. + len: integer, + } + + /// A part of the longest common subsequence. + @since(version = 0.3.0-dev) + record lcs-idx-match { + /// The inclusive start and end positions of the match in the first key's string. + key1-range: tuple, + /// The inclusive start and end positions of the match in the second key's string. + key2-range: tuple, + /// The length of the match, with `with-match-len`. + len: option, + } + + /// The condition for setting the keys, see `msetex`. + @since(version = 0.3.0-dev) + variant msetex-precondition { + /// Only set the keys if all of them do not already exist. + nx, + /// Only set the keys if all of them already exist. + xx, + } + + /// The expiration to set on the keys, see `msetex`. + @since(version = 0.3.0-dev) + type msetex-ttl = set-ttl; + + /// The condition for setting a key, see `set`. + @since(version = 0.3.0-dev) + variant set-precondition { + /// Only set the key if it does not already exist. + nx, + /// Only set the key if it already exists. + xx, + /// Set the key if the comparison value matches the existing value. + ifeq(string-or-bytes), + /// Set the key if the comparison value does not match the existing value. + ifne(string-or-bytes), + } + + /// The expiration to set on a key, see `set`. + @since(version = 0.3.0-dev) + variant set-ttl { + /// Set the specified expire time, in seconds. + ex(duration-seconds), + /// Set the specified expire time, in milliseconds. + px(duration-milliseconds), + /// Set the specified Unix time at which the key will expire, in seconds. + exat(unix-time-seconds), + /// Set the specified Unix time at which the key will expire, in milliseconds. + pxat(unix-time-milliseconds), + /// Retain the time to live associated with the keys. + keepttl, + } + + /// The expiration to set on the fields, see `hsetex`. + @since(version = 0.3.0-dev) + type hsetex-ttl = set-ttl; +} diff --git a/wit/worlds.wit b/wit/worlds.wit index d229a58..18125be 100644 --- a/wit/worlds.wit +++ b/wit/worlds.wit @@ -1,6 +1,10 @@ package componentized:valkey@0.3.0-dev; world imports { - import resp; - import store; + import commands; + import connection; + import connector; + import tcp; + import tcp-connector-constants; + import types; } diff --git a/wkg.lock b/wkg.lock index 5b9a143..2ad4aa8 100644 --- a/wkg.lock +++ b/wkg.lock @@ -1,12 +1,4 @@ # This file is automatically generated. # It is not intended for manual editing. version = 1 - -[[packages]] -name = "wasi:clocks" -registry = "wasi.dev" - -[[packages.versions]] -requirement = "=0.3.0" -version = "0.3.0" -digest = "sha256:59e1f4079e64ada450e19ffc9d08854c904e356ed8cc1fda36ff4fe150264db2" +packages = []