commit 7c11581e1131d7026c63f3dedd335a6cce78917c Author: AI Engineer Date: Sun Jun 28 10:18:30 2026 +0800 fix(aof): do not override context database when conn is nil (AOF replay fix) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..e38f45c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,80 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to make participation in our project +and our community a harassment-free experience for everyone, regardless +of age, body size, disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic +status, nationality, personal appearance, race, religion, or sexual +identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual + attention or advances +- Trolling, insulting/derogatory comments, and personal or political + attacks +- Public or private harassment +- Publishing others' private information, such as a physical or + electronic address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in + a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of +acceptable behavior and are expected to take appropriate and fair +corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, +or reject comments, commits, code, wiki edits, issues, and other +contributions that are not aligned to this Code of Conduct, or to ban +temporarily or permanently any contributor for other behaviors that they +deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all project spaces, and it also +applies when an individual is representing the project or its community +in public spaces. Examples of representing a project or community +include using an official project e-mail address, posting via an +official social media account, or acting as an appointed representative +at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may +be reported by contacting the project team at . +All complaints will be reviewed and investigated and will result in a +response that is deemed necessary and appropriate to the circumstances. +The project team is obligated to maintain confidentiality with regard to +the reporter of an incident. Further details of specific enforcement +policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in +good faith may face temporary or permanent repercussions as determined +by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor +Covenant](https://www.contributor-covenant.org), version 1.4, available +at + + +For answers to common questions about this code of conduct, see + \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ab2becd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing + +## Issues + +Issues are very valuable to this project. + +- Ideas are a valuable source of contributions others can make +- Problems show where this project is lacking +- With a question you show where contributors can improve the user + experience + +Thank you for creating them. + +## Pull Requests + +Pull requests are a great way to get your ideas into this repository. + +Before opening a pull request, reach out to one of the organization +members or state your intent in the [discord server](https://discord.com/invite/JrG4kPrF8v). This prevents you +from potentially doing redundant or low-priority work. + +When deciding if I merge in a pull request I look at the following +things: + +### Does it state intent + +You should be clear which problem you're trying to solve with your +contribution. + +For example: + +> Add link to code of conduct in README.md + +Doesn't tell me anything about why you're doing that + +> Add link to code of conduct in README.md because users don't always +> look in the CONTRIBUTING.md + +Tells me the problem that you have found, and the pull request shows me +the action you have taken to solve it. + +### Is it of good quality + +- There are no spelling mistakes +- It reads well +- For english language contributions: Has a good score on + [Grammarly](https://www.grammarly.com) or [Hemingway + App](https://www.hemingwayapp.com/) + +### Does it move this repository closer to my vision for the repository + +The aim of this repository is: + +- To provide a distributed (primarily) in-memory data cache that is + embeddable in Go and compatible with RESP. +- Foster a culture of respect and gratitude in the open source + community. + +### Does it follow the contributor covenant + +This repository has a [code of conduct](CODE_OF_CONDUCT.md), I will +remove things that do not respect it. + +If all the above conditions are met, feel free to fork the project +and open a pull request. \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..e44ef8c --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,62 @@ +FROM --platform=linux/amd64 golang:alpine AS build + +RUN apk add --no-cache gcc musl-dev + +WORKDIR /build +COPY . ./ + +ENV CGO_ENABLED=1 CC=gcc GOOS=linux GOARCH=amd64 + +ENV DEST=volumes/modules +RUN CGO_ENABLED=$CGO_ENABLED CC=$CC GOOS=$GOOS GOARCH=$GOARCH go build -buildmode=plugin -o $DEST/module_set/module_set.so ./internal/volumes/modules/go/module_set/module_set.go +RUN CGO_ENABLED=$CGO_ENABLED CC=$CC GOOS=$GOOS GOARCH=$GOARCH go build -buildmode=plugin -o $DEST/module_get/module_get.so ./internal/volumes/modules/go/module_get/module_get.go + +ENV DEST=bin +RUN CGO_ENABLED=$CGO_ENABLED CC=$CC GOOS=$GOOS GOARCH=$GOARCH go build -o $DEST/server ./cmd/... + + + +FROM --platform=linux/amd64 alpine:latest AS server + +RUN mkdir -p /usr/sugardb/bin/modules +RUN mkdir -p /etc/ssl/certs/sugardb/server +RUN mkdir -p /etc/ssl/certs/sugardb/client + +COPY --from=build /build/volumes/modules /usr/sugardb/bin/modules +COPY --from=build /build/bin/server /usr/sugardb/bin +COPY ./openssl/server /etc/ssl/certs/sugardb/server +COPY ./openssl/client /etc/ssl/certs/sugardb/client + +WORKDIR /usr/sugardb/bin + +CMD "./server" \ + "--bind-addr" "${BIND_ADDR}" \ + "--port" "${PORT}" \ + "--discovery-port" "${DISCOVERY_PORT}" \ + "--server-id" "${SERVER_ID}" \ + "--join-addr" "${JOIN_ADDR}" \ + "--data-dir" "${DATA_DIR}" \ + "--snapshot-threshold" "${SNAPSHOT_THRESHOLD}" \ + "--snapshot-interval" "${SNAPSHOT_INTERVAL}" \ + "--max-memory" "${MAX_MEMORY}" \ + "--eviction-policy" "${EVICTION_POLICY}" \ + "--eviction-sample" "${EVICTION_SAMPLE}" \ + "--eviction-interval" "${EVICTION_INTERVAL}" \ + "--tls=${TLS}" \ + "--mtls=${MTLS}" \ + "--bootstrap-cluster=${BOOTSTRAP_CLUSTER}" \ + "--acl-config=${ACL_CONFIG}" \ + "--require-pass=${REQUIRE_PASS}" \ + "--password=${PASSWORD}" \ + "--forward-commands=${FORWARD_COMMAND}" \ + "--restore-snapshot=${RESTORE_SNAPSHOT}" \ + "--restore-aof=${RESTORE_AOF}" \ + "--aof-sync-strategy=${AOF_SYNC_STRATEGY}" \ + # List of sugardb cert/key pairs + "--cert-key-pair=${CERT_KEY_PAIR_1}" \ + "--cert-key-pair=${CERT_KEY_PAIR_2}" \ + # List of client certs + "--client-ca=${CLIENT_CA_1}" \ + # List of plugins to load on startup + "--loadmodule=${MODULE_1}" \ + "--loadmodule=${MODULE_2}" \ diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..54201ef --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,11 @@ +FROM --platform=linux/amd64 golang:alpine AS build +RUN apk add --no-cache gcc musl-dev +WORKDIR /build +COPY . ./ +RUN CGO_ENABLED=1 CC=gcc GOOS=linux GOARCH=amd64 go build -o bin/server ./cmd/... + +FROM --platform=linux/amd64 alpine:latest AS server +RUN mkdir -p /usr/sugardb/bin +COPY --from=build /build/bin/server /usr/sugardb/bin +WORKDIR /usr/sugardb/bin +ENTRYPOINT ["./server"] \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..59711bc --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,51 @@ +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: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and +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 +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 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..18d9ff5 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +run: + docker-compose up --build + +build-local: + CGO_ENABLED=1 go build -buildmode=plugin -o ./bin/modules/module_set/module_set.so ./internal/volumes/modules/go/module_set/module_set.go && \ + CGO_ENABLED=1 go build -buildmode=plugin -o ./bin/modules/module_get/module_get.so ./internal/volumes/modules/go/module_get/module_get.go && \ + CGO_ENABLED=1 go build -o ./bin ./... + + +build-modules-test: + CGO_ENABLED=1 go build --race=$(RACE) -buildmode=plugin -o $(OUT)/modules/module_set/module_set.so ./internal/volumes/modules/go/module_set/module_set.go && \ + CGO_ENABLED=1 go build --race=$(RACE) -buildmode=plugin -o $(OUT)/modules/module_get/module_get.so ./internal/volumes/modules/go/module_get/module_get.go + +test: + env RACE=false OUT=internal/modules/admin/testdata make build-modules-test && \ + env RACE=false OUT=sugardb/testdata make build-modules-test && \ + CGO_ENABLED=1 go test ./... -coverprofile coverage/coverage.out + +test-race: + env RACE=true OUT=internal/modules/admin/testdata make build-modules-test && \ + env RACE=true OUT=sugardb/testdata make build-modules-test && \ + CGO_ENABLED=1 go test ./... --race + +testenv-run: + docker-compose -f test_env/run/docker-compose.yaml build + docker-compose -f test_env/run/docker-compose.yaml run projenv + +testenv-test: + docker-compose -f test_env/test/docker-compose.yaml up --build + +testenv-test-race: + docker-compose -f test_env/test_race/docker-compose.yaml up --build + +testenv-all: + docker-compose -f test_env/all/docker-compose.yaml up --build + +cover: + go tool cover -html=./coverage/coverage.out + +benchmark: + go run redis_benchmark.go $(if $(commands),-commands="$(commands)") $(if $(use_local_server),-use_local_server) diff --git a/README.md b/README.md new file mode 100644 index 0000000..5836c83 --- /dev/null +++ b/README.md @@ -0,0 +1,341 @@ +[![Go](https://github.com/EchoVault/SugarDB/workflows/Go/badge.svg)]() +[![Go Report Card](https://goreportcard.com/badge/github.com/echovault/echovault)](https://goreportcard.com/report/github.com/echovault/echovault) +[![codecov](https://codecov.io/gh/EchoVault/SugarDB/graph/badge.svg?token=CHWTW0IUNV)](https://codecov.io/gh/EchoVault/SugarDB) +
+[![Go Reference](https://pkg.go.dev/badge/github.com/echovault/echovault.svg)](https://pkg.go.dev/github.com/echovault/sugardb) +[![GitHub Release](https://img.shields.io/github/v/release/EchoVault/SugarDB)]() +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) +
+[![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) +[![Discord](https://img.shields.io/discord/1211815152291414037?label=Discord&labelColor=%237289da)](https://discord.com/invite/JrG4kPrF8v) +
+ +
+ +# Table of Contents +1. [What is SugarDB](#what-is-sugardb) +2. [Features](#features) +3. [Usage (Embedded)](#usage-embedded) +4. [Usage (Client-Server)](#usage-client-server) + 1. [Homebrew](#usage-homebrew) + 2. [Docker](#usage-docker) + 3. [GitHub Container Registry](#usage-container-registry) + 4. [Binaries](#usage-binaries) +5. [Clients](#clients) +6. [Benchmarks](#benchmarks) +7. [Commands](#commands) + 1. [ACL](#commands-acl) + 2. [ADMIN](#commands-admin) + 3. [CONNECTION](#commands-connection) + 4. [GENERIC](#commands-generic) + 5. [HASH](#commands-hash) + 6. [LIST](#commands-list) + 7. [PUBSUB](#commands-pubsub) + 8. [SET](#commands-set) + 9. [SORTED SET](#commands-sortedset) + 10. [STRING](#commands-string) + + +# What is SugarDB? + +SugarDB is a highly configurable, distributed, in-memory data store and cache implemented in Go. +It can be imported as a Go library or run as an independent service. + +SugarDB aims to provide a rich set of data structures and functions for +manipulating data in memory. These data structures include, but are not limited to: +Lists, Sets, Sorted Sets, Hashes, and much more to come soon. + +SugarDB provides a persistence layer for increased reliability. Both Append-Only files +and snapshots can be used to persist data in the disk for recovery in case of unexpected shutdowns. + +Replication is a core feature of SugarDB and is implemented using the RAFT algorithm, +allowing you to create a fault-tolerant cluster of SugarDB nodes to improve reliability. +If you do not need a replication cluster, you can always run SugarDB +in standalone mode and have a fully capable single node. + +SugarDB aims to not only be a server but to be importable to existing +projects to enhance them with SugarDB features, this +capability is always being worked on and improved. + + +# Features + +Features offered by SugarDB include: + +1) TLS and mTLS support for multiple server and client RootCAs. +2) Replication cluster support using the RAFT algorithm. +3) ACL Layer for user Authentication and Authorization. +4) Distributed Pub/Sub functionality. +5) Sets, Sorted Sets, Hashes, Lists and more. +6) Persistence layer with Snapshots and Append-Only files. +7) Key Eviction Policies. +8) Command extension via shared object files. +9) Command extension via embedded API. +10) Command extension via Lua Modules. +11) Command extension via JavaScript Modules. +12) Multi-database support for key namespacing. + +We are working hard to add more features to SugarDB to make it +much more powerful. Features in the roadmap include: + +1) Sharding +2) Streams +3) Transactions +4) Bitmap +5) HyperLogLog +6) JSON +7) Improved Observability + + + +# Usage (Embedded) + +Install SugarDB with: `go get github.com/echovault/sugardb`. + +Here's an example of using SugarDB as an embedded library. +You can access all of SugarDB's commands using an ergonomic API. + +```go +func main() { + server, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + + _, _, _ = server.Set("key", "Hello, SugarDB!", sugardb.SETOptions{}) + + v, _ := server.Get("key") + fmt.Println(v) // Hello, SugarDB! + + // (Optional): Listen for TCP connections on this SugarDB instance. + server.Start() +} +``` + +An embedded SugarDB instance can still be part of a cluster, and the changes triggered +from the API will be consistent across the cluster. + + +# Usage (Client-Server) + + +### Homebrew + +To install via homebrew, run: +1) `brew tap echovault/sugardb` +2) `brew install echovault/echovault/sugardb` + +Once installed, you can run the server with the following command: +`sugardb --bind-addr=localhost --data-dir="path/to/persistence/directory"` + + +### Docker + +`docker pull echovault/sugardb` + +The full list of tags can be found [here](https://hub.docker.com/r/echovault/sugardb/tags). + + +### Container Registry + +`docker pull ghcr.io/echovault/sugardb` + +The full list of tags can be found [here](https://github.com/EchoVault/SugarDB/pkgs/container/sugardb). + + +### Binaries + +You can download the binaries by clicking on a release tag and downloading +the binary for your system. + + +# Clients + +SugarDB uses RESP, which makes it compatible with existing +Redis clients. + + +# Benchmarks +To compare command performance with Redis, benchmarks can be run with: + +`make benchmark` + +Prerequisites: +- `brew install redis` to run the Redis server and benchmark script +- `brew tap echovault/sugardb` & `brew install echovault/echovault/sugardb` to run the SugarDB Client-Server + +Benchmark script options: +- `make benchmark use_local_server=true` runs on your local SugarDB Client-Server +- `make benchmark commands=ping,set,get...` runs the benchmark script on the specified commands + + +# Supported Commands + + +## ACL +* [ACL CAT](https://sugardb.io/docs/commands/acl/acl_cat) +* [ACL DELUSER](https://sugardb.io/docs/commands/acl/acl_deluser) +* [ACL GETUSER](https://sugardb.io/docs/commands/acl/acl_getuser) +* [ACL LIST](https://sugardb.io/docs/commands/acl/acl_list) +* [ACL LOAD](https://sugardb.io/docs/commands/acl/acl_load) +* [ACL SAVE](https://sugardb.io/docs/commands/acl/acl_save) +* [ACL SETUSER](https://sugardb.io/docs/commands/acl/acl_setuser) +* [ACL USERS](https://sugardb.io/docs/commands/acl/acl_users) +* [ACL WHOAMI](https://sugardb.io/docs/commands/acl/acl_whoami) + + +## ADMIN +* [COMMAND COUNT](https://sugardb.io/docs/commands/admin/command_count) +* [COMMAND LIST](https://sugardb.io/docs/commands/admin/command_list) +* [COMMANDS](https://sugardb.io/docs/commands/admin/commands) +* [LASTSAVE](https://sugardb.io/docs/commands/admin/lastsave) +* [MODULE LIST](https://sugardb.io/docs/commands/admin/module_list) +* [MODULE LOAD](https://sugardb.io/docs/commands/admin/module_load) +* [MODULE UNLOAD](https://sugardb.io/docs/commands/admin/module_unload) +* [REWRITEAOF](https://sugardb.io/docs/commands/admin/rewriteaof) +* [SAVE](https://sugardb.io/docs/commands/admin/save) + + +## CONNECTION +* [AUTH](https://sugardb.io/docs/commands/connection/auth) +* [ECHO](https://sugardb.io/docs/commands/connection/echo) +* [HELLO](https://sugardb.io/docs/commands/connection/hello) +* [PING](https://sugardb.io/docs/commands/connection/ping) +* [SELECT](https://sugardb.io/docs/commands/connection/select) +* [SWAPDB](https://sugardb.io/docs/commands/connection/swapdb) + + +## GENERIC +* [COPY](https://sugardb.io/docs/commands/generic/copy) +* [DBSIZE](https://sugardb.io/docs/commands/generic/dbsize) +* [DECR](https://sugardb.io/docs/commands/generic/decr) +* [DECRBY](https://sugardb.io/docs/commands/generic/decrby) +* [DEL](https://sugardb.io/docs/commands/generic/del) +* [EXISTS](https://sugardb.io/docs/commands/generic/exists) +* [EXPIRE](https://sugardb.io/docs/commands/generic/expire) +* [EXPIRETIME](https://sugardb.io/docs/commands/generic/expiretime) +* [FLUSHALL](https://sugardb.io/docs/commands/generic/flushall) +* [FLUSHDB](https://sugardb.io/docs/commands/generic/flushdb) +* [GET](https://sugardb.io/docs/commands/generic/get) +* [GETDEL](https://sugardb.io/docs/commands/generic/getdel) +* [GETEX](https://sugardb.io/docs/commands/generic/get) +* [INCR](https://sugardb.io/docs/commands/generic/incr) +* [INCRBY](https://sugardb.io/docs/commands/generic/incrby) +* [INCRBYFLOAT](https://sugardb.io/docs/commands/generic/incrbyfloat) +* [MGET](https://sugardb.io/docs/commands/generic/mget) +* [MOVE](https://sugardb.io/docs/commands/generic/move) +* [MSET](https://sugardb.io/docs/commands/generic/mset) +* [OBJECTFREQ](https://sugardb.io/docs/commands/generic/objectfreq) +* [OBJECTIDLETIME](https://sugardb.io/docs/commands/generic/objectidletime) +* [PERSIST](https://sugardb.io/docs/commands/generic/persist) +* [PEXPIRE](https://sugardb.io/docs/commands/generic/pexpire) +* [PEXPIREAT](https://sugardb.io/docs/commands/generic/pexpireat) +* [PEXPIRETIME](https://sugardb.io/docs/commands/generic/pexpiretime) +* [PTTL](https://sugardb.io/docs/commands/generic/pttl) +* [RANDOMKEY](https://sugardb.io/docs/commands/generic/randomkey) +* [RENAME](https://sugardb.io/docs/commands/generic/rename) +* [SET](https://sugardb.io/docs/commands/generic/set) +* [TTL](https://sugardb.io/docs/commands/generic/ttl) +* [TYPE](https://sugardb.io/docs/commands/generic/type) + + + +## HASH +* [HDEL](https://sugardb.io/docs/commands/hash/hdel) +* [HEXISTS](https://sugardb.io/docs/commands/hash/hexists) +* [HEXPIRE](https://sugardb.io/docs/commands/hash/hexpire) +* [HGET](https://sugardb.io/docs/commands/hash/hget) +* [HGETALL](https://sugardb.io/docs/commands/hash/hgetall) +* [HINCRBY](https://sugardb.io/docs/commands/hash/hincrby) +* [HINCRBYFLOAT](https://sugardb.io/docs/commands/hash/hincrbyfloat) +* [HKEYS](https://sugardb.io/docs/commands/hash/hkeys) +* [HLEN](https://sugardb.io/docs/commands/hash/hlen) +* [HMGET](https://sugardb.io/docs/commands/hash/hmget) +* [HRANDFIELD](https://sugardb.io/docs/commands/hash/hrandfield) +* [HSET](https://sugardb.io/docs/commands/hash/hset) +* [HSETNX](https://sugardb.io/docs/commands/hash/hsetnx) +* [HSTRLEN](https://sugardb.io/docs/commands/hash/hstrlen) +* [HTTL](https://sugardb.io/docs/commands/hash/httl) +* [HVALS](https://sugardb.io/docs/commands/hash/hvals) + + +## LIST +* [LINDEX](https://sugardb.io/docs/commands/list/lindex) +* [LLEN](https://sugardb.io/docs/commands/list/llen) +* [LMOVE](https://sugardb.io/docs/commands/list/lmove) +* [LPOP](https://sugardb.io/docs/commands/list/lpop) +* [LPUSH](https://sugardb.io/docs/commands/list/lpush) +* [LPUSHX](https://sugardb.io/docs/commands/list/lpushx) +* [LRANGE](https://sugardb.io/docs/commands/list/lrange) +* [LREM](https://sugardb.io/docs/commands/list/lrem) +* [LSET](https://sugardb.io/docs/commands/list/lset) +* [LTRIM](https://sugardb.io/docs/commands/list/ltrim) +* [RPOP](https://sugardb.io/docs/commands/list/rpop) +* [RPUSH](https://sugardb.io/docs/commands/list/rpush) +* [RPUSHX](https://sugardb.io/docs/commands/list/rpushx) + + +## PUBSUB +* [PSUBSCRIBE](https://sugardb.io/docs/commands/pubsub/psubscribe) +* [PUBLISH](https://sugardb.io/docs/commands/pubsub/publish) +* [PUBSUB CHANNELS](https://sugardb.io/docs/commands/pubsub/pubsub_channels) +* [PUBSUB NUMPAT](https://sugardb.io/docs/commands/pubsub/pubsub_numpat) +* [PUBSUB NUMSUB](https://sugardb.io/docs/commands/pubsub/pubsub_numsub) +* [PUNSUBSCRIBE](https://sugardb.io/docs/commands/pubsub/punsubscribe) +* [SUBSCRIBE](https://sugardb.io/docs/commands/pubsub/subscribe) +* [UNSUBSCRIBE](https://sugardb.io/docs/commands/pubsub/unsubscribe) + + +## SET +* [SADD](https://sugardb.io/docs/commands/set/sadd) +* [SCARD](https://sugardb.io/docs/commands/set/scard) +* [SDIFF](https://sugardb.io/docs/commands/set/sdiff) +* [SDIFFSTORE](https://sugardb.io/docs/commands/set/sdiffstore) +* [SINTER](https://sugardb.io/docs/commands/set/sinter) +* [SINTERCARD](https://sugardb.io/docs/commands/set/sintercard) +* [SINTERSTORE](https://sugardb.io/docs/commands/set/sinterstore) +* [SISMEMBER](https://sugardb.io/docs/commands/set/sismember) +* [SMEMBERS](https://sugardb.io/docs/commands/set/smembers) +* [SMISMEMBER](https://sugardb.io/docs/commands/set/smismember) +* [SMOVE](https://sugardb.io/docs/commands/set/smove) +* [SPOP](https://sugardb.io/docs/commands/set/spop) +* [SRANDMEMBER](https://sugardb.io/docs/commands/set/srandmember) +* [SREM](https://sugardb.io/docs/commands/set/srem) +* [SUNION](https://sugardb.io/docs/commands/set/sunion) +* [SUNIONSTORE](https://sugardb.io/docs/commands/set/sunionstore) + + +## SORTED SET +* [ZADD](https://sugardb.io/docs/commands/sorted_set/zadd) +* [ZCARD](https://sugardb.io/docs/commands/sorted_set/zcard) +* [ZCOUNT](https://sugardb.io/docs/commands/sorted_set/zcount) +* [ZDIFF](https://sugardb.io/docs/commands/sorted_set/zdiff) +* [ZDIFFSTORE](https://sugardb.io/docs/commands/sorted_set/zdiffstore) +* [ZINCRBY](https://sugardb.io/docs/commands/sorted_set/zincrby) +* [ZINTER](https://sugardb.io/docs/commands/sorted_set/zinter) +* [ZINTERSTORE](https://sugardb.io/docs/commands/sorted_set/zinterstore) +* [ZLEXCOUNT](https://sugardb.io/docs/commands/sorted_set/zlexcount) +* [ZMPOP](https://sugardb.io/docs/commands/sorted_set/zmpop) +* [ZMSCORE](https://sugardb.io/docs/commands/sorted_set/zmscore) +* [ZPOPMAX](https://sugardb.io/docs/commands/sorted_set/zpopmax) +* [ZPOPMIN](https://sugardb.io/docs/commands/sorted_set/zpopmin) +* [ZRANDMEMBER](https://sugardb.io/docs/commands/sorted_set/zrandmember) +* [ZRANGE](https://sugardb.io/docs/commands/sorted_set/zrange) +* [ZRANGESTORE](https://sugardb.io/docs/commands/sorted_set/zrangestore) +* [ZRANK](https://sugardb.io/docs/commands/sorted_set/zrank) +* [ZREM](https://sugardb.io/docs/commands/sorted_set/zrem) +* [ZREMRANGEBYLEX](https://sugardb.io/docs/commands/sorted_set/zremrangebylex) +* [ZREMRANGEBYRANK](https://sugardb.io/docs/commands/sorted_set/zremrangebyrank) +* [ZREMRANGEBYSCORE](https://sugardb.io/docs/commands/sorted_set/zremrangebyscore) +* [ZREVRANK](https://sugardb.io/docs/commands/sorted_set/zrevrank) +* [ZSCORE](https://sugardb.io/docs/commands/sorted_set/zscore) +* [ZUNION](https://sugardb.io/docs/commands/sorted_set/zunion) +* [ZUNIONSTORE](https://sugardb.io/docs/commands/sorted_set/zunionstore) + + +## STRING +* [APPEND](https://sugardb.io/docs/commands/string/append) +* [GETRANGE](https://sugardb.io/docs/commands/string/getrange) +* [SETRANGE](https://sugardb.io/docs/commands/string/setrange) +* [STRLEN](https://sugardb.io/docs/commands/string/strlen) +* [SUBSTR](https://sugardb.io/docs/commands/string/substr) diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..aeca179 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,53 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package main + +import ( + "context" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/sugardb" + "log" + "os" + "os/signal" + "syscall" +) + +func main() { + conf, err := config.GetConfig() + if err != nil { + log.Fatal(err) + } + + ctx := context.WithValue(context.Background(), internal.ContextServerID("ServerID"), conf.ServerID) + + cancelCh := make(chan os.Signal, 1) + signal.Notify(cancelCh, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) + + server, err := sugardb.NewSugarDB( + sugardb.WithContext(ctx), + sugardb.WithConfig(conf), + ) + + if err != nil { + log.Fatal(err) + } + + go server.Start() + + <-cancelCh + + server.ShutDown() +} diff --git a/coverage/coverage.out b/coverage/coverage.out new file mode 100644 index 0000000..5d1cd5a --- /dev/null +++ b/coverage/coverage.out @@ -0,0 +1,10378 @@ +mode: set +github.com/echovault/sugardb/redis_benchmark.go:25.38,31.15 6 0 +github.com/echovault/sugardb/redis_benchmark.go:31.15,33.3 1 0 +github.com/echovault/sugardb/redis_benchmark.go:34.2,34.29 1 0 +github.com/echovault/sugardb/redis_benchmark.go:37.68,43.16 4 0 +github.com/echovault/sugardb/redis_benchmark.go:43.16,45.3 1 0 +github.com/echovault/sugardb/redis_benchmark.go:47.2,50.29 4 0 +github.com/echovault/sugardb/redis_benchmark.go:50.29,51.56 1 0 +github.com/echovault/sugardb/redis_benchmark.go:51.56,71.4 9 0 +github.com/echovault/sugardb/redis_benchmark.go:74.2,74.21 1 0 +github.com/echovault/sugardb/redis_benchmark.go:77.75,78.46 1 0 +github.com/echovault/sugardb/redis_benchmark.go:78.46,80.3 1 0 +github.com/echovault/sugardb/redis_benchmark.go:82.2,85.41 4 0 +github.com/echovault/sugardb/redis_benchmark.go:85.41,93.3 6 0 +github.com/echovault/sugardb/redis_benchmark.go:94.2,94.11 1 0 +github.com/echovault/sugardb/redis_benchmark.go:97.13,108.16 6 0 +github.com/echovault/sugardb/redis_benchmark.go:108.16,111.3 2 0 +github.com/echovault/sugardb/redis_benchmark.go:113.2,113.15 1 0 +github.com/echovault/sugardb/redis_benchmark.go:113.15,117.3 2 0 +github.com/echovault/sugardb/redis_benchmark.go:120.2,122.16 3 0 +github.com/echovault/sugardb/redis_benchmark.go:122.16,126.3 3 0 +github.com/echovault/sugardb/redis_benchmark.go:129.2,134.15 3 0 +github.com/echovault/sugardb/redis_benchmark.go:134.15,137.53 2 0 +github.com/echovault/sugardb/redis_benchmark.go:137.53,139.4 1 0 +github.com/echovault/sugardb/cmd/main.go:28.13,30.16 2 0 +github.com/echovault/sugardb/cmd/main.go:30.16,32.3 1 0 +github.com/echovault/sugardb/cmd/main.go:34.2,44.16 5 0 +github.com/echovault/sugardb/cmd/main.go:44.16,46.3 1 0 +github.com/echovault/sugardb/cmd/main.go:48.2,52.19 3 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 0 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 0 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 0 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 0 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 0 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 0 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 0 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 0 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 0 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 0 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 0 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 0 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 0 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 0 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 0 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 0 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 0 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 0 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 0 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 0 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 0 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 0 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 0 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 0 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 0 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 0 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 0 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 0 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 0 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 0 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 0 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 0 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 0 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 0 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 0 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 0 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 0 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 0 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 0 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 0 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 0 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 0 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 0 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 0 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 0 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 0 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 0 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 0 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 0 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 0 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 0 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 0 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 0 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 0 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 0 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 0 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 0 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 0 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 0 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 0 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 0 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 0 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 0 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 0 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 0 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 0 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 0 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/config/config.go:64.34,70.24 3 0 +github.com/echovault/sugardb/internal/config/config.go:70.24,72.35 2 0 +github.com/echovault/sugardb/internal/config/config.go:72.35,74.5 1 0 +github.com/echovault/sugardb/internal/config/config.go:75.4,75.22 1 0 +github.com/echovault/sugardb/internal/config/config.go:75.22,77.5 1 0 +github.com/echovault/sugardb/internal/config/config.go:78.4,79.14 2 0 +github.com/echovault/sugardb/internal/config/config.go:82.2,82.115 1 0 +github.com/echovault/sugardb/internal/config/config.go:82.115,85.3 2 0 +github.com/echovault/sugardb/internal/config/config.go:87.2,90.29 2 0 +github.com/echovault/sugardb/internal/config/config.go:90.29,91.86 1 0 +github.com/echovault/sugardb/internal/config/config.go:91.86,93.5 1 0 +github.com/echovault/sugardb/internal/config/config.go:93.7,95.5 1 0 +github.com/echovault/sugardb/internal/config/config.go:96.4,97.14 2 0 +github.com/echovault/sugardb/internal/config/config.go:100.2,103.59 2 0 +github.com/echovault/sugardb/internal/config/config.go:103.59,105.17 2 0 +github.com/echovault/sugardb/internal/config/config.go:105.17,107.4 1 0 +github.com/echovault/sugardb/internal/config/config.go:108.3,109.13 2 0 +github.com/echovault/sugardb/internal/config/config.go:112.2,121.88 2 0 +github.com/echovault/sugardb/internal/config/config.go:121.88,128.23 3 0 +github.com/echovault/sugardb/internal/config/config.go:128.23,130.5 1 0 +github.com/echovault/sugardb/internal/config/config.go:131.4,132.14 2 0 +github.com/echovault/sugardb/internal/config/config.go:135.2,139.24 2 0 +github.com/echovault/sugardb/internal/config/config.go:139.24,140.36 1 0 +github.com/echovault/sugardb/internal/config/config.go:140.36,142.5 1 0 +github.com/echovault/sugardb/internal/config/config.go:143.4,144.14 2 0 +github.com/echovault/sugardb/internal/config/config.go:147.2,188.14 23 0 +github.com/echovault/sugardb/internal/config/config.go:188.14,190.3 1 0 +github.com/echovault/sugardb/internal/config/config.go:191.2,192.14 2 0 +github.com/echovault/sugardb/internal/config/config.go:192.14,194.3 1 0 +github.com/echovault/sugardb/internal/config/config.go:196.2,226.22 2 0 +github.com/echovault/sugardb/internal/config/config.go:226.22,228.45 1 0 +github.com/echovault/sugardb/internal/config/config.go:228.45,229.14 1 0 +github.com/echovault/sugardb/internal/config/config.go:230.9,231.17 1 0 +github.com/echovault/sugardb/internal/config/config.go:231.17,232.36 1 0 +github.com/echovault/sugardb/internal/config/config.go:232.36,234.6 1 0 +github.com/echovault/sugardb/internal/config/config.go:237.4,239.22 2 0 +github.com/echovault/sugardb/internal/config/config.go:239.22,240.59 1 0 +github.com/echovault/sugardb/internal/config/config.go:240.59,242.6 1 0 +github.com/echovault/sugardb/internal/config/config.go:245.4,245.39 1 0 +github.com/echovault/sugardb/internal/config/config.go:245.39,246.59 1 0 +github.com/echovault/sugardb/internal/config/config.go:246.59,248.6 1 0 +github.com/echovault/sugardb/internal/config/config.go:254.2,256.45 2 0 +github.com/echovault/sugardb/internal/config/config.go:256.45,258.3 1 0 +github.com/echovault/sugardb/internal/config/config.go:260.2,260.18 1 0 +github.com/echovault/sugardb/internal/config/default.go:9.29,42.2 3 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 0 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 0 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 0 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 0 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 0 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 0 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 0 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 0 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 0 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 0 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 0 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 0 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 0 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 0 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 0 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 0 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 0 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 0 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 0 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 0 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 0 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 0 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 0 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 0 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 0 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 0 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 0 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 0 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 0 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 0 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 0 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 0 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 0 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 0 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 0 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 0 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 0 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 0 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 0 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 0 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 0 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 0 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 0 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 0 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 0 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 0 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 0 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 0 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 0 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 0 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 0 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 0 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 0 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 0 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 0 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 0 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 0 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 0 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 0 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 0 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 0 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 0 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 0 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 0 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 0 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 0 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 0 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 0 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 0 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 0 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 0 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 0 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 0 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 0 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 0 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 0 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 0 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 0 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 0 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 0 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 0 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 0 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 0 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 0 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 0 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 0 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 0 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 0 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 0 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 0 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 0 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 0 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 0 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 0 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 0 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 0 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 0 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 0 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 0 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 0 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 0 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 0 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 0 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 0 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 0 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 1 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 1 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 1 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 1 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 1 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 1 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 1 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 1 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 0 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 0 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 0 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 0 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 0 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 0 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 0 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 0 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 0 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 0 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 0 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 0 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 0 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 0 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 0 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 0 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 0 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 0 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 0 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 0 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:44.54,45.28 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:45.28,47.3 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:50.55,51.28 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:51.28,53.3 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:56.88,57.28 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:57.28,59.3 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:62.101,63.28 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:63.28,65.3 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:68.57,69.28 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:69.28,71.3 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:74.70,80.60 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:80.60,83.4 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:84.74,84.75 0 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:87.2,87.33 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:87.33,89.3 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:92.2,92.46 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:92.46,94.17 2 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:94.17,96.4 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:97.3,98.17 2 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:98.17,100.4 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:101.3,101.15 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:104.2,104.19 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:107.44,114.16 5 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:114.16,116.3 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:119.2,119.44 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:119.44,121.3 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:123.2,123.46 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:123.46,125.3 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:127.2,127.44 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:127.44,129.3 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:132.2,132.39 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:132.39,134.3 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:136.2,136.12 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:139.37,140.21 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:140.21,142.3 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:145.2,145.47 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:145.47,147.3 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:149.2,150.16 2 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:150.16,152.3 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:154.2,154.17 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:154.17,156.3 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:158.2,159.49 2 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:159.49,161.3 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:163.2,163.83 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:163.83,164.34 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:164.34,166.4 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:169.2,169.12 1 1 +github.com/echovault/sugardb/internal/aof/preamble/store.go:172.35,175.21 3 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:175.21,177.3 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:178.2,178.41 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:178.41,180.3 1 0 +github.com/echovault/sugardb/internal/aof/preamble/store.go:181.2,181.12 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 1 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 0 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 0 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 0 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 0 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 0 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 0 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 0 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 0 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 0 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 0 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 0 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 0 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 1 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 0 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 1 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 1 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 1 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 0 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 0 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 0 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 0 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 0 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 0 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 0 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 0 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 0 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 0 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 0 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 0 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 0 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 0 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 0 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 0 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 0 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 0 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 0 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 0 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 0 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 0 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 0 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 0 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 0 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 0 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 0 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 0 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 0 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 0 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 0 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 0 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 0 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 0 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 0 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 0 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 0 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 0 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 0 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 0 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 0 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 0 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 0 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 0 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 0 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 0 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 0 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 0 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:55.54,56.28 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:56.28,58.3 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:61.55,62.28 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:62.28,64.3 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:67.55,68.28 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:68.28,70.3 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:73.57,74.28 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:74.28,76.3 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:79.85,80.28 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:80.28,82.3 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:85.68,93.55 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:93.56,93.57 0 0 +github.com/echovault/sugardb/internal/aof/log/store.go:96.2,96.33 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:96.33,98.3 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:101.2,101.46 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:101.46,104.17 2 1 +github.com/echovault/sugardb/internal/aof/log/store.go:104.17,106.4 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:107.3,108.17 2 1 +github.com/echovault/sugardb/internal/aof/log/store.go:108.17,110.4 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:111.3,111.15 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:116.2,116.51 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:116.51,117.13 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:117.13,119.17 2 1 +github.com/echovault/sugardb/internal/aof/log/store.go:119.17,121.5 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:122.4,122.8 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:122.8,124.40 2 1 +github.com/echovault/sugardb/internal/aof/log/store.go:124.40,127.11 3 1 +github.com/echovault/sugardb/internal/aof/log/store.go:129.5,130.15 2 0 +github.com/echovault/sugardb/internal/aof/log/store.go:135.2,135.19 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:138.63,140.21 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:140.21,142.3 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:144.2,150.39 3 1 +github.com/echovault/sugardb/internal/aof/log/store.go:150.39,152.17 2 1 +github.com/echovault/sugardb/internal/aof/log/store.go:152.17,154.4 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:155.3,155.35 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:158.2,158.51 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:158.51,160.3 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:162.2,162.49 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:162.49,163.38 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:163.38,165.4 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:168.2,168.12 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:171.34,172.21 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:172.21,174.3 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:175.2,175.12 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:178.37,183.47 3 1 +github.com/echovault/sugardb/internal/aof/log/store.go:183.47,185.3 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:187.2,190.6 3 1 +github.com/echovault/sugardb/internal/aof/log/store.go:190.6,192.34 2 1 +github.com/echovault/sugardb/internal/aof/log/store.go:192.34,194.4 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:195.3,195.13 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:195.13,197.9 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:200.3,201.17 2 1 +github.com/echovault/sugardb/internal/aof/log/store.go:201.17,203.4 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:206.3,207.17 2 1 +github.com/echovault/sugardb/internal/aof/log/store.go:207.17,209.4 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:211.3,211.42 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:211.42,213.18 2 1 +github.com/echovault/sugardb/internal/aof/log/store.go:213.18,215.5 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:217.4,217.12 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:220.3,220.41 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:223.2,223.12 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:226.38,230.45 3 0 +github.com/echovault/sugardb/internal/aof/log/store.go:230.45,232.3 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:235.2,235.47 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:235.47,237.3 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:240.2,242.16 2 0 +github.com/echovault/sugardb/internal/aof/log/store.go:242.16,244.3 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:246.2,246.39 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:246.39,248.3 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:250.2,250.12 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:253.35,256.21 3 1 +github.com/echovault/sugardb/internal/aof/log/store.go:256.21,258.3 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:259.2,259.41 1 1 +github.com/echovault/sugardb/internal/aof/log/store.go:259.41,261.3 1 0 +github.com/echovault/sugardb/internal/aof/log/store.go:262.2,262.12 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 0 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 0 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 0 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 0 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 0 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 0 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 0 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 0 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 0 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 0 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 0 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 0 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 0 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 1 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 0 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 1 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 1 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 1 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 0 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 0 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 0 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 0 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 0 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 0 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 0 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 0 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 0 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 0 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 0 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 0 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 0 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 0 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 0 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 0 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 0 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 0 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 0 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 0 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 0 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 0 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 0 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 1 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 1 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 1 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 1 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 1 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 1 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 1 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 1 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 1 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 0 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 0 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 0 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 0 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 0 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 0 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 0 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 0 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 0 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 0 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 0 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 0 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 0 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 0 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 0 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 0 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 0 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 0 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 0 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 0 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/aof/engine.go:48.56,49.30 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:49.30,51.3 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:54.57,55.30 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:55.30,57.3 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:60.59,61.30 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:61.30,63.3 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:66.58,67.30 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:67.30,69.3 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:72.59,73.30 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:73.30,75.3 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:78.90,79.30 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:79.30,81.3 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:84.103,85.30 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:85.30,87.3 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:90.87,91.30 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:91.30,93.3 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:96.74,97.30 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:97.30,99.3 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:102.72,103.30 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:103.30,105.3 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:108.69,115.29 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:115.30,115.31 0 0 +github.com/echovault/sugardb/internal/aof/engine.go:116.30,116.31 0 0 +github.com/echovault/sugardb/internal/aof/engine.go:117.65,117.79 1 0 +github.com/echovault/sugardb/internal/aof/engine.go:118.77,118.78 0 0 +github.com/echovault/sugardb/internal/aof/engine.go:119.58,119.59 0 0 +github.com/echovault/sugardb/internal/aof/engine.go:124.2,124.33 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:124.33,126.3 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:129.2,136.16 2 1 +github.com/echovault/sugardb/internal/aof/engine.go:136.16,138.3 1 0 +github.com/echovault/sugardb/internal/aof/engine.go:139.2,149.16 3 1 +github.com/echovault/sugardb/internal/aof/engine.go:149.16,151.3 1 0 +github.com/echovault/sugardb/internal/aof/engine.go:152.2,154.20 2 1 +github.com/echovault/sugardb/internal/aof/engine.go:157.64,158.68 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:158.68,160.3 1 0 +github.com/echovault/sugardb/internal/aof/engine.go:163.42,171.62 5 1 +github.com/echovault/sugardb/internal/aof/engine.go:171.62,173.3 1 0 +github.com/echovault/sugardb/internal/aof/engine.go:176.2,176.54 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:176.54,178.3 1 0 +github.com/echovault/sugardb/internal/aof/engine.go:180.2,180.12 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:183.39,184.55 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:184.55,186.3 1 0 +github.com/echovault/sugardb/internal/aof/engine.go:187.2,187.53 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:187.53,189.3 1 0 +github.com/echovault/sugardb/internal/aof/engine.go:190.2,190.12 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:193.31,194.53 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:194.53,196.3 1 0 +github.com/echovault/sugardb/internal/aof/engine.go:197.2,197.51 1 1 +github.com/echovault/sugardb/internal/aof/engine.go:197.51,199.3 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 1 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:32.88,35.9 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:35.9,37.3 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:39.2,39.33 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:40.18,42.56 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:43.20,45.62 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:46.10,47.15 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:52.60,55.16 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:55.16,58.3 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:60.2,60.12 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:64.55,66.2 0 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:42.47,46.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:49.54,59.16 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:63.2,63.10 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:67.54,69.55 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:69.55,72.3 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:74.2,74.20 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:75.18,77.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:77.39,80.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:86.19,88.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:88.39,91.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:93.3,99.67 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:99.67,101.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:103.20,105.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:105.39,108.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:110.3,115.17 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:115.17,118.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.3,120.67 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.67,122.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:127.71,129.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:132.56,135.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:138.68,140.2 0 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:33.62,37.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:40.71,42.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:45.72,52.16 4 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:52.16,55.3 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:57.2,59.16 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:65.74,67.2 0 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:56.43,63.2 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:65.58,80.26 7 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:80.26,84.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:85.26,89.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:93.2,94.41 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:94.41,99.3 4 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:101.2,104.16 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:104.16,106.3 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.2,108.37 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.37,111.70 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:111.70,113.18 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:113.18,115.5 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:116.4,116.14 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.3,119.17 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.17,121.4 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:123.3,123.27 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:127.45,137.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:141.72,154.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:158.75,171.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:173.43,176.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:176.16,179.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:181.2,182.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:182.16,185.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:187.2,187.49 1 0 +github.com/echovault/sugardb/internal/eviction/lfu.go:39.30,47.2 3 1 +github.com/echovault/sugardb/internal/eviction/lfu.go:49.58,51.69 1 0 +github.com/echovault/sugardb/internal/eviction/lfu.go:51.69,53.3 1 0 +github.com/echovault/sugardb/internal/eviction/lfu.go:55.2,55.19 1 0 +github.com/echovault/sugardb/internal/eviction/lfu.go:55.19,58.3 2 0 +github.com/echovault/sugardb/internal/eviction/lfu.go:58.8,60.3 1 0 +github.com/echovault/sugardb/internal/eviction/lfu.go:64.32,67.2 2 0 +github.com/echovault/sugardb/internal/eviction/lfu.go:69.34,71.2 1 1 +github.com/echovault/sugardb/internal/eviction/lfu.go:73.44,75.54 1 1 +github.com/echovault/sugardb/internal/eviction/lfu.go:75.54,77.3 1 1 +github.com/echovault/sugardb/internal/eviction/lfu.go:79.2,79.56 1 1 +github.com/echovault/sugardb/internal/eviction/lfu.go:82.39,86.2 3 1 +github.com/echovault/sugardb/internal/eviction/lfu.go:88.38,97.2 3 1 +github.com/echovault/sugardb/internal/eviction/lfu.go:99.34,108.2 8 1 +github.com/echovault/sugardb/internal/eviction/lfu.go:110.43,113.26 1 1 +github.com/echovault/sugardb/internal/eviction/lfu.go:113.26,116.3 2 1 +github.com/echovault/sugardb/internal/eviction/lfu.go:118.2,118.69 1 1 +github.com/echovault/sugardb/internal/eviction/lfu.go:118.69,120.3 1 1 +github.com/echovault/sugardb/internal/eviction/lfu.go:121.2,123.27 3 1 +github.com/echovault/sugardb/internal/eviction/lfu.go:126.43,127.73 1 0 +github.com/echovault/sugardb/internal/eviction/lfu.go:127.73,129.3 1 0 +github.com/echovault/sugardb/internal/eviction/lfu.go:130.2,130.19 1 0 +github.com/echovault/sugardb/internal/eviction/lfu.go:130.19,132.3 1 0 +github.com/echovault/sugardb/internal/eviction/lfu.go:135.50,138.2 2 1 +github.com/echovault/sugardb/internal/eviction/lru.go:38.30,46.2 3 1 +github.com/echovault/sugardb/internal/eviction/lru.go:48.59,50.69 1 0 +github.com/echovault/sugardb/internal/eviction/lru.go:50.69,52.3 1 0 +github.com/echovault/sugardb/internal/eviction/lru.go:53.2,53.19 1 0 +github.com/echovault/sugardb/internal/eviction/lru.go:53.19,56.3 2 0 +github.com/echovault/sugardb/internal/eviction/lru.go:56.8,58.3 1 0 +github.com/echovault/sugardb/internal/eviction/lru.go:61.32,64.2 2 0 +github.com/echovault/sugardb/internal/eviction/lru.go:66.34,68.2 1 1 +github.com/echovault/sugardb/internal/eviction/lru.go:70.44,72.2 1 1 +github.com/echovault/sugardb/internal/eviction/lru.go:74.39,78.2 3 1 +github.com/echovault/sugardb/internal/eviction/lru.go:80.38,87.2 2 1 +github.com/echovault/sugardb/internal/eviction/lru.go:89.34,98.2 8 1 +github.com/echovault/sugardb/internal/eviction/lru.go:100.43,103.26 1 1 +github.com/echovault/sugardb/internal/eviction/lru.go:103.26,105.3 1 1 +github.com/echovault/sugardb/internal/eviction/lru.go:107.2,107.69 1 1 +github.com/echovault/sugardb/internal/eviction/lru.go:107.69,109.3 1 1 +github.com/echovault/sugardb/internal/eviction/lru.go:110.2,112.27 3 1 +github.com/echovault/sugardb/internal/eviction/lru.go:115.43,116.73 1 0 +github.com/echovault/sugardb/internal/eviction/lru.go:116.73,118.3 1 0 +github.com/echovault/sugardb/internal/eviction/lru.go:119.2,119.19 1 0 +github.com/echovault/sugardb/internal/eviction/lru.go:119.19,121.3 1 0 +github.com/echovault/sugardb/internal/eviction/lru.go:124.50,127.2 2 1 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 1 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 0 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 1 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 0 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 0 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 0 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 1 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 1 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 1 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 0 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 0 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 0 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 1 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 1 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 1 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 1 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 1 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 1 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 1 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 1 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 1 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 1 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 1 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 1 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 1 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 1 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 1 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 1 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 1 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 1 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 1 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 1 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 1 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 1 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 1 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 0 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 0 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 0 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 1 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 1 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 0 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 0 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 0 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 0 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 0 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 1 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 1 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 1 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 1 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 1 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 1 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 1 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 1 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 1 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 1 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 1 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 1 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 1 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 0 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 1 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 1 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 1 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 1 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 1 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 1 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 0 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:32.88,35.9 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:35.9,37.3 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:39.2,39.33 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:40.18,42.56 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:43.20,45.62 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:46.10,47.15 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:52.60,55.16 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:55.16,58.3 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:60.2,60.12 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:64.55,66.2 0 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:42.47,46.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:49.54,59.16 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:63.2,63.10 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:67.54,69.55 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:69.55,72.3 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:74.2,74.20 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:75.18,77.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:77.39,80.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:86.19,88.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:88.39,91.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:93.3,99.67 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:99.67,101.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:103.20,105.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:105.39,108.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:110.3,115.17 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:115.17,118.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.3,120.67 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.67,122.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:127.71,129.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:132.56,135.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:138.68,140.2 0 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:33.62,37.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:40.71,42.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:45.72,52.16 4 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:52.16,55.3 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:57.2,59.16 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:65.74,67.2 0 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:56.43,63.2 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:65.58,80.26 7 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:80.26,84.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:85.26,89.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:93.2,94.41 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:94.41,99.3 4 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:101.2,104.16 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:104.16,106.3 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.2,108.37 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.37,111.70 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:111.70,113.18 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:113.18,115.5 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:116.4,116.14 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.3,119.17 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.17,121.4 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:123.3,123.27 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:127.45,137.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:141.72,154.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:158.75,171.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:173.43,176.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:176.16,179.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:181.2,182.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:182.16,185.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:187.2,187.49 1 0 +github.com/echovault/sugardb/internal/modules/connection/commands.go:28.68,29.56 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:29.56,31.3 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:32.2,33.9 2 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:33.9,35.3 1 0 +github.com/echovault/sugardb/internal/modules/connection/commands.go:36.2,39.116 3 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:39.116,41.3 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:42.2,42.42 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:45.68,46.29 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:47.10,48.54 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:49.9,50.34 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:51.9,52.94 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:56.68,57.30 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:57.30,59.3 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:60.2,60.93 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:63.69,64.65 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:64.65,66.3 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:68.2,68.30 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:68.30,72.3 3 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:74.2,90.16 2 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:90.16,92.3 1 0 +github.com/echovault/sugardb/internal/modules/connection/commands.go:95.2,96.16 2 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:96.16,98.3 1 0 +github.com/echovault/sugardb/internal/modules/connection/commands.go:99.2,99.45 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:99.45,101.3 1 0 +github.com/echovault/sugardb/internal/modules/connection/commands.go:102.2,105.31 2 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:105.31,107.10 2 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:107.10,109.4 1 0 +github.com/echovault/sugardb/internal/modules/connection/commands.go:110.3,116.17 3 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:116.17,118.4 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:122.2,128.60 5 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:131.70,132.30 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:132.30,134.3 1 0 +github.com/echovault/sugardb/internal/modules/connection/commands.go:136.2,137.16 2 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:137.16,139.3 1 0 +github.com/echovault/sugardb/internal/modules/connection/commands.go:140.2,140.18 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:140.18,142.3 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:144.2,147.42 3 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:150.70,151.30 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:151.30,153.3 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:155.2,156.16 2 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:156.16,158.3 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:160.2,161.16 2 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:161.16,163.3 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:165.2,165.36 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:165.36,167.3 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:169.2,171.42 2 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:174.36,185.84 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:185.84,191.5 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:203.84,209.5 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:219.84,225.5 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:237.84,243.5 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:253.84,259.5 1 1 +github.com/echovault/sugardb/internal/modules/connection/commands.go:277.84,283.5 1 1 +github.com/echovault/sugardb/internal/modules/connection/utils.go:20.80,21.19 1 1 +github.com/echovault/sugardb/internal/modules/connection/utils.go:21.19,23.3 1 1 +github.com/echovault/sugardb/internal/modules/connection/utils.go:24.2,24.33 1 1 +github.com/echovault/sugardb/internal/modules/connection/utils.go:25.14,26.19 1 1 +github.com/echovault/sugardb/internal/modules/connection/utils.go:26.19,28.4 1 0 +github.com/echovault/sugardb/internal/modules/connection/utils.go:29.3,32.43 4 1 +github.com/echovault/sugardb/internal/modules/connection/utils.go:33.17,34.19 1 1 +github.com/echovault/sugardb/internal/modules/connection/utils.go:34.19,36.4 1 0 +github.com/echovault/sugardb/internal/modules/connection/utils.go:37.3,38.43 2 1 +github.com/echovault/sugardb/internal/modules/connection/utils.go:39.10,40.76 1 0 +github.com/echovault/sugardb/internal/modules/connection/utils.go:44.104,47.34 2 1 +github.com/echovault/sugardb/internal/modules/connection/utils.go:47.34,50.3 1 1 +github.com/echovault/sugardb/internal/modules/connection/utils.go:50.8,53.3 1 1 +github.com/echovault/sugardb/internal/modules/connection/utils.go:55.2,62.44 8 1 +github.com/echovault/sugardb/internal/modules/connection/utils.go:62.44,64.3 1 1 +github.com/echovault/sugardb/internal/modules/connection/utils.go:65.2,65.12 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:48.36,52.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:55.50,56.18 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:57.10,57.10 0 0 +github.com/echovault/sugardb/internal/raft/fsm.go:59.23,62.60 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:62.60,67.4 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:69.3,74.40 5 0 +github.com/echovault/sugardb/internal/raft/fsm.go:75.11,79.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:81.21,82.66 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:82.66,87.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:88.4,91.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:93.18,96.18 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:96.18,101.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:103.4,106.18 3 0 +github.com/echovault/sugardb/internal/raft/fsm.go:106.18,111.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:112.4,113.10 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:113.10,115.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.4,117.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.96,122.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:122.10,127.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:131.2,131.12 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:135.54,143.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:146.55,149.16 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:149.16,152.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:154.2,159.48 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:159.48,162.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.2,165.81 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.81,167.34 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:167.34,168.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:168.96,170.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:171.4,171.60 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:176.2,178.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:39.50,43.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:46.58,50.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:50.16,53.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:55.2,62.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:62.16,65.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.2,67.40 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.40,70.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:72.2,74.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:78.30,80.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:52.31,56.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:58.46,70.24 9 0 +github.com/echovault/sugardb/internal/raft/raft.go:70.24,75.3 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:75.8,77.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:77.17,79.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:86.3,89.17 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:89.17,91.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:94.2,96.16 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:96.16,98.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:100.2,108.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:108.16,110.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:113.2,133.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:133.16,135.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.2,137.27 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.27,148.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:150.2,150.21 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:153.74,155.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:157.36,159.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:161.38,163.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:165.40,172.2 4 0 +github.com/echovault/sugardb/internal/raft/raft.go:179.9,180.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:180.22,182.44 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:182.44,184.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.3,186.56 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.56,188.42 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:188.42,190.5 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:193.3,194.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:194.17,196.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:199.2,199.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:202.61,203.23 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:203.23,205.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.2,207.73 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.73,209.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:211.2,211.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:214.37,216.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:218.31,220.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:220.22,222.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:222.17,225.4 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:226.3,226.49 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 0 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 0 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 0 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 0 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 0 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 0 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 0 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 0 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 0 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 0 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 0 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 0 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 1 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 1 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 1 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 1 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 1 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 1 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 1 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 1 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 1 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 1 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 1 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 1 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 1 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 1 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 1 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 1 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 1 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 1 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 1 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 1 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 1 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 1 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 1 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 0 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 0 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 0 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 0 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 0 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 0 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 0 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 0 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 0 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 0 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 1 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 1 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 1 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 1 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 1 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 1 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 1 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 1 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 1 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 1 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 1 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 1 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 1 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 1 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 1 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 1 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 1 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 1 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 1 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 1 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 1 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 1 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 1 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 1 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 1 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 1 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 1 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 1 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 1 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 1 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 1 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 1 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 1 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 1 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 1 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 0 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:32.88,35.9 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:35.9,37.3 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:39.2,39.33 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:40.18,42.56 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:43.20,45.62 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:46.10,47.15 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:52.60,55.16 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:55.16,58.3 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:60.2,60.12 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:64.55,66.2 0 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:42.47,46.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:49.54,59.16 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:63.2,63.10 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:67.54,69.55 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:69.55,72.3 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:74.2,74.20 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:75.18,77.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:77.39,80.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:86.19,88.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:88.39,91.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:93.3,99.67 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:99.67,101.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:103.20,105.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:105.39,108.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:110.3,115.17 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:115.17,118.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.3,120.67 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.67,122.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:127.71,129.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:132.56,135.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:138.68,140.2 0 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:33.62,37.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:40.71,42.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:45.72,52.16 4 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:52.16,55.3 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:57.2,59.16 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:65.74,67.2 0 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:56.43,63.2 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:65.58,80.26 7 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:80.26,84.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:85.26,89.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:93.2,94.41 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:94.41,99.3 4 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:101.2,104.16 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:104.16,106.3 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.2,108.37 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.37,111.70 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:111.70,113.18 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:113.18,115.5 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:116.4,116.14 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.3,119.17 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.17,121.4 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:123.3,123.27 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:127.45,137.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:141.72,154.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:158.75,171.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:173.43,176.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:176.16,179.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:181.2,182.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:182.16,185.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:187.2,187.49 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:53.55,56.20 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:56.20,58.70 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:58.70,61.4 2 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:63.3,64.17 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:64.17,67.4 2 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:69.3,69.16 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:69.16,70.36 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:70.36,72.5 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:75.3,77.38 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:77.38,78.60 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:78.60,81.5 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:84.3,84.71 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:84.71,85.60 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:85.60,88.5 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:92.2,92.14 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:95.40,100.24 3 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:100.24,108.3 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:111.2,115.29 3 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:115.29,116.33 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:116.33,118.9 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:121.2,121.20 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:121.20,123.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:126.2,126.29 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:126.29,128.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:130.2,140.13 3 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:143.52,148.70 3 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:148.70,150.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:151.2,155.3 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:158.45,164.33 3 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:164.33,165.30 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:165.30,166.47 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:166.47,168.5 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:168.10,171.5 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:175.2,176.45 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:176.45,178.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:180.2,187.12 4 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:190.73,195.37 4 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:195.37,196.28 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:196.28,198.12 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:201.3,201.31 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:201.31,202.30 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:202.30,204.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:207.3,207.18 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:207.18,208.12 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:211.3,211.52 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:211.52,212.49 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:212.49,214.5 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:217.3,217.63 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:217.63,219.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:221.2,221.12 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:224.95,228.19 3 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:228.19,237.60 4 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:237.60,239.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:240.3,240.24 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:243.2,243.19 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:243.19,253.31 5 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:253.31,254.28 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:254.28,257.10 3 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:260.3,260.17 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:260.17,262.4 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:266.2,266.19 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:266.19,268.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:271.2,271.21 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:271.21,277.3 2 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:279.2,279.46 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:279.46,280.38 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:280.38,283.18 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:283.18,290.5 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:294.2,294.50 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:297.131,306.16 6 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:310.2,314.59 4 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:314.59,318.17 4 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:318.17,320.4 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:324.2,324.36 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:324.36,326.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:329.2,329.79 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:329.79,331.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:334.2,334.37 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:334.37,336.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:339.2,342.29 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:342.29,344.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:347.2,347.57 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:347.57,349.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:351.2,355.63 3 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:355.63,356.39 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:356.39,358.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:359.3,359.63 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:359.63,360.36 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:360.36,362.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:364.3,365.26 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:365.26,367.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:371.2,371.64 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:371.64,372.101 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:372.101,373.63 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:373.63,376.5 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:377.4,377.16 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:379.5,381.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:384.2,384.94 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:384.94,386.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:386.5,388.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:391.2,391.93 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:391.93,393.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:393.5,395.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:398.2,398.59 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:398.59,400.36 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:400.36,402.106 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:402.106,404.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:404.7,406.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:408.4,408.105 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:408.105,410.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:410.7,412.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:414.3,414.13 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:417.2,417.45 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:417.45,419.29 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:419.29,421.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:424.3,424.80 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:424.80,425.95 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:425.95,426.49 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:426.49,428.6 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:429.5,429.70 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:429.70,431.6 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:432.5,432.17 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:434.6,435.27 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:435.27,437.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:441.3,441.82 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:441.82,442.97 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:442.97,443.50 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:443.50,445.6 1 0 +github.com/echovault/sugardb/internal/modules/acl/acl.go:446.5,446.70 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:446.70,448.6 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:449.5,449.17 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:451.6,453.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:456.2,456.12 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:459.32,463.33 3 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:463.33,468.31 5 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:468.31,469.37 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:469.37,471.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:473.3,473.25 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:476.2,476.29 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:476.29,477.33 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:477.33,479.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:483.29,485.2 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:487.31,489.2 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:491.30,493.2 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:495.32,497.2 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:499.68,501.31 2 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:501.31,502.13 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:502.13,504.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:507.2,507.58 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:507.58,509.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/acl.go:510.2,510.19 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:31.67,32.29 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:32.29,34.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:36.2,40.35 3 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:40.35,41.36 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:41.36,42.48 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:42.48,44.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:45.4,45.12 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:47.3,47.50 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:47.50,48.51 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:48.51,51.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:55.2,55.30 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:55.30,58.34 3 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:58.34,61.4 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:62.3,63.28 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:63.28,65.24 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:65.24,67.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:69.3,69.26 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:72.2,72.30 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:72.30,74.46 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:74.46,75.54 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:75.54,77.38 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:77.38,79.30 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:79.30,81.7 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:83.5,83.28 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:88.2,88.85 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:91.71,92.30 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:92.30,94.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:96.2,97.9 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:97.9,99.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:100.2,105.30 5 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:105.30,106.38 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:106.38,109.9 3 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:113.2,113.16 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:113.16,115.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:118.2,122.18 3 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:122.18,124.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:124.8,126.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:127.2,127.21 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:127.21,129.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:130.2,130.17 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:130.17,132.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:134.2,135.29 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:135.29,137.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:140.2,141.51 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:141.51,142.22 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:142.22,144.12 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:146.3,146.49 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:148.2,148.51 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:148.51,149.22 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:149.22,151.12 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:153.3,153.49 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:157.2,158.48 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:158.48,159.21 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:159.21,161.12 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:163.3,163.47 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:165.2,165.48 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:165.48,166.21 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:166.21,168.12 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:170.3,170.47 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:174.2,175.79 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:175.79,176.37 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:176.37,178.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:180.2,181.30 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:181.30,182.10 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:183.100,185.53 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:186.53,188.52 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:189.52,191.52 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:196.2,198.54 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:198.54,200.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:201.2,201.54 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:201.54,203.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:205.2,207.25 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:210.69,212.9 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:212.9,214.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:216.2,217.33 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:217.33,219.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:220.2,221.25 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:224.71,226.9 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:226.9,228.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:229.2,229.56 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:229.56,231.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:232.2,232.42 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:235.71,236.29 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:236.29,238.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:239.2,240.9 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:240.9,242.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:243.2,243.75 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:243.75,245.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:246.2,246.42 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:249.70,251.9 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:251.9,253.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:254.2,258.74 4 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:261.68,262.29 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:262.29,264.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:265.2,266.9 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:266.9,268.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:269.2,274.33 5 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:274.33,277.19 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:277.19,279.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:279.9,281.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:283.3,283.22 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:283.22,285.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:287.3,287.18 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:287.18,289.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:291.3,291.43 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:291.43,292.61 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:292.61,294.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:295.4,295.58 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:295.58,297.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:300.3,300.52 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:300.52,301.23 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:301.23,303.13 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:305.4,305.39 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:308.3,308.52 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:308.52,309.23 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:309.23,311.13 2 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:313.4,313.39 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:316.3,316.49 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:316.49,317.22 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:317.22,319.13 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:321.4,321.37 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:324.3,324.49 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:324.49,325.22 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:325.22,327.13 2 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:329.4,329.37 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:332.3,332.45 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:332.45,333.52 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:333.52,335.13 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:337.4,337.41 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:340.3,340.46 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:340.46,341.52 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:341.52,343.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:346.3,346.55 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:346.55,348.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:350.3,350.55 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:350.55,352.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:353.3,353.54 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:356.2,357.25 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:360.68,361.30 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:361.30,363.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:365.2,366.9 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:366.9,368.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:369.2,373.16 4 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:373.16,375.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:377.2,377.15 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:377.15,378.35 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:378.35,380.4 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:383.2,387.37 3 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:387.37,388.59 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:388.59,390.4 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:393.2,393.70 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:393.70,394.59 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:394.59,396.4 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:400.2,400.29 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:400.29,404.31 3 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:404.31,405.35 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:405.35,408.54 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:408.54,410.6 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:410.11,413.6 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:414.5,414.10 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:418.3,418.17 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:418.17,420.4 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:423.2,423.42 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:426.68,427.29 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:427.29,429.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:431.2,432.9 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:432.9,434.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:435.2,439.16 4 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:439.16,441.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:443.2,443.15 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:443.15,444.35 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:444.35,446.4 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:449.2,451.37 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:451.37,454.17 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:454.17,456.4 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:457.3,457.40 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:457.40,459.4 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:462.2,462.70 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:462.70,465.17 2 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:465.17,467.4 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:468.3,468.40 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:468.40,470.4 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:473.2,473.32 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:473.32,475.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/commands.go:477.2,477.42 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:480.36,489.84 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:489.84,495.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:504.86,510.7 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:519.86,525.7 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:534.86,540.7 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:549.86,555.7 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:565.86,571.7 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:580.86,586.7 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:595.86,601.7 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:613.86,619.7 1 1 +github.com/echovault/sugardb/internal/modules/acl/commands.go:628.86,634.7 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:53.31,55.39 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:55.39,57.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:58.2,59.51 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:59.51,61.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:63.2,64.37 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:64.37,66.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:67.2,68.49 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:68.49,70.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:72.2,73.53 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:73.53,75.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:76.2,77.54 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:77.54,79.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:81.2,82.43 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:82.43,84.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:85.2,86.55 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:86.55,88.3 1 0 +github.com/echovault/sugardb/internal/modules/acl/user.go:91.2,91.64 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:91.64,97.3 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:100.79,102.32 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:102.32,103.24 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:103.24,105.12 2 0 +github.com/echovault/sugardb/internal/modules/acl/user.go:107.3,107.25 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:109.2,109.33 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:109.33,110.41 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:110.41,113.4 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:114.3,114.17 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:114.17,116.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:118.2,118.8 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:121.50,122.26 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:122.26,124.35 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:124.35,126.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:127.3,127.36 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:127.36,129.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:131.3,131.37 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:131.37,137.12 3 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:139.3,139.20 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:139.20,140.84 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:140.84,142.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:143.4,143.12 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:145.3,145.20 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:145.20,146.84 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:146.84,148.5 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:149.4,149.12 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:152.3,152.43 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:152.43,155.12 3 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:157.3,157.46 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:157.46,159.12 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:161.3,161.36 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:161.36,162.21 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:162.21,164.13 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:166.4,166.21 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:166.21,168.13 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:172.3,172.40 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:172.40,176.12 4 0 +github.com/echovault/sugardb/internal/modules/acl/user.go:178.3,178.93 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:178.93,183.12 5 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:185.3,185.57 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:185.57,188.12 3 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:190.3,190.57 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:190.57,193.12 3 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:196.3,196.44 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:196.44,198.12 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:200.3,200.36 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:200.36,201.21 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:201.21,203.13 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:205.4,205.21 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:205.21,207.13 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:211.3,211.44 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:211.44,214.12 3 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:216.3,216.66 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:216.66,217.21 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:217.21,219.13 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:221.4,221.21 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:221.21,223.13 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:229.2,229.26 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:229.26,230.39 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:230.39,233.4 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:236.2,236.26 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:236.26,238.42 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:238.42,241.4 2 0 +github.com/echovault/sugardb/internal/modules/acl/user.go:243.3,243.43 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:243.43,248.4 4 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:250.3,250.60 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:250.60,254.4 3 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:256.3,256.46 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:256.46,259.4 2 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:262.2,262.12 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:265.36,279.41 12 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:279.41,280.65 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:280.65,282.4 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:282.6,284.4 1 0 +github.com/echovault/sugardb/internal/modules/acl/user.go:287.2,287.18 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:290.38,303.2 12 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:305.40,320.2 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:322.46,323.24 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:323.24,325.3 1 1 +github.com/echovault/sugardb/internal/modules/acl/user.go:326.2,326.26 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:48.36,52.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:55.50,56.18 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:57.10,57.10 0 0 +github.com/echovault/sugardb/internal/raft/fsm.go:59.23,62.60 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:62.60,67.4 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:69.3,74.40 5 0 +github.com/echovault/sugardb/internal/raft/fsm.go:75.11,79.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:81.21,82.66 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:82.66,87.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:88.4,91.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:93.18,96.18 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:96.18,101.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:103.4,106.18 3 0 +github.com/echovault/sugardb/internal/raft/fsm.go:106.18,111.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:112.4,113.10 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:113.10,115.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.4,117.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.96,122.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:122.10,127.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:131.2,131.12 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:135.54,143.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:146.55,149.16 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:149.16,152.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:154.2,159.48 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:159.48,162.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.2,165.81 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.81,167.34 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:167.34,168.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:168.96,170.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:171.4,171.60 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:176.2,178.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:39.50,43.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:46.58,50.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:50.16,53.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:55.2,62.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:62.16,65.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.2,67.40 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.40,70.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:72.2,74.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:78.30,80.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:52.31,56.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:58.46,70.24 9 0 +github.com/echovault/sugardb/internal/raft/raft.go:70.24,75.3 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:75.8,77.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:77.17,79.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:86.3,89.17 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:89.17,91.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:94.2,96.16 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:96.16,98.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:100.2,108.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:108.16,110.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:113.2,133.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:133.16,135.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.2,137.27 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.27,148.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:150.2,150.21 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:153.74,155.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:157.36,159.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:161.38,163.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:165.40,172.2 4 0 +github.com/echovault/sugardb/internal/raft/raft.go:179.9,180.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:180.22,182.44 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:182.44,184.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.3,186.56 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.56,188.42 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:188.42,190.5 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:193.3,194.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:194.17,196.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:199.2,199.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:202.61,203.23 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:203.23,205.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.2,207.73 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.73,209.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:211.2,211.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:214.37,216.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:218.31,220.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:220.22,222.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:222.17,225.4 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:226.3,226.49 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 1 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 0 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 1 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 0 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 0 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 1 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 1 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 1 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 1 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 1 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 1 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 1 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 1 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 1 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 1 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 1 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 1 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 1 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 1 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 1 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 1 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 1 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 1 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 1 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 1 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 1 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 1 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 1 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 1 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 1 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 0 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 0 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 0 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 0 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 1 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 1 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 0 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 1 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 1 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 1 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 0 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 0 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 0 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 0 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 0 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 0 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 0 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 0 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 0 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 0 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 1 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 1 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 1 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 1 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 1 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 1 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 1 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 1 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 0 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 1 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 1 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 1 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 1 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 1 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 1 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 1 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:32.88,35.9 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:35.9,37.3 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:39.2,39.33 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:40.18,42.56 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:43.20,45.62 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:46.10,47.15 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:52.60,55.16 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:55.16,58.3 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:60.2,60.12 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:64.55,66.2 0 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:42.47,46.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:49.54,59.16 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:63.2,63.10 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:67.54,69.55 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:69.55,72.3 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:74.2,74.20 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:75.18,77.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:77.39,80.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:86.19,88.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:88.39,91.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:93.3,99.67 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:99.67,101.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:103.20,105.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:105.39,108.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:110.3,115.17 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:115.17,118.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.3,120.67 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.67,122.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:127.71,129.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:132.56,135.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:138.68,140.2 0 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:33.62,37.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:40.71,42.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:45.72,52.16 4 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:52.16,55.3 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:57.2,59.16 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:65.74,67.2 0 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:56.43,63.2 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:65.58,80.26 7 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:80.26,84.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:85.26,89.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:93.2,94.41 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:94.41,99.3 4 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:101.2,104.16 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:104.16,106.3 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.2,108.37 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.37,111.70 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:111.70,113.18 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:113.18,115.5 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:116.4,116.14 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.3,119.17 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.17,121.4 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:123.3,123.27 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:127.45,137.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:141.72,154.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:158.75,171.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:173.43,176.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:176.16,179.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:181.2,182.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:182.16,185.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:187.2,187.49 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:30.68,32.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:32.16,34.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:36.2,40.36 4 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:40.36,42.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:44.2,44.49 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:44.49,47.3 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:49.2,49.16 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:49.16,50.95 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:50.95,52.4 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:53.3,53.59 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:56.2,57.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:57.9,59.95 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:59.95,61.4 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:62.3,62.59 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:65.2,66.44 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:67.16,69.33 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:69.33,70.33 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:70.33,72.5 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:75.3,75.34 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:75.34,77.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:78.10,80.34 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:80.34,81.35 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:81.35,83.5 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:85.3,85.23 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:88.2,88.94 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:88.94,90.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:92.2,92.51 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:95.68,97.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:97.16,99.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:101.2,105.16 4 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:105.16,107.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:109.2,110.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:110.9,112.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:114.2,117.31 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:117.31,119.25 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:119.25,121.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:123.3,123.40 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:123.40,125.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:127.3,127.37 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:127.37,129.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:131.3,131.41 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:131.41,134.12 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:136.3,136.32 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:139.2,139.25 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:142.69,144.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:144.16,146.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:148.2,150.16 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:150.16,152.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:154.2,155.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:155.9,157.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:159.2,164.31 4 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:164.31,166.10 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:166.10,168.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:171.3,171.40 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:171.40,173.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:175.3,175.37 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:175.37,177.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:179.3,179.41 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:179.41,182.12 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:184.3,184.32 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:187.2,187.25 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:190.71,192.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:192.16,194.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:196.2,200.16 4 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:200.16,202.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:204.2,205.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:205.9,207.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:209.2,212.31 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:212.31,214.25 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:214.25,216.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:218.3,218.40 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:218.40,220.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:222.3,222.41 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:222.41,225.12 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:227.3,227.37 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:227.37,229.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:231.3,231.18 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:234.2,234.25 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:237.69,239.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:239.16,241.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:243.2,246.16 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:246.16,248.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:250.2,251.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:251.9,253.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:255.2,256.27 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:256.27,257.38 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:257.38,259.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:261.3,261.39 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:261.39,264.12 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:266.3,266.35 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:266.35,268.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:271.2,271.25 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:274.74,276.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:276.16,278.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:280.2,284.30 4 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:284.30,286.17 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:286.17,288.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:289.3,289.13 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:289.13,291.4 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:292.3,292.12 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:295.2,296.30 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:296.30,297.57 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:297.57,299.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:299.9,301.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:304.2,304.16 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:304.16,306.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:308.2,309.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:309.9,311.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:314.2,314.24 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:314.24,316.17 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:316.17,318.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:319.3,319.34 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:319.34,321.18 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:321.18,322.42 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:322.42,324.14 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:326.5,326.43 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:326.43,329.14 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:331.5,331.39 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:331.39,333.14 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:337.3,337.26 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:341.2,342.29 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:342.29,344.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:347.2,349.46 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:349.46,353.16 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:353.16,354.59 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:354.59,356.5 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:360.2,361.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:361.16,363.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:364.2,364.38 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:364.38,366.17 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:366.17,367.47 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:367.47,369.13 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:371.4,371.48 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:371.48,374.13 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:376.4,376.44 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:376.44,378.13 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:383.2,383.25 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:386.68,388.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:388.16,390.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:392.2,395.16 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:395.16,397.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:399.2,400.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:400.9,402.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:404.2,404.55 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:407.69,409.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:409.16,411.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:413.2,416.16 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:416.16,418.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:420.2,421.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:421.9,423.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:425.2,426.29 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:426.29,428.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:430.2,430.25 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:433.71,435.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:435.16,437.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:439.2,446.58 6 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:446.58,448.17 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:448.17,450.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:451.3,451.21 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:452.8,454.17 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:454.17,456.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:457.3,457.19 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:460.2,460.16 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:460.16,462.59 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:462.59,464.93 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:464.93,466.5 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:467.4,467.96 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:468.9,470.93 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:470.93,472.5 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:473.4,473.60 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:477.2,478.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:478.9,480.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:482.2,482.30 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:482.30,484.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:486.2,486.34 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:487.10,488.69 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:489.11,491.59 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:491.59,493.4 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:493.9,495.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:496.15,498.59 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:498.59,500.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:500.9,502.4 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:505.2,505.91 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:505.91,507.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:509.2,509.46 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:509.46,511.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:513.2,514.47 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:517.71,519.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:519.16,521.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:523.2,526.16 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:526.16,528.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:530.2,531.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:531.9,533.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:535.2,536.33 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:536.33,538.40 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:538.40,540.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:542.3,542.41 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:542.41,545.4 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:547.3,547.37 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:547.37,549.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:552.2,552.25 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:555.71,557.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:557.16,559.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:561.2,565.16 4 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:565.16,567.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:569.2,570.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:570.9,572.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:574.2,574.30 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:574.30,576.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:578.2,578.30 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:581.68,583.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:583.16,585.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:587.2,591.16 4 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:591.16,593.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:595.2,596.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:596.9,598.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:600.2,602.31 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:602.31,603.31 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:603.31,606.4 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:609.2,609.91 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:609.91,611.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:613.2,613.51 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:616.71,618.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:618.16,620.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:621.2,626.16 4 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:626.16,628.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:631.2,632.28 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:632.28,634.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:634.8,634.35 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:634.35,636.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:636.8,638.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:641.2,642.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:642.16,644.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:645.2,655.16 6 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:655.16,656.34 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:656.34,658.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:659.3,659.27 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:662.2,663.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:663.9,665.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:668.2,668.18 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:668.18,669.34 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:669.34,671.4 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:672.3,672.27 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:675.2,675.20 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:675.20,677.38 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:678.13,679.29 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:679.29,681.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:681.12,683.14 2 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:685.5,686.41 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:686.41,688.14 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:690.5,691.19 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:691.19,693.6 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:695.5,695.27 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:698.13,699.29 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:699.29,701.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:701.12,703.14 2 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:705.5,706.41 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:706.41,708.14 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:710.5,711.19 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:711.19,713.6 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:715.5,715.27 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:718.13,719.29 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:719.29,721.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:721.12,723.14 2 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:725.5,727.77 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:727.77,729.14 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:731.5,732.19 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:732.19,734.6 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:736.5,736.27 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:739.13,740.29 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:740.29,742.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:742.12,744.14 2 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:746.5,747.77 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:747.77,749.14 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:751.5,752.19 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:752.19,754.6 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:756.5,756.27 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:759.11,760.123 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:762.8,763.28 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:763.28,765.11 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:765.11,767.13 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:769.4,770.18 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:770.18,772.5 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:774.4,774.26 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:780.2,780.26 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:783.68,785.16 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:785.16,787.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:789.2,791.16 3 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:791.16,793.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:795.2,802.16 5 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:802.16,805.3 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:808.2,809.9 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:809.9,811.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:814.2,814.31 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:814.31,816.10 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:816.10,818.12 2 0 +github.com/echovault/sugardb/internal/modules/hash/commands.go:820.3,820.34 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:820.34,822.12 2 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:824.3,824.114 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:829.2,829.26 1 1 +github.com/echovault/sugardb/internal/modules/hash/commands.go:832.36,998.2 1 1 +github.com/echovault/sugardb/internal/modules/hash/hash.go:17.30,23.26 3 1 +github.com/echovault/sugardb/internal/modules/hash/hash.go:23.26,31.33 5 1 +github.com/echovault/sugardb/internal/modules/hash/hash.go:34.12,35.13 1 0 +github.com/echovault/sugardb/internal/modules/hash/hash.go:36.12,37.36 1 1 +github.com/echovault/sugardb/internal/modules/hash/hash.go:38.23,39.13 1 1 +github.com/echovault/sugardb/internal/modules/hash/hash.go:40.15,42.26 2 1 +github.com/echovault/sugardb/internal/modules/hash/hash.go:45.2,45.13 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:24.74,25.18 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:25.18,27.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:28.2,32.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:35.76,36.18 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:36.18,38.3 1 0 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:39.2,43.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:46.74,47.18 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:47.18,49.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:50.2,54.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:57.75,58.18 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:58.18,60.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:61.2,65.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:68.77,69.18 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:69.18,71.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:72.2,76.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:79.75,80.19 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:80.19,82.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:83.2,87.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:90.80,91.34 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:91.34,93.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:94.2,94.19 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:94.19,100.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:101.2,105.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:108.74,109.19 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:109.19,111.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:112.2,116.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:119.75,120.19 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:120.19,122.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:123.2,127.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:130.77,131.19 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:131.19,133.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:134.2,138.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:141.77,142.19 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:142.19,144.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:145.2,149.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:152.77,153.19 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:153.19,155.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:156.2,160.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:163.74,164.18 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:164.18,166.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:167.2,171.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:174.77,175.18 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:175.18,177.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:179.2,183.8 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:186.74,187.18 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:187.18,189.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:191.2,191.24 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:191.24,193.3 1 1 +github.com/echovault/sugardb/internal/modules/hash/key_funcs.go:195.2,199.8 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:48.36,52.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:55.50,56.18 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:57.10,57.10 0 0 +github.com/echovault/sugardb/internal/raft/fsm.go:59.23,62.60 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:62.60,67.4 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:69.3,74.40 5 0 +github.com/echovault/sugardb/internal/raft/fsm.go:75.11,79.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:81.21,82.66 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:82.66,87.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:88.4,91.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:93.18,96.18 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:96.18,101.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:103.4,106.18 3 0 +github.com/echovault/sugardb/internal/raft/fsm.go:106.18,111.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:112.4,113.10 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:113.10,115.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.4,117.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.96,122.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:122.10,127.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:131.2,131.12 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:135.54,143.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:146.55,149.16 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:149.16,152.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:154.2,159.48 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:159.48,162.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.2,165.81 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.81,167.34 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:167.34,168.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:168.96,170.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:171.4,171.60 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:176.2,178.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:39.50,43.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:46.58,50.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:50.16,53.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:55.2,62.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:62.16,65.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.2,67.40 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.40,70.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:72.2,74.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:78.30,80.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:52.31,56.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:58.46,70.24 9 0 +github.com/echovault/sugardb/internal/raft/raft.go:70.24,75.3 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:75.8,77.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:77.17,79.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:86.3,89.17 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:89.17,91.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:94.2,96.16 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:96.16,98.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:100.2,108.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:108.16,110.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:113.2,133.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:133.16,135.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.2,137.27 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.27,148.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:150.2,150.21 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:153.74,155.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:157.36,159.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:161.38,163.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:165.40,172.2 4 0 +github.com/echovault/sugardb/internal/raft/raft.go:179.9,180.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:180.22,182.44 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:182.44,184.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.3,186.56 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.56,188.42 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:188.42,190.5 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:193.3,194.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:194.17,196.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:199.2,199.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:202.61,203.23 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:203.23,205.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.2,207.73 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.73,209.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:211.2,211.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:214.37,216.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:218.31,220.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:220.22,222.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:222.17,225.4 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:226.3,226.49 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 1 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 1 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 1 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 1 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 1 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 1 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 1 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 1 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 1 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 1 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 1 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 1 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 0 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 1 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 1 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 1 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 1 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 1 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 1 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 1 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 1 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 1 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 1 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 1 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 1 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 1 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 1 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 1 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 1 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 1 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 1 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 1 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 1 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 1 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 1 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 1 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 1 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 0 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 1 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 1 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 1 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 1 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 1 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 1 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 1 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 1 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 1 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 1 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 1 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 1 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 1 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 1 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 1 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 1 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 1 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 1 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 1 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 1 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 1 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 1 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 0 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 1 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 1 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 1 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 1 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 1 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 1 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 1 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:32.88,35.9 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:35.9,37.3 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:39.2,39.33 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:40.18,42.56 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:43.20,45.62 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:46.10,47.15 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:52.60,55.16 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:55.16,58.3 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:60.2,60.12 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:64.55,66.2 0 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:42.47,46.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:49.54,59.16 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:63.2,63.10 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:67.54,69.55 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:69.55,72.3 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:74.2,74.20 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:75.18,77.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:77.39,80.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:86.19,88.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:88.39,91.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:93.3,99.67 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:99.67,101.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:103.20,105.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:105.39,108.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:110.3,115.17 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:115.17,118.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.3,120.67 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.67,122.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:127.71,129.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:132.56,135.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:138.68,140.2 0 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:33.62,37.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:40.71,42.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:45.72,52.16 4 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:52.16,55.3 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:57.2,59.16 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:65.74,67.2 0 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:56.43,63.2 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:65.58,80.26 7 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:80.26,84.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:85.26,89.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:93.2,94.41 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:94.41,99.3 4 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:101.2,104.16 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:104.16,106.3 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.2,108.37 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.37,111.70 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:111.70,113.18 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:113.18,115.5 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:116.4,116.14 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.3,119.17 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.17,121.4 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:123.3,123.27 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:127.45,137.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:141.72,154.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:158.75,171.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:173.43,176.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:176.16,179.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:181.2,182.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:182.16,185.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:187.2,187.49 1 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:27.78,33.29 4 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:33.29,34.54 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:34.54,40.42 4 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:40.42,42.5 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:44.4,47.12 3 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:50.3,50.36 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:50.36,57.43 5 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:57.43,59.5 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:61.4,63.21 2 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:67.2,69.25 2 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:72.76,76.35 3 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:76.35,77.65 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:77.65,78.41 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:78.41,80.5 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:81.4,81.12 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:83.3,83.13 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:86.2,86.51 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:89.75,90.29 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:91.9,96.36 4 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:96.36,97.66 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:97.66,98.52 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:98.52,102.6 3 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:103.5,103.13 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:105.4,106.14 2 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:108.3,109.26 2 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:111.9,115.56 3 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:115.56,117.4 1 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:118.3,118.53 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:118.53,122.37 3 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:122.37,123.67 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:123.67,124.53 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:124.53,125.59 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:125.59,129.8 3 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:131.6,131.14 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:133.5,133.54 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:133.54,136.6 2 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:138.9,138.61 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:138.61,142.37 3 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:142.37,143.67 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:143.67,144.53 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:144.53,146.24 2 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:146.24,149.8 2 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:151.6,151.14 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:153.5,153.33 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:153.33,156.6 2 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:158.9,158.60 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:158.60,162.37 3 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:162.37,163.67 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:163.67,164.53 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:164.53,165.55 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:165.55,169.8 3 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:171.6,171.14 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:173.5,173.50 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:173.50,176.6 2 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:178.9,180.4 1 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:181.3,182.26 2 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:183.10,184.54 1 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:188.75,190.2 1 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:192.36,201.84 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:201.84,205.5 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:215.84,219.5 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:227.86,231.7 1 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:240.86,244.7 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:254.86,258.7 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:270.84,274.5 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:275.73,276.49 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:276.49,278.6 1 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:279.5,279.45 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:289.84,293.5 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:294.73,296.18 2 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:296.18,298.6 1 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:299.5,299.53 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:309.84,313.5 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:314.73,315.47 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:315.47,317.6 1 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:318.5,318.45 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:327.84,331.5 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:341.86,345.7 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:346.75,347.34 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:347.34,349.8 1 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:350.7,351.34 2 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:351.34,353.8 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:354.7,354.75 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:354.75,356.8 1 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:357.7,357.47 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:367.86,371.7 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:372.75,373.35 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:373.35,375.8 1 0 +github.com/echovault/sugardb/internal/modules/admin/commands.go:376.7,377.47 2 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:386.86,390.7 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:391.75,394.38 3 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:394.38,396.8 1 1 +github.com/echovault/sugardb/internal/modules/admin/commands.go:397.7,397.30 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:48.36,52.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:55.50,56.18 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:57.10,57.10 0 0 +github.com/echovault/sugardb/internal/raft/fsm.go:59.23,62.60 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:62.60,67.4 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:69.3,74.40 5 0 +github.com/echovault/sugardb/internal/raft/fsm.go:75.11,79.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:81.21,82.66 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:82.66,87.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:88.4,91.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:93.18,96.18 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:96.18,101.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:103.4,106.18 3 0 +github.com/echovault/sugardb/internal/raft/fsm.go:106.18,111.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:112.4,113.10 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:113.10,115.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.4,117.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.96,122.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:122.10,127.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:131.2,131.12 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:135.54,143.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:146.55,149.16 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:149.16,152.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:154.2,159.48 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:159.48,162.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.2,165.81 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.81,167.34 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:167.34,168.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:168.96,170.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:171.4,171.60 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:176.2,178.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:39.50,43.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:46.58,50.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:50.16,53.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:55.2,62.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:62.16,65.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.2,67.40 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.40,70.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:72.2,74.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:78.30,80.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:52.31,56.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:58.46,70.24 9 0 +github.com/echovault/sugardb/internal/raft/raft.go:70.24,75.3 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:75.8,77.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:77.17,79.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:86.3,89.17 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:89.17,91.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:94.2,96.16 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:96.16,98.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:100.2,108.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:108.16,110.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:113.2,133.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:133.16,135.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.2,137.27 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.27,148.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:150.2,150.21 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:153.74,155.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:157.36,159.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:161.38,163.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:165.40,172.2 4 0 +github.com/echovault/sugardb/internal/raft/raft.go:179.9,180.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:180.22,182.44 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:182.44,184.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.3,186.56 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.56,188.42 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:188.42,190.5 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:193.3,194.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:194.17,196.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:199.2,199.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:202.61,203.23 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:203.23,205.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.2,207.73 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.73,209.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:211.2,211.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:214.37,216.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:218.31,220.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:220.22,222.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:222.17,225.4 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:226.3,226.49 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 1 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 0 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 1 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 1 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 1 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 0 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 1 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 1 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 1 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 0 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 0 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 0 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 1 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 1 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 1 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 1 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 1 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 1 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 1 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 1 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 1 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 1 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 1 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 1 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 1 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 1 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 1 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 1 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 1 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 1 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 0 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 0 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 0 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 0 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 1 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 1 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 1 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 1 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 1 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 1 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 0 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 0 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 0 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 0 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 0 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 0 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 0 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 0 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 0 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 0 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 1 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 1 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 1 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 1 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 1 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 1 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 1 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 1 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 0 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 1 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 1 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 1 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 1 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 1 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 1 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 0 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:32.88,35.9 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:35.9,37.3 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:39.2,39.33 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:40.18,42.56 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:43.20,45.62 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:46.10,47.15 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:52.60,55.16 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:55.16,58.3 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:60.2,60.12 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:64.55,66.2 0 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:42.47,46.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:49.54,59.16 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:63.2,63.10 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:67.54,69.55 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:69.55,72.3 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:74.2,74.20 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:75.18,77.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:77.39,80.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:86.19,88.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:88.39,91.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:93.3,99.67 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:99.67,101.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:103.20,105.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:105.39,108.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:110.3,115.17 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:115.17,118.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.3,120.67 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.67,122.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:127.71,129.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:132.56,135.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:138.68,140.2 0 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:33.62,37.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:40.71,42.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:45.72,52.16 4 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:52.16,55.3 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:57.2,59.16 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:65.74,67.2 0 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:56.43,63.2 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:65.58,80.26 7 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:80.26,84.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:85.26,89.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:93.2,94.41 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:94.41,99.3 4 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:101.2,104.16 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:104.16,106.3 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.2,108.37 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.37,111.70 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:111.70,113.18 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:113.18,115.5 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:116.4,116.14 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.3,119.17 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.17,121.4 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:123.3,123.27 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:127.45,137.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:141.72,154.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:158.75,171.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:173.43,176.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:176.16,179.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:181.2,182.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:182.16,185.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:187.2,187.49 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:27.68,29.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:29.16,31.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:33.2,36.16 3 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:36.16,39.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:41.2,41.85 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:41.85,43.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:45.2,45.57 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:48.70,50.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:50.16,52.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:54.2,56.16 3 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:56.16,58.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:60.2,61.9 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:61.9,63.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:65.2,66.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:66.16,68.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:70.2,70.15 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:70.15,72.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:74.2,74.37 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:74.37,76.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:78.2,78.81 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:81.70,83.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:83.16,85.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:87.2,89.16 3 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:89.16,91.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:93.2,94.9 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:94.9,96.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:98.2,99.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:99.16,101.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:103.2,103.15 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:103.15,105.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:107.2,108.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:108.16,110.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:112.2,112.13 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:112.13,114.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:116.2,116.21 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:116.21,118.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:120.2,120.38 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:120.38,122.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:124.2,125.32 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:125.32,127.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:129.2,129.25 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:132.68,134.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:134.16,136.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:138.2,140.16 3 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:140.16,142.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:144.2,145.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:145.16,147.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:149.2,150.9 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:150.9,152.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:155.2,155.15 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:155.15,157.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:159.2,159.40 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:159.40,161.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:163.2,164.91 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:164.91,166.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:168.2,168.42 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:171.69,173.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:173.16,175.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:177.2,179.16 3 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:179.16,181.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:183.2,184.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:184.16,186.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:187.2,188.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:188.16,190.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:192.2,193.9 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:193.9,195.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:198.2,198.15 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:198.15,200.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:201.2,201.13 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:201.13,203.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:206.2,206.40 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:206.40,207.62 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:207.62,209.4 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:210.3,210.43 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:214.2,214.21 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:214.21,216.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:218.2,218.24 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:218.24,220.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:222.2,222.102 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:222.102,224.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:226.2,226.42 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:229.68,231.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:231.16,233.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:235.2,240.16 5 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:240.16,242.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:243.2,245.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:245.16,247.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:249.2,250.9 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:250.9,252.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:254.2,256.9 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:257.10,259.34 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:259.34,260.24 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:260.24,263.5 2 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:265.17,267.34 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:267.34,268.26 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:268.26,269.10 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:271.4,271.24 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:271.24,274.5 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:276.17,278.39 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:278.39,279.26 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:279.26,280.10 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:282.4,282.24 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:282.24,286.5 3 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:290.2,290.91 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:290.91,292.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:294.2,295.58 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:298.69,300.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:300.16,302.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:304.2,309.116 5 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:309.116,311.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:313.2,313.51 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:313.51,315.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:317.2,321.33 4 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:321.33,323.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:325.2,325.19 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:326.14,329.33 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:329.33,330.26 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:330.26,332.6 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:334.5,334.50 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:337.15,340.33 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:340.33,341.26 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:341.26,343.6 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:345.5,345.66 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:350.2,350.16 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:350.16,352.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:354.2,354.42 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:357.69,359.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:359.16,361.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:363.2,365.42 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:365.42,367.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:369.2,372.16 3 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:372.16,373.45 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:374.17,375.64 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:376.11,377.99 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:377.99,379.5 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:383.2,385.9 3 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:385.9,387.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:389.2,389.109 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:389.109,391.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:393.2,393.66 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:396.69,398.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:398.16,400.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:402.2,407.42 4 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:407.42,409.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:411.2,411.16 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:411.16,412.45 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:413.17,414.64 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:415.11,416.99 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:416.99,418.5 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:422.2,424.9 3 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:424.9,426.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:428.2,428.109 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:428.109,430.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:431.2,431.66 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:434.67,436.16 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:436.16,438.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:440.2,442.16 3 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:442.16,444.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:446.2,447.9 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:447.9,449.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:451.2,454.30 3 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:454.30,457.17 3 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:457.17,459.4 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:461.3,463.24 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:463.24,465.4 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:469.2,469.20 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:469.20,471.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:473.2,474.29 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:474.29,475.51 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:475.51,479.4 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:479.9,483.4 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:485.2,485.91 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:485.91,487.3 1 0 +github.com/echovault/sugardb/internal/modules/list/commands.go:490.2,490.16 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:490.16,492.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:494.2,495.35 2 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:495.35,497.3 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:498.2,498.25 1 1 +github.com/echovault/sugardb/internal/modules/list/commands.go:501.36,643.2 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:23.75,24.18 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:24.18,26.3 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:27.2,31.8 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:34.73,35.34 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:35.34,37.3 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:38.2,42.8 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:45.74,46.19 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:46.19,48.3 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:49.2,53.8 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:56.76,57.19 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:57.19,59.3 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:60.2,64.8 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:67.76,68.19 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:68.19,70.3 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:71.2,75.8 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:78.74,79.19 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:79.19,81.3 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:82.2,86.8 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:89.75,90.19 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:90.19,92.3 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:93.2,97.8 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:100.74,101.19 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:101.19,103.3 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:104.2,108.8 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:111.75,112.18 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:112.18,114.3 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:115.2,119.8 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:122.75,123.19 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:123.19,125.3 1 1 +github.com/echovault/sugardb/internal/modules/list/key_funcs.go:126.2,130.8 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:48.36,52.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:55.50,56.18 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:57.10,57.10 0 0 +github.com/echovault/sugardb/internal/raft/fsm.go:59.23,62.60 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:62.60,67.4 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:69.3,74.40 5 0 +github.com/echovault/sugardb/internal/raft/fsm.go:75.11,79.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:81.21,82.66 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:82.66,87.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:88.4,91.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:93.18,96.18 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:96.18,101.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:103.4,106.18 3 0 +github.com/echovault/sugardb/internal/raft/fsm.go:106.18,111.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:112.4,113.10 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:113.10,115.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.4,117.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.96,122.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:122.10,127.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:131.2,131.12 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:135.54,143.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:146.55,149.16 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:149.16,152.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:154.2,159.48 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:159.48,162.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.2,165.81 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.81,167.34 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:167.34,168.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:168.96,170.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:171.4,171.60 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:176.2,178.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:39.50,43.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:46.58,50.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:50.16,53.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:55.2,62.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:62.16,65.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.2,67.40 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.40,70.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:72.2,74.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:78.30,80.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:52.31,56.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:58.46,70.24 9 0 +github.com/echovault/sugardb/internal/raft/raft.go:70.24,75.3 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:75.8,77.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:77.17,79.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:86.3,89.17 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:89.17,91.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:94.2,96.16 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:96.16,98.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:100.2,108.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:108.16,110.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:113.2,133.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:133.16,135.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.2,137.27 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.27,148.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:150.2,150.21 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:153.74,155.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:157.36,159.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:161.38,163.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:165.40,172.2 4 0 +github.com/echovault/sugardb/internal/raft/raft.go:179.9,180.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:180.22,182.44 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:182.44,184.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.3,186.56 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.56,188.42 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:188.42,190.5 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:193.3,194.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:194.17,196.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:199.2,199.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:202.61,203.23 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:203.23,205.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.2,207.73 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.73,209.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:211.2,211.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:214.37,216.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:218.31,220.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:220.22,222.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:222.17,225.4 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:226.3,226.49 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 0 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 0 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 0 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 0 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 0 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 0 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 0 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 0 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 0 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 0 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 0 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 0 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 1 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 1 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 1 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 1 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 1 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 1 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 1 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 1 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 1 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 1 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 1 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 1 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 1 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 1 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 1 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 1 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 1 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 1 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 1 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 1 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 1 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 1 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 1 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 0 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 0 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 0 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 0 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 0 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 0 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 0 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 0 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 0 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 0 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 1 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 1 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 1 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 0 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 0 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 1 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 1 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 1 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 1 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 1 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 1 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 1 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 1 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 1 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 1 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 1 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 1 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 1 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 1 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 1 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 1 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 1 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 1 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 1 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 1 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 1 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 1 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 0 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:32.88,35.9 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:35.9,37.3 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:39.2,39.33 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:40.18,42.56 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:43.20,45.62 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:46.10,47.15 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:52.60,55.16 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:55.16,58.3 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:60.2,60.12 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:64.55,66.2 0 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:42.47,46.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:49.54,59.16 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:63.2,63.10 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:67.54,69.55 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:69.55,72.3 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:74.2,74.20 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:75.18,77.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:77.39,80.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:86.19,88.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:88.39,91.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:93.3,99.67 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:99.67,101.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:103.20,105.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:105.39,108.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:110.3,115.17 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:115.17,118.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.3,120.67 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.67,122.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:127.71,129.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:132.56,135.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:138.68,140.2 0 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:33.62,37.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:40.71,42.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:45.72,52.16 4 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:52.16,55.3 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:57.2,59.16 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:65.74,67.2 0 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:56.43,63.2 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:65.58,80.26 7 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:80.26,84.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:85.26,89.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:93.2,94.41 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:94.41,99.3 4 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:101.2,104.16 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:104.16,106.3 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.2,108.37 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.37,111.70 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:111.70,113.18 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:113.18,115.5 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:116.4,116.14 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.3,119.17 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.17,121.4 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:123.3,123.27 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:127.45,137.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:141.72,154.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:158.75,171.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:173.43,176.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:176.16,179.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:181.2,182.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:182.16,185.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:187.2,187.49 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:34.51,35.32 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:35.32,37.3 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:41.57,42.32 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:42.32,45.3 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:48.61,59.33 3 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:59.33,61.3 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:63.2,63.16 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:66.28,67.12 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:67.12,68.7 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:68.7,73.40 3 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:73.40,74.30 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:74.30,79.21 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:79.21,81.7 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:85.4,85.33 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:90.34,92.2 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:94.40,96.2 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:98.51,101.40 3 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:101.40,103.3 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:104.2,105.11 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:108.53,111.40 3 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:111.40,113.3 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:114.2,115.13 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:118.44,120.2 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:122.36,129.2 4 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:131.34,138.2 4 1 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:140.59,145.35 4 0 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:145.35,147.3 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/channel.go:149.2,149.20 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:25.73,27.9 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:27.9,29.3 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:31.2,33.24 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:33.24,35.3 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:37.2,40.17 3 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:43.75,45.9 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:45.9,47.3 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:49.2,53.90 3 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:56.71,58.9 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:58.9,60.3 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:61.2,61.30 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:61.30,63.3 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:64.2,65.42 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:68.78,69.29 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:69.29,71.3 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:73.2,74.9 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:74.9,76.3 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:78.2,79.30 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:79.30,81.3 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:83.2,83.38 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:86.76,88.9 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:88.9,90.3 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:91.2,92.49 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:95.77,97.9 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:97.9,99.3 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:100.2,100.47 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:103.36,112.84 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:112.84,114.21 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:114.21,116.6 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:117.5,121.11 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:132.84,134.21 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:134.21,136.6 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:137.5,141.11 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:152.84,154.22 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:154.22,156.6 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:157.5,161.11 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:174.84,181.5 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:193.84,199.5 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:209.84,215.5 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:216.68,218.5 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:228.86,234.7 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:243.86,249.7 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/commands.go:259.86,265.7 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:34.26,39.2 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:41.101,48.17 5 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:48.17,50.3 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:52.2,52.37 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:52.37,56.75 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:56.75,58.4 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:60.3,60.23 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:60.23,63.19 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:63.19,65.5 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:65.10,67.5 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:68.4,69.31 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:69.31,74.20 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:74.20,76.6 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:77.5,77.47 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:79.9,81.47 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:81.47,86.20 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:86.20,88.6 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:94.110,99.17 4 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:99.17,101.3 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:103.2,106.24 3 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:106.24,107.19 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:107.19,110.40 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:110.40,111.31 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:111.31,112.14 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:114.5,114.34 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:114.34,117.6 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:119.9,122.40 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:122.40,123.31 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:123.31,124.14 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:126.5,126.34 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:126.34,129.6 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:137.2,137.38 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:137.38,138.30 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:138.30,139.54 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:139.54,142.5 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:148.2,148.17 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:148.17,149.36 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:149.36,151.40 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:151.40,153.58 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:153.58,154.35 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:154.35,157.7 2 0 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:158.6,158.14 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:161.5,161.30 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:161.30,162.35 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:162.35,165.7 2 0 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:171.2,172.39 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:172.39,174.3 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:176.2,176.20 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:179.82,183.38 3 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:183.38,185.29 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:185.29,186.35 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:186.35,188.5 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:189.4,189.12 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:192.3,192.41 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:192.41,194.4 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:198.51,205.19 5 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:205.19,206.39 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:206.39,207.26 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:207.26,210.5 2 0 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:212.3,213.21 2 0 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:216.2,218.38 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:218.38,220.78 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:220.78,223.12 3 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:226.3,226.50 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:226.50,229.4 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:232.2,232.53 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:235.32,240.38 4 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:240.38,241.51 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:241.51,243.4 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:245.2,245.14 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:248.52,253.35 4 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:253.35,255.66 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:255.66,257.4 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:258.3,258.20 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:258.20,260.12 2 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:262.3,262.106 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:264.2,264.20 1 1 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:267.47,272.38 4 0 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:272.38,274.3 1 0 +github.com/echovault/sugardb/internal/modules/pubsub/pubsub.go:276.2,276.17 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:48.36,52.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:55.50,56.18 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:57.10,57.10 0 0 +github.com/echovault/sugardb/internal/raft/fsm.go:59.23,62.60 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:62.60,67.4 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:69.3,74.40 5 0 +github.com/echovault/sugardb/internal/raft/fsm.go:75.11,79.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:81.21,82.66 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:82.66,87.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:88.4,91.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:93.18,96.18 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:96.18,101.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:103.4,106.18 3 0 +github.com/echovault/sugardb/internal/raft/fsm.go:106.18,111.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:112.4,113.10 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:113.10,115.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.4,117.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.96,122.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:122.10,127.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:131.2,131.12 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:135.54,143.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:146.55,149.16 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:149.16,152.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:154.2,159.48 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:159.48,162.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.2,165.81 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.81,167.34 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:167.34,168.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:168.96,170.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:171.4,171.60 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:176.2,178.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:39.50,43.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:46.58,50.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:50.16,53.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:55.2,62.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:62.16,65.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.2,67.40 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.40,70.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:72.2,74.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:78.30,80.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:52.31,56.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:58.46,70.24 9 0 +github.com/echovault/sugardb/internal/raft/raft.go:70.24,75.3 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:75.8,77.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:77.17,79.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:86.3,89.17 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:89.17,91.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:94.2,96.16 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:96.16,98.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:100.2,108.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:108.16,110.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:113.2,133.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:133.16,135.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.2,137.27 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.27,148.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:150.2,150.21 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:153.74,155.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:157.36,159.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:161.38,163.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:165.40,172.2 4 0 +github.com/echovault/sugardb/internal/raft/raft.go:179.9,180.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:180.22,182.44 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:182.44,184.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.3,186.56 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.56,188.42 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:188.42,190.5 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:193.3,194.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:194.17,196.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:199.2,199.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:202.61,203.23 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:203.23,205.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.2,207.73 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.73,209.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:211.2,211.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:214.37,216.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:218.31,220.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:220.22,222.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:222.17,225.4 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:226.3,226.49 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 1 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 1 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 1 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 0 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 0 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 1 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 1 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 1 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 1 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 1 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 1 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 0 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 1 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 1 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 1 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 1 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 1 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 1 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 1 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 1 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 1 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 1 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 1 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 1 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 1 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 1 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 1 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 1 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 1 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 1 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 0 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 0 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 0 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 0 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 1 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 1 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 1 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 1 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 1 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 1 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 0 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 0 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 0 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 0 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 0 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 0 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 0 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 0 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 0 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 0 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 1 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 1 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 1 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 1 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 1 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 1 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 1 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 1 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 0 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 1 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 1 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 1 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 1 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 1 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 1 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 0 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:32.88,35.9 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:35.9,37.3 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:39.2,39.33 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:40.18,42.56 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:43.20,45.62 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:46.10,47.15 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:52.60,55.16 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:55.16,58.3 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:60.2,60.12 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:64.55,66.2 0 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:42.47,46.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:49.54,59.16 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:63.2,63.10 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:67.54,69.55 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:69.55,72.3 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:74.2,74.20 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:75.18,77.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:77.39,80.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:86.19,88.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:88.39,91.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:93.3,99.67 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:99.67,101.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:103.20,105.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:105.39,108.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:110.3,115.17 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:115.17,118.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.3,120.67 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.67,122.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:127.71,129.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:132.56,135.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:138.68,140.2 0 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:33.62,37.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:40.71,42.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:45.72,52.16 4 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:52.16,55.3 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:57.2,59.16 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:65.74,67.2 0 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:56.43,63.2 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:65.58,80.26 7 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:80.26,84.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:85.26,89.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:93.2,94.41 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:94.41,99.3 4 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:101.2,104.16 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:104.16,106.3 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.2,108.37 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.37,111.70 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:111.70,113.18 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:113.18,115.5 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:116.4,116.14 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.3,119.17 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.17,121.4 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:123.3,123.27 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:127.45,137.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:141.72,154.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:158.75,171.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:173.43,176.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:176.16,179.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:181.2,182.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:182.16,185.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:187.2,187.49 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:26.68,28.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:28.16,30.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:32.2,37.16 4 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:37.16,39.91 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:39.91,41.4 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:42.3,42.70 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:45.2,46.9 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:46.9,48.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:50.2,52.51 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:55.69,57.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:57.16,59.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:61.2,64.16 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:64.16,66.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:68.2,69.9 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:69.9,71.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:73.2,75.57 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:78.69,80.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:80.16,82.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:84.2,87.34 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:87.34,89.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:91.2,92.9 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:92.9,94.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:96.2,97.41 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:97.41,99.10 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:99.10,100.12 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:102.3,102.27 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:105.2,109.26 4 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:109.26,111.24 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:111.24,113.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:116.2,116.25 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:119.74,121.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:121.16,123.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:125.2,129.34 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:129.34,131.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:133.2,134.9 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:134.9,136.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:138.2,139.40 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:139.40,141.10 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:141.10,142.12 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:144.3,144.27 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:147.2,152.99 4 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:152.99,154.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:156.2,156.25 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:159.70,161.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:161.16,163.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:165.2,169.37 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:169.37,170.14 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:170.14,172.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:173.3,174.10 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:174.10,177.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:178.3,178.27 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:181.2,181.20 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:181.20,183.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:185.2,189.26 4 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:189.26,191.24 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:191.24,193.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:196.2,196.25 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:199.74,201.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:201.16,203.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:205.2,209.67 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:209.67,211.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:212.2,212.35 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:212.35,214.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:215.2,215.20 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:215.20,217.38 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:217.38,219.4 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:221.3,221.71 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:221.71,223.4 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:223.9,225.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:228.2,230.37 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:230.37,231.14 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:231.14,233.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:234.3,235.10 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:235.10,238.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:239.3,239.27 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:242.2,242.20 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:242.20,244.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:246.2,248.69 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:251.75,253.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:253.16,255.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:257.2,261.37 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:261.37,262.14 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:262.14,264.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:265.3,266.10 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:266.10,269.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:270.3,270.27 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:273.2,276.104 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:276.104,278.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:280.2,280.69 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:283.73,285.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:285.16,287.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:289.2,292.16 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:292.16,294.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:296.2,297.9 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:297.9,299.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:301.2,301.38 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:301.38,303.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:305.2,305.30 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:308.72,310.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:310.16,312.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:314.2,317.16 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:317.16,319.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:321.2,322.9 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:322.9,324.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:326.2,329.26 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:329.26,331.24 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:331.24,333.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:336.2,336.25 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:339.74,341.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:341.16,343.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:345.2,349.16 4 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:349.16,351.29 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:351.29,353.27 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:353.27,355.5 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:357.3,357.26 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:360.2,361.9 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:361.9,363.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:365.2,366.36 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:366.36,367.31 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:367.31,369.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:369.9,371.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:373.2,375.25 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:378.69,380.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:380.16,382.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:384.2,388.24 4 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:388.24,390.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:392.2,395.9 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:395.9,397.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:399.2,400.9 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:400.9,402.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:404.2,406.49 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:409.68,411.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:411.16,413.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:415.2,419.30 4 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:419.30,421.10 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:421.10,423.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:424.3,424.12 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:427.2,427.16 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:427.16,429.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:431.2,432.9 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:432.9,434.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:436.2,439.28 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:439.28,441.26 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:441.26,443.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:446.2,446.25 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:449.75,451.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:451.16,453.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:455.2,459.30 4 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:459.30,461.10 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:461.10,463.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:464.3,464.12 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:467.2,467.16 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:467.16,469.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:471.2,472.9 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:472.9,474.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:476.2,479.28 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:479.28,481.26 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:481.26,483.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:486.2,486.25 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:489.68,491.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:491.16,493.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:495.2,499.16 4 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:499.16,501.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:503.2,504.9 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:504.9,506.3 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:508.2,510.51 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:513.70,515.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:515.16,517.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:519.2,522.33 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:522.33,524.10 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:524.10,526.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:527.3,527.27 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:530.2,533.35 3 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:533.35,535.33 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:535.33,537.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:540.2,540.25 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:543.75,545.16 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:545.16,547.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:549.2,554.33 4 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:554.33,556.10 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:556.10,558.4 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:559.3,559.27 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:562.2,564.100 2 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:564.100,566.3 1 0 +github.com/echovault/sugardb/internal/modules/set/commands.go:567.2,567.65 1 1 +github.com/echovault/sugardb/internal/modules/set/commands.go:570.36,739.2 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:25.74,26.18 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:26.18,28.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:29.2,33.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:36.75,37.19 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:37.19,39.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:40.2,44.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:47.75,48.18 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:48.18,50.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:51.2,55.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:58.80,59.18 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:59.18,61.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:62.2,66.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:69.76,70.18 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:70.18,72.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:73.2,77.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:80.80,81.18 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:81.18,83.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:85.2,85.56 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:85.56,87.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:89.2,89.20 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:89.20,95.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:97.2,101.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:104.81,105.18 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:105.18,107.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:108.2,112.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:115.79,116.19 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:116.19,118.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:119.2,123.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:126.78,127.19 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:127.19,129.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:130.2,134.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:137.80,138.18 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:138.18,140.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:141.2,145.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:148.75,149.19 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:149.19,151.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:152.2,156.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:159.74,160.34 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:160.34,162.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:163.2,167.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:170.81,171.34 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:171.34,173.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:174.2,178.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:181.74,182.18 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:182.18,184.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:185.2,189.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:192.76,193.18 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:193.18,195.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:196.2,200.8 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:203.81,204.18 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:204.18,206.3 1 1 +github.com/echovault/sugardb/internal/modules/set/key_funcs.go:207.2,211.8 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:31.32,36.32 4 1 +github.com/echovault/sugardb/internal/modules/set/set.go:36.32,40.3 3 1 +github.com/echovault/sugardb/internal/modules/set/set.go:41.2,41.13 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:47.34,54.2 3 1 +github.com/echovault/sugardb/internal/modules/set/set.go:56.41,58.26 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:58.26,59.23 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:59.23,62.4 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:64.2,65.14 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:68.43,70.2 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:72.35,74.32 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:74.32,76.3 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:77.2,77.12 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:80.35,82.2 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:84.47,87.16 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:87.16,89.3 1 0 +github.com/echovault/sugardb/internal/modules/set/set.go:91.2,91.49 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:91.49,93.3 1 0 +github.com/echovault/sugardb/internal/modules/set/set.go:95.2,99.15 3 1 +github.com/echovault/sugardb/internal/modules/set/set.go:99.15,101.47 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:101.47,104.4 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:105.8,107.43 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:107.43,109.38 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:109.38,111.59 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:111.59,113.6 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:114.5,114.8 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:119.2,119.12 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:122.44,124.26 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:124.26,125.24 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:125.24,128.4 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:130.2,131.14 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:134.41,138.2 3 1 +github.com/echovault/sugardb/internal/modules/set/set.go:140.41,142.2 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:145.46,148.27 3 1 +github.com/echovault/sugardb/internal/modules/set/set.go:148.27,149.31 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:149.31,150.24 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:150.24,152.5 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:155.2,156.13 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:159.54,160.22 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:160.22,162.3 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:163.2,165.10 3 1 +github.com/echovault/sugardb/internal/modules/set/set.go:171.57,173.19 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:174.9,175.24 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:176.9,179.43 3 1 +github.com/echovault/sugardb/internal/modules/set/set.go:179.43,180.56 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:180.56,182.10 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:184.4,184.32 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:184.32,186.5 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:188.3,188.36 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:189.10,191.11 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:191.11,193.4 1 0 +github.com/echovault/sugardb/internal/modules/set/set.go:194.3,195.11 2 1 +github.com/echovault/sugardb/internal/modules/set/set.go:195.11,197.4 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:198.3,198.42 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:203.31,204.19 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:205.9,206.17 1 1 +github.com/echovault/sugardb/internal/modules/set/set.go:207.9,210.15 3 1 +github.com/echovault/sugardb/internal/modules/set/set.go:211.10,214.28 3 1 +github.com/echovault/sugardb/internal/raft/fsm.go:48.36,52.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:55.50,56.18 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:57.10,57.10 0 0 +github.com/echovault/sugardb/internal/raft/fsm.go:59.23,62.60 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:62.60,67.4 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:69.3,74.40 5 0 +github.com/echovault/sugardb/internal/raft/fsm.go:75.11,79.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:81.21,82.66 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:82.66,87.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:88.4,91.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:93.18,96.18 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:96.18,101.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:103.4,106.18 3 0 +github.com/echovault/sugardb/internal/raft/fsm.go:106.18,111.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:112.4,113.10 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:113.10,115.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.4,117.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.96,122.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:122.10,127.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:131.2,131.12 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:135.54,143.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:146.55,149.16 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:149.16,152.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:154.2,159.48 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:159.48,162.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.2,165.81 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.81,167.34 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:167.34,168.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:168.96,170.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:171.4,171.60 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:176.2,178.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:39.50,43.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:46.58,50.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:50.16,53.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:55.2,62.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:62.16,65.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.2,67.40 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.40,70.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:72.2,74.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:78.30,80.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:52.31,56.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:58.46,70.24 9 0 +github.com/echovault/sugardb/internal/raft/raft.go:70.24,75.3 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:75.8,77.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:77.17,79.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:86.3,89.17 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:89.17,91.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:94.2,96.16 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:96.16,98.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:100.2,108.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:108.16,110.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:113.2,133.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:133.16,135.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.2,137.27 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.27,148.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:150.2,150.21 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:153.74,155.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:157.36,159.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:161.38,163.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:165.40,172.2 4 0 +github.com/echovault/sugardb/internal/raft/raft.go:179.9,180.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:180.22,182.44 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:182.44,184.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.3,186.56 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.56,188.42 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:188.42,190.5 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:193.3,194.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:194.17,196.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:199.2,199.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:202.61,203.23 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:203.23,205.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.2,207.73 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.73,209.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:211.2,211.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:214.37,216.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:218.31,220.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:220.22,222.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:222.17,225.4 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:226.3,226.49 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_set/module_set.go:33.82,34.19 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_set/module_set.go:34.19,36.3 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_set/module_set.go:37.2,37.34 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_set/module_set.go:46.34,49.16 2 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_set/module_set.go:49.16,51.3 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_set/module_set.go:52.2,55.16 3 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_set/module_set.go:55.16,57.3 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_set/module_set.go:59.2,60.16 2 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_set/module_set.go:60.16,62.3 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_set/module_set.go:64.2,64.31 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_get/module_get.go:33.82,34.19 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_get/module_get.go:34.19,36.3 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_get/module_get.go:37.2,37.33 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_get/module_get.go:46.34,49.16 2 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_get/module_get.go:49.16,51.3 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_get/module_get.go:52.2,55.13 3 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_get/module_get.go:55.13,57.3 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_get/module_get.go:59.2,60.9 2 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_get/module_get.go:60.9,62.3 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_get/module_get.go:64.2,65.20 2 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_get/module_get.go:65.20,67.17 2 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_get/module_get.go:67.17,69.4 1 0 +github.com/echovault/sugardb/internal/volumes/modules/go/module_get/module_get.go:72.2,72.56 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:48.36,52.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:55.50,56.18 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:57.10,57.10 0 0 +github.com/echovault/sugardb/internal/raft/fsm.go:59.23,62.60 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:62.60,67.4 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:69.3,74.40 5 0 +github.com/echovault/sugardb/internal/raft/fsm.go:75.11,79.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:81.21,82.66 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:82.66,87.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:88.4,91.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:93.18,96.18 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:96.18,101.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:103.4,106.18 3 0 +github.com/echovault/sugardb/internal/raft/fsm.go:106.18,111.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:112.4,113.10 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:113.10,115.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.4,117.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.96,122.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:122.10,127.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:131.2,131.12 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:135.54,143.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:146.55,149.16 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:149.16,152.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:154.2,159.48 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:159.48,162.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.2,165.81 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.81,167.34 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:167.34,168.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:168.96,170.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:171.4,171.60 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:176.2,178.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:39.50,43.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:46.58,50.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:50.16,53.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:55.2,62.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:62.16,65.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.2,67.40 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.40,70.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:72.2,74.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:78.30,80.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:52.31,56.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:58.46,70.24 9 0 +github.com/echovault/sugardb/internal/raft/raft.go:70.24,75.3 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:75.8,77.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:77.17,79.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:86.3,89.17 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:89.17,91.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:94.2,96.16 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:96.16,98.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:100.2,108.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:108.16,110.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:113.2,133.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:133.16,135.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.2,137.27 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.27,148.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:150.2,150.21 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:153.74,155.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:157.36,159.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:161.38,163.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:165.40,172.2 4 0 +github.com/echovault/sugardb/internal/raft/raft.go:179.9,180.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:180.22,182.44 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:182.44,184.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.3,186.56 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.56,188.42 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:188.42,190.5 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:193.3,194.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:194.17,196.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:199.2,199.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:202.61,203.23 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:203.23,205.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.2,207.73 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.73,209.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:211.2,211.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:214.37,216.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:218.31,220.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:220.22,222.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:222.17,225.4 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:226.3,226.49 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 1 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 1 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 1 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 0 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 0 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 0 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 1 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 1 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 1 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 1 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 1 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 0 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 1 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 1 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 1 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 1 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 1 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 1 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 1 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 1 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 1 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 1 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 1 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 1 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 1 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 1 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 1 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 1 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 1 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 1 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 0 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 0 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 0 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 0 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 1 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 1 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 1 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 0 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 1 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 1 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 0 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 0 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 0 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 0 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 0 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 0 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 0 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 0 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 0 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 0 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 1 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 1 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 1 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 1 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 1 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 1 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 1 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 1 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 0 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 1 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 1 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 1 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 1 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 1 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 1 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 0 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:32.88,35.9 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:35.9,37.3 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:39.2,39.33 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:40.18,42.56 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:43.20,45.62 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:46.10,47.15 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:52.60,55.16 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:55.16,58.3 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:60.2,60.12 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:64.55,66.2 0 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:42.47,46.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:49.54,59.16 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:63.2,63.10 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:67.54,69.55 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:69.55,72.3 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:74.2,74.20 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:75.18,77.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:77.39,80.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:86.19,88.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:88.39,91.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:93.3,99.67 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:99.67,101.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:103.20,105.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:105.39,108.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:110.3,115.17 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:115.17,118.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.3,120.67 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.67,122.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:127.71,129.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:132.56,135.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:138.68,140.2 0 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:33.62,37.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:40.71,42.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:45.72,52.16 4 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:52.16,55.3 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:57.2,59.16 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:65.74,67.2 0 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:56.43,63.2 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:65.58,80.26 7 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:80.26,84.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:85.26,89.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:93.2,94.41 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:94.41,99.3 4 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:101.2,104.16 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:104.16,106.3 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.2,108.37 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.37,111.70 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:111.70,113.18 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:113.18,115.5 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:116.4,116.14 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.3,119.17 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.17,121.4 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:123.3,123.27 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:127.45,137.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:141.72,154.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:158.75,171.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:173.43,176.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:176.16,179.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:181.2,182.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:182.16,185.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:187.2,187.49 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:25.72,27.16 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:27.16,29.3 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:31.2,35.9 4 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:35.9,37.3 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:39.2,41.16 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:41.16,43.3 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:45.2,46.9 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:46.9,48.3 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:51.2,51.24 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:51.24,53.94 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:53.94,55.4 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:56.3,56.58 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:60.2,60.16 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:60.16,62.94 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:62.94,64.4 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:65.3,65.58 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:68.2,70.35 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:70.35,72.24 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:72.24,75.12 3 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:78.3,79.8 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:82.2,82.103 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:82.103,84.3 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:86.2,86.59 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:89.70,91.16 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:91.16,93.3 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:95.2,98.16 3 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:98.16,100.3 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:102.2,104.9 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:104.9,106.3 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:108.2,108.56 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:111.70,113.16 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:113.16,115.3 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:117.2,124.24 6 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:124.24,126.3 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:128.2,128.16 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:128.16,130.3 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:132.2,133.9 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:133.9,135.3 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:137.2,137.15 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:137.15,139.3 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:140.2,140.13 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:140.13,142.3 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:144.2,144.30 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:144.30,146.3 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:148.2,148.22 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:148.22,150.3 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:152.2,152.17 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:152.17,155.3 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:157.2,159.14 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:159.14,161.38 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:161.38,163.4 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:164.3,164.12 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:167.2,167.65 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:170.70,172.16 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:172.16,174.3 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:176.2,179.16 4 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:179.16,182.18 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:182.18,184.4 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:185.3,185.57 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:187.2,188.9 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:188.9,190.3 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:191.2,194.17 2 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:194.17,196.3 1 0 +github.com/echovault/sugardb/internal/modules/string/commands.go:197.2,197.59 1 1 +github.com/echovault/sugardb/internal/modules/string/commands.go:200.36,254.2 1 1 +github.com/echovault/sugardb/internal/modules/string/key_funcs.go:24.78,25.19 1 1 +github.com/echovault/sugardb/internal/modules/string/key_funcs.go:25.19,27.3 1 1 +github.com/echovault/sugardb/internal/modules/string/key_funcs.go:28.2,32.8 1 1 +github.com/echovault/sugardb/internal/modules/string/key_funcs.go:35.76,36.19 1 1 +github.com/echovault/sugardb/internal/modules/string/key_funcs.go:36.19,38.3 1 1 +github.com/echovault/sugardb/internal/modules/string/key_funcs.go:39.2,43.8 1 1 +github.com/echovault/sugardb/internal/modules/string/key_funcs.go:46.76,47.19 1 1 +github.com/echovault/sugardb/internal/modules/string/key_funcs.go:47.19,49.3 1 1 +github.com/echovault/sugardb/internal/modules/string/key_funcs.go:50.2,54.8 1 1 +github.com/echovault/sugardb/internal/modules/string/key_funcs.go:57.76,58.19 1 1 +github.com/echovault/sugardb/internal/modules/string/key_funcs.go:58.19,60.3 1 1 +github.com/echovault/sugardb/internal/modules/string/key_funcs.go:61.2,65.8 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:48.36,52.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:55.50,56.18 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:57.10,57.10 0 0 +github.com/echovault/sugardb/internal/raft/fsm.go:59.23,62.60 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:62.60,67.4 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:69.3,74.40 5 0 +github.com/echovault/sugardb/internal/raft/fsm.go:75.11,79.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:81.21,82.66 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:82.66,87.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:88.4,91.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:93.18,96.18 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:96.18,101.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:103.4,106.18 3 0 +github.com/echovault/sugardb/internal/raft/fsm.go:106.18,111.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:112.4,113.10 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:113.10,115.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.4,117.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.96,122.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:122.10,127.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:131.2,131.12 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:135.54,143.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:146.55,149.16 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:149.16,152.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:154.2,159.48 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:159.48,162.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.2,165.81 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.81,167.34 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:167.34,168.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:168.96,170.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:171.4,171.60 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:176.2,178.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:39.50,43.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:46.58,50.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:50.16,53.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:55.2,62.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:62.16,65.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.2,67.40 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.40,70.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:72.2,74.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:78.30,80.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:52.31,56.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:58.46,70.24 9 0 +github.com/echovault/sugardb/internal/raft/raft.go:70.24,75.3 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:75.8,77.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:77.17,79.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:86.3,89.17 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:89.17,91.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:94.2,96.16 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:96.16,98.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:100.2,108.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:108.16,110.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:113.2,133.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:133.16,135.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.2,137.27 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.27,148.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:150.2,150.21 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:153.74,155.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:157.36,159.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:161.38,163.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:165.40,172.2 4 0 +github.com/echovault/sugardb/internal/raft/raft.go:179.9,180.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:180.22,182.44 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:182.44,184.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.3,186.56 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.56,188.42 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:188.42,190.5 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:193.3,194.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:194.17,196.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:199.2,199.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:202.61,203.23 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:203.23,205.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.2,207.73 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.73,209.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:211.2,211.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:214.37,216.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:218.31,220.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:220.22,222.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:222.17,225.4 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:226.3,226.49 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 0 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 0 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 0 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 0 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 0 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 0 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 0 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 0 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 0 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 0 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 0 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 0 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 0 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 0 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 0 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 0 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 0 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 0 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 0 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 0 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 0 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 0 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 0 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 0 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 0 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 0 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 0 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 0 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 0 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 0 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 0 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 0 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 0 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 0 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 0 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 0 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 0 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 0 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 0 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 0 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 1 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 1 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 1 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 1 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 1 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 1 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 0 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 0 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 0 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 0 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 0 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 0 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 0 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 0 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 0 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 0 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 0 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 0 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 0 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 0 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 0 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 0 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 0 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 0 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 0 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 0 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 1 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:55.56,56.30 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:56.30,58.3 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:61.59,62.30 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:62.30,64.3 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:67.64,68.30 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:68.30,70.3 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:73.59,74.30 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:74.30,76.3 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:79.59,80.30 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:80.30,82.3 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:85.60,86.30 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:86.30,88.3 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:91.90,92.30 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:92.30,94.3 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:97.77,98.30 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:98.30,100.3 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:103.73,104.30 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:104.30,106.3 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:109.103,110.30 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:110.30,112.3 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:115.65,122.30 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:122.31,122.32 0 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:123.31,123.32 0 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:124.60,126.4 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:127.85,127.86 0 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:128.48,128.49 0 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:129.43,131.4 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:134.2,134.33 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:134.33,136.3 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:138.2,138.34 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:138.34,139.13 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:139.13,141.17 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:141.17,143.5 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:144.4,144.8 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:144.8,146.62 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:146.62,147.50 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:147.50,149.7 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:155.2,155.15 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:158.44,177.58 6 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:177.58,180.3 2 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:183.2,185.16 3 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:185.16,186.37 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:186.37,189.18 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:189.18,192.5 2 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:193.4,193.24 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:194.9,197.4 2 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:200.2,201.16 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:201.16,204.3 2 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:205.2,205.35 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:205.35,208.3 2 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:210.2,212.20 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:212.20,213.53 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:213.53,216.4 2 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:220.2,225.16 3 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:225.16,228.3 2 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:230.2,231.49 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:231.49,233.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:236.2,239.16 3 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:239.16,242.3 2 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:245.2,246.16 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:246.16,249.3 2 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:252.2,257.16 3 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:257.16,260.3 2 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:261.2,261.39 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:261.39,264.3 2 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:265.2,265.33 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:265.33,267.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:268.2,268.34 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:268.34,271.3 2 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:274.2,275.58 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:275.58,277.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:280.2,281.16 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:281.16,284.3 2 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:285.2,285.15 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:285.15,286.35 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:286.35,288.4 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:292.2,292.39 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:292.39,294.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:295.2,295.32 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:295.32,297.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:300.2,305.12 3 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:308.39,310.50 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:310.50,312.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:313.2,313.16 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:313.16,315.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:316.2,316.15 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:316.15,317.36 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:317.36,319.4 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:322.2,325.16 3 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:325.16,327.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:329.2,329.52 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:329.52,331.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:333.2,333.46 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:333.46,335.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:337.2,342.50 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:342.50,344.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:345.2,345.16 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:348.2,348.15 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:348.15,349.36 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:349.36,351.4 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:354.2,355.16 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:355.16,357.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:359.2,360.58 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:360.58,362.3 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:364.2,366.99 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:366.99,367.34 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:367.34,369.4 1 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:372.2,374.12 2 1 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:377.46,379.2 1 0 +github.com/echovault/sugardb/internal/snapshot/snapshot.go:381.42,383.2 1 1 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 1 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 1 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 0 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 1 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 0 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 0 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 1 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 1 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 1 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 1 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 1 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 1 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 1 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 1 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 1 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 1 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 1 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 1 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 1 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 1 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 1 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 1 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 1 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 1 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 1 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 1 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 1 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 1 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 1 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 1 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 1 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 0 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 0 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 0 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 0 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 1 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 1 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 1 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 1 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 1 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 1 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 0 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 0 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 0 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 0 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 0 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 0 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 0 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 1 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 1 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 1 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 1 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 1 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 1 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 1 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 1 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 1 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 0 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 0 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 0 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 0 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 0 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 1 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 1 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 1 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 1 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 1 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 1 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 1 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 1 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 0 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 1 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 1 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 1 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 1 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 1 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 1 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 0 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:32.88,35.9 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:35.9,37.3 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:39.2,39.33 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:40.18,42.56 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:43.20,45.62 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:46.10,47.15 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:52.60,55.16 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:55.16,58.3 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:60.2,60.12 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:64.55,66.2 0 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:42.47,46.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:49.54,59.16 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:63.2,63.10 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:67.54,69.55 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:69.55,72.3 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:74.2,74.20 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:75.18,77.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:77.39,80.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:86.19,88.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:88.39,91.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:93.3,99.67 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:99.67,101.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:103.20,105.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:105.39,108.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:110.3,115.17 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:115.17,118.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.3,120.67 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.67,122.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:127.71,129.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:132.56,135.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:138.68,140.2 0 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:33.62,37.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:40.71,42.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:45.72,52.16 4 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:52.16,55.3 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:57.2,59.16 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:65.74,67.2 0 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:56.43,63.2 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:65.58,80.26 7 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:80.26,84.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:85.26,89.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:93.2,94.41 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:94.41,99.3 4 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:101.2,104.16 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:104.16,106.3 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.2,108.37 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.37,111.70 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:111.70,113.18 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:113.18,115.5 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:116.4,116.14 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.3,119.17 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.17,121.4 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:123.3,123.27 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:127.45,137.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:141.72,154.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:158.75,171.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:173.43,176.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:176.16,179.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:181.2,182.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:182.16,185.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:187.2,187.49 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:29.68,31.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:31.16,33.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:35.2,45.43 8 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:45.43,46.29 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:46.29,47.9 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:49.3,49.55 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:50.15,51.85 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:51.85,53.5 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:54.16,55.25 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:56.12,57.25 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:61.2,61.77 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:61.77,63.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:65.2,67.63 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:67.63,68.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:68.15,69.12 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:71.3,72.23 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:73.11,74.64 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:75.15,77.49 2 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:77.49,83.5 2 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:84.4,84.49 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:84.49,90.5 2 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:91.16,96.6 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:97.12,102.6 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:107.2,107.27 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:107.27,109.34 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:109.34,110.70 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:110.70,113.61 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:113.61,115.6 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:116.5,116.13 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:118.4,118.70 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:118.70,122.36 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:122.36,124.6 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:125.5,125.13 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:127.4,127.39 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:127.39,129.13 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:131.4,131.41 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:131.41,134.25 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:134.25,136.6 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:137.5,137.13 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:139.4,139.55 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:143.2,143.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:143.15,146.10 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:146.10,148.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:149.3,150.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:150.17,152.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:154.3,154.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:154.18,157.4 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:159.3,159.52 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:163.2,164.90 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:164.90,166.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:168.2,168.63 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:171.69,173.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:173.16,175.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:177.2,180.16 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:180.16,182.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:184.2,185.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:185.9,187.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:189.2,189.63 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:192.70,194.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:194.16,196.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:198.2,202.54 4 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:203.10,204.60 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:205.14,206.51 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:206.51,208.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:208.9,210.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:211.15,213.21 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:214.11,216.21 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:219.2,220.54 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:221.10,222.60 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:223.14,224.51 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:224.51,226.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:226.9,228.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:229.15,231.21 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:232.11,234.21 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:237.2,237.16 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:237.16,239.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:241.2,242.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:242.9,244.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:246.2,247.33 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:247.33,248.47 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:248.47,250.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:253.2,253.58 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:256.73,258.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:258.16,260.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:262.2,267.16 5 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:267.16,269.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:271.2,272.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:272.9,274.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:276.2,279.38 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:279.38,280.45 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:280.45,282.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:285.2,287.28 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:287.28,289.81 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:289.81,291.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:294.2,294.51 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:297.69,299.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:299.16,301.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:303.2,305.74 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:305.74,307.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:308.2,308.49 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:308.49,310.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:313.2,313.34 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:313.34,316.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:318.2,319.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:319.9,321.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:324.2,326.42 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:326.42,327.35 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:327.35,328.12 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:330.3,331.10 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:331.10,333.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:334.3,334.27 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:337.2,342.34 4 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:342.34,343.20 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:343.20,346.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:346.9,348.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:351.2,353.25 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:356.74,358.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:358.16,360.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:362.2,366.34 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:366.34,369.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:371.2,372.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:372.9,374.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:376.2,378.42 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:378.42,379.34 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:379.34,381.11 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:381.11,383.5 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:384.4,384.28 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:388.2,389.99 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:389.99,391.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:393.2,393.64 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:396.71,398.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:398.16,400.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:402.2,408.54 5 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:409.10,410.55 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:411.14,412.68 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:412.68,414.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:414.9,414.75 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:414.75,416.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:416.9,418.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:419.15,421.23 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:422.11,424.23 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:427.2,427.16 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:427.16,435.17 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:435.17,437.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:438.3,438.99 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:441.2,442.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:442.9,444.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:445.2,451.23 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:451.23,453.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:454.2,455.74 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:458.70,460.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:460.16,462.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:464.2,465.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:465.16,467.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:468.2,473.33 4 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:473.33,474.26 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:474.26,477.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:478.3,479.10 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:479.10,481.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:482.3,485.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:488.2,492.33 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:492.33,493.40 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:493.40,494.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:494.18,496.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:496.10,498.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:502.2,504.25 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:507.75,509.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:509.16,511.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:513.2,517.63 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:517.63,519.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:521.2,522.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:522.16,524.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:526.2,529.33 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:529.33,530.26 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:530.26,532.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:533.3,534.10 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:534.10,536.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:537.3,540.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:543.2,546.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:546.17,548.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:550.2,550.69 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:553.69,555.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:555.16,557.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:559.2,566.67 5 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:566.67,568.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:569.2,569.20 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:569.20,570.19 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:570.19,572.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:573.3,573.40 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:573.40,575.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:576.3,577.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:577.17,579.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:580.3,580.13 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:580.13,582.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:583.3,584.25 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:588.2,588.68 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:588.68,590.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:591.2,591.21 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:591.21,592.20 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:592.20,594.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:595.3,596.53 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:596.53,598.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:601.2,601.43 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:601.43,602.35 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:602.35,604.35 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:604.35,605.13 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:607.4,608.18 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:608.18,610.5 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:612.4,614.38 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:614.38,616.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:618.4,620.27 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:624.2,624.30 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:627.68,629.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:629.16,631.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:633.2,638.53 5 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:638.53,640.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:642.2,642.30 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:642.30,644.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:644.17,646.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:647.3,647.12 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:647.12,649.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:652.2,652.16 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:652.16,654.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:656.2,657.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:657.9,659.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:661.2,662.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:662.16,664.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:666.2,667.36 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:667.36,670.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:672.2,674.25 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:677.71,679.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:679.16,681.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:683.2,686.16 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:686.16,688.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:690.2,691.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:691.9,693.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:695.2,701.36 4 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:701.36,703.21 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:703.21,705.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:705.9,707.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:710.2,712.25 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:715.75,717.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:717.16,719.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:721.2,725.30 4 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:725.30,727.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:727.17,729.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:730.3,730.13 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:730.13,732.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:735.2,736.30 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:736.30,737.57 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:737.57,739.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:739.9,741.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:744.2,744.16 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:744.16,746.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:748.2,749.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:749.9,751.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:753.2,756.28 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:756.28,757.17 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:757.17,759.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:759.9,761.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:764.2,766.25 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:769.69,771.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:771.16,773.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:775.2,780.84 5 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:780.84,782.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:784.2,784.16 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:784.16,786.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:788.2,789.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:789.9,791.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:793.2,794.54 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:794.54,795.55 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:795.55,797.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:798.3,798.39 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:801.2,801.36 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:801.36,802.40 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:802.40,803.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:803.18,806.5 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:806.10,808.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:812.2,812.31 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:815.68,817.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:817.16,819.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:821.2,824.16 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:824.16,826.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:828.2,829.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:829.9,831.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:833.2,834.39 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:834.39,835.27 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:835.27,837.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:840.2,840.58 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:843.70,845.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:845.16,847.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:849.2,852.16 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:852.16,854.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:856.2,857.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:857.9,859.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:860.2,861.20 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:861.20,863.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:865.2,867.69 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:870.80,872.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:872.16,874.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:876.2,882.16 5 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:882.16,884.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:886.2,887.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:887.16,889.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:891.2,891.16 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:891.16,893.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:895.2,896.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:896.9,898.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:900.2,900.33 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:900.33,901.61 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:901.61,904.4 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:907.2,907.58 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:910.79,912.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:912.16,914.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:916.2,920.16 4 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:920.16,922.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:924.2,925.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:925.16,927.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:929.2,929.16 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:929.16,931.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:933.2,934.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:934.9,936.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:938.2,938.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:938.15,940.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:941.2,941.14 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:941.14,943.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:945.2,945.88 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:945.88,947.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:949.2,950.54 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:950.54,952.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:954.2,956.18 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:956.18,957.34 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:957.34,960.4 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:961.8,962.34 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:962.34,965.4 2 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:968.2,968.58 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:971.78,973.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:973.16,975.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:977.2,982.16 5 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:982.16,984.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:986.2,987.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:987.9,989.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:991.2,994.38 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:994.38,995.45 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:995.45,997.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1000.2,1003.28 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1003.28,1005.81 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1005.81,1008.4 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1011.2,1011.58 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1014.70,1016.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1016.16,1018.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1020.2,1031.76 10 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1031.76,1033.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1035.2,1035.73 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1035.73,1037.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1039.2,1039.65 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1039.65,1041.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1041.5,1043.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1043.8,1046.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1046.17,1048.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1049.3,1050.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1050.17,1052.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1055.2,1055.65 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1055.65,1057.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1057.5,1058.72 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1058.72,1060.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1061.3,1061.61 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1061.61,1063.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1064.3,1065.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1065.17,1067.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1068.3,1068.17 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1068.17,1070.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1071.3,1072.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1072.17,1074.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1077.2,1077.16 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1077.16,1079.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1081.2,1082.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1082.9,1084.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1086.2,1086.32 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1086.32,1088.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1089.2,1089.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1089.15,1091.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1093.2,1094.42 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1094.42,1095.55 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1095.55,1097.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1097.15,1099.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1100.4,1100.40 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1103.2,1103.40 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1103.40,1105.39 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1105.39,1106.46 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1106.46,1108.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1110.3,1110.55 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1110.55,1111.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1111.15,1113.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1114.4,1114.64 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1118.2,1120.35 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1120.35,1121.24 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1121.24,1122.9 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1124.3,1124.43 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1124.43,1125.85 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1125.85,1127.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1128.4,1128.12 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1130.3,1131.90 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1131.90,1133.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1136.2,1138.34 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1138.34,1139.17 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1139.17,1141.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1141.9,1143.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1146.2,1148.25 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1151.75,1153.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1153.16,1155.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1157.2,1168.73 11 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1168.73,1170.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1172.2,1172.65 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1172.65,1174.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1174.5,1176.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1176.8,1179.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1179.17,1181.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1182.3,1183.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1183.17,1185.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1188.2,1188.65 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1188.65,1190.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1190.5,1191.72 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1191.72,1193.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1194.3,1194.61 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1194.61,1196.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1197.3,1198.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1198.17,1200.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1201.3,1201.17 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1201.17,1203.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1204.3,1205.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1205.17,1207.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1210.2,1210.19 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1210.19,1212.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1214.2,1215.9 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1215.9,1217.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1219.2,1219.32 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1219.32,1221.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1222.2,1222.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1222.15,1224.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1226.2,1227.42 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1227.42,1228.55 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1228.55,1230.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1230.15,1232.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1233.4,1233.40 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1236.2,1236.40 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1236.40,1238.39 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1238.39,1239.46 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1239.46,1241.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1243.3,1243.55 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1243.55,1244.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1244.15,1246.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1247.4,1247.64 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1251.2,1253.35 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1253.35,1254.24 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1254.24,1255.9 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1257.3,1257.43 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1257.43,1258.85 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1258.85,1260.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1261.4,1261.12 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1263.3,1264.90 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1264.90,1266.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1269.2,1272.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1272.17,1274.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1276.2,1276.72 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1279.70,1280.57 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1280.57,1282.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1284.2,1285.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1285.16,1287.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1289.2,1294.33 4 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1294.33,1295.25 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1295.25,1297.11 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1297.11,1299.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1300.4,1303.6 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1307.2,1310.35 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1310.35,1311.17 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1311.17,1313.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1313.9,1315.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1318.2,1320.25 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1323.75,1325.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1325.16,1327.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1329.2,1332.73 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1332.73,1334.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1336.2,1337.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1337.16,1339.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1341.2,1346.33 4 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1346.33,1347.25 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1347.25,1349.11 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1349.11,1351.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1352.4,1355.6 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1359.2,1362.17 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1362.17,1364.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1366.2,1366.65 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/commands.go:1369.36,1666.2 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:25.74,26.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:26.18,28.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:29.2,33.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:36.75,37.19 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:37.19,39.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:40.2,44.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:47.76,48.19 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:48.19,50.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:51.2,55.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:58.75,59.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:59.18,61.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:63.2,63.63 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:63.63,65.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:67.2,67.27 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:67.27,73.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:75.2,79.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:82.80,83.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:83.18,85.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:86.2,90.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:93.77,94.19 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:94.19,96.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:97.2,101.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:104.76,105.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:105.18,107.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:108.2,108.58 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:108.58,111.39 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:111.39,113.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:114.3,114.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:116.2,116.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:116.18,122.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:123.2,123.17 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:123.17,129.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:130.2,130.84 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:133.81,134.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:134.18,136.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:137.2,137.58 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:137.58,141.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:143.2,143.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:143.18,149.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:151.2,151.17 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:151.17,157.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:159.2,159.84 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:162.75,163.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:163.18,165.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:166.2,166.54 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:166.54,168.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:169.2,169.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:169.18,175.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:176.2,176.17 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:176.17,182.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:183.2,183.84 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:186.77,187.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:187.18,189.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:190.2,194.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:197.74,198.34 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:198.34,200.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:201.2,205.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:208.81,209.34 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:209.34,211.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:212.2,216.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:219.75,220.34 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:220.34,222.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:223.2,227.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:230.74,231.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:231.18,233.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:234.2,238.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:241.78,242.18 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:242.18,244.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:245.2,249.8 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:252.76,253.19 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:253.19,255.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:256.2,260.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:263.84,264.19 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:264.19,266.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:267.2,271.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:274.85,275.19 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:275.19,277.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:278.2,282.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:285.86,286.19 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:286.19,288.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:289.2,293.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:296.79,297.19 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:297.19,299.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:300.2,304.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:307.77,308.35 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:308.35,310.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:311.2,315.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:318.81,319.35 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:319.35,321.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:322.2,326.8 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:329.76,330.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:330.18,332.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:333.2,333.58 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:333.58,336.39 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:336.39,338.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:339.3,339.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:341.2,341.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:341.18,347.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:348.2,348.17 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:348.17,354.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:355.2,355.84 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:358.81,359.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:359.18,361.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:362.2,362.58 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:362.58,365.39 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:365.39,367.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:368.3,368.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:370.2,370.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:370.18,376.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:377.2,377.17 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:377.17,383.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/key_funcs.go:384.2,384.84 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:51.36,56.30 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:56.30,66.3 5 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:68.2,68.13 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:74.53,78.28 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:78.28,84.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:85.2,85.10 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:88.46,90.2 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:92.49,94.2 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:96.58,101.44 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:101.44,103.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:105.2,107.15 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:107.15,109.47 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:109.47,112.4 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:113.8,116.43 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:116.43,118.58 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:118.58,120.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:120.7,125.5 4 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:129.2,129.12 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:132.46,134.32 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:134.32,139.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:140.2,140.12 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:143.41,145.2 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:149.16,151.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:151.16,153.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:154.2,155.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:155.16,157.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:158.2,159.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:159.16,161.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:162.2,163.16 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:163.16,165.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:166.2,166.51 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:166.51,168.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:169.2,169.57 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:169.57,171.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:173.2,175.36 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:175.36,176.29 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:176.29,177.30 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:177.30,187.5 3 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:188.4,188.101 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:188.101,190.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:191.4,196.35 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:196.35,198.5 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:200.3,200.20 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:203.2,203.28 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:203.28,204.38 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:204.38,206.29 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:206.29,212.36 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:212.36,214.6 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:216.4,216.12 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:218.3,218.38 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:218.38,220.30 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:220.30,227.5 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:228.4,228.12 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:231.3,231.76 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:231.76,233.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:234.3,238.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:240.2,240.19 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:243.44,244.21 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:244.21,247.3 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:248.2,248.14 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:251.73,253.71 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:253.71,255.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:256.2,256.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:256.15,258.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:259.2,259.16 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:259.16,261.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:263.2,265.54 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:265.54,266.39 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:266.39,268.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:269.3,269.39 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:272.2,272.29 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:272.29,273.24 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:273.24,274.9 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:276.3,278.17 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:278.17,280.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:283.2,283.20 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:286.64,288.28 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:288.28,289.33 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:289.33,290.29 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:290.29,292.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:295.2,295.12 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:305.70,306.24 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:307.9,308.39 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:309.9,311.52 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:311.52,316.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:317.3,317.30 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:318.9,321.52 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:321.52,323.48 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:323.48,328.13 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:331.4,333.42 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:333.42,335.23 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:336.17,337.26 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:338.17,339.46 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:340.14,342.46 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:349.4,349.34 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:353.3,353.52 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:353.52,354.65 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:354.65,356.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:356.7,361.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:363.3,363.30 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:364.10,371.40 4 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:371.40,372.37 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:372.37,375.13 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:377.4,379.42 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:379.42,380.23 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:381.17,382.26 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:383.17,384.46 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:385.14,387.46 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:393.3,393.41 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:393.41,394.65 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:394.65,396.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:396.7,398.5 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:400.3,400.30 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:405.74,406.24 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:407.9,408.39 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:409.9,411.52 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:411.52,416.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:417.3,417.30 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:418.9,421.52 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:421.52,423.48 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:423.48,424.13 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:427.4,429.42 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:429.42,431.23 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:432.17,433.26 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:434.17,435.46 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:436.14,438.46 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:445.4,445.34 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:447.3,447.30 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:448.10,454.40 4 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:454.40,455.37 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:455.37,456.13 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:458.4,460.42 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:460.42,461.23 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:462.17,463.26 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:464.17,465.46 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:466.14,468.46 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/sorted_set.go:474.3,474.30 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:24.97,26.60 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:26.60,28.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:29.2,29.24 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:29.24,30.48 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:30.48,31.85 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:31.85,32.10 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:34.4,35.18 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:35.18,37.5 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:38.4,38.32 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:42.2,43.62 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:43.62,45.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:46.2,46.26 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:46.26,47.94 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:47.94,49.4 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:50.3,50.53 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:53.2,54.63 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:54.63,56.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:57.2,57.27 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:57.27,59.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:62.2,63.85 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:63.85,64.26 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:64.26,65.12 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:67.3,67.31 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:67.31,69.12 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:71.3,71.41 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:71.41,73.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:76.2,77.30 2 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:77.30,79.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:79.8,81.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:83.2,83.55 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:83.55,85.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:85.8,85.31 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:85.31,86.34 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:86.34,88.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:91.2,91.50 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:94.69,95.25 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:95.25,97.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:98.2,100.9 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:100.9,102.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:103.2,103.69 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:103.69,105.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:106.2,106.20 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:109.65,110.23 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:110.23,112.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:113.2,115.9 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:115.9,117.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:118.2,118.67 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:118.67,120.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:121.2,121.18 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:124.59,125.20 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:125.20,127.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:128.2,130.9 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:130.9,132.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:133.2,133.34 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:133.34,135.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:136.2,136.16 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:139.53,140.17 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:140.17,142.3 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:143.2,145.9 3 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:145.9,147.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:148.2,148.35 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:148.35,150.3 1 0 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:151.2,151.15 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:154.61,155.31 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:156.10,157.13 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:158.12,159.16 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:159.16,161.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:162.3,162.13 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:163.12,164.16 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:164.16,166.4 1 1 +github.com/echovault/sugardb/internal/modules/sorted_set/utils.go:167.3,167.13 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:48.36,52.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:55.50,56.18 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:57.10,57.10 0 0 +github.com/echovault/sugardb/internal/raft/fsm.go:59.23,62.60 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:62.60,67.4 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:69.3,74.40 5 0 +github.com/echovault/sugardb/internal/raft/fsm.go:75.11,79.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:81.21,82.66 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:82.66,87.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:88.4,91.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:93.18,96.18 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:96.18,101.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:103.4,106.18 3 0 +github.com/echovault/sugardb/internal/raft/fsm.go:106.18,111.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:112.4,113.10 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:113.10,115.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.4,117.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.96,122.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:122.10,127.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:131.2,131.12 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:135.54,143.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:146.55,149.16 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:149.16,152.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:154.2,159.48 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:159.48,162.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.2,165.81 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.81,167.34 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:167.34,168.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:168.96,170.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:171.4,171.60 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:176.2,178.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:39.50,43.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:46.58,50.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:50.16,53.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:55.2,62.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:62.16,65.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.2,67.40 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.40,70.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:72.2,74.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:78.30,80.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:52.31,56.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:58.46,70.24 9 0 +github.com/echovault/sugardb/internal/raft/raft.go:70.24,75.3 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:75.8,77.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:77.17,79.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:86.3,89.17 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:89.17,91.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:94.2,96.16 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:96.16,98.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:100.2,108.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:108.16,110.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:113.2,133.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:133.16,135.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.2,137.27 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.27,148.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:150.2,150.21 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:153.74,155.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:157.36,159.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:161.38,163.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:165.40,172.2 4 0 +github.com/echovault/sugardb/internal/raft/raft.go:179.9,180.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:180.22,182.44 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:182.44,184.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.3,186.56 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.56,188.42 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:188.42,190.5 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:193.3,194.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:194.17,196.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:199.2,199.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:202.61,203.23 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:203.23,205.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.2,207.73 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.73,209.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:211.2,211.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:214.37,216.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:218.31,220.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:220.22,222.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:222.17,225.4 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:226.3,226.49 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 1 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 0 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 1 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 1 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 1 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 1 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 1 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 1 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 1 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 1 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 1 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 1 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 1 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 1 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 1 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 1 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 1 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 1 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 1 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 1 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 1 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 1 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 1 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 1 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 1 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 1 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 0 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 0 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 0 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 0 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 0 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 0 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 0 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 1 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 1 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 1 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 1 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 1 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 1 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 0 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 0 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 0 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 0 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 0 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 1 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 0 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 0 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 0 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 1 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 1 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 1 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 1 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 0 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 0 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 0 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 0 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 0 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 0 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 0 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 0 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 0 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 0 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 0 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 0 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 0 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 0 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 0 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 1 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 1 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 1 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 0 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 0 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 1 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 1 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 0 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 0 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 0 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 0 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 0 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 0 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 0 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 0 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 0 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 0 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 0 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 0 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 0 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 0 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 0 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 0 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 0 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 0 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 0 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 0 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 0 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 0 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 0 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 0 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 0 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 0 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 0 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 0 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 0 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 0 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 0 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 0 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 0 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 0 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 0 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 0 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 0 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 0 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 0 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 0 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 0 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 0 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 1 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 1 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 1 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 1 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 1 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 1 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 1 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 1 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 1 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 1 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 1 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 1 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 1 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 1 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 1 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 0 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 0 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 0 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 0 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 0 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 0 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 0 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 0 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 1 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:32.88,35.9 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:35.9,37.3 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:39.2,39.33 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:40.18,42.56 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:43.20,45.62 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:46.10,47.15 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:52.60,55.16 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:55.16,58.3 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:60.2,60.12 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:64.55,66.2 0 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:42.47,46.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:49.54,59.16 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:63.2,63.10 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:67.54,69.55 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:69.55,72.3 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:74.2,74.20 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:75.18,77.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:77.39,80.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:86.19,88.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:88.39,91.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:93.3,99.67 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:99.67,101.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:103.20,105.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:105.39,108.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:110.3,115.17 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:115.17,118.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.3,120.67 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.67,122.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:127.71,129.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:132.56,135.2 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:138.68,140.2 0 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:33.62,37.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:40.71,42.2 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:45.72,52.16 4 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:52.16,55.3 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:57.2,59.16 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:65.74,67.2 0 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:56.43,63.2 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:65.58,80.26 7 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:80.26,84.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:85.26,89.4 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:93.2,94.41 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:94.41,99.3 4 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:101.2,104.16 3 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:104.16,106.3 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.2,108.37 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.37,111.70 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:111.70,113.18 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:113.18,115.5 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:116.4,116.14 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.3,119.17 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.17,121.4 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:123.3,123.27 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:127.45,137.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:141.72,154.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:158.75,171.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:173.43,176.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:176.16,179.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:181.2,182.16 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:182.16,185.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:187.2,187.49 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:36.67,38.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:38.16,40.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:42.2,49.16 7 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:49.16,51.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:55.2,55.17 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:55.17,56.17 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:56.17,58.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:58.9,60.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:63.2,63.45 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:63.45,65.17 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:65.17,67.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:68.8,68.52 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:68.52,70.16 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:70.16,72.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:75.2,77.17 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:77.17,79.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:82.2,82.29 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:82.29,84.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:86.2,86.17 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:89.68,91.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:91.16,93.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:95.2,98.41 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:98.41,99.15 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:99.15,101.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:105.2,105.65 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:105.65,107.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:109.2,109.42 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:112.67,114.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:114.16,116.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:117.2,120.16 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:120.16,122.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:124.2,126.51 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:129.68,131.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:131.16,133.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:135.2,136.74 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:136.74,137.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:137.19,139.12 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:141.3,141.41 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:144.2,146.41 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:146.41,147.24 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:147.24,149.12 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:151.3,151.96 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:154.2,154.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:157.67,159.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:159.16,161.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:162.2,163.76 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:163.76,164.14 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:164.14,165.12 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:167.3,168.17 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:168.17,170.12 2 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:172.3,172.13 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:174.2,174.51 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:177.71,179.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:179.16,181.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:183.2,186.16 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:186.16,188.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:190.2,191.31 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:191.31,193.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:195.2,197.30 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:200.74,202.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:202.16,204.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:206.2,209.16 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:209.16,211.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:213.2,215.31 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:215.31,217.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:219.2,220.57 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:220.57,222.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:224.2,224.47 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:227.67,229.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:229.16,231.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:233.2,238.16 4 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:238.16,240.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:242.2,244.31 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:244.31,246.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:248.2,249.50 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:249.50,251.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:253.2,253.12 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:253.12,255.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:257.2,257.47 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:260.70,262.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:262.16,264.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:266.2,271.16 4 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:271.16,273.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:275.2,276.53 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:276.53,278.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:278.8,280.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:282.2,282.16 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:282.16,284.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:286.2,286.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:286.30,289.3 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:291.2,293.44 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:294.12,295.39 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:295.39,297.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:298.3,298.57 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:299.12,300.39 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:300.39,302.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:303.3,303.57 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:304.12,305.39 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:305.39,307.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:308.3,308.39 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:308.39,310.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:311.3,311.57 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:312.12,313.39 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:313.39,314.40 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:314.40,316.5 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:317.4,317.58 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:319.3,319.57 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:320.10,321.82 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:324.2,324.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:327.72,329.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:329.16,331.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:333.2,338.16 4 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:338.16,340.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:342.2,343.55 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:343.55,345.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:345.8,347.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:349.2,349.16 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:349.16,351.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:353.2,353.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:353.30,356.3 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:358.2,360.44 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:361.12,362.39 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:362.39,364.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:365.3,365.57 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:366.12,367.39 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:367.39,369.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:370.3,370.57 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:371.12,372.39 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:372.39,374.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:375.3,375.39 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:375.39,377.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:378.3,378.57 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:379.12,380.39 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:380.39,381.40 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:381.40,383.5 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:384.4,384.58 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:386.3,386.57 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:387.10,388.82 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:391.2,391.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:394.68,397.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:397.16,399.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:401.2,409.32 6 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:409.32,412.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:412.8,414.35 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:415.15,418.18 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:418.18,420.5 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:421.12,422.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:423.14,424.23 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:425.11,427.62 2 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:429.3,429.33 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:433.2,433.115 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:433.115,435.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:438.2,438.54 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:441.68,444.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:444.16,446.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:448.2,456.32 6 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:456.32,459.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:459.8,461.35 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:462.15,465.18 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:465.18,467.5 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:468.12,469.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:470.14,471.23 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:472.11,474.62 2 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:476.3,476.33 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:480.2,480.115 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:480.115,482.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:485.2,485.54 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:488.70,491.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:491.16,493.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:496.2,497.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:497.16,499.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:501.2,509.32 6 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:509.32,512.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:512.8,514.35 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:515.15,517.18 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:517.18,519.5 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:520.12,521.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:522.14,523.23 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:524.11,526.62 2 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:528.3,528.41 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:532.2,532.115 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:532.115,534.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:537.2,537.54 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:540.75,543.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:543.16,545.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:548.2,549.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:549.16,551.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:553.2,561.32 6 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:561.32,564.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:564.8,566.35 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:567.15,569.18 2 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:569.18,571.19 2 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:571.19,573.6 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:574.5,574.49 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:576.16,577.25 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:578.14,579.34 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:580.12,581.34 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:582.11,584.62 2 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:586.3,586.43 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:590.2,590.115 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:590.115,592.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:595.2,596.30 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:599.70,602.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:602.16,604.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:607.2,608.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:608.16,610.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:612.2,620.32 6 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:620.32,623.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:623.8,625.35 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:626.15,628.18 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:628.18,630.5 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:631.12,632.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:633.14,634.23 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:635.11,637.62 2 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:639.3,639.41 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:643.2,643.115 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:643.115,645.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:648.2,648.54 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:651.70,652.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:652.30,654.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:656.2,663.28 5 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:663.28,665.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:668.2,668.99 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:668.99,670.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:673.2,673.65 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:673.65,675.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:677.2,677.31 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:680.72,681.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:681.30,683.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:685.2,688.28 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:688.28,690.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:692.2,692.29 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:695.70,697.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:697.16,699.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:702.2,704.36 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:704.36,705.24 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:705.24,707.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:710.2,710.54 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:713.69,714.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:714.30,716.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:718.2,718.54 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:718.54,721.3 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:723.2,725.42 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:728.73,733.2 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:735.70,738.2 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:740.70,742.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:742.16,744.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:745.2,748.16 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:748.16,750.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:752.2,755.16 4 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:755.16,757.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:759.2,759.51 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:762.69,764.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:764.16,766.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:768.2,771.16 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:771.16,773.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:775.2,782.17 4 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:782.17,784.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:787.2,789.28 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:789.28,793.3 2 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:796.2,796.17 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:796.17,798.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:801.2,803.16 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:803.16,805.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:807.2,808.19 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:809.12,810.73 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:811.12,812.78 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:813.14,814.29 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:815.14,816.31 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:817.17,818.25 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:819.10,820.98 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:823.2,825.51 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:829.68,831.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:831.16,833.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:834.2,837.16 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:837.16,839.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:841.2,844.18 4 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:845.22,846.25 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:847.19,848.26 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:849.23,850.24 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:851.21,852.23 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:853.19,854.37 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:854.37,856.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:856.9,858.4 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:859.23,860.31 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:860.31,862.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:862.9,862.44 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:862.44,864.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:864.9,866.4 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:867.10,868.41 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:870.2,870.57 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:873.69,875.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:875.16,877.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:879.2,880.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:880.16,882.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:884.2,884.57 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:887.71,889.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:889.16,891.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:893.2,895.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:895.16,897.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:899.2,899.50 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:902.75,904.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:904.16,906.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:908.2,909.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:909.16,911.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:913.2,913.54 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:916.68,918.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:918.16,920.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:922.2,923.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:923.16,925.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:926.2,930.22 4 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:930.22,932.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:934.2,934.22 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:934.22,937.27 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:937.27,939.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:942.2,946.28 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:946.28,949.3 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:951.2,953.17 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:953.17,955.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:957.2,957.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:960.68,962.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:962.16,964.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:965.2,970.18 4 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:970.18,972.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:974.2,975.16 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:975.16,977.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:978.2,978.15 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:978.15,980.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:983.2,985.16 3 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:985.16,988.17 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:988.17,990.4 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:993.3,994.17 2 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:994.17,996.4 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:998.3,998.48 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:1002.2,1002.47 1 0 +github.com/echovault/sugardb/internal/modules/generic/commands.go:1005.36,1274.84 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:1274.84,1278.5 1 1 +github.com/echovault/sugardb/internal/modules/generic/commands.go:1294.84,1298.5 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:24.73,25.34 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:25.34,27.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:28.2,32.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:35.74,36.25 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:36.25,38.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:39.2,40.30 2 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:40.30,41.15 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:41.15,43.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:45.2,49.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:52.73,53.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:53.19,55.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:56.2,60.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:63.74,64.18 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:64.18,66.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:67.2,71.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:74.73,75.18 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:75.18,77.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:78.2,82.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:85.77,86.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:86.19,88.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:89.2,93.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:96.80,97.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:97.19,99.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:100.2,104.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:107.73,108.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:108.19,110.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:111.2,115.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:118.76,119.34 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:119.34,121.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:122.2,126.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:129.78,130.34 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:130.34,132.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:133.2,137.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:140.74,141.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:141.19,143.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:144.2,146.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:149.74,150.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:150.19,152.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:153.2,155.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:158.76,159.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:159.19,161.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:162.2,164.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:167.81,168.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:168.19,170.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:171.2,173.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:176.76,177.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:177.19,179.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:180.2,182.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:185.76,186.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:186.19,188.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:189.2,191.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:194.78,195.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:195.19,197.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:198.2,200.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:203.76,204.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:204.19,206.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:207.2,211.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:214.76,215.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:215.19,217.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:218.2,222.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:225.76,226.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:226.19,228.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:229.2,233.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:236.75,237.34 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:237.34,239.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:240.2,244.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:247.74,248.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:248.19,250.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:251.2,255.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:258.75,259.18 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:259.18,261.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:262.2,266.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:269.77,270.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:270.19,272.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:273.2,277.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:280.81,281.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:281.19,283.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:284.2,288.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:291.74,292.34 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:292.34,294.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:296.2,300.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:303.74,304.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:304.19,306.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:307.2,311.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:314.76,315.18 1 1 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:315.18,317.3 1 0 +github.com/echovault/sugardb/internal/modules/generic/key_funcs.go:318.2,322.8 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:37.100,38.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:38.19,40.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:41.2,41.33 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:42.13,44.55 2 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:46.12,47.27 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:47.27,49.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:50.3,51.55 2 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:53.12,54.27 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:54.27,56.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:57.3,58.55 2 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:60.12,61.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:61.19,63.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:64.3,64.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:64.30,66.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:67.3,69.17 3 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:69.17,71.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:72.3,73.55 2 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:75.12,76.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:76.19,78.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:79.3,79.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:79.30,81.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:82.3,84.17 3 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:84.17,86.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:87.3,88.55 2 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:90.14,91.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:91.19,93.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:94.3,94.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:94.30,96.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:97.3,99.17 3 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:99.17,101.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:102.3,103.55 2 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:105.14,106.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:106.19,108.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:109.3,109.30 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:109.30,111.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:112.3,114.17 3 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:114.17,116.4 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:117.3,118.55 2 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:120.10,121.96 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:125.84,126.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:126.19,128.3 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:130.2,130.32 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:131.17,133.49 2 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:135.12,136.19 1 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:136.19,138.4 1 0 +github.com/echovault/sugardb/internal/modules/generic/utils.go:140.3,141.17 2 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:141.17,143.4 1 0 +github.com/echovault/sugardb/internal/modules/generic/utils.go:145.3,146.49 2 1 +github.com/echovault/sugardb/internal/modules/generic/utils.go:149.10,150.98 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:48.36,52.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:55.50,56.18 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:57.10,57.10 0 0 +github.com/echovault/sugardb/internal/raft/fsm.go:59.23,62.60 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:62.60,67.4 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:69.3,74.40 5 0 +github.com/echovault/sugardb/internal/raft/fsm.go:75.11,79.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:81.21,82.66 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:82.66,87.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:88.4,91.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:93.18,96.18 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:96.18,101.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:103.4,106.18 3 0 +github.com/echovault/sugardb/internal/raft/fsm.go:106.18,111.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:112.4,113.10 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:113.10,115.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.4,117.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.96,122.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:122.10,127.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:131.2,131.12 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:135.54,143.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:146.55,149.16 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:149.16,152.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:154.2,159.48 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:159.48,162.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.2,165.81 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.81,167.34 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:167.34,168.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:168.96,170.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:171.4,171.60 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:176.2,178.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:39.50,43.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:46.58,50.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:50.16,53.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:55.2,62.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:62.16,65.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.2,67.40 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.40,70.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:72.2,74.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:78.30,80.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:52.31,56.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:58.46,70.24 9 0 +github.com/echovault/sugardb/internal/raft/raft.go:70.24,75.3 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:75.8,77.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:77.17,79.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:86.3,89.17 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:89.17,91.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:94.2,96.16 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:96.16,98.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:100.2,108.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:108.16,110.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:113.2,133.16 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:133.16,135.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.2,137.27 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.27,148.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:150.2,150.21 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:153.74,155.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:157.36,159.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:161.38,163.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:165.40,172.2 4 0 +github.com/echovault/sugardb/internal/raft/raft.go:179.9,180.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:180.22,182.44 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:182.44,184.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.3,186.56 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.56,188.42 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:188.42,190.5 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:193.3,194.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:194.17,196.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:199.2,199.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:202.61,203.23 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:203.23,205.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.2,207.73 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:207.73,209.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:211.2,211.12 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:214.37,216.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:218.31,220.22 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:220.22,222.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:222.17,225.4 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:226.3,226.49 1 0 +github.com/echovault/sugardb/internal/types.go:35.43,40.29 3 1 +github.com/echovault/sugardb/internal/types.go:41.11,42.12 1 1 +github.com/echovault/sugardb/internal/types.go:44.11,45.34 1 1 +github.com/echovault/sugardb/internal/types.go:47.22,48.12 1 1 +github.com/echovault/sugardb/internal/types.go:49.14,52.24 2 1 +github.com/echovault/sugardb/internal/types.go:55.16,56.23 1 1 +github.com/echovault/sugardb/internal/types.go:56.23,59.4 2 1 +github.com/echovault/sugardb/internal/types.go:62.31,63.53 1 1 +github.com/echovault/sugardb/internal/types.go:65.10,66.117 1 0 +github.com/echovault/sugardb/internal/types.go:69.2,69.18 1 1 +github.com/echovault/sugardb/internal/utils.go:41.38,45.16 2 1 +github.com/echovault/sugardb/internal/utils.go:45.16,47.3 1 1 +github.com/echovault/sugardb/internal/utils.go:49.2,49.15 1 1 +github.com/echovault/sugardb/internal/utils.go:49.15,52.3 2 1 +github.com/echovault/sugardb/internal/utils.go:54.2,56.10 2 1 +github.com/echovault/sugardb/internal/utils.go:59.43,63.16 3 1 +github.com/echovault/sugardb/internal/utils.go:63.16,65.3 1 1 +github.com/echovault/sugardb/internal/utils.go:67.2,68.42 2 1 +github.com/echovault/sugardb/internal/utils.go:68.42,70.3 1 1 +github.com/echovault/sugardb/internal/utils.go:72.2,72.17 1 1 +github.com/echovault/sugardb/internal/utils.go:75.47,82.6 4 1 +github.com/echovault/sugardb/internal/utils.go:82.6,84.43 2 1 +github.com/echovault/sugardb/internal/utils.go:84.43,85.9 1 1 +github.com/echovault/sugardb/internal/utils.go:87.3,87.17 1 1 +github.com/echovault/sugardb/internal/utils.go:87.17,89.4 1 0 +github.com/echovault/sugardb/internal/utils.go:90.3,91.21 2 1 +github.com/echovault/sugardb/internal/utils.go:91.21,92.9 1 1 +github.com/echovault/sugardb/internal/utils.go:94.3,94.15 1 0 +github.com/echovault/sugardb/internal/utils.go:97.2,97.37 1 1 +github.com/echovault/sugardb/internal/utils.go:100.120,102.20 2 1 +github.com/echovault/sugardb/internal/utils.go:102.20,104.3 1 1 +github.com/echovault/sugardb/internal/utils.go:105.2,105.16 1 1 +github.com/echovault/sugardb/internal/utils.go:105.16,107.3 1 1 +github.com/echovault/sugardb/internal/utils.go:108.2,108.24 1 1 +github.com/echovault/sugardb/internal/utils.go:108.24,110.3 1 0 +github.com/echovault/sugardb/internal/utils.go:111.2,111.21 1 1 +github.com/echovault/sugardb/internal/utils.go:111.21,113.3 1 0 +github.com/echovault/sugardb/internal/utils.go:114.2,114.16 1 1 +github.com/echovault/sugardb/internal/utils.go:117.37,119.16 2 1 +github.com/echovault/sugardb/internal/utils.go:119.16,121.3 1 0 +github.com/echovault/sugardb/internal/utils.go:122.2,122.15 1 1 +github.com/echovault/sugardb/internal/utils.go:122.15,123.37 1 1 +github.com/echovault/sugardb/internal/utils.go:123.37,125.4 1 0 +github.com/echovault/sugardb/internal/utils.go:128.2,130.23 2 1 +github.com/echovault/sugardb/internal/utils.go:133.72,134.65 1 1 +github.com/echovault/sugardb/internal/utils.go:134.65,137.3 1 1 +github.com/echovault/sugardb/internal/utils.go:138.2,138.18 1 1 +github.com/echovault/sugardb/internal/utils.go:138.18,141.3 1 0 +github.com/echovault/sugardb/internal/utils.go:142.2,142.49 1 1 +github.com/echovault/sugardb/internal/utils.go:142.49,143.52 1 1 +github.com/echovault/sugardb/internal/utils.go:143.52,145.4 1 1 +github.com/echovault/sugardb/internal/utils.go:147.2,147.71 1 1 +github.com/echovault/sugardb/internal/utils.go:150.66,152.2 1 1 +github.com/echovault/sugardb/internal/utils.go:154.24,155.11 1 1 +github.com/echovault/sugardb/internal/utils.go:155.11,157.3 1 1 +github.com/echovault/sugardb/internal/utils.go:158.2,158.10 1 1 +github.com/echovault/sugardb/internal/utils.go:162.49,166.16 3 0 +github.com/echovault/sugardb/internal/utils.go:166.16,168.3 1 0 +github.com/echovault/sugardb/internal/utils.go:170.2,171.17 2 0 +github.com/echovault/sugardb/internal/utils.go:172.12,173.19 1 0 +github.com/echovault/sugardb/internal/utils.go:174.12,175.26 1 0 +github.com/echovault/sugardb/internal/utils.go:176.12,177.33 1 0 +github.com/echovault/sugardb/internal/utils.go:178.12,179.40 1 0 +github.com/echovault/sugardb/internal/utils.go:180.12,181.47 1 0 +github.com/echovault/sugardb/internal/utils.go:182.10,183.91 1 0 +github.com/echovault/sugardb/internal/utils.go:186.2,186.30 1 0 +github.com/echovault/sugardb/internal/utils.go:190.64,191.20 1 1 +github.com/echovault/sugardb/internal/utils.go:191.20,193.3 1 1 +github.com/echovault/sugardb/internal/utils.go:196.2,196.33 1 1 +github.com/echovault/sugardb/internal/utils.go:196.33,198.3 1 1 +github.com/echovault/sugardb/internal/utils.go:203.2,206.37 2 0 +github.com/echovault/sugardb/internal/utils.go:210.100,211.36 1 1 +github.com/echovault/sugardb/internal/utils.go:211.36,213.26 2 1 +github.com/echovault/sugardb/internal/utils.go:213.26,215.35 1 1 +github.com/echovault/sugardb/internal/utils.go:215.35,216.13 1 1 +github.com/echovault/sugardb/internal/utils.go:219.4,219.30 1 0 +github.com/echovault/sugardb/internal/utils.go:219.30,221.5 1 0 +github.com/echovault/sugardb/internal/utils.go:223.3,223.36 1 1 +github.com/echovault/sugardb/internal/utils.go:223.36,225.4 1 0 +github.com/echovault/sugardb/internal/utils.go:227.2,227.14 1 1 +github.com/echovault/sugardb/internal/utils.go:232.43,233.14 1 1 +github.com/echovault/sugardb/internal/utils.go:233.14,235.3 1 1 +github.com/echovault/sugardb/internal/utils.go:236.2,236.30 1 1 +github.com/echovault/sugardb/internal/utils.go:236.30,238.3 1 0 +github.com/echovault/sugardb/internal/utils.go:239.2,239.30 1 1 +github.com/echovault/sugardb/internal/utils.go:239.30,241.3 1 0 +github.com/echovault/sugardb/internal/utils.go:243.2,244.21 2 1 +github.com/echovault/sugardb/internal/utils.go:244.21,246.3 1 0 +github.com/echovault/sugardb/internal/utils.go:248.2,249.29 2 1 +github.com/echovault/sugardb/internal/utils.go:249.29,251.13 2 1 +github.com/echovault/sugardb/internal/utils.go:251.13,252.9 1 1 +github.com/echovault/sugardb/internal/utils.go:256.2,256.10 1 1 +github.com/echovault/sugardb/internal/utils.go:259.41,261.28 2 1 +github.com/echovault/sugardb/internal/utils.go:261.28,263.3 1 1 +github.com/echovault/sugardb/internal/utils.go:264.2,264.20 1 1 +github.com/echovault/sugardb/internal/utils.go:267.47,270.16 3 1 +github.com/echovault/sugardb/internal/utils.go:270.16,272.3 1 0 +github.com/echovault/sugardb/internal/utils.go:273.2,273.24 1 1 +github.com/echovault/sugardb/internal/utils.go:276.52,279.16 3 1 +github.com/echovault/sugardb/internal/utils.go:279.16,281.3 1 0 +github.com/echovault/sugardb/internal/utils.go:282.2,282.24 1 1 +github.com/echovault/sugardb/internal/utils.go:285.50,288.16 3 1 +github.com/echovault/sugardb/internal/utils.go:288.16,290.3 1 0 +github.com/echovault/sugardb/internal/utils.go:291.2,291.25 1 1 +github.com/echovault/sugardb/internal/utils.go:294.52,297.16 3 1 +github.com/echovault/sugardb/internal/utils.go:297.16,299.3 1 0 +github.com/echovault/sugardb/internal/utils.go:300.2,300.23 1 1 +github.com/echovault/sugardb/internal/utils.go:303.51,306.16 3 1 +github.com/echovault/sugardb/internal/utils.go:306.16,308.3 1 0 +github.com/echovault/sugardb/internal/utils.go:309.2,309.22 1 1 +github.com/echovault/sugardb/internal/utils.go:312.59,316.16 3 1 +github.com/echovault/sugardb/internal/utils.go:316.16,318.3 1 0 +github.com/echovault/sugardb/internal/utils.go:320.2,320.16 1 1 +github.com/echovault/sugardb/internal/utils.go:320.16,322.3 1 1 +github.com/echovault/sugardb/internal/utils.go:324.2,324.39 1 1 +github.com/echovault/sugardb/internal/utils.go:324.39,326.3 1 0 +github.com/echovault/sugardb/internal/utils.go:328.2,329.30 2 1 +github.com/echovault/sugardb/internal/utils.go:329.30,330.17 1 1 +github.com/echovault/sugardb/internal/utils.go:330.17,332.12 2 1 +github.com/echovault/sugardb/internal/utils.go:334.3,334.22 1 1 +github.com/echovault/sugardb/internal/utils.go:336.2,336.17 1 1 +github.com/echovault/sugardb/internal/utils.go:339.67,342.16 3 1 +github.com/echovault/sugardb/internal/utils.go:342.16,344.3 1 0 +github.com/echovault/sugardb/internal/utils.go:345.2,345.16 1 1 +github.com/echovault/sugardb/internal/utils.go:345.16,347.3 1 0 +github.com/echovault/sugardb/internal/utils.go:348.2,349.31 2 1 +github.com/echovault/sugardb/internal/utils.go:349.31,350.18 1 1 +github.com/echovault/sugardb/internal/utils.go:350.18,352.12 2 0 +github.com/echovault/sugardb/internal/utils.go:354.3,355.33 2 1 +github.com/echovault/sugardb/internal/utils.go:355.33,357.4 1 1 +github.com/echovault/sugardb/internal/utils.go:358.3,358.17 1 1 +github.com/echovault/sugardb/internal/utils.go:360.2,360.17 1 1 +github.com/echovault/sugardb/internal/utils.go:363.57,366.16 3 1 +github.com/echovault/sugardb/internal/utils.go:366.16,368.3 1 0 +github.com/echovault/sugardb/internal/utils.go:369.2,369.16 1 1 +github.com/echovault/sugardb/internal/utils.go:369.16,371.3 1 0 +github.com/echovault/sugardb/internal/utils.go:372.2,373.30 2 1 +github.com/echovault/sugardb/internal/utils.go:373.30,374.17 1 1 +github.com/echovault/sugardb/internal/utils.go:374.17,376.12 2 0 +github.com/echovault/sugardb/internal/utils.go:378.3,378.23 1 1 +github.com/echovault/sugardb/internal/utils.go:380.2,380.17 1 1 +github.com/echovault/sugardb/internal/utils.go:383.58,386.16 3 1 +github.com/echovault/sugardb/internal/utils.go:386.16,388.3 1 0 +github.com/echovault/sugardb/internal/utils.go:389.2,389.16 1 1 +github.com/echovault/sugardb/internal/utils.go:389.16,391.3 1 0 +github.com/echovault/sugardb/internal/utils.go:392.2,393.30 2 1 +github.com/echovault/sugardb/internal/utils.go:393.30,394.17 1 1 +github.com/echovault/sugardb/internal/utils.go:394.17,396.12 2 0 +github.com/echovault/sugardb/internal/utils.go:398.3,398.20 1 1 +github.com/echovault/sugardb/internal/utils.go:400.2,400.17 1 1 +github.com/echovault/sugardb/internal/utils.go:403.70,404.32 1 1 +github.com/echovault/sugardb/internal/utils.go:404.32,405.60 1 1 +github.com/echovault/sugardb/internal/utils.go:405.60,407.4 1 1 +github.com/echovault/sugardb/internal/utils.go:407.6,409.4 1 0 +github.com/echovault/sugardb/internal/utils.go:411.2,411.30 1 1 +github.com/echovault/sugardb/internal/utils.go:411.30,412.62 1 1 +github.com/echovault/sugardb/internal/utils.go:412.62,414.4 1 1 +github.com/echovault/sugardb/internal/utils.go:414.6,416.4 1 0 +github.com/echovault/sugardb/internal/utils.go:418.2,418.13 1 1 +github.com/echovault/sugardb/internal/utils.go:421.33,423.16 2 1 +github.com/echovault/sugardb/internal/utils.go:423.16,425.3 1 0 +github.com/echovault/sugardb/internal/utils.go:427.2,428.16 2 1 +github.com/echovault/sugardb/internal/utils.go:428.16,430.3 1 0 +github.com/echovault/sugardb/internal/utils.go:431.2,431.15 1 1 +github.com/echovault/sugardb/internal/utils.go:431.15,433.3 1 1 +github.com/echovault/sugardb/internal/utils.go:435.2,435.42 1 1 +github.com/echovault/sugardb/internal/utils.go:438.61,443.12 4 1 +github.com/echovault/sugardb/internal/utils.go:443.12,444.7 1 1 +github.com/echovault/sugardb/internal/utils.go:444.7,446.73 2 1 +github.com/echovault/sugardb/internal/utils.go:446.73,448.13 1 1 +github.com/echovault/sugardb/internal/utils.go:450.4,450.9 1 1 +github.com/echovault/sugardb/internal/utils.go:452.3,452.21 1 1 +github.com/echovault/sugardb/internal/utils.go:455.2,456.15 2 1 +github.com/echovault/sugardb/internal/utils.go:456.15,458.3 1 1 +github.com/echovault/sugardb/internal/utils.go:460.2,460.9 1 1 +github.com/echovault/sugardb/internal/utils.go:461.18,462.47 1 0 +github.com/echovault/sugardb/internal/utils.go:463.14,464.19 1 1 +github.com/echovault/sugardb/internal/utils.go:468.84,473.12 4 1 +github.com/echovault/sugardb/internal/utils.go:473.12,474.7 1 1 +github.com/echovault/sugardb/internal/utils.go:474.7,476.73 2 1 +github.com/echovault/sugardb/internal/utils.go:476.73,478.13 1 0 +github.com/echovault/sugardb/internal/utils.go:480.4,480.9 1 1 +github.com/echovault/sugardb/internal/utils.go:482.3,482.21 1 1 +github.com/echovault/sugardb/internal/utils.go:485.2,486.15 2 1 +github.com/echovault/sugardb/internal/utils.go:486.15,488.3 1 1 +github.com/echovault/sugardb/internal/utils.go:490.2,490.9 1 1 +github.com/echovault/sugardb/internal/utils.go:491.18,492.47 1 0 +github.com/echovault/sugardb/internal/utils.go:493.14,494.19 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:14.23,16.43 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:16.43,18.3 1 1 +github.com/echovault/sugardb/internal/clock/clock.go:19.2,19.20 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:24.34,26.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:28.58,30.2 1 0 +github.com/echovault/sugardb/internal/clock/clock.go:34.34,37.2 2 1 +github.com/echovault/sugardb/internal/clock/clock.go:39.58,41.2 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:32.88,35.9 2 1 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:35.9,37.3 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:39.2,39.33 1 1 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:40.18,42.56 1 1 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:43.20,45.62 1 1 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:46.10,47.15 1 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:52.60,55.16 2 1 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:55.16,58.3 2 0 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:60.2,60.12 1 1 +github.com/echovault/sugardb/internal/memberlist/broadcast.go:64.55,66.2 0 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:42.47,46.2 1 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:49.54,59.16 3 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:59.16,61.3 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:63.2,63.10 1 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:67.54,69.55 2 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:69.55,72.3 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:74.2,74.20 1 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:75.18,77.39 1 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:77.39,80.4 2 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:81.3,82.17 2 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:86.19,88.39 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:88.39,91.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:93.3,99.67 3 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:99.67,101.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:103.20,105.39 1 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:105.39,108.4 2 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:110.3,115.17 3 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:115.17,118.4 2 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.3,120.67 1 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:120.67,122.4 1 0 +github.com/echovault/sugardb/internal/memberlist/delegate.go:127.71,129.2 1 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:132.56,135.2 1 1 +github.com/echovault/sugardb/internal/memberlist/delegate.go:138.68,140.2 0 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:33.62,37.2 1 1 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:40.71,42.2 1 1 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:45.72,52.16 4 1 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:52.16,55.3 2 0 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:57.2,59.16 2 1 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:59.16,61.3 1 1 +github.com/echovault/sugardb/internal/memberlist/event_delegate.go:65.74,67.2 0 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:56.43,63.2 1 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:65.58,80.26 7 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:80.26,84.4 3 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:85.26,89.4 3 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:93.2,94.41 2 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:94.41,99.3 4 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:101.2,104.16 3 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:104.16,106.3 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.2,108.37 1 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:108.37,111.70 2 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:111.70,113.18 2 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:113.18,115.5 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:116.4,116.14 1 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.3,119.17 1 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:119.17,121.4 1 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:123.3,123.27 1 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:127.45,137.2 2 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:141.72,154.2 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:158.75,171.2 2 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:173.43,176.16 2 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:176.16,179.3 2 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:181.2,182.16 2 1 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:182.16,185.3 2 0 +github.com/echovault/sugardb/internal/memberlist/memberlist.go:187.2,187.49 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:48.36,52.2 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:55.50,56.18 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:57.10,57.10 0 0 +github.com/echovault/sugardb/internal/raft/fsm.go:59.23,62.60 2 1 +github.com/echovault/sugardb/internal/raft/fsm.go:62.60,67.4 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:69.3,74.40 5 1 +github.com/echovault/sugardb/internal/raft/fsm.go:75.11,79.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:81.21,82.66 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:82.66,87.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:88.4,91.5 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:93.18,96.18 2 1 +github.com/echovault/sugardb/internal/raft/fsm.go:96.18,101.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:103.4,106.18 3 1 +github.com/echovault/sugardb/internal/raft/fsm.go:106.18,111.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:112.4,113.10 2 1 +github.com/echovault/sugardb/internal/raft/fsm.go:113.10,115.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:117.4,117.96 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:117.96,122.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:122.10,127.5 1 1 +github.com/echovault/sugardb/internal/raft/fsm.go:131.2,131.12 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:135.54,143.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:146.55,149.16 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:149.16,152.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:154.2,159.48 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:159.48,162.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.2,165.81 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:165.81,167.34 2 0 +github.com/echovault/sugardb/internal/raft/fsm.go:167.34,168.96 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:168.96,170.5 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:171.4,171.60 1 0 +github.com/echovault/sugardb/internal/raft/fsm.go:176.2,178.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:39.50,43.2 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:46.58,50.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:50.16,53.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:55.2,62.16 3 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:62.16,65.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.2,67.40 1 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:67.40,70.3 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:72.2,74.12 2 0 +github.com/echovault/sugardb/internal/raft/fsm_snapshot.go:78.30,80.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:52.31,56.2 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:58.46,70.24 9 1 +github.com/echovault/sugardb/internal/raft/raft.go:70.24,75.3 3 1 +github.com/echovault/sugardb/internal/raft/raft.go:75.8,77.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:77.17,79.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:81.3,82.17 2 0 +github.com/echovault/sugardb/internal/raft/raft.go:82.17,84.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:86.3,89.17 3 0 +github.com/echovault/sugardb/internal/raft/raft.go:89.17,91.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:94.2,96.16 3 1 +github.com/echovault/sugardb/internal/raft/raft.go:96.16,98.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:100.2,108.16 2 1 +github.com/echovault/sugardb/internal/raft/raft.go:108.16,110.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:113.2,133.16 2 1 +github.com/echovault/sugardb/internal/raft/raft.go:133.16,135.3 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:137.2,137.27 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:137.27,148.3 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:150.2,150.21 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:153.74,155.2 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:157.36,159.2 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:161.38,163.2 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:165.40,172.2 4 1 +github.com/echovault/sugardb/internal/raft/raft.go:179.9,180.22 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:180.22,182.44 2 1 +github.com/echovault/sugardb/internal/raft/raft.go:182.44,184.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:186.3,186.56 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:186.56,188.42 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:188.42,190.5 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:193.3,194.17 2 1 +github.com/echovault/sugardb/internal/raft/raft.go:194.17,196.4 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:199.2,199.12 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:202.61,203.23 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:203.23,205.3 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:207.2,207.73 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:207.73,209.3 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:211.2,211.12 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:214.37,216.2 1 0 +github.com/echovault/sugardb/internal/raft/raft.go:218.31,220.22 1 1 +github.com/echovault/sugardb/internal/raft/raft.go:220.22,222.17 2 1 +github.com/echovault/sugardb/internal/raft/raft.go:222.17,225.4 2 1 +github.com/echovault/sugardb/internal/raft/raft.go:226.3,226.49 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:126.69,128.23 2 1 +github.com/echovault/sugardb/sugardb/api_acl.go:128.23,130.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:131.2,132.16 2 1 +github.com/echovault/sugardb/sugardb/api_acl.go:132.16,134.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:135.2,135.45 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:139.53,141.16 2 1 +github.com/echovault/sugardb/sugardb/api_acl.go:141.16,143.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:144.2,144.45 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:155.60,158.18 2 1 +github.com/echovault/sugardb/sugardb/api_acl.go:158.18,160.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:160.8,162.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:164.2,164.21 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:164.21,166.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:168.2,168.17 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:168.17,170.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:172.2,172.21 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:172.21,174.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:176.2,176.20 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:176.20,178.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:180.2,180.20 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:180.20,182.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:184.2,184.24 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:184.24,186.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:188.2,188.50 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:188.50,190.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:192.2,192.53 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:192.53,194.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:196.2,196.49 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:196.49,198.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:200.2,200.52 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:200.52,202.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:204.2,204.50 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:204.50,206.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:208.2,208.50 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:208.50,210.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:212.2,212.47 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:212.47,214.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:216.2,216.47 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:216.47,218.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:220.2,220.48 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:220.48,222.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:224.2,224.43 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:224.43,226.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:228.2,228.44 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:228.44,230.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:232.2,232.47 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:232.47,234.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:236.2,236.47 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:236.47,238.3 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:240.2,241.16 2 1 +github.com/echovault/sugardb/sugardb/api_acl.go:241.16,243.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:245.2,246.40 2 1 +github.com/echovault/sugardb/sugardb/api_acl.go:293.81,295.16 2 1 +github.com/echovault/sugardb/sugardb/api_acl.go:295.16,297.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:299.2,301.16 3 1 +github.com/echovault/sugardb/sugardb/api_acl.go:301.16,303.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:305.2,309.35 3 1 +github.com/echovault/sugardb/sugardb/api_acl.go:309.35,315.35 4 1 +github.com/echovault/sugardb/sugardb/api_acl.go:315.35,317.4 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:320.2,320.20 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:330.70,333.16 3 1 +github.com/echovault/sugardb/sugardb/api_acl.go:333.16,335.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:336.2,337.40 2 1 +github.com/echovault/sugardb/sugardb/api_acl.go:341.52,343.16 2 1 +github.com/echovault/sugardb/sugardb/api_acl.go:343.16,345.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:346.2,346.45 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:357.70,359.9 2 1 +github.com/echovault/sugardb/sugardb/api_acl.go:360.21,361.29 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:362.23,363.31 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:364.10,365.31 1 1 +github.com/echovault/sugardb/sugardb/api_acl.go:368.2,369.16 2 1 +github.com/echovault/sugardb/sugardb/api_acl.go:369.16,371.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:373.2,374.40 2 1 +github.com/echovault/sugardb/sugardb/api_acl.go:380.48,382.16 2 1 +github.com/echovault/sugardb/sugardb/api_acl.go:382.16,384.3 1 0 +github.com/echovault/sugardb/sugardb/api_acl.go:385.2,386.40 2 1 +github.com/echovault/sugardb/sugardb/api_admin.go:142.85,145.22 2 1 +github.com/echovault/sugardb/sugardb/api_admin.go:145.22,146.10 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:147.32,148.75 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:149.33,150.77 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:151.32,152.75 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:156.2,157.16 2 1 +github.com/echovault/sugardb/sugardb/api_admin.go:157.16,159.3 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:161.2,161.45 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:167.52,169.16 2 1 +github.com/echovault/sugardb/sugardb/api_admin.go:169.16,171.3 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:172.2,172.41 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:179.45,181.16 2 1 +github.com/echovault/sugardb/sugardb/api_admin.go:181.16,183.3 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:184.2,185.42 2 1 +github.com/echovault/sugardb/sugardb/api_admin.go:189.48,191.16 2 1 +github.com/echovault/sugardb/sugardb/api_admin.go:191.16,193.3 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:194.2,194.41 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:198.53,200.16 2 1 +github.com/echovault/sugardb/sugardb/api_admin.go:200.16,202.3 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:203.2,203.40 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:215.65,219.36 3 1 +github.com/echovault/sugardb/sugardb/api_admin.go:219.36,220.52 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:220.52,222.4 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:225.2,225.63 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:225.63,230.32 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:230.32,233.44 2 1 +github.com/echovault/sugardb/sugardb/api_admin.go:233.44,235.6 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:236.5,236.16 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:240.111,242.19 2 0 +github.com/echovault/sugardb/sugardb/api_admin.go:242.19,244.6 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:245.5,249.11 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:251.94,259.5 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:261.3,261.13 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:265.2,268.31 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:268.31,271.43 2 1 +github.com/echovault/sugardb/sugardb/api_admin.go:271.43,273.5 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:274.4,274.15 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:278.83,280.4 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:281.71,281.90 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:285.2,285.40 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:285.40,287.92 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:287.92,289.4 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:289.6,290.12 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:292.3,295.32 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:295.32,298.39 2 1 +github.com/echovault/sugardb/sugardb/api_admin.go:298.39,300.6 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:301.5,301.16 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:305.111,307.19 2 0 +github.com/echovault/sugardb/sugardb/api_admin.go:307.19,309.6 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:310.5,314.11 1 0 +github.com/echovault/sugardb/sugardb/api_admin.go:316.94,324.5 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:328.2,330.12 2 1 +github.com/echovault/sugardb/sugardb/api_admin.go:356.74,358.2 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:374.57,378.22 3 1 +github.com/echovault/sugardb/sugardb/api_admin.go:379.9,381.86 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:381.86,383.4 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:384.9,386.45 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:386.45,387.66 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:387.66,388.13 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:390.4,390.88 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:390.88,391.122 1 1 +github.com/echovault/sugardb/sugardb/api_admin.go:391.122,393.6 1 1 +github.com/echovault/sugardb/sugardb/api_connection.go:32.56,33.45 1 1 +github.com/echovault/sugardb/sugardb/api_connection.go:33.45,35.3 1 1 +github.com/echovault/sugardb/sugardb/api_connection.go:36.2,39.12 4 1 +github.com/echovault/sugardb/sugardb/api_connection.go:53.53,54.18 1 1 +github.com/echovault/sugardb/sugardb/api_connection.go:54.18,56.3 1 1 +github.com/echovault/sugardb/sugardb/api_connection.go:58.2,59.35 2 1 +github.com/echovault/sugardb/sugardb/api_connection.go:59.35,61.3 1 1 +github.com/echovault/sugardb/sugardb/api_connection.go:62.2,69.12 5 1 +github.com/echovault/sugardb/sugardb/api_generic.go:42.50,42.62 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:67.41,67.53 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:112.32,112.44 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:140.41,140.53 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:167.89,170.29 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:170.29,172.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:174.2,174.30 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:174.30,176.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:178.2,178.17 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:178.17,180.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:182.2,183.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:183.16,185.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:187.2,188.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:188.16,190.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:191.2,191.18 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:191.18,193.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:195.2,195.33 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:210.70,213.28 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:213.28,215.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:217.2,218.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:218.16,220.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:222.2,223.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:223.16,225.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:227.2,227.40 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:238.56,240.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:240.16,242.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:243.2,243.40 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:254.63,256.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:256.16,258.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:259.2,259.45 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:269.57,271.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:271.16,273.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:274.2,274.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:285.58,287.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:287.16,289.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:290.2,290.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:300.60,302.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:302.16,304.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:305.2,305.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:315.61,317.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:317.16,319.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:320.2,320.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:330.53,332.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:332.16,334.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:335.2,335.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:345.54,347.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:347.16,349.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:350.2,350.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:365.96,368.30 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:368.30,369.17 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:369.17,371.4 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:374.2,375.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:375.16,377.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:379.2,379.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:394.102,397.30 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:397.30,398.17 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:398.17,400.4 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:403.2,404.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:404.16,406.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:408.2,408.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:423.101,426.30 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:426.30,427.17 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:427.17,429.4 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:432.2,433.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:433.16,435.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:437.2,437.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:452.107,455.30 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:455.30,456.17 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:456.17,458.4 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:461.2,462.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:462.16,464.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:466.2,466.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:477.54,483.16 3 1 +github.com/echovault/sugardb/sugardb/api_generic.go:483.16,485.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:488.2,488.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:499.54,505.16 3 1 +github.com/echovault/sugardb/sugardb/api_generic.go:505.16,507.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:510.2,510.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:524.70,529.16 3 1 +github.com/echovault/sugardb/sugardb/api_generic.go:529.16,531.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:533.2,533.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:547.79,552.16 3 1 +github.com/echovault/sugardb/sugardb/api_generic.go:552.16,554.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:556.2,556.39 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:570.70,575.16 3 1 +github.com/echovault/sugardb/sugardb/api_generic.go:575.16,577.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:579.2,579.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:592.77,597.16 3 1 +github.com/echovault/sugardb/sugardb/api_generic.go:597.16,599.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:601.2,601.40 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:613.79,618.16 3 1 +github.com/echovault/sugardb/sugardb/api_generic.go:618.16,620.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:622.2,622.40 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:627.52,629.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:629.16,631.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:632.2,632.40 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:637.46,639.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:639.16,641.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:642.2,642.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:653.59,655.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:655.16,657.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:658.2,658.40 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:672.92,679.19 4 1 +github.com/echovault/sugardb/sugardb/api_generic.go:679.19,682.3 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:684.2,684.19 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:684.19,686.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:688.2,689.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:689.16,691.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:692.2,692.40 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:703.59,706.25 3 1 +github.com/echovault/sugardb/sugardb/api_generic.go:706.25,708.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:710.2,711.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:711.16,713.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:714.2,714.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:725.60,727.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:727.16,729.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:730.2,730.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:741.68,743.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:743.16,745.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:746.2,746.39 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:757.57,759.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:759.16,761.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:762.2,762.40 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:774.97,777.28 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:777.28,779.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:781.2,781.21 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:781.21,783.3 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:785.2,786.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:786.16,788.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:789.2,789.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:802.73,804.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:804.16,806.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:807.2,807.41 1 1 +github.com/echovault/sugardb/sugardb/api_generic.go:818.60,820.16 2 1 +github.com/echovault/sugardb/sugardb/api_generic.go:820.16,822.3 1 0 +github.com/echovault/sugardb/sugardb/api_generic.go:823.2,823.41 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:48.89,51.36 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:51.36,53.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:55.2,56.16 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:56.16,58.3 1 0 +github.com/echovault/sugardb/sugardb/api_hash.go:60.2,60.41 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:78.91,81.36 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:81.36,83.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:85.2,86.16 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:86.16,88.3 1 0 +github.com/echovault/sugardb/sugardb/api_hash.go:90.2,90.41 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:106.77,114.16 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:114.16,116.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:117.2,117.45 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:133.78,141.16 2 0 +github.com/echovault/sugardb/sugardb/api_hash.go:141.16,143.3 1 0 +github.com/echovault/sugardb/sugardb/api_hash.go:145.2,145.45 1 0 +github.com/echovault/sugardb/sugardb/api_hash.go:162.77,166.16 3 1 +github.com/echovault/sugardb/sugardb/api_hash.go:166.16,168.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:170.2,170.46 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:184.60,186.16 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:186.16,188.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:189.2,189.45 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:205.92,208.24 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:208.24,210.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:210.8,212.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:214.2,214.24 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:214.24,216.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:218.2,219.16 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:219.16,221.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:223.2,223.45 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:237.54,239.16 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:239.16,241.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:242.2,242.41 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:256.60,258.16 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:258.16,260.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:261.2,261.45 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:280.83,282.16 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:282.16,284.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:285.2,285.39 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:289.92,291.16 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:291.16,293.3 1 0 +github.com/echovault/sugardb/sugardb/api_hash.go:294.2,294.39 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:309.62,311.16 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:311.16,313.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:314.2,314.45 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:330.65,332.16 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:332.16,334.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:335.2,335.41 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:351.72,354.16 3 1 +github.com/echovault/sugardb/sugardb/api_hash.go:354.16,356.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:357.2,357.41 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:381.111,384.18 3 1 +github.com/echovault/sugardb/sugardb/api_hash.go:384.18,387.3 2 1 +github.com/echovault/sugardb/sugardb/api_hash.go:389.2,394.16 5 1 +github.com/echovault/sugardb/sugardb/api_hash.go:394.16,396.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:397.2,397.46 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:416.74,421.16 4 1 +github.com/echovault/sugardb/sugardb/api_hash.go:421.16,423.3 1 1 +github.com/echovault/sugardb/sugardb/api_hash.go:424.2,424.46 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:34.54,36.16 2 1 +github.com/echovault/sugardb/sugardb/api_list.go:36.16,38.3 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:39.2,39.41 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:58.77,60.16 2 1 +github.com/echovault/sugardb/sugardb/api_list.go:60.16,62.3 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:63.2,63.45 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:79.71,81.16 2 1 +github.com/echovault/sugardb/sugardb/api_list.go:81.16,83.3 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:84.2,84.40 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:104.80,106.16 2 1 +github.com/echovault/sugardb/sugardb/api_list.go:106.16,108.3 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:109.2,110.40 2 1 +github.com/echovault/sugardb/sugardb/api_list.go:117.76,119.16 2 1 +github.com/echovault/sugardb/sugardb/api_list.go:119.16,121.3 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:122.2,123.40 2 1 +github.com/echovault/sugardb/sugardb/api_list.go:141.79,148.16 2 1 +github.com/echovault/sugardb/sugardb/api_list.go:148.16,150.3 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:151.2,151.41 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:175.92,177.16 2 1 +github.com/echovault/sugardb/sugardb/api_list.go:177.16,179.3 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:180.2,181.40 2 1 +github.com/echovault/sugardb/sugardb/api_list.go:195.71,203.16 2 1 +github.com/echovault/sugardb/sugardb/api_list.go:203.16,205.3 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:206.2,206.45 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:220.71,228.16 2 1 +github.com/echovault/sugardb/sugardb/api_list.go:228.16,230.3 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:231.2,231.45 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:248.73,251.16 3 1 +github.com/echovault/sugardb/sugardb/api_list.go:251.16,253.3 1 0 +github.com/echovault/sugardb/sugardb/api_list.go:254.2,254.41 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:270.74,273.16 3 1 +github.com/echovault/sugardb/sugardb/api_list.go:273.16,275.3 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:276.2,276.41 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:293.73,296.16 3 1 +github.com/echovault/sugardb/sugardb/api_list.go:296.16,298.3 1 0 +github.com/echovault/sugardb/sugardb/api_list.go:299.2,299.41 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:315.74,318.16 3 1 +github.com/echovault/sugardb/sugardb/api_list.go:318.16,320.3 1 1 +github.com/echovault/sugardb/sugardb/api_list.go:321.2,321.41 1 0 +github.com/echovault/sugardb/sugardb/api_pubsub.go:42.69,46.41 3 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:46.41,55.3 4 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:55.8,58.10 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:58.10,60.4 1 0 +github.com/echovault/sugardb/sugardb/api_pubsub.go:61.3,62.33 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:65.2,65.33 1 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:78.93,80.16 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:80.16,81.26 1 0 +github.com/echovault/sugardb/sugardb/api_pubsub.go:81.26,83.4 1 0 +github.com/echovault/sugardb/sugardb/api_pubsub.go:87.2,88.12 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:88.12,90.3 1 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:92.2,92.25 1 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:92.25,97.33 4 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:97.33,99.4 1 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:101.3,101.13 1 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:112.68,114.9 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:114.9,116.3 1 0 +github.com/echovault/sugardb/sugardb/api_pubsub.go:117.2,118.107 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:131.94,133.16 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:133.16,134.26 1 0 +github.com/echovault/sugardb/sugardb/api_pubsub.go:134.26,136.4 1 0 +github.com/echovault/sugardb/sugardb/api_pubsub.go:140.2,141.12 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:141.12,143.3 1 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:145.2,145.25 1 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:145.25,150.33 4 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:150.33,152.4 1 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:154.3,154.13 1 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:165.69,167.9 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:167.9,169.3 1 0 +github.com/echovault/sugardb/sugardb/api_pubsub.go:170.2,171.107 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:184.71,186.16 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:186.16,188.3 1 0 +github.com/echovault/sugardb/sugardb/api_pubsub.go:189.2,190.40 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:200.73,202.19 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:202.19,204.3 1 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:205.2,206.16 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:206.16,208.3 1 0 +github.com/echovault/sugardb/sugardb/api_pubsub.go:209.2,209.45 1 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:215.52,217.16 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:217.16,219.3 1 0 +github.com/echovault/sugardb/sugardb/api_pubsub.go:220.2,220.41 1 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:230.81,234.16 3 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:234.16,236.3 1 0 +github.com/echovault/sugardb/sugardb/api_pubsub.go:238.2,240.16 3 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:240.16,242.3 1 0 +github.com/echovault/sugardb/sugardb/api_pubsub.go:244.2,247.28 3 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:247.28,250.3 2 1 +github.com/echovault/sugardb/sugardb/api_pubsub.go:252.2,252.20 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:36.73,39.16 3 1 +github.com/echovault/sugardb/sugardb/api_set.go:39.16,41.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:42.2,42.41 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:56.55,58.16 2 1 +github.com/echovault/sugardb/sugardb/api_set.go:58.16,60.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:61.2,61.41 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:78.64,81.16 3 1 +github.com/echovault/sugardb/sugardb/api_set.go:81.16,83.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:84.2,84.45 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:91.84,94.16 3 1 +github.com/echovault/sugardb/sugardb/api_set.go:94.16,96.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:97.2,97.41 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:114.65,117.16 3 1 +github.com/echovault/sugardb/sugardb/api_set.go:117.16,119.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:120.2,120.45 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:138.75,140.15 2 1 +github.com/echovault/sugardb/sugardb/api_set.go:140.15,142.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:143.2,144.16 2 1 +github.com/echovault/sugardb/sugardb/api_set.go:144.16,146.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:147.2,147.41 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:152.85,155.16 3 1 +github.com/echovault/sugardb/sugardb/api_set.go:155.16,157.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:158.2,158.41 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:174.68,176.16 2 1 +github.com/echovault/sugardb/sugardb/api_set.go:176.16,178.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:179.2,179.41 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:193.63,195.16 2 1 +github.com/echovault/sugardb/sugardb/api_set.go:195.16,197.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:198.2,198.45 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:215.82,218.16 3 1 +github.com/echovault/sugardb/sugardb/api_set.go:218.16,220.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:221.2,221.46 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:243.80,245.16 2 1 +github.com/echovault/sugardb/sugardb/api_set.go:245.16,247.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:248.2,248.41 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:264.71,266.16 2 1 +github.com/echovault/sugardb/sugardb/api_set.go:266.16,268.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:269.2,269.45 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:286.77,288.16 2 1 +github.com/echovault/sugardb/sugardb/api_set.go:288.16,290.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:291.2,291.45 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:307.73,310.16 3 1 +github.com/echovault/sugardb/sugardb/api_set.go:310.16,312.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:313.2,313.41 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:328.65,331.16 3 1 +github.com/echovault/sugardb/sugardb/api_set.go:331.16,333.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:334.2,334.45 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:341.85,344.16 3 1 +github.com/echovault/sugardb/sugardb/api_set.go:344.16,346.3 1 1 +github.com/echovault/sugardb/sugardb/api_set.go:347.2,347.41 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:105.87,107.28 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:107.28,108.17 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:108.17,110.18 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:110.18,112.5 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:113.4,114.12 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:116.3,116.23 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:118.2,118.20 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:142.103,145.9 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:146.18,147.26 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:148.18,149.26 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:152.2,152.9 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:153.18,154.26 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:155.18,156.26 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:159.2,159.16 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:159.16,161.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:163.2,163.18 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:163.18,165.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:167.2,167.37 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:167.37,169.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:171.2,172.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:172.16,174.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:176.2,176.41 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:190.55,192.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:192.16,194.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:195.2,195.41 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:213.74,221.16 3 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:221.16,223.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:224.2,224.41 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:242.91,244.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:244.16,246.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:247.2,248.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:248.16,250.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:252.2,253.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:253.16,255.3 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:257.2,257.45 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:274.84,277.16 3 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:277.16,279.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:280.2,280.41 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:297.97,300.30 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:300.30,302.45 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:302.45,304.4 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:307.2,307.29 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:307.29,309.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:311.2,311.24 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:311.24,313.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:315.2,316.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:316.16,318.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:320.2,321.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:321.16,323.3 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:325.2,325.53 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:344.112,347.30 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:347.30,349.42 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:349.42,351.4 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:354.2,354.29 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:354.29,356.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:358.2,358.24 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:358.24,360.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:362.2,363.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:363.16,365.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:367.2,367.41 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:384.97,387.30 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:387.30,389.42 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:389.42,391.4 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:394.2,394.29 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:394.29,396.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:398.2,398.24 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:398.24,400.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:402.2,403.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:403.16,405.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:407.2,408.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:408.16,410.3 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:412.2,412.53 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:431.112,434.30 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:434.30,436.42 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:436.42,438.4 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:441.2,441.29 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:441.29,443.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:445.2,445.24 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:445.24,447.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:449.2,450.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:450.16,452.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:454.2,454.41 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:473.95,476.16 3 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:476.16,478.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:479.2,480.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:480.16,482.3 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:483.2,483.15 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:500.87,503.9 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:504.19,505.27 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:506.19,507.27 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:508.10,509.27 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:512.2,512.9 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:513.26,514.76 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:515.10,516.59 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:519.2,520.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:520.16,522.3 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:524.2,524.51 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:542.86,544.33 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:544.33,546.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:548.2,549.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:549.16,551.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:553.2,554.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:554.16,556.3 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:558.2,559.24 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:559.24,560.14 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:560.14,562.12 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:564.3,565.17 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:565.17,567.4 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:568.3,568.20 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:571.2,571.20 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:591.69,594.16 3 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:594.16,596.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:597.2,597.41 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:616.76,618.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:618.16,620.3 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:621.2,621.51 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:640.76,642.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:642.16,644.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:645.2,645.51 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:669.96,671.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:671.16,673.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:674.2,674.16 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:674.16,676.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:678.2,679.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:679.16,681.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:683.2,683.51 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:704.99,706.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:706.16,708.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:710.2,711.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:711.16,713.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:715.2,717.19 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:717.19,719.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:721.2,722.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:722.16,724.3 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:726.2,728.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:728.16,730.17 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:730.17,732.4 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:733.3,733.13 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:736.2,736.17 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:741.102,743.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:743.16,745.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:747.2,748.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:748.16,750.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:752.2,754.19 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:754.19,756.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:758.2,759.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:759.16,761.3 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:763.2,765.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:765.16,767.17 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:767.17,769.4 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:770.3,770.13 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:773.2,773.17 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:790.79,793.16 3 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:793.16,795.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:797.2,798.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:798.16,800.3 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:802.2,802.11 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:802.11,804.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:806.2,807.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:807.16,809.3 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:811.2,811.19 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:827.73,829.33 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:829.33,831.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:832.2,833.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:833.16,835.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:836.2,836.41 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:854.92,863.16 3 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:863.16,865.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:867.2,867.41 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:885.74,892.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:892.16,894.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:895.2,895.41 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:913.79,920.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:920.16,922.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:923.2,923.41 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:943.107,946.9 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:947.23,948.31 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:949.21,950.29 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:951.19,952.27 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:953.10,954.31 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:957.2,957.24 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:957.24,959.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:961.2,961.47 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:961.47,963.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:965.2,966.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:966.16,968.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:970.2,971.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:971.16,973.3 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:975.2,975.53 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:997.118,1000.9 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:1001.23,1002.31 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:1003.21,1004.29 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:1005.19,1006.27 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:1007.10,1008.31 1 0 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:1011.2,1011.47 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:1011.47,1013.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:1015.2,1016.16 2 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:1016.16,1018.3 1 1 +github.com/echovault/sugardb/sugardb/api_sorted_set.go:1020.2,1020.41 1 1 +github.com/echovault/sugardb/sugardb/api_string.go:31.82,33.16 2 1 +github.com/echovault/sugardb/sugardb/api_string.go:33.16,35.3 1 0 +github.com/echovault/sugardb/sugardb/api_string.go:36.2,36.41 1 1 +github.com/echovault/sugardb/sugardb/api_string.go:46.56,48.16 2 1 +github.com/echovault/sugardb/sugardb/api_string.go:48.16,50.3 1 0 +github.com/echovault/sugardb/sugardb/api_string.go:51.2,51.41 1 1 +github.com/echovault/sugardb/sugardb/api_string.go:64.75,66.16 2 1 +github.com/echovault/sugardb/sugardb/api_string.go:66.16,68.3 1 0 +github.com/echovault/sugardb/sugardb/api_string.go:69.2,69.40 1 1 +github.com/echovault/sugardb/sugardb/api_string.go:73.77,75.16 2 1 +github.com/echovault/sugardb/sugardb/api_string.go:75.16,77.3 1 0 +github.com/echovault/sugardb/sugardb/api_string.go:78.2,78.40 1 1 +github.com/echovault/sugardb/sugardb/api_string.go:89.70,91.16 2 1 +github.com/echovault/sugardb/sugardb/api_string.go:91.16,93.3 1 1 +github.com/echovault/sugardb/sugardb/api_string.go:94.2,94.41 1 1 +github.com/echovault/sugardb/sugardb/cluster.go:25.43,27.2 1 1 +github.com/echovault/sugardb/sugardb/cluster.go:29.82,44.16 6 1 +github.com/echovault/sugardb/sugardb/cluster.go:44.16,46.3 1 0 +github.com/echovault/sugardb/sugardb/cluster.go:48.2,50.43 2 1 +github.com/echovault/sugardb/sugardb/cluster.go:50.43,52.3 1 0 +github.com/echovault/sugardb/sugardb/cluster.go:54.2,56.9 2 1 +github.com/echovault/sugardb/sugardb/cluster.go:56.9,58.3 1 0 +github.com/echovault/sugardb/sugardb/cluster.go:60.2,60.20 1 1 +github.com/echovault/sugardb/sugardb/cluster.go:60.20,62.3 1 0 +github.com/echovault/sugardb/sugardb/cluster.go:64.2,64.12 1 1 +github.com/echovault/sugardb/sugardb/cluster.go:67.92,83.16 7 1 +github.com/echovault/sugardb/sugardb/cluster.go:83.16,85.3 1 0 +github.com/echovault/sugardb/sugardb/cluster.go:87.2,89.43 2 1 +github.com/echovault/sugardb/sugardb/cluster.go:89.43,91.3 1 0 +github.com/echovault/sugardb/sugardb/cluster.go:93.2,95.9 2 1 +github.com/echovault/sugardb/sugardb/cluster.go:95.9,97.3 1 0 +github.com/echovault/sugardb/sugardb/cluster.go:99.2,99.20 1 1 +github.com/echovault/sugardb/sugardb/cluster.go:99.20,101.3 1 0 +github.com/echovault/sugardb/sugardb/cluster.go:103.2,103.24 1 1 +github.com/echovault/sugardb/sugardb/config.go:27.36,29.2 1 1 +github.com/echovault/sugardb/sugardb/config.go:31.60,36.23 1 1 +github.com/echovault/sugardb/sugardb/config.go:36.23,37.28 1 1 +github.com/echovault/sugardb/sugardb/config.go:37.28,39.5 1 1 +github.com/echovault/sugardb/sugardb/config.go:40.4,40.23 1 1 +github.com/echovault/sugardb/sugardb/config.go:42.23,43.29 1 1 +github.com/echovault/sugardb/sugardb/config.go:43.29,45.5 1 1 +github.com/echovault/sugardb/sugardb/config.go:46.4,46.34 1 1 +github.com/echovault/sugardb/sugardb/config.go:46.34,48.5 1 1 +github.com/echovault/sugardb/sugardb/config.go:49.4,49.20 1 1 +github.com/echovault/sugardb/sugardb/config.go:60.48,61.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:61.32,62.17 1 0 +github.com/echovault/sugardb/sugardb/config.go:62.17,64.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:64.9,66.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:73.62,74.32 1 1 +github.com/echovault/sugardb/sugardb/config.go:74.32,76.3 1 1 +github.com/echovault/sugardb/sugardb/config.go:82.62,83.32 1 1 +github.com/echovault/sugardb/sugardb/config.go:83.32,85.3 1 1 +github.com/echovault/sugardb/sugardb/config.go:91.49,92.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:92.32,93.17 1 0 +github.com/echovault/sugardb/sugardb/config.go:93.17,95.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:95.9,97.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:110.74,111.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:111.32,112.37 1 0 +github.com/echovault/sugardb/sugardb/config.go:112.37,114.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:121.63,122.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:122.32,124.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:130.51,131.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:131.32,133.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:139.59,140.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:140.32,142.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:148.59,149.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:149.32,151.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:157.59,158.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:158.32,160.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:166.57,167.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:167.32,169.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:175.61,176.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:176.32,177.17 1 0 +github.com/echovault/sugardb/sugardb/config.go:177.17,179.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:179.9,181.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:188.61,189.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:189.32,191.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:197.59,198.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:198.32,199.17 1 0 +github.com/echovault/sugardb/sugardb/config.go:199.17,201.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:201.9,203.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:210.56,211.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:211.32,212.17 1 0 +github.com/echovault/sugardb/sugardb/config.go:212.17,214.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:214.9,216.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:223.59,224.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:224.32,226.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:232.77,233.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:233.32,235.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:241.82,242.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:242.32,244.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:250.60,251.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:251.32,252.17 1 0 +github.com/echovault/sugardb/sugardb/config.go:252.17,254.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:254.9,256.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:263.55,264.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:264.32,265.17 1 0 +github.com/echovault/sugardb/sugardb/config.go:265.17,267.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:267.9,269.4 1 0 +github.com/echovault/sugardb/sugardb/config.go:276.73,277.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:277.32,279.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:285.61,286.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:286.32,288.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:294.71,295.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:295.32,297.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:303.69,304.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:304.32,306.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:312.82,313.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:313.32,315.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:321.59,322.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:322.32,324.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:330.69,331.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:331.32,333.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:339.67,340.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:340.32,342.3 1 0 +github.com/echovault/sugardb/sugardb/config.go:348.67,349.32 1 0 +github.com/echovault/sugardb/sugardb/config.go:349.32,351.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:42.58,44.28 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:44.28,46.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:49.2,50.55 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:50.55,51.36 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:51.36,53.4 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:55.2,60.59 4 0 +github.com/echovault/sugardb/sugardb/keyspace.go:60.59,61.24 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:62.18,68.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:69.18,75.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:82.44,89.20 5 0 +github.com/echovault/sugardb/sugardb/keyspace.go:89.20,90.35 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:90.35,103.4 8 0 +github.com/echovault/sugardb/sugardb/keyspace.go:104.3,104.9 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:108.2,118.48 8 0 +github.com/echovault/sugardb/sugardb/keyspace.go:121.86,129.27 5 1 +github.com/echovault/sugardb/sugardb/keyspace.go:129.27,132.3 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:134.2,134.15 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:137.77,144.9 5 1 +github.com/echovault/sugardb/sugardb/keyspace.go:144.9,146.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:148.2,148.23 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:151.95,158.9 5 0 +github.com/echovault/sugardb/sugardb/keyspace.go:158.9,160.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:162.2,164.29 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:167.93,175.27 5 1 +github.com/echovault/sugardb/sugardb/keyspace.go:175.27,177.10 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:177.10,179.12 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:182.3,182.83 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:182.83,183.29 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:183.29,186.19 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:186.19,188.6 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:189.10,189.65 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:189.65,192.19 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:192.19,194.6 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:195.10,195.66 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:195.66,200.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:201.4,202.12 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:205.3,205.28 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:209.2,209.46 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:209.46,210.64 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:210.64,212.4 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:215.2,215.15 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:218.93,222.131 3 1 +github.com/echovault/sugardb/sugardb/keyspace.go:222.131,225.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:227.2,230.35 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:230.35,232.3 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:234.2,234.34 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:234.34,236.47 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:236.47,238.4 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:239.3,245.17 4 1 +github.com/echovault/sugardb/sugardb/keyspace.go:245.17,247.4 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:248.3,252.28 4 1 +github.com/echovault/sugardb/sugardb/keyspace.go:252.28,254.4 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:258.2,258.63 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:258.63,259.31 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:259.31,261.18 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:261.18,263.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:267.2,267.12 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:270.99,283.65 6 1 +github.com/echovault/sugardb/sugardb/keyspace.go:283.65,285.3 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:286.2,289.11 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:289.11,290.44 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:290.44,292.18 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:292.18,294.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:299.111,306.9 5 1 +github.com/echovault/sugardb/sugardb/keyspace.go:306.9,308.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:309.2,315.65 3 1 +github.com/echovault/sugardb/sugardb/keyspace.go:315.65,317.3 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:318.2,320.12 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:323.73,329.16 4 1 +github.com/echovault/sugardb/sugardb/keyspace.go:329.16,331.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:332.2,342.117 7 1 +github.com/echovault/sugardb/sugardb/keyspace.go:342.117,344.3 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:347.2,347.9 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:348.108,349.46 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:350.108,351.46 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:354.2,356.12 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:359.53,377.2 10 1 +github.com/echovault/sugardb/sugardb/keyspace.go:379.66,381.6 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:381.6,382.83 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:382.83,384.9 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:387.2,388.38 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:388.38,390.27 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:390.27,392.4 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:394.2,395.13 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:400.93,405.83 3 1 +github.com/echovault/sugardb/sugardb/keyspace.go:405.83,407.3 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:409.2,409.34 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:409.34,411.3 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:413.2,416.27 3 1 +github.com/echovault/sugardb/sugardb/keyspace.go:416.27,418.48 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:418.48,419.12 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:422.3,424.56 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:425.29,428.50 3 1 +github.com/echovault/sugardb/sugardb/keyspace.go:429.29,432.50 3 1 +github.com/echovault/sugardb/sugardb/keyspace.go:433.30,435.61 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:435.61,437.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:438.4,438.50 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:439.30,441.61 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:441.61,443.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:444.4,444.50 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:448.2,452.34 4 1 +github.com/echovault/sugardb/sugardb/keyspace.go:452.34,455.87 3 1 +github.com/echovault/sugardb/sugardb/keyspace.go:455.87,456.56 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:456.56,458.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:459.4,459.13 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:463.2,463.12 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:463.12,466.3 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:468.2,468.9 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:469.24,470.71 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:471.18,471.18 0 1 +github.com/echovault/sugardb/sugardb/keyspace.go:474.2,474.26 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:478.69,480.34 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:480.34,482.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:484.2,489.54 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:489.54,491.3 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:493.2,494.54 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:494.54,496.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:502.2,503.9 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:504.125,509.7 3 0 +github.com/echovault/sugardb/sugardb/keyspace.go:509.7,511.50 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:511.50,513.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:515.4,516.29 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:516.29,518.54 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:518.54,522.6 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:523.10,523.65 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:523.65,525.63 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:525.63,528.6 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:531.4,533.56 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:533.56,535.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:537.125,542.7 3 0 +github.com/echovault/sugardb/sugardb/keyspace.go:542.7,544.50 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:544.50,546.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:548.4,549.29 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:549.29,551.54 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:551.54,554.6 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:555.10,555.65 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:555.65,558.63 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:558.63,560.6 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:564.4,566.56 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:566.56,568.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:570.105,573.7 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:573.7,575.30 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:575.30,578.5 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:580.4,581.39 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:581.39,582.23 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:582.23,583.31 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:583.31,584.19 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:584.19,585.33 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:585.33,587.58 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:587.58,591.10 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:592.14,592.69 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:592.69,593.67 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:593.67,596.10 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:599.8,601.60 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:601.60,603.9 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:605.7,605.12 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:610.106,613.7 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:613.7,620.29 5 0 +github.com/echovault/sugardb/sugardb/keyspace.go:620.29,622.54 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:622.54,626.6 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:627.10,627.65 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:627.65,628.63 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:628.63,631.6 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:635.4,637.56 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:637.56,639.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:641.10,642.13 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:651.75,653.57 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:653.57,655.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:657.2,664.60 4 0 +github.com/echovault/sugardb/sugardb/keyspace.go:664.60,666.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:667.2,674.33 6 0 +github.com/echovault/sugardb/sugardb/keyspace.go:674.33,675.7 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:675.7,679.35 3 0 +github.com/echovault/sugardb/sugardb/keyspace.go:679.35,681.10 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:685.2,690.25 4 0 +github.com/echovault/sugardb/sugardb/keyspace.go:690.25,695.30 3 0 +github.com/echovault/sugardb/sugardb/keyspace.go:695.30,698.11 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:698.11,700.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:702.4,702.30 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:702.30,703.38 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:703.38,705.6 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:711.3,712.36 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:712.36,713.12 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:717.3,718.28 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:718.28,719.51 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:719.51,721.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:722.9,722.64 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:722.64,723.60 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:723.60,725.5 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:730.2,730.21 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:730.21,732.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:734.2,737.58 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:737.58,741.3 2 0 +github.com/echovault/sugardb/sugardb/keyspace.go:743.2,743.12 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:746.62,753.15 5 1 +github.com/echovault/sugardb/sugardb/keyspace.go:753.15,755.3 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:757.2,761.45 4 1 +github.com/echovault/sugardb/sugardb/keyspace.go:761.45,762.19 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:762.19,764.9 2 1 +github.com/echovault/sugardb/sugardb/keyspace.go:765.9,767.4 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:771.2,771.16 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:774.56,780.2 4 1 +github.com/echovault/sugardb/sugardb/keyspace.go:782.84,787.34 4 1 +github.com/echovault/sugardb/sugardb/keyspace.go:787.34,791.3 3 1 +github.com/echovault/sugardb/sugardb/keyspace.go:791.8,793.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:795.2,795.16 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:795.16,797.3 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:799.2,799.18 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:802.92,807.34 4 1 +github.com/echovault/sugardb/sugardb/keyspace.go:807.34,811.3 3 1 +github.com/echovault/sugardb/sugardb/keyspace.go:811.8,813.3 1 0 +github.com/echovault/sugardb/sugardb/keyspace.go:815.2,815.16 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:815.16,817.3 1 1 +github.com/echovault/sugardb/sugardb/keyspace.go:819.2,822.18 3 1 +github.com/echovault/sugardb/sugardb/modules.go:30.73,33.42 3 1 +github.com/echovault/sugardb/sugardb/modules.go:33.42,34.46 1 1 +github.com/echovault/sugardb/sugardb/modules.go:34.46,36.4 1 1 +github.com/echovault/sugardb/sugardb/modules.go:38.2,38.72 1 1 +github.com/echovault/sugardb/sugardb/modules.go:41.123,72.58 1 1 +github.com/echovault/sugardb/sugardb/modules.go:72.58,76.4 3 1 +github.com/echovault/sugardb/sugardb/modules.go:77.67,81.4 3 1 +github.com/echovault/sugardb/sugardb/modules.go:82.90,92.24 5 1 +github.com/echovault/sugardb/sugardb/modules.go:92.24,94.5 1 0 +github.com/echovault/sugardb/sugardb/modules.go:97.4,98.37 2 1 +github.com/echovault/sugardb/sugardb/modules.go:98.37,100.5 1 1 +github.com/echovault/sugardb/sugardb/modules.go:101.4,106.43 3 1 +github.com/echovault/sugardb/sugardb/modules.go:111.135,114.25 2 1 +github.com/echovault/sugardb/sugardb/modules.go:114.25,120.3 3 1 +github.com/echovault/sugardb/sugardb/modules.go:120.8,126.3 3 1 +github.com/echovault/sugardb/sugardb/modules.go:127.2,130.16 3 1 +github.com/echovault/sugardb/sugardb/modules.go:130.16,132.3 1 1 +github.com/echovault/sugardb/sugardb/modules.go:134.2,134.19 1 1 +github.com/echovault/sugardb/sugardb/modules.go:134.19,136.3 1 1 +github.com/echovault/sugardb/sugardb/modules.go:139.2,139.39 1 1 +github.com/echovault/sugardb/sugardb/modules.go:139.39,141.3 1 0 +github.com/echovault/sugardb/sugardb/modules.go:143.2,144.16 2 1 +github.com/echovault/sugardb/sugardb/modules.go:144.16,146.3 1 1 +github.com/echovault/sugardb/sugardb/modules.go:148.2,152.16 4 1 +github.com/echovault/sugardb/sugardb/modules.go:152.16,154.3 1 1 +github.com/echovault/sugardb/sugardb/modules.go:155.2,156.8 2 1 +github.com/echovault/sugardb/sugardb/modules.go:156.8,159.3 2 1 +github.com/echovault/sugardb/sugardb/modules.go:161.2,161.51 1 1 +github.com/echovault/sugardb/sugardb/modules.go:161.51,164.87 1 1 +github.com/echovault/sugardb/sugardb/modules.go:164.87,166.4 1 0 +github.com/echovault/sugardb/sugardb/modules.go:170.2,170.50 1 1 +github.com/echovault/sugardb/sugardb/modules.go:170.50,171.7 1 1 +github.com/echovault/sugardb/sugardb/modules.go:171.7,172.42 1 1 +github.com/echovault/sugardb/sugardb/modules.go:172.42,174.10 2 1 +github.com/echovault/sugardb/sugardb/modules.go:179.2,179.43 1 1 +github.com/echovault/sugardb/sugardb/modules.go:179.43,181.17 2 1 +github.com/echovault/sugardb/sugardb/modules.go:181.17,183.4 1 1 +github.com/echovault/sugardb/sugardb/modules.go:185.3,185.62 1 1 +github.com/echovault/sugardb/sugardb/modules.go:185.62,189.4 3 1 +github.com/echovault/sugardb/sugardb/modules.go:191.3,193.18 2 1 +github.com/echovault/sugardb/sugardb/modules.go:197.2,197.32 1 1 +github.com/echovault/sugardb/sugardb/modules.go:197.32,200.17 3 1 +github.com/echovault/sugardb/sugardb/modules.go:200.17,202.4 1 0 +github.com/echovault/sugardb/sugardb/modules.go:203.3,203.18 1 1 +github.com/echovault/sugardb/sugardb/modules.go:207.2,207.34 1 1 +github.com/echovault/sugardb/sugardb/modules.go:207.34,210.3 2 1 +github.com/echovault/sugardb/sugardb/modules.go:212.2,212.72 1 1 +github.com/echovault/sugardb/sugardb/modules.go:215.57,217.2 1 1 +github.com/echovault/sugardb/sugardb/modules.go:219.45,221.2 1 1 +github.com/echovault/sugardb/sugardb/modules.go:223.48,225.2 1 1 +github.com/echovault/sugardb/sugardb/modules.go:227.47,229.2 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:30.105,32.2 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:37.9,40.37 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:40.37,42.3 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:42.8,42.43 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:42.43,44.3 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:47.2,48.65 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:48.65,50.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:53.2,61.33 8 1 +github.com/echovault/sugardb/sugardb/plugin.go:62.13,63.105 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:64.12,65.104 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:68.2,68.16 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:68.16,70.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:73.2,92.84 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:92.84,94.72 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:94.72,95.36 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:96.13,101.12 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:102.16,103.51 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:104.15,105.50 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:109.72,111.67 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:111.67,112.36 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:113.13,114.79 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:115.16,116.61 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:117.15,118.60 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:125.2,127.12 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:138.70,142.49 3 1 +github.com/echovault/sugardb/sugardb/plugin.go:142.49,143.38 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:143.38,145.4 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:148.2,148.41 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:148.41,149.37 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:149.37,151.4 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:152.3,152.44 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:155.2,156.16 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:156.16,158.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:160.2,161.16 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:161.16,163.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:164.2,165.9 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:165.9,167.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:169.2,170.16 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:170.16,172.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:173.2,174.9 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:174.9,176.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:178.2,179.16 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:179.16,181.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:182.2,183.9 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:183.9,185.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:187.2,188.16 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:188.16,190.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:191.2,192.9 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:192.9,194.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:196.2,197.16 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:197.16,199.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:200.2,201.9 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:201.9,203.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:205.2,206.16 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:206.16,208.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:209.2,217.9 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:217.9,219.3 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:222.2,222.91 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:222.91,224.3 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:227.2,230.31 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:230.31,233.36 2 1 +github.com/echovault/sugardb/sugardb/plugin.go:233.36,235.5 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:236.4,236.15 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:241.83,243.18 2 0 +github.com/echovault/sugardb/sugardb/plugin.go:243.18,245.5 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:246.4,250.10 1 0 +github.com/echovault/sugardb/sugardb/plugin.go:252.72,261.4 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:264.2,264.12 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:272.52,275.91 3 1 +github.com/echovault/sugardb/sugardb/plugin.go:275.91,277.3 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:283.47,287.42 4 1 +github.com/echovault/sugardb/sugardb/plugin.go:287.42,288.61 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:288.61,290.4 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:290.6,292.4 1 1 +github.com/echovault/sugardb/sugardb/plugin.go:294.2,294.16 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:39.48,43.2 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:45.51,47.2 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:49.28,52.2 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:54.101,60.16 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:60.16,62.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:63.2,63.42 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:63.42,65.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:68.2,68.61 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:68.61,72.33 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:72.33,74.36 2 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:74.36,78.5 3 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:81.3,83.21 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:87.2,87.60 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:87.60,91.33 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:91.33,94.36 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:94.36,98.5 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:99.4,99.16 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:102.3,104.21 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:108.2,108.64 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:108.64,112.34 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:112.34,114.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:115.3,117.55 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:117.55,119.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:121.3,129.21 7 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:133.2,133.61 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:133.61,136.41 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:136.41,137.23 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:137.23,139.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:140.4,142.15 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:142.15,144.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:145.4,146.11 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:146.11,148.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:149.4,149.31 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:151.3,155.21 4 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:159.2,160.16 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:160.16,162.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:163.2,164.37 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:164.37,166.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:169.2,170.16 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:170.16,172.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:173.2,174.39 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:174.39,176.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:177.2,182.16 4 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:182.16,184.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:185.2,186.41 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:186.41,188.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:191.2,192.16 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:192.16,194.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:195.2,195.20 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:195.20,197.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:198.2,203.93 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:207.115,210.9 2 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:210.9,212.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:213.2,223.21 6 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:223.21,225.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:226.2,227.16 2 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:227.16,229.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:230.2,230.19 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:230.19,232.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:233.2,238.9 5 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:238.9,239.39 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:239.39,241.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:242.3,242.24 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:245.2,248.9 4 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:248.9,249.39 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:249.39,251.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:252.3,252.25 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:255.2,259.8 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:263.120,266.9 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:266.9,268.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:269.2,279.21 6 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:279.21,281.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:282.2,286.21 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:286.21,291.4 4 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:297.34,300.35 3 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:300.35,302.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:303.4,303.22 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:307.34,310.35 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:310.35,311.25 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:312.13,313.29 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:314.14,315.40 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:316.19,318.44 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:318.44,320.7 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:321.6,321.33 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:322.20,325.33 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:326.19,329.33 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:330.32,333.34 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:336.4,336.22 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:340.40,342.36 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:342.36,343.25 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:344.13,345.99 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:346.14,347.23 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:348.17,349.54 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:350.16,351.38 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:352.18,353.35 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:354.19,355.36 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:356.33,358.37 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:358.37,360.7 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:361.6,362.17 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:362.17,370.7 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:371.6,371.24 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:372.14,373.91 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:374.21,375.36 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:376.20,377.35 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:378.33,379.48 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:383.4,383.67 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:383.67,385.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:391.2,391.16 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:391.16,393.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:394.2,398.25 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:401.53,404.61 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:404.61,406.35 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:406.35,410.4 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:412.3,413.15 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:415.2,415.63 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:415.63,418.35 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:418.35,419.35 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:419.35,420.13 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:422.4,425.37 4 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:427.3,428.11 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:430.2,430.61 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:430.61,432.41 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:432.41,436.4 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:437.3,437.24 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:439.2,439.61 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:439.61,442.3 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:443.2,443.61 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:443.61,445.29 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:445.29,448.4 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:449.3,449.24 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:451.2,451.64 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:451.64,453.41 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:453.41,458.4 4 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:459.3,459.24 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:461.2,461.61 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:461.61,463.41 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:463.41,465.35 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:465.35,468.5 2 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:470.3,471.16 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:475.51,478.61 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:478.61,480.18 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:480.18,482.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:483.3,484.35 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:484.35,488.4 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:489.3,491.16 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:493.2,493.61 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:493.61,498.28 5 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:498.28,500.4 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:501.3,501.24 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:503.2,503.66 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:503.66,507.3 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:508.2,508.69 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:508.69,511.3 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:512.2,512.64 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:512.64,514.18 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:514.18,516.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:517.3,518.35 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:518.35,522.4 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:523.3,524.16 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:526.2,526.61 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:526.61,530.25 4 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:530.25,532.4 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:533.3,533.24 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:535.2,535.64 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:535.64,540.28 5 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:540.28,542.4 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:543.3,543.24 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:545.2,545.62 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:545.62,550.14 5 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:550.14,552.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:553.3,553.19 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:554.11,555.59 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:556.17,559.17 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:561.3,561.26 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:563.2,563.66 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:563.66,564.67 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:564.67,566.34 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:566.34,568.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:569.4,570.31 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:570.31,572.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:573.4,573.36 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:573.36,576.30 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:576.30,578.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:580.5,583.16 4 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:583.16,585.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:586.5,587.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:587.12,589.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:590.5,590.27 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:592.4,592.20 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:594.3,595.17 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:595.17,597.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:598.3,601.24 4 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:605.74,608.63 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:608.63,609.33 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:610.10,613.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:614.10,617.19 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:617.19,619.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:620.4,620.33 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:621.11,628.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:630.3,630.26 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:632.2,632.63 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:632.63,633.33 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:634.10,636.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:637.10,639.21 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:639.21,641.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:642.4,642.33 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:643.11,650.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:652.3,652.26 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:656.56,659.28 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:659.28,661.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:662.2,663.79 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:663.79,665.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:666.2,667.23 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:667.23,669.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:670.2,670.12 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:673.71,675.83 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:675.83,677.35 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:677.35,679.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:680.3,681.30 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:681.30,683.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:684.3,684.35 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:684.35,687.29 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:687.29,689.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:691.4,694.15 4 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:694.15,696.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:697.4,698.11 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:698.11,700.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:701.4,701.33 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:703.3,703.22 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:707.2,713.82 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:713.82,715.33 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:715.33,717.4 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:718.3,718.35 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:718.35,720.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:721.3,723.35 3 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:723.35,724.43 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:724.43,727.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:728.4,729.15 2 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:730.18,731.23 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:731.23,733.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:734.5,735.15 2 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:735.15,737.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:737.11,739.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:740.22,741.38 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:742.19,743.23 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:743.23,745.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:746.5,747.32 2 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:748.16,749.23 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:749.23,751.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:752.5,753.26 2 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:756.3,756.24 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:759.2,761.61 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:761.61,762.63 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:762.63,764.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:766.3,767.17 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:767.17,769.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:771.3,772.17 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:772.17,774.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:775.3,782.17 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:782.17,784.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:785.3,786.11 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:788.2,788.64 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:788.64,789.63 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:789.63,791.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:793.3,794.17 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:794.17,796.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:798.3,799.17 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:799.17,801.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:802.3,809.17 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:809.17,811.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:812.3,813.11 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:815.2,815.64 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:815.64,816.34 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:816.34,818.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:819.3,821.11 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:823.2,823.69 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:823.69,826.3 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:827.2,827.66 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:827.66,828.34 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:828.34,830.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:831.3,832.11 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:834.2,834.64 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:834.64,835.34 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:835.34,837.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:838.3,840.46 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:840.46,844.4 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:845.3,846.11 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:848.2,848.61 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:848.61,850.33 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:850.33,854.4 3 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:855.3,856.11 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:858.2,858.66 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:858.66,859.81 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:859.81,861.34 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:861.34,863.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:864.4,865.31 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:865.31,867.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:868.4,868.36 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:868.36,871.30 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:871.30,873.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:875.5,878.16 4 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:878.16,880.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:881.5,882.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:882.12,884.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:885.5,885.29 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:887.4,887.21 1 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:889.3,890.17 2 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:890.17,892.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:893.3,896.24 4 1 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:900.68,902.12 2 0 +github.com/echovault/sugardb/sugardb/plugin_javascript.go:905.37,907.14 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:29.103,33.39 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:33.39,35.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:38.2,41.77 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:41.77,47.3 5 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:49.2,50.38 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:50.38,55.56 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:55.56,58.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:58.12,61.6 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:62.5,62.56 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:62.56,64.42 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:64.42,67.7 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:69.6,70.20 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:70.20,73.7 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:74.6,74.49 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:74.49,76.7 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:76.12,80.7 3 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:81.6,81.16 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:84.4,85.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:87.40,92.56 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:92.56,95.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:95.12,98.6 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:99.5,99.56 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:99.56,101.42 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:101.42,103.7 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:104.6,105.20 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:105.20,108.7 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:110.6,110.49 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:110.49,113.7 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:116.4,117.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:119.38,124.58 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:124.58,125.42 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:125.42,128.6 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:129.5,129.42 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:129.42,132.6 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:133.5,134.47 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:134.47,136.6 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:136.11,138.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:139.5,139.32 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:141.4,142.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:144.38,149.4 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:150.38,154.36 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:154.36,156.5 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:157.4,158.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:160.41,165.58 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:165.58,166.42 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:166.42,169.6 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:170.5,170.42 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:170.42,173.6 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:174.5,175.44 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:177.4,178.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:180.38,185.58 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:185.58,186.42 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:186.42,189.6 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:190.5,190.42 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:190.42,193.6 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:194.5,194.47 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:194.47,197.6 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:199.4,200.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:205.2,208.76 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:208.76,212.26 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:212.26,214.57 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:214.57,216.5 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:217.4,217.16 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:220.3,224.11 5 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:227.2,228.38 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:228.38,233.55 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:233.55,235.5 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:237.4,238.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:240.38,245.43 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:245.43,247.5 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:249.4,250.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:252.43,256.4 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:257.46,261.4 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:262.41,267.55 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:267.55,269.5 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:271.4,272.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:274.39,281.4 6 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:282.43,287.55 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:287.55,289.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:289.12,292.6 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:293.5,294.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:294.12,297.6 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:298.5,298.27 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:301.4,305.12 5 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:307.38,311.33 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:311.33,313.5 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:315.4,316.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:318.41,323.46 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:323.46,325.5 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:327.4,328.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:333.2,336.88 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:336.88,340.26 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:340.26,342.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:344.3,345.54 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:345.54,346.41 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:347.17,348.45 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:348.45,351.6 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:352.5,352.47 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:353.17,354.51 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:355.12,356.89 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:360.3,360.24 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:360.24,362.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:364.3,368.11 5 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:371.2,372.40 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:372.40,374.27 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:374.27,377.5 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:378.4,379.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:381.40,383.27 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:383.27,386.5 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:387.4,388.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:393.2,396.82 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:396.82,399.26 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:399.26,401.58 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:401.58,403.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:403.12,405.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:406.5,406.55 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:406.55,409.6 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:410.5,410.91 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:414.3,419.11 6 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:422.2,423.38 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:423.38,429.61 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:429.61,431.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:431.12,433.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:434.5,434.56 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:434.56,437.6 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:438.5,438.72 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:442.4,446.27 5 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:446.27,448.61 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:448.61,449.26 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:450.14,451.74 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:452.20,453.29 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:453.29,455.8 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:455.13,457.8 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:458.24,459.34 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:460.21,461.29 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:461.29,463.8 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:464.18,465.29 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:465.29,467.8 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:472.4,473.18 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:473.18,475.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:476.4,477.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:479.41,485.61 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:485.61,487.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:487.12,489.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:490.5,490.56 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:490.56,493.6 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:494.5,494.72 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:498.4,502.27 5 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:502.27,504.61 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:504.61,505.26 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:506.14,507.74 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:508.20,509.29 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:509.29,511.8 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:511.13,513.8 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:514.24,515.34 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:516.21,517.29 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:517.29,519.8 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:520.18,521.29 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:521.29,523.8 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:528.4,529.18 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:529.18,531.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:532.4,533.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:535.41,539.4 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:540.46,543.4 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:544.43,548.4 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:549.41,553.27 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:553.27,555.5 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:557.4,559.35 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:559.35,564.5 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:566.4,567.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:569.38,573.39 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:573.39,578.5 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:580.4,581.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:583.43,588.55 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:588.55,590.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:590.12,592.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:593.5,594.12 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:594.12,596.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:597.5,597.34 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:600.4,606.12 6 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:611.2,612.36 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:612.36,614.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:617.2,619.35 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:619.35,621.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:622.2,622.45 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:622.45,624.3 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:627.2,628.35 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:628.35,630.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:633.2,638.95 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:642.116,645.9 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:645.9,647.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:648.2,658.24 6 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:658.24,660.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:662.2,663.25 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:663.25,665.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:668.2,673.54 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:673.54,677.4 3 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:680.2,680.16 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:680.16,682.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:683.2,684.45 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:684.45,688.30 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:688.30,691.39 3 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:691.39,693.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:694.5,694.13 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:696.31,699.39 3 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:699.39,701.6 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:702.5,702.13 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:705.8,709.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:713.121,716.9 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:716.9,718.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:719.2,733.35 9 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:733.35,735.3 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:737.2,737.57 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:737.57,743.33 4 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:743.33,745.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:747.3,750.34 3 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:750.34,752.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:754.3,755.11 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:758.2,758.57 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:758.57,764.33 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:764.33,766.4 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:768.3,771.34 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:771.34,774.4 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:776.3,777.11 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:780.2,780.57 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:780.57,786.52 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:786.52,789.18 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:789.18,791.5 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:793.3,793.65 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:793.65,795.4 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:797.3,798.11 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:801.2,802.25 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:802.25,804.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:807.2,812.54 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:812.54,816.4 3 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:818.2,818.16 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:818.16,820.3 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:822.2,823.40 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:826.48,828.39 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:828.39,830.3 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:831.2,832.12 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:835.46,837.38 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:837.38,839.3 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:840.2,841.12 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:844.73,846.53 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:846.53,848.3 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:849.2,850.12 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:853.65,855.51 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:855.51,857.3 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:858.2,859.12 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:862.54,865.55 3 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:865.55,868.10 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:868.10,871.4 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:873.3,874.10 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:874.10,877.4 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:878.3,878.30 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:878.30,881.4 2 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:882.3,882.34 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:884.2,884.18 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:887.65,888.22 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:889.17,890.18 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:891.20,892.29 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:893.20,894.49 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:895.19,896.41 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:897.22,898.46 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:899.11,900.47 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:901.18,902.56 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:903.17,904.55 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:905.30,906.68 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:908.10,909.58 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:913.71,914.22 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:915.14,916.37 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:917.15,918.38 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:919.15,920.38 1 0 +github.com/echovault/sugardb/sugardb/plugin_lua.go:921.18,922.34 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:923.16,925.44 2 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:925.44,927.4 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:928.3,928.13 1 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:929.17,933.12 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:934.16,938.12 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:939.29,943.12 4 1 +github.com/echovault/sugardb/sugardb/plugin_lua.go:945.2,945.12 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:137.70,167.39 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:167.39,180.4 12 1 +github.com/echovault/sugardb/sugardb/sugardb.go:185.2,185.33 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:185.33,187.3 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:189.2,195.46 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:195.46,196.50 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:196.50,198.12 2 0 +github.com/echovault/sugardb/sugardb/sugardb.go:200.3,200.41 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:204.2,209.27 3 1 +github.com/echovault/sugardb/sugardb/sugardb.go:209.27,219.59 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:219.59,223.5 3 1 +github.com/echovault/sugardb/sugardb/sugardb.go:224.57,226.53 2 0 +github.com/echovault/sugardb/sugardb/sugardb.go:226.53,227.30 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:227.30,228.47 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:228.47,230.8 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:233.5,233.17 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:236.3,244.5 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:245.8,256.73 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:256.73,258.52 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:258.52,260.35 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:260.35,261.54 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:261.54,263.8 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:266.5,266.17 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:268.86,270.91 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:270.91,272.6 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:273.5,273.54 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:278.3,284.68 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:284.68,286.52 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:286.52,288.35 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:288.35,289.54 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:289.54,291.8 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:294.5,294.17 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:296.82,298.92 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:298.92,300.6 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:301.5,301.55 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:303.65,307.19 4 1 +github.com/echovault/sugardb/sugardb/sugardb.go:307.19,309.6 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:312.3,312.17 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:312.17,314.4 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:315.3,315.32 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:319.2,319.59 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:319.59,320.13 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:320.13,322.17 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:322.17,324.5 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:325.4,325.8 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:325.8,326.12 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:327.21,330.59 2 0 +github.com/echovault/sugardb/sugardb/sugardb.go:330.59,333.56 3 0 +github.com/echovault/sugardb/sugardb/sugardb.go:333.56,334.67 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:334.67,336.9 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:337.8,337.17 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:340.6,340.15 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:341.28,342.11 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:348.2,348.65 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:348.65,350.3 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:352.2,352.27 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:352.27,358.3 3 1 +github.com/echovault/sugardb/sugardb/sugardb.go:360.2,360.28 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:360.28,363.32 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:363.32,365.18 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:365.18,367.5 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:371.3,371.67 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:371.67,373.18 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:373.18,375.5 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:379.2,379.21 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:382.35,394.16 4 1 +github.com/echovault/sugardb/sugardb/sugardb.go:394.16,397.3 2 0 +github.com/echovault/sugardb/sugardb/sugardb.go:399.2,399.15 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:399.15,402.3 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:404.2,404.27 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:404.27,406.16 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:406.16,408.4 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:408.9,410.4 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:412.3,413.49 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:413.49,415.18 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:415.18,418.5 2 0 +github.com/echovault/sugardb/sugardb/sugardb.go:419.4,419.42 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:422.3,425.16 3 1 +github.com/echovault/sugardb/sugardb/sugardb.go:425.16,427.37 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:427.37,429.19 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:429.19,432.6 2 0 +github.com/echovault/sugardb/sugardb/sugardb.go:433.5,434.19 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:434.19,436.6 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:437.5,437.61 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:437.61,439.6 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:443.3,447.5 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:450.2,453.6 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:453.6,454.10 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:455.22,456.10 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:457.11,459.18 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:459.18,462.5 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:464.4,464.36 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:469.56,471.23 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:471.23,473.3 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:475.2,492.15 7 1 +github.com/echovault/sugardb/sugardb/sugardb.go:492.15,494.38 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:494.38,496.4 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:499.2,499.6 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:499.6,502.43 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:502.43,505.9 2 0 +github.com/echovault/sugardb/sugardb/sugardb.go:508.3,508.17 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:508.17,510.9 2 0 +github.com/echovault/sugardb/sugardb/sugardb.go:513.3,514.43 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:514.43,515.9 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:517.3,517.17 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:517.17,519.87 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:519.87,521.5 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:522.4,522.12 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:525.3,528.20 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:528.20,529.12 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:532.3,532.28 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:532.28,534.12 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:538.3,539.7 2 0 +github.com/echovault/sugardb/sugardb/sugardb.go:539.7,541.41 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:541.41,543.19 2 0 +github.com/echovault/sugardb/sugardb/sugardb.go:543.19,545.6 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:546.5,546.10 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:548.4,549.21 2 0 +github.com/echovault/sugardb/sugardb/sugardb.go:549.21,550.10 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:552.4,552.27 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:562.32,564.2 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:567.45,568.38 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:568.38,570.3 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:572.2,572.12 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:572.12,573.27 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:573.27,575.53 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:575.53,577.5 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:578.4,578.10 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:581.3,581.62 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:581.62,583.4 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:586.2,586.12 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:589.40,591.2 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:593.41,595.2 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:597.54,599.2 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:602.54,604.2 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:606.42,608.2 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:610.43,612.2 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:615.43,616.40 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:616.40,618.3 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:619.2,619.54 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:619.54,621.3 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:622.2,622.12 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:627.35,628.35 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:628.35,629.13 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:629.13,629.42 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:630.3,630.13 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:630.13,630.45 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:632.3,633.71 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:633.71,635.4 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:639.2,641.42 3 1 +github.com/echovault/sugardb/sugardb/sugardb.go:641.42,642.73 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:642.73,644.11 2 0 +github.com/echovault/sugardb/sugardb/sugardb.go:644.11,645.13 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:647.4,652.24 3 0 +github.com/echovault/sugardb/sugardb/sugardb.go:653.22,654.37 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:656.4,656.25 1 0 +github.com/echovault/sugardb/sugardb/sugardb.go:659.2,661.27 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:661.27,664.3 1 1 +github.com/echovault/sugardb/sugardb/sugardb.go:664.8,668.3 2 1 +github.com/echovault/sugardb/sugardb/sugardb.go:671.43,689.40 3 1 +github.com/echovault/sugardb/sugardb/sugardb.go:689.40,692.3 2 0 +github.com/echovault/sugardb/sugardb/test_helpers.go:12.31,20.2 2 1 +github.com/echovault/sugardb/sugardb/test_helpers.go:22.59,27.2 2 1 +github.com/echovault/sugardb/sugardb/test_helpers.go:29.93,31.82 2 1 +github.com/echovault/sugardb/sugardb/test_helpers.go:31.82,33.3 1 0 +github.com/echovault/sugardb/sugardb/test_helpers.go:34.2,34.12 1 1 +github.com/echovault/sugardb/sugardb/test_helpers.go:37.93,41.2 3 1 +github.com/echovault/sugardb/sugardb/test_helpers.go:43.104,45.16 2 1 +github.com/echovault/sugardb/sugardb/test_helpers.go:45.16,47.3 1 0 +github.com/echovault/sugardb/sugardb/test_helpers.go:48.2,50.55 2 1 diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..2a34b9e --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,281 @@ +networks: + testnet: + driver: bridge + +services: + standalone_node: + container_name: standalone_node + build: + context: . + dockerfile: Dockerfile.dev + environment: + - BIND_ADDR=0.0.0.0 + - PORT=7480 + - DISCOVERY_PORT=7946 + - SERVER_ID=1 + - PLUGIN_DIR=/usr/local/lib/sugardb + - DATA_DIR=/var/lib/sugardb + - TLS=false + - MTLS=false + - BOOTSTRAP_CLUSTER=false + - ACL_CONFIG=/etc/sugardb/config/acl.yml + - REQUIRE_PASS=false + - PASSWORD=password1 + - FORWARD_COMMAND=false + - SNAPSHOT_THRESHOLD=1000 + - SNAPSHOT_INTERVAL=5m30s + - RESTORE_SNAPSHOT=true + - RESTORE_AOF=false + - AOF_SYNC_STRATEGY=everysec + - MAX_MEMORY=2000kb + - EVICTION_POLICY=noeviction + - EVICTION_SAMPLE=20 + - EVICTION_INTERVAL=100ms + # List of sugardb cert/key pairs + - CERT_KEY_PAIR_1=/etc/ssl/certs/sugardb/server/server1.crt,/etc/ssl/certs/sugardb/server/server1.key + - CERT_KEY_PAIR_2=/etc/ssl/certs/sugardb/server/server2.crt,/etc/ssl/certs/sugardb/server/server2.key + # List of client certificate authorities + - CLIENT_CA_1=/etc/ssl/certs/sugardb/client/rootCA.crt + # List of shared object plugins to load on startup + - MODULE_1=./modules/module_set/module_set.so + - MODULE_2=./modules/module_get/module_get.so + ports: + - "7480:7480" + - "7946:7946" + volumes: + - ./internal/volumes/config:/etc/sugardb/config + - ./internal/volumes/nodes/standalone_node:/var/lib/sugardb + - ./internal/volumes/modules/lua:/var/lib/sugardb/scripts/lua + - ./internal/volumes/modules/js:/var/lib/sugardb/scripts/js + networks: + - testnet + + cluster_node_1: + container_name: cluster_node_1 + build: + context: . + dockerfile: Dockerfile.dev + environment: + - BIND_ADDR=0.0.0.0 + - PORT=7480 + - DISCOVERY_PORT=7946 + - SERVER_ID=1 + - JOIN_ADDR=2/cluster_node_2:7946 + - DATA_DIR=/var/lib/sugardb + - TLS=false + - MTLS=false + - BOOTSTRAP_CLUSTER=true + - ACL_CONFIG=/etc/sugardb/config/acl.yml + - REQUIRE_PASS=false + - FORWARD_COMMAND=true + - SNAPSHOT_THRESHOLD=1000 + - SNAPSHOT_INTERVAL=5m30s + - RESTORE_SNAPSHOT=false + - RESTORE_AOF=false + - AOF_SYNC_STRATEGY=everysec + - MAX_MEMORY=100mb + - EVICTION_POLICY=noeviction + - EVICTION_SAMPLE=20 + - EVICTION_INTERVAL=100ms + # List of sugardb cert/key pairs + - CERT_KEY_PAIR_1=/etc/ssl/certs/sugardb/server/server1.crt,/etc/ssl/certs/sugardb/server/server1.key + - CERT_KEY_PAIR_2=/etc/ssl/certs/sugardb/server/server2.crt,/etc/ssl/certs/sugardb/server/server2.key + # List of client certificate authorities + - CLIENT_CA_1=/etc/ssl/certs/sugardb/client/rootCA.crt + # List of shared object plugins to load on startup + - MODULE_1=./modules/module_set/module_set.so + - MODULE_2=./modules/module_get/module_get.so + ports: + - "7481:7480" + - "7945:7946" + volumes: + - ./internal/volumes/config:/etc/sugardb/config + - ./internal/volumes/nodes/cluster_node_1:/var/lib/sugardb + - ./internal/volumes/modules/lua:/var/lib/sugardb/scripts/lua + - ./internal/volumes/modules/js:/var/lib/sugardb/scripts/js + networks: + - testnet + + cluster_node_2: + container_name: cluster_node_2 + build: + context: . + dockerfile: Dockerfile.dev + environment: + - BIND_ADDR=0.0.0.0 + - PORT=7480 + - DISCOVERY_PORT=7946 + - SERVER_ID=2 + - JOIN_ADDR=3/cluster_node_3:7946 + - DATA_DIR=/var/lib/sugardb + - TLS=false + - MTLS=false + - BOOTSTRAP_CLUSTER=false + - ACL_CONFIG=/etc/sugardb/config/acl.yml + - REQUIRE_PASS=false + - FORWARD_COMMAND=true + - SNAPSHOT_THRESHOLD=1000 + - SNAPSHOT_INTERVAL=5m30s + - RESTORE_SNAPSHOT=false + - RESTORE_AOF=false + - AOF_SYNC_STRATEGY=everysec + - MAX_MEMORY=100mb + - EVICTION_POLICY=noeviction + - EVICTION_SAMPLE=20 + - EVICTION_INTERVAL=100ms + # List of sugardb cert/key pairs + - CERT_KEY_PAIR_1=/etc/ssl/certs/sugardb/server/server1.crt,/etc/ssl/certs/sugardb/server/server1.key + - CERT_KEY_PAIR_2=/etc/ssl/certs/sugardb/server/server2.crt,/etc/ssl/certs/sugardb/server/server2.key + # List of client certificate authorities + - CLIENT_CA_1=/etc/ssl/certs/sugardb/client/rootCA.crt + # List of shared object plugins to load on startup + - MODULE_1=./modules/module_set/module_set.so + - MODULE_2=./modules/module_get/module_get.so + ports: + - "7482:7480" + - "7947:7946" + volumes: + - ./internal/volumes/config:/etc/sugardb/config + - ./internal/volumes/nodes/cluster_node_2:/var/lib/sugardb + - ./internal/volumes/modules/lua:/var/lib/sugardb/scripts/lua + - ./internal/volumes/modules/js:/var/lib/sugardb/scripts/js + networks: + - testnet + + cluster_node_3: + container_name: cluster_node_3 + build: + context: . + dockerfile: Dockerfile.dev + environment: + - BIND_ADDR=0.0.0.0 + - PORT=7480 + - DISCOVERY_PORT=7946 + - SERVER_ID=3 + - JOIN_ADDR=4/cluster_node_4:7946 + - DATA_DIR=/var/lib/sugardb + - TLS=false + - MTLS=false + - BOOTSTRAP_CLUSTER=false + - ACL_CONFIG=/etc/sugardb/config/acl.yml + - REQUIRE_PASS=false + - FORWARD_COMMAND=true + - SNAPSHOT_THRESHOLD=1000 + - SNAPSHOT_INTERVAL=5m30s + - RESTORE_SNAPSHOT=false + - RESTORE_AOF=false + - AOF_SYNC_STRATEGY=everysec + - MAX_MEMORY=100mb + - EVICTION_POLICY=noeviction + - EVICTION_SAMPLE=20 + - EVICTION_INTERVAL=100ms + # List of sugardb cert/key pairs + - CERT_KEY_PAIR_1=/etc/ssl/certs/sugardb/server/server1.crt,/etc/ssl/certs/sugardb/server/server1.key + - CERT_KEY_PAIR_2=/etc/ssl/certs/sugardb/server/server2.crt,/etc/ssl/certs/sugardb/server/server2.key + # List of client certificate authorities + - CLIENT_CA_1=/etc/ssl/certs/sugardb/client/rootCA.crt + # List of shared object plugins to load on startup + - MODULE_1=./modules/module_set/module_set.so + - MODULE_2=./modules/module_get/module_get.so + ports: + - "7483:7480" + - "7948:7946" + volumes: + - ./internal/volumes/config:/etc/sugardb/config + - ./internal/volumes/nodes/cluster_node_3:/var/lib/sugardb + - ./internal/volumes/modules/lua:/var/lib/sugardb/scripts/lua + - ./internal/volumes/modules/js:/var/lib/sugardb/scripts/js + networks: + - testnet + + cluster_node_4: + container_name: cluster_node_4 + build: + context: . + dockerfile: Dockerfile.dev + environment: + - BIND_ADDR=0.0.0.0 + - PORT=7480 + - DISCOVERY_PORT=7946 + - SERVER_ID=4 + - JOIN_ADDR=5/cluster_node_5:7946 + - DATA_DIR=/var/lib/sugardb + - TLS=false + - MTLS=false + - BOOTSTRAP_CLUSTER=false + - ACL_CONFIG=/etc/sugardb/config/acl.yml + - REQUIRE_PASS=false + - FORWARD_COMMAND=true + - SNAPSHOT_THRESHOLD=1000 + - SNAPSHOT_INTERVAL=5m30s + - RESTORE_SNAPSHOT=false + - RESTORE_AOF=false + - AOF_SYNC_STRATEGY=everysec + - MAX_MEMORY=100mb + - EVICTION_POLICY=noeviction + - EVICTION_SAMPLE=20 + - EVICTION_INTERVAL=100ms + # List of sugardb cert/key pairs + - CERT_KEY_PAIR_1=/etc/ssl/certs/sugardb/server/server1.crt,/etc/ssl/certs/sugardb/server/server1.key + - CERT_KEY_PAIR_2=/etc/ssl/certs/sugardb/server/server2.crt,/etc/ssl/certs/sugardb/server/server2.key + # List of client certificate authorities + - CLIENT_CA_1=/etc/ssl/certs/sugardb/client/rootCA.crt + # List of shared object plugins to load on startup + - MODULE_1=./modules/module_set/module_set.so + - MODULE_2=./modules/module_get/module_get.so + ports: + - "7484:7480" + - "7949:7946" + volumes: + - ./internal/volumes/config:/etc/sugardb/config + - ./internal/volumes/nodes/cluster_node_4:/var/lib/sugardb + - ./internal/volumes/modules/lua:/var/lib/sugardb/scripts/lua + - ./internal/volumes/modules/js:/var/lib/sugardb/scripts/js + networks: + - testnet + + cluster_node_5: + container_name: cluster_node_5 + build: + context: . + dockerfile: Dockerfile.dev + environment: + - BIND_ADDR=0.0.0.0 + - PORT=7480 + - DISCOVERY_PORT=7946 + - SERVER_ID=5 + - JOIN_ADDR=1/cluster_node_1:7946 + - DATA_DIR=/var/lib/sugardb + - TLS=false + - MTLS=false + - BOOTSTRAP_CLUSTER=false + - ACL_CONFIG=/etc/sugardb/config/acl.yml + - REQUIRE_PASS=false + - FORWARD_COMMAND=true + - SNAPSHOT_THRESHOLD=1000 + - SNAPSHOT_INTERVAL=5m30s + - RESTORE_SNAPSHOT=false + - RESTORE_AOF=false + - AOF_SYNC_STRATEGY=everysec + - MAX_MEMORY=100mb + - EVICTION_POLICY=noeviction + - EVICTION_SAMPLE=20 + - EVICTION_INTERVAL=100ms + # List of sugardb cert/key pairs + - CERT_KEY_PAIR_1=/etc/ssl/certs/sugardb/server/server1.crt,/etc/ssl/certs/sugardb/server/server1.key + - CERT_KEY_PAIR_2=/etc/ssl/certs/sugardb/server/server2.crt,/etc/ssl/certs/sugardb/server/server2.key + # List of client certificate authorities + - CLIENT_CA_1=/etc/ssl/certs/sugardb/client/rootCA.crt + # List of shared object plugins to load on startup + - MODULE_1=./modules/module_set/module_set.so + - MODULE_2=./modules/module_get/module_get.so + ports: + - "7485:7480" + - "7950:7946" + volumes: + - ./internal/volumes/config:/etc/sugardb/config + - ./internal/volumes/nodes/cluster_node_5:/var/lib/sugardb + - ./internal/volumes/modules/lua:/var/lib/sugardb/scripts/lua + - ./internal/volumes/modules/js:/var/lib/sugardb/scripts/js + networks: + - testnet \ No newline at end of file diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..cf21810 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,22 @@ +# Dependencies +/node_modules + +# Production +/build + +# Generated files +.docusaurus +.cache-loader +.next +package-lock.json + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..7e98318 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +.io \ No newline at end of file diff --git a/docs/babel.config.js b/docs/babel.config.js new file mode 100644 index 0000000..e00595d --- /dev/null +++ b/docs/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: [require.resolve('@docusaurus/core/lib/babel/preset')], +}; diff --git a/docs/blog/authors.yml b/docs/blog/authors.yml new file mode 100644 index 0000000..a524b5f --- /dev/null +++ b/docs/blog/authors.yml @@ -0,0 +1,5 @@ +kelvinmwinuka: + name: Kelvin Clement Mwinuka + title: EchoVault Maintainer + url: https://kelvinmwinuka.com + image_url: https://github.com/kelvinmwinuka.png diff --git a/docs/docs/acl.md b/docs/docs/acl.md new file mode 100644 index 0000000..40a529c --- /dev/null +++ b/docs/docs/acl.md @@ -0,0 +1,199 @@ +--- +sidebar_position: 7 +--- + +# Access Control List + +Access Control Lists enable you to add a layer of security to the SugarDB server or cluster. You can create users with associated rules and require clients to authorize before executing commands on the server. + +SugarDB creates a default user upon startup. You can see this user by executing the following command: + +``` +> ACL LIST +1) "default on +@all +all %RW~* +&*" +``` + +The default user is enabled, and has access to all categories, commands, keys and pub/sub channels. Connections are associated with user by default. + +You can configure the default user to require a passwords by using the following configuration options: + +- `--require-pass` forces the SugarDB server to require a user to authenticate itself using a password and/or username. + +- `--password` attaches the provided password to the default user. + +## Authorization + +The TCP client can authenticate itself using the `AUTH` command: + +`AUTH ` tries to authenticate the TCP connection with the provided username and password. + +`AUTH ` tries to authenticate the TCP connection with the default user and the provided passsword. + +Authorization is not supported in embedded mode. When an SugarDB instance is embedded, it autimatically has access to all the commands exposed by the API. + +## Configuration files + +You can configure ACL Rules by passing the path to the config file to the `--acl-config=` flag. The configuration file can be either a YAML or JSON file. + +### YAML Config example + +```yaml +- Username: "user1" + Enabled: true + NoPassword: false + NoKeys: false + Passwords: + - PasswordType: "plaintext" + PasswordValue: "password1" + - PasswordType: "SHA256" + PasswordValue: "6cf615d5bcaac778352a8f1f3360d23f02f34ec182e259897fd6ce485d7870d4" + IncludedCategories: ["*"] + ExcludedCategories: [] + IncludedReadKeys: ["*"] + IncludedWriteKeys: ["*"] + IncludedPubSubChannels: ["*"] + ExcludedPubSubChannels: [] + +- Username: "user2" + Enabled: true + NoPassword: false + NoKeys: false + Passwords: + - PasswordType: "plaintext" + PasswordValue: "password4" + - PasswordType: "SHA256" + PasswordValue: "8b2c86ea9cf2ea4eb517fd1e06b74f399e7fec0fef92e3b482a6cf2e2b092023" + IncludedCategories: ["hash", "set", "sortedset", "list", "generic"] + ExcludedCategories: [] + IncludedReadKeys: ["*"] + IncludedWriteKeys: ["*"] + IncludedPubSubChannels: ["user:channel:*"] + ExcludedPubSubChannels: ["admin:channel:*"] +``` + +### JSON Config example + +```json +[ + { + "Username": "user1", + "Enabled": true, + "NoPassword": false, + "NoKeys": false, + "Passwords": [ + { + "PasswordType": "plaintext", + "PasswordValue": "password1" + }, + { + "PasswordType": "SHA256", + "PasswordValue": "6cf615d5bcaac778352a8f1f3360d23f02f34ec182e259897fd6ce485d7870d4" + } + ], + "IncludedCategories": ["*"], + "ExcludedCategories": [], + "IncludedReadKeys": ["*"], + "IncludedWriteKeys": ["*"], + "IncludedPubSubChannels": ["*"], + "ExcludedPubSubChannels": [] + }, + { + "Username": "user2", + "Enabled": true, + "NoPassword": false, + "NoKeys": false, + "Passwords": [ + { + "PasswordType": "plaintext", + "PasswordValue": "password4" + }, + { + "PasswordType": "SHA256", + "PasswordValue": "8b2c86ea9cf2ea4eb517fd1e06b74f399e7fec0fef92e3b482a6cf2e2b092023" + } + ], + "IncludedCategories": ["hash", "set", "sortedset", "list", "generic"], + "ExcludedCategories": [], + "IncludedReadKeys": ["*"], + "IncludedWriteKeys": ["*"], + "IncludedPubSubChannels": ["user:channel:*"], + "ExcludedPubSubChannels": ["admin:channel:*"] + } +] +``` + +## ACL rules + +ACL rules allow you to add new user profiles and set fine-grained rules that determine what clients can do on the server. + +The default user's rules are very permissive so if you want to restrict access, you will have to explicitly configure ACL rules. The default user can be configured too. + +### Enable and disable users + +- `on` - Enable this user. A TCP connection can authenticate as this user. +- `off` - Disable this user. It's impossible to authenticate as thsi user. + +### Allow and disallow categories + +- `+@all` - Allow this user to access all categories (aliased by `allCategories` and `+@*`). This overrides all other category access rules. +- `-@all` - Block this user from accessing any categories (aliased by `-@*`, and `nocommands`). This overrides all other category access rules. +- `+@` - Allow this user to access the specified category. If updating an existing user, then this category will be added to the list of categories they are allowed to access. +- `-@` - Block the user from accessing this specific category. If updating an existing user, then this category is removed from the list of categories the user is allowed to access. + +If both `+@all` and `-@all` are specified, whichever one is specified last will take effect. + +The `nocommands` flag will apply the `-@all` rule. + +### Allow and disallow commands + +- `+all` - Allow this user to execute all commands (aliased by `allCommands`). This overrides all other command access rules. +- `-all` - Block this user from executing any commands. This overrides all other command access rules. +- `+` - Allow the user to access the specified command. In order to allow the user to access only a specific subcommand, you can use `+|`. +- `-` - Block this user from executing any commands. In order to allow the user to access only a specific subscommand, you can user `-|`. + +If both `+all` and `-all` are specified, whichever one is specified last will take effect. + +The `nocommands` flag will apply the `-all` rule. + +### Allow and disallow access to keys + +By default, SugarDB allows each user to read and write to all keys. If you'd like to control what keys users have access to and what they can do with those keys, you can make use of the following options: + +- `%RW~*` - Allow this user to read and write all keys on the SugarDB instance (aliased by `allKeys`). +- `%RW~` - Allow this user to read and write to the specified key. This option accepts a glob pattern for the key which allows you to restrict certain key patterns. +- `%W~*` - Allow the user to write to all keys. +- `%W~` - Block the user from writing to any keys except the one specified. A glob pattern can be used in place of the key. +- `%R~*` - Allow the user to read from all the keys. +- `%R~` - Block the user from reading any keys except the one specified. A glob pattern can be used in place of the key. + +### Allow and disallow Pub/Sub channels + +- `+&*` - Allow this user to access all pub/sub channels (aliased by `allChannels`). +- `-&*` - Block this user from accessing any of the pub/sub channels. +- `+&` - Allow this user to access the specied channel. This rule accepts a glob pattern (e.g. "channel\*"). +- `-&` - Block this user from accessing the specied channel. This rule accepts a glob pattern (e.g. "channel\*"). + +If both `+&*` and `-&*` are specified, the one specified last will take effect. + +### Add and remove passwords + +By default users have no password and require no password to authenticate against them except when the `--require-pass` configuration is `true`. You can add and remove passwords associated with a user using the following options: + +- `>` - Adds the plaintext password to the list of passwords associated with the user. +- `<` - Removes the plaintext password from the list of passwords associated with the user. +- `#` - Adds the hash to the list of passwords associated with the user. The hash must be a SHA256 hash. When the user is being authenticated, they provide a plaintext passwords and the passwords will be compared with the user's plaintext passwords. If no match is found, the password's SHA256 hash is compared with the list of password hashes associated with the user. +- `!` - Removes the SHA256 hash from the list of passwords hashes associated with the user. + +### Reset the user + +You can pass certain flags to make sweeping updates to a user's ACL rules. These flags often reset the granular rules specified above. + +- `nopass` - Deletes all the user's associated passwords. Future TCP connections will not need to provide a password to authenticate against this user. +- `resetpass` - Deletes all the user's associated passwords, but does not set the `nopass` flag to true. +- `nocommands` - Blocks the user from executing any commands. +- `resetkeys` - Blocks the user from accesssing any keys for both reads and writes (aliased by `nokeys`). +- `resetchannels` - Allows the user to access all pub/sub channels. + +## Examples + +For examples on how to create and update ACL users and their rules, checkout out the `ACL SETUSER` command documentation. diff --git a/docs/docs/architecture/index.md b/docs/docs/architecture/index.md new file mode 100644 index 0000000..ac353a1 --- /dev/null +++ b/docs/docs/architecture/index.md @@ -0,0 +1,11 @@ +--- +sidebar_position: 6 +--- + +# Architecture + +SugarDB can be run in the following modes: + +- Standalone mode - Where only one instance runs in isolation. +- Replication cluster - Strongly consistent RAFT cluster. +- Sharding - To be implemented. diff --git a/docs/docs/commands/acl/acl_cat.mdx b/docs/docs/commands/acl/acl_cat.mdx new file mode 100644 index 0000000..985339e --- /dev/null +++ b/docs/docs/commands/acl/acl_cat.mdx @@ -0,0 +1,59 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ACL CAT + +### Syntax +``` +ACL CAT [category] +``` + +### Module +acl + +### Categories +slow + +### Description +Lists all the categories. If the optional category is provided, lists all the commands in the category. + +### Examples + + + + List all categories: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + categories, err := db.ACLCat() + ``` + + List all commands/subcommands in pubsub module: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + commands, err := db.ACLCat("pubsub") + ``` + + + List all categories: + ``` + > ACL CAT + ``` + + List all commands/subcommands in pubsub module: + ``` + > ACL CAT pubsub + ``` + + diff --git a/docs/docs/commands/acl/acl_deluser.mdx b/docs/docs/commands/acl/acl_deluser.mdx new file mode 100644 index 0000000..1863956 --- /dev/null +++ b/docs/docs/commands/acl/acl_deluser.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ACL DELUSER + +### Syntax +``` +ACL DELUSER username [username ...] +``` + +### Module +acl + +### Categories +admin +dangerous +slow + +### Description +Deletes users and terminates their connections. This command cannot delete the default user. + +### Examples + + + + Delete users: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.ACLDelUser("username1", "username2") + ``` + + + Delete users: + ``` + > ACL DELUSER username1 username2 + ``` + + diff --git a/docs/docs/commands/acl/acl_getuser.mdx b/docs/docs/commands/acl/acl_getuser.mdx new file mode 100644 index 0000000..fa14637 --- /dev/null +++ b/docs/docs/commands/acl/acl_getuser.mdx @@ -0,0 +1,77 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ACL GETUSER + +### Syntax +``` +ACL GETUSER username +``` + +### Module +acl + +### Categories +admin +dangerous +slow + +### Description +List the ACL rules of a user. + +### Examples + + + + Retrieve user: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + rules, err := db.ACLGetUser("username") + ``` + + Returns a map[string][]string map where each key is the rule category and each value is a string slice of relevant values. + The map returned has the following structure: + + - "username" - string slice containing the user's username. + - "flags" - string slices containing the following values: "on" if the user is enabled, otherwise "off", + - "nokeys" if the user is not allowed to access any keys (and NoKeys is true), + - "nopass" if the user has no passwords (and NoPass is true). + - "categories" - string slice af ACL command categories associated with the user. + If the user is allowed to access all categories, it will contain "+@*". + For each category the user is allowed to access, the slice will contain "+@\". + If the user is not allowed to access any categories, it will contain "-@*". + For each category the user is not allowed to access, the slice will contain "-@\". + - "commands" - string slice af commands associated with the user. + If the user is allowed to execute all commands, it will contain "+all". + For each command the user is allowed to execute, the slice will contain "+\". + If the user is not allowed to execute any commands, it will contain "-all". + For each command the user is not allowed to execute, the slice will contain "-\". + - "keys" - string slice af keys associated with the user. + If the user is allowed read/write access all keys, the slice will contain "%RW~*". + For each key glob pattern the user has read/write access to, the slice will contain "%RW~\". + If the user is allowed read access to all keys, the slice will contain "%R~*". + For each key glob pattern the user has read access to, the slice will contain "%R~\". + If the user is allowed write access to all keys, the slice will contain "%W~*". + For each key glob pattern the user has write access to, the slice will contain "%W~\". + - "channels" - string slice af pubsub channels associated with the user. + If the user is allowed to access all channels, the slice will contain "+&*". + For each channel the user is allowed to access, the slice will contain "+&\". + If the user is not allowed to access any channels, the slice will contain "-&*". + For each channel the user is not allowed to access, the slice will contain "-&\". + + + Retrieve user: + ``` + > ACL GETUSER username + ``` + + diff --git a/docs/docs/commands/acl/acl_list.mdx b/docs/docs/commands/acl/acl_list.mdx new file mode 100644 index 0000000..b10955b --- /dev/null +++ b/docs/docs/commands/acl/acl_list.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ACL LIST + +### Syntax +``` +ACL LIST +``` + +### Module +acl + +### Categories +admin +dangerous +slow + +### Description +Dumps effective acl rules in ACL DSL format. + +### Examples + + + + List ACL rules: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + rules, err := db.ACLList() + ``` + + + List ACL rules: + ``` + > ACL LIST + ``` + + diff --git a/docs/docs/commands/acl/acl_load.mdx b/docs/docs/commands/acl/acl_load.mdx new file mode 100644 index 0000000..fdf4d95 --- /dev/null +++ b/docs/docs/commands/acl/acl_load.mdx @@ -0,0 +1,58 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ACL LOAD + +### Syntax +``` +ACL LOAD +``` + +### Module +acl + +### Categories +admin +dangerous +slow + +### Description +Reloads the rules from the configured ACL config file. +When 'MERGE' is passed, users from config file who share a username with users in memory will be merged. +When 'REPLACE' is passed, users from config file who share a username with users in memory will replace the user in memory. + +### Examples + + + + Load ACL config: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + // Load config and merge with currently loaded ACL config + ok, err := db.ACLLoad(sugardb.ACLLoadOptions{Merge: true}) + + // Load config and replace currently loaded ACL config + ok, err := db.ACLLoad(sugardb.ACLLoadOptions{Replace: true})` + ``` + + + Load ACL config file and merge it with currently loaded config: + ``` + > ACL LOAD MERGE + ``` + + Load ACL config file and replace the currently loaded config: + ``` + > ACL LOAD REPLACE + ``` + + diff --git a/docs/docs/commands/acl/acl_save.mdx b/docs/docs/commands/acl/acl_save.mdx new file mode 100644 index 0000000..6d110eb --- /dev/null +++ b/docs/docs/commands/acl/acl_save.mdx @@ -0,0 +1,49 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ACL SAVE + +### Syntax +``` +ACL SAVE +``` + +### Module +acl + +### Categories +admin +dangerous +slow + +### Description +Saves the effective ACL rules the configured ACL config file. +The save command overwrites the current ACL config file entirely and using the current +in-memory ACL configuration. + +### Examples + + + + Save ACL rules: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := server.ACLSave() + ``` + + + Save ACL rules: + ``` + > ACL SAVE + ``` + + diff --git a/docs/docs/commands/acl/acl_setuser.mdx b/docs/docs/commands/acl/acl_setuser.mdx new file mode 100644 index 0000000..a1cd2ba --- /dev/null +++ b/docs/docs/commands/acl/acl_setuser.mdx @@ -0,0 +1,123 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ACL SETUSER + +### Syntax +``` +ACL SAVE +``` + +### Module +acl + +### Categories +admin +dangerous +slow + +### Description +Configure a new or existing user. + +### Examples + + + + Save user: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + user := sugardb.User{} + ok, err := server.ACLSetUser(user) + ``` + + The User struct takes the following shape: + ```go + type User struct { + // Username - string - the user's username. + Username string + + // Enabled - bool - whether the user should be enabled (i.e connections can authenticate with this user). + Enabled bool + + // NoPassword - bool - if true, this user can be authenticated against without a password. + NoPassword bool + + // NoKeys - bool - if true, this user will not be allowed to access any keys. + NoKeys bool + + // NoCommands - bool - if true, this user will not be allowed to execute any commands. + NoCommands bool + + // ResetPass - bool - if true, all the user's configured passwords are removed and NoPassword is set to false. + ResetPass bool + + // ResetKeys - bool - if true, the user's NoKeys flag is set to true and all their currently accessible keys are cleared. + ResetKeys bool + + // ResetChannels - bool - if true, the user will be allowed to access all PubSub channels. + ResetChannels bool + + // AddPlainPasswords - []string - the list of plaintext passwords to add to the user's passwords. + AddPlainPasswords []string + + // RemovePlainPasswords - []string - the list of plaintext passwords to remove from the user's passwords. + RemovePlainPasswords []string + + // AddHashPasswords - []string - the list of SHA256 password hashes to add to the user's passwords. + AddHashPasswords []string + + // RemoveHashPasswords - []string - the list of SHA256 password hashes to add to the user's passwords. + RemoveHashPasswords []string + + // IncludeCategories - []string - the list of ACL command categories to allow this user to access, default is all. + IncludeCategories []string + + // ExcludeCategories - []string - the list of ACL command categories to bar the user from accessing. The default is none. + ExcludeCategories []string + + // IncludeCommands - []string - the list of commands to allow the user to execute. The default is none. If you want to + // specify a subcommand, use the format "command|subcommand". + IncludeCommands []string + + // ExcludeCommands - []string - the list of commands to bar the user from executing. + // The default is none. If you want to specify a subcommand, use the format "command|subcommand". + ExcludeCommands []string + + // IncludeReadWriteKeys - []string - the list of keys the user is allowed read and write access to. The default is all. + // This field accepts glob pattern strings. + IncludeReadWriteKeys []string + + // IncludeReadKeys - []string - the list of keys the user is allowed read access to. The default is all. + // This field accepts glob pattern strings. + IncludeReadKeys []string + + // IncludeWriteKeys - []string - the list of keys the user is allowed write access to. The default is all. + // This field accepts glob pattern strings. + IncludeWriteKeys []string + + // IncludeChannels - []string - the list of PubSub channels the user is allowed to access ("Subscribe" and "Publish"). + // This field accepts glob pattern strings. + IncludeChannels []string + + // ExcludeChannels - []string - the list of PubSub channels the user cannot access ("Subscribe" and "Publish"). + // This field accepts glob pattern strings. + ExcludeChannels []string + } + ``` + + + Checkout the Access Control List documentation for the list of rules. + ``` + > ACL SETUSER username + ``` + + diff --git a/docs/docs/commands/acl/acl_users.mdx b/docs/docs/commands/acl/acl_users.mdx new file mode 100644 index 0000000..ac72d0c --- /dev/null +++ b/docs/docs/commands/acl/acl_users.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ACL USERS + +### Syntax +``` +ACL USERS +``` + +### Module +acl + +### Categories +admin +dangerous +slow + +### Description +Lists all usernames of the configured ACL users. + +### Examples + + + + List ACL usernames: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + users, err := db.ACLUsers() + ``` + + + List ACL usersnames: + ``` + > ACL USERS + ``` + + diff --git a/docs/docs/commands/acl/acl_whoami.mdx b/docs/docs/commands/acl/acl_whoami.mdx new file mode 100644 index 0000000..f9e2980 --- /dev/null +++ b/docs/docs/commands/acl/acl_whoami.mdx @@ -0,0 +1,38 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ACL WHOAMI + +### Syntax +``` +ACL WHOAMI +``` + +### Module +acl + +### Categories +fast + +### Description +Returns the authenticated user of the current connection. + +### Examples + + + + Not available in embedded mode. + + + Get the username of the user associated with the current connection: + ``` + > ACL WHOAMI + ``` + + diff --git a/docs/docs/commands/acl/index.md b/docs/docs/commands/acl/index.md new file mode 100644 index 0000000..8cd8bbc --- /dev/null +++ b/docs/docs/commands/acl/index.md @@ -0,0 +1 @@ +# ACL diff --git a/docs/docs/commands/admin/command_count.mdx b/docs/docs/commands/admin/command_count.mdx new file mode 100644 index 0000000..d1d26cc --- /dev/null +++ b/docs/docs/commands/admin/command_count.mdx @@ -0,0 +1,46 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# COMMAND COUNT + +### Syntax +``` +COMMAND COUNT +``` + +### Module +admin + +### Categories +admin +slow + +### Description +Get the number of commands in the SugarDB instance. + +### Examples + + + + Get server command count: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + count, err := db.CommandCount() + ``` + + + Get server command count: + ``` + > COMMAND COUNT + ``` + + diff --git a/docs/docs/commands/admin/command_list.mdx b/docs/docs/commands/admin/command_list.mdx new file mode 100644 index 0000000..954350c --- /dev/null +++ b/docs/docs/commands/admin/command_list.mdx @@ -0,0 +1,94 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# COMMAND LIST + +### Syntax +``` +COMMAND LIST [FILTERBY ] +``` + +### Module +admin + +### Categories +admin +slow + +### Description +Get the list of command names. Allows for filtering by ACL category or glob pattern. + +### Options + +FILTERBY - An optional condition used to filter the response. ACLCAT filters by the provided acl +category string. PATTERN filters the response by the provided glob pattern. +MODULE filters the response by the provided SugarDB module. + +### Examples + + + + Get a list of all the loaded commands: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + commands, err := db.CommandList(sugardb.CommandListOptions{}) + ``` + + Get a list of all commands with the \"fast\" ACL category: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + commands, err := db.CommandList(sugardb.CommandListOptions{ACLCAT: "fast"}) + ``` + + Get a list of all commands which satisfy the \"z*\" glob pattern: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + commands, err := db.CommandList(sugardb.CommandListOptions{PATTERN: "z*"}) + ``` + + Get a list of all the commands in the \"set\" module: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + commands, err := db.CommandList(sugardb.CommandListOptions{MODULE: "set"}) + ``` + + + Get a list of all the loaded commands: + ``` + > COMMAND LIST + ``` + + Get a list of all commands with the "fast" ACL category: + ``` + > COMMAND LIST FILTERBY ACLCAT fast + ``` + + Get a list of all commands which satisfy the "z*" glob pattern: + ``` + > COMMAND LIST FILTERBY PATTERN z* + ``` + + Get a list of all the commands in the "set" module: + ``` + > COMMAND LIST FILTERBY MODULE set + ``` + + diff --git a/docs/docs/commands/admin/commands.mdx b/docs/docs/commands/admin/commands.mdx new file mode 100644 index 0000000..af8f5d0 --- /dev/null +++ b/docs/docs/commands/admin/commands.mdx @@ -0,0 +1,40 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# COMMANDS + +### Syntax +``` +COMMANDS +``` + +### Module +admin + +### Categories +admin +slow + +### Description +Get a list of all the commands in available on the SugarDB instance with categories and descriptions. +Sub-commands are formatted as "command|subcommand". + +### Examples + + + + Make use of the CommandList method. + + + Get List of commands: + ``` + > COMMAND + ``` + + diff --git a/docs/docs/commands/admin/index.md b/docs/docs/commands/admin/index.md new file mode 100644 index 0000000..4d221b3 --- /dev/null +++ b/docs/docs/commands/admin/index.md @@ -0,0 +1 @@ +# Admin \ No newline at end of file diff --git a/docs/docs/commands/admin/lastsave.mdx b/docs/docs/commands/admin/lastsave.mdx new file mode 100644 index 0000000..fb93006 --- /dev/null +++ b/docs/docs/commands/admin/lastsave.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# LASTSAVE + +### Syntax +``` +LASTSAVE +``` + +### Module +admin + +### Categories +admin +dangerous +fast + +### Description +Get unix timestamp for the latest snapshot in milliseconds. + +### Examples + + + + Get last snapshot timestamp: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + count, err := db.LastSave() + ``` + + + Get last snapshot timestamp: + ``` + > LASTSAVE + ``` + + diff --git a/docs/docs/commands/admin/module_list.mdx b/docs/docs/commands/admin/module_list.mdx new file mode 100644 index 0000000..590eeb8 --- /dev/null +++ b/docs/docs/commands/admin/module_list.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# MODULE LIST + +### Syntax +``` +MODULE LIST +``` + +### Module +admin + +### Categories +admin +dangerous +fast + +### Description +List all the modules that are currently loaded in the server/instance. + +### Examples + + + + List all the modules that are currently loaded in the instance: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + modules := db.ListModules() + ``` + + + List all the modules that are currently loaded in the server: + ``` + > MODULE LIST + ``` + + diff --git a/docs/docs/commands/admin/module_load.mdx b/docs/docs/commands/admin/module_load.mdx new file mode 100644 index 0000000..6684fcd --- /dev/null +++ b/docs/docs/commands/admin/module_load.mdx @@ -0,0 +1,63 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# MODULE LOAD + +### Syntax +``` +MODULE LOAD path [arg [arg ...]] +``` + +### Module +admin + +### Categories +admin +dangerous +fast + +### Description +Load a module from a dynamic library at runtime. +The path should be the full path to the module, including the .so filename. Any args will be passed unmodified to the +module's key extraction and handler functions. + +### Examples + + + + Load a modules with no args: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + err := server.LoadModule("/path/to/module.so") + ``` + + Load a module with a few args: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + err := server.LoadModule("/path/to/module.so", "arg1", "arg2", "arg3") + ``` + + + Load a module with no args: + ``` + > MODULE LOAD path/to/module.so + ``` + + Load a module with a few args: + ``` + > MODULE LOAD path/to/module.so arg1 arg2 arg3 + ``` + + diff --git a/docs/docs/commands/admin/module_unload.mdx b/docs/docs/commands/admin/module_unload.mdx new file mode 100644 index 0000000..b176bcb --- /dev/null +++ b/docs/docs/commands/admin/module_unload.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# MODULE UNLOAD + +### Syntax +``` +MODULE UNLOAD name +``` + +### Module +admin + +### Categories +admin +dangerous +fast + +### Description +Unloads a module based on the its name as displayed by the MODULE LIST command. + +### Examples + + + + Unload a module: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + err := server.UnloadModule("module-name") + ``` + + + Unload a module: + ``` + > MODULE UNLOAD module-name + ``` + + diff --git a/docs/docs/commands/admin/rewriteaof.mdx b/docs/docs/commands/admin/rewriteaof.mdx new file mode 100644 index 0000000..b563fda --- /dev/null +++ b/docs/docs/commands/admin/rewriteaof.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# REWRITEAOF + +### Syntax +``` +REWRITEAOF +``` + +### Module +admin + +### Categories +admin +dangerous +fast + +### Description +Trigger re-writing of append process. + +### Examples + + + + Trigger re-writing of append process: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + count, err := db.RewriteAOF() + ``` + + + Trigger re-writing of append process: + ``` + > REWRITEAOF + ``` + + diff --git a/docs/docs/commands/admin/save.mdx b/docs/docs/commands/admin/save.mdx new file mode 100644 index 0000000..c470dd9 --- /dev/null +++ b/docs/docs/commands/admin/save.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SAVE + +### Syntax +``` +SAVE +``` + +### Module +admin + +### Categories +admin +dangerous +fast + +### Description +Trigger a snapshot save. + +### Examples + + + + Trigger a snapshot save: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + count, err := db.Save() + ``` + + + Trigger a snapshot save: + ``` + > SAVE + ``` + + diff --git a/docs/docs/commands/connection/auth.mdx b/docs/docs/commands/connection/auth.mdx new file mode 100644 index 0000000..50f0eb0 --- /dev/null +++ b/docs/docs/commands/connection/auth.mdx @@ -0,0 +1,46 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# AUTH + +### Syntax +``` +AUTH [username] password +``` + +### Module +connection + +### Categories +connection +slow + +### Description +Authenticates the connection. If the username is not provided, the connection will be authenticated against the +default ACL user. Otherwise, it is authenticated against the ACL user with the provided username. + +### Examples + + + + ```go + // Not available in embedded mode. + ``` + + + Authenticate against the default user: + ``` + > AUTH password + ``` + Authenticate against a specific user: + ``` + > AUTH username password + ``` + + diff --git a/docs/docs/commands/connection/echo.mdx b/docs/docs/commands/connection/echo.mdx new file mode 100644 index 0000000..de3c7e5 --- /dev/null +++ b/docs/docs/commands/connection/echo.mdx @@ -0,0 +1,42 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ECHO + +### Syntax +``` +ECHO [message] +``` + +### Module +connection + +### Categories +connection +fast + +### Description +Sends a message to the SugarDB server and it returns the same message back. + +### Examples + + + + ```go + // Not available in embedded mode. + ``` + + + Echo with message: + + ``` + > ECHO "Hello, World!" + ``` + + diff --git a/docs/docs/commands/connection/hello.mdx b/docs/docs/commands/connection/hello.mdx new file mode 100644 index 0000000..10ce772 --- /dev/null +++ b/docs/docs/commands/connection/hello.mdx @@ -0,0 +1,86 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HELLO + +### Syntax +``` +HELLO [protover [AUTH username password] [SETNAME clientname]] +``` + +### Module +connection + +### Categories +connection +fast + +### Description +Switch to a different protocol, optionally authenticating and setting the connection's name. +This command returns a contextual client report. + +### Options +- `protover` - The protocol version to switch to. The default is 2. +- `AUTH username password` - Authenticate with the server using the specified username and password. +- `SETNAME clientname` - Set the connection's name to the specified clientname. + +### Examples + + + + When using the embedded API, there's no need to authenticate the API caller or set an alias for the caller. + Therefore, only the set protocol functionality is available in embedded mode. You can set the protocol using + the SetProtocol method: + + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + err := db.SetProtocol(2) + ``` + + The method above changes the protocol to version 3. This is relevant when executing commands using the + `ExecuteCommand` method. Since this methods returns a raw RESP response. It will not affect any other methods' + return types as they return native go types. + + `SetProtocol` return an error if the protocol version is not supported (i.e. not 2 or 3). + + + Only fetch client report: + ``` + > HELLO + ``` + + Authenticate and set the connection's name: + ``` + > HELLO 2 AUTH myuser mypass SETNAME myclient + ``` + + Authenticate only: + ``` + > HELLO 2 AUTH myuser mypass + ``` + + Set the connection's name only: + ``` + > HELLO 2 SETNAME myclient + ``` + + Switch to protocol version 3: + ``` + > HELLO 3 + ``` + + Authenticate and switch to protocol version 3: + ``` + > HELLO 3 AUTH myuser mypass + ``` + + diff --git a/docs/docs/commands/connection/index.md b/docs/docs/commands/connection/index.md new file mode 100644 index 0000000..42d070c --- /dev/null +++ b/docs/docs/commands/connection/index.md @@ -0,0 +1 @@ +# Connection diff --git a/docs/docs/commands/connection/ping.mdx b/docs/docs/commands/connection/ping.mdx new file mode 100644 index 0000000..ab90ff9 --- /dev/null +++ b/docs/docs/commands/connection/ping.mdx @@ -0,0 +1,46 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# PING + +### Syntax +``` +PING [message] +``` + +### Module +connection + +### Categories +connection +fast + +### Description +Ping the SugarDB server. If a message is provided, the message will be echoed back to the client. +Otherwise, the server will return "PONG". + +### Examples + + + + ```go + // Not available in embedded mode. + ``` + + + Ping without message (returns PONG): + ``` + > PING + ``` + Ping with message (returns "Hello, world!"): + ``` + > PING "Hello, world!" + ``` + + diff --git a/docs/docs/commands/connection/select.mdx b/docs/docs/commands/connection/select.mdx new file mode 100644 index 0000000..f9b57fe --- /dev/null +++ b/docs/docs/commands/connection/select.mdx @@ -0,0 +1,50 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SELECT + +### Syntax +``` +SELECT index +``` + +### Module +connection + +### Categories +connection +fast + +### Description +Change the logical database that the current connection is operating from. +If the database does not exist, it will be created. +When this command is executed in a RAFT cluster, the database will be created in all the nodes of the cluster. + +### Examples + + + + Select the database that the embedded instance is operating from: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + err := db.SelectDB(2) + ``` + After successfully calling this method, all subsequent commands executed on that instance + will be executed on the selected database. So you should to be careful when doing this in a multi-threaded environment. + + + Select the database with index 1: + ``` + > SELECT 1 + ``` + + diff --git a/docs/docs/commands/connection/swapdb.mdx b/docs/docs/commands/connection/swapdb.mdx new file mode 100644 index 0000000..0658950 --- /dev/null +++ b/docs/docs/commands/connection/swapdb.mdx @@ -0,0 +1,52 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SWAPDB + +### Syntax +``` +SWAPDB index1 index2 +``` + +### Module +connection + +### Categories +connection +dangerous +keyspace +slow + +### Description +This command swaps two databases, +so that immediately all the clients connected to a given database will see the data of the other database, +and the other way around. If either one of the databases does not exist, it will be created. + +### Examples + + + + Swap the databases with indexes 1 and 2: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + err := db.SwapDBs(1, 2) + ``` + The method above only switches the databases for the currently active TCP connections. + To switch the database for the embedded instance, use the `SelectDB` method. + + + Swap the databases with indexes 1 and 2: + ``` + > SWAPDB 1 2 + ``` + + diff --git a/docs/docs/commands/generic/copy.mdx b/docs/docs/commands/generic/copy.mdx new file mode 100644 index 0000000..a3e50f3 --- /dev/null +++ b/docs/docs/commands/generic/copy.mdx @@ -0,0 +1,85 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# COPY + +### Syntax +``` +COPY source destination [DB destination-db] [REPLACE] +``` + +### Module +generic + +### Categories +slow +write +keyspace + +### Description +Copies the value stored at the source key to the destination key. +Returns 1 if copied and 0 if not copied. +Also returns 0 if the destination key already exists in the database and the REPLACE option is not set. + +### Options +- `DB destination-db`: the destination database to copy the key to +- `REPLACE`: replace the destination key if it already exists + +### Examples + + + + The API provides a struct called COPYOptions that wraps these options in a convenient object. + ```go + type COPYOptions struct { + Database string + Replace bool + } + ``` + + Copy the value stored at key 'hello' to the new key 'bye' + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + db.Set("hello", "world") + key = db.Copy("hello", "bye") + ``` + + Copy the value stored at key 'hello' in database 0 and replace the value at key 'bye' in database 1 + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + err := db.SelectDB(1) + ok, err := db.Set("bye", "goodbye") + err := db.SelectDB(0) + ok, err := db.Set("hello", "world") + ret, err = db.Copy("hello", "bye", db.COPYOptions{Database: "1", Replace: true}) + ``` + + + Copy the value stored at key 'hello' to the key 'bye' + ``` + > SET "hello" "world" + > COPY "hello" "bye" + ``` + + Copy the value stored at key 'hello' in database 0 and replace the value at key 'bye' in database 1 + ``` + > SELECT 1 + > SET "bye" "goodbye" + > SELECT 0 + > SET "hello" "world" + > COPY "hello" "bye" DB 1 REPLACE + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/generic/dbsize.mdx b/docs/docs/commands/generic/dbsize.mdx new file mode 100644 index 0000000..9d3eece --- /dev/null +++ b/docs/docs/commands/generic/dbsize.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# DBSIZE + +### Syntax +``` +DBSIZE +``` + +### Module +generic + +### Categories +fast +read +keyspace + +### Description +Return the number of keys in the currently-selected database. + +### Examples + + + + Get the number of keys in the database: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + key, err := db.DBSize() + ``` + + + Get the number of keys in the database: + ``` + > DBSIZE + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/generic/decr.mdx b/docs/docs/commands/generic/decr.mdx new file mode 100644 index 0000000..8db3cd2 --- /dev/null +++ b/docs/docs/commands/generic/decr.mdx @@ -0,0 +1,50 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# DECR + +### Syntax +``` +DECR key +``` + +### Module +generic + +### Categories +fast +write + +### Description +Decrements the number stored at key by one. +If the key does not exist, it is set to 0 before performing the operation. +An error is returned if the key contains a value of the wrong type or contains a string that cannot be represented as integer. +This operation is limited to 64 bit signed integers. + + +### Examples + + + + Decrement the value of the key `mykey`: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + value, err := db.Decr("mykey") + ``` + + + Decrement the value of the key `mykey`: + ``` + > DECR mykey + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/generic/decrby.mdx b/docs/docs/commands/generic/decrby.mdx new file mode 100644 index 0000000..2b2007d --- /dev/null +++ b/docs/docs/commands/generic/decrby.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# DECRBY + +### Syntax +``` +DECRBY key decrement +``` + +### Module +generic + +### Categories +fast +write + +### Description +The DECRBY command reduces the value stored at the specified key by the specified decrement. +If the key does not exist, it is initialized with a value of 0 before performing the operation. +If the key's value is not of the correct type or cannot be represented as an integer, an error is returned. + +### Examples + + + + Decrement the value of the key `mykey` by 5: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + value, err := db.DecrBy("mykey 5") + ``` + + + Decrement the value of the key `mykey` by 5: + ``` + > DECRBY mykey 5 + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/generic/del.mdx b/docs/docs/commands/generic/del.mdx new file mode 100644 index 0000000..a0627b3 --- /dev/null +++ b/docs/docs/commands/generic/del.mdx @@ -0,0 +1,61 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# DEL + +### Syntax +``` +DEL key [key ...] +``` + +### Module +generic + +### Categories +fast +keyspace +write + +### Description +Removes one or more keys from the store. + +### Examples + + + + Delete a single key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + noOfDeletedKeys, err = db.Del("key1") + ``` + + Delete multiple keys: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + noOfDeletedKeys, err = db.Del("key1", "key2", "key3") + ``` + + + Delete a single key: + ``` + > DEL key + ``` + + Delete multiple keys: + ``` + > DEL key1 key2 key3 + ``` + + diff --git a/docs/docs/commands/generic/exists.mdx b/docs/docs/commands/generic/exists.mdx new file mode 100644 index 0000000..686bbd9 --- /dev/null +++ b/docs/docs/commands/generic/exists.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# EXISTS + +### Syntax +``` +EXISTS +``` + +### Module +generic + +### Categories +fast +read +keyspace + +### Description +Returns the number of keys that exists from the provided list of keys. Note: If duplicate keys are provided, each one is counted separately. + +### Examples + + + + Return the number of keys that exists: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + key, err := db.Exists("key1") + ``` + + + Return the number of keys that exists: + ``` + > EXISTS key1 key2 + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/generic/expire.mdx b/docs/docs/commands/generic/expire.mdx new file mode 100644 index 0000000..c908809 --- /dev/null +++ b/docs/docs/commands/generic/expire.mdx @@ -0,0 +1,106 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# EXPIRE + +### Syntax +``` +EXPIRE key seconds [NX | XX | GT | LT] +``` + +### Module +generic + +### Categories +fast +keyspace +write + +### Description +Expire the key in the specified number of seconds. This commands turns a key into a volatile one. + +### Options + +- `NX` - Only set the expiry time if the key has no associated expiry. +- `XX` - Only set the expiry time if the key already has an expiry time. +- `GT` - Only set the expiry time if the new expiry time is greater than the current one. +- `LT` - Only set the expiry time if the new expiry time is less than the current one. +

+NX, GT, and LT are mutually exclusive. XX can additionally be passed in with either GT or LT. + +### Examples + + + + The embedded API utilizes the ExpireOptions interface, which acts as a wrapper for the various expiry options. +

+ ExpireOptions include the following constants: + - `NX` - Only set the expiry time if the key has no associated expiry. + - `XX` - Only set the expiry time if the key already has an expiry time. + - `GT` - Only set the expiry time if the new expiry time is greater than the current one. + - `LT` - Only set the expiry time if the new expiry time is less than the current one. +

+Add an expiration to a key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Expire("key", 10, nil) + ``` + + Add an expiration to a key only if it does not have one already: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Expire("key", 10, sugardb.NX) + ``` + + Add an expiration to a key only if it has one already: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Expire("key", 10, sugardb.XX) + ``` + + Add an expiration to a key only if it already has one that is less than the current expiry: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Expire("key", 10, sugardb.XX, sugardb.LT) + ``` +
+ + Add an expiration to a key: + ``` + > EXPIRE key 10 + ``` + + Add an expiration to a key only if it does not have one already: + ``` + > EXPIRE key 10 NX + ``` + + Add an expiration to a key only if it has one already: + ``` + > EXPIRE key 10 XX + ``` + + Add an expiration to a key only if it already has one that is less than the current expiry: + ``` + > EXPIRE key 10 XX LT + ``` + +
diff --git a/docs/docs/commands/generic/expireat.mdx b/docs/docs/commands/generic/expireat.mdx new file mode 100644 index 0000000..69a1e9d --- /dev/null +++ b/docs/docs/commands/generic/expireat.mdx @@ -0,0 +1,106 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# EXPIREAT + +### Syntax +``` +EXPIREAT key unix-time-seconds [NX | XX | GT | LT] +``` + +### Module +generic + +### Categories +fast +keyspace +write + +### Description +Expire the key at the provided unix-time. This commands turns a key into a volatile one. + +### Options + +- `NX` - Only set the expiry time if the key has no associated expiry. +- `XX` - Only set the expiry time if the key already has an expiry time. +- `GT` - Only set the expiry time if the new expiry time is greater than the current one. +- `LT` - Only set the expiry time if the new expiry time is less than the current one. +

+NX, GT, and LT are mutually exclusive. XX can additionally be passed in with either GT or LT. + +### Examples + + + + The embedded API utilizes the ExpireOptions interface, which acts as a wrapper for the various expiry options. +

+ ExpireOptions include the following constants: + - `NX` - Only set the expiry time if the key has no associated expiry. + - `XX` - Only set the expiry time if the key already has an expiry time. + - `GT` - Only set the expiry time if the new expiry time is greater than the current one. + - `LT` - Only set the expiry time if the new expiry time is less than the current one. +

+Add an expiration to a key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Expire("key", 1767160800, nil) + ``` + + Add an expiration to a key only if it does not have one already: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Expire("key", 1767160800, sugardb.NX) + ``` + + Add an expiration to a key only if it has one already: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Expire("key", 1767160800, sugardb.XX) + ``` + + Add an expiration to a key only if it already has one that is less than the current expiry: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Expire("key", 1767160800, sugardb.XX, sugardb.LT) + ``` +
+ + Add an expiration to a key: + ``` + > EXPIRE key 1767160800 + ``` + + Add an expiration to a key only if it does not have one already: + ``` + > EXPIRE key 1767160800 NX + ``` + + Add an expiration to a key only if it has one already: + ``` + > EXPIRE key 1767160800 XX + ``` + + Add an expiration to a key only if it already has one that is less than the current expiry: + ``` + > EXPIRE key 1767160800 XX LT + ``` + +
diff --git a/docs/docs/commands/generic/expiretime.mdx b/docs/docs/commands/generic/expiretime.mdx new file mode 100644 index 0000000..644f770 --- /dev/null +++ b/docs/docs/commands/generic/expiretime.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# EXPIRETIME + +### Syntax +``` +EXPIRETIME key +``` + +### Module +generic + +### categories +fast +keyspace +read + +### Description +Returns the absolute unix time in seconds when the key will expire. + +### Examples + + + + Get the expiration time of a key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + expireTime, err := db.ExpireTime("key") + ``` + + + Get the expiration time of a key: + ``` + > EXPIRETIME key + ``` + + diff --git a/docs/docs/commands/generic/flushall.mdx b/docs/docs/commands/generic/flushall.mdx new file mode 100644 index 0000000..f8ecc9e --- /dev/null +++ b/docs/docs/commands/generic/flushall.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# FLUSHALL + +### Syntax +``` +FLUSHALL +``` + +### Module +generic + +### Categories +dangerous +keyspace +slow +write + +### Description +Delete all the keys in all the existing databases. This command is always synchronous. + +### Examples + + + + In order to delete all the keys in all the databases, you need to pass -1 to the `Flush` method: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + db.Flush(-1) + ``` + + + Flush all the databases: + ``` + > FLUSHALL + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/generic/flushdb.mdx b/docs/docs/commands/generic/flushdb.mdx new file mode 100644 index 0000000..574b48f --- /dev/null +++ b/docs/docs/commands/generic/flushdb.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# FLUSHDB + +### Syntax +``` +FLUSHDB +``` + +### Module +generic + +### Categories +dangerous +keyspace +slow +write + +### Description +Delete all the keys in the currently selected database. This command is always synchronous. + +### Examples + + + + For the embedded instance, you need to pass the database index to the `Flush` method: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + db.Flush(0) + ``` + + + Flush the database that the current connection is operating from: + ``` + FLUSHDB + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/generic/get.mdx b/docs/docs/commands/generic/get.mdx new file mode 100644 index 0000000..010f03c --- /dev/null +++ b/docs/docs/commands/generic/get.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# GET + +### Syntax +``` +GET key +``` + +### Module +generic + +### Categories +fast +keyspace +read + +### Description +Get the value at the specified key. + +### Examples + + + + Get the value at the specified key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + value, err := db.Get("key") + ``` + + + Get the value at the specified key: + ``` + > GET key + ``` + + diff --git a/docs/docs/commands/generic/getdel.mdx b/docs/docs/commands/generic/getdel.mdx new file mode 100644 index 0000000..5c4b77d --- /dev/null +++ b/docs/docs/commands/generic/getdel.mdx @@ -0,0 +1,46 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# GETDEL + +### Syntax +``` +GETDEL key +``` + +### Module +generic + +### Categories +fast +write + +### Description +Get the value of key and delete the key. This command is similar to [GET], but deletes key on success. + +### Examples + + + + Get the value at the specified key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + value, err := db.GetDel("key") + ``` + + + Get the value at the specified key: + ``` + > GETDEL key + ``` + + diff --git a/docs/docs/commands/generic/getex.mdx b/docs/docs/commands/generic/getex.mdx new file mode 100644 index 0000000..0357625 --- /dev/null +++ b/docs/docs/commands/generic/getex.mdx @@ -0,0 +1,65 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# GETEX + +### Syntax +``` +GETEX key [EX | PX | EXAT | PXAT | PERSIST] +``` + +### Module +generic + +### Categories +fast +write + +### Description +Get the value of key and optionally set its expiration. GETEX is similar to [GET], but is a write command with additional options. + +### Options + - `EX` - Set the specified expire time, in seconds. + - `PX` - Set the specified expire time, in milliseconds. + - `EXAT` - Set the specified Unix time at which the key will expire, in seconds. + - `PXAT` - Set the specified Unix time at which the key will expire, in milliseconds. + - `PERSIST` - Remove the time to live associated with the key. + +### Examples + + + +The embedded API utilizes the GetExOption interface, which acts as a wrapper for the various expiry options of the GETEX command. +

+ GetExOption includes the following constants: + - `EX` - Set the specified expire time, in seconds. + - `PX` - Set the specified expire time, in milliseconds. + - `EXAT` - Set the specified Unix time at which the key will expire, in seconds. + - `PXAT` - Set the specified Unix time at which the key will expire, in milliseconds. + - `PERSIST` - Remove the time to live associated with the key. +

+ Get the value at the specified key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + value, err := db.GetEx("key", nil, 0) + + // optionally set expiry + value, err = db.GetEx("key", sugardb.EX, 10) + ``` +
+ + Get the value at the specified key and set the expiry: + ``` + > GETEX key EX 10 + ``` + +
diff --git a/docs/docs/commands/generic/incr.mdx b/docs/docs/commands/generic/incr.mdx new file mode 100644 index 0000000..40dec85 --- /dev/null +++ b/docs/docs/commands/generic/incr.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# INCR + +### Syntax +``` +INCR key +``` + +### Module +generic + +### Categories +fast +write + +### Description +Increments the number stored at key by one. If the key does not exist, it is set to 0 before performing the operation. +An error is returned if the key contains a value of the wrong type or contains a string that cannot be represented as integer. +This operation is limited to 64 bit signed integers. + +### Examples + + + + Increment the value of the key `mykey`: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + value, err := db.Incr("mykey") + ``` + + + Increment the value of the key `mykey`: + ``` + > INCR mykey + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/generic/incrby.mdx b/docs/docs/commands/generic/incrby.mdx new file mode 100644 index 0000000..d555896 --- /dev/null +++ b/docs/docs/commands/generic/incrby.mdx @@ -0,0 +1,50 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# INCRBY + +### Syntax +``` +INCRBY key increment +``` + +### Module +generic + +### Categories +fast +write + +### Description +Increments the number stored at key by increment. If the key does not exist, it is set to 0 before performing +the operation. An error is returned if the key contains a value of the wrong type or contains a string +that can not be represented as integer. + +### Options + +### Examples + + + + Increment the value of the key `mykey` by 5: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + value, err := db.IncrBy("mykey", "5") + ``` + + + Increment the value of the key `mykey` by 5: + ``` + > INCRBY mykey 5 + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/generic/incrbyfloat.mdx b/docs/docs/commands/generic/incrbyfloat.mdx new file mode 100644 index 0000000..2eaf0af --- /dev/null +++ b/docs/docs/commands/generic/incrbyfloat.mdx @@ -0,0 +1,50 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# INCRBYFLOAT + +### Syntax +``` +INCRBYFLOAT key increment +``` + +### Module +generic + +### Categories +fast +write + +### Description +Increments the floating point number stored at key by increment. If the key does not exist, it is set to 0 before performing +the operation. An error is returned if the key contains a value of the wrong type or contains a string +that can not be represented as a floating point number. + +### Options + +### Examples + + + + Increment the value of the key `mykey` by 10.33: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + value, err := db.IncrByFloat("mykey", "10.33") + ``` + + + Increment the value of the key `mykey` by 10.33: + ``` + > INCRBYFLOAT mykey 10.33 + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/generic/index.md b/docs/docs/commands/generic/index.md new file mode 100644 index 0000000..8b8c281 --- /dev/null +++ b/docs/docs/commands/generic/index.md @@ -0,0 +1 @@ +# Generic diff --git a/docs/docs/commands/generic/mget.mdx b/docs/docs/commands/generic/mget.mdx new file mode 100644 index 0000000..14e4e83 --- /dev/null +++ b/docs/docs/commands/generic/mget.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# MGET + +### Syntax +``` +MGET key [key ...] +``` + +### Module +generic + +### Categories +fast +keyspace +read + +### Description +Get multiple values from the specified keys. + +### Examples + + + + Get the values at the specified keys: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + values, err := db.MGet("key1", "key2", "key3") + ``` + + + Get the values at the specified keys: + ``` + > MGET key1 key2 key3 + ``` + + diff --git a/docs/docs/commands/generic/move.mdx b/docs/docs/commands/generic/move.mdx new file mode 100644 index 0000000..39e649d --- /dev/null +++ b/docs/docs/commands/generic/move.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# MOVE + +### Syntax +``` +MOVE key database +``` + +### Module +generic + +### Categories +fast +keyspace +write + +### Description +Move key from currently selected database to specified destination database. Returns 1 if successful, if +key already exists in the destination database, or key does not exist in the source database, it does nothing and returns 0. + +### Examples + + + + Move the key to database 1: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + value, err := db.Move("key", 1) + ``` + + + Move the key to database 1: + ``` + > MOVE key 1 + ``` + + diff --git a/docs/docs/commands/generic/mset.mdx b/docs/docs/commands/generic/mset.mdx new file mode 100644 index 0000000..cbce2db --- /dev/null +++ b/docs/docs/commands/generic/mset.mdx @@ -0,0 +1,46 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# MSET + +### Syntax +``` +MSET key value [key value ...] +``` + +### Module +generic + +### Categories +write +slow + +### Description +Set or modify multiple key/value pairs at once. + +### Examples + + + + Set multiple key/value pairs: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.MSet(map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"}) + ``` + + + Set multiple key/value pairs: + ``` + > MSET key1 value1 key2 value2 key3 value3 + ``` + + diff --git a/docs/docs/commands/generic/objectfreq.mdx b/docs/docs/commands/generic/objectfreq.mdx new file mode 100644 index 0000000..0f80e5a --- /dev/null +++ b/docs/docs/commands/generic/objectfreq.mdx @@ -0,0 +1,49 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# OBJECTFREQ + +### Syntax +``` +OBJECTFREQ keys +``` + +### Module +generic + +### Categories +keyspace +read +slow + +### Description +Get the time in seconds since the last access to the value stored at the key. +The command is only available when the maxmemory-policy configuration directive is set to one of the LRU policies. +This command returns an integer representing the access frequency. If the key doesn't exist -1 and an error is returned. + +### Examples + + + + Get a key's access frequency: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + freq, err := db.ObjectFreq("key") + ``` + + + Get the access frequency of a key: + ``` + > OBJECTFREQ key + ``` + + diff --git a/docs/docs/commands/generic/objectidletime.mdx b/docs/docs/commands/generic/objectidletime.mdx new file mode 100644 index 0000000..693b305 --- /dev/null +++ b/docs/docs/commands/generic/objectidletime.mdx @@ -0,0 +1,50 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# OBJECTIDLETIME + +### Syntax +``` +OBJECTIDLETIME key +``` + +### Module +generic + +### Categories +keyspace +read +slow + +### Description +Get the time in seconds since the last access to the value stored at the key. +The command is only available when the maxmemory-policy configuration directive is set to one of the LRU policies. +This commands returns a float representing the seconds since the key was last accessed. If the key doesn't exist -1 +and an error is returned. + +### Examples + + + + Get a key's idle time: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + idletime, err := db.ObjectIdleTime("key") + ``` + + + Get the idle time of a key: + ``` + > OBJECTIDLETIME key + ``` + + diff --git a/docs/docs/commands/generic/persist.mdx b/docs/docs/commands/generic/persist.mdx new file mode 100644 index 0000000..0d5a666 --- /dev/null +++ b/docs/docs/commands/generic/persist.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# PERSIST + +### Syntax +``` +PERSIST key +``` + +### Module +generic + +### Categories +fast +keyspace +write + +### Description +Removes the TTl associated with a key, turning it from a volatile key to a persistent key. + +### Examples + + + + Remove the TTL associated with a key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Persist("key") + ``` + + + Remove the TTL associated with a key: + ``` + > PERSIST key + ``` + + diff --git a/docs/docs/commands/generic/pexpire.mdx b/docs/docs/commands/generic/pexpire.mdx new file mode 100644 index 0000000..f7893ed --- /dev/null +++ b/docs/docs/commands/generic/pexpire.mdx @@ -0,0 +1,103 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# PEXPIRE + +### Syntax +``` +PEXPIRE key seconds [NX | XX | GT | LT] +``` + +### Module +generic + +### Categories +fast +write + +### Description +Expire the key in the specified number of milliseconds. This commands turns a key into a volatile one. + +## Options + +- `NX` - Only set the expiry time if the key has no associated expiry. +- `XX` - Only set the expiry time if the key already has an expiry time. +- `GT` - Only set the expiry time if the new expiry time is greater than the current one. +- `LT` - Only set the expiry time if the new expiry time is less than the current one. + +### Examples + + + + The embedded API utilizes the ExpireOptions interface, which acts as a wrapper for the various expiry options. +

+ ExpireOptions include the following constants: + - `NX` - Only set the expiry time if the key has no associated expiry. + - `XX` - Only set the expiry time if the key already has an expiry time. + - `GT` - Only set the expiry time if the new expiry time is greater than the current one. + - `LT` - Only set the expiry time if the new expiry time is less than the current one. +

+ Add an expiration to a key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + updated, err := db.PExpire("key", 10000, nil) + ``` + + Add an expiration to a key only if it does not have one already: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + updated, err := db.PExpire("key", 10000, db.NX) + ``` + + Add an expiration to a key only if it has one already: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + updated, err := db.PExpire("key", 10000, db.XX) + ``` + + Add an expiration to a key only if it already has one that is less than the current expiry: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + updated, err := db.PExpire("key", 10000, db.XX, db.LT) + ``` +
+ + Add an expiration to a key: + ``` + > PEXPIRE key 10000 + ``` + + Add an expiration to a key only if it does not have one already: + ``` + > PEXPIRE key 10000 NX + ``` + + Add an expiration to a key only if it has one already: + ``` + > PEXPIRE key 10000 XX + ``` + + Add an expiration to a key only if it already has one that is less than the current expiry: + ``` + > PEXPIRE key 10000 XX LT + ``` + +
diff --git a/docs/docs/commands/generic/pexpireat.mdx b/docs/docs/commands/generic/pexpireat.mdx new file mode 100644 index 0000000..312c634 --- /dev/null +++ b/docs/docs/commands/generic/pexpireat.mdx @@ -0,0 +1,106 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# PEXPIREAT + +### Syntax +``` +PEXPIREAT key unix-time-milliseconds [NX | XX | GT | LT] +``` + +### Module +generic + +### Categories +fast +keyspace +write + +### Description +Expire the key at the provided unix-time. This commands turns a key into a volatile one. + +### Options + +- `NX` - Only set the expiry time if the key has no associated expiry. +- `XX` - Only set the expiry time if the key already has an expiry time. +- `GT` - Only set the expiry time if the new expiry time is greater than the current one. +- `LT` - Only set the expiry time if the new expiry time is less than the current one. +

+NX, GT, and LT are mutually exclusive. XX can additionally be passed in with either GT or LT. + +### Examples + + + + The embedded API utilizes the ExpireOptions interface, which acts as a wrapper for the various expiry options. +

+ ExpireOptions include the following constants: + - `NX` - Only set the expiry time if the key has no associated expiry. + - `XX` - Only set the expiry time if the key already has an expiry time. + - `GT` - Only set the expiry time if the new expiry time is greater than the current one. + - `LT` - Only set the expiry time if the new expiry time is less than the current one. +

+Add an expiration to a key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Expire("key", 1767160800000, nil) + ``` + + Add an expiration to a key only if it does not have one already: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Expire("key", 1767160800000, db.NX) + ``` + + Add an expiration to a key only if it has one already: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Expire("key", 1767160800000, db.XX) + ``` + + Add an expiration to a key only if it already has one that is less than the current expiry: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Expire("key", 1767160800000, db.XX, db.LT) + ``` +
+ + Add an expiration to a key: + ``` + > EXPIRE key 1767160800000 + ``` + + Add an expiration to a key only if it does not have one already: + ``` + > EXPIRE key 1767160800000 NX + ``` + + Add an expiration to a key only if it has one already: + ``` + > EXPIRE key 1767160800000 XX + ``` + + Add an expiration to a key only if it already has one that is less than the current expiry: + ``` + > EXPIRE key 1767160800000 XX LT + ``` + +
diff --git a/docs/docs/commands/generic/pexpiretime.mdx b/docs/docs/commands/generic/pexpiretime.mdx new file mode 100644 index 0000000..c6b9bc9 --- /dev/null +++ b/docs/docs/commands/generic/pexpiretime.mdx @@ -0,0 +1,50 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# PEXPIRETIME + +### Syntax +``` +PEXPIRETIME key +``` + +### Module +generic + +### Categories +fast +keyspace +read + + +### Description +Returns the absolute unix time in milliseconds when the key will expire. +Returns -1 if the key exists but has no associated expiry time. +Returns -2 if the key does not exist. + +### Examples + + + + Retrieve the expiration time of a key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + pexpireTime, err := db.PExpireTime("key") + ``` + + + Retrieve the expiration time of a key: + ``` + > PEXPIRETIME key + ``` + + diff --git a/docs/docs/commands/generic/pttl.mdx b/docs/docs/commands/generic/pttl.mdx new file mode 100644 index 0000000..8dd4663 --- /dev/null +++ b/docs/docs/commands/generic/pttl.mdx @@ -0,0 +1,49 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# PTTL + +### Syntax +``` +PTTL key +``` + +### Module +generic + +### Categories +fast +keyspace +read + +### Description +Returns the remaining time to live for a key that has an expiry time in milliseconds. +If the key exists but does not have an associated expiry time, -1 is returned. +If the key does not exist, -2 is returned. + +### Examples + + + + Retrieve the expiration time of a key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ttl, err := db.PTTL("key") + ``` + + + Retrieve the expiration time of a key: + ``` + > PTTL key + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/generic/randomkey.mdx b/docs/docs/commands/generic/randomkey.mdx new file mode 100644 index 0000000..9779234 --- /dev/null +++ b/docs/docs/commands/generic/randomkey.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# RANDOMKEY + +### Syntax +``` +RANDOMKEY +``` + +### Module +generic + +### Categories +slow +read +keyspace + +### Description +Returns a random key from the currently selected database. If no keys are available, an empty string is returned. + +### Examples + + + + Get a random key from the database: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + key, err := db.RandomKey() + ``` + + + Get a random key from the database: + ``` + > RANDOMKEY + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/generic/rename.mdx b/docs/docs/commands/generic/rename.mdx new file mode 100644 index 0000000..168896a --- /dev/null +++ b/docs/docs/commands/generic/rename.mdx @@ -0,0 +1,46 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# RENAME + +### Syntax +``` +RENAME key newkey +``` + +### Module +generic + +### Categories +fast +write + +### Description +Renames key to newkey. If newkey already exists, it is overwritten. If key does not exist, an error is returned. + +### Examples + + + + Rename the key `mykey` to `newkey`: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + err = db.Rename("mykey", "newkey") + ``` + + + Rename the key `mykey` to `newkey`: + ``` + > RENAME mykey newkey + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/generic/renamenx.md b/docs/docs/commands/generic/renamenx.md new file mode 100644 index 0000000..942945a --- /dev/null +++ b/docs/docs/commands/generic/renamenx.md @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# RENAMENX + +### Syntax +``` +RENAMENX key newkey +``` + +### Module +generic + +### Categories +fast +keyspace +write + +### Description +Renames the specified key with the new name only if the new name does not already exist. + +### Examples + + + + Rename the key `mykey` to `newkey`: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + err = db.RenameNX("mykey", "newkey") + ``` + + + Rename the key `mykey` to `newkey`: + ``` + > RENAMENX mykey newkey + ``` + + diff --git a/docs/docs/commands/generic/set.mdx b/docs/docs/commands/generic/set.mdx new file mode 100644 index 0000000..f483bb8 --- /dev/null +++ b/docs/docs/commands/generic/set.mdx @@ -0,0 +1,121 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SET + +### Syntax +``` +SET key value [NX | XX] [GET] [EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds] +``` + +### Module +generic + +### Categories +slow +write + +### Description +Set the value of a key, considering the value's type. If the key already exists, it is overwritten. + +### Options +- `NX` - Only set if the key does not exist. +- `XX` - Only set if the key exists. +- `GET` - Return the old value stored at key, or nil if the value does not exist. +- `EX` - Expire the key after the specified number of seconds (positive integer). +- `PX` - Expire the key after the specified number of milliseconds (positive integer). +- `EXAT` - Expire at the exact time in unix seconds (positive integer). +- `PXAT` - Expire at the exat time in unix milliseconds (positive integer). + + + +### Examples + + + + The embedded API organizes the SET command options into constants wrapped in interfaces. +

+ SetWriteOption + - `SETNX` - Only set if the key does not exist. + - `SETXX` - Only set if the key exists. +

+ SetExOption + - `SETEX` - Expire the key after the specified number of seconds. + - `SETPX` - Expire the key after the specified number of milliseconds. + - `SETEXAT` - Expire at the exact time in unix seconds. + - `SETPXAT` - Expire at the exact time in unix milliseconds. +

+ The API provides a struct called SETOptions that wraps these options in a convenient object. + ```go + type SETOptions struct { + WriteOpt SetWriteOption + ExpireOpt SetExOption + ExpireTime int + Get bool + } + ``` +

+ Set a value at a key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Set("name", "SugarDB", db.SETOptions{}) + ``` + + Set a value only if the key does not exist: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Set("name", "SugarDB", db.SETOptions{WriteOpt: db.SETNX}) + ``` + + Set a value if key already exists and get the previous value: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + previousValue, err := db.Set("name", "SugarDB", db.SetOptions{WriteOpt: db.SETXX, Get: true}) + ``` + + Set a value if the key already exists, return the previous value, and expire after 10 seconds: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + previousValue, err := db.Set("name", "SugarDB", db.SetOptions{WriteOpt: db.SETXX, ExpireOpt: db.SETEX, ExpireTime 10, Get: true}) + ``` +
+ + Set a value at a key: + ``` + > SET name SugarDB + ``` + + Set a value only if the key does not exist: + ``` + > SET name SugarDB NX + ``` + + Set a value if key already exists and get the previous value: + ``` + > SET name SugarDB XX GET + ``` + + Set a value if the key already exists, return the previous value, and expire after 10 seconds: + ``` + > SET name SugarDB XX GET EX 10 + ``` + +
diff --git a/docs/docs/commands/generic/touch.mdx b/docs/docs/commands/generic/touch.mdx new file mode 100644 index 0000000..9bcf38c --- /dev/null +++ b/docs/docs/commands/generic/touch.mdx @@ -0,0 +1,62 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# TOUCH + +### Syntax +``` +TOUCH keys [key ...] +``` + +### Module +generic + +### Categories +keyspace +read +fast + +### Description +Alters the last access time or access count of the key(s) depending on whether LFU or LRU strategy was used. +A key is ignored if it does not exist. This commands returns the number of keys that were touched. + +### Examples + + + + Touch a key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + touched, err := db.Touch("key1") + ``` + + Touch multiple keys: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + touched, err := db.Touch("key1", "key2", "key3") + ``` + + + Touch a key: + ``` + > TOUCH key1 + ``` + + Touch multiple keys: + ``` + > TOUCH key1 key2 key3 + ``` + + diff --git a/docs/docs/commands/generic/ttl.mdx b/docs/docs/commands/generic/ttl.mdx new file mode 100644 index 0000000..0685ac5 --- /dev/null +++ b/docs/docs/commands/generic/ttl.mdx @@ -0,0 +1,49 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# TTL + +### Syntax +``` +TTL key +``` + +### Module +generic + +### Categories +fast +keyspace +read + +### Description +Returns the remaining time to live for a key that has an expiry time in milliseconds. +If the key exists but does not have an associated expiry time, -1 is returned. +If the key does not exist, -2 is returned. + +### Examples + + + + Retrieve the expiration time of a key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ttl, err := db.TTL("key") + ``` + + + Retrieve the expiration time of a key: + ``` + > TTL key + ``` + + diff --git a/docs/docs/commands/generic/type.mdx b/docs/docs/commands/generic/type.mdx new file mode 100644 index 0000000..783393f --- /dev/null +++ b/docs/docs/commands/generic/type.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# TYPE + +### Syntax +``` +TYPE key +``` + +### Module +generic + +### Categories +fast +keyspace +read + +### Description +Returns the string representation of the value type stored at the key. +The types that can be returned are string, integer, float, list, set, set, and hash. + +### Examples + + + + Retrieve the type of the value stored at key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + type, err := db.Type("key") + ``` + + + Retrieve the type of the value stored at key: + ``` + > TYPE key + ``` + + diff --git a/docs/docs/commands/hash/hdel.mdx b/docs/docs/commands/hash/hdel.mdx new file mode 100644 index 0000000..65f9302 --- /dev/null +++ b/docs/docs/commands/hash/hdel.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HDEL + +### Syntax +``` +HDEL key field [field ...] +``` + +### Module +hash + +### Categories +fast +hash +write + + +### Description +Deletes the specified fields from the hash. + +### Examples + + + + Delete fields from a hash: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + deletedCount, err := db.HDel("key", "field1", "field2") + ``` + + + Delete fields from a hash: + ``` + > HDEL key field1 field2 + ``` + + diff --git a/docs/docs/commands/hash/hexists.mdx b/docs/docs/commands/hash/hexists.mdx new file mode 100644 index 0000000..ce589b3 --- /dev/null +++ b/docs/docs/commands/hash/hexists.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HEXISTS + +### Syntax +``` +HEXISTS key field +``` + +### Module +hash + +### Categories +fast +hash +read + + +### Description +Returns if field is an existing field in the hash. + +### Examples + + + + Returns if field exists in a hash: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + exists, err := db.HExists ("key", "field1") + ``` + + + Returns if field exists in a hash: + ``` + > HEXISTS key field1 + ``` + + diff --git a/docs/docs/commands/hash/hexpire.mdx b/docs/docs/commands/hash/hexpire.mdx new file mode 100644 index 0000000..cdb090e --- /dev/null +++ b/docs/docs/commands/hash/hexpire.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HEXPIRE + +### Syntax +``` +HEXPIRE key seconds [NX | XX | GT | LT] FIELDS numfields field [field...] +``` + +### Module +hash + +### Categories +fast +hash +write + +### Description +Set an expiration (TTL or time to live) in seconds on one or more fields of a given hash key. +You must specify at least one field. Field(s) will automatically be deleted from the hash key when their TTLs expire. + +### Examples + + + + Set the expiration in seconds for fields in the hash: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + respArray, err := db.HExpire("key", 500, nil, field1, field2) + ``` + + + Set the expiration in seconds for fields in the hash: + ``` + > HEXPIRE key 500 FIELDS 2 field1 field2 + ``` + + diff --git a/docs/docs/commands/hash/hget.mdx b/docs/docs/commands/hash/hget.mdx new file mode 100644 index 0000000..891eae3 --- /dev/null +++ b/docs/docs/commands/hash/hget.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HGET + +### Syntax +``` +HGET key field [field ...] +``` + +### Module +hash + +### Categories +fast +hash +read + +### Description +Retrieve the value of each of the listed fields from the hash. + +### Examples + + + + Retrieve values from a hash: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + values, err := db.HGet("key", "field1", "field2", "field3") + ``` + + + Retrieve values from a hash: + ``` + > HGET key field1 field2 + ``` + + diff --git a/docs/docs/commands/hash/hgetall.mdx b/docs/docs/commands/hash/hgetall.mdx new file mode 100644 index 0000000..a179117 --- /dev/null +++ b/docs/docs/commands/hash/hgetall.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HGETALL + +### Syntax +``` +HGETALL key +``` + +### Module +hash + +### Categories +hash +read +slow + +### Description +Get all fields and values of a hash. + +### Examples + + + + Get all fields and values of a hash: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + result, err := db.HGetAll("key") + ``` + + + Get all fields and values of a hash: + ``` + > HGETALL key + ``` + + diff --git a/docs/docs/commands/hash/hincrby.mdx b/docs/docs/commands/hash/hincrby.mdx new file mode 100644 index 0000000..67f2595 --- /dev/null +++ b/docs/docs/commands/hash/hincrby.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HINCRBY + +### Syntax +``` +HINCRBY key field increment +``` + +### Module +hash + +### Categories +fast +hash +write + +### Description +Increment the hash value by the integer increment. + +### Examples + + + + Increment the hash value by the integer increment: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + newValue, err := db.HIncrBy("key", "field", 7) + ``` + + + Increment the hash value by the integer increment: + ``` + > HINCRBY key field 7 + ``` + + diff --git a/docs/docs/commands/hash/hincrbyfloat.mdx b/docs/docs/commands/hash/hincrbyfloat.mdx new file mode 100644 index 0000000..b653231 --- /dev/null +++ b/docs/docs/commands/hash/hincrbyfloat.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HINCRBYFLOAT + +### Syntax +``` +HINCRBYFLOAT key field increment +``` + +### Module +hash + +### Categories +fast +hash +write + +### Description +Increment the hash value by the float increment. + +### Examples + + + + Increment the hash value by the float increment: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + newValue, err := db.HIncrByFloat("key", "field", 7.75) + ``` + + + Increment the hash value by the float increment: + ``` + > HINCRBYFLOAT key field 7.75 + ``` + + diff --git a/docs/docs/commands/hash/hkeys.mdx b/docs/docs/commands/hash/hkeys.mdx new file mode 100644 index 0000000..8967462 --- /dev/null +++ b/docs/docs/commands/hash/hkeys.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HKEYS + +### Syntax +``` +HKEYS key +``` + +### Module +hash + +### Categories +hash +read +slow + +### Description +Returns all the fields in a hash. + +### Examples + + + + Retrieve all fields from a hash: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + keys, err := db.HKeys("key") + ``` + + + Retrieve all fields from a hash: + ``` + > HKEYS key + ``` + + diff --git a/docs/docs/commands/hash/hlen.mdx b/docs/docs/commands/hash/hlen.mdx new file mode 100644 index 0000000..c0dac4f --- /dev/null +++ b/docs/docs/commands/hash/hlen.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HLEN + +### Syntax +``` +HLEN key +``` + +### Module +hash + +### Categories +fast +hash +read + +### Description +Returns the number of fields in the hash. + +### Examples + + + + Retrieve the number of fields in the hash: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + length, err := db.HLen("key") + ``` + + + Retrieve the number of fields in the hash: + ``` + > HLEN key + ``` + + diff --git a/docs/docs/commands/hash/hmget.mdx b/docs/docs/commands/hash/hmget.mdx new file mode 100644 index 0000000..a3a2927 --- /dev/null +++ b/docs/docs/commands/hash/hmget.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HMGET + +### Syntax +``` +HMGET key field [field ...] +``` + +### Module +hash + +### Categories +fast +hash +read + +### Description +Retrieves the value of each of the listed fields from the hash. + +### Examples + + + + Retrieve values from a hash: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + values, err := db.HMGet("key", "field1", "field2", "field3") + ``` + + + Retrieve values from a hash: + ``` + > HMGET key field1 field2 + ``` + + diff --git a/docs/docs/commands/hash/hrandfield.mdx b/docs/docs/commands/hash/hrandfield.mdx new file mode 100644 index 0000000..54644a5 --- /dev/null +++ b/docs/docs/commands/hash/hrandfield.mdx @@ -0,0 +1,51 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HRANDFIELD + +### Syntax +``` +HRANDFIELD key [count [WITHVALUES]] +``` + +### Module +hash + +### Categories +hash +read +slow + +### Description +Returns one or more random fields from the hash. + +## Options +- `WITHVALUES` - When provided, the return value will contain the field and its value. + Otherwise, only the field is returned. + +### Examples + + + + Returns one or more random fields from the hash: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + fields, err := db.HRandField("key", db.HRandFieldOptions{}) + ``` + + + Returns one or more random fields from the hash: + ``` + > HRANDFIELD key + ``` + + diff --git a/docs/docs/commands/hash/hset.mdx b/docs/docs/commands/hash/hset.mdx new file mode 100644 index 0000000..1381f13 --- /dev/null +++ b/docs/docs/commands/hash/hset.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HSET + +### Syntax +``` +HSET key field value [field value ...] +``` + +### Module +hash + +### Categories +fast +hash +write + +### Description +Update each field of the hash with the corresponding value. +If the field does not exist, it is created. + +### Examples + + + + Update each field of the hash with the corresponding value: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + noOfUpdatedFields, err := db.HSet("key", map[string]string{"field1": "value1", "field2": "value2"}) + ``` + + + Update each field of the hash with the corresponding value: + ``` + > HSET key field1 value1 field2 value2 + ``` + + diff --git a/docs/docs/commands/hash/hsetnx.mdx b/docs/docs/commands/hash/hsetnx.mdx new file mode 100644 index 0000000..006bb13 --- /dev/null +++ b/docs/docs/commands/hash/hsetnx.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HSETNX + +### Syntax +``` +HSETNX key field value [field value ...] +``` + +### Module +hash + +### Categories +fast +hash +write + +### Description +Set hash field value only if the field does not exist. + +### Examples + + + + Set hash field value: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + noOfUpdatedFields, err := db.HSetNX("key", map[string]string{"field1": "value1", "field2": "value2"}) + ``` + + + Set hash field value: + ``` + > HSETNX key field1 value1 field2 value2 + ``` + + diff --git a/docs/docs/commands/hash/hstrlen.mdx b/docs/docs/commands/hash/hstrlen.mdx new file mode 100644 index 0000000..67a8cd0 --- /dev/null +++ b/docs/docs/commands/hash/hstrlen.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HSTRLEN + +### Syntax +``` +HSTRLEN key field [field ...] +``` + +### Module +hash + +### Categories +fast +hash +read + +### Description +Return the string length of the values stored at the specified fields. +Returns 0 if the value does not exist. + +### Examples + + + + Return the string length of the values stored at the specified fields: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + lengths, err := db.HStrLen("key", "field1", "field2", "field3") + ``` + + + Return the string length of the values stored at the specified fields: + ``` + > HSTRLEN key field1 field2 + ``` + + diff --git a/docs/docs/commands/hash/httl.mdx b/docs/docs/commands/hash/httl.mdx new file mode 100644 index 0000000..88b0c7f --- /dev/null +++ b/docs/docs/commands/hash/httl.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HTTL + +### Syntax +``` +HTTL key FIELDS numfields field [field...] +``` + +### Module +hash + +### Categories +fast +hash +read + +### Description +Returns the remaining TTL (time to live) of a hash key's field(s) that have a set expiration. +This introspection capability allows you to check how many seconds a given hash field will continue to be part of the hash key. + +### Examples + + + + Get the expiration time in seconds for fields in the hash: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + TTLArray, err := db.HTTL("key", field1, field2) + ``` + + + Get the expiration time in seconds for fields in the hash: + ``` + > HTTL key FIELDS 2 field1 field2 + ``` + + diff --git a/docs/docs/commands/hash/hvals.mdx b/docs/docs/commands/hash/hvals.mdx new file mode 100644 index 0000000..9361260 --- /dev/null +++ b/docs/docs/commands/hash/hvals.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# HVALS + +### Syntax +``` +HVALS key +``` + +### Module +hash + +### Categories +hash +read +slow + +### Description +Returns all the values of the hash at key. + +### Examples + + + + Returns all the values of the hash at key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + values, err := db.HVals("key") + ``` + + + Returns all the values of the hash at key: + ``` + > HVALS key + ``` + + diff --git a/docs/docs/commands/hash/index.md b/docs/docs/commands/hash/index.md new file mode 100644 index 0000000..b9705e8 --- /dev/null +++ b/docs/docs/commands/hash/index.md @@ -0,0 +1 @@ +# Hash diff --git a/docs/docs/commands/index.md b/docs/docs/commands/index.md new file mode 100644 index 0000000..89560a4 --- /dev/null +++ b/docs/docs/commands/index.md @@ -0,0 +1,5 @@ +--- +sidebar_position: 8 +--- + +# Commands diff --git a/docs/docs/commands/list/index.md b/docs/docs/commands/list/index.md new file mode 100644 index 0000000..6811f7b --- /dev/null +++ b/docs/docs/commands/list/index.md @@ -0,0 +1 @@ +# List diff --git a/docs/docs/commands/list/lindex.mdx b/docs/docs/commands/list/lindex.mdx new file mode 100644 index 0000000..473ef54 --- /dev/null +++ b/docs/docs/commands/list/lindex.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# LINDEX + +### Syntax +``` +LINDEX key index +``` + +### Module +list + +### Categories +fast +list +read + +### Description +Returns the list element at the given index. + +### Examples + + + + Returns the list element at the given index: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + element, err := db.LIndex("key", 2) + ``` + + + Returns the list element at the given index: + ``` + > LINDEX key 2 + ``` + + diff --git a/docs/docs/commands/list/llen.mdx b/docs/docs/commands/list/llen.mdx new file mode 100644 index 0000000..6da2fe3 --- /dev/null +++ b/docs/docs/commands/list/llen.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# LLEN + +### Syntax +``` +LLEN key +``` + +### Module +list + +### Categories +fast +list +read + +### Description +Returns the length of a list. + +### Examples + + + + Returns the length of a list: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + length, err := db.LLen("key") + ``` + + + Returns the length of a list: + ``` + > LLEN key + ``` + + diff --git a/docs/docs/commands/list/lmove.mdx b/docs/docs/commands/list/lmove.mdx new file mode 100644 index 0000000..3ab42ac --- /dev/null +++ b/docs/docs/commands/list/lmove.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# LMOVE + +### Syntax +``` +LMOVE source destination +``` + +### Module +list + +### Categories +list +slow +write + +### Description +Move element from one list to the other specifying left/right for both lists. +LEFT represents the start of a list. RIGHT represents the end of a list. + +### Examples + + + + Move an element from the beginning of the source list to the end of the destination list: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.LMove("source", "destination", "LEFT", "RIGHT") + ``` + + + Move an element from the beginning of the source list to the end of the destination list: + ``` + > LMOVE source destination LEFT RIGHT + ``` + + diff --git a/docs/docs/commands/list/lpop.mdx b/docs/docs/commands/list/lpop.mdx new file mode 100644 index 0000000..dab9bf5 --- /dev/null +++ b/docs/docs/commands/list/lpop.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# LPOP + +### Syntax +``` +LPOP key +``` + +### Module +list + +### Categories +list +write +fast + +### Description +Removes and returns the first element of a list. + +### Examples + + + + Removes and returns the first element of a list: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + element, err := db.LPop("key") + ``` + + + Removes and returns the first element of a list: + ``` + > LPOP key + ``` + + diff --git a/docs/docs/commands/list/lpush.mdx b/docs/docs/commands/list/lpush.mdx new file mode 100644 index 0000000..ce0f45b --- /dev/null +++ b/docs/docs/commands/list/lpush.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# LPUSH + +### Syntax +``` +LPUSH key element [element ...] +``` + +### Module +list + +### Categories +fast +list +write + +### Description +Prepends one or more values to the beginning of a list, creates the list if it does not exist. + +### Examples + + + + Prepends one or more values to the beginning of a list, creates the list if it does not exist: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + length, err := db.LPush("key", "element1", "element2") + ``` + + + Prepends one or more values to the beginning of a list, creates the list if it does not exist: + ``` + > LPUSH key element1 element2 + ``` + + diff --git a/docs/docs/commands/list/lpushx.mdx b/docs/docs/commands/list/lpushx.mdx new file mode 100644 index 0000000..1ff872d --- /dev/null +++ b/docs/docs/commands/list/lpushx.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# LPUSHX + +### Syntax +``` +LPUSHX key element [element ...] +``` + +### Module +list + +### Categories +fast +list +write + +### Description +Prepends a value to the beginning of a list only if the list exists. + +### Examples + + + + Prepends a value to the beginning of a list only if the list exists: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + length, err := db.LPushX("key", "element1", "element2") + ``` + + + Prepends a value to the beginning of a list only if the list exists: + ``` + > LPUSHX key element1 element2 + ``` + + diff --git a/docs/docs/commands/list/lrange.mdx b/docs/docs/commands/list/lrange.mdx new file mode 100644 index 0000000..4f479b2 --- /dev/null +++ b/docs/docs/commands/list/lrange.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# LRANGE + +### Syntax +``` +LRANGE key start end +``` + +### Module +list + +### Categories +list +read +slow + +### Description +Return a range of elements between the given indices. + +### Examples + + + + Return a range of elements between the given indices: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + list, err := db.LRange("key", 2, 6) + ``` + + + Return a range of elements between the given indices: + ``` + > LRANGE key 2 6 + ``` + + diff --git a/docs/docs/commands/list/lrem.mdx b/docs/docs/commands/list/lrem.mdx new file mode 100644 index 0000000..ec64799 --- /dev/null +++ b/docs/docs/commands/list/lrem.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# LREM + +### Syntax +``` +LREM key count element +``` + +### Module +list + +### Categories +list +write +slow + +### Description +Remove `` elements from list. + +### Examples + + + + Remove 2 instances if "value1" from the list at key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Lrem("key", 2, "value1") + ``` + + + Remove 2 instances if "value1" from the list at key: + ``` + > LREM key 2 value1 + ``` + + diff --git a/docs/docs/commands/list/lset.mdx b/docs/docs/commands/list/lset.mdx new file mode 100644 index 0000000..ede813f --- /dev/null +++ b/docs/docs/commands/list/lset.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# LSET + +### Syntax +``` +LSET key index element +``` + +### Module +list + +### Categories +fast +list +write + +### Description +Sets the value of an element in a list by its index. + +### Examples + + + + Sets the value of an element in a list by its index: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.LSet("key", 2, "element") + ``` + + + Sets the value of an element in a list by its index: + ``` + > LSET key 2 element + ``` + + diff --git a/docs/docs/commands/list/ltrim.mdx b/docs/docs/commands/list/ltrim.mdx new file mode 100644 index 0000000..fa5bb98 --- /dev/null +++ b/docs/docs/commands/list/ltrim.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# LTRIM + +### Syntax +``` +LTRIM key start end +``` + +### Module +list + +### Categories +list +write +slow + +### Description +Trims a list using the specified range. + +### Examples + + + + Trims a list using the specified range: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.LTrim("key", 2, 6) + ``` + + + Trims a list using the specified range: + ``` + > LTRIM key 2 6 + ``` + + diff --git a/docs/docs/commands/list/rpop.mdx b/docs/docs/commands/list/rpop.mdx new file mode 100644 index 0000000..4717d74 --- /dev/null +++ b/docs/docs/commands/list/rpop.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# RPOP + +### Syntax +``` +LPOP key +``` + +### Module +list + +### Categories +list +write +fast + +### Description +Removes and returns the last element of a list. + +### Examples + + + + Removes and returns the last element of a list: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + element, err := db.RPop("key") + ``` + + + Removes and returns the last element of a list: + ``` + > RPOP key + ``` + + diff --git a/docs/docs/commands/list/rpush.mdx b/docs/docs/commands/list/rpush.mdx new file mode 100644 index 0000000..84a7fed --- /dev/null +++ b/docs/docs/commands/list/rpush.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# RPUSH + +### Syntax +``` +RPUSH key element [element ...] +``` + +### Module +list + +### Categories +fast +list +write + +### Description +Prepends one or more values to the end of a list, creates the list if it does not exist. + +### Examples + + + + Prepends one or more values to the end of a list, creates the list if it does not exist: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + length, err := db.RPush("key", "element1", "element2") + ``` + + + Prepends one or more values to the end of a list, creates the list if it does not exist: + ``` + > RPUSH key element1 element2 + ``` + + diff --git a/docs/docs/commands/list/rpushx.mdx b/docs/docs/commands/list/rpushx.mdx new file mode 100644 index 0000000..87fefa8 --- /dev/null +++ b/docs/docs/commands/list/rpushx.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# RPUSHX + +### Syntax +``` +RPUSHX key element [element ...] +``` + +### Module +list + +### Categories +fast +list +write + +### Description +Appends a value to the end of a list only if the list exists. + +### Examples + + + + Appends a value to the end of a list only if the list exists: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + length, err := db.RPushX("key", "element1", "element2") + ``` + + + Appends a value to the end of a list only if the list exists: + ``` + > RPUSHX key element1 element2 + ``` + + diff --git a/docs/docs/commands/pubsub/index.md b/docs/docs/commands/pubsub/index.md new file mode 100644 index 0000000..4e5c5cb --- /dev/null +++ b/docs/docs/commands/pubsub/index.md @@ -0,0 +1 @@ +# PubSub diff --git a/docs/docs/commands/pubsub/psubscribe.mdx b/docs/docs/commands/pubsub/psubscribe.mdx new file mode 100644 index 0000000..5106811 --- /dev/null +++ b/docs/docs/commands/pubsub/psubscribe.mdx @@ -0,0 +1,57 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# PSUBSCRIBE + +### Syntax +``` +PSUBSCRIBE pattern [pattern ...] +``` + +### Module +pubsub + +### Categories +connection +pubsub +slow + +### Description +Subscribe to one or more patterns. This command accepts glob patterns. + +### Examples + + + + The Subscribe method returns a readMessage function. + This method is lazy so it must be invoked each time the you want to read the next message from + the pattern. + When subscribing to an'N' number of patterns, the first N messages will be + the subscription confimations. + The readMessage functions returns a message object when called. The message + object is a string slice with the following inforamtion: + event type at index 0 (e.g. subscribe, message), pattern at index 1, + message/subscription index at index 2. + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + readMessage := db.PSubscribe("subscribe_tag_1", "pattern_[12]", "pattern_h[ae]llo") // Return lazy readMessage function + for i := 0; i < 2; i++ { + message := readMessage() // Call the readMessage function for each channel subscription. + } + ``` + + + ``` + > PSUBSCRIBE pattern_[12] pattern_h[ae]llo + ``` + + diff --git a/docs/docs/commands/pubsub/publish.mdx b/docs/docs/commands/pubsub/publish.mdx new file mode 100644 index 0000000..b0943ce --- /dev/null +++ b/docs/docs/commands/pubsub/publish.mdx @@ -0,0 +1,46 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# PUBLISH + +### Syntax +``` +PUBLISH channel message +``` + +### Module +pubsub + +### Categories +pubsub +fast + +### Description +Publish a message to the specified channel. + +### Examples + + + + Publish a message to the specified channel: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.Publish("channel1", "Hello, world!") + ``` + + + Publish a message to the specified channel: + ``` + > PUBLISH channel1 "Hello, world!" + ``` + + diff --git a/docs/docs/commands/pubsub/pubsub_channels.mdx b/docs/docs/commands/pubsub/pubsub_channels.mdx new file mode 100644 index 0000000..6ced30d --- /dev/null +++ b/docs/docs/commands/pubsub/pubsub_channels.mdx @@ -0,0 +1,50 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# PUBSUB CHANNELS + +### Syntax +``` +PUBSUB CHANNELS [pattern] +``` + +### Module +pubsub + +### Categories +pubsub +slow + +### Description +Returns an array containing the list of channels that + +### Examples + + + + Returns an array containing the list of channels that + match the given pattern. If no pattern is provided, all active channels are returned. Active channels are + channels with 1 or more subscribers. + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + channels, err := db.PubSubChannels("channel*") + ``` + + + Returns an array containing the list of channels that + match the given pattern. If no pattern is provided, all active channels are returned. Active channels are + channels with 1 or more subscribers. + ``` + > PUBSUB CHANNELS channel* + ``` + + diff --git a/docs/docs/commands/pubsub/pubsub_numpat.mdx b/docs/docs/commands/pubsub/pubsub_numpat.mdx new file mode 100644 index 0000000..967b723 --- /dev/null +++ b/docs/docs/commands/pubsub/pubsub_numpat.mdx @@ -0,0 +1,46 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# PUBSUB NUMPAT + +### Syntax +``` +PUBSUB NUMPAT +``` + +### Module +pubsub + +### Categories +pubsub +slow + +### Description +Return the number of patterns that are currently subscribed to by clients. + +### Examples + + + + Return the number of patterns that are currently subscribed to by clients. + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + numOfPatterns, err := db.PubSubNumPat() + ``` + + + Return the number of patterns that are currently subscribed to by clients. + ``` + > PUBSUB NUMPAT + ``` + + diff --git a/docs/docs/commands/pubsub/pubsub_numsub.mdx b/docs/docs/commands/pubsub/pubsub_numsub.mdx new file mode 100644 index 0000000..67873d5 --- /dev/null +++ b/docs/docs/commands/pubsub/pubsub_numsub.mdx @@ -0,0 +1,49 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# PUBSUB NUMSUB + +### Syntax +``` +PUBSUB NUMSUB [channel [channel ...]] +``` + +### Module +pubsub + +### Categories +pubsub +slow + +### Description +Return an array of arrays containing the provided channel name and +how many clients are currently subscribed to the channel. + +### Examples + + + + Return an array of arrays containing the provided channel name and + how many clients are currently subscribed to the channel. + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + stats, err := server.PubSubNumSub() + ``` + + + Return an array of arrays containing the provided channel name and + how many clients are currently subscribed to the channel. + ``` + > PUBSUB NUMSUB channel1 channel2 + ``` + + diff --git a/docs/docs/commands/pubsub/punsubscribe.mdx b/docs/docs/commands/pubsub/punsubscribe.mdx new file mode 100644 index 0000000..082b921 --- /dev/null +++ b/docs/docs/commands/pubsub/punsubscribe.mdx @@ -0,0 +1,61 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# PUNSUBSCRIBE + +### Syntax +``` +PUNSUBSCRIBE [pattern [pattern ...]] +``` + +### Module +pubsub + +### Categories +pubsub +connection +slow + +### Description +Unsubscribe from a list of channels using patterns. +If the pattern list is not provided, then the connection will be unsubscribed from all the patterns that +it's currently subscribed to. + +### Examples + + + + Unsubscribe from all patterns: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + db.PUnsubscribe() + ``` + Unsubscribe from specific patterns: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + db.PUnsubscribe("pattern_[12]", "pattern_h[ae]llo") + ``` + + + Unsubscribe from all patterns: + ``` + > PUNSUBSCRIBE + ``` + Unsubscribe from specific patterns: + ``` + > PUNSUBSCRIBE pattern_[12] pattern_h[ae]llo + ``` + + diff --git a/docs/docs/commands/pubsub/subscribe.mdx b/docs/docs/commands/pubsub/subscribe.mdx new file mode 100644 index 0000000..6975f61 --- /dev/null +++ b/docs/docs/commands/pubsub/subscribe.mdx @@ -0,0 +1,57 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SUBSCRIBE + +### Syntax +``` +SUBSCRIBE channel [channel ...] +``` + +### Module +pubsub + +### Categories +pubsub +connection +slow + +### Description +Subscribe to one or more channels. + +### Examples + + + + The Subscribe method returns a readMessage function. + This method is lazy so it must be invoked each time the you want to read the next message from + the channel. + When subscribing to an'N' number of channels, the first N messages will be + the subscription confimations. + The readMessage functions returns a message object when called. The message + object is a string slice with the following inforamtion: + event type at index 0 (e.g. subscribe, message), channel name at index 1, + message/subscription index at index 2. + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + readMessage := db.Subscribe("subscribe_tag_1", "channel1", "channel2") // Return lazy readMessage function + for i := 0; i < 2; i++ { + message := readMessage() // Call the readMessage function for each channel subscription. + } + ``` + + + ``` + > SUBSCRIBE channel1 channel2 + ``` + + diff --git a/docs/docs/commands/pubsub/unsubscribe.mdx b/docs/docs/commands/pubsub/unsubscribe.mdx new file mode 100644 index 0000000..e8674d9 --- /dev/null +++ b/docs/docs/commands/pubsub/unsubscribe.mdx @@ -0,0 +1,61 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# UNSUBSCRIBE + +### Syntax +``` +UNSUBSCRIBE [channel [channel ...]] +``` + +### Module +pubsub + +### Categories +pubsub +connection +slow + +### Description +Unsubscribe from a list of channels. +If the channel list is not provided, then the connection will be unsubscribed from all the channels that +it's currently subscribed to. + +### Examples + + + + Unsubscribe from all channels: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + db.Unsubscribe() + ``` + Unsubscribe from specific channels: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + db.Unsubscribe("channel1", "channel2") + ``` + + + Unsubscribe from all channels: + ``` + > UNSUBSCRIBE + ``` + Unsubscribe from specific channels: + ``` + > UNSUBSCRIBE channel1 channel2 + ``` + + diff --git a/docs/docs/commands/set/index.md b/docs/docs/commands/set/index.md new file mode 100644 index 0000000..caca0f7 --- /dev/null +++ b/docs/docs/commands/set/index.md @@ -0,0 +1 @@ +# Set diff --git a/docs/docs/commands/set/sadd.mdx b/docs/docs/commands/set/sadd.mdx new file mode 100644 index 0000000..2ff4b1d --- /dev/null +++ b/docs/docs/commands/set/sadd.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SADD + +### Syntax +``` +SADD key member [member...] +``` + +### Module +set + +### Categories +fast +set +write + +### Description +Add one or more members to the set. If the set does not exist, it's created. + +### Examples + + + + Add members to the set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := db.SAdd("key", "member1", "member2") + ``` + + + Add members to the set: + ``` + > SADD key member1 member2 + ``` + + diff --git a/docs/docs/commands/set/scard.mdx b/docs/docs/commands/set/scard.mdx new file mode 100644 index 0000000..c78f2cc --- /dev/null +++ b/docs/docs/commands/set/scard.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SCARD + +### Syntax +``` +SCARD key +``` + +### Module +set + +### Categories +fast +set +read + +### Description +Returns the cardinality of the set. + +### Examples + + + + Get the set's cardinality: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := db.SCard("key") + ``` + + + Get the set's cardinality: + ``` + > SCARD key + ``` + + diff --git a/docs/docs/commands/set/sdiff.mdx b/docs/docs/commands/set/sdiff.mdx new file mode 100644 index 0000000..dc6df2f --- /dev/null +++ b/docs/docs/commands/set/sdiff.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SDIFF + +### Syntax +``` +SDIFF key [key...] +``` + +### Module +set + +### Categories +read +set +slow + +### Description +Returns the difference between all the sets in the given keys. + +### Examples + + + + Get the difference between 2 sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + elements, err := db.SDiff("key1", "key2") + ``` + + + Get the difference between 2 sets: + ``` + > SDIFF key1 key2 + ``` + + diff --git a/docs/docs/commands/set/sdiffstore.mdx b/docs/docs/commands/set/sdiffstore.mdx new file mode 100644 index 0000000..1d23eed --- /dev/null +++ b/docs/docs/commands/set/sdiffstore.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SDIFFSTORE + +### Syntax +``` +SDIFFSTORE destination key [key...] +``` + +### Module +set + +### Categories +set +slow +write + +### Description +Works the same as SDIFF but stores the result at 'destination'. + +### Examples + + + + Store the difference between 2 sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := db.SDiffStore("destination", "key1", "key2") + ``` + + + Store the difference between 2 sets: + ``` + > SDIFFSTORE destination key1 key2 + ``` + + diff --git a/docs/docs/commands/set/sinter.mdx b/docs/docs/commands/set/sinter.mdx new file mode 100644 index 0000000..3a6e2a9 --- /dev/null +++ b/docs/docs/commands/set/sinter.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SINTER + +### Syntax +``` +SINTER key [key...] +``` + +### Module +set + +### Categories +read +set +slow + +### Description +Returns the intersection of multiple sets. + +### Examples + + + + Get the difference between 2 sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + elements, err := db.SInter("key1", "key2") + ``` + + + Get the difference between 2 sets: + ``` + > SINTER key1 key2 + ``` + + \ No newline at end of file diff --git a/docs/docs/commands/set/sintercard.mdx b/docs/docs/commands/set/sintercard.mdx new file mode 100644 index 0000000..7078039 --- /dev/null +++ b/docs/docs/commands/set/sintercard.mdx @@ -0,0 +1,65 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SINTERCARD + +### Syntax +``` +SINTERCARD key [key...] [LIMIT limit] +``` + +### Module +set + +### Categories +read +set +slow + +### Description +Returns the cardinality of the intersection between multiple sets. + +### Options +- LIMIT - limit is an integer which determines the cardinality at which the intersection calculation +is terminated. + +### Examples + + + + Get the difference between 2 sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := db.SInterCard([]string{"key1", "key2"}, 0) + ``` + + Get the intersection only upto an intersection cardinality of 5: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := db.SInterCard([]string{"key1", "key2"}, 5) + ``` + + + Get the difference between 2 sets: + ``` + > SINTERCARD key1 key2 + ``` + + Get the intersection only upto an intersection cardinality of 5: + ``` + > SINTERCARD key1 key2 LIMIT 5 + ``` + + diff --git a/docs/docs/commands/set/sinterstore.mdx b/docs/docs/commands/set/sinterstore.mdx new file mode 100644 index 0000000..2ed3ea6 --- /dev/null +++ b/docs/docs/commands/set/sinterstore.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SINTERSTORE + +### Syntax +``` +SINTERSTORE destination key [key...] +``` + +### Module +set + +### Categories +set +slow +write + +### Description +Stores the intersection of multiple sets at the destination key. + +### Examples + + + + Get the difference between 2 sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := db.SInterStore("destination", "key1", "key2") + ``` + + + Get the difference between 2 sets: + ``` + > SINTERSTORE destination key1 key2 + ``` + + diff --git a/docs/docs/commands/set/sismember.mdx b/docs/docs/commands/set/sismember.mdx new file mode 100644 index 0000000..bd20fc0 --- /dev/null +++ b/docs/docs/commands/set/sismember.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SISMEMBER + +### Syntax +``` +SISMEMBER key member +``` + +### Module +set + +### Categories +fast +read +set + +### Description +Returns if member is contained in the set. + +### Examples + + + + Check if a member is in the set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + ok, err := db.SisMember("key", "member") + ``` + + + Check if a member is in the set: + ``` + > SISMEMBER key member + ``` + + diff --git a/docs/docs/commands/set/smembers.mdx b/docs/docs/commands/set/smembers.mdx new file mode 100644 index 0000000..2dee975 --- /dev/null +++ b/docs/docs/commands/set/smembers.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SMEMBERS + +### Syntax +``` +SMEMBERS key +``` + +### Module +set + +### Categories +read +set +slow + +### Description +Returns all members of a set. + +### Examples + + + + Get all members of a set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + elements, err := db.SMembers("key") + ``` + + + Get all members of a set: + ``` + > SMEMBERS key + ``` + + diff --git a/docs/docs/commands/set/smismember.mdx b/docs/docs/commands/set/smismember.mdx new file mode 100644 index 0000000..57d6818 --- /dev/null +++ b/docs/docs/commands/set/smismember.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SMISMEMBER + +### Syntax +``` +SMISMEMBER key member [member...] +``` + +### Module +set + +### Categories +fast +read +set + +### Description +Returns if multiple members are in the set. + +### Examples + + + + Returns if multiple members are in the set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + isMemberSlice, err := db.SMisMember("key", "member1", "member2") + ``` + + + Returns if multiple members are in the set: + ``` + > SMISMEMBER key member1 member2 + ``` + + diff --git a/docs/docs/commands/set/smove.mdx b/docs/docs/commands/set/smove.mdx new file mode 100644 index 0000000..5e7494a --- /dev/null +++ b/docs/docs/commands/set/smove.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SMOVE + +### Syntax +``` +SMOVE source destination member +``` + +### Module +set + +### Categories +fast +set +write + +### Description +Moves a member from source set to destination set. + +### Examples + + + + Move a member from source set to destination set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + moved, err := db.SMove("source", "destination", "member") + ``` + + + Move a member from source set to destination set: + ``` + > SMOVE source destination member + ``` + + diff --git a/docs/docs/commands/set/spop.mdx b/docs/docs/commands/set/spop.mdx new file mode 100644 index 0000000..fb8d8d3 --- /dev/null +++ b/docs/docs/commands/set/spop.mdx @@ -0,0 +1,61 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SPOP + +### Syntax +``` +SPOP key [count] +``` + +### Module +set + +### Categories +set +slow +write + +### Description +Returns and removes one or more random members from the set. + +### Examples + + + + Pop one element from the set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + elements, err := db.SPop("key", 1) + ``` + + Pop 5 elements from the set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + elements, err := db.SPop("key", 5) + ``` + + + Pop one element from the set: + ``` + > SPOP key + ``` + + Pop 5 elements from the set: + ``` + > SPOP key 5 + ``` + + diff --git a/docs/docs/commands/set/srandmember.mdx b/docs/docs/commands/set/srandmember.mdx new file mode 100644 index 0000000..6e73354 --- /dev/null +++ b/docs/docs/commands/set/srandmember.mdx @@ -0,0 +1,71 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SRANDMEMBER + +### Syntax +``` +SRANDMEMBER key [count] +``` + +### Module +set + +### Categories +read +set +slow + +### Description +Returns one or more random members from the set without removing them. + +### Examples + + + + Return one random element from the set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + elements, err := db.SRandMember("key", 1) + ``` + Return 5 unique random elements from the set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + elements, err := db.SRandMember("key", 5) + ``` + Return 5 random elements from the set allowing duplicates: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + elements, err := db.SRandMember("key", -5) + ``` + + + Return one random element from the set: + ``` + > SRANDMEMBER key + ``` + Return 5 unique random elements from the set: + ``` + > SRANDMEMBER key 5 + ``` + Return 5 random elements from the set allowing duplicates: + ``` + > SRANDMEMBER key -5 + ``` + + diff --git a/docs/docs/commands/set/srem.mdx b/docs/docs/commands/set/srem.mdx new file mode 100644 index 0000000..611e886 --- /dev/null +++ b/docs/docs/commands/set/srem.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SREM + +### Syntax +``` +SREM key member [member...] +``` + +### Module +set + +### Categories +fast +set +write + +### Description +Remove one or more members from a set. + +### Examples + + + + Remove members from the set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + deletedCount, err := db.SRem("key", "member1", "member2") + ``` + + + Remove members from the set: + ``` + > SREM key member1 member2 + ``` + + diff --git a/docs/docs/commands/set/sunion.mdx b/docs/docs/commands/set/sunion.mdx new file mode 100644 index 0000000..40e4924 --- /dev/null +++ b/docs/docs/commands/set/sunion.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SUNION + +### Syntax +``` +SUNION key [key...] +``` + +### Module +set + +### Categories +set +slow +read + +### Description +Returns the members of the set resulting from the union of the provided sets. + +### Examples + + + + Return the members of the set resulting from the union of the provided sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + elements, err := db.SUnion("key1", "key2") + ``` + + + Return the members of the set resulting from the union of the provided sets: + ``` + > SUNION key1 key2 + ``` + + diff --git a/docs/docs/commands/set/sunionstore.mdx b/docs/docs/commands/set/sunionstore.mdx new file mode 100644 index 0000000..fa0d499 --- /dev/null +++ b/docs/docs/commands/set/sunionstore.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SUNIONSTORE + +### Syntax +``` +SUNIONSTORE destination key [key...] +``` + +### Module +set + +### Categories +set +slow +write + +### Description +Stores the union of the given sets into destination. + +### Examples + + + + Stores the union of the given sets into destination: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := db.SUnionStore("destination", "key1", "key2") + ``` + + + + Stores the union of the given sets into destination: + ``` + > SUNIONSTORE destination key1 key2 + ``` + + diff --git a/docs/docs/commands/sorted_set/index.md b/docs/docs/commands/sorted_set/index.md new file mode 100644 index 0000000..6117b12 --- /dev/null +++ b/docs/docs/commands/sorted_set/index.md @@ -0,0 +1 @@ +# Sorted Set diff --git a/docs/docs/commands/sorted_set/zadd.mdx b/docs/docs/commands/sorted_set/zadd.mdx new file mode 100644 index 0000000..302b5f8 --- /dev/null +++ b/docs/docs/commands/sorted_set/zadd.mdx @@ -0,0 +1,95 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZADD + +### Syntax +``` +ZADD key [NX | XX] [GT | LT] [CH] [INCR] score member [score member...] +``` + +### Module +sortedset + +### Categories +fast +sortedset +write + +### Description +Adds all the specified members with the specified scores to the sorted set at the key. + +### Options +- `NX` - only adds the member if it currently does not exist in the sorted set. +- `XX` - only updates the scores of members that exist in the sorted set. +- `GT` - only updates the score if the new score is greater than the current score. +- `LT` - only updates the score if the new score is less than the current score. +- `CH` - modifies the result to return total number of members changed + added, instead of only new members added. +- `INCR` - modifies the command to act like ZINCRBY, only one score/member pair can be specified in this mode. + +### Examples + + + + Add elements to sorted set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + changedCount, err := vault.ZAdd( + "key", + map[string]float64{"member1": 2.5, "member2": 1.25, "member3": 3}, + db.ZAddOptions{}, + ) + ``` + + Add elements to sorted set, skipping members that already exist in the set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + changedCount, err := vault.ZAdd( + "key", + map[string]float64{"member1": 2.5, "member2": 1.25, "member3": 3, "member4": 4}, + db.ZAddOptions{NX: true}, + ) + ``` + + Increment the element by the specified score: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + changedCount, err := vault.ZAdd( + "key", + map[string]float64{"member1": 5.75}, + db.ZAddOptions{INCR: true}, + ) + ``` + + + Add elements to sorted set: + ``` + > ZADD key 2.5 member1 1.25 member2 3 member3 + ``` + + Add elements to sorted set, skipping members that already exist in the set: + ``` + > ZADD key NX 2.5 member1 1.25 member2 3 member3 4 member4 + ``` + + Increment the element by the specified score: + ``` + > ZADD key INCR 5.75 member1 + ``` + + diff --git a/docs/docs/commands/sorted_set/zcard.mdx b/docs/docs/commands/sorted_set/zcard.mdx new file mode 100644 index 0000000..be1a3bd --- /dev/null +++ b/docs/docs/commands/sorted_set/zcard.mdx @@ -0,0 +1,49 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZCARD + +### Syntax +``` +ZCARD key +``` + +### Module +sortedset + +### Categories +read +slow +sortedset + +### Description +Returns the set cardinality of the sorted set at key. +If the key does not exist, 0 is returned, otherwise the cardinality of the sorted set is returned. +If the key holds a value that is not a sorted set, this command will return an error. + +### Examples + + + + Get the cardinality of the sorted set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := db.ZCard("key") + ``` + + + Get the cardinality of the sorted set: + ``` + > ZCARD key + ``` + + diff --git a/docs/docs/commands/sorted_set/zcount.mdx b/docs/docs/commands/sorted_set/zcount.mdx new file mode 100644 index 0000000..83c95d8 --- /dev/null +++ b/docs/docs/commands/sorted_set/zcount.mdx @@ -0,0 +1,49 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZCOUNT + +### Syntax +``` +ZCOUNT key min max +``` + +### Module +sortedset + +### Categories +read +slow +sortedset + +### Description +Returns the number of elements in the sorted set key with scores in the range of min and max. +If the key does not exist, a count of 0 is returned, otherwise return the count. +If the key holds a value that is not a sorted set, an error is returned. + +### Examples + + + + Get the cardinality of the sorted set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + count, err := db.ZCount("key", 1.25, 10.55) + ``` + + + Get the cardinality of the sorted set: + ``` + > ZCOUNT key 1.25 10.55 + ``` + + diff --git a/docs/docs/commands/sorted_set/zdiff.mdx b/docs/docs/commands/sorted_set/zdiff.mdx new file mode 100644 index 0000000..c78cc48 --- /dev/null +++ b/docs/docs/commands/sorted_set/zdiff.mdx @@ -0,0 +1,64 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZDIFF + +### Syntax +``` +ZDIFF key [key...] [WITHSCORES] +``` + +### Module +sortedset + +### Categories +read +slow +sortedset + +### Description +Computes the difference between all the sorted sets specified in the list of keys and returns the result. + +## Options +- `WITHSCORES` - Whether the returned sorted set should include scores + +### Examples + + + + Get the difference between 2 sorted sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + sortedSet, err := db.ZDiff(false, "key1", "key2") + ``` + + Get the difference between 2 sorted sets and include the scores: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + sortedSet, err := vault.ZDiff(true, "key1", "key2") + ``` + + + Get the difference between 2 sorted sets: + ``` + > ZDIFF key1 key2 + ``` + + Get the difference between 2 sorted sets and include the scores: + ``` + > ZDIFF key1 key2 WITHSCORES + ``` + + diff --git a/docs/docs/commands/sorted_set/zdiffstore.mdx b/docs/docs/commands/sorted_set/zdiffstore.mdx new file mode 100644 index 0000000..b4a8051 --- /dev/null +++ b/docs/docs/commands/sorted_set/zdiffstore.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZDIFFSTORE + +### Syntax +``` +ZDIFFSTORE destination key1 key2 +``` + +### Module +sortedset + +### Categories +slow +sortedset +write + +### Description +Computes the difference between all the sorted sets specifies in the list of keys. Stores the result in destination. +If the base set (first key) does not exist, return 0, otherwise, return the cardinality of the diff. + +### Examples + + + + Store the difference between 2 sorted sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := vault.ZDiffStore("destination", "key1", "key2") + ``` + + + Store the difference between 2 sorted sets: + ``` + > ZDIFFSTORE destination key1 key2 + ``` + + diff --git a/docs/docs/commands/sorted_set/zincrby.mdx b/docs/docs/commands/sorted_set/zincrby.mdx new file mode 100644 index 0000000..c520528 --- /dev/null +++ b/docs/docs/commands/sorted_set/zincrby.mdx @@ -0,0 +1,51 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZINCRBY + +### Syntax +``` +ZINCRBY key increment member +``` + +### Module +sortedset + +### Categories +fast +sortedset +write + +### Description +Increments the score of the specified sorted set's member by the increment. If the member does not exist, it is created. +If the key does not exist, it is created with new sorted set and the member added with the increment as its score. + +## Options +- `WITHSCORES` - Whether the returned sorted set should include scores + +### Examples + + + + Increment the score of the sorted set's member: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := vault.ZIncrBy("key", 2.55, "member1") + ``` + + + Increment the score of the sorted set's member: + ``` + > ZINCRBY key 2.55 member1 + ``` + + diff --git a/docs/docs/commands/sorted_set/zinter.mdx b/docs/docs/commands/sorted_set/zinter.mdx new file mode 100644 index 0000000..3978a00 --- /dev/null +++ b/docs/docs/commands/sorted_set/zinter.mdx @@ -0,0 +1,73 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZINTER + +### Syntax +``` +ZINTER key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE ] [WITHSCORES] +``` + +### Module +sortedset + +### Categories +read +slow +sortedset + +### Description +Computes the intersection of the sets in the keys, with weights, aggregate and scores. + +## Options +- `WEIGHTS` - A list of floats that determine the weight of each sorted set. The scores of each member +of a sort set are multiplied by the corresponding weight. If weights are not provided, the default weight +is 1 for all sorted sets. +- `AGGREGATE` - Determines the strategy used to compare the scores of members in the intersection. +SUM will add the scores, MIN will choose the minimum score, and MAX will choose the maximum score. +- `WITHSCORES` - Determines whether scores should be included in the resulting sorted set. + + +### Examples + + + + Find the intersection between 2 sorted sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + sortedSet, err := db.ZInter([]string{"key1", "key2"}, db.ZInterOptions{}) + ``` + + Find the intersection between 2 sorted sets with a sum of the weighted scores: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + sortedSet, err := db.ZInter( + []string{"key1", "key2"}, + db.ZInterOptions{Weights: []float64{2, 4}, Aggregate: "SUM", WithScores: true}, + ) + ``` + + + Find the intersection between 2 sorted sets: + ``` + > ZINTER key1 key2 + ``` + + Find the intersection between 2 sorted sets with a sum of the weighted scores: + ``` + > ZINTER key1 key2 WEIGHTS 2 4 AGGREGATE SUM WITHSCORES + ``` + + diff --git a/docs/docs/commands/sorted_set/zinterstore.mdx b/docs/docs/commands/sorted_set/zinterstore.mdx new file mode 100644 index 0000000..60205dd --- /dev/null +++ b/docs/docs/commands/sorted_set/zinterstore.mdx @@ -0,0 +1,75 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZINTERSTORE + +### Syntax +``` +ZINTERSTORE destination key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE ] [WITHSCORES] +``` + +### Module +sortedset + +### Categories +write +slow +sortedset + +### Description +Computes the intersection of the sets in the keys, with weights, aggregate and scores. +The result is stored in destination. + +## Options +- `WEIGHTS` - A list of floats that determine the weight of each sorted set. The scores of each member +of a sort set are multiplied by the corresponding weight. If weights are not provided, the default weight +is 1 for all sorted sets. +- `AGGREGATE` - Determines the strategy used to compare the scores of members in the intersection. +SUM will add the scores, MIN will choose the minimum score, and MAX will choose the maximum score. +- `WITHSCORES` - Determines whether scores should be included in the resulting sorted set. + +### Examples + + + + Store the intersection between 2 sorted sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := vault.ZInterStore("destination", []string{"key1", "key2"}, db.ZInterStoreOptions{}) + ``` + + Store the intersection between 2 sorted sets with a sum of the weighted scores: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := vault.ZInterStore( + "destination", + []string{"key1", "key2"}, + db.ZInterStoreOptions{Weights: []float64{2, 4}, Aggregate: "SUM", WithScores: true}, + ) + ``` + + + Store the intersection between 2 sorted sets: + ``` + > ZINTERSTORE destination key1 key2 + ``` + + Store the intersection between 2 sorted sets with a sum of the weighted scores: + ``` + > ZINTERSTORE destination key1 key2 WEIGHTS 2 4 AGGREGATE SUM WITHSCORES + ``` + + + \ No newline at end of file diff --git a/docs/docs/commands/sorted_set/zlexcount.mdx b/docs/docs/commands/sorted_set/zlexcount.mdx new file mode 100644 index 0000000..0c397ef --- /dev/null +++ b/docs/docs/commands/sorted_set/zlexcount.mdx @@ -0,0 +1,49 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZLEXCOUNT + +### Syntax +``` +ZLEXCOUNT key min max +``` + +### Module +sortedset + +### Categories +read +slow +sortedset + +### Description +Returns the number of elements in within the sorted set within the +lexicographical range between min and max. Returns 0, if the keys does not exist or if all the members do not have +the same score. If the value held at key is not a sorted set, an error is returned. + +### Examples + + + + Find the intersection between 2 sorted sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + count, err := vault.ZLexCount("key", "aa", "xx") + ``` + + + Find the intersection between 2 sorted sets: + ``` + > ZLEXCOUNT key aa xx + ``` + + diff --git a/docs/docs/commands/sorted_set/zmpop.mdx b/docs/docs/commands/sorted_set/zmpop.mdx new file mode 100644 index 0000000..44c2f76 --- /dev/null +++ b/docs/docs/commands/sorted_set/zmpop.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZMPOP + +### Syntax +``` +ZMPOP key [key ...] [COUNT count] +``` + +### Module +sortedset + +### Categories +write +slow +sortedset + +### Description +Pop a 'count' elements from multiple sorted sets. +MIN or MAX determines whether to pop elements with the lowest or highest scores respectively. + +### Examples + + + + Pop a 'count' elements from multiple sorted sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + sortedSets, err := vault.ZMPop([]string{"key1", "key2"}, db.ZMPopOptions{Min: true, Count: 2}) + ``` + + + Pop a 'count' elements from multiple sorted sets: + ``` + > ZMPOP key1 key2 MIN COUNT 2 + ``` + + diff --git a/docs/docs/commands/sorted_set/zmscore.mdx b/docs/docs/commands/sorted_set/zmscore.mdx new file mode 100644 index 0000000..1df8cf0 --- /dev/null +++ b/docs/docs/commands/sorted_set/zmscore.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZMSCORE + +### Syntax +``` +ZMSCORE key member [member ...] +``` + +### Module +sortedset + +### Categories +fast +read +sortedset + +### Description +Returns the associated scores of the specified member in the sorted set. +Returns nil for members that do not exist in the set. + +### Examples + + + + Get the scores of the specified members in the sorted set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + scores, err := vault.ZMScore("key", "member1", "member2") + ``` + + + Get the scores of the specified members in the sorted set: + ``` + > ZMSCORE key member1 member2 + ``` + + diff --git a/docs/docs/commands/sorted_set/zpopmax.mdx b/docs/docs/commands/sorted_set/zpopmax.mdx new file mode 100644 index 0000000..ff0ec1d --- /dev/null +++ b/docs/docs/commands/sorted_set/zpopmax.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZPOPMAX + +### Syntax +``` +ZPOPMAX key [count] +``` + +### Module +sortedset + +### Categories +write +slow +sortedset + +### Description +Removes and returns 'count' number of members in the sorted set with the highest scores. +Default count is 1. + +### Examples + + + + Remove and return 'count' number of members in the sorted set with the highest scores: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + members, err := db.ZPopMax("key", 2) + ``` + + + Remove and return 'count' number of members in the sorted set with the highest scores: + ``` + > ZPOPMAX key 2 + ``` + + diff --git a/docs/docs/commands/sorted_set/zpopmin.mdx b/docs/docs/commands/sorted_set/zpopmin.mdx new file mode 100644 index 0000000..d799c8c --- /dev/null +++ b/docs/docs/commands/sorted_set/zpopmin.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZPOPMIN + +### Syntax +``` +ZPOPMIN key [count] +``` + +### Module +sortedset + +### Categories +write +slow +sortedset + +### Description +Removes and returns 'count' number of members in the sorted set with the lowest scores. +Default count is 1. + +### Examples + + + + Remove and return 'count' number of members in the sorted set with the lowest scores: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + members, err := db.ZPopMin("key", 2) + ``` + + + Remove and return 'count' number of members in the sorted set with the lowest scores: + ``` + > ZPOPMIN key 2 + ``` + + diff --git a/docs/docs/commands/sorted_set/zrandmember.mdx b/docs/docs/commands/sorted_set/zrandmember.mdx new file mode 100644 index 0000000..8224774 --- /dev/null +++ b/docs/docs/commands/sorted_set/zrandmember.mdx @@ -0,0 +1,77 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZRANDMEMBER + +### Syntax +``` +ZRANDMEMBER key [count [WITHSCORES]] +``` + +### Module +sortedset + +### Categories +read +slow +sortedset + +### Description +Return a list of length equivalent to count containing random members of the sorted set. +If count is negative, repeated elements are allowed. If count is positive, the returned elements will be distinct. +WITHSCORES modifies the result to include scores in the result. + +### Examples + + + + Get a random member from the sorted set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + members, err := vault.ZRandMember("key", 1, false) + ``` + + Get 2 unique random members from the sorted set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + members, err := vault.ZRandMember("key", 2, false) + ``` + + Get 4 non-unique random members from the sorted set with scores: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + members, err := vault.ZRandMember("key", -4, true) + ``` + + + Get a random member from the sorted set: + ``` + > ZRANDMEMBER key + ``` + + Get 2 unique random members from the sorted set: + ``` + > ZRANDMEMBER key 2 + ``` + + Get 4 non-unique random members from the sorted set with scores: + ``` + > ZRANDMEMBER key -4 WITHSCORES + ``` + + diff --git a/docs/docs/commands/sorted_set/zrange.mdx b/docs/docs/commands/sorted_set/zrange.mdx new file mode 100644 index 0000000..c5ce859 --- /dev/null +++ b/docs/docs/commands/sorted_set/zrange.mdx @@ -0,0 +1,62 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZRANGE + +### Syntax +``` +ZRANGE key start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count] [WITHSCORES] +``` + +### Module +sortedset + +### Categories +read +slow +sortedset + +### Description +Returns the range of elements in the sorted set. + +### Options +- `BYSCORE` - Sorts the elements in accending order of score before calculating the range. +- `BYLEX` - Sorts the elements in ascending lexicographical order before calcularing the range. + This option only works if all the members have the same score. +- `REV` - Reverse the order determined by BYSCORE or BYLEX. +- `LIMIT` - Offset determines where SugarDB will start counting from after the previous modification. + Count is the number of elements to extract after the offset. +- `WITHSCORES` - Whether the result should include scores. + +### Examples + + + + Get range by score: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + sortedSet, err := vault.ZRange("key", "11.55", "15.66", db.ZRangeOptions{ + ByScore: true, + Rev: true, + WithScores: true, + Offset: 0, + Count: 2, + }) + ``` + + + Get range by score: + ``` + > ZRANGE key 11.55 15.66 BYSCORE REV LIMIT 0 2 WITHSCORES + ``` + + diff --git a/docs/docs/commands/sorted_set/zrangestore.mdx b/docs/docs/commands/sorted_set/zrangestore.mdx new file mode 100644 index 0000000..4b7853f --- /dev/null +++ b/docs/docs/commands/sorted_set/zrangestore.mdx @@ -0,0 +1,64 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZRANGESTORE + +### Syntax +``` +ZRANGESTORE destination source start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count] [WITHSCORES] +``` + +### Module +sortedset + +### Categories +write +slow +sortedset + +### Description +Retrieve the range of elements in the sorted set and store it in destination. + +### Options +- `BYSCORE` - Sorts the elements in accending order of score before calculating the range. +- `BYLEX` - Sorts the elements in ascending lexicographical order before calcularing the range. + This option only works if all the members have the same score. +- `REV` - Reverse the order determined by BYSCORE or BYLEX. +- `LIMIT` - Offset determines where SugarDB will start counting from after the previous modification. + Count is the number of elements to extract after the offset. +- `WITHSCORES` - Whether the result should include scores. + +### Examples + + + + Get range by score and store it at the destination key: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := db.ZRangeStore( + "destination", "source", "11.55", "15.66", + db.ZRangeStoreOptions{ + ByScore: true, + Rev: true, + WithScores: true, + Offset: 0, + Count: 2, + }) + ``` + + + Get range by score and store it at the destination key: + ``` + > ZRANGESTORE key 11.55 15.66 BYSCORE REV LIMIT 0 2 WITHSCORES + ``` + + diff --git a/docs/docs/commands/sorted_set/zrank.mdx b/docs/docs/commands/sorted_set/zrank.mdx new file mode 100644 index 0000000..75fc8ad --- /dev/null +++ b/docs/docs/commands/sorted_set/zrank.mdx @@ -0,0 +1,51 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZRANK + +### Syntax +``` +ZRANK key member [WITHSCORE] +``` + +### Module +sortedset + +### Categories +read +slow +sortedset + +### Description +Returns the rank of the specified member in the sorted set. +WITHSCORE modifies the result to also return the score. + +## Options +- WITHSCORE - Determines whether to return the score along with the member value. + +### Examples + + + + Get the rank of the specified member in the sorted set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + member, err := db.ZRank("key", "member", true) + ``` + + + Get the rank of the specified member in the sorted set: + ``` + > ZRANK key member WITHSCORE + ``` + + diff --git a/docs/docs/commands/sorted_set/zrem.mdx b/docs/docs/commands/sorted_set/zrem.mdx new file mode 100644 index 0000000..45fc0b2 --- /dev/null +++ b/docs/docs/commands/sorted_set/zrem.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZREM + +### Syntax +``` +ZREM key member [member ...] +``` + +### Module +sortedset + +### Categories +write +fast +sortedset + +### Description +Removes the listed members from the sorted set. Returns the number of elements removed. + +### Examples + + + + Remove the listed members from the sorted set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + deletedCount, err := db.ZRem("key", "member1", "member2") + ``` + + + Remove the listed members from the sorted set: + ``` + > ZREM key member1 member2 + ``` + + diff --git a/docs/docs/commands/sorted_set/zremrangebylex.mdx b/docs/docs/commands/sorted_set/zremrangebylex.mdx new file mode 100644 index 0000000..5459bd4 --- /dev/null +++ b/docs/docs/commands/sorted_set/zremrangebylex.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZREMRANGEBYLEX + +### Syntax +``` +ZREMRANGEBYLEX key min max +``` + +### Module +sortedset + +### Categories +write +slow +sortedset + +### Description +Removes the elements in the lexicographical range between min and max. + +### Examples + + + + Remove the elements in the lexicographical range between min and max: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + deletedCount, err := vault.ZRemRangeByLex("key", "aa", "xx") + ``` + + + Remove the elements in the lexicographical range between min and max: + ``` + > ZREMRANGEBYLEX key aa xx + ``` + + diff --git a/docs/docs/commands/sorted_set/zremrangebyrank.mdx b/docs/docs/commands/sorted_set/zremrangebyrank.mdx new file mode 100644 index 0000000..3cba6be --- /dev/null +++ b/docs/docs/commands/sorted_set/zremrangebyrank.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZREMRANGEBYRANK + +### Syntax +``` +ZREMRANGEBYRANK key min max +``` + +### Module +sortedset + +### Categories +write +slow +sortedset + +### Description +Removes the elements in the rank range between start and stop. +The elements are ordered from lowest score to highest score. + +### Examples + + + + Remove the elements in the rank range between start and stop: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + deletedCount, err := vault.ZRemRangeByRank("key", 3, 7) + ``` + + + Remove the elements in the rank range between start and stop: + ``` + > ZREMRANGEBYRANK key 3 7 + ``` + + diff --git a/docs/docs/commands/sorted_set/zremrangebyscore.mdx b/docs/docs/commands/sorted_set/zremrangebyscore.mdx new file mode 100644 index 0000000..1481dc0 --- /dev/null +++ b/docs/docs/commands/sorted_set/zremrangebyscore.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZREMRANGEBYSCORE + +### Syntax +``` +ZREMRANGEBYSCORE key min max +``` + +### Module +sortedset + +### Categories +write +slow +sortedset + +### Description +Removes the elements whose scores are in the range between min and max. + +### Examples + + + + Remove the elements whose scores are in the range between min and max: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + deletedCount, err := vault.ZRemRangeByScore("key", 3.55, 12.75) + ``` + + + Remove the elements whose scores are in the range between min and max: + ``` + > ZREMRANGEBYSCORE key 3.55 12.75 + ``` + + diff --git a/docs/docs/commands/sorted_set/zrevrank.mdx b/docs/docs/commands/sorted_set/zrevrank.mdx new file mode 100644 index 0000000..003939d --- /dev/null +++ b/docs/docs/commands/sorted_set/zrevrank.mdx @@ -0,0 +1,51 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZREVRANK + +### Syntax +``` +ZREVRANK key member [WITHSCORE] +``` + +### Module +sortedset + +### Categories +read +slow +sortedset + +### Description +Returns the rank of the member in the sorted set in reverse order. +WITHSCORE modifies the result to include the score. + +## Options +- WITHSCORE - Determines whether to return the score along with the member value. + +### Examples + + + + Get the rank of the specified member in the sorted set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + member, err := vault.ZRevRank("key", "member", true) + ``` + + + Get the rank of the specified member in the sorted set: + ``` + > ZREVRANK key member WITHSCORE + ``` + + diff --git a/docs/docs/commands/sorted_set/zscore.mdx b/docs/docs/commands/sorted_set/zscore.mdx new file mode 100644 index 0000000..fd17695 --- /dev/null +++ b/docs/docs/commands/sorted_set/zscore.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZSCORE + +### Syntax +``` +ZSCORE key member +``` + +### Module +sortedset + +### Categories +read +fast +sortedset + +### Description +Returns the score of the member in the sorted set. + +### Examples + + + + Returns the score of the member in the sorted set: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + score, err := vault.ZScore("key", "member") + ``` + + + Returns the score of the member in the sorted set: + ``` + > ZSCORE key member + ``` + + diff --git a/docs/docs/commands/sorted_set/zunion.mdx b/docs/docs/commands/sorted_set/zunion.mdx new file mode 100644 index 0000000..ebd3055 --- /dev/null +++ b/docs/docs/commands/sorted_set/zunion.mdx @@ -0,0 +1,74 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZUNION + +### Syntax +``` +ZUNION key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE ] [WITHSCORES] +``` + +### Module +sortedset + +### Categories +read +slow +sortedset + +### Description +Return the union of the sorted sets in keys. The scores of each member of +a sorted set are multiplied by the corresponding weight in WEIGHTS. Aggregate determines how the scores are combined. +WITHSCORES option determines whether to return the result with scores included. + +### Options +- `WEIGHTS` - A list of floats that determine the weight of each sorted set. The scores of each member +of a sort set are multiplied by the corresponding weight. If weights are not provided, the default weight +is 1 for all sorted sets. +- `AGGREGATE` - Determines the strategy used to compare the scores of members in the union. + SUM will add the scores, MIN will choose the minimum score, and MAX will choose the maximum score. +- `WITHSCORES` - Determines whether scores should be included in the resulting sorted set. + +### Examples + + + + Find the union between 2 sorted sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + sortedSet, err := db.ZUnion([]string{"key1", "key2"}, db.ZUnionOptions{}) + ``` + + Find the union between 2 sorted sets with a sum of the weighted scores: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + sortedSet, err := db.ZUnion( + []string{"key1", "key2"}, + db.ZUnionOptions{Weights: []float64{2, 4}, Aggregate: "SUM", WithScores: true}, + ) + ``` + + + Find the union between 2 sorted sets: + ``` + > ZUNION key1 key2 + ``` + + Find the union between 2 sorted sets with a sum of the weighted scores: + ``` + > ZUNION key1 key2 WEIGHTS 2 4 AGGREGATE SUM WITHSCORES + ``` + + diff --git a/docs/docs/commands/sorted_set/zunionstore.mdx b/docs/docs/commands/sorted_set/zunionstore.mdx new file mode 100644 index 0000000..7d09e23 --- /dev/null +++ b/docs/docs/commands/sorted_set/zunionstore.mdx @@ -0,0 +1,75 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# ZUNIONSTORE + +### Syntax +``` +ZUNIONSTORE destination key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE ] [WITHSCORES] +``` + +### Module +sortedset + +### Categories +write +slow +sortedset + +### Description +Return the union of the sorted sets in keys. The scores of each member of +a sorted set are multiplied by the corresponding weight in WEIGHTS. Aggregate determines how the scores are combined. +The resulting union is stored at the destination key. + +### Options +- `WEIGHTS` - A list of floats that determine the weight of each sorted set. The scores of each member +of a sort set are multiplied by the corresponding weight. If weights are not provided, the default weight +is 1 for all sorted sets. +- `AGGREGATE` - Determines the strategy used to compare the scores of members in the union. + SUM will add the scores, MIN will choose the minimum score, and MAX will choose the maximum score. +- `WITHSCORES` - Determines whether scores should be included in the resulting sorted set. + +### Examples + + + + Store the union between 2 sorted sets: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := vault.ZUnionStore("destination", []string{"key1", "key2"}, db.ZUnionStoreOptions{}) + ``` + + Store the union between 2 sorted sets with a sum of the weighted scores: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + cardinality, err := vault.ZUnionStore( + "destination", + []string{"key1", "key2"}, + db.ZUnionStoreOptions{Weights: []float64{2, 4}, Aggregate: "SUM", WithScores: true}, + ) + ``` + + + Store the union between 2 sorted sets: + ``` + > ZUNIONSTORE destination key1 key2 + ``` + + Store the union between 2 sorted sets with a sum of the weighted scores: + ``` + > ZUNIONSTORE destination key1 key2 WEIGHTS 2 4 AGGREGATE SUM WITHSCORES + ``` + + diff --git a/docs/docs/commands/string/append.mdx b/docs/docs/commands/string/append.mdx new file mode 100644 index 0000000..78e167c --- /dev/null +++ b/docs/docs/commands/string/append.mdx @@ -0,0 +1,48 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# APPEND + +### Syntax +``` +APPEND key value +``` + +### Module +string + +### Categories +write +fast +string + +### Description +Appends a value to the end of a string. If the doesn't exist, it creates the key with the value (acts as a SET). +Returns the length of the string after the append operation. + +### Examples + + + + Append a value to the end of a string: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + len, err := db.Append("key", "value") + ``` + + + Append a value to the end of a string: + ``` + > APPEND "key" "value" + ``` + + diff --git a/docs/docs/commands/string/getrange.mdx b/docs/docs/commands/string/getrange.mdx new file mode 100644 index 0000000..91efc2a --- /dev/null +++ b/docs/docs/commands/string/getrange.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# GETRANGE + +### Syntax +``` +GETRANGE key start end +``` + +### Module +string + +### Categories +read +slow +string + +### Description +Return the substring of the string value stored at key. The substring is specified by the start and end indexes. + +### Examples + + + + Get the substring of a string value: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + substring, err := db.GetRange("key", 4, 10) + ``` + + + Get the substring of a string value: + ``` + > GETRANGE key 4 10 + ``` + + diff --git a/docs/docs/commands/string/index.md b/docs/docs/commands/string/index.md new file mode 100644 index 0000000..2c0923c --- /dev/null +++ b/docs/docs/commands/string/index.md @@ -0,0 +1 @@ +# String diff --git a/docs/docs/commands/string/setrange.mdx b/docs/docs/commands/string/setrange.mdx new file mode 100644 index 0000000..a2b987b --- /dev/null +++ b/docs/docs/commands/string/setrange.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SETRANGE + +### Syntax +``` +SETRANGE key offset value +``` + +### Module +string + +### Categories +write +slow +string + +### Description +Overwrites part of a string value with another by offset. Creates the key if it doesn't exist. + +### Examples + + + + Overwrite part of a string value: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + length, err := db.SetRange("key", 2, "replacement string") + ``` + + + Overwrite part of a string value: + ``` + > SETRANGE key 2 "replacement string" + ``` + + diff --git a/docs/docs/commands/string/strlen.mdx b/docs/docs/commands/string/strlen.mdx new file mode 100644 index 0000000..83401af --- /dev/null +++ b/docs/docs/commands/string/strlen.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# STRLEN + +### Syntax +``` +STRLEN key +``` + +### Module +string + +### Categories +fast +read +string + +### Description +Returns length of the key's value if it's a string. + +### Examples + + + + Get the substring of a string value: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + length, err := db.StrLen("key") + ``` + + + Get the substring of a string value: + ``` + > STRLEN key + ``` + + diff --git a/docs/docs/commands/string/substr.mdx b/docs/docs/commands/string/substr.mdx new file mode 100644 index 0000000..5e0bf22 --- /dev/null +++ b/docs/docs/commands/string/substr.mdx @@ -0,0 +1,47 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# SUBSTR + +### Syntax +``` +SUBSTR key start end +``` + +### Module +string + +### Categories +read +slow +string + +### Description +Return the substring of the string value stored at key. The substring is specified by the start and end indexes. + +### Examples + + + + Get the substring of a string value: + ```go + db, err := sugardb.NewSugarDB() + if err != nil { + log.Fatal(err) + } + substring, err := db.GetRange("key", 4, 10) + ``` + + + Get the substring of a string value: + ``` + > GETRANGE key 4 10 + ``` + + diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md new file mode 100644 index 0000000..7768aaf --- /dev/null +++ b/docs/docs/configuration.md @@ -0,0 +1,127 @@ +--- +sidebar_position: 2 +--- + +# Configuration + +SugarDB is highly configurable. It provides the following configuration options to you: + +Flag: `--config`
+Type: `string/path`
+Description: The file path for the server configuration. A JSON or YAML file can be used for server configuration. You can combine CLI flags and config files, but remember that config files override CLI flags. The config file will be prioritised if you have the same config option in the CLI flags and the config file. + +Flag: `--port`
+Type: `integer`
+Description: The port on which to listen to client connections. The default is `7480`. + +Flag: `--bind-addr`
+Type: `string`
+Description: Specify the IP address to which the listener is bound. + +Flag: `--require-pass`
+Type: `boolean`
+Description: Determines whether the server should require a password for the default user before allowing commands. The default is `false`. If this option is provided, it must be accompanied by the `--password` config. + +Flag: `--password`
+Type: `string`
+Description: The password used to authorize the default user to run commands. This flag should be provided alongside the `--require-pass` flag. + +Flag: `--tls`
+Type: `boolean`
+Description: A TLS connection with a client is required. The default is `false`. + +Flag: `mtls`
+Type: `boolean`
+Description: Require mTLS connection with client. It is useful when the client and the server need to verify each other. If `--tls` and `mtls` are provided, `--mtls` will take higher priority. The default is `false`. + +Flag: `--cert-key-pair`
+Type: `string`
+Description: The cert/key pair used by the server to authenticate itself to the client when using TLS or mTLS. This flag can be provided multiple times with multiple cert/key pairs. This is a comma-separated string in the following format: `,`, + +Flag: `--client-ca`
+Type: `string`
+Description: The path to the RootCA that is used to verify client certs when the `--mtls` flag is provided to enable verifying the client. This flag can be passed multiple times with paths to several client RootCAs. + +Flag: `--server-id`
+Type: `string`
+Description: If this node is part of a raft replication cluster, then this flag provides the server ID to use within the cluster configuration. This ID must be unique to all the other nodes' IDs in the cluster. + +Flag: `--join-addr`
+Type: `string`
+Description: When adding a node to a replication cluster, this is the address and port of any cluster member. The current node will use this to request permission to join the cluster. The format of this flag is `/:`. + +Flag: `--discovery-port`
+Type: `integer`
+Description. If starting a node in a replication cluster, this port is used for communication between nodes on the memberlist layer. The default is `7946`. + +Flag: `--in-memory`
+Type: `boolean`
+Description: When starting a node in a raft replication cluster, this directs the raft layer to store logs and snapshots in memory. It is only recommended in test mode. The default is `false`. + +Flag: `--data-dir`
+Type: `string`
+Description: The directory for storing Append-Only Logs, Write Ahead Logs, and Snapshots. The default is `/var/lib/` + +Flag: `--bootstrap-cluster`
+Type: `boolean`
+Description: Whether to initialize a new replication cluster with this node as the leader. The default is `false`. + +Flag: `--acl-config`
+Type: `string`
+Description: The file path for the ACL layer config file. The ACL configuration file can be a YAML or JSON file. + +Flag: `--snapshot-threshold`
+Type: `integer`
+Description: The number of write commands required to trigger a snapshot. The default is `1,000` + +Flag: `--snapshot-interval`
+Type: `string`
+Description: The interval between snapshots. You can provide a parseable time format such as `30m45s` or `1h45m`. The default is 5 minutes. + +Flag: `--aof-sync-strategy`
+Type: `string`
+Description: How often to flush the file contents written to append only file. +The options are `always` for syncing on each command, `everysec` to sync every second, and `no` to leave it up to the os. + +Flag: `--restore-snapshot`
+Type: `boolean`
+Description: Determines whether to restore from a snapshot on startup. The default is `false`. + +Flag: `--restore-aof`
+Type: `boolean`
+Description: This flag determines whether to restore from an aof file on startup. If both this flag and `--restore-snapshot` are provided, this flag will take higher priority. + +Flag: `--forward-commands`
+Type: `boolean`
+Description: This flag allows you to send write commands to any node in the cluster. The node will forward the command to the cluster leader. When this is false, write commands can only be accepted by the leader. The default is `false`. + +Flag: `--max-memory`
+Type: `string`
+Examples: "200mb", "8gb", "1tb"
+Description: The maximum memory usage that SugarDB should observe. Once this limit is reached, the chosen key eviction strategy is triggered. The default is no limit. + +Flag: `--eviction-policy`
+Type: `string`
+Description: This flag allows you to choose the key eviction strategy when the maximum memory is reached. The flag accepts the following options:
+ +1. noeviction - Do not evict any keys even when max-memory is exceeded. All new write operations will be rejected. This is the default eviction strategy. +2. allkeys-lfu - Evict the least frequently used keys when max-memory is exceeded. +3. allkeys-lru - Evict the least recently used keys when max-memory is exceeded. +4. volatile-lfu - Evict the least frequently used keys with an expiration when max-memory is exceeded. +5. volatile-lru - Evict the least recently used keys with an expiration when max-memory is exceeded. +6. allkeys-random - Evict random keys until we get under the max-memory limit when max-memory is exceeded. +7. volatile-random - Evict random keys with an expiration when max-memory is exceeded. + +Flag: `--eviction-sample`
+Type: `integer`
+Description: An integer specifying the number of keys to sample when checking for expired keys. By default, SugarDB will sample 20 keys. The sampling is repeated if the number of expired keys found exceeds 20%. + +Flag: `--eviction-interval`
+Type: `string`
+Example: "10s", "5m30s", "100ms"
+Description: The interval between each sampling of keys to evict. By default, this happens every 100 milliseconds. + +Flag: `--loadmodule`
+Type: `string/path`
+Example: "path/to/module.so"
+Description: The full file path to the .so file to load into SugarDB to extend its commands. This flag can be specified multiple times to load multiple plugins. diff --git a/docs/docs/contribution.md b/docs/docs/contribution.md new file mode 100644 index 0000000..5c8bcc8 --- /dev/null +++ b/docs/docs/contribution.md @@ -0,0 +1,25 @@ +--- +sidebar_position: 4 +--- + +# Contribution + +Contributions are welcome! If you're interested in contributing, +feel free to clone the repository and submit a Pull Request. + +Join the [discord server](https://discord.gg/JrG4kPrF8v) if you'd like to discuss your contribution and/or be a part of the community. + +# Development Setup + +Pre-requisites: + +1. Go +2. Docker +3. Docker Compose +4. x86_64-linux-musl-gcc cross-compile toolchain as the development image is built for an Alpine container + +Steps: + +1. Clone the repository. +2. If you're on MacOS, you can run `make run` to build the project and spin up the development docker container. +3. If you're on another OS, you will have to use `go build` with the relevant flags for your system. diff --git a/docs/docs/eviction.md b/docs/docs/eviction.md new file mode 100644 index 0000000..9ed9f47 --- /dev/null +++ b/docs/docs/eviction.md @@ -0,0 +1,42 @@ +--- +sidebar_position: 3 +--- + +# Eviction + +### Memory Limit + +The memory limit can be set using the `--max-memory` config flag. This flag accepts a parsable memory value (e.g 100mb, 16gb). If the limit set is 0, then no memory limit is imposed. The default value is 0. + +### Passive eviction + +In passive eviction, the expired key is not deleted immediately after the expiry time. The key will remain in the store until the next time it is accessed. When attempting to access an expired key, that is when the key is deleted. + +### Active eviction + +Echovault will run a background goroutine that samples a set of volatile keys at a given interval. Any keys that are found to be expired will be deleted. If 20% or more of the sampled keys are deleted, then the process will immediately begin again. Otherwise, wait for the given interval until the next round of sampling/eviction. The default number of keys sampled is 20, and the default interval for sampling is 100 milliseconds. These can be configured using the `--eviction-sample` and `--eviction-interval` flags. + +### Eviction Policies + +Eviction policy can be set using the --eviction-policy flag. The following options are available. + +noeviction:
+This policy does not evict any keys. When max memory is reached, all new write commands will be rejected until keys are manually deleted by the user. + +allkeys-lfu:
+With this policy, all keys are considered for eviction when the max memory is reached. When max memory is reached, the least frequently accessed keys will be evicted until the memory usage is under the memory limit. + +allkeys-lru:
+This policy will consider all keys for eviction when max memory is reached. The least recently accessed keys will be deleted one by one until we are below the memory limit. + +allkeys-random:
+Evict random keys until we're below the max memory limit. + +volatile-lfu:
+With this policy, only keys with an associated expiry time will be evicted to adhere to the memory limit. When the memory limit is exceeded, volatile keys will be evicted starting from the least frequently used until we are below the memory limit or are out of volatile keys to evict. + +volatile-lru:
+With this policy, only keys with an associated expiry time will be evicted to adhere to the memory limit. When the memory limit is exceeded, volatile keys will be evicted starting from the list recently used until we are below the memory limit or are out of volatile keys to evict. + +volatile-random:
+Evict random volatile keys until we're below the memory limit, or we're out of volatile keys to evict. diff --git a/docs/docs/extension/embedded.md b/docs/docs/extension/embedded.md new file mode 100644 index 0000000..68d7cef --- /dev/null +++ b/docs/docs/extension/embedded.md @@ -0,0 +1,323 @@ +# Embedded + +SugarDB allows you to programmatically extend its list of commands ar runtime. + +The `AddCommand` method allows you to extend the SugarDB server by adding new commands and subcommands. + +Each command can have its own handler and key extraction logic. This method ensures that commands are unique within the server and properly integrated with the existing command handling infrastructure. + +## Method Definition + +```go +func (server *SugarDB) AddCommand(command CommandOptions) error +``` + +## Parameters + +- `command` - An instance of `CommandOptions` which provides the specification of the command to be added. + +## Errors + +- `"command already exists"` - If a command with the same name as the provided command already exists in the server. + +## Explanation + +The `AddCommand` method performs the following steps: + +1. **Command Uniqueness Check**: It checks if the command already exists in the server. If it does, it returns an error. +2. **Command Addition**: + - **Without Subcommands**: If the command does not have subcommands, it adds the command directly to the server's command list. + - **With Subcommands**: If the command has subcommands, it initializes a new command structure and iterates through the provided subcommands to add them to the server's command list. + +## Execute Custom Commands + +### Adding a Command without Subcommands + +In this example, we will be adding a command `COPYDEFAULT` that reads the value from the first key and +copies it into the second key only if both keys exist. + +If the first key does not exist, return an error. If the second key does not exist, the key will be created with the string value 'default'. + +The command will have the following format: `COPYDEFAULT key1 key2`. + +```go +// Define the key extraction function +func myKeyExtractionFunc(cmd []string) (db.CommandKeyExtractionFuncResult, error) { + if len(cmd) != 3 { + return db.CommandKeyExtractionFuncResult{}, errors.New("command must be length 3") + } + if cmd[1] == cmd[2] { + return db.CommandKeyExtractionFuncResult{}, errors.New("keys must be different") + } + return db.CommandKeyExtractionFuncResult{ + ReadKeys: []string{cmd[1]}, + WriteKeys: []string{cmd[2]}, + }, nil +} + +// Define the command handler function +func myCommandHandler(params db.CommandHandlerFuncParams) ([]byte, error) { + // Extract keys + keys, err := myKeyExtractionFunc(params.Command) + if err != nil { + return nil, err + } + + // Get the write and read keys. + readKey, writeKey := keys.ReadKeys[0], keys.WriteKeys[0] + + keysExist := params.KeysExist(params.Context, []string{writeKey, readKey}) + + // If readKey does not exist, return an error. + if !keysExist[readKey] { + return nil, fmt.Errorf("%s does not exist", readKey) + } + + // If writeKey does not exist, set "default" value at the key. + if !keysExist[writeKey] { + err = params.SetValues(params.Context, map[string]interface{}{writeKey: "default"}) + return []byte("+OK\r\n"), err + } + + // Set the value from readKey to writeKey. + err = params.SetValues(params.Context, map[string]interface{}{ + writeKey: params.GetValues(params.Context, []string{readKey})[readKey], + }) + return []byte("+OK\r\n"), err +} + +func main() { + server, err := db.NewSugarDB() + if err != nil { + log.Fatal(err) + } + + _, _ = server.MSet(map[string]string{ + "key1": "value1", + "key2": "value2", + }) + + // Define the command options + command := db.CommandOptions{ + Command: "COPYDEFAULT", // Command keyword + Module: "generic", // Add command to generic module, can be a new custom module. + Categories: []string{"write", "fast"}, // Can be custom categories here. + Description: `(COPYDEFAULT key1 key2) +Copies the value from key1 to key2. If key1 does not exist, an error is returned. If key1 exists but key2 +does not, the value "default" will be stored at key2. If both keys exist, the value from key1 will be copied to key2.`, + Sync: true, + KeyExtractionFunc: myKeyExtractionFunc, + HandlerFunc: myCommandHandler, + } + + // Add the command. + err = server.AddCommand(command) + if err != nil { + fmt.Println("Error adding command:", err) + } else { + fmt.Println("Command added successfully") + } +} +``` + +### Adding a Command with Subcommands + +You can add a command with a list of subcommands by defining them in the `SubCommand` property +of `CommandOptions`. + +```go +// Define the key extraction function for subcommands +func mySubCommandKeyExtractionFunc(cmd []string) (db.CommandKeyExtractionFuncResult, error) { + return db.CommandKeyExtractionFuncResult{ + ReadKeys: []string{"subkey1"}, + WriteKeys: []string{"subkey2"}, + }, nil +} + +// Define the subcommand handler function +func mySubCommandHandler(params db.CommandHandlerFuncParams) ([]byte, error) { + fmt.Println("Subcommand executed:", strings.Join(params.Command, " ")) + return []byte("+OK\r\n"), nil +} + +func main() { + server, err := db.NewSugarDB() + if err != nil { + log.Fatal(err) + } + + // Define the subcommands + subCommands := []db.SubCommandOptions{ + { + Command: "SUB1", + Module: "mymodule", + Categories: []string{"subcategory1"}, + Description: "This is subcommand 1", + Sync: false, + KeyExtractionFunc: mySubCommandKeyExtractionFunc, + HandlerFunc: mySubCommandHandler, + }, + { + Command: "SUB2", + Module: "mymodule", + Categories: []string{"subcategory2"}, + Description: "This is subcommand 2", + Sync: true, + KeyExtractionFunc: mySubCommandKeyExtractionFunc, + HandlerFunc: mySubCommandHandler, + }, + } + + // Define the main command options + command := db.CommandOptions{ + Command: "MYCOMMAND", + Module: "mymodule", + Categories: []string{"category1"}, + Description: "This is a sample command with subcommands", + Sync: true, + SubCommand: subCommands, + } + + // Add the command to the server + err := server.AddCommand(command) + if err != nil { + fmt.Println("Error adding command:", err) + } else { + fmt.Println("Command with subcommands added successfully") + } +} +``` + +Although the example above shows subcommands that share a handler and key extraction function, in practice, each subcommand should provide its own unique key extraction and handler functions. + +Note: If you provide a command handler for the top, level command, it will be ignored. Whenever +a command has subcommands, SugarDB will try to look for subcommands that match the second element +of the subcommand slice. If a subcommand cannot be found, an error is returned. + +## Executing Custom Commands + +You can use the custom command using the `ExecuteCommand` method. The method has the following signature: + +```go +func (server *SugarDB) ExecuteCommand(command ...string) ([]byte, error) +``` + +It accepts a command of varying length to accomodate any custom command. The command passed is case-insensitive. So "COPYDEFAULT" is considered the same as "copydefault". + +The returned values are: + +1. A byte slice containing the raw RESP returned from the custom command handler. +2. The error returned from the command handler in RESP error format. + +### Execute Command without Subcommands + +Here's an example of executing the COPYDEFAULT custom command that we created previously: + +```go +// Set the values for key1 and key2 +_, _ = server.MSet(map[string]string{ + "key1": "value1", + "key2": "value2", +}) + +// Execute the custom COPYDEFAULT command +res, err := server.ExecuteCommand("COPYDEFAULT", "key1", "key2") +if err != nil { + fmt.Println(err) +} else { + fmt.Println(string(res)) +} + +// Execute COPYDEFAULT command with lower case parameters +res, err := server.ExecuteCommand("copydefault", "key1", "key2") +if err != nil { + fmt.Println(err) +} else { + fmt.Println(string(res)) +} +``` + +### Execute Command with Subcommands + +Example of executing custom subcommands created previously: + +```go +// Execute subcommand 1 +res, err := server.ExecuteCommand("MYCOMMAND", "SUB1") +if err != nil { + fmt.Println(err) +} else { + fmt.Println(string(res)) +} + +// Execute subcommand 2 +res, err := server.ExecuteCommand("mycommand", "sub2") +if err != nil { + fmt.Println(err) +} else { + fmt.Println(string(res)) +} +``` + +### Execute in TCP client + +You can also execute programmatically added commands with a Redis client over TCP such as redis-cli. An example of executing the COPYDEFAULT commands looks as follows: + +``` +> COPYDEFAULT key1 key2 +``` + +To execute one of the subcommands: + +``` +> MYCOMMAND SUB1 +``` + +## Removing Commands + +You can remove commands using the `RemoveCommand` method. This methods does not only remove programmatically added commands but any commands loaded into the SugarDB instance. Including built-in commands and commands loaded from shared object files. + +The method has the following signature: + +```go +func (server *SugarDB) RemoveCommand(command ...string) +``` + +It accepts a command or subcommand to remove. If you'd like to remove an entire command, including all it's subcommands, you can pass only the command name. If you'd like to remove a particular subcommand but retain the command and it's other subcommands, then you must pass the names of command and the subcommand you'd like to delete. + +### Remove Command with no Subcommands + +Example demonstrating how to remove the "COPYDEFAULT" command created previously. + +```go +server.RemoveCommand("COPYDEFAULT") +``` + +### Remove a Subcommand + +To remove the "SUB1" subcommand of the "MYCOMMAND" command, you can pass the following parameters: + +```go +server.RemoveCommand("MYCOMMAND", "SUB1") +``` + +This leaves the "MYCOMMAND" command and "SUB2" subcommand available for execution. + +### Remove an entire Command with Multiple Subcommands + +If you'd like to remove the entirety of "MYCOMMAND" along with all its subcommands, you can pass the top-level command name as follows: + +```go +server.RemoveCommand("MYCOMMAND") +``` + +### Example + +## Important considerations + +Programmatically extending SugarDB like this brings some challenges: + +- If you're running in cluster mode, you have to make sure the custom command is added to all the nodes and that the command's key extraction and handler function implementations are exactly identical. Otherwise, the cluster will not be able to accurately sync the command's side effects across the cluster. +- When removing commands programmatically, you must make sure to remove the commands accross the entire cluster otherwise, the nodes with the missing command will not be able to replicate the command's side effects. + +Due to the reasons above, it's recommended that programmatically adding/removing commands should be done in standalone mode. It can be done in a cluster, but you must be careful and take into account the considerations above. diff --git a/docs/docs/extension/index.md b/docs/docs/extension/index.md new file mode 100644 index 0000000..c8b63bd --- /dev/null +++ b/docs/docs/extension/index.md @@ -0,0 +1,11 @@ +# Extension + +The SugarDB command list is always growing, but we realise that it may not provide an exhaustive list of commands. If you're switching from Redis, then there are a lot of commands that may be missing in SugarDB. + +As we continue to develop SugarDB and add built-in commands, we also provide a few ways in which you can extend SugarDB's functionality to add more commands. + +There are multiple ways to extend SugarDB: + +1. Using the embedded API. +2. Using shared object plugins. +3. Using Lua modules (Coming). diff --git a/docs/docs/extension/js.mdx b/docs/docs/extension/js.mdx new file mode 100644 index 0000000..2446762 --- /dev/null +++ b/docs/docs/extension/js.mdx @@ -0,0 +1,548 @@ +--- +title: JavaScript Modules +toc_min_heading_level: 2 +toc_max_heading_level: 4 +--- + +import LoadModuleDocs from "@site/src/components/load_module" +import CodeBlock from "@theme/CodeBlock" + +# JavaScript Modules + +SugarDB allows you to create new command modules using JavaScript. +These scripts are loaded into SugarDB at runtime and can be triggered by both embedded clients and +TCP clients just like native commands. + +SugarDB uses the [Otto engine (v0.5.1)](https://github.com/robertkrimen/otto) which targets ES5. +ES6 and later features will not be avaliable so you should refrain from using them. + +## Creating a JavaScript Module + +A JavaScript module has the following anatomy: + +```js + +// The keyword to trigger the command +var command = "JS.EXAMPLE" + +// The string array of categories this command belongs to. +// This array can contain both built-in categories and new custom categories. +var categories = ["generic", "write", "fast"] + +// The description of the command. +var description = "(JS.EXAMPLE) Example JS command that sets various data types to keys" + +// Whether the command should be synced across the RAFT cluster. +var sync = true + +/** + * keyExtractionFunc is a function that extracts the keys from the command and returns them to SugarDB.keyExtractionFunc + * The returned data from this function is used in the Access Control Layer to determine if the current connection is + * authorized to execute this command. The function must return a table that specifies which keys in this command + * are read keys and which ones are write keys. + * Example return: {readKeys: ["key1", "key2"], writeKeys: ["key3", "key4", "key5"]} + * + * 1. "command" is a string array representing the command that triggered this key extraction function. + * + * 2. "args" is a string array of the modifier args that were passed when loading the module into SugarDB. + * These args are passed to the key extraction function everytime it's invoked. +*/ +function keyExtractionFunc(command, args) { + if (command.length > 1) { + throw "wrong number of args, expected 0" + } + return { + readKeys: [], + writeKeys: [] + } +} + +/** + * handlerFunc is the command's handler function. The function is passed some arguments that allow it to interact with + * SugarDB. The function must return a valid RESP response or throw an error. + * The handler function accepts the following args: + * + * 1. "context" is a table that contains some information about the environment this command has been executed in. + * Example: {protocol: 2, database: 0} + * This object contains the following properties: + * i) protocol - the protocol version of the client that executed the command (either 2 or 3). + * ii) database - the active database index of the client that executed the command. + * + * 2. "command" is the string array representing the command that triggered this handler function. + * + * 3. "keyExists" is a function that can be called to check if a list of keys exists in the SugarDB store database. + * This function accepts a string array of keys to check and returns a table with each key having a corresponding + * boolean value indicating whether it exists. + * Examples: + * i) Example invocation: keyExists(["key1", "key2", "key3"]) + * ii) Example return: {key1: true, key2: false, key3: true} + * + * 4. "getValues" is a function that can be called to retrieve values from the SugarDB store database. + * The function accepts a string array of keys whose values we would like to fetch, and returns a table with each key + * containing the corresponding value from the store. + * The possible data types for the values are: number, string, nil, hash, set, zset + * Examples: + * i) Example invocation: getValues(["key1", "key2", "key3"]) + * ii) Example return: {key1: 3.142, key2: nil, key3: "Pi"} + * + * 5. "setValues" is a function that can be called to set values in the active database in the SugarDB store. + * This function accepts a table with keys and the corresponding values to set for each key in the active database + * in the store. + * The accepted data types for the values are: number, string, nil, hash, set, zset. + * The setValues function does not return anything. + * Examples: + * i) Example invocation: setValues({key1: 3.142, key2: nil, key3: "Pi"}) + * + * 6. "args" is a string array of the modifier args passed to the module at load time. These args are passed to the + * handler everytime it's invoked. + */ +function handlerFunc(ctx, command, keysExist, getValues, setValues, args) { + // Set various data types to keys + var keyValues = { + "numberKey": 42, + "stringKey": "Hello, SugarDB!", + "floatKey": 3.142, + "nilKey": null, + } + + // Store the values in the database + setValues(keyValues) + + // Verify the values have been set correctly + var keysToGet = ["numberKey", "stringKey", "floatKey", "nilKey"] + var retrievedValues = getValues(keysToGet) + + // Create an array to track mismatches + var mismatches = []; + for (var key in keyValues) { + if (Object.prototype.hasOwnProperty.call(keyValues, key)) { + var expectedValue = keyValues[key]; + var retrievedValue = retrievedValues[key]; + if (retrievedValue !== expectedValue) { + var msg = "Key " + key + ": expected " + expectedValue + ", got " + retrievedValue + mismatches.push(msg); + console.log(msg) + } + } + } + + // If mismatches exist, return an error + if (mismatches.length > 0) { + throw "values mismatch" + } + + // If all values match, return OK + return "+OK\r\n" +} +``` + +## Loading JavaScript Modules + + +## Standard Data Types + +Sugar DB supports the following standard data types in JavaScript modules: + +- string +- number (integers and floating-point numbers) +- null +- arrays (tables with integer keys) + +These data types can be stored using the setValues function and retrieved using the getValues function. + +## Custom Data Types + +In addition to the standard data types, SugarDB also supports custom data types in JavaScript modules. +These custom data types include: + +- Hashes +- Sets +- Sorted Sets + +Just like the standard types, these custom data types can be stored and retrieved using the setValues +and getValues functions respectively. + +### Hashes + +The hash data type is a custom data type in SugarDB designed for storing and managing key-value pairs. +It supports several methods for interacting with the hash, including adding, updating, retrieving, deleting, +and checking values.This section explains how to make use of the hash data type in your JavaScript modules. + +#### Creating a Hash + +```js +var myHash = new Hash(); +``` + +#### Hash methods + +`set` - Adds or updates key-value pairs in the hash. If the key exists, +the value is updated; otherwise, it is added. + +```js +var myHash = new Hash(); +var numUpdated = myHash.set({ + "field1": "value1", + "field2": "value2", + "field3": "value3", + "field4": "value4" +}); +console.log(numUpdated) // Output: 4 +``` + +`setnx` - Adds key-value pairs to the hash only if the key does not already exist. + +```js +var myHash = new Hash(); +myHash.set({"field1": "value1"}); +var numAdded = myHash.setnx({ + "field1": "newValue", // Will not overwrite because field1 exists + "field2": "value2" // Will be added +}) +console.log(numAdded) // Output: 1 +``` + +`get` - Retrieves the values for the specified keys. Returns nil for keys that do not exist. + +```js +var myHash = new Hash(); +myHash.set({ + key1: "value1" , + key2: "value2" +}); +// Get values from the hash +var values = myHash.get(["key1", "key2", "key3"]); +// Iterate over the values and log them +for (var key in values) { + if (values.hasOwnProperty(key)) { + console.log(key, values[key]); // Output: key1 value1, key2 value2, key3 undefined + } +} +``` + +`len` - Returns the number of key-value pairs in the hash. + +```js +var myHash = new Hash(); +myHash.set({ + "key1": "value1", + "key2": "value2" +}); +console.log(myHash:len()) // Output: 2 +``` + +`all` - Returns a table containing all key-value pairs in the hash. + +```js +var myHash = new Hash(); +myHash.set({ + "key1": "value1", + "key2": "value2" +}); +var allKVPairs = myHash:all() +for (var key in allKVPairs) { + if (allKVPairs.hasOwnProperty(key)) { + console.log(key, allKVPairs[key]); // Output: key1 value1, key2 value2 + } +} +``` + +`exists` - Checks if specified keys exist in the hash. + +```js +var myHash = new Hash(); +myHash.set({ + "key1": "value1" +}); +var existence = myHash.exists(["key1", "key2"]) +for (var key in existence) { + if (existence.hasOwnProperty(key)) { + console.log(key, existence[key]); // Output: key1 true, key2 false + } +} +``` + +`del` - Deletes the specified keys from the hash. Returns the number of keys deleted. + +```js +var myHash = new Hash(); +myHash.set({ + "key1": "value1", + "key2": "value2" +}); +var numDeleted = myHash.del(["key1", "key3"]) +console.log(numDeleted) // Output: 1 +``` + +### Sets + +The `set` data type is a custom data type in SugarDB designed for managing unique elements. +It supports operations like adding, removing, checking for membership, +and performing set operations such as subtraction. +This section explains how to use the `set` data type in your JavaScript modules. + +#### Creating a Set + +```js +var mySet1 = new Set(); // Create new empty set +var mySet2 = new Set(["apple", "banana", "cherry"]) // Create new set with elements +``` + +#### Set methods + +`add` - Adds one or more elements to the set. Returns the number of elements added. + +```js +var mySet = new Set(); +var addedCount = mySet.add(["apple", "banana"]) +console.log(addedCount) // Output: 2 +``` + +`pop` - Removes and returns a specified number of random elements from the set. + +```js +var mySet = new Set(["apple", "banana", "cherry"]) +var popped = mySet.pop(2) +console.log(popped) // Outputs an array of 2 random elements from the set +``` + +`contains` - Checks if a specific element exists in the set. + +```js +var mySet = new Set(["apple", "banana"]) +console.log(mySet.contains("apple")) // Output: true +console.log(mySet.contains("cherry")) // Output: false +``` + +`cardinality` - Returns the number of elements in the set. + +```js +var mySet = new Set(["apple", "banana"]) +console.log(mySet.cardinality()) // Output: 2 +``` + +`remove` - Removes one or more specified elements from the set. Returns the number of elements removed. + +```js +var mySet = new Set(["apple", "banana", "cherry"]) +var removedCount = mySet.remove(["banana", "cherry"]) +console.log(removedCount) // Output: 2 +``` + +`move` - Moves an element from one set to another. Returns true if the element was successfully moved. + +```js +var set1 = new Set(["apple", "banana"]) +var set2 = new Set(["cherry"]) +var success = set1.move(set2, "banana") +console.log(success) // Output: true +``` + +`subtract` - Returns a new set that is the result of subtracting other sets from the current set. + +```js +var set1 = new Set(["apple", "banana", "cherry"]) +var set2 = new Set(["banana"]) +var resultSet = set1.subtract([set2]) +var allElems = resultSet.all() +for (var i = 0; i < allElems.length; i++) { + console.log(allElems[i]); // Output: "apple", "cherry" +} +``` + +`all` - Returns a table containing all elements in the set. + +```js +var mySet = new Set(["apple", "banana", "cherry"]) +var allElems = mySet.all() +for (var i = 0; i < allElems.length; i++) { + console.log(allElems[i]); // Output: "apple", "banana", "cherry" +} +``` + +`random` - Returns a table of randomly selected elements from the set. The number of elements to return is specified as an argument. + +```js +var mySet = new Set(["apple", "banana", "cherry", "date"]) +var randomElems = mySet.random(2) +console.log(randomElems) // Outputs an array of 2 random elements from the set +``` + +### Sorted Sets + +A zset is a sorted set that stores zmember elements, ordered by their score. +The zset type provides methods to manipulate and query the set. A zset is made up of +zmember elements, each of which has a value and a score. + +#### zmember + +A zmember represents an element in a zset (sorted set). Each zmember consists of: +- value: A unique identifier for the member (e.g., a string). +- score: A numeric value used to sort the member in the sorted set. + +You can create a zmember as follows: + +```js +var m = new ZMember({value: "example", score: 42}) +``` + +The zmember type provides methods to retrieve or modify these properties. + +To set/get the value of a zmember, use the `value` method: + +```js +// Get the value +var value = m.value() + +// Set the value +m.value("new_value") +``` + +To set/get the score, use the `score` method: + +```js +// Get the score +var score = m.score() + +// Set the score +m.score(99.5) +``` + +#### Creating a Sorted Set + +```js +// Create a new zset with no zmembers +var zset1 = new ZSet() + +// Create a new zset with two zmembers +var zset2 = new ZSet([ + new ZMember({value: "a", score: 10}), + new ZMember({value: "b", score: 20}), +]) +``` + +#### Sorted Set Methods + +`add` - Adds one or more zmember elements to the zset. +Optionally, you can specify update policies using the optional modifiers. + +Optional Modifiers: +- "exists": Specifies whether to only update existing members ("xx") or only add new members ("nx"). Defaults to no restriction. +- "comparison": Specifies a comparison method for updating scores (e.g., "min", "max"). +- "changed": If true, returns the count of changed elements. +- "incr": If true, increments the score of the specified member by the given score instead of replacing it. + +Basic usage: + +```js +// Create members +var m1 = new ZMember({value: "item1", score: 10}) +var m2 = new ZMember({value: "item2", score: 20}) + +// Create zset and add members +var zset = new ZSet() +zset.add([m1, m2]) + +// Check cardinality +console.log(zset.cardinality()) // Outputs: 2 +``` + +Usage with optional modifiers: + +```js +// Create zset +var zset = new ZSet([ + new ZMember({value: "a", score: 10}), + new ZMember({value: "b", score: 20}), +]) + +// Attempt to add members with different policies +var new_members = { + new ZMember({value: "a", score: 5}), // Existing member + new ZMember({value: "c", score: 15}), // New member +} + +// Use policies to update and add +var options = { + exists = "xx", // Only update existing members + comparison = "max", // Keep the maximum score for existing members + changed = true, // Return the count of changed elements +} +var changed_count = zset.add(new_members, options) + +// Display results +console.log("Changed count:", changed_count) // Outputs: 1 (only "a" is updated) + +// Adding with different policies +var incr_options = { + exists = "nx", // Only add new members + incr = true, // Increment the score of the added members +} +zset.add([new ZMember({value: "d", score: 10})], incr_options) +``` + +`update` - Updates one or more zmember elements in the zset. +If the member doesn’t exist, the behavior depends on the provided update options. + +Optional Modifiers: +- "exists": Specifies whether to only update existing members ("xx") or only add new members ("nx"). Defaults to no restriction. +- "comparison": Specifies a comparison method for updating scores (e.g., "min", "max"). +- "changed": If true, returns the count of changed elements. +- "incr": If true, increments the score of the specified member by the given score instead of replacing it. + +```js +// Create members +var m1 = new ZMember({value: "item1", score: 10}) +var m2 = new ZMember({value: "item2", score: 20}) + +// Create zset and add members +var zset = new ZSet([m1, m2]) + +// Update a member +var m_update = new ZMember({value: "item1", score: 15}) +var changed_count = zset.update([m_update], {exists = true, comparison = "max", changed = true}) +console.log("Changed count:", changed_count) // Outputs the number of elements updated +``` + +`remove` - Removes a member from the zset by its value. + +```js +var removed = zset.remove("a") // Returns true if removed +``` + +`cardinality` - Returns the number of zmembers in the zset. + +```js +var count = zset.cardinality() +``` + +`contains` - Checks if a zmember with the specified value exists in the zset. + +```js +var exists = zset.contains("b") // Returns true if exists +``` + +`random` - Returns a random zmember from the zset. + +```js +var members = zset.random(2) // Returns up to 2 random members +``` + +`all` - Returns all zmembers in the zset. + +```js +var members = zset.all() +for (var i = 0; i < members.length; i++) { + console.log(members[i].value(), members[i].score()) +} +``` + +`subtract` - Returns a new zset that is the result of subtracting other zsets from the current one. + +```js +var other_zset = new ZSet([ + new ZMember({value: "b", score: 20}), +]) +var result_zset = zset.subtract([other_zset]) +``` + diff --git a/docs/docs/extension/lua.mdx b/docs/docs/extension/lua.mdx new file mode 100644 index 0000000..94854a4 --- /dev/null +++ b/docs/docs/extension/lua.mdx @@ -0,0 +1,511 @@ +--- +title: Lua Modules +toc_min_heading_level: 2 +toc_max_heading_level: 4 +--- + +import LoadModuleDocs from "@site/src/components/load_module" +import CodeBlock from "@theme/CodeBlock" + +# Lua Modules + +SugarDB allows you to create new command modules using Lua scripts. These scripts are loaded into SugarDB at runtime and can be triggered by both embedded clients and TCP clients just like native commands. + +## Creating a Lua Script Module + +A Lua script has the following anatomy: + +```lua +-- The keyword to trigger the command +command = "LUA.EXAMPLE" + +--[[ +The string array of categories this command belongs to. +This array can contain both built-in categories and new custom categories. +]] +categories = {"generic", "write", "fast"} + +-- The description of the command +description = "(LUA.EXAMPLE) Example lua command that sets various data types to keys" + +-- Whether the command should be synced across the RAFT cluster +sync = true + +--[[ +keyExtractionFunc is a function that extracts the keys from the command and returns them to SugarDB.keyExtractionFunc +The returned data from this function is used in the Access Control Layer to determine if the current connection is +authorized to execute this command. The function must return a table that specifies which keys in this command +are read keys and which ones are write keys. +Example return: {["readKeys"] = {"key1", "key2"}, ["writeKeys"] = {"key3", "key4", "key5"}} + +1. "command" is a string array representing the command that triggered this key extraction function. + +2. "args" is a string array of the modifier args that were passed when loading the module into SugarDB. + These args are passed to the key extraction function everytime it's invoked. +]] +function keyExtractionFunc (command, args) + if (#command ~= 1) then + error("wrong number of args, expected 0") + end + return { ["readKeys"] = {}, ["writeKeys"] = {} } +end + +--[[ +handlerFunc is the command's handler function. The function is passed some arguments that allow it to interact with +SugarDB. The function must return a valid RESP response or throw an error. +The handler function accepts the following args: + +1. "context" is a table that contains some information about the environment this command has been executed in. + Example: {["protocol"] = 2, ["database"] = 0} + This object contains the following properties: + i) protocol - the protocol version of the client that executed the command (either 2 or 3). + ii) database - the active database index of the client that executed the command. + +2. "command" is the string array representing the command that triggered this handler function. + +3. "keyExists" is a function that can be called to check if a list of keys exists in the SugarDB store database. + This function accepts a string array of keys to check and returns a table with each key having a corresponding + boolean value indicating whether it exists. + Examples: + i) Example invocation: keyExists({"key1", "key2", "key3"}) + ii) Example return: {["key1"] = true, ["key2"] = false, ["key3"] = true} + +4. "getValues" is a function that can be called to retrieve values from the SugarDB store database. + The function accepts a string array of keys whose values we would like to fetch, and returns a table with each key + containing the corresponding value from the store. + The possible data types for the values are: number, string, nil, hash, set, zset + Examples: + i) Example invocation: getValues({"key1", "key2", "key3"}) + ii) Example return: {["key1"] = 3.142, ["key2"] = nil, ["key3"] = "Pi"} + +5. "setValues" is a function that can be called to set values in the active database in the SugarDB store. + This function accepts a table with keys and the corresponding values to set for each key in the active database + in the store. + The accepted data types for the values are: number, string, nil, hash, set, zset. + The setValues function does not return anything. + Examples: + i) Example invocation: setValues({["key1"] = 3.142, ["key2"] = nil, ["key3"] = "Pi"}) + +6. "args" is a string array of the modifier args passed to the module at load time. These args are passed to the + handler everytime it's invoked. +]] +function handlerFunc(ctx, command, keysExist, getValues, setValues, args) + -- Set various data types to keys + local keyValues = { + ["numberKey"] = 42, + ["stringKey"] = "Hello, SugarDB!", + ["nilKey"] = nil, + } + + -- Store the values in the database + setValues(keyValues) + + -- Verify the values have been set correctly + local keysToGet = {"numberKey", "stringKey", "nilKey"} + local retrievedValues = getValues(keysToGet) + + -- Create a table to track mismatches + local mismatches = {} + for key, expectedValue in pairs(keyValues) do + local retrievedValue = retrievedValues[key] + if retrievedValue ~= expectedValue then + table.insert(mismatches, string.format("Key '%s': expected '%s', got '%s'", key, tostring(expectedValue), tostring(retrievedValue))) + end + end + + -- If mismatches exist, return an error + if #mismatches > 0 then + error("values mismatch") + end + + -- If all values match, return OK + return "+OK\r\n" +end +``` + +## Loading Lua Modules + + +## Standard Data Types + +Sugar DB supports the following standard data types in Lua scripts: + +- string +- number (integers and floating-point numbers) +- nil +- arrays (tables with integer keys) + +These data types can be stored using the setValues function and retrieved using the getValues function. + +## Custom Data Types + +In addition to the standard data types, SugarDB also supports custom data types in Lua scripts. +These custom data types include: + +- Hashes +- Sets +- Sorted Sets + +Just like the standard types, these custom data types can be stored and retrieved using the setValues +and getValues functions respectively. + +### Hashes + +The hash data type is a custom data type in SugarDB designed for storing and managing key-value pairs. +It supports several methods for interacting with the hash, including adding, updating, retrieving, deleting, and checking values. +This section explains how to make use of the hash data type in your Lua scripts. + +#### Creating a Hash + +```lua +local myHash = hash.new() +``` + +#### Hash methods + +`set` - Adds or updates key-value pairs in the hash. If the key exists, +the value is updated; otherwise, it is added. + +```lua +local myHash = hash.new() +local numUpdated = myHash:set({ + {key1 = "value1"}, + {key2 = "value2"} +}) +print(numUpdated) -- Output: 2 +``` + +`setnx` - Adds key-value pairs to the hash only if the key does not already exist. + +```lua +local myHash = hash.new() +myHash:set({{key1 = "value1"}}) +local numAdded = myHash:setnx({ + {key1 = "newValue"}, -- Will not overwrite because key1 exists + {key2 = "value2"} -- Will be added +}) +print(numAdded) -- Output: 1 +``` + +`get` - Retrieves the values for the specified keys. Returns nil for keys that do not exist. + +```lua +local myHash = hash.new() +myHash:set({{key1 = "value1"}, {key2 = "value2"}}) +local values = myHash:get({"key1", "key2", "key3"}) +for k, v in pairs(values) do + print(k, v) -- Output: key1 value1, key2 value2, key3 nil +end +``` + +`len` - Returns the number of key-value pairs in the hash. + +```lua +local myHash = hash.new() +myHash:set({{key1 = "value1"}, {key2 = "value2"}}) +print(myHash:len()) -- Output: 2 +``` + +`all` - Returns a table containing all key-value pairs in the hash. + +```lua +local myHash = hash.new() +myHash:set({{key1 = "value1"}, {key2 = "value2"}}) +local allValues = myHash:all() +for k, v in pairs(allValues) do + print(k, v) -- Output: key1 value1, key2 value2 +end +``` + +`exists` - Checks if specified keys exist in the hash. + +```lua +local myHash = hash.new() +myHash:set({{key1 = "value1"}}) +local existence = myHash:exists({"key1", "key2"}) +for k, v in pairs(existence) do + print(k, v) -- Output: key1 true, key2 false +end +``` + +`del` - Deletes the specified keys from the hash. Returns the number of keys deleted. + +```lua +local myHash = hash.new() +myHash:set({{key1 = "value1"}, {key2 = "value2"}}) +local numDeleted = myHash:del({"key1", "key3"}) +print(numDeleted) -- Output: 1 +``` + + +### Sets + +The `set` data type is a custom data type in SugarDB designed for managing unique elements. +It supports operations like adding, removing, checking for membership, and performing set operations such as subtraction. +This section explains how to use the `set` data type in your Lua scripts. + +#### Creating a Set + +```lua +local mySet = set.new({"apple", "banana", "cherry"}) +``` + +#### Set methods + +`add` - Adds one or more elements to the set. Returns the number of elements added. + +```lua +local mySet = set.new() +local addedCount = mySet:add({"apple", "banana"}) +print(addedCount) -- Output: 2 +``` + +`pop` - Removes and returns a specified number of random elements from the set. + +```lua +local mySet = set.new({"apple", "banana", "cherry"}) +local popped = mySet:pop(2) +for i, v in ipairs(popped) do + print(i, v) -- Output: Two random elements +end +``` + +`contains` - Checks if a specific element exists in the set. + +```lua +local mySet = set.new({"apple", "banana"}) +print(mySet:contains("apple")) -- Output: true +print(mySet:contains("cherry")) -- Output: false +``` + +`cardinality` - Returns the number of elements in the set. + +```lua +local mySet = set.new({"apple", "banana"}) +print(mySet:cardinality()) -- Output: 2 +``` + +`remove` - Removes one or more specified elements from the set. Returns the number of elements removed. + +```lua +local mySet = set.new({"apple", "banana", "cherry"}) +local removedCount = mySet:remove({"banana", "cherry"}) +print(removedCount) -- Output: 2 +``` + +`move` - Moves an element from one set to another. Returns true if the element was successfully moved. + +```lua +local set1 = set.new({"apple", "banana"}) +local set2 = set.new({"cherry"}) +local success = set1:move(set2, "banana") +print(success) -- Output: true +``` + +`subtract` - Returns a new set that is the result of subtracting other sets from the current set. + +```lua +local set1 = set.new({"apple", "banana", "cherry"}) +local set2 = set.new({"banana"}) +local resultSet = set1:subtract({set2}) +local allElems = resultSet:all() +for i, v in ipairs(allElems) do + print(i, v) -- Output: "apple", "cherry" +end +``` + +`all` - Returns a table containing all elements in the set. + +```lua +local mySet = set.new({"apple", "banana", "cherry"}) +local allElems = mySet:all() +for i, v in ipairs(allElems) do + print(i, v) -- Output: "apple", "banana", "cherry" +end +``` + +`random` - Returns a table of randomly selected elements from the set. The number of elements to return is specified as an argument. + +```lua +local mySet = set.new({"apple", "banana", "cherry", "date"}) +local randomElems = mySet:random(2) +for i, v in ipairs(randomElems) do + print(i, v) -- Output: Two random elements +end +``` + +### Sorted Sets + +A zset is a sorted set that stores zmember elements, ordered by their score. +The zset type provides methods to manipulate and query the set. A zset is made up of +zmember elements, each of which has a value and a score. + +#### zmember + +A zmember represents an element in a zset (sorted set). Each zmember consists of: +- value: A unique identifier for the member (e.g., a string). +- score: A numeric value used to sort the member in the sorted set. + +You can create a zmember using the `zmember.new` method: + +```lua +local m = zmember.new({value = "example", score = 42}) +``` + +The zmember type provides methods to retrieve or modify these properties. + +To set/get the value of a zmember, use the `value` method: + +```lua +-- Get the value +local value = m:value() + +-- Set the value +m:value("new_value") +``` + +To set/get the score, use the `score` method: + +```lua +-- Get the score +local score = m:score() + +-- Set the score +m:score(99.5) +``` + +#### Creating a Sorted Set + +```lua +-- Create a new zset with no zmembers +local zset1 = zset.new() + +-- Create a new zset with two zmembers +local zset2 = zset.new({ + zmember.new({value = "a", score = 10}), + zmember.new({value = "b", score = 20}), +}) +``` + +#### Sorted Set Methods + +`add` - Adds one or more zmember elements to the zset. +Optionally, you can specify update policies using the optional modifiers. + +Optional Modifiers: +- "exists": Specifies whether to only update existing members ("xx") or only add new members ("nx"). Defaults to no restriction. +- "comparison": Specifies a comparison method for updating scores (e.g., "min", "max"). +- "changed": If true, returns the count of changed elements. +- "incr": If true, increments the score of the specified member by the given score instead of replacing it. + +Basic usage: + +```lua +-- Create members +local m1 = zmember.new({value = "item1", score = 10}) +local m2 = zmember.new({value = "item2", score = 20}) + +-- Create zset and add members +local zset = zset.new() +zset:add({m1, m2}) + +-- Check cardinality +print(zset:cardinality()) -- Outputs: 2 +``` + +Usage with optional modifiers: + +```lua +-- Create zset +local zset = zset.new({ + zmember.new({value = "a", score = 10}), + zmember.new({value = "b", score = 20}), +}) + +-- Attempt to add members with different policies +local new_members = { + zmember.new({value = "a", score = 5}), -- Existing member + zmember.new({value = "c", score = 15}), -- New member +} + +-- Use policies to update and add +local options = { + exists = "xx", -- Only update existing members + comparison = "max", -- Keep the maximum score for existing members + changed = true, -- Return the count of changed elements +} +local changed_count = zset:add(new_members, options) +print("Changed count:", changed_count) -- Outputs: 1 (only "a" is updated) + +-- Adding with different policies +local incr_options = { + exists = "nx", -- Only add new members + incr = true, -- Increment the score of the added members +} +zset:add({zmember.new({value = "d", score = 10})}, incr_options) +``` + +`update` - Updates one or more zmember elements in the zset. +If the member doesn’t exist, the behavior depends on the provided update options. + +Optional Modifiers: +- "exists": Specifies whether to only update existing members ("xx") or only add new members ("nx"). Defaults to no restriction. +- "comparison": Specifies a comparison method for updating scores (e.g., "min", "max"). +- "changed": If true, returns the count of changed elements. +- "incr": If true, increments the score of the specified member by the given score instead of replacing it. + +```lua +-- Create members +local m1 = zmember.new({value = "item1", score = 10}) +local m2 = zmember.new({value = "item2", score = 20}) + +-- Create zset and add members +local zset = zset.new({m1, m2}) + +-- Update a member +local m_update = zmember.new({value = "item1", score = 15}) +local changed_count = zset:update({m_update}, {exists = true, comparison = "max", changed = true}) +print("Changed count:", changed_count) -- Outputs the number of elements updated +``` + +`remove` - Removes a member from the zset by its value. + +```lua +local removed = zset:remove("a") -- Returns true if removed +``` + +`cardinality` - Returns the number of zmembers in the zset. + +```lua +local count = zset:cardinality() +``` + +`contains` - Checks if a zmember with the specified value exists in the zset. + +```lua +local exists = zset:contains("b") -- Returns true if exists +``` + +`random` - Returns a random zmember from the zset. + +```lua +local members = zset:random(2) -- Returns up to 2 random members +``` + +`all` - Returns all zmembers in the zset. + +```lua +local members = zset:all() +for _, member in ipairs(members) do + print(member:value(), member:score()) +end +``` + +`subtract` - Returns a new zset that is the result of subtracting other zsets from the current one. + +```lua +local other_zset = zset.new({ + zmember.new({value = "b", score = 20}), +}) +local result_zset = zset:subtract({other_zset}) +``` \ No newline at end of file diff --git a/docs/docs/extension/shared_object.mdx b/docs/docs/extension/shared_object.mdx new file mode 100644 index 0000000..64bfd82 --- /dev/null +++ b/docs/docs/extension/shared_object.mdx @@ -0,0 +1,126 @@ +--- +title: Shared Object Files +toc_min_heading_level: 2 +toc_max_heading_level: 4 +--- + +import LoadModuleDocs from "@site/src/components/load_module" + +# Shared Object Files + +SugarDB allows you to extend its list of commands using shared object files. You can write Go scripts that are compiled in plugin mode to achieve this. + +## Creating a Module + +To demonstrate the creation of a new module, we will create a plugin that adds a command with the keyword `Module.Set`. The command will have the format `Module.Set key `. It's parameters will be a key to write to and an integer value. + +Below is an example of the Go plugin script: + +```go +package main + +import ( + "context" + "fmt" + "strconv" + "strings" +) + +// The command keyword. +var Command string = "Module.Set" + +// The list of categories this command belongs to. +// You can use built-in categories or your own custom categories. +var Categories []string = []string{"write", "fast"} + +// The command's description. +var Description string = `(Module.Set key value) +This module stores the given value at the specified key. The value must be an integer` + +// Whether the command should be synced across all nodes in a raft cluster. +// This is ignores in standalone mode. +var Sync bool = true + +// The key extraction function. +func KeyExtractionFunc( + cmd []string, // The command slice (e.g []string{"Module.Set", "key1", "10"}). + args ...string, // Args passed from module loading. +) ( + // Slice of keys the command handler will read from, extracted from the command slice. + readKeys []string, + // Slice of keys the command handler will write to, extracted from the command slice. + writeKeys []string, + // Error from key extraction handler. + err error, +) { + if len(cmd) != 3 { + return nil, nil, fmt.Errorf("wrong no of args for %s command", strings.ToLower(Command)) + } + return []string{}, cmd[1:2], nil +} + +// The command's handler function. +func HandlerFunc( + // Context passed from the SugarDB instance. + ctx context.Context, + // The command slice (e.g []string{"Module.Set", "key1", "10"}). + command []string, + // keysExist checks whether the keys exist in the store. + // Returns a map with each key pointing to a corresponding boolean value + // that states if the key exists. + keysExist func(ctx context.Context, keys []string) map[string]bool, + // getValues retrieves the values from the provided keys from the store. + // Returns a map with each key pointing to the corresponding value. + // If a key does not exist, its value will be nil. + getValues func(ctx context.Context, keys []string) map[string]interface{}, + // setValues sets the values for each key in the store with the corresponding + // value. If the value exists in the store, it is overwritten. If it does + // not exist, it is created with the new value. + setValues func(ctx context.Context, entries map[string]interface{}) error, + // The arguments passed when the command is loaded. + args ...string, +) ( + []byte, // Byte slice containing raw RESP response. + error, +) { + + _, writeKeys, err := KeyExtractionFunc(command, args...) + if err != nil { + return nil, err + } + key := writeKeys[0] + + value, err := strconv.ParseInt(command[2], 10, 64) + if err != nil { + return nil, err + } + + err = setValues(ctx, map[string]interface{}{key: value}) + if err != nil { + return nil, err + } + + return []byte("+OK\r\n"), nil +} +``` + +### Compiling Module File + +Compiling plugins can be quite tricky due to Golang's plugin system. Make sure that the environment variables you set when compiling the module match the ones used when compiling SugarDB. + +If you're using the official docker images, you can reference the `Dockerfile.dev` amd `Dockerfile.prod` files for reference on which flags you should use. + +If you're building SugarDB from source, make sure the environment variables for the plugin and SugarDB compilation match. + +Pass the -buildmode=plugin flag when compiling the plugin and the -o flag to specify a .so output file. Here's an example of a command to compile a plugin for the dev alpine docker image: + +``` +CGO_ENABLED=1 CC=gcc GOOS=linux GOARCH=amd64 go build -buildmode=plugin -o module_set.so module_set.go +``` + +## Loading Modules + + +## Important considerations + +When loading external plugins to SugarDB in cluster mode, make sure to load the modules in all of the cluster's nodes. Otherwise, replication will fail as some nodes will not be able to handle the module's commands during replication. diff --git a/docs/docs/intro.md b/docs/docs/intro.md new file mode 100644 index 0000000..69f76d7 --- /dev/null +++ b/docs/docs/intro.md @@ -0,0 +1,98 @@ +--- +sidebar_position: 1 +--- + +# Getting started + +## Embedded + +Install SugarDB with: `go get github.com/echovault/sugardb`. + +Here's an example of using SugarDB as an embedded library. +You can access all of SugarDB's commands using an ergonomic API. + +```go +func main() { + server, err := db.NewSugarDB() + + if err != nil { + log.Fatal(err) + } + + _, _, _ = server.Set("key", "Hello, world!", db.SETOptions{}) + + v, _ := server.Get("key") + fmt.Println(v) // Hello, world! + + // (Optional): Listen for TCP connections on this SugarDB instance. + server.Start() +} +``` + +An embedded SugarDB instance can still be part of a cluster, and the changes triggered +from the API will be consistent across the cluster. + +If you want to configure the SugarDB instance, you can modify retrieve the default config and +update its properties to suit your requirements. + +```go +conf := db.DefaultConfig() +conf.ServerID = "ServerInstance1" + +server, err := db.NewSugarDB( + db.WithConfig(conf), +) + +if err != nil { + log.Fatal(err) +} +``` + +For more information on the available configuration values, +check out the configuration page. + +You can also pass in a custom context using the `WithContext` option. + +```go +ctx := context.WithValue(context.Background(), "name", "default") + +server, err := db.NewSugarDB( + db.WithContext(ctx), +) + +if err != nil { + log.Fatal(err) +} +``` + +## Client-Server + +### Homebrew + +To install via homebrew, run: +1) `brew tap echovault/sugardb` +2) `brew install echovault/echovault/sugardb` + +Once installed, you can run the server with the following command: +`echovault --bind-addr=localhost --data-dir="path/to/persistence/directory"` + +### Docker + +`docker pull echovault/sugardb` + +The full list of tags can be found [here](https://hub.docker.com/r/echovault/sugardb/tags). + +### Container Registry + +`docker pull ghcr.io/echovault/sugardb` + +The full list of tags can be found [here](https://github.com/EchoVault/SugarDB/pkgs/container/echovault). + +### Binaries + +You can download the binaries by clicking on a release tag and downloading +the binary for your system. + +### Clients + +SugarDB uses RESP, which makes it compatible with existing Redis clients. diff --git a/docs/docs/persistence/append-only.md b/docs/docs/persistence/append-only.md new file mode 100644 index 0000000..8f50ded --- /dev/null +++ b/docs/docs/persistence/append-only.md @@ -0,0 +1,27 @@ +--- +sidebar_position: 2 +--- + +# Append-Only File + +SugarDB offers an append-only log file which keeps track of every write command. The log can be configured to trigger a compaction once a certain threshold of write commands is reached. + +## How it works + +Whenever a write command is executed, the command is logged in an append-only log file. Once a configured threshold of write commands is reached, the log file is compacted using a snapshot of the current data and then a fresh append-only log file is started. + +On restoration of data, SugarDB will first load the data from the snapshot, and then replay all the write commands from the latest log file. If there is not snapshot, it will simply replay the write commands in the log file. + +To restore data from the AOF file, set the `--restore-aof` configuration flag to `true` when starting an SugarDB instance. Make sure to set the `--data-dir` to the folder containing the AOF file so SugarDB knows where to load the file from. + +You can also trigger a manual compaction of the AOF file using the `REWRITEAOF` command. + +## File sync + +The append-only file strategy allows you to configure how often the file is flushed to disk. You can configure this using the `--aof-sync-strategy` flag. The valid options are: + +- `everysec` - Sync the file every second. This is the default sync strategy. +- `always` - Sync the file with each write command that is logged. +- `no` - Do not sync the file manually, instead, let the OS kernel handle the file syncing whenever it deems fit. + +NOTE: The behaviour described above is only relevant when running a standalone node. Logging and log-compaction in a replication cluster is handled through the `hashicorp/raft` package in the replication layer. At the moment, this is backed by `boltdb`, although there are plans to replace the boltdb dependency with the same append-only engine used by standalone nodes. diff --git a/docs/docs/persistence/index.md b/docs/docs/persistence/index.md new file mode 100644 index 0000000..a548d73 --- /dev/null +++ b/docs/docs/persistence/index.md @@ -0,0 +1,14 @@ +--- +sidebar_position: 5 +--- + +# Persistence + +SugarDB stores data in-memory but allows you to persist the data on disk. This offers a way to recover data upon restarting an instance. + +There are 2 strategies for persisting data to disk: + +- [Append-Only Files](./append-only) +- [Snapshots](./snapshot) + +NOTE: In standalon mode, if both Append-Only and Snapshot strategies are configured, the append-only strategy will be used. diff --git a/docs/docs/persistence/snapshot.md b/docs/docs/persistence/snapshot.md new file mode 100644 index 0000000..bbf68e8 --- /dev/null +++ b/docs/docs/persistence/snapshot.md @@ -0,0 +1,16 @@ +--- +sidebar_position: 1 +--- + +# Snapshot + +SugarDB can take periodic snapshots of the current data and store it on disk. There are 2 configuration values used to configure the snapshot behaviour: + +- `--snapshot-threshold` - The number of write commands before a snapshot is triggered. The default number is 1,000 write commands. +- `--snapshot-interval` - The interval between snapshots. It accepts a parseable time format such as `30m45s` or `1h45m`. The default is 5 minutes. + +To restore data from a snapshot, set the `--restore-snapshot` configuration flag to `true` when starting a new SugarDB instance. Make sure to set the `--data-dir` to the folder containing the snapshot file so SugarDB knows where to load the file from. + +You can trigger a snapshot manually using the `SAVE` command. + +When both of these configuration options are set, the snapshot is triggered by whichever one is reached first since the instance's initialization or the last snapshot. diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts new file mode 100644 index 0000000..65310e7 --- /dev/null +++ b/docs/docusaurus.config.ts @@ -0,0 +1,142 @@ +// @ts-ignore +import { themes as prismThemes } from "prism-react-renderer"; +// @ts-ignore +import type { Config } from "@docusaurus/types"; +// @ts-ignore +import type * as Preset from "@docusaurus/preset-classic"; + +const config: Config = { + title: "SugarDB", + tagline: "Embeddable distributed in-memory data store.", + favicon: "img/logo.png", + + // Set the production url of your site here + url: "https://sugardb.io", + // Set the // pathname under which your site is served + // For GitHub pages deployment, it is often '//' + baseUrl: "/", + + // GitHub pages deployment config. + // If you aren't using GitHub pages, you don't need these. + organizationName: "", // Usually your GitHub org/user name. + projectName: "SugarDB", // Usually your repo name. + + onBrokenLinks: "throw", + onBrokenMarkdownLinks: "warn", + + // Even if you don't use internationalization, you can use this field to set + // useful metadata like html lang. For example, if your site is Chinese, you + // may want to replace "en" with "zh-Hans". + i18n: { + defaultLocale: "en", + locales: ["en"], + }, + + // Custom plugin for hot reloading + plugins: [ + function hotReload() { + return { + name: "hot-reload", + configureWebpack() { + return { + watchOptions: { + poll: 1000, // Check for changes every second + aggregateTimeout: 300, // Delay before rebuilding + }, + }; + }, + }; + }, + ], + + presets: [ + [ + "classic", + { + docs: { + sidebarPath: "./sidebars.ts", + }, + blog: { + showReadingTime: true, + }, + theme: { + customCss: "./src/css/custom.css", + }, + } satisfies Preset.Options, + ], + ], + + themeConfig: { + colorMode: { + respectPrefersColorScheme: true, + }, + algolia: { + appId: "QGK73FSNRI", + apiKey: "f9225d8721591a9664e4346847407e2d", + indexName: "echovault", + contextualSearch: false, + }, + // Replace with your project's social card + navbar: { + title: "", + style: "dark", + logo: { + alt: "SugarDB Logo", + src: "img/logo.png", + }, + items: [ + { + type: "docSidebar", + sidebarId: "documentationSidebar", + position: "right", + label: "Documentation", + }, + { + href: "https://github.com/EchoVault/SugarDB", + label: "GitHub", + position: "right", + }, + ], + }, + footer: { + style: "dark", + links: [ + { + title: "Docs", + items: [ + { + label: "Documentation", + to: "/docs/intro", + }, + ], + }, + { + title: "Community", + items: [ + { + label: "Discord", + href: "https://discord.gg/JrG4kPrF8v", + }, + ], + }, + { + title: "More", + items: [ + { + label: "GitHub", + href: "https://github.com/EchoVault/SugarDB", + }, + ], + }, + ], + copyright: `Copyright © ${new Date().getFullYear()} SugarDB.`, + }, + prism: { + additionalLanguages: ["lua"], + theme: prismThemes.github, + darkTheme: prismThemes.dracula, + }, + } satisfies Preset.ThemeConfig, +}; + +export default config; diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..109a936 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,49 @@ +{ + "name": "-docs", + "version": "0.0.0", + "private": true, + "scripts": { + "docusaurus": "docusaurus", + "start": "docusaurus start", + "build": "docusaurus build", + "swizzle": "docusaurus swizzle", + "deploy": "docusaurus deploy", + "clear": "docusaurus clear", + "serve": "docusaurus serve", + "write-translations": "docusaurus write-translations", + "write-heading-ids": "docusaurus write-heading-ids", + "typecheck": "tsc" + }, + "dependencies": { + "@docusaurus/core": "3.6.3", + "@docusaurus/plugin-content-docs": "3.6.3", + "@docusaurus/preset-classic": "3.6.3", + "@mdx-js/react": "^3.0.0", + "clsx": "^2.0.0", + "prism-react-renderer": "^2.4.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@docusaurus/module-type-aliases": "3.2.1", + "@docusaurus/tsconfig": "3.2.1", + "@docusaurus/types": "3.2.1", + "@types/react": "^18.3.12", + "typescript": "~5.2.2" + }, + "browserslist": { + "production": [ + ">0.5%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 3 chrome version", + "last 3 firefox version", + "last 5 safari version" + ] + }, + "engines": { + "node": ">=18.0" + } +} diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml new file mode 100644 index 0000000..b8d161b --- /dev/null +++ b/docs/pnpm-lock.yaml @@ -0,0 +1,11486 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@docusaurus/core': + specifier: 3.6.3 + version: 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/plugin-content-docs': + specifier: 3.6.3 + version: 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/preset-classic': + specifier: 3.6.3 + version: 3.6.3(@algolia/client-search@5.15.0)(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.2.2) + '@mdx-js/react': + specifier: ^3.0.0 + version: 3.1.0(@types/react@18.3.12)(react@18.3.1) + clsx: + specifier: ^2.0.0 + version: 2.1.1 + prism-react-renderer: + specifier: ^2.4.0 + version: 2.4.0(react@18.3.1) + react: + specifier: ^18.0.0 + version: 18.3.1 + react-dom: + specifier: ^18.0.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@docusaurus/module-type-aliases': + specifier: 3.2.1 + version: 3.2.1(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/tsconfig': + specifier: 3.2.1 + version: 3.2.1 + '@docusaurus/types': + specifier: 3.2.1 + version: 3.2.1(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ^18.3.12 + version: 18.3.12 + typescript: + specifier: ~5.2.2 + version: 5.2.2 + +packages: + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/cache-browser-local-storage@4.24.0': + resolution: {integrity: sha512-t63W9BnoXVrGy9iYHBgObNXqYXM3tYXCjDSHeNwnsc324r4o5UiVKUiAB4THQ5z9U5hTj6qUvwg/Ez43ZD85ww==} + + '@algolia/cache-common@4.24.0': + resolution: {integrity: sha512-emi+v+DmVLpMGhp0V9q9h5CdkURsNmFC+cOS6uK9ndeJm9J4TiqSvPYVu+THUP8P/S08rxf5x2P+p3CfID0Y4g==} + + '@algolia/cache-in-memory@4.24.0': + resolution: {integrity: sha512-gDrt2so19jW26jY3/MkFg5mEypFIPbPoXsQGQWAi6TrCPsNOSEYepBMPlucqWigsmEy/prp5ug2jy/N3PVG/8w==} + + '@algolia/client-abtesting@5.15.0': + resolution: {integrity: sha512-FaEM40iuiv1mAipYyiptP4EyxkJ8qHfowCpEeusdHUC4C7spATJYArD2rX3AxkVeREkDIgYEOuXcwKUbDCr7Nw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-account@4.24.0': + resolution: {integrity: sha512-adcvyJ3KjPZFDybxlqnf+5KgxJtBjwTPTeyG2aOyoJvx0Y8dUQAEOEVOJ/GBxX0WWNbmaSrhDURMhc+QeevDsA==} + + '@algolia/client-analytics@4.24.0': + resolution: {integrity: sha512-y8jOZt1OjwWU4N2qr8G4AxXAzaa8DBvyHTWlHzX/7Me1LX8OayfgHexqrsL4vSBcoMmVw2XnVW9MhL+Y2ZDJXg==} + + '@algolia/client-analytics@5.15.0': + resolution: {integrity: sha512-lho0gTFsQDIdCwyUKTtMuf9nCLwq9jOGlLGIeQGKDxXF7HbiAysFIu5QW/iQr1LzMgDyM9NH7K98KY+BiIFriQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@4.24.0': + resolution: {integrity: sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==} + + '@algolia/client-common@5.15.0': + resolution: {integrity: sha512-IofrVh213VLsDkPoSKMeM9Dshrv28jhDlBDLRcVJQvlL8pzue7PEB1EZ4UoJFYS3NSn7JOcJ/V+olRQzXlJj1w==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.15.0': + resolution: {integrity: sha512-bDDEQGfFidDi0UQUCbxXOCdphbVAgbVmxvaV75cypBTQkJ+ABx/Npw7LkFGw1FsoVrttlrrQbwjvUB6mLVKs/w==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@4.24.0': + resolution: {integrity: sha512-l5FRFm/yngztweU0HdUzz1rC4yoWCFo3IF+dVIVTfEPg906eZg5BOd1k0K6rZx5JzyyoP4LdmOikfkfGsKVE9w==} + + '@algolia/client-personalization@5.15.0': + resolution: {integrity: sha512-LfaZqLUWxdYFq44QrasCDED5bSYOswpQjSiIL7Q5fYlefAAUO95PzBPKCfUhSwhb4rKxigHfDkd81AvEicIEoA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.15.0': + resolution: {integrity: sha512-wu8GVluiZ5+il8WIRsGKu8VxMK9dAlr225h878GGtpTL6VBvwyJvAyLdZsfFIpY0iN++jiNb31q2C1PlPL+n/A==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@4.24.0': + resolution: {integrity: sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==} + + '@algolia/client-search@5.15.0': + resolution: {integrity: sha512-Z32gEMrRRpEta5UqVQA612sLdoqY3AovvUPClDfMxYrbdDAebmGDVPtSogUba1FZ4pP5dx20D3OV3reogLKsRA==} + engines: {node: '>= 14.0.0'} + + '@algolia/events@4.0.1': + resolution: {integrity: sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==} + + '@algolia/ingestion@1.15.0': + resolution: {integrity: sha512-MkqkAxBQxtQ5if/EX2IPqFA7LothghVyvPoRNA/meS2AW2qkHwcxjuiBxv4H6mnAVEPfJlhu9rkdVz9LgCBgJg==} + engines: {node: '>= 14.0.0'} + + '@algolia/logger-common@4.24.0': + resolution: {integrity: sha512-LLUNjkahj9KtKYrQhFKCzMx0BY3RnNP4FEtO+sBybCjJ73E8jNdaKJ/Dd8A/VA4imVHP5tADZ8pn5B8Ga/wTMA==} + + '@algolia/logger-console@4.24.0': + resolution: {integrity: sha512-X4C8IoHgHfiUROfoRCV+lzSy+LHMgkoEEU1BbKcsfnV0i0S20zyy0NLww9dwVHUWNfPPxdMU+/wKmLGYf96yTg==} + + '@algolia/monitoring@1.15.0': + resolution: {integrity: sha512-QPrFnnGLMMdRa8t/4bs7XilPYnoUXDY8PMQJ1sf9ZFwhUysYYhQNX34/enoO0LBjpoOY6rLpha39YQEFbzgKyQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@4.24.0': + resolution: {integrity: sha512-P9kcgerfVBpfYHDfVZDvvdJv0lEoCvzNlOy2nykyt5bK8TyieYyiD0lguIJdRZZYGre03WIAFf14pgE+V+IBlw==} + + '@algolia/recommend@5.15.0': + resolution: {integrity: sha512-5eupMwSqMLDObgSMF0XG958zR6GJP3f7jHDQ3/WlzCM9/YIJiWIUoJFGsko9GYsA5xbLDHE/PhWtq4chcCdaGQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@4.24.0': + resolution: {integrity: sha512-Z2NxZMb6+nVXSjF13YpjYTdvV3032YTBSGm2vnYvYPA6mMxzM3v5rsCiSspndn9rzIW4Qp1lPHBvuoKJV6jnAA==} + + '@algolia/requester-browser-xhr@5.15.0': + resolution: {integrity: sha512-Po/GNib6QKruC3XE+WKP1HwVSfCDaZcXu48kD+gwmtDlqHWKc7Bq9lrS0sNZ456rfCKhXksOmMfUs4wRM/Y96w==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-common@4.24.0': + resolution: {integrity: sha512-k3CXJ2OVnvgE3HMwcojpvY6d9kgKMPRxs/kVohrwF5WMr2fnqojnycZkxPoEg+bXm8fi5BBfFmOqgYztRtHsQA==} + + '@algolia/requester-fetch@5.15.0': + resolution: {integrity: sha512-rOZ+c0P7ajmccAvpeeNrUmEKoliYFL8aOR5qGW5pFq3oj3Iept7Y5mEtEsOBYsRt6qLnaXn4zUKf+N8nvJpcIw==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@4.24.0': + resolution: {integrity: sha512-JF18yTjNOVYvU/L3UosRcvbPMGT9B+/GQWNWnenIImglzNVGpyzChkXLnrSf6uxwVNO6ESGu6oN8MqcGQcjQJw==} + + '@algolia/requester-node-http@5.15.0': + resolution: {integrity: sha512-b1jTpbFf9LnQHEJP5ddDJKE2sAlhYd7EVSOWgzo/27n/SfCoHfqD0VWntnWYD83PnOKvfe8auZ2+xCb0TXotrQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/transporter@4.24.0': + resolution: {integrity: sha512-86nI7w6NzWxd1Zp9q3413dRshDqAzSbsQjhcDhPIatEFiZrL1/TjnHL8S7jVKFePlIMzDsZWXAXwXzcok9c5oA==} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/code-frame@7.26.2': + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.26.2': + resolution: {integrity: sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.26.0': + resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.26.2': + resolution: {integrity: sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.25.9': + resolution: {integrity: sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-builder-binary-assignment-operator-visitor@7.25.9': + resolution: {integrity: sha512-C47lC7LIDCnz0h4vai/tpNOI95tCd5ZT3iBt/DBH5lXKHZsyNQv18yf1wIIg2ntiQNgmAvA+DgZ82iW8Qdym8g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.25.9': + resolution: {integrity: sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.25.9': + resolution: {integrity: sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.25.9': + resolution: {integrity: sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.3': + resolution: {integrity: sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-member-expression-to-functions@7.25.9': + resolution: {integrity: sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.25.9': + resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.26.0': + resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.25.9': + resolution: {integrity: sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.25.9': + resolution: {integrity: sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.25.9': + resolution: {integrity: sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.25.9': + resolution: {integrity: sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-simple-access@7.25.9': + resolution: {integrity: sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-skip-transparent-expression-wrappers@7.25.9': + resolution: {integrity: sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.25.9': + resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.25.9': + resolution: {integrity: sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.26.0': + resolution: {integrity: sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.26.2': + resolution: {integrity: sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9': + resolution: {integrity: sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.25.9': + resolution: {integrity: sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.25.9': + resolution: {integrity: sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.25.9': + resolution: {integrity: sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.25.9': + resolution: {integrity: sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.26.0': + resolution: {integrity: sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.26.0': + resolution: {integrity: sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.25.9': + resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.25.9': + resolution: {integrity: sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.25.9': + resolution: {integrity: sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.25.9': + resolution: {integrity: sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.25.9': + resolution: {integrity: sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.25.9': + resolution: {integrity: sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.25.9': + resolution: {integrity: sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.25.9': + resolution: {integrity: sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.26.0': + resolution: {integrity: sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.25.9': + resolution: {integrity: sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.25.9': + resolution: {integrity: sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.25.9': + resolution: {integrity: sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.25.9': + resolution: {integrity: sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.25.9': + resolution: {integrity: sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.25.9': + resolution: {integrity: sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-dynamic-import@7.25.9': + resolution: {integrity: sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.25.9': + resolution: {integrity: sha512-KRhdhlVk2nObA5AYa7QMgTMTVJdfHprfpAk4DjZVtllqRg9qarilstTKEhpVjyt+Npi8ThRyiV8176Am3CodPA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.25.9': + resolution: {integrity: sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.25.9': + resolution: {integrity: sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.25.9': + resolution: {integrity: sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.25.9': + resolution: {integrity: sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.25.9': + resolution: {integrity: sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.25.9': + resolution: {integrity: sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.25.9': + resolution: {integrity: sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.25.9': + resolution: {integrity: sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.25.9': + resolution: {integrity: sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@7.25.9': + resolution: {integrity: sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.25.9': + resolution: {integrity: sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.25.9': + resolution: {integrity: sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.25.9': + resolution: {integrity: sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.25.9': + resolution: {integrity: sha512-ENfftpLZw5EItALAD4WsY/KUWvhUlZndm5GC7G3evUsVeSJB6p0pBeLQUnRnBCBx7zV0RKQjR9kCuwrsIrjWog==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.25.9': + resolution: {integrity: sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.25.9': + resolution: {integrity: sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.25.9': + resolution: {integrity: sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.25.9': + resolution: {integrity: sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.25.9': + resolution: {integrity: sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.25.9': + resolution: {integrity: sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.25.9': + resolution: {integrity: sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.25.9': + resolution: {integrity: sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.25.9': + resolution: {integrity: sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-constant-elements@7.25.9': + resolution: {integrity: sha512-Ncw2JFsJVuvfRsa2lSHiC55kETQVLSnsYGQ1JDDwkUeWGTL/8Tom8aLTnlqgoeuopWrbbGndrc9AlLYrIosrow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.25.9': + resolution: {integrity: sha512-KJfMlYIUxQB1CJfO3e0+h0ZHWOTLCPP115Awhaz8U0Zpq36Gl/cXlpoyMRnUWlhNUBAzldnCiAZNvCDj7CrKxQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.25.9': + resolution: {integrity: sha512-9mj6rm7XVYs4mdLIpbZnHOYdpW42uoiBCTVowg7sP1thUOiANgMb4UtpRivR0pp5iL+ocvUv7X4mZgFRpJEzGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.25.9': + resolution: {integrity: sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.25.9': + resolution: {integrity: sha512-KQ/Takk3T8Qzj5TppkS1be588lkbTp5uj7w6a0LeQaTMSckU/wK0oJ/pih+T690tkgI5jfmg2TqDJvd41Sj1Cg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.25.9': + resolution: {integrity: sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.26.0': + resolution: {integrity: sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.25.9': + resolution: {integrity: sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-runtime@7.25.9': + resolution: {integrity: sha512-nZp7GlEl+yULJrClz0SwHPqir3lc0zsPrDHQUcxGspSL7AKrexNSEfTbfqnDNJUO13bgKyfuOLMF8Xqtu8j3YQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.25.9': + resolution: {integrity: sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.25.9': + resolution: {integrity: sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.25.9': + resolution: {integrity: sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.25.9': + resolution: {integrity: sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.25.9': + resolution: {integrity: sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.25.9': + resolution: {integrity: sha512-7PbZQZP50tzv2KGGnhh82GSyMB01yKY9scIjf1a+GfZCtInOWqUH5+1EBU4t9fyR5Oykkkc9vFTs4OHrhHXljQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.25.9': + resolution: {integrity: sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.25.9': + resolution: {integrity: sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.25.9': + resolution: {integrity: sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.25.9': + resolution: {integrity: sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.26.0': + resolution: {integrity: sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + + '@babel/preset-react@7.25.9': + resolution: {integrity: sha512-D3to0uSPiWE7rBrdIICCd0tJSIGpLaaGptna2+w7Pft5xMqLpA1sz99DK5TZ1TjGbdQ/VI1eCSZ06dv3lT4JOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.26.0': + resolution: {integrity: sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime-corejs3@7.26.0': + resolution: {integrity: sha512-YXHu5lN8kJCb1LOb9PgV6pvak43X2h4HvRApcN5SdWeaItQOzfn1hgP6jasD6KWQyJDBxrVmA9o9OivlnNJK/w==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.26.0': + resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.25.9': + resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.25.9': + resolution: {integrity: sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.26.0': + resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==} + engines: {node: '>=6.9.0'} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@csstools/cascade-layer-name-parser@2.0.4': + resolution: {integrity: sha512-7DFHlPuIxviKYZrOiwVU/PiHLm3lLUR23OMuEEtfEOQTOp9hzQ2JjdY6X5H18RVuUPJqSCI+qNnD5iOLMVE0bA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/color-helpers@5.0.1': + resolution: {integrity: sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.0': + resolution: {integrity: sha512-X69PmFOrjTZfN5ijxtI8hZ9kRADFSLrmmQ6hgDJ272Il049WGKpDY64KhrFm/7rbWve0z81QepawzjkKlqkNGw==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-color-parser@3.0.6': + resolution: {integrity: sha512-S/IjXqTHdpI4EtzGoNCHfqraXF37x12ZZHA1Lk7zoT5pm2lMjFuqhX/89L7dqX4CcMacKK+6ZCs5TmEGb/+wKw==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-parser-algorithms@3.0.4': + resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-tokenizer@3.0.3': + resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} + engines: {node: '>=18'} + + '@csstools/media-query-list-parser@4.0.2': + resolution: {integrity: sha512-EUos465uvVvMJehckATTlNqGj4UJWkTmdWuDMjqvSUkjGpmOyFZBVwb4knxCm/k2GMTXY+c/5RkdndzFYWeX5A==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/postcss-cascade-layers@5.0.1': + resolution: {integrity: sha512-XOfhI7GShVcKiKwmPAnWSqd2tBR0uxt+runAxttbSp/LY2U16yAVPmAf7e9q4JJ0d+xMNmpwNDLBXnmRCl3HMQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-color-function@4.0.6': + resolution: {integrity: sha512-EcvXfC60cTIumzpsxWuvVjb7rsJEHPvqn3jeMEBUaE3JSc4FRuP7mEQ+1eicxWmIrs3FtzMH9gR3sgA5TH+ebQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-color-mix-function@3.0.6': + resolution: {integrity: sha512-jVKdJn4+JkASYGhyPO+Wa5WXSx1+oUgaXb3JsjJn/BlrtFh5zjocCY7pwWi0nuP24V1fY7glQsxEYcYNy0dMFg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-content-alt-text@2.0.4': + resolution: {integrity: sha512-YItlZUOuZJCBlRaCf8Aucc1lgN41qYGALMly0qQllrxYJhiyzlI6RxOTMUvtWk+KhS8GphMDsDhKQ7KTPfEMSw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-exponential-functions@2.0.5': + resolution: {integrity: sha512-mi8R6dVfA2nDoKM3wcEi64I8vOYEgQVtVKCfmLHXupeLpACfGAided5ddMt5f+CnEodNu4DifuVwb0I6fQDGGQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-font-format-keywords@4.0.0': + resolution: {integrity: sha512-usBzw9aCRDvchpok6C+4TXC57btc4bJtmKQWOHQxOVKen1ZfVqBUuCZ/wuqdX5GHsD0NRSr9XTP+5ID1ZZQBXw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-gamut-mapping@2.0.6': + resolution: {integrity: sha512-0ke7fmXfc8H+kysZz246yjirAH6JFhyX9GTlyRnM0exHO80XcA9zeJpy5pOp5zo/AZiC/q5Pf+Hw7Pd6/uAoYA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-gradients-interpolation-method@5.0.6': + resolution: {integrity: sha512-Itrbx6SLUzsZ6Mz3VuOlxhbfuyLTogG5DwEF1V8dAi24iMuvQPIHd7Ti+pNDp7j6WixndJGZaoNR0f9VSzwuTg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-hwb-function@4.0.6': + resolution: {integrity: sha512-927Pqy3a1uBP7U8sTfaNdZVB0mNXzIrJO/GZ8us9219q9n06gOqCdfZ0E6d1P66Fm0fYHvxfDbfcUuwAn5UwhQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-ic-unit@4.0.0': + resolution: {integrity: sha512-9QT5TDGgx7wD3EEMN3BSUG6ckb6Eh5gSPT5kZoVtUuAonfPmLDJyPhqR4ntPpMYhUKAMVKAg3I/AgzqHMSeLhA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-initial@2.0.0': + resolution: {integrity: sha512-dv2lNUKR+JV+OOhZm9paWzYBXOCi+rJPqJ2cJuhh9xd8USVrd0cBEPczla81HNOyThMQWeCcdln3gZkQV2kYxA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-is-pseudo-class@5.0.1': + resolution: {integrity: sha512-JLp3POui4S1auhDR0n8wHd/zTOWmMsmK3nQd3hhL6FhWPaox5W7j1se6zXOG/aP07wV2ww0lxbKYGwbBszOtfQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-light-dark-function@2.0.7': + resolution: {integrity: sha512-ZZ0rwlanYKOHekyIPaU+sVm3BEHCe+Ha0/px+bmHe62n0Uc1lL34vbwrLYn6ote8PHlsqzKeTQdIejQCJ05tfw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-float-and-clear@3.0.0': + resolution: {integrity: sha512-SEmaHMszwakI2rqKRJgE+8rpotFfne1ZS6bZqBoQIicFyV+xT1UF42eORPxJkVJVrH9C0ctUgwMSn3BLOIZldQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-overflow@2.0.0': + resolution: {integrity: sha512-spzR1MInxPuXKEX2csMamshR4LRaSZ3UXVaRGjeQxl70ySxOhMpP2252RAFsg8QyyBXBzuVOOdx1+bVO5bPIzA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-overscroll-behavior@2.0.0': + resolution: {integrity: sha512-e/webMjoGOSYfqLunyzByZj5KKe5oyVg/YSbie99VEaSDE2kimFm0q1f6t/6Jo+VVCQ/jbe2Xy+uX+C4xzWs4w==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-resize@3.0.0': + resolution: {integrity: sha512-DFbHQOFW/+I+MY4Ycd/QN6Dg4Hcbb50elIJCfnwkRTCX05G11SwViI5BbBlg9iHRl4ytB7pmY5ieAFk3ws7yyg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-viewport-units@3.0.3': + resolution: {integrity: sha512-OC1IlG/yoGJdi0Y+7duz/kU/beCwO+Gua01sD6GtOtLi7ByQUpcIqs7UE/xuRPay4cHgOMatWdnDdsIDjnWpPw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-media-minmax@2.0.5': + resolution: {integrity: sha512-sdh5i5GToZOIAiwhdntRWv77QDtsxP2r2gXW/WbLSCoLr00KTq/yiF1qlQ5XX2+lmiFa8rATKMcbwl3oXDMNew==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-media-queries-aspect-ratio-number-values@3.0.4': + resolution: {integrity: sha512-AnGjVslHMm5xw9keusQYvjVWvuS7KWK+OJagaG0+m9QnIjZsrysD2kJP/tr/UJIyYtMCtu8OkUd+Rajb4DqtIQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-nested-calc@4.0.0': + resolution: {integrity: sha512-jMYDdqrQQxE7k9+KjstC3NbsmC063n1FTPLCgCRS2/qHUbHM0mNy9pIn4QIiQGs9I/Bg98vMqw7mJXBxa0N88A==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-normalize-display-values@4.0.0': + resolution: {integrity: sha512-HlEoG0IDRoHXzXnkV4in47dzsxdsjdz6+j7MLjaACABX2NfvjFS6XVAnpaDyGesz9gK2SC7MbNwdCHusObKJ9Q==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-oklab-function@4.0.6': + resolution: {integrity: sha512-Hptoa0uX+XsNacFBCIQKTUBrFKDiplHan42X73EklG6XmQLG7/aIvxoNhvZ7PvOWMt67Pw3bIlUY2nD6p5vL8A==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-progressive-custom-properties@4.0.0': + resolution: {integrity: sha512-XQPtROaQjomnvLUSy/bALTR5VCtTVUFwYs1SblvYgLSeTo2a/bMNwUwo2piXw5rTv/FEYiy5yPSXBqg9OKUx7Q==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-random-function@1.0.1': + resolution: {integrity: sha512-Ab/tF8/RXktQlFwVhiC70UNfpFQRhtE5fQQoP2pO+KCPGLsLdWFiOuHgSRtBOqEshCVAzR4H6o38nhvRZq8deA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-relative-color-syntax@3.0.6': + resolution: {integrity: sha512-yxP618Xb+ji1I624jILaYM62uEmZcmbdmFoZHoaThw896sq0vU39kqTTF+ZNic9XyPtPMvq0vyvbgmHaszq8xg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-scope-pseudo-class@4.0.1': + resolution: {integrity: sha512-IMi9FwtH6LMNuLea1bjVMQAsUhFxJnyLSgOp/cpv5hrzWmrUYU5fm0EguNDIIOHUqzXode8F/1qkC/tEo/qN8Q==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-sign-functions@1.1.0': + resolution: {integrity: sha512-SLcc20Nujx/kqbSwDmj6oaXgpy3UjFhBy1sfcqPgDkHfOIfUtUVH7OXO+j7BU4v/At5s61N5ZX6shvgPwluhsA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-stepped-value-functions@4.0.5': + resolution: {integrity: sha512-G6SJ6hZJkhxo6UZojVlLo14MohH4J5J7z8CRBrxxUYy9JuZiIqUo5TBYyDGcE0PLdzpg63a7mHSJz3VD+gMwqw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-text-decoration-shorthand@4.0.1': + resolution: {integrity: sha512-xPZIikbx6jyzWvhms27uugIc0I4ykH4keRvoa3rxX5K7lEhkbd54rjj/dv60qOCTisoS+3bmwJTeyV1VNBrXaw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-trigonometric-functions@4.0.5': + resolution: {integrity: sha512-/YQThYkt5MLvAmVu7zxjhceCYlKrYddK6LEmK5I4ojlS6BmO9u2yO4+xjXzu2+NPYmHSTtP4NFSamBCMmJ1NJA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-unset-value@4.0.0': + resolution: {integrity: sha512-cBz3tOCI5Fw6NIFEwU3RiwK6mn3nKegjpJuzCndoGq3BZPkUjnsq7uQmIeMNeMbMk7YD2MfKcgCpZwX5jyXqCA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/selector-resolve-nested@3.0.0': + resolution: {integrity: sha512-ZoK24Yku6VJU1gS79a5PFmC8yn3wIapiKmPgun0hZgEI5AOqgH2kiPRsPz1qkGv4HL+wuDLH83yQyk6inMYrJQ==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@csstools/selector-specificity@5.0.0': + resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@csstools/utilities@2.0.0': + resolution: {integrity: sha512-5VdOr0Z71u+Yp3ozOx8T11N703wIFGVRgOWbOZMKgglPJsWA54MRIoMNVMa7shUToIhx5J8vX4sOZgD2XiihiQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@discoveryjs/json-ext@0.5.7': + resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} + engines: {node: '>=10.0.0'} + + '@docsearch/css@3.8.0': + resolution: {integrity: sha512-pieeipSOW4sQ0+bE5UFC51AOZp9NGxg89wAlZ1BAQFaiRAGK1IKUaPQ0UGZeNctJXyqZ1UvBtOQh2HH+U5GtmA==} + + '@docsearch/react@3.8.0': + resolution: {integrity: sha512-WnFK720+iwTVt94CxY3u+FgX6exb3BfN5kE9xUY6uuAH/9W/UFboBZFLlrw/zxFRHoHZCOXRtOylsXF+6LHI+Q==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@docusaurus/babel@3.6.3': + resolution: {integrity: sha512-7dW9Hat9EHYCVicFXYA4hjxBY38+hPuCURL8oRF9fySRm7vzNWuEOghA1TXcykuXZp0HLG2td4RhDxCvGG7tNw==} + engines: {node: '>=18.0'} + + '@docusaurus/bundler@3.6.3': + resolution: {integrity: sha512-47JLuc8D4wA+6VOvmMd5fUC9rFppBQpQOnxDYiVXffm/DeV/wmm3sbpNd5Y+O+G2+nevLTRnvCm/qyancv0Y3A==} + engines: {node: '>=18.0'} + peerDependencies: + '@docusaurus/faster': '*' + peerDependenciesMeta: + '@docusaurus/faster': + optional: true + + '@docusaurus/core@3.6.3': + resolution: {integrity: sha512-xL7FRY9Jr5DWqB6pEnqgKqcMPJOX5V0pgWXi5lCiih11sUBmcFKM7c3+GyxcVeeWFxyYSDP3grLTWqJoP4P9Vw==} + engines: {node: '>=18.0'} + hasBin: true + peerDependencies: + '@mdx-js/react': ^3.0.0 + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/cssnano-preset@3.6.3': + resolution: {integrity: sha512-qP7SXrwZ+23GFJdPN4aIHQrZW+oH/7tzwEuc/RNL0+BdZdmIjYQqUxdXsjE4lFxLNZjj0eUrSNYIS6xwfij+5Q==} + engines: {node: '>=18.0'} + + '@docusaurus/logger@3.6.3': + resolution: {integrity: sha512-xSubJixcNyMV9wMV4q0s47CBz3Rlc5jbcCCuij8pfQP8qn/DIpt0ks8W6hQWzHAedg/J/EwxxUOUrnEoKzJo8g==} + engines: {node: '>=18.0'} + + '@docusaurus/mdx-loader@3.6.3': + resolution: {integrity: sha512-3iJdiDz9540ppBseeI93tWTDtUGVkxzh59nMq4ignylxMuXBLK8dFqVeaEor23v1vx6TrGKZ2FuLaTB+U7C0QQ==} + engines: {node: '>=18.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/module-type-aliases@3.2.1': + resolution: {integrity: sha512-FyViV5TqhL1vsM7eh29nJ5NtbRE6Ra6LP1PDcPvhwPSlA7eiWGRKAn3jWwMUcmjkos5SYY+sr0/feCdbM3eQHQ==} + peerDependencies: + react: '*' + react-dom: '*' + + '@docusaurus/module-type-aliases@3.6.3': + resolution: {integrity: sha512-MjaXX9PN/k5ugNvfRZdWyKWq4FsrhN4LEXaj0pEmMebJuBNlFeGyKQUa9DRhJHpadNaiMLrbo9m3U7Ig5YlsZg==} + peerDependencies: + react: '*' + react-dom: '*' + + '@docusaurus/plugin-content-blog@3.6.3': + resolution: {integrity: sha512-k0ogWwwJU3pFRFfvW1kRVHxzf2DutLGaaLjAnHVEU6ju+aRP0Z5ap/13DHyPOfHeE4WKpn/M0TqjdwZAcY3kAw==} + engines: {node: '>=18.0'} + peerDependencies: + '@docusaurus/plugin-content-docs': '*' + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/plugin-content-docs@3.6.3': + resolution: {integrity: sha512-r2wS8y/fsaDcxkm20W5bbYJFPzdWdEaTWVYjNxlHlcmX086eqQR1Fomlg9BHTJ0dLXPzAlbC8EN4XqMr3QzNCQ==} + engines: {node: '>=18.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/plugin-content-pages@3.6.3': + resolution: {integrity: sha512-eHrmTgjgLZsuqfsYr5X2xEwyIcck0wseSofWrjTwT9FLOWp+KDmMAuVK+wRo7sFImWXZk3oV/xX/g9aZrhD7OA==} + engines: {node: '>=18.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/plugin-debug@3.6.3': + resolution: {integrity: sha512-zB9GXfIZNPRfzKnNjU6xGVrqn9bPXuGhpjgsuc/YtcTDjnjhasg38NdYd5LEqXex5G/zIorQgWB3n6x/Ut62vQ==} + engines: {node: '>=18.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/plugin-google-analytics@3.6.3': + resolution: {integrity: sha512-rCDNy1QW8Dag7nZq67pcum0bpFLrwvxJhYuVprhFh8BMBDxV0bY+bAkGHbSf68P3Bk9C3hNOAXX1srGLIDvcTA==} + engines: {node: '>=18.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/plugin-google-gtag@3.6.3': + resolution: {integrity: sha512-+OyDvhM6rqVkQOmLVkQWVJAizEEfkPzVWtIHXlWPOCFGK9X4/AWeBSrU0WG4iMg9Z4zD4YDRrU+lvI4s6DSC+w==} + engines: {node: '>=18.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/plugin-google-tag-manager@3.6.3': + resolution: {integrity: sha512-1M6UPB13gWUtN2UHX083/beTn85PlRI9ABItTl/JL1FJ5dJTWWFXXsHf9WW/6hrVwthwTeV/AGbGKvLKV+IlCA==} + engines: {node: '>=18.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/plugin-sitemap@3.6.3': + resolution: {integrity: sha512-94qOO4M9Fwv9KfVQJsgbe91k+fPJ4byf1L3Ez8TUa6TAFPo/BrLwQ80zclHkENlL1824TuxkcMKv33u6eydQCg==} + engines: {node: '>=18.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/preset-classic@3.6.3': + resolution: {integrity: sha512-VHSYWROT3flvNNI1SrnMOtW1EsjeHNK9dhU6s9eY5hryZe79lUqnZJyze/ymDe2LXAqzyj6y5oYvyBoZZk6ErA==} + engines: {node: '>=18.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/react-loadable@5.5.2': + resolution: {integrity: sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==} + peerDependencies: + react: '*' + + '@docusaurus/react-loadable@6.0.0': + resolution: {integrity: sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==} + peerDependencies: + react: '*' + + '@docusaurus/theme-classic@3.6.3': + resolution: {integrity: sha512-1RRLK1tSArI2c00qugWYO3jRocjOZwGF1mBzPPylDVRwWCS/rnWWR91ChdbbaxIupRJ+hX8ZBYrwr5bbU0oztQ==} + engines: {node: '>=18.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/theme-common@3.6.3': + resolution: {integrity: sha512-b8ZkhczXHDxWWyvz+YJy4t/PlPbEogTTbgnHoflYnH7rmRtyoodTsu8WVM12la5LmlMJBclBXFl29OH8kPE7gg==} + engines: {node: '>=18.0'} + peerDependencies: + '@docusaurus/plugin-content-docs': '*' + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/theme-search-algolia@3.6.3': + resolution: {integrity: sha512-rt+MGCCpYgPyWCGXtbxlwFbTSobu15jWBTPI2LHsHNa5B0zSmOISX6FWYAPt5X1rNDOqMGM0FATnh7TBHRohVA==} + engines: {node: '>=18.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/theme-translations@3.6.3': + resolution: {integrity: sha512-Gb0regclToVlngSIIwUCtBMQBq48qVUaN1XQNKW4XwlsgUyk0vP01LULdqbem7czSwIeBAFXFoORJ0RPX7ht/w==} + engines: {node: '>=18.0'} + + '@docusaurus/tsconfig@3.2.1': + resolution: {integrity: sha512-+biUwtsYW3oChLxYezzA+NIgS3Q9KDRl7add/YT54RXs9Q4rKInebxdHdG6JFs5BaTg45gyjDu0rvNVcGeHODg==} + + '@docusaurus/types@3.2.1': + resolution: {integrity: sha512-n/toxBzL2oxTtRTOFiGKsHypzn/Pm+sXyw+VSk1UbqbXQiHOwHwts55bpKwbcUgA530Is6kix3ELiFOv9GAMfw==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/types@3.6.3': + resolution: {integrity: sha512-xD9oTGDrouWzefkhe9ogB2fDV96/82cRpNGx2HIvI5L87JHNhQVIWimQ/3JIiiX/TEd5S9s+VO6FFguwKNRVow==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@docusaurus/utils-common@3.6.3': + resolution: {integrity: sha512-v4nKDaANLgT3pMBewHYEMAl/ufY0LkXao1QkFWzI5huWFOmNQ2UFzv2BiKeHX5Ownis0/w6cAyoxPhVdDonlSQ==} + engines: {node: '>=18.0'} + + '@docusaurus/utils-validation@3.6.3': + resolution: {integrity: sha512-bhEGGiN5BE38h21vjqD70Gxg++j+PfYVddDUE5UFvLDup68QOcpD33CLr+2knPorlxRbEaNfz6HQDUMQ3HuqKw==} + engines: {node: '>=18.0'} + + '@docusaurus/utils@3.6.3': + resolution: {integrity: sha512-0R/FR3bKVl4yl8QwbL4TYFfR+OXBRpVUaTJdENapBGR3YMwfM6/JnhGilWQO8AOwPJGtGoDK7ib8+8UF9f3OZQ==} + engines: {node: '>=18.0'} + + '@hapi/hoek@9.3.0': + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + + '@hapi/topo@5.1.0': + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.6': + resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@leichtgewicht/ip-codec@2.0.5': + resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + + '@mdx-js/mdx@3.1.0': + resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} + + '@mdx-js/react@3.1.0': + resolution: {integrity: sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==} + peerDependencies: + '@types/react': '>=16' + react: '>=16' + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pnpm/config.env-replace@1.1.0': + resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} + engines: {node: '>=12.22.0'} + + '@pnpm/network.ca-file@1.0.2': + resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} + engines: {node: '>=12.22.0'} + + '@pnpm/npm-conf@2.3.1': + resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} + engines: {node: '>=12'} + + '@polka/url@1.0.0-next.28': + resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + + '@sideway/address@4.1.5': + resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} + + '@sideway/formula@3.0.1': + resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} + + '@sideway/pinpoint@2.0.0': + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@sindresorhus/is@5.6.0': + resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} + engines: {node: '>=14.16'} + + '@slorber/remark-comment@1.0.0': + resolution: {integrity: sha512-RCE24n7jsOj1M0UPvIQCHTe7fI0sFL4S2nwKVWwHyVr/wI/H8GosgsJGyhnsZoGFnD/P2hLf1mSbrrgSLN93NA==} + + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': + resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0': + resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0': + resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0': + resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0': + resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0': + resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0': + resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-svg-component@8.0.0': + resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==} + engines: {node: '>=12'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-preset@8.1.0': + resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/core@8.1.0': + resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} + engines: {node: '>=14'} + + '@svgr/hast-util-to-babel-ast@8.0.0': + resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==} + engines: {node: '>=14'} + + '@svgr/plugin-jsx@8.1.0': + resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + + '@svgr/plugin-svgo@8.1.0': + resolution: {integrity: sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + + '@svgr/webpack@8.1.0': + resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==} + engines: {node: '>=14'} + + '@szmarczak/http-timer@5.0.1': + resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} + engines: {node: '>=14.16'} + + '@trysound/sax@0.2.0': + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + + '@types/acorn@4.0.6': + resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} + + '@types/body-parser@1.19.5': + resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + + '@types/bonjour@3.5.13': + resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} + + '@types/connect-history-api-fallback@1.5.4': + resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + + '@types/express-serve-static-core@4.19.6': + resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + + '@types/express-serve-static-core@5.0.2': + resolution: {integrity: sha512-vluaspfvWEtE4vcSDlKRNer52DvOGrB2xv6diXy6UKyKW0lqZiWHGNApSyxOv+8DE5Z27IzVvE7hNkxg7EXIcg==} + + '@types/express@4.17.21': + resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + + '@types/gtag.js@0.0.12': + resolution: {integrity: sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/history@4.7.11': + resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} + + '@types/html-minifier-terser@6.1.0': + resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==} + + '@types/http-cache-semantics@4.0.4': + resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + + '@types/http-errors@2.0.4': + resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + + '@types/http-proxy@1.17.15': + resolution: {integrity: sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.13': + resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/ms@0.7.34': + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + + '@types/node-forge@1.3.11': + resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==} + + '@types/node@17.0.45': + resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} + + '@types/node@22.10.1': + resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/prismjs@1.26.5': + resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} + + '@types/prop-types@15.7.13': + resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} + + '@types/qs@6.9.17': + resolution: {integrity: sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/react-router-config@5.0.11': + resolution: {integrity: sha512-WmSAg7WgqW7m4x8Mt4N6ZyKz0BubSj/2tVUMsAHp+Yd2AMwcSbeFq9WympT19p5heCFmF97R9eD5uUR/t4HEqw==} + + '@types/react-router-dom@5.3.3': + resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==} + + '@types/react-router@5.1.20': + resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} + + '@types/react@18.3.12': + resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==} + + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + + '@types/sax@1.2.7': + resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + + '@types/send@0.17.4': + resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + + '@types/serve-index@1.9.4': + resolution: {integrity: sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==} + + '@types/serve-static@1.15.7': + resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + + '@types/sockjs@0.3.36': + resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/ws@8.5.13': + resolution: {integrity: sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.33': + resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + + '@ungap/structured-clone@1.2.0': + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + + address@1.2.2: + resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} + engines: {node: '>= 10.0.0'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + algoliasearch-helper@3.22.5: + resolution: {integrity: sha512-lWvhdnc+aKOKx8jyA3bsdEgHzm/sglC4cYdMG4xSQyRiPLJVJtH/IVYZG3Hp6PkTEhQqhyVYkeP9z2IlcHJsWw==} + peerDependencies: + algoliasearch: '>= 3.1 < 6' + + algoliasearch@4.24.0: + resolution: {integrity: sha512-bf0QV/9jVejssFBmz2HQLxUadxk574t4iwjCKp5E7NBzwKkrDEhKPISIIjAU/p6K5qDx3qoeh4+26zWN1jmw3g==} + + algoliasearch@5.15.0: + resolution: {integrity: sha512-Yf3Swz1s63hjvBVZ/9f2P1Uu48GjmjCN+Esxb6MAONMGtZB1fRX8/S1AhUTtsuTlcGovbYLxpHgc7wEzstDZBw==} + engines: {node: '>= 14.0.0'} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-html-community@0.0.8: + resolution: {integrity: sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==} + engines: {'0': node >= 0.8.0} + hasBin: true + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + autoprefixer@10.4.20: + resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + babel-loader@9.2.1: + resolution: {integrity: sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==} + engines: {node: '>= 14.15.0'} + peerDependencies: + '@babel/core': ^7.12.0 + webpack: '>=5' + + babel-plugin-dynamic-import-node@2.3.3: + resolution: {integrity: sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==} + + babel-plugin-polyfill-corejs2@0.4.12: + resolution: {integrity: sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.10.6: + resolution: {integrity: sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.3: + resolution: {integrity: sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + batch@0.6.1: + resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} + + big.js@5.2.2: + resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bonjour-service@1.3.0: + resolution: {integrity: sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boxen@6.2.1: + resolution: {integrity: sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + boxen@7.1.1: + resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} + engines: {node: '>=14.16'} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.2: + resolution: {integrity: sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.0.0: + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + engines: {node: '>= 0.8'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cacheable-lookup@7.0.0: + resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} + engines: {node: '>=14.16'} + + cacheable-request@10.2.14: + resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==} + engines: {node: '>=14.16'} + + call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + + caniuse-api@3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} + + caniuse-lite@1.0.30001684: + resolution: {integrity: sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.0.0-rc.12: + resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} + engines: {node: '>= 6'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + clean-css@5.3.3: + resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} + engines: {node: '>= 10.0'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combine-promises@1.2.0: + resolution: {integrity: sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ==} + engines: {node: '>=10'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + common-path-prefix@3.0.0: + resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.7.5: + resolution: {integrity: sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + configstore@6.0.0: + resolution: {integrity: sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==} + engines: {node: '>=12'} + + connect-history-api-fallback@2.0.0: + resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==} + engines: {node: '>=0.8'} + + consola@3.2.3: + resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@0.5.2: + resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} + engines: {node: '>= 0.6'} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + copy-text-to-clipboard@3.2.0: + resolution: {integrity: sha512-RnJFp1XR/LOBDckxTib5Qjr/PMfkatD0MUCQgdpqS8MdKiNUzBjAQBEN6oUy+jW7LI93BBG3DtMB2KOOKpGs2Q==} + engines: {node: '>=12'} + + copy-webpack-plugin@11.0.0: + resolution: {integrity: sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==} + engines: {node: '>= 14.15.0'} + peerDependencies: + webpack: ^5.1.0 + + core-js-compat@3.39.0: + resolution: {integrity: sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==} + + core-js-pure@3.39.0: + resolution: {integrity: sha512-7fEcWwKI4rJinnK+wLTezeg2smbFFdSBP6E2kQZNbnzM2s1rpKQ6aaRteZSSg7FLU3P0HGGVo/gbpfanU36urg==} + + core-js@3.39.0: + resolution: {integrity: sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cosmiconfig@6.0.0: + resolution: {integrity: sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==} + engines: {node: '>=8'} + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-random-string@4.0.0: + resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} + engines: {node: '>=12'} + + css-blank-pseudo@7.0.1: + resolution: {integrity: sha512-jf+twWGDf6LDoXDUode+nc7ZlrqfaNphrBIBrcmeP3D8yw1uPaix1gCC8LUQUGQ6CycuK2opkbFFWFuq/a94ag==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + css-declaration-sorter@7.2.0: + resolution: {integrity: sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.0.9 + + css-has-pseudo@7.0.1: + resolution: {integrity: sha512-EOcoyJt+OsuKfCADgLT7gADZI5jMzIe/AeI6MeAYKiFBDmNmM7kk46DtSfMj5AohUJisqVzopBpnQTlvbyaBWg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + css-loader@6.11.0: + resolution: {integrity: sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==} + engines: {node: '>= 12.13.0'} + peerDependencies: + '@rspack/core': 0.x || 1.x + webpack: ^5.0.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + webpack: + optional: true + + css-minimizer-webpack-plugin@5.0.1: + resolution: {integrity: sha512-3caImjKFQkS+ws1TGcFn0V1HyDJFq1Euy589JlD6/3rV2kj+w7r5G9WDMgSHvpvXHNZ2calVypZWuEDQd9wfLg==} + engines: {node: '>= 14.15.0'} + peerDependencies: + '@parcel/css': '*' + '@swc/css': '*' + clean-css: '*' + csso: '*' + esbuild: '*' + lightningcss: '*' + webpack: ^5.0.0 + peerDependenciesMeta: + '@parcel/css': + optional: true + '@swc/css': + optional: true + clean-css: + optional: true + csso: + optional: true + esbuild: + optional: true + lightningcss: + optional: true + + css-prefers-color-scheme@10.0.0: + resolution: {integrity: sha512-VCtXZAWivRglTZditUfB4StnsWr6YVZ2PRtuxQLKTNRdtAf8tpzaVPE9zXIF3VaSc7O70iK/j1+NXxyQCqdPjQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + css-select@4.3.0: + resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} + + css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + + cssdb@8.2.1: + resolution: {integrity: sha512-KwEPys7lNsC8OjASI8RrmwOYYDcm0JOW9zQhcV83ejYcQkirTEyeAGui8aO2F5PiS6SLpxuTzl6qlMElIdsgIg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssnano-preset-advanced@6.1.2: + resolution: {integrity: sha512-Nhao7eD8ph2DoHolEzQs5CfRpiEP0xa1HBdnFZ82kvqdmbwVBUr2r1QuQ4t1pi+D1ZpqpcO4T+wy/7RxzJ/WPQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + cssnano-preset-default@6.1.2: + resolution: {integrity: sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + cssnano-utils@4.0.2: + resolution: {integrity: sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + cssnano@6.1.2: + resolution: {integrity: sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.0.2: + resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-gateway@6.0.3: + resolution: {integrity: sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==} + engines: {node: '>= 10'} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + del@6.1.1: + resolution: {integrity: sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==} + engines: {node: '>=10'} + + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + detect-port-alt@1.1.6: + resolution: {integrity: sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==} + engines: {node: '>= 4.2.1'} + hasBin: true + + detect-port@1.6.1: + resolution: {integrity: sha512-CmnVc+Hek2egPx1PeTFVta2W78xy2K/9Rkf6cC4T59S50tVnzKj+tnx5mmx5lwvCkujZ4uRrpRSuV+IVs3f90Q==} + engines: {node: '>= 4.0.0'} + hasBin: true + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dns-packet@5.6.1: + resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} + engines: {node: '>=6'} + + dom-converter@0.2.0: + resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} + + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + + domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.67: + resolution: {integrity: sha512-nz88NNBsD7kQSAGGJyp8hS6xSPtWwqNogA0mjtc2nUYeEf3nURK9qpV18TuBdDmEDgVWotS8Wkzf+V52dSQ/LQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + emojilib@2.4.0: + resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + + emojis-list@3.0.0: + resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} + engines: {node: '>= 4'} + + emoticon@4.1.0: + resolution: {integrity: sha512-VWZfnxqwNcc51hIy/sbOdEem6D+cVtpPzEEtVAFdaas30+1dgkyaOQ4sQ6Bp0tOMqWO1v+HQfYaoodOkdhK6SQ==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.17.1: + resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} + engines: {node: '>=10.13.0'} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + + es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.5.4: + resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==} + + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-goat@4.0.0: + resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==} + engines: {node: '>=12'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-value-to-estree@3.2.1: + resolution: {integrity: sha512-Vt2UOjyPbNQQgT5eJh+K5aATti0OjCIAGc9SgMdOFYbohuifsWclR74l0iZTJwePMgWYdX1hlVS+dedH9XV8kw==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eta@2.2.0: + resolution: {integrity: sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==} + engines: {node: '>=6.0.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eval@0.1.8: + resolution: {integrity: sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==} + engines: {node: '>= 0.8'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + express@4.21.1: + resolution: {integrity: sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==} + engines: {node: '>= 0.10.0'} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-uri@3.0.3: + resolution: {integrity: sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==} + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + + faye-websocket@0.11.4: + resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} + engines: {node: '>=0.8.0'} + + feed@4.2.2: + resolution: {integrity: sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==} + engines: {node: '>=0.4.0'} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-loader@6.2.0: + resolution: {integrity: sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + + filesize@8.0.7: + resolution: {integrity: sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==} + engines: {node: '>= 0.4.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + + find-cache-dir@4.0.0: + resolution: {integrity: sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==} + engines: {node: '>=14.16'} + + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + find-up@6.3.0: + resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + fork-ts-checker-webpack-plugin@6.5.3: + resolution: {integrity: sha512-SbH/l9ikmMWycd5puHJKTkZJKddF4iRLyW3DeZ08HTI7NGyLS38MXd/KGgeWumQO7YNQbW2u/NtPT2YowbPaGQ==} + engines: {node: '>=10', yarn: '>=1.0.0'} + peerDependencies: + eslint: '>= 6' + typescript: '>= 2.7' + vue-template-compiler: '*' + webpack: '>= 4' + peerDependenciesMeta: + eslint: + optional: true + vue-template-compiler: + optional: true + + form-data-encoder@2.1.4: + resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} + engines: {node: '>= 14.17'} + + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-extra@11.2.0: + resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} + engines: {node: '>=14.14'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-monkey@1.0.6: + resolution: {integrity: sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + + get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + github-slugger@1.5.0: + resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globby@13.2.2: + resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + gopd@1.1.0: + resolution: {integrity: sha512-FQoVQnqcdk4hVM4JN1eromaun4iuS34oStkdlLENLdpULsuQcTyXj8w7ayhuUfPwEYZ1ZOooOTT6fdA9Vmx/RA==} + engines: {node: '>= 0.4'} + + got@12.6.1: + resolution: {integrity: sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==} + engines: {node: '>=14.16'} + + graceful-fs@4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + + handle-thing@2.0.1: + resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + + has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + has-yarn@3.0.0: + resolution: {integrity: sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hast-util-from-parse5@8.0.2: + resolution: {integrity: sha512-SfMzfdAi/zAoZ1KkFEyyeXBn7u/ShQrfd675ZEE9M3qj+PMFX05xubzRyF76CCSJu8au9jgVxDV1+okFvgZU4A==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-estree@3.1.0: + resolution: {integrity: sha512-lfX5g6hqVh9kjS/B9E2gSkvHH4SZNiQFiqWS0x9fENzEl+8W12RqdRxX6d/Cwxi30tPQs3bIO+aolQJNp1bIyw==} + + hast-util-to-jsx-runtime@2.3.2: + resolution: {integrity: sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==} + + hast-util-to-parse5@8.0.0: + resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.0: + resolution: {integrity: sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw==} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + history@4.10.1: + resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + hpack.js@2.1.6: + resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} + + html-entities@2.5.2: + resolution: {integrity: sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-minifier-terser@6.1.0: + resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==} + engines: {node: '>=12'} + hasBin: true + + html-minifier-terser@7.2.0: + resolution: {integrity: sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==} + engines: {node: ^14.13.1 || >=16.0.0} + hasBin: true + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html-webpack-plugin@5.6.3: + resolution: {integrity: sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==} + engines: {node: '>=10.13.0'} + peerDependencies: + '@rspack/core': 0.x || 1.x + webpack: ^5.20.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + webpack: + optional: true + + htmlparser2@6.1.0: + resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + http-cache-semantics@4.1.1: + resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + + http-deceiver@1.2.7: + resolution: {integrity: sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==} + + http-errors@1.6.3: + resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==} + engines: {node: '>= 0.6'} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + http-parser-js@0.5.8: + resolution: {integrity: sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==} + + http-proxy-middleware@2.0.7: + resolution: {integrity: sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/express': ^4.17.13 + peerDependenciesMeta: + '@types/express': + optional: true + + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + http2-wrapper@2.2.1: + resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} + engines: {node: '>=10.19.0'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + icss-utils@5.1.0: + resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + image-size@1.1.1: + resolution: {integrity: sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==} + engines: {node: '>=16.x'} + hasBin: true + + immer@9.0.21: + resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + infima@0.2.0-alpha.45: + resolution: {integrity: sha512-uyH0zfr1erU1OohLk0fT4Rrb94AOhguWNOcD9uGrSpRvNB+6gZXUoJX5J0NtvzBO10YZ9PgvA4NFgt+fYg8ojw==} + engines: {node: '>=12'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.3: + resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + + inline-style-parser@0.1.1: + resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} + + inline-style-parser@0.2.4: + resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + + interpret@1.4.0: + resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} + engines: {node: '>= 0.10'} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.2.0: + resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} + engines: {node: '>= 10'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-ci@3.0.1: + resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} + hasBin: true + + is-core-module@2.15.1: + resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} + engines: {node: '>= 0.4'} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-installed-globally@0.4.0: + resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} + engines: {node: '>=10'} + + is-npm@6.0.0: + resolution: {integrity: sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-path-cwd@2.2.0: + resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==} + engines: {node: '>=6'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-plain-obj@3.0.0: + resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} + engines: {node: '>=10'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + + is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + + is-root@2.1.0: + resolution: {integrity: sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==} + engines: {node: '>=6'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-yarn-global@0.4.1: + resolution: {integrity: sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==} + engines: {node: '>=12'} + + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jiti@1.21.6: + resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} + hasBin: true + + joi@17.13.3: + resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsesc@3.0.2: + resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + latest-version@7.0.0: + resolution: {integrity: sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==} + engines: {node: '>=14.16'} + + launch-editor@2.9.1: + resolution: {integrity: sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + lilconfig@3.1.2: + resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + loader-runner@4.3.0: + resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} + engines: {node: '>=6.11.5'} + + loader-utils@2.0.4: + resolution: {integrity: sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==} + engines: {node: '>=8.9.0'} + + loader-utils@3.3.1: + resolution: {integrity: sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==} + engines: {node: '>= 12.13.0'} + + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + + lowercase-keys@3.0.0: + resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + markdown-table@2.0.0: + resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-directive@3.0.0: + resolution: {integrity: sha512-JUpYOqKI4mM3sZcNxmF/ox04XYFFkNwr0CFlrQIkCwbvH0xzMCqkMqAde9wRd80VAhaUrwFwKm2nxretdT1h7Q==} + + mdast-util-find-and-replace@3.0.1: + resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==} + + mdast-util-from-markdown@2.0.2: + resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.0.0: + resolution: {integrity: sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.0.0: + resolution: {integrity: sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.1.3: + resolution: {integrity: sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.0: + resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromark-core-commonmark@2.0.2: + resolution: {integrity: sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w==} + + micromark-extension-directive@3.0.2: + resolution: {integrity: sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.0: + resolution: {integrity: sha512-Ub2ncQv+fwD70/l4ou27b4YzfNaCJOvyX4HxXU15m7mpYY+rjuWzsLIPZHJL253Z643RpbcP1oeIJlQ/SKW67g==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-mdx-expression@3.0.0: + resolution: {integrity: sha512-sI0nwhUDz97xyzqJAbHQhp5TfaxEvZZZ2JDqUo+7NvyIYG6BZ5CPPqj2ogUoPJlmXHBnyZUzISg9+oUmU6tUjQ==} + + micromark-extension-mdx-jsx@3.0.1: + resolution: {integrity: sha512-vNuFb9czP8QCtAQcEJn0UJQJZA8Dk6DXKBqx+bg/w0WGuSxDxNr7hErW89tHUY31dUW4NqEOWwmEUNhjTFmHkg==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.2: + resolution: {integrity: sha512-5E5I2pFzJyg2CtemqAbcyCktpHXuJbABnsb32wX2U8IQKhhVFBqkcZR5LRm1WVoFqa4kTueZK4abep7wdo9nrw==} + + micromark-factory-space@1.1.0: + resolution: {integrity: sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@1.2.0: + resolution: {integrity: sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.2: + resolution: {integrity: sha512-Fk+xmBrOv9QZnEDguL9OI9/NQQp6Hz4FuQ4YmCb/5V7+9eAh1s6AYSvL20kHkD67YIg7EpE54TiSlcsf3vyZgA==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.0.3: + resolution: {integrity: sha512-VXJJuNxYWSoYL6AJ6OQECCFGhIU2GGHMw8tahogePBrjkG8aCCas3ibkp7RnVOSTClg2is05/R7maAhF1XyQMg==} + + micromark-util-symbol@1.1.0: + resolution: {integrity: sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@1.1.0: + resolution: {integrity: sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==} + + micromark-util-types@2.0.1: + resolution: {integrity: sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ==} + + micromark@4.0.1: + resolution: {integrity: sha512-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.33.0: + resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.53.0: + resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.18: + resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + mimic-response@4.0.0: + resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + mini-css-extract-plugin@2.9.2: + resolution: {integrity: sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^5.0.0 + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multicast-dns@7.2.5: + resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} + hasBin: true + + nanoid@3.3.8: + resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + + node-emoji@2.1.3: + resolution: {integrity: sha512-E2WEOVsgs7O16zsURJ/eH8BqhF029wGpEOnv7Urwdo2wmQanOACwJQh0devF9D9RhoZru0+9JXIS0dBXIAz+lA==} + engines: {node: '>=18'} + + node-forge@1.3.1: + resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} + engines: {node: '>= 6.13.0'} + + node-releases@2.0.18: + resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + normalize-url@8.0.1: + resolution: {integrity: sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==} + engines: {node: '>=14.16'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nprogress@0.2.0: + resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + null-loader@4.0.1: + resolution: {integrity: sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.3: + resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.5: + resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} + engines: {node: '>= 0.4'} + + obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + opener@1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + + p-cancelable@3.0.0: + resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} + engines: {node: '>=12.20'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json@8.1.1: + resolution: {integrity: sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==} + engines: {node: '>=14.16'} + + param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-entities@4.0.1: + resolution: {integrity: sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-numeric-range@1.3.0: + resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==} + + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5@7.2.1: + resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-is-inside@1.0.2: + resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@0.1.10: + resolution: {integrity: sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==} + + path-to-regexp@1.9.0: + resolution: {integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==} + + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pkg-dir@7.0.0: + resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} + engines: {node: '>=14.16'} + + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + + postcss-attribute-case-insensitive@7.0.1: + resolution: {integrity: sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-calc@9.0.1: + resolution: {integrity: sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.2.2 + + postcss-clamp@4.1.0: + resolution: {integrity: sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==} + engines: {node: '>=7.6.0'} + peerDependencies: + postcss: ^8.4.6 + + postcss-color-functional-notation@7.0.6: + resolution: {integrity: sha512-wLXvm8RmLs14Z2nVpB4CWlnvaWPRcOZFltJSlcbYwSJ1EDZKsKDhPKIMecCnuU054KSmlmubkqczmm6qBPCBhA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-color-hex-alpha@10.0.0: + resolution: {integrity: sha512-1kervM2cnlgPs2a8Vt/Qbe5cQ++N7rkYo/2rz2BkqJZIHQwaVuJgQH38REHrAi4uM0b1fqxMkWYmese94iMp3w==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-color-rebeccapurple@10.0.0: + resolution: {integrity: sha512-JFta737jSP+hdAIEhk1Vs0q0YF5P8fFcj+09pweS8ktuGuZ8pPlykHsk6mPxZ8awDl4TrcxUqJo9l1IhVr/OjQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-colormin@6.1.0: + resolution: {integrity: sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-convert-values@6.1.0: + resolution: {integrity: sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-custom-media@11.0.5: + resolution: {integrity: sha512-SQHhayVNgDvSAdX9NQ/ygcDQGEY+aSF4b/96z7QUX6mqL5yl/JgG/DywcF6fW9XbnCRE+aVYk+9/nqGuzOPWeQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-custom-properties@14.0.4: + resolution: {integrity: sha512-QnW8FCCK6q+4ierwjnmXF9Y9KF8q0JkbgVfvQEMa93x1GT8FvOiUevWCN2YLaOWyByeDX8S6VFbZEeWoAoXs2A==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-custom-selectors@8.0.4: + resolution: {integrity: sha512-ASOXqNvDCE0dAJ/5qixxPeL1aOVGHGW2JwSy7HyjWNbnWTQCl+fDc968HY1jCmZI0+BaYT5CxsOiUhavpG/7eg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-dir-pseudo-class@9.0.1: + resolution: {integrity: sha512-tRBEK0MHYvcMUrAuYMEOa0zg9APqirBcgzi6P21OhxtJyJADo/SWBwY1CAwEohQ/6HDaa9jCjLRG7K3PVQYHEA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-discard-comments@6.0.2: + resolution: {integrity: sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-discard-duplicates@6.0.3: + resolution: {integrity: sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-discard-empty@6.0.3: + resolution: {integrity: sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-discard-overridden@6.0.2: + resolution: {integrity: sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-discard-unused@6.0.5: + resolution: {integrity: sha512-wHalBlRHkaNnNwfC8z+ppX57VhvS+HWgjW508esjdaEYr3Mx7Gnn2xA4R/CKf5+Z9S5qsqC+Uzh4ueENWwCVUA==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-double-position-gradients@6.0.0: + resolution: {integrity: sha512-JkIGah3RVbdSEIrcobqj4Gzq0h53GG4uqDPsho88SgY84WnpkTpI0k50MFK/sX7XqVisZ6OqUfFnoUO6m1WWdg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-focus-visible@10.0.1: + resolution: {integrity: sha512-U58wyjS/I1GZgjRok33aE8juW9qQgQUNwTSdxQGuShHzwuYdcklnvK/+qOWX1Q9kr7ysbraQ6ht6r+udansalA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-focus-within@9.0.1: + resolution: {integrity: sha512-fzNUyS1yOYa7mOjpci/bR+u+ESvdar6hk8XNK/TRR0fiGTp2QT5N+ducP0n3rfH/m9I7H/EQU6lsa2BrgxkEjw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-font-variant@5.0.0: + resolution: {integrity: sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==} + peerDependencies: + postcss: ^8.1.0 + + postcss-gap-properties@6.0.0: + resolution: {integrity: sha512-Om0WPjEwiM9Ru+VhfEDPZJAKWUd0mV1HmNXqp2C29z80aQ2uP9UVhLc7e3aYMIor/S5cVhoPgYQ7RtfeZpYTRw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-image-set-function@7.0.0: + resolution: {integrity: sha512-QL7W7QNlZuzOwBTeXEmbVckNt1FSmhQtbMRvGGqqU4Nf4xk6KUEQhAoWuMzwbSv5jxiRiSZ5Tv7eiDB9U87znA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-lab-function@7.0.6: + resolution: {integrity: sha512-HPwvsoK7C949vBZ+eMyvH2cQeMr3UREoHvbtra76/UhDuiViZH6pir+z71UaJQohd7VDSVUdR6TkWYKExEc9aQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-loader@7.3.4: + resolution: {integrity: sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A==} + engines: {node: '>= 14.15.0'} + peerDependencies: + postcss: ^7.0.0 || ^8.0.1 + webpack: ^5.0.0 + + postcss-logical@8.0.0: + resolution: {integrity: sha512-HpIdsdieClTjXLOyYdUPAX/XQASNIwdKt5hoZW08ZOAiI+tbV0ta1oclkpVkW5ANU+xJvk3KkA0FejkjGLXUkg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-merge-idents@6.0.3: + resolution: {integrity: sha512-1oIoAsODUs6IHQZkLQGO15uGEbK3EAl5wi9SS8hs45VgsxQfMnxvt+L+zIr7ifZFIH14cfAeVe2uCTa+SPRa3g==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-merge-longhand@6.0.5: + resolution: {integrity: sha512-5LOiordeTfi64QhICp07nzzuTDjNSO8g5Ksdibt44d+uvIIAE1oZdRn8y/W5ZtYgRH/lnLDlvi9F8btZcVzu3w==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-merge-rules@6.1.1: + resolution: {integrity: sha512-KOdWF0gju31AQPZiD+2Ar9Qjowz1LTChSjFFbS+e2sFgc4uHOp3ZvVX4sNeTlk0w2O31ecFGgrFzhO0RSWbWwQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-minify-font-values@6.1.0: + resolution: {integrity: sha512-gklfI/n+9rTh8nYaSJXlCo3nOKqMNkxuGpTn/Qm0gstL3ywTr9/WRKznE+oy6fvfolH6dF+QM4nCo8yPLdvGJg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-minify-gradients@6.0.3: + resolution: {integrity: sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-minify-params@6.1.0: + resolution: {integrity: sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-minify-selectors@6.0.4: + resolution: {integrity: sha512-L8dZSwNLgK7pjTto9PzWRoMbnLq5vsZSTu8+j1P/2GB8qdtGQfn+K1uSvFgYvgh83cbyxT5m43ZZhUMTJDSClQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-modules-extract-imports@3.1.0: + resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-local-by-default@4.1.0: + resolution: {integrity: sha512-rm0bdSv4jC3BDma3s9H19ZddW0aHX6EoqwDYU2IfZhRN+53QrufTRo2IdkAbRqLx4R2IYbZnbjKKxg4VN5oU9Q==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-scope@3.2.1: + resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-values@4.0.0: + resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-nesting@13.0.1: + resolution: {integrity: sha512-VbqqHkOBOt4Uu3G8Dm8n6lU5+9cJFxiuty9+4rcoyRPO9zZS1JIs6td49VIoix3qYqELHlJIn46Oih9SAKo+yQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-normalize-charset@6.0.2: + resolution: {integrity: sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-display-values@6.0.2: + resolution: {integrity: sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-positions@6.0.2: + resolution: {integrity: sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-repeat-style@6.0.2: + resolution: {integrity: sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-string@6.0.2: + resolution: {integrity: sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-timing-functions@6.0.2: + resolution: {integrity: sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-unicode@6.1.0: + resolution: {integrity: sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-url@6.0.2: + resolution: {integrity: sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-whitespace@6.0.2: + resolution: {integrity: sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-opacity-percentage@3.0.0: + resolution: {integrity: sha512-K6HGVzyxUxd/VgZdX04DCtdwWJ4NGLG212US4/LA1TLAbHgmAsTWVR86o+gGIbFtnTkfOpb9sCRBx8K7HO66qQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-ordered-values@6.0.2: + resolution: {integrity: sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-overflow-shorthand@6.0.0: + resolution: {integrity: sha512-BdDl/AbVkDjoTofzDQnwDdm/Ym6oS9KgmO7Gr+LHYjNWJ6ExORe4+3pcLQsLA9gIROMkiGVjjwZNoL/mpXHd5Q==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-page-break@3.0.4: + resolution: {integrity: sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==} + peerDependencies: + postcss: ^8 + + postcss-place@10.0.0: + resolution: {integrity: sha512-5EBrMzat2pPAxQNWYavwAfoKfYcTADJ8AXGVPcUZ2UkNloUTWzJQExgrzrDkh3EKzmAx1evfTAzF9I8NGcc+qw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-preset-env@10.1.1: + resolution: {integrity: sha512-wqqsnBFD6VIwcHHRbhjTOcOi4qRVlB26RwSr0ordPj7OubRRxdWebv/aLjKLRR8zkZrbxZyuus03nOIgC5elMQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-pseudo-class-any-link@10.0.1: + resolution: {integrity: sha512-3el9rXlBOqTFaMFkWDOkHUTQekFIYnaQY55Rsp8As8QQkpiSgIYEcF/6Ond93oHiDsGb4kad8zjt+NPlOC1H0Q==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-reduce-idents@6.0.3: + resolution: {integrity: sha512-G3yCqZDpsNPoQgbDUy3T0E6hqOQ5xigUtBQyrmq3tn2GxlyiL0yyl7H+T8ulQR6kOcHJ9t7/9H4/R2tv8tJbMA==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-reduce-initial@6.1.0: + resolution: {integrity: sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-reduce-transforms@6.0.2: + resolution: {integrity: sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-replace-overflow-wrap@4.0.0: + resolution: {integrity: sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==} + peerDependencies: + postcss: ^8.0.3 + + postcss-selector-not@8.0.1: + resolution: {integrity: sha512-kmVy/5PYVb2UOhy0+LqUYAhKj7DUGDpSWa5LZqlkWJaaAV+dxxsOG3+St0yNLu6vsKD7Dmqx+nWQt0iil89+WA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-selector-parser@7.0.0: + resolution: {integrity: sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==} + engines: {node: '>=4'} + + postcss-sort-media-queries@5.2.0: + resolution: {integrity: sha512-AZ5fDMLD8SldlAYlvi8NIqo0+Z8xnXU2ia0jxmuhxAU+Lqt9K+AlmLNJ/zWEnE9x+Zx3qL3+1K20ATgNOr3fAA==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.4.23 + + postcss-svgo@6.0.3: + resolution: {integrity: sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==} + engines: {node: ^14 || ^16 || >= 18} + peerDependencies: + postcss: ^8.4.31 + + postcss-unique-selectors@6.0.4: + resolution: {integrity: sha512-K38OCaIrO8+PzpArzkLKB42dSARtC2tmG6PvD4b1o1Q2E9Os8jzfWFfSy/rixsHwohtsDdFtAWGjFVFUdwYaMg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss-zindex@6.0.2: + resolution: {integrity: sha512-5BxW9l1evPB/4ZIc+2GobEBoKC+h8gPGCMi+jxsYvd2x0mjq7wazk6DrP71pStqxE9Foxh5TVnonbWpFZzXaYg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss@8.4.49: + resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + engines: {node: ^10 || ^12 || >=14} + + pretty-error@4.0.0: + resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==} + + pretty-time@1.1.0: + resolution: {integrity: sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==} + engines: {node: '>=4'} + + prism-react-renderer@2.4.0: + resolution: {integrity: sha512-327BsVCD/unU4CNLZTWVHyUHKnsqcvj2qbPlQ8MiBE2eq2rgctjigPA1Gp9HLF83kZ20zNN6jgizHJeEsyFYOw==} + peerDependencies: + react: '>=16.0.0' + + prismjs@1.29.0: + resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} + engines: {node: '>=6'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + property-information@6.5.0: + resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pupa@3.1.0: + resolution: {integrity: sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==} + engines: {node: '>=12.20'} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + range-parser@1.2.0: + resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} + engines: {node: '>= 0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dev-utils@12.0.1: + resolution: {integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=2.7' + webpack: '>=4' + peerDependenciesMeta: + typescript: + optional: true + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-error-overlay@6.0.11: + resolution: {integrity: sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==} + + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-helmet-async@1.3.0: + resolution: {integrity: sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==} + peerDependencies: + react: ^16.6.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.6.0 || ^17.0.0 || ^18.0.0 + + react-helmet-async@2.0.5: + resolution: {integrity: sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==} + peerDependencies: + react: ^16.6.0 || ^17.0.0 || ^18.0.0 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-json-view-lite@1.5.0: + resolution: {integrity: sha512-nWqA1E4jKPklL2jvHWs6s+7Na0qNgw9HCP6xehdQJeg6nPBTFZgGwyko9Q0oj+jQWKTTVRS30u0toM5wiuL3iw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + + react-loadable-ssr-addon-v5-slorber@1.0.1: + resolution: {integrity: sha512-lq3Lyw1lGku8zUEJPDxsNm1AfYHBrO9Y1+olAYwpUJ2IGFBskM0DMKok97A6LWUpHm+o7IvQBOWu9MLenp9Z+A==} + engines: {node: '>=10.13.0'} + peerDependencies: + react-loadable: '*' + webpack: '>=4.41.1 || 5.x' + + react-router-config@5.1.1: + resolution: {integrity: sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==} + peerDependencies: + react: '>=15' + react-router: '>=5' + + react-router-dom@5.3.4: + resolution: {integrity: sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==} + peerDependencies: + react: '>=15' + + react-router@5.3.4: + resolution: {integrity: sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==} + peerDependencies: + react: '>=15' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + reading-time@1.5.0: + resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==} + + rechoir@0.6.2: + resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} + engines: {node: '>= 0.10'} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.0: + resolution: {integrity: sha512-5vwkv65qWwYxg+Atz95acp8DMu1JDSqdGkA2Of1j6rCreyFUE/gp15fC8MnGEuG1W68UKjM6x6+YTWIh7hZM/Q==} + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + + recursive-readdir@2.2.3: + resolution: {integrity: sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==} + engines: {node: '>=6.0.0'} + + regenerate-unicode-properties@10.2.0: + resolution: {integrity: sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + regenerator-transform@0.15.2: + resolution: {integrity: sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==} + + regexpu-core@6.2.0: + resolution: {integrity: sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==} + engines: {node: '>=4'} + + registry-auth-token@5.0.3: + resolution: {integrity: sha512-1bpc9IyC+e+CNFRaWyn77tk4xGG4PPUyfakSmA6F6cvUDjrm58dfyJ3II+9yb10EDkHoy1LaPSmHaWLOH3m6HA==} + engines: {node: '>=14'} + + registry-url@6.0.1: + resolution: {integrity: sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==} + engines: {node: '>=12'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.12.0: + resolution: {integrity: sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==} + hasBin: true + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + relateurl@0.2.7: + resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} + engines: {node: '>= 0.10'} + + remark-directive@3.0.0: + resolution: {integrity: sha512-l1UyWJ6Eg1VPU7Hm/9tt0zKtReJQNOA4+iDMAxTyZNWnJnFlbS/7zhiel/rogTLQ2vMYwDzSJa4BiVNqGlqIMA==} + + remark-emoji@4.0.1: + resolution: {integrity: sha512-fHdvsTR1dHkWKev9eNyhTo4EFwbUvJ8ka9SgeWkMPYFX4WoI7ViVBms3PjlQYgw5TLvNQso3GUB/b/8t3yo+dg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + remark-frontmatter@5.0.0: + resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} + + remark-gfm@4.0.0: + resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==} + + remark-mdx@3.1.0: + resolution: {integrity: sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.1: + resolution: {integrity: sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + renderkid@3.0.0: + resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==} + + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-like@0.1.2: + resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pathname@3.0.0: + resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==} + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + responselike@3.0.0: + resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} + engines: {node: '>=14.16'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rtl-detect@1.1.2: + resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==} + + rtlcss@4.3.0: + resolution: {integrity: sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==} + engines: {node: '>=12.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + schema-utils@2.7.0: + resolution: {integrity: sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==} + engines: {node: '>= 8.9.0'} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.2.0: + resolution: {integrity: sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==} + engines: {node: '>= 12.13.0'} + + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + + select-hose@2.0.0: + resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==} + + selfsigned@2.4.1: + resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==} + engines: {node: '>=10'} + + semver-diff@4.0.0: + resolution: {integrity: sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==} + engines: {node: '>=12'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + serve-handler@6.1.6: + resolution: {integrity: sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==} + + serve-index@1.9.1: + resolution: {integrity: sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setprototypeof@1.1.0: + resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.2: + resolution: {integrity: sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==} + engines: {node: '>= 0.4'} + + shelljs@0.8.5: + resolution: {integrity: sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==} + engines: {node: '>=4'} + hasBin: true + + side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sitemap@7.1.2: + resolution: {integrity: sha512-ARCqzHJ0p4gWt+j7NlU5eDlIO9+Rkr/JhPFZKKQ1l5GCus7rJH4UdrlVAh0xC/gDS/Qir2UMxqYNHtsKr2rpCw==} + engines: {node: '>=12.0.0', npm: '>=5.6.0'} + hasBin: true + + skin-tone@2.0.0: + resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} + engines: {node: '>=8'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@4.0.0: + resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} + engines: {node: '>=12'} + + snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + + sockjs@0.3.24: + resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==} + + sort-css-media-queries@2.2.0: + resolution: {integrity: sha512-0xtkGhWCC9MGt/EzgnvbbbKhqWjl1+/rncmhTh5qCpbYguXh6S/qwePfv/JQ8jePXXmqingylxoC49pCkSPIbA==} + engines: {node: '>= 6.3.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + spdy-transport@3.0.0: + resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==} + + spdy@4.0.2: + resolution: {integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==} + engines: {node: '>=6.0.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + srcset@4.0.0: + resolution: {integrity: sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==} + engines: {node: '>=12'} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + std-env@3.8.0: + resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + style-to-object@0.4.4: + resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} + + style-to-object@1.0.8: + resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==} + + stylehacks@6.1.1: + resolution: {integrity: sha512-gSTTEQ670cJNoaeIp9KX6lZmm8LJ3jPB5yJmX8Zq/wQxOsAFXV3qjWzHas3YYk1qesuVIyYWWUpZ0vSE/dTSGg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg-parser@2.0.4: + resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + + svgo@3.3.2: + resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} + engines: {node: '>=14.0.0'} + hasBin: true + + tapable@1.1.3: + resolution: {integrity: sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==} + engines: {node: '>=6'} + + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + + terser-webpack-plugin@5.3.10: + resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + + terser@5.36.0: + resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==} + engines: {node: '>=10'} + hasBin: true + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thunky@1.1.0: + resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@1.4.0: + resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} + engines: {node: '>=10'} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typedarray-to-buffer@3.1.5: + resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + + typescript@5.2.2: + resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-emoji-modifier-base@1.0.0: + resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.0: + resolution: {integrity: sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.1.0: + resolution: {integrity: sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==} + engines: {node: '>=4'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unique-string@3.0.0: + resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} + engines: {node: '>=12'} + + unist-util-is@6.0.0: + resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.1: + resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.1.1: + resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + update-notifier@6.0.2: + resolution: {integrity: sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==} + engines: {node: '>=14.16'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-loader@4.1.1: + resolution: {integrity: sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==} + engines: {node: '>= 10.13.0'} + peerDependencies: + file-loader: '*' + webpack: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + file-loader: + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utila@0.4.0: + resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==} + + utility-types@3.11.0: + resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} + engines: {node: '>= 4'} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + value-equal@1.0.1: + resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.2: + resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + watchpack@2.4.2: + resolution: {integrity: sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==} + engines: {node: '>=10.13.0'} + + wbuf@1.7.3: + resolution: {integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + webpack-bundle-analyzer@4.10.2: + resolution: {integrity: sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==} + engines: {node: '>= 10.13.0'} + hasBin: true + + webpack-dev-middleware@5.3.4: + resolution: {integrity: sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + + webpack-dev-server@4.15.2: + resolution: {integrity: sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==} + engines: {node: '>= 12.13.0'} + hasBin: true + peerDependencies: + webpack: ^4.37.0 || ^5.0.0 + webpack-cli: '*' + peerDependenciesMeta: + webpack: + optional: true + webpack-cli: + optional: true + + webpack-merge@5.10.0: + resolution: {integrity: sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==} + engines: {node: '>=10.0.0'} + + webpack-merge@6.0.1: + resolution: {integrity: sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==} + engines: {node: '>=18.0.0'} + + webpack-sources@3.2.3: + resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} + engines: {node: '>=10.13.0'} + + webpack@5.96.1: + resolution: {integrity: sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + webpackbar@6.0.1: + resolution: {integrity: sha512-TnErZpmuKdwWBdMoexjio3KKX6ZtoKHRVvLIU0A47R0VVBDtx3ZyOJDktgYixhoJokZTYTt1Z37OkO9pnGJa9Q==} + engines: {node: '>=14.21.3'} + peerDependencies: + webpack: 3 || 4 || 5 + + websocket-driver@0.7.4: + resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + engines: {node: '>=0.8.0'} + + websocket-extensions@0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + + wildcard@2.0.1: + resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@3.0.3: + resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} + + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xdg-basedir@5.1.0: + resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} + engines: {node: '>=12'} + + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-queue@1.1.1: + resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} + engines: {node: '>=12.20'} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.15.0)(algoliasearch@5.15.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.15.0)(algoliasearch@5.15.0)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.15.0)(algoliasearch@5.15.0) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.15.0)(algoliasearch@5.15.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.15.0)(algoliasearch@5.15.0) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.15.0)(algoliasearch@5.15.0)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.15.0)(algoliasearch@5.15.0) + '@algolia/client-search': 5.15.0 + algoliasearch: 5.15.0 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.15.0)(algoliasearch@5.15.0)': + dependencies: + '@algolia/client-search': 5.15.0 + algoliasearch: 5.15.0 + + '@algolia/cache-browser-local-storage@4.24.0': + dependencies: + '@algolia/cache-common': 4.24.0 + + '@algolia/cache-common@4.24.0': {} + + '@algolia/cache-in-memory@4.24.0': + dependencies: + '@algolia/cache-common': 4.24.0 + + '@algolia/client-abtesting@5.15.0': + dependencies: + '@algolia/client-common': 5.15.0 + '@algolia/requester-browser-xhr': 5.15.0 + '@algolia/requester-fetch': 5.15.0 + '@algolia/requester-node-http': 5.15.0 + + '@algolia/client-account@4.24.0': + dependencies: + '@algolia/client-common': 4.24.0 + '@algolia/client-search': 4.24.0 + '@algolia/transporter': 4.24.0 + + '@algolia/client-analytics@4.24.0': + dependencies: + '@algolia/client-common': 4.24.0 + '@algolia/client-search': 4.24.0 + '@algolia/requester-common': 4.24.0 + '@algolia/transporter': 4.24.0 + + '@algolia/client-analytics@5.15.0': + dependencies: + '@algolia/client-common': 5.15.0 + '@algolia/requester-browser-xhr': 5.15.0 + '@algolia/requester-fetch': 5.15.0 + '@algolia/requester-node-http': 5.15.0 + + '@algolia/client-common@4.24.0': + dependencies: + '@algolia/requester-common': 4.24.0 + '@algolia/transporter': 4.24.0 + + '@algolia/client-common@5.15.0': {} + + '@algolia/client-insights@5.15.0': + dependencies: + '@algolia/client-common': 5.15.0 + '@algolia/requester-browser-xhr': 5.15.0 + '@algolia/requester-fetch': 5.15.0 + '@algolia/requester-node-http': 5.15.0 + + '@algolia/client-personalization@4.24.0': + dependencies: + '@algolia/client-common': 4.24.0 + '@algolia/requester-common': 4.24.0 + '@algolia/transporter': 4.24.0 + + '@algolia/client-personalization@5.15.0': + dependencies: + '@algolia/client-common': 5.15.0 + '@algolia/requester-browser-xhr': 5.15.0 + '@algolia/requester-fetch': 5.15.0 + '@algolia/requester-node-http': 5.15.0 + + '@algolia/client-query-suggestions@5.15.0': + dependencies: + '@algolia/client-common': 5.15.0 + '@algolia/requester-browser-xhr': 5.15.0 + '@algolia/requester-fetch': 5.15.0 + '@algolia/requester-node-http': 5.15.0 + + '@algolia/client-search@4.24.0': + dependencies: + '@algolia/client-common': 4.24.0 + '@algolia/requester-common': 4.24.0 + '@algolia/transporter': 4.24.0 + + '@algolia/client-search@5.15.0': + dependencies: + '@algolia/client-common': 5.15.0 + '@algolia/requester-browser-xhr': 5.15.0 + '@algolia/requester-fetch': 5.15.0 + '@algolia/requester-node-http': 5.15.0 + + '@algolia/events@4.0.1': {} + + '@algolia/ingestion@1.15.0': + dependencies: + '@algolia/client-common': 5.15.0 + '@algolia/requester-browser-xhr': 5.15.0 + '@algolia/requester-fetch': 5.15.0 + '@algolia/requester-node-http': 5.15.0 + + '@algolia/logger-common@4.24.0': {} + + '@algolia/logger-console@4.24.0': + dependencies: + '@algolia/logger-common': 4.24.0 + + '@algolia/monitoring@1.15.0': + dependencies: + '@algolia/client-common': 5.15.0 + '@algolia/requester-browser-xhr': 5.15.0 + '@algolia/requester-fetch': 5.15.0 + '@algolia/requester-node-http': 5.15.0 + + '@algolia/recommend@4.24.0': + dependencies: + '@algolia/cache-browser-local-storage': 4.24.0 + '@algolia/cache-common': 4.24.0 + '@algolia/cache-in-memory': 4.24.0 + '@algolia/client-common': 4.24.0 + '@algolia/client-search': 4.24.0 + '@algolia/logger-common': 4.24.0 + '@algolia/logger-console': 4.24.0 + '@algolia/requester-browser-xhr': 4.24.0 + '@algolia/requester-common': 4.24.0 + '@algolia/requester-node-http': 4.24.0 + '@algolia/transporter': 4.24.0 + + '@algolia/recommend@5.15.0': + dependencies: + '@algolia/client-common': 5.15.0 + '@algolia/requester-browser-xhr': 5.15.0 + '@algolia/requester-fetch': 5.15.0 + '@algolia/requester-node-http': 5.15.0 + + '@algolia/requester-browser-xhr@4.24.0': + dependencies: + '@algolia/requester-common': 4.24.0 + + '@algolia/requester-browser-xhr@5.15.0': + dependencies: + '@algolia/client-common': 5.15.0 + + '@algolia/requester-common@4.24.0': {} + + '@algolia/requester-fetch@5.15.0': + dependencies: + '@algolia/client-common': 5.15.0 + + '@algolia/requester-node-http@4.24.0': + dependencies: + '@algolia/requester-common': 4.24.0 + + '@algolia/requester-node-http@5.15.0': + dependencies: + '@algolia/client-common': 5.15.0 + + '@algolia/transporter@4.24.0': + dependencies: + '@algolia/cache-common': 4.24.0 + '@algolia/logger-common': 4.24.0 + '@algolia/requester-common': 4.24.0 + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + + '@babel/code-frame@7.26.2': + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.26.2': {} + + '@babel/core@7.26.0': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.2 + '@babel/helper-compilation-targets': 7.25.9 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) + '@babel/helpers': 7.26.0 + '@babel/parser': 7.26.2 + '@babel/template': 7.25.9 + '@babel/traverse': 7.25.9 + '@babel/types': 7.26.0 + convert-source-map: 2.0.0 + debug: 4.3.7 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.26.2': + dependencies: + '@babel/parser': 7.26.2 + '@babel/types': 7.26.0 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.0.2 + + '@babel/helper-annotate-as-pure@7.25.9': + dependencies: + '@babel/types': 7.26.0 + + '@babel/helper-builder-binary-assignment-operator-visitor@7.25.9': + dependencies: + '@babel/traverse': 7.25.9 + '@babel/types': 7.26.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-compilation-targets@7.25.9': + dependencies: + '@babel/compat-data': 7.26.2 + '@babel/helper-validator-option': 7.25.9 + browserslist: 4.24.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-annotate-as-pure': 7.25.9 + '@babel/helper-member-expression-to-functions': 7.25.9 + '@babel/helper-optimise-call-expression': 7.25.9 + '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 + '@babel/traverse': 7.25.9 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-annotate-as-pure': 7.25.9 + regexpu-core: 6.2.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.3(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-compilation-targets': 7.25.9 + '@babel/helper-plugin-utils': 7.25.9 + debug: 4.3.7 + lodash.debounce: 4.0.8 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-member-expression-to-functions@7.25.9': + dependencies: + '@babel/traverse': 7.25.9 + '@babel/types': 7.26.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.25.9': + dependencies: + '@babel/traverse': 7.25.9 + '@babel/types': 7.26.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@babel/traverse': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.25.9': + dependencies: + '@babel/types': 7.26.0 + + '@babel/helper-plugin-utils@7.25.9': {} + + '@babel/helper-remap-async-to-generator@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-annotate-as-pure': 7.25.9 + '@babel/helper-wrap-function': 7.25.9 + '@babel/traverse': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-member-expression-to-functions': 7.25.9 + '@babel/helper-optimise-call-expression': 7.25.9 + '@babel/traverse': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/helper-simple-access@7.25.9': + dependencies: + '@babel/traverse': 7.25.9 + '@babel/types': 7.26.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.25.9': + dependencies: + '@babel/traverse': 7.25.9 + '@babel/types': 7.26.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.25.9': {} + + '@babel/helper-validator-identifier@7.25.9': {} + + '@babel/helper-validator-option@7.25.9': {} + + '@babel/helper-wrap-function@7.25.9': + dependencies: + '@babel/template': 7.25.9 + '@babel/traverse': 7.25.9 + '@babel/types': 7.26.0 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.26.0': + dependencies: + '@babel/template': 7.25.9 + '@babel/types': 7.26.0 + + '@babel/parser@7.26.2': + dependencies: + '@babel/types': 7.26.0 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/traverse': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 + '@babel/plugin-transform-optional-chaining': 7.25.9(@babel/core@7.26.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/traverse': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-syntax-import-assertions@7.26.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-arrow-functions@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-async-generator-functions@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.26.0) + '@babel/traverse': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.26.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-block-scoping@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-class-properties@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.26.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-annotate-as-pure': 7.25.9 + '@babel/helper-compilation-targets': 7.25.9 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.0) + '@babel/traverse': 7.25.9 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/template': 7.25.9 + + '@babel/plugin-transform-destructuring@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-dotall-regex@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-duplicate-keys@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-dynamic-import@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-exponentiation-operator@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-builder-binary-assignment-operator-visitor': 7.25.9 + '@babel/helper-plugin-utils': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-export-namespace-from@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-for-of@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-compilation-targets': 7.25.9 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/traverse': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-literals@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-logical-assignment-operators@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-member-expression-literals@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-modules-amd@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-simple-access': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@babel/traverse': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-umd@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-new-target@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-nullish-coalescing-operator@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-numeric-separator@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-object-rest-spread@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-compilation-targets': 7.25.9 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.26.0) + + '@babel/plugin-transform-object-super@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-optional-chaining@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-private-methods@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-annotate-as-pure': 7.25.9 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-react-constant-elements@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-react-display-name@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-react-jsx-development@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/plugin-transform-react-jsx': 7.25.9(@babel/core@7.26.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-annotate-as-pure': 7.25.9 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) + '@babel/types': 7.26.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-annotate-as-pure': 7.25.9 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-regenerator@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + regenerator-transform: 0.15.2 + + '@babel/plugin-transform-regexp-modifiers@7.26.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-reserved-words@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-runtime@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-plugin-utils': 7.25.9 + babel-plugin-polyfill-corejs2: 0.4.12(@babel/core@7.26.0) + babel-plugin-polyfill-corejs3: 0.10.6(@babel/core@7.26.0) + babel-plugin-polyfill-regenerator: 0.6.3(@babel/core@7.26.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-shorthand-properties@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-spread@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-template-literals@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-typeof-symbol@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-typescript@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-annotate-as-pure': 7.25.9 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 + '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-unicode-escapes@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-unicode-property-regex@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-unicode-regex@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/plugin-transform-unicode-sets-regex@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-plugin-utils': 7.25.9 + + '@babel/preset-env@7.26.0(@babel/core@7.26.0)': + dependencies: + '@babel/compat-data': 7.26.2 + '@babel/core': 7.26.0 + '@babel/helper-compilation-targets': 7.25.9 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-validator-option': 7.25.9 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.26.0) + '@babel/plugin-syntax-import-assertions': 7.26.0(@babel/core@7.26.0) + '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.26.0) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.26.0) + '@babel/plugin-transform-arrow-functions': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-async-generator-functions': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-async-to-generator': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-block-scoped-functions': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-block-scoping': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-class-properties': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-class-static-block': 7.26.0(@babel/core@7.26.0) + '@babel/plugin-transform-classes': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-computed-properties': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-destructuring': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-dotall-regex': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-duplicate-keys': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-dynamic-import': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-exponentiation-operator': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-export-namespace-from': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-for-of': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-function-name': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-json-strings': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-literals': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-logical-assignment-operators': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-member-expression-literals': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-modules-amd': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-modules-commonjs': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-modules-systemjs': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-modules-umd': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-new-target': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-numeric-separator': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-object-rest-spread': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-object-super': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-optional-catch-binding': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-optional-chaining': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-private-methods': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-private-property-in-object': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-property-literals': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-regenerator': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-regexp-modifiers': 7.26.0(@babel/core@7.26.0) + '@babel/plugin-transform-reserved-words': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-shorthand-properties': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-spread': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-sticky-regex': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-template-literals': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-typeof-symbol': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-unicode-escapes': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-unicode-property-regex': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-unicode-regex': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-unicode-sets-regex': 7.25.9(@babel/core@7.26.0) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.26.0) + babel-plugin-polyfill-corejs2: 0.4.12(@babel/core@7.26.0) + babel-plugin-polyfill-corejs3: 0.10.6(@babel/core@7.26.0) + babel-plugin-polyfill-regenerator: 0.6.3(@babel/core@7.26.0) + core-js-compat: 3.39.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/types': 7.26.0 + esutils: 2.0.3 + + '@babel/preset-react@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-validator-option': 7.25.9 + '@babel/plugin-transform-react-display-name': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-react-jsx': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-react-jsx-development': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-react-pure-annotations': 7.25.9(@babel/core@7.26.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.26.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-validator-option': 7.25.9 + '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-modules-commonjs': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-typescript': 7.25.9(@babel/core@7.26.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime-corejs3@7.26.0': + dependencies: + core-js-pure: 3.39.0 + regenerator-runtime: 0.14.1 + + '@babel/runtime@7.26.0': + dependencies: + regenerator-runtime: 0.14.1 + + '@babel/template@7.25.9': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/parser': 7.26.2 + '@babel/types': 7.26.0 + + '@babel/traverse@7.25.9': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.2 + '@babel/parser': 7.26.2 + '@babel/template': 7.25.9 + '@babel/types': 7.26.0 + debug: 4.3.7 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.26.0': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + + '@colors/colors@1.5.0': + optional: true + + '@csstools/cascade-layer-name-parser@2.0.4(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/color-helpers@5.0.1': {} + + '@csstools/css-calc@2.1.0(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-color-parser@3.0.6(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/color-helpers': 5.0.1 + '@csstools/css-calc': 2.1.0(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-tokenizer@3.0.3': {} + + '@csstools/media-query-list-parser@4.0.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/postcss-cascade-layers@5.0.1(postcss@8.4.49)': + dependencies: + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.0.0) + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + + '@csstools/postcss-color-function@4.0.6(postcss@8.4.49)': + dependencies: + '@csstools/css-color-parser': 3.0.6(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + + '@csstools/postcss-color-mix-function@3.0.6(postcss@8.4.49)': + dependencies: + '@csstools/css-color-parser': 3.0.6(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + + '@csstools/postcss-content-alt-text@2.0.4(postcss@8.4.49)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + + '@csstools/postcss-exponential-functions@2.0.5(postcss@8.4.49)': + dependencies: + '@csstools/css-calc': 2.1.0(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + postcss: 8.4.49 + + '@csstools/postcss-font-format-keywords@4.0.0(postcss@8.4.49)': + dependencies: + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-gamut-mapping@2.0.6(postcss@8.4.49)': + dependencies: + '@csstools/css-color-parser': 3.0.6(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + postcss: 8.4.49 + + '@csstools/postcss-gradients-interpolation-method@5.0.6(postcss@8.4.49)': + dependencies: + '@csstools/css-color-parser': 3.0.6(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + + '@csstools/postcss-hwb-function@4.0.6(postcss@8.4.49)': + dependencies: + '@csstools/css-color-parser': 3.0.6(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + + '@csstools/postcss-ic-unit@4.0.0(postcss@8.4.49)': + dependencies: + '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-initial@2.0.0(postcss@8.4.49)': + dependencies: + postcss: 8.4.49 + + '@csstools/postcss-is-pseudo-class@5.0.1(postcss@8.4.49)': + dependencies: + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.0.0) + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + + '@csstools/postcss-light-dark-function@2.0.7(postcss@8.4.49)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + + '@csstools/postcss-logical-float-and-clear@3.0.0(postcss@8.4.49)': + dependencies: + postcss: 8.4.49 + + '@csstools/postcss-logical-overflow@2.0.0(postcss@8.4.49)': + dependencies: + postcss: 8.4.49 + + '@csstools/postcss-logical-overscroll-behavior@2.0.0(postcss@8.4.49)': + dependencies: + postcss: 8.4.49 + + '@csstools/postcss-logical-resize@3.0.0(postcss@8.4.49)': + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-logical-viewport-units@3.0.3(postcss@8.4.49)': + dependencies: + '@csstools/css-tokenizer': 3.0.3 + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + + '@csstools/postcss-media-minmax@2.0.5(postcss@8.4.49)': + dependencies: + '@csstools/css-calc': 2.1.0(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/media-query-list-parser': 4.0.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + postcss: 8.4.49 + + '@csstools/postcss-media-queries-aspect-ratio-number-values@3.0.4(postcss@8.4.49)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/media-query-list-parser': 4.0.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + postcss: 8.4.49 + + '@csstools/postcss-nested-calc@4.0.0(postcss@8.4.49)': + dependencies: + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-normalize-display-values@4.0.0(postcss@8.4.49)': + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-oklab-function@4.0.6(postcss@8.4.49)': + dependencies: + '@csstools/css-color-parser': 3.0.6(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + + '@csstools/postcss-progressive-custom-properties@4.0.0(postcss@8.4.49)': + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-random-function@1.0.1(postcss@8.4.49)': + dependencies: + '@csstools/css-calc': 2.1.0(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + postcss: 8.4.49 + + '@csstools/postcss-relative-color-syntax@3.0.6(postcss@8.4.49)': + dependencies: + '@csstools/css-color-parser': 3.0.6(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + + '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.4.49)': + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + + '@csstools/postcss-sign-functions@1.1.0(postcss@8.4.49)': + dependencies: + '@csstools/css-calc': 2.1.0(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + postcss: 8.4.49 + + '@csstools/postcss-stepped-value-functions@4.0.5(postcss@8.4.49)': + dependencies: + '@csstools/css-calc': 2.1.0(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + postcss: 8.4.49 + + '@csstools/postcss-text-decoration-shorthand@4.0.1(postcss@8.4.49)': + dependencies: + '@csstools/color-helpers': 5.0.1 + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-trigonometric-functions@4.0.5(postcss@8.4.49)': + dependencies: + '@csstools/css-calc': 2.1.0(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + postcss: 8.4.49 + + '@csstools/postcss-unset-value@4.0.0(postcss@8.4.49)': + dependencies: + postcss: 8.4.49 + + '@csstools/selector-resolve-nested@3.0.0(postcss-selector-parser@7.0.0)': + dependencies: + postcss-selector-parser: 7.0.0 + + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.0.0)': + dependencies: + postcss-selector-parser: 7.0.0 + + '@csstools/utilities@2.0.0(postcss@8.4.49)': + dependencies: + postcss: 8.4.49 + + '@discoveryjs/json-ext@0.5.7': {} + + '@docsearch/css@3.8.0': {} + + '@docsearch/react@3.8.0(@algolia/client-search@5.15.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.15.0)(algoliasearch@5.15.0)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.15.0)(algoliasearch@5.15.0) + '@docsearch/css': 3.8.0 + algoliasearch: 5.15.0 + optionalDependencies: + '@types/react': 18.3.12 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@docusaurus/babel@3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@babel/core': 7.26.0 + '@babel/generator': 7.26.2 + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.26.0) + '@babel/plugin-transform-runtime': 7.25.9(@babel/core@7.26.0) + '@babel/preset-env': 7.26.0(@babel/core@7.26.0) + '@babel/preset-react': 7.25.9(@babel/core@7.26.0) + '@babel/preset-typescript': 7.26.0(@babel/core@7.26.0) + '@babel/runtime': 7.26.0 + '@babel/runtime-corejs3': 7.26.0 + '@babel/traverse': 7.25.9 + '@docusaurus/logger': 3.6.3 + '@docusaurus/utils': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + babel-plugin-dynamic-import-node: 2.3.3 + fs-extra: 11.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@swc/core' + - acorn + - esbuild + - react + - react-dom + - supports-color + - typescript + - uglify-js + - webpack-cli + + '@docusaurus/bundler@3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@babel/core': 7.26.0 + '@docusaurus/babel': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/cssnano-preset': 3.6.3 + '@docusaurus/logger': 3.6.3 + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + babel-loader: 9.2.1(@babel/core@7.26.0)(webpack@5.96.1) + clean-css: 5.3.3 + copy-webpack-plugin: 11.0.0(webpack@5.96.1) + css-loader: 6.11.0(webpack@5.96.1) + css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(webpack@5.96.1) + cssnano: 6.1.2(postcss@8.4.49) + file-loader: 6.2.0(webpack@5.96.1) + html-minifier-terser: 7.2.0 + mini-css-extract-plugin: 2.9.2(webpack@5.96.1) + null-loader: 4.0.1(webpack@5.96.1) + postcss: 8.4.49 + postcss-loader: 7.3.4(postcss@8.4.49)(typescript@5.2.2)(webpack@5.96.1) + postcss-preset-env: 10.1.1(postcss@8.4.49) + react-dev-utils: 12.0.1(typescript@5.2.2)(webpack@5.96.1) + terser-webpack-plugin: 5.3.10(webpack@5.96.1) + tslib: 2.8.1 + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.96.1))(webpack@5.96.1) + webpack: 5.96.1 + webpackbar: 6.0.1(webpack@5.96.1) + transitivePeerDependencies: + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - acorn + - csso + - esbuild + - eslint + - lightningcss + - react + - react-dom + - supports-color + - typescript + - uglify-js + - vue-template-compiler + - webpack-cli + + '@docusaurus/core@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/babel': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/bundler': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/logger': 3.6.3 + '@docusaurus/mdx-loader': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/utils': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/utils-common': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@mdx-js/react': 3.1.0(@types/react@18.3.12)(react@18.3.1) + boxen: 6.2.1 + chalk: 4.1.2 + chokidar: 3.6.0 + cli-table3: 0.6.5 + combine-promises: 1.2.0 + commander: 5.1.0 + core-js: 3.39.0 + del: 6.1.1 + detect-port: 1.6.1 + escape-html: 1.0.3 + eta: 2.2.0 + eval: 0.1.8 + fs-extra: 11.2.0 + html-tags: 3.3.1 + html-webpack-plugin: 5.6.3(webpack@5.96.1) + leven: 3.1.0 + lodash: 4.17.21 + p-map: 4.0.0 + prompts: 2.4.2 + react: 18.3.1 + react-dev-utils: 12.0.1(typescript@5.2.2)(webpack@5.96.1) + react-dom: 18.3.1(react@18.3.1) + react-helmet-async: 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-loadable: '@docusaurus/react-loadable@6.0.0(react@18.3.1)' + react-loadable-ssr-addon-v5-slorber: 1.0.1(@docusaurus/react-loadable@6.0.0(react@18.3.1))(webpack@5.96.1) + react-router: 5.3.4(react@18.3.1) + react-router-config: 5.1.1(react-router@5.3.4(react@18.3.1))(react@18.3.1) + react-router-dom: 5.3.4(react@18.3.1) + rtl-detect: 1.1.2 + semver: 7.6.3 + serve-handler: 6.1.6 + shelljs: 0.8.5 + tslib: 2.8.1 + update-notifier: 6.0.2 + webpack: 5.96.1 + webpack-bundle-analyzer: 4.10.2 + webpack-dev-server: 4.15.2(webpack@5.96.1) + webpack-merge: 6.0.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - acorn + - bufferutil + - csso + - debug + - esbuild + - eslint + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - vue-template-compiler + - webpack-cli + + '@docusaurus/cssnano-preset@3.6.3': + dependencies: + cssnano-preset-advanced: 6.1.2(postcss@8.4.49) + postcss: 8.4.49 + postcss-sort-media-queries: 5.2.0(postcss@8.4.49) + tslib: 2.8.1 + + '@docusaurus/logger@3.6.3': + dependencies: + chalk: 4.1.2 + tslib: 2.8.1 + + '@docusaurus/mdx-loader@3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/logger': 3.6.3 + '@docusaurus/utils': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/utils-validation': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@mdx-js/mdx': 3.1.0(acorn@8.14.0) + '@slorber/remark-comment': 1.0.0 + escape-html: 1.0.3 + estree-util-value-to-estree: 3.2.1 + file-loader: 6.2.0(webpack@5.96.1) + fs-extra: 11.2.0 + image-size: 1.1.1 + mdast-util-mdx: 3.0.0 + mdast-util-to-string: 4.0.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + rehype-raw: 7.0.0 + remark-directive: 3.0.0 + remark-emoji: 4.0.1 + remark-frontmatter: 5.0.0 + remark-gfm: 4.0.0 + stringify-object: 3.3.0 + tslib: 2.8.1 + unified: 11.0.5 + unist-util-visit: 5.0.0 + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.96.1))(webpack@5.96.1) + vfile: 6.0.3 + webpack: 5.96.1 + transitivePeerDependencies: + - '@swc/core' + - acorn + - esbuild + - supports-color + - typescript + - uglify-js + - webpack-cli + + '@docusaurus/module-type-aliases@3.2.1(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@docusaurus/react-loadable': 5.5.2(react@18.3.1) + '@docusaurus/types': 3.2.1(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/history': 4.7.11 + '@types/react': 18.3.12 + '@types/react-router-config': 5.0.11 + '@types/react-router-dom': 5.3.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-helmet-async: 2.0.5(react@18.3.1) + react-loadable: '@docusaurus/react-loadable@5.5.2(react@18.3.1)' + transitivePeerDependencies: + - '@swc/core' + - acorn + - esbuild + - supports-color + - uglify-js + - webpack-cli + + '@docusaurus/module-type-aliases@3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/history': 4.7.11 + '@types/react': 18.3.12 + '@types/react-router-config': 5.0.11 + '@types/react-router-dom': 5.3.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-helmet-async: 2.0.5(react@18.3.1) + react-loadable: '@docusaurus/react-loadable@6.0.0(react@18.3.1)' + transitivePeerDependencies: + - '@swc/core' + - acorn + - esbuild + - supports-color + - uglify-js + - webpack-cli + + '@docusaurus/plugin-content-blog@3.6.3(@docusaurus/plugin-content-docs@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2))(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/core': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/logger': 3.6.3 + '@docusaurus/mdx-loader': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/plugin-content-docs': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/theme-common': 3.6.3(@docusaurus/plugin-content-docs@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/utils-common': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + cheerio: 1.0.0-rc.12 + feed: 4.2.2 + fs-extra: 11.2.0 + lodash: 4.17.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + reading-time: 1.5.0 + srcset: 4.0.0 + tslib: 2.8.1 + unist-util-visit: 5.0.0 + utility-types: 3.11.0 + webpack: 5.96.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - acorn + - bufferutil + - csso + - debug + - esbuild + - eslint + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - vue-template-compiler + - webpack-cli + + '@docusaurus/plugin-content-docs@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/core': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/logger': 3.6.3 + '@docusaurus/mdx-loader': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/module-type-aliases': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-common': 3.6.3(@docusaurus/plugin-content-docs@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/utils-common': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@types/react-router-config': 5.0.11 + combine-promises: 1.2.0 + fs-extra: 11.2.0 + js-yaml: 4.1.0 + lodash: 4.17.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + utility-types: 3.11.0 + webpack: 5.96.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - acorn + - bufferutil + - csso + - debug + - esbuild + - eslint + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - vue-template-compiler + - webpack-cli + + '@docusaurus/plugin-content-pages@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/core': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/mdx-loader': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/utils-validation': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + fs-extra: 11.2.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + webpack: 5.96.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - acorn + - bufferutil + - csso + - debug + - esbuild + - eslint + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - vue-template-compiler + - webpack-cli + + '@docusaurus/plugin-debug@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/core': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + fs-extra: 11.2.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-json-view-lite: 1.5.0(react@18.3.1) + tslib: 2.8.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - acorn + - bufferutil + - csso + - debug + - esbuild + - eslint + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - vue-template-compiler + - webpack-cli + + '@docusaurus/plugin-google-analytics@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/core': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - acorn + - bufferutil + - csso + - debug + - esbuild + - eslint + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - vue-template-compiler + - webpack-cli + + '@docusaurus/plugin-google-gtag@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/core': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@types/gtag.js': 0.0.12 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - acorn + - bufferutil + - csso + - debug + - esbuild + - eslint + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - vue-template-compiler + - webpack-cli + + '@docusaurus/plugin-google-tag-manager@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/core': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - acorn + - bufferutil + - csso + - debug + - esbuild + - eslint + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - vue-template-compiler + - webpack-cli + + '@docusaurus/plugin-sitemap@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/core': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/logger': 3.6.3 + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/utils-common': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + fs-extra: 11.2.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + sitemap: 7.1.2 + tslib: 2.8.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - acorn + - bufferutil + - csso + - debug + - esbuild + - eslint + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - vue-template-compiler + - webpack-cli + + '@docusaurus/preset-classic@3.6.3(@algolia/client-search@5.15.0)(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.2.2)': + dependencies: + '@docusaurus/core': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/plugin-content-blog': 3.6.3(@docusaurus/plugin-content-docs@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2))(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/plugin-content-docs': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/plugin-content-pages': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/plugin-debug': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/plugin-google-analytics': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/plugin-google-gtag': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/plugin-google-tag-manager': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/plugin-sitemap': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/theme-classic': 3.6.3(@types/react@18.3.12)(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/theme-common': 3.6.3(@docusaurus/plugin-content-docs@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/theme-search-algolia': 3.6.3(@algolia/client-search@5.15.0)(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.2.2) + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@algolia/client-search' + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - '@types/react' + - acorn + - bufferutil + - csso + - debug + - esbuild + - eslint + - lightningcss + - search-insights + - supports-color + - typescript + - uglify-js + - utf-8-validate + - vue-template-compiler + - webpack-cli + + '@docusaurus/react-loadable@5.5.2(react@18.3.1)': + dependencies: + '@types/react': 18.3.12 + prop-types: 15.8.1 + react: 18.3.1 + + '@docusaurus/react-loadable@6.0.0(react@18.3.1)': + dependencies: + '@types/react': 18.3.12 + react: 18.3.1 + + '@docusaurus/theme-classic@3.6.3(@types/react@18.3.12)(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/core': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/logger': 3.6.3 + '@docusaurus/mdx-loader': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/module-type-aliases': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-blog': 3.6.3(@docusaurus/plugin-content-docs@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2))(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/plugin-content-docs': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/plugin-content-pages': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/theme-common': 3.6.3(@docusaurus/plugin-content-docs@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/theme-translations': 3.6.3 + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/utils-common': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@mdx-js/react': 3.1.0(@types/react@18.3.12)(react@18.3.1) + clsx: 2.1.1 + copy-text-to-clipboard: 3.2.0 + infima: 0.2.0-alpha.45 + lodash: 4.17.21 + nprogress: 0.2.0 + postcss: 8.4.49 + prism-react-renderer: 2.4.0(react@18.3.1) + prismjs: 1.29.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router-dom: 5.3.4(react@18.3.1) + rtlcss: 4.3.0 + tslib: 2.8.1 + utility-types: 3.11.0 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - '@types/react' + - acorn + - bufferutil + - csso + - debug + - esbuild + - eslint + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - vue-template-compiler + - webpack-cli + + '@docusaurus/theme-common@3.6.3(@docusaurus/plugin-content-docs@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/mdx-loader': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/module-type-aliases': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-docs': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/utils': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/utils-common': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/history': 4.7.11 + '@types/react': 18.3.12 + '@types/react-router-config': 5.0.11 + clsx: 2.1.1 + parse-numeric-range: 1.3.0 + prism-react-renderer: 2.4.0(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + utility-types: 3.11.0 + transitivePeerDependencies: + - '@swc/core' + - acorn + - esbuild + - supports-color + - typescript + - uglify-js + - webpack-cli + + '@docusaurus/theme-search-algolia@3.6.3(@algolia/client-search@5.15.0)(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.2.2)': + dependencies: + '@docsearch/react': 3.8.0(@algolia/client-search@5.15.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) + '@docusaurus/core': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/logger': 3.6.3 + '@docusaurus/plugin-content-docs': 3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/theme-common': 3.6.3(@docusaurus/plugin-content-docs@3.6.3(@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2))(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/theme-translations': 3.6.3 + '@docusaurus/utils': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/utils-validation': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + algoliasearch: 4.24.0 + algoliasearch-helper: 3.22.5(algoliasearch@4.24.0) + clsx: 2.1.1 + eta: 2.2.0 + fs-extra: 11.2.0 + lodash: 4.17.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + utility-types: 3.11.0 + transitivePeerDependencies: + - '@algolia/client-search' + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - '@types/react' + - acorn + - bufferutil + - csso + - debug + - esbuild + - eslint + - lightningcss + - search-insights + - supports-color + - typescript + - uglify-js + - utf-8-validate + - vue-template-compiler + - webpack-cli + + '@docusaurus/theme-translations@3.6.3': + dependencies: + fs-extra: 11.2.0 + tslib: 2.8.1 + + '@docusaurus/tsconfig@3.2.1': {} + + '@docusaurus/types@3.2.1(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@mdx-js/mdx': 3.1.0(acorn@8.14.0) + '@types/history': 4.7.11 + '@types/react': 18.3.12 + commander: 5.1.0 + joi: 17.13.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-helmet-async: 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + utility-types: 3.11.0 + webpack: 5.96.1 + webpack-merge: 5.10.0 + transitivePeerDependencies: + - '@swc/core' + - acorn + - esbuild + - supports-color + - uglify-js + - webpack-cli + + '@docusaurus/types@3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@mdx-js/mdx': 3.1.0(acorn@8.14.0) + '@types/history': 4.7.11 + '@types/react': 18.3.12 + commander: 5.1.0 + joi: 17.13.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-helmet-async: 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + utility-types: 3.11.0 + webpack: 5.96.1 + webpack-merge: 5.10.0 + transitivePeerDependencies: + - '@swc/core' + - acorn + - esbuild + - supports-color + - uglify-js + - webpack-cli + + '@docusaurus/utils-common@3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tslib: 2.8.1 + transitivePeerDependencies: + - '@swc/core' + - acorn + - esbuild + - react + - react-dom + - supports-color + - uglify-js + - webpack-cli + + '@docusaurus/utils-validation@3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/logger': 3.6.3 + '@docusaurus/utils': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2) + '@docusaurus/utils-common': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + fs-extra: 11.2.0 + joi: 17.13.3 + js-yaml: 4.1.0 + lodash: 4.17.21 + tslib: 2.8.1 + transitivePeerDependencies: + - '@swc/core' + - acorn + - esbuild + - react + - react-dom + - supports-color + - typescript + - uglify-js + - webpack-cli + + '@docusaurus/utils@3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.2.2)': + dependencies: + '@docusaurus/logger': 3.6.3 + '@docusaurus/types': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-common': 3.6.3(acorn@8.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@svgr/webpack': 8.1.0(typescript@5.2.2) + escape-string-regexp: 4.0.0 + file-loader: 6.2.0(webpack@5.96.1) + fs-extra: 11.2.0 + github-slugger: 1.5.0 + globby: 11.1.0 + gray-matter: 4.0.3 + jiti: 1.21.6 + js-yaml: 4.1.0 + lodash: 4.17.21 + micromatch: 4.0.8 + prompts: 2.4.2 + resolve-pathname: 3.0.0 + shelljs: 0.8.5 + tslib: 2.8.1 + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.96.1))(webpack@5.96.1) + utility-types: 3.11.0 + webpack: 5.96.1 + transitivePeerDependencies: + - '@swc/core' + - acorn + - esbuild + - react + - react-dom + - supports-color + - typescript + - uglify-js + - webpack-cli + + '@hapi/hoek@9.3.0': {} + + '@hapi/topo@5.1.0': + dependencies: + '@hapi/hoek': 9.3.0 + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.10.1 + '@types/yargs': 17.0.33 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/source-map@0.3.6': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@leichtgewicht/ip-codec@2.0.5': {} + + '@mdx-js/mdx@3.1.0(acorn@8.14.0)': + dependencies: + '@types/estree': 1.0.6 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.13 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.2 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.0(acorn@8.14.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.0 + remark-parse: 11.0.0 + remark-rehype: 11.1.1 + source-map: 0.7.4 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - acorn + - supports-color + + '@mdx-js/react@3.1.0(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@types/mdx': 2.0.13 + '@types/react': 18.3.12 + react: 18.3.1 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@pnpm/config.env-replace@1.1.0': {} + + '@pnpm/network.ca-file@1.0.2': + dependencies: + graceful-fs: 4.2.10 + + '@pnpm/npm-conf@2.3.1': + dependencies: + '@pnpm/config.env-replace': 1.1.0 + '@pnpm/network.ca-file': 1.0.2 + config-chain: 1.1.13 + + '@polka/url@1.0.0-next.28': {} + + '@sideway/address@4.1.5': + dependencies: + '@hapi/hoek': 9.3.0 + + '@sideway/formula@3.0.1': {} + + '@sideway/pinpoint@2.0.0': {} + + '@sinclair/typebox@0.27.8': {} + + '@sindresorhus/is@4.6.0': {} + + '@sindresorhus/is@5.6.0': {} + + '@slorber/remark-comment@1.0.0': + dependencies: + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + + '@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + + '@svgr/babel-preset@8.1.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.26.0) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.26.0) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.26.0) + '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.26.0) + '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.26.0) + '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.26.0) + '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.26.0) + '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.26.0) + + '@svgr/core@8.1.0(typescript@5.2.2)': + dependencies: + '@babel/core': 7.26.0 + '@svgr/babel-preset': 8.1.0(@babel/core@7.26.0) + camelcase: 6.3.0 + cosmiconfig: 8.3.6(typescript@5.2.2) + snake-case: 3.0.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@svgr/hast-util-to-babel-ast@8.0.0': + dependencies: + '@babel/types': 7.26.0 + entities: 4.5.0 + + '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.2.2))': + dependencies: + '@babel/core': 7.26.0 + '@svgr/babel-preset': 8.1.0(@babel/core@7.26.0) + '@svgr/core': 8.1.0(typescript@5.2.2) + '@svgr/hast-util-to-babel-ast': 8.0.0 + svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color + + '@svgr/plugin-svgo@8.1.0(@svgr/core@8.1.0(typescript@5.2.2))(typescript@5.2.2)': + dependencies: + '@svgr/core': 8.1.0(typescript@5.2.2) + cosmiconfig: 8.3.6(typescript@5.2.2) + deepmerge: 4.3.1 + svgo: 3.3.2 + transitivePeerDependencies: + - typescript + + '@svgr/webpack@8.1.0(typescript@5.2.2)': + dependencies: + '@babel/core': 7.26.0 + '@babel/plugin-transform-react-constant-elements': 7.25.9(@babel/core@7.26.0) + '@babel/preset-env': 7.26.0(@babel/core@7.26.0) + '@babel/preset-react': 7.25.9(@babel/core@7.26.0) + '@babel/preset-typescript': 7.26.0(@babel/core@7.26.0) + '@svgr/core': 8.1.0(typescript@5.2.2) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.2.2)) + '@svgr/plugin-svgo': 8.1.0(@svgr/core@8.1.0(typescript@5.2.2))(typescript@5.2.2) + transitivePeerDependencies: + - supports-color + - typescript + + '@szmarczak/http-timer@5.0.1': + dependencies: + defer-to-connect: 2.0.1 + + '@trysound/sax@0.2.0': {} + + '@types/acorn@4.0.6': + dependencies: + '@types/estree': 1.0.6 + + '@types/body-parser@1.19.5': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.10.1 + + '@types/bonjour@3.5.13': + dependencies: + '@types/node': 22.10.1 + + '@types/connect-history-api-fallback@1.5.4': + dependencies: + '@types/express-serve-static-core': 5.0.2 + '@types/node': 22.10.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.10.1 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 0.7.34 + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.6 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.6 + '@types/json-schema': 7.0.15 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.6 + + '@types/estree@1.0.6': {} + + '@types/express-serve-static-core@4.19.6': + dependencies: + '@types/node': 22.10.1 + '@types/qs': 6.9.17 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.4 + + '@types/express-serve-static-core@5.0.2': + dependencies: + '@types/node': 22.10.1 + '@types/qs': 6.9.17 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.4 + + '@types/express@4.17.21': + dependencies: + '@types/body-parser': 1.19.5 + '@types/express-serve-static-core': 4.19.6 + '@types/qs': 6.9.17 + '@types/serve-static': 1.15.7 + + '@types/gtag.js@0.0.12': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/history@4.7.11': {} + + '@types/html-minifier-terser@6.1.0': {} + + '@types/http-cache-semantics@4.0.4': {} + + '@types/http-errors@2.0.4': {} + + '@types/http-proxy@1.17.15': + dependencies: + '@types/node': 22.10.1 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/json-schema@7.0.15': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.13': {} + + '@types/mime@1.3.5': {} + + '@types/ms@0.7.34': {} + + '@types/node-forge@1.3.11': + dependencies: + '@types/node': 22.10.1 + + '@types/node@17.0.45': {} + + '@types/node@22.10.1': + dependencies: + undici-types: 6.20.0 + + '@types/parse-json@4.0.2': {} + + '@types/prismjs@1.26.5': {} + + '@types/prop-types@15.7.13': {} + + '@types/qs@6.9.17': {} + + '@types/range-parser@1.2.7': {} + + '@types/react-router-config@5.0.11': + dependencies: + '@types/history': 4.7.11 + '@types/react': 18.3.12 + '@types/react-router': 5.1.20 + + '@types/react-router-dom@5.3.3': + dependencies: + '@types/history': 4.7.11 + '@types/react': 18.3.12 + '@types/react-router': 5.1.20 + + '@types/react-router@5.1.20': + dependencies: + '@types/history': 4.7.11 + '@types/react': 18.3.12 + + '@types/react@18.3.12': + dependencies: + '@types/prop-types': 15.7.13 + csstype: 3.1.3 + + '@types/retry@0.12.0': {} + + '@types/sax@1.2.7': + dependencies: + '@types/node': 17.0.45 + + '@types/send@0.17.4': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.10.1 + + '@types/serve-index@1.9.4': + dependencies: + '@types/express': 4.17.21 + + '@types/serve-static@1.15.7': + dependencies: + '@types/http-errors': 2.0.4 + '@types/node': 22.10.1 + '@types/send': 0.17.4 + + '@types/sockjs@0.3.36': + dependencies: + '@types/node': 22.10.1 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@types/ws@8.5.13': + dependencies: + '@types/node': 22.10.1 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.33': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@ungap/structured-clone@1.2.0': {} + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-jsx@5.3.2(acorn@8.14.0): + dependencies: + acorn: 8.14.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.14.0 + + acorn@8.14.0: {} + + address@1.2.2: {} + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv-formats@2.1.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-keywords@3.5.2(ajv@6.12.6): + dependencies: + ajv: 6.12.6 + + ajv-keywords@5.1.0(ajv@8.17.1): + dependencies: + ajv: 8.17.1 + fast-deep-equal: 3.1.3 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + algoliasearch-helper@3.22.5(algoliasearch@4.24.0): + dependencies: + '@algolia/events': 4.0.1 + algoliasearch: 4.24.0 + + algoliasearch@4.24.0: + dependencies: + '@algolia/cache-browser-local-storage': 4.24.0 + '@algolia/cache-common': 4.24.0 + '@algolia/cache-in-memory': 4.24.0 + '@algolia/client-account': 4.24.0 + '@algolia/client-analytics': 4.24.0 + '@algolia/client-common': 4.24.0 + '@algolia/client-personalization': 4.24.0 + '@algolia/client-search': 4.24.0 + '@algolia/logger-common': 4.24.0 + '@algolia/logger-console': 4.24.0 + '@algolia/recommend': 4.24.0 + '@algolia/requester-browser-xhr': 4.24.0 + '@algolia/requester-common': 4.24.0 + '@algolia/requester-node-http': 4.24.0 + '@algolia/transporter': 4.24.0 + + algoliasearch@5.15.0: + dependencies: + '@algolia/client-abtesting': 5.15.0 + '@algolia/client-analytics': 5.15.0 + '@algolia/client-common': 5.15.0 + '@algolia/client-insights': 5.15.0 + '@algolia/client-personalization': 5.15.0 + '@algolia/client-query-suggestions': 5.15.0 + '@algolia/client-search': 5.15.0 + '@algolia/ingestion': 1.15.0 + '@algolia/monitoring': 1.15.0 + '@algolia/recommend': 5.15.0 + '@algolia/requester-browser-xhr': 5.15.0 + '@algolia/requester-fetch': 5.15.0 + '@algolia/requester-node-http': 5.15.0 + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-html-community@0.0.8: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-flatten@1.1.1: {} + + array-union@2.1.0: {} + + astring@1.9.0: {} + + at-least-node@1.0.0: {} + + autoprefixer@10.4.20(postcss@8.4.49): + dependencies: + browserslist: 4.24.2 + caniuse-lite: 1.0.30001684 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + babel-loader@9.2.1(@babel/core@7.26.0)(webpack@5.96.1): + dependencies: + '@babel/core': 7.26.0 + find-cache-dir: 4.0.0 + schema-utils: 4.2.0 + webpack: 5.96.1 + + babel-plugin-dynamic-import-node@2.3.3: + dependencies: + object.assign: 4.1.5 + + babel-plugin-polyfill-corejs2@0.4.12(@babel/core@7.26.0): + dependencies: + '@babel/compat-data': 7.26.2 + '@babel/core': 7.26.0 + '@babel/helper-define-polyfill-provider': 0.6.3(@babel/core@7.26.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.10.6(@babel/core@7.26.0): + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-define-polyfill-provider': 0.6.3(@babel/core@7.26.0) + core-js-compat: 3.39.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.3(@babel/core@7.26.0): + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-define-polyfill-provider': 0.6.3(@babel/core@7.26.0) + transitivePeerDependencies: + - supports-color + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + batch@0.6.1: {} + + big.js@5.2.2: {} + + binary-extensions@2.3.0: {} + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bonjour-service@1.3.0: + dependencies: + fast-deep-equal: 3.1.3 + multicast-dns: 7.2.5 + + boolbase@1.0.0: {} + + boxen@6.2.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + + boxen@7.1.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.3.0 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.24.2: + dependencies: + caniuse-lite: 1.0.30001684 + electron-to-chromium: 1.5.67 + node-releases: 2.0.18 + update-browserslist-db: 1.1.1(browserslist@4.24.2) + + buffer-from@1.1.2: {} + + bytes@3.0.0: {} + + bytes@3.1.2: {} + + cacheable-lookup@7.0.0: {} + + cacheable-request@10.2.14: + dependencies: + '@types/http-cache-semantics': 4.0.4 + get-stream: 6.0.1 + http-cache-semantics: 4.1.1 + keyv: 4.5.4 + mimic-response: 4.0.0 + normalize-url: 8.0.1 + responselike: 3.0.0 + + call-bind@1.0.7: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + set-function-length: 1.2.2 + + callsites@3.1.0: {} + + camel-case@4.1.2: + dependencies: + pascal-case: 3.1.2 + tslib: 2.8.1 + + camelcase@6.3.0: {} + + camelcase@7.0.1: {} + + caniuse-api@3.0.0: + dependencies: + browserslist: 4.24.2 + caniuse-lite: 1.0.30001684 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 + + caniuse-lite@1.0.30001684: {} + + ccount@2.0.1: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.3.0: {} + + char-regex@1.0.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + + cheerio@1.0.0-rc.12: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.1.0 + htmlparser2: 8.0.2 + parse5: 7.2.1 + parse5-htmlparser2-tree-adapter: 7.1.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chrome-trace-event@1.0.4: {} + + ci-info@3.9.0: {} + + clean-css@5.3.3: + dependencies: + source-map: 0.6.1 + + clean-stack@2.2.0: {} + + cli-boxes@3.0.0: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + clone-deep@4.0.1: + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + + clsx@2.1.1: {} + + collapse-white-space@2.1.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + colorette@2.0.20: {} + + combine-promises@1.2.0: {} + + comma-separated-tokens@2.0.3: {} + + commander@10.0.1: {} + + commander@2.20.3: {} + + commander@5.1.0: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + common-path-prefix@3.0.0: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.53.0 + + compression@1.7.5: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.0.2 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + configstore@6.0.0: + dependencies: + dot-prop: 6.0.1 + graceful-fs: 4.2.11 + unique-string: 3.0.0 + write-file-atomic: 3.0.3 + xdg-basedir: 5.1.0 + + connect-history-api-fallback@2.0.0: {} + + consola@3.2.3: {} + + content-disposition@0.5.2: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.6: {} + + cookie@0.7.1: {} + + copy-text-to-clipboard@3.2.0: {} + + copy-webpack-plugin@11.0.0(webpack@5.96.1): + dependencies: + fast-glob: 3.3.2 + glob-parent: 6.0.2 + globby: 13.2.2 + normalize-path: 3.0.0 + schema-utils: 4.2.0 + serialize-javascript: 6.0.2 + webpack: 5.96.1 + + core-js-compat@3.39.0: + dependencies: + browserslist: 4.24.2 + + core-js-pure@3.39.0: {} + + core-js@3.39.0: {} + + core-util-is@1.0.3: {} + + cosmiconfig@6.0.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.0 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + + cosmiconfig@8.3.6(typescript@5.2.2): + dependencies: + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.2.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto-random-string@4.0.0: + dependencies: + type-fest: 1.4.0 + + css-blank-pseudo@7.0.1(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + + css-declaration-sorter@7.2.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + css-has-pseudo@7.0.1(postcss@8.4.49): + dependencies: + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.0.0) + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + postcss-value-parser: 4.2.0 + + css-loader@6.11.0(webpack@5.96.1): + dependencies: + icss-utils: 5.1.0(postcss@8.4.49) + postcss: 8.4.49 + postcss-modules-extract-imports: 3.1.0(postcss@8.4.49) + postcss-modules-local-by-default: 4.1.0(postcss@8.4.49) + postcss-modules-scope: 3.2.1(postcss@8.4.49) + postcss-modules-values: 4.0.0(postcss@8.4.49) + postcss-value-parser: 4.2.0 + semver: 7.6.3 + optionalDependencies: + webpack: 5.96.1 + + css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(webpack@5.96.1): + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + cssnano: 6.1.2(postcss@8.4.49) + jest-worker: 29.7.0 + postcss: 8.4.49 + schema-utils: 4.2.0 + serialize-javascript: 6.0.2 + webpack: 5.96.1 + optionalDependencies: + clean-css: 5.3.3 + + css-prefers-color-scheme@10.0.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + css-select@4.3.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 4.3.1 + domutils: 2.8.0 + nth-check: 2.1.1 + + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + + css-what@6.1.0: {} + + cssdb@8.2.1: {} + + cssesc@3.0.0: {} + + cssnano-preset-advanced@6.1.2(postcss@8.4.49): + dependencies: + autoprefixer: 10.4.20(postcss@8.4.49) + browserslist: 4.24.2 + cssnano-preset-default: 6.1.2(postcss@8.4.49) + postcss: 8.4.49 + postcss-discard-unused: 6.0.5(postcss@8.4.49) + postcss-merge-idents: 6.0.3(postcss@8.4.49) + postcss-reduce-idents: 6.0.3(postcss@8.4.49) + postcss-zindex: 6.0.2(postcss@8.4.49) + + cssnano-preset-default@6.1.2(postcss@8.4.49): + dependencies: + browserslist: 4.24.2 + css-declaration-sorter: 7.2.0(postcss@8.4.49) + cssnano-utils: 4.0.2(postcss@8.4.49) + postcss: 8.4.49 + postcss-calc: 9.0.1(postcss@8.4.49) + postcss-colormin: 6.1.0(postcss@8.4.49) + postcss-convert-values: 6.1.0(postcss@8.4.49) + postcss-discard-comments: 6.0.2(postcss@8.4.49) + postcss-discard-duplicates: 6.0.3(postcss@8.4.49) + postcss-discard-empty: 6.0.3(postcss@8.4.49) + postcss-discard-overridden: 6.0.2(postcss@8.4.49) + postcss-merge-longhand: 6.0.5(postcss@8.4.49) + postcss-merge-rules: 6.1.1(postcss@8.4.49) + postcss-minify-font-values: 6.1.0(postcss@8.4.49) + postcss-minify-gradients: 6.0.3(postcss@8.4.49) + postcss-minify-params: 6.1.0(postcss@8.4.49) + postcss-minify-selectors: 6.0.4(postcss@8.4.49) + postcss-normalize-charset: 6.0.2(postcss@8.4.49) + postcss-normalize-display-values: 6.0.2(postcss@8.4.49) + postcss-normalize-positions: 6.0.2(postcss@8.4.49) + postcss-normalize-repeat-style: 6.0.2(postcss@8.4.49) + postcss-normalize-string: 6.0.2(postcss@8.4.49) + postcss-normalize-timing-functions: 6.0.2(postcss@8.4.49) + postcss-normalize-unicode: 6.1.0(postcss@8.4.49) + postcss-normalize-url: 6.0.2(postcss@8.4.49) + postcss-normalize-whitespace: 6.0.2(postcss@8.4.49) + postcss-ordered-values: 6.0.2(postcss@8.4.49) + postcss-reduce-initial: 6.1.0(postcss@8.4.49) + postcss-reduce-transforms: 6.0.2(postcss@8.4.49) + postcss-svgo: 6.0.3(postcss@8.4.49) + postcss-unique-selectors: 6.0.4(postcss@8.4.49) + + cssnano-utils@4.0.2(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + cssnano@6.1.2(postcss@8.4.49): + dependencies: + cssnano-preset-default: 6.1.2(postcss@8.4.49) + lilconfig: 3.1.2 + postcss: 8.4.49 + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + csstype@3.1.3: {} + + debounce@1.2.1: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.3.7: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.0.2: + dependencies: + character-entities: 2.0.2 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + deepmerge@4.3.1: {} + + default-gateway@6.0.3: + dependencies: + execa: 5.1.1 + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.1.0 + + define-lazy-prop@2.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + del@6.1.1: + dependencies: + globby: 11.1.0 + graceful-fs: 4.2.11 + is-glob: 4.0.3 + is-path-cwd: 2.2.0 + is-path-inside: 3.0.3 + p-map: 4.0.0 + rimraf: 3.0.2 + slash: 3.0.0 + + depd@1.1.2: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destroy@1.2.0: {} + + detect-node@2.1.0: {} + + detect-port-alt@1.1.6: + dependencies: + address: 1.2.2 + debug: 2.6.9 + transitivePeerDependencies: + - supports-color + + detect-port@1.6.1: + dependencies: + address: 1.2.2 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dns-packet@5.6.1: + dependencies: + '@leichtgewicht/ip-codec': 2.0.5 + + dom-converter@0.2.0: + dependencies: + utila: 0.4.0 + + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + + domutils@3.1.0: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + + duplexer@0.1.2: {} + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.67: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + emojilib@2.4.0: {} + + emojis-list@3.0.0: {} + + emoticon@4.1.0: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.17.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + entities@2.2.0: {} + + entities@4.5.0: {} + + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.0: + dependencies: + get-intrinsic: 1.2.4 + + es-errors@1.3.0: {} + + es-module-lexer@1.5.4: {} + + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.14.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.2 + + escalade@3.2.0: {} + + escape-goat@4.0.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + esprima@4.0.1: {} + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.6 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.4 + + estree-util-value-to-estree@3.2.1: + dependencies: + '@types/estree': 1.0.6 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.6 + + esutils@2.0.3: {} + + eta@2.2.0: {} + + etag@1.8.1: {} + + eval@0.1.8: + dependencies: + '@types/node': 22.10.1 + require-like: 0.1.2 + + eventemitter3@4.0.7: {} + + events@3.3.0: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + express@4.21.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.10 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-uri@3.0.3: {} + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + fault@2.0.1: + dependencies: + format: 0.2.2 + + faye-websocket@0.11.4: + dependencies: + websocket-driver: 0.7.4 + + feed@4.2.2: + dependencies: + xml-js: 1.6.11 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-loader@6.2.0(webpack@5.96.1): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.96.1 + + filesize@8.0.7: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-cache-dir@4.0.0: + dependencies: + common-path-prefix: 3.0.0 + pkg-dir: 7.0.0 + + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + find-up@6.3.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + + flat@5.0.2: {} + + follow-redirects@1.15.9: {} + + fork-ts-checker-webpack-plugin@6.5.3(typescript@5.2.2)(webpack@5.96.1): + dependencies: + '@babel/code-frame': 7.26.2 + '@types/json-schema': 7.0.15 + chalk: 4.1.2 + chokidar: 3.6.0 + cosmiconfig: 6.0.0 + deepmerge: 4.3.1 + fs-extra: 9.1.0 + glob: 7.2.3 + memfs: 3.5.3 + minimatch: 3.1.2 + schema-utils: 2.7.0 + semver: 7.6.3 + tapable: 1.1.3 + typescript: 5.2.2 + webpack: 5.96.1 + + form-data-encoder@2.1.4: {} + + format@0.2.2: {} + + forwarded@0.2.0: {} + + fraction.js@4.3.7: {} + + fresh@0.5.2: {} + + fs-extra@11.2.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fs-monkey@1.0.6: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.2.4: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + + get-own-enumerable-property-symbols@3.0.2: {} + + get-stream@6.0.1: {} + + github-slugger@1.5.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-dirs@3.0.1: + dependencies: + ini: 2.0.0 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globals@11.12.0: {} + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globby@13.2.2: + dependencies: + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 4.0.0 + + gopd@1.1.0: + dependencies: + get-intrinsic: 1.2.4 + + got@12.6.1: + dependencies: + '@sindresorhus/is': 5.6.0 + '@szmarczak/http-timer': 5.0.1 + cacheable-lookup: 7.0.0 + cacheable-request: 10.2.14 + decompress-response: 6.0.0 + form-data-encoder: 2.1.4 + get-stream: 6.0.1 + http2-wrapper: 2.2.1 + lowercase-keys: 3.0.0 + p-cancelable: 3.0.0 + responselike: 3.0.0 + + graceful-fs@4.2.10: {} + + graceful-fs@4.2.11: {} + + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.1 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + + handle-thing@2.0.1: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.0 + + has-proto@1.0.3: {} + + has-symbols@1.0.3: {} + + has-yarn@3.0.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hast-util-from-parse5@8.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.0 + property-information: 6.5.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.2.0 + hast-util-from-parse5: 8.0.2 + hast-util-to-parse5: 8.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + parse5: 7.2.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-estree@3.1.0: + dependencies: + '@types/estree': 1.0.6 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.1.3 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + style-to-object: 0.4.4 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-jsx-runtime@2.3.2: + dependencies: + '@types/estree': 1.0.6 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.1.3 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + style-to-object: 1.0.8 + unist-util-position: 5.0.0 + vfile-message: 4.0.2 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + + he@1.2.0: {} + + history@4.10.1: + dependencies: + '@babel/runtime': 7.26.0 + loose-envify: 1.4.0 + resolve-pathname: 3.0.0 + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + value-equal: 1.0.1 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + hpack.js@2.1.6: + dependencies: + inherits: 2.0.4 + obuf: 1.1.2 + readable-stream: 2.3.8 + wbuf: 1.7.3 + + html-entities@2.5.2: {} + + html-escaper@2.0.2: {} + + html-minifier-terser@6.1.0: + dependencies: + camel-case: 4.1.2 + clean-css: 5.3.3 + commander: 8.3.0 + he: 1.2.0 + param-case: 3.0.4 + relateurl: 0.2.7 + terser: 5.36.0 + + html-minifier-terser@7.2.0: + dependencies: + camel-case: 4.1.2 + clean-css: 5.3.3 + commander: 10.0.1 + entities: 4.5.0 + param-case: 3.0.4 + relateurl: 0.2.7 + terser: 5.36.0 + + html-tags@3.3.1: {} + + html-void-elements@3.0.0: {} + + html-webpack-plugin@5.6.3(webpack@5.96.1): + dependencies: + '@types/html-minifier-terser': 6.1.0 + html-minifier-terser: 6.1.0 + lodash: 4.17.21 + pretty-error: 4.0.0 + tapable: 2.2.1 + optionalDependencies: + webpack: 5.96.1 + + htmlparser2@6.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + domutils: 2.8.0 + entities: 2.2.0 + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + + http-cache-semantics@4.1.1: {} + + http-deceiver@1.2.7: {} + + http-errors@1.6.3: + dependencies: + depd: 1.1.2 + inherits: 2.0.3 + setprototypeof: 1.1.0 + statuses: 1.5.0 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + http-parser-js@0.5.8: {} + + http-proxy-middleware@2.0.7(@types/express@4.17.21): + dependencies: + '@types/http-proxy': 1.17.15 + http-proxy: 1.18.1 + is-glob: 4.0.3 + is-plain-obj: 3.0.0 + micromatch: 4.0.8 + optionalDependencies: + '@types/express': 4.17.21 + transitivePeerDependencies: + - debug + + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.9 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + + http2-wrapper@2.2.1: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + human-signals@2.1.0: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + icss-utils@5.1.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + ignore@5.3.2: {} + + image-size@1.1.1: + dependencies: + queue: 6.0.2 + + immer@9.0.21: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-lazy@4.0.0: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + infima@0.2.0-alpha.45: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.3: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@2.0.0: {} + + inline-style-parser@0.1.1: {} + + inline-style-parser@0.2.4: {} + + interpret@1.4.0: {} + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + ipaddr.js@1.9.1: {} + + ipaddr.js@2.2.0: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-ci@3.0.1: + dependencies: + ci-info: 3.9.0 + + is-core-module@2.15.1: + dependencies: + hasown: 2.0.2 + + is-decimal@2.0.1: {} + + is-docker@2.2.1: {} + + is-extendable@0.1.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-installed-globally@0.4.0: + dependencies: + global-dirs: 3.0.1 + is-path-inside: 3.0.3 + + is-npm@6.0.0: {} + + is-number@7.0.0: {} + + is-obj@1.0.1: {} + + is-obj@2.0.0: {} + + is-path-cwd@2.2.0: {} + + is-path-inside@3.0.3: {} + + is-plain-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + + is-regexp@1.0.0: {} + + is-root@2.1.0: {} + + is-stream@2.0.1: {} + + is-typedarray@1.0.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-yarn-global@0.4.1: {} + + isarray@0.0.1: {} + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + isobject@3.0.1: {} + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.10.1 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + jest-worker@27.5.1: + dependencies: + '@types/node': 22.10.1 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest-worker@29.7.0: + dependencies: + '@types/node': 22.10.1 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jiti@1.21.6: {} + + joi@17.13.3: + dependencies: + '@hapi/hoek': 9.3.0 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.5 + '@sideway/formula': 3.0.1 + '@sideway/pinpoint': 2.0.0 + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.0.2: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kind-of@6.0.3: {} + + kleur@3.0.3: {} + + latest-version@7.0.0: + dependencies: + package-json: 8.1.1 + + launch-editor@2.9.1: + dependencies: + picocolors: 1.1.1 + shell-quote: 1.8.2 + + leven@3.1.0: {} + + lilconfig@3.1.2: {} + + lines-and-columns@1.2.4: {} + + loader-runner@4.3.0: {} + + loader-utils@2.0.4: + dependencies: + big.js: 5.2.2 + emojis-list: 3.0.0 + json5: 2.2.3 + + loader-utils@3.3.1: {} + + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash.debounce@4.0.8: {} + + lodash.memoize@4.1.2: {} + + lodash.uniq@4.5.0: {} + + lodash@4.17.21: {} + + longest-streak@3.1.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + + lowercase-keys@3.0.0: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + markdown-extensions@2.0.0: {} + + markdown-table@2.0.0: + dependencies: + repeat-string: 1.6.1 + + markdown-table@3.0.4: {} + + mdast-util-directive@3.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.1 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-find-and-replace@3.0.1: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + mdast-util-from-markdown@2.0.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.1 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.0.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.1.3: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.1 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.1.3 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.0 + + mdast-util-to-hast@13.2.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.2.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.0.30: {} + + media-typer@0.3.0: {} + + memfs@3.5.3: + dependencies: + fs-monkey: 1.0.6 + + merge-descriptors@1.0.3: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromark-core-commonmark@2.0.2: + dependencies: + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-extension-directive@3.0.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + parse-entities: 4.0.1 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.2 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-extension-gfm-table@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.1 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.0 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-extension-mdx-expression@3.0.0: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.2 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-extension-mdx-jsx@3.0.1: + dependencies: + '@types/acorn': 4.0.6 + '@types/estree': 1.0.6 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.2 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + vfile-message: 4.0.2 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.1 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.2 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.2 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.14.0 + acorn-jsx: 5.3.2(acorn@8.14.0) + micromark-extension-mdx-expression: 3.0.0 + micromark-extension-mdx-jsx: 3.0.1 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-factory-mdx-expression@2.0.2: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.2 + + micromark-factory-space@1.1.0: + dependencies: + micromark-util-character: 1.2.0 + micromark-util-types: 1.1.0 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.1 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-util-character@1.2.0: + dependencies: + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.0.2 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.2: + dependencies: + '@types/acorn': 4.0.6 + '@types/estree': 1.0.6 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + vfile-message: 4.0.2 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.1 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.0.3: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + + micromark-util-symbol@1.1.0: {} + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@1.1.0: {} + + micromark-util-types@2.0.1: {} + + micromark@4.0.1: + dependencies: + '@types/debug': 4.1.12 + debug: 4.3.7 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.2 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.1 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.33.0: {} + + mime-db@1.52.0: {} + + mime-db@1.53.0: {} + + mime-types@2.1.18: + dependencies: + mime-db: 1.33.0 + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mimic-fn@2.1.0: {} + + mimic-response@3.1.0: {} + + mimic-response@4.0.0: {} + + mini-css-extract-plugin@2.9.2(webpack@5.96.1): + dependencies: + schema-utils: 4.2.0 + tapable: 2.2.1 + webpack: 5.96.1 + + minimalistic-assert@1.0.1: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimist@1.2.8: {} + + mrmime@2.0.0: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + multicast-dns@7.2.5: + dependencies: + dns-packet: 5.6.1 + thunky: 1.1.0 + + nanoid@3.3.8: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + neo-async@2.6.2: {} + + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + + node-emoji@2.1.3: + dependencies: + '@sindresorhus/is': 4.6.0 + char-regex: 1.0.2 + emojilib: 2.4.0 + skin-tone: 2.0.0 + + node-forge@1.3.1: {} + + node-releases@2.0.18: {} + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + normalize-url@8.0.1: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nprogress@0.2.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + null-loader@4.0.1(webpack@5.96.1): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.96.1 + + object-assign@4.1.1: {} + + object-inspect@1.13.3: {} + + object-keys@1.1.1: {} + + object.assign@4.1.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + has-symbols: 1.0.3 + object-keys: 1.1.1 + + obuf@1.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.0.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + opener@1.5.2: {} + + p-cancelable@3.0.0: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@4.0.0: + dependencies: + yocto-queue: 1.1.1 + + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + p-try@2.2.0: {} + + package-json@8.1.1: + dependencies: + got: 12.6.1 + registry-auth-token: 5.0.3 + registry-url: 6.0.1 + semver: 7.6.3 + + param-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-entities@4.0.1: + dependencies: + '@types/unist': 2.0.11 + character-entities: 2.0.2 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.0.2 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.26.2 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-numeric-range@1.3.0: {} + + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.2.1 + + parse5@7.2.1: + dependencies: + entities: 4.5.0 + + parseurl@1.3.3: {} + + pascal-case@3.1.2: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + path-exists@3.0.0: {} + + path-exists@4.0.0: {} + + path-exists@5.0.0: {} + + path-is-absolute@1.0.1: {} + + path-is-inside@1.0.2: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-to-regexp@0.1.10: {} + + path-to-regexp@1.9.0: + dependencies: + isarray: 0.0.1 + + path-to-regexp@3.3.0: {} + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + pkg-dir@7.0.0: + dependencies: + find-up: 6.3.0 + + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + + postcss-attribute-case-insensitive@7.0.1(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + + postcss-calc@9.0.1(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 6.1.2 + postcss-value-parser: 4.2.0 + + postcss-clamp@4.1.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-color-functional-notation@7.0.6(postcss@8.4.49): + dependencies: + '@csstools/css-color-parser': 3.0.6(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + + postcss-color-hex-alpha@10.0.0(postcss@8.4.49): + dependencies: + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-color-rebeccapurple@10.0.0(postcss@8.4.49): + dependencies: + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-colormin@6.1.0(postcss@8.4.49): + dependencies: + browserslist: 4.24.2 + caniuse-api: 3.0.0 + colord: 2.9.3 + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-convert-values@6.1.0(postcss@8.4.49): + dependencies: + browserslist: 4.24.2 + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-custom-media@11.0.5(postcss@8.4.49): + dependencies: + '@csstools/cascade-layer-name-parser': 2.0.4(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/media-query-list-parser': 4.0.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + postcss: 8.4.49 + + postcss-custom-properties@14.0.4(postcss@8.4.49): + dependencies: + '@csstools/cascade-layer-name-parser': 2.0.4(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-custom-selectors@8.0.4(postcss@8.4.49): + dependencies: + '@csstools/cascade-layer-name-parser': 2.0.4(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + + postcss-dir-pseudo-class@9.0.1(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + + postcss-discard-comments@6.0.2(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + postcss-discard-duplicates@6.0.3(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + postcss-discard-empty@6.0.3(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + postcss-discard-overridden@6.0.2(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + postcss-discard-unused@6.0.5(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 6.1.2 + + postcss-double-position-gradients@6.0.0(postcss@8.4.49): + dependencies: + '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-focus-visible@10.0.1(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + + postcss-focus-within@9.0.1(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + + postcss-font-variant@5.0.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + postcss-gap-properties@6.0.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + postcss-image-set-function@7.0.0(postcss@8.4.49): + dependencies: + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-lab-function@7.0.6(postcss@8.4.49): + dependencies: + '@csstools/css-color-parser': 3.0.6(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) + '@csstools/utilities': 2.0.0(postcss@8.4.49) + postcss: 8.4.49 + + postcss-loader@7.3.4(postcss@8.4.49)(typescript@5.2.2)(webpack@5.96.1): + dependencies: + cosmiconfig: 8.3.6(typescript@5.2.2) + jiti: 1.21.6 + postcss: 8.4.49 + semver: 7.6.3 + webpack: 5.96.1 + transitivePeerDependencies: + - typescript + + postcss-logical@8.0.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-merge-idents@6.0.3(postcss@8.4.49): + dependencies: + cssnano-utils: 4.0.2(postcss@8.4.49) + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-merge-longhand@6.0.5(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + stylehacks: 6.1.1(postcss@8.4.49) + + postcss-merge-rules@6.1.1(postcss@8.4.49): + dependencies: + browserslist: 4.24.2 + caniuse-api: 3.0.0 + cssnano-utils: 4.0.2(postcss@8.4.49) + postcss: 8.4.49 + postcss-selector-parser: 6.1.2 + + postcss-minify-font-values@6.1.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-minify-gradients@6.0.3(postcss@8.4.49): + dependencies: + colord: 2.9.3 + cssnano-utils: 4.0.2(postcss@8.4.49) + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-minify-params@6.1.0(postcss@8.4.49): + dependencies: + browserslist: 4.24.2 + cssnano-utils: 4.0.2(postcss@8.4.49) + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-minify-selectors@6.0.4(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 6.1.2 + + postcss-modules-extract-imports@3.1.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + postcss-modules-local-by-default@4.1.0(postcss@8.4.49): + dependencies: + icss-utils: 5.1.0(postcss@8.4.49) + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + postcss-value-parser: 4.2.0 + + postcss-modules-scope@3.2.1(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + + postcss-modules-values@4.0.0(postcss@8.4.49): + dependencies: + icss-utils: 5.1.0(postcss@8.4.49) + postcss: 8.4.49 + + postcss-nesting@13.0.1(postcss@8.4.49): + dependencies: + '@csstools/selector-resolve-nested': 3.0.0(postcss-selector-parser@7.0.0) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.0.0) + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + + postcss-normalize-charset@6.0.2(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + postcss-normalize-display-values@6.0.2(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-normalize-positions@6.0.2(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-normalize-repeat-style@6.0.2(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-normalize-string@6.0.2(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-normalize-timing-functions@6.0.2(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-normalize-unicode@6.1.0(postcss@8.4.49): + dependencies: + browserslist: 4.24.2 + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-normalize-url@6.0.2(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-normalize-whitespace@6.0.2(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-opacity-percentage@3.0.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + postcss-ordered-values@6.0.2(postcss@8.4.49): + dependencies: + cssnano-utils: 4.0.2(postcss@8.4.49) + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-overflow-shorthand@6.0.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-page-break@3.0.4(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + postcss-place@10.0.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-preset-env@10.1.1(postcss@8.4.49): + dependencies: + '@csstools/postcss-cascade-layers': 5.0.1(postcss@8.4.49) + '@csstools/postcss-color-function': 4.0.6(postcss@8.4.49) + '@csstools/postcss-color-mix-function': 3.0.6(postcss@8.4.49) + '@csstools/postcss-content-alt-text': 2.0.4(postcss@8.4.49) + '@csstools/postcss-exponential-functions': 2.0.5(postcss@8.4.49) + '@csstools/postcss-font-format-keywords': 4.0.0(postcss@8.4.49) + '@csstools/postcss-gamut-mapping': 2.0.6(postcss@8.4.49) + '@csstools/postcss-gradients-interpolation-method': 5.0.6(postcss@8.4.49) + '@csstools/postcss-hwb-function': 4.0.6(postcss@8.4.49) + '@csstools/postcss-ic-unit': 4.0.0(postcss@8.4.49) + '@csstools/postcss-initial': 2.0.0(postcss@8.4.49) + '@csstools/postcss-is-pseudo-class': 5.0.1(postcss@8.4.49) + '@csstools/postcss-light-dark-function': 2.0.7(postcss@8.4.49) + '@csstools/postcss-logical-float-and-clear': 3.0.0(postcss@8.4.49) + '@csstools/postcss-logical-overflow': 2.0.0(postcss@8.4.49) + '@csstools/postcss-logical-overscroll-behavior': 2.0.0(postcss@8.4.49) + '@csstools/postcss-logical-resize': 3.0.0(postcss@8.4.49) + '@csstools/postcss-logical-viewport-units': 3.0.3(postcss@8.4.49) + '@csstools/postcss-media-minmax': 2.0.5(postcss@8.4.49) + '@csstools/postcss-media-queries-aspect-ratio-number-values': 3.0.4(postcss@8.4.49) + '@csstools/postcss-nested-calc': 4.0.0(postcss@8.4.49) + '@csstools/postcss-normalize-display-values': 4.0.0(postcss@8.4.49) + '@csstools/postcss-oklab-function': 4.0.6(postcss@8.4.49) + '@csstools/postcss-progressive-custom-properties': 4.0.0(postcss@8.4.49) + '@csstools/postcss-random-function': 1.0.1(postcss@8.4.49) + '@csstools/postcss-relative-color-syntax': 3.0.6(postcss@8.4.49) + '@csstools/postcss-scope-pseudo-class': 4.0.1(postcss@8.4.49) + '@csstools/postcss-sign-functions': 1.1.0(postcss@8.4.49) + '@csstools/postcss-stepped-value-functions': 4.0.5(postcss@8.4.49) + '@csstools/postcss-text-decoration-shorthand': 4.0.1(postcss@8.4.49) + '@csstools/postcss-trigonometric-functions': 4.0.5(postcss@8.4.49) + '@csstools/postcss-unset-value': 4.0.0(postcss@8.4.49) + autoprefixer: 10.4.20(postcss@8.4.49) + browserslist: 4.24.2 + css-blank-pseudo: 7.0.1(postcss@8.4.49) + css-has-pseudo: 7.0.1(postcss@8.4.49) + css-prefers-color-scheme: 10.0.0(postcss@8.4.49) + cssdb: 8.2.1 + postcss: 8.4.49 + postcss-attribute-case-insensitive: 7.0.1(postcss@8.4.49) + postcss-clamp: 4.1.0(postcss@8.4.49) + postcss-color-functional-notation: 7.0.6(postcss@8.4.49) + postcss-color-hex-alpha: 10.0.0(postcss@8.4.49) + postcss-color-rebeccapurple: 10.0.0(postcss@8.4.49) + postcss-custom-media: 11.0.5(postcss@8.4.49) + postcss-custom-properties: 14.0.4(postcss@8.4.49) + postcss-custom-selectors: 8.0.4(postcss@8.4.49) + postcss-dir-pseudo-class: 9.0.1(postcss@8.4.49) + postcss-double-position-gradients: 6.0.0(postcss@8.4.49) + postcss-focus-visible: 10.0.1(postcss@8.4.49) + postcss-focus-within: 9.0.1(postcss@8.4.49) + postcss-font-variant: 5.0.0(postcss@8.4.49) + postcss-gap-properties: 6.0.0(postcss@8.4.49) + postcss-image-set-function: 7.0.0(postcss@8.4.49) + postcss-lab-function: 7.0.6(postcss@8.4.49) + postcss-logical: 8.0.0(postcss@8.4.49) + postcss-nesting: 13.0.1(postcss@8.4.49) + postcss-opacity-percentage: 3.0.0(postcss@8.4.49) + postcss-overflow-shorthand: 6.0.0(postcss@8.4.49) + postcss-page-break: 3.0.4(postcss@8.4.49) + postcss-place: 10.0.0(postcss@8.4.49) + postcss-pseudo-class-any-link: 10.0.1(postcss@8.4.49) + postcss-replace-overflow-wrap: 4.0.0(postcss@8.4.49) + postcss-selector-not: 8.0.1(postcss@8.4.49) + + postcss-pseudo-class-any-link@10.0.1(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + + postcss-reduce-idents@6.0.3(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-reduce-initial@6.1.0(postcss@8.4.49): + dependencies: + browserslist: 4.24.2 + caniuse-api: 3.0.0 + postcss: 8.4.49 + + postcss-reduce-transforms@6.0.2(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + postcss-replace-overflow-wrap@4.0.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + postcss-selector-not@8.0.1(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 7.0.0 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@7.0.0: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-sort-media-queries@5.2.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + sort-css-media-queries: 2.2.0 + + postcss-svgo@6.0.3(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + svgo: 3.3.2 + + postcss-unique-selectors@6.0.4(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 6.1.2 + + postcss-value-parser@4.2.0: {} + + postcss-zindex@6.0.2(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + + postcss@8.4.49: + dependencies: + nanoid: 3.3.8 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-error@4.0.0: + dependencies: + lodash: 4.17.21 + renderkid: 3.0.0 + + pretty-time@1.1.0: {} + + prism-react-renderer@2.4.0(react@18.3.1): + dependencies: + '@types/prismjs': 1.26.5 + clsx: 2.1.1 + react: 18.3.1 + + prismjs@1.29.0: {} + + process-nextick-args@2.0.1: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + property-information@6.5.0: {} + + proto-list@1.2.4: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + pupa@3.1.0: + dependencies: + escape-goat: 4.0.0 + + qs@6.13.0: + dependencies: + side-channel: 1.0.6 + + queue-microtask@1.2.3: {} + + queue@6.0.2: + dependencies: + inherits: 2.0.4 + + quick-lru@5.1.1: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + range-parser@1.2.0: {} + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dev-utils@12.0.1(typescript@5.2.2)(webpack@5.96.1): + dependencies: + '@babel/code-frame': 7.26.2 + address: 1.2.2 + browserslist: 4.24.2 + chalk: 4.1.2 + cross-spawn: 7.0.6 + detect-port-alt: 1.1.6 + escape-string-regexp: 4.0.0 + filesize: 8.0.7 + find-up: 5.0.0 + fork-ts-checker-webpack-plugin: 6.5.3(typescript@5.2.2)(webpack@5.96.1) + global-modules: 2.0.0 + globby: 11.1.0 + gzip-size: 6.0.0 + immer: 9.0.21 + is-root: 2.1.0 + loader-utils: 3.3.1 + open: 8.4.2 + pkg-up: 3.1.0 + prompts: 2.4.2 + react-error-overlay: 6.0.11 + recursive-readdir: 2.2.3 + shell-quote: 1.8.2 + strip-ansi: 6.0.1 + text-table: 0.2.0 + webpack: 5.96.1 + optionalDependencies: + typescript: 5.2.2 + transitivePeerDependencies: + - eslint + - supports-color + - vue-template-compiler + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-error-overlay@6.0.11: {} + + react-fast-compare@3.2.2: {} + + react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.26.0 + invariant: 2.2.4 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-fast-compare: 3.2.2 + shallowequal: 1.1.0 + + react-helmet-async@2.0.5(react@18.3.1): + dependencies: + invariant: 2.2.4 + react: 18.3.1 + react-fast-compare: 3.2.2 + shallowequal: 1.1.0 + + react-is@16.13.1: {} + + react-json-view-lite@1.5.0(react@18.3.1): + dependencies: + react: 18.3.1 + + react-loadable-ssr-addon-v5-slorber@1.0.1(@docusaurus/react-loadable@6.0.0(react@18.3.1))(webpack@5.96.1): + dependencies: + '@babel/runtime': 7.26.0 + react-loadable: '@docusaurus/react-loadable@6.0.0(react@18.3.1)' + webpack: 5.96.1 + + react-router-config@5.1.1(react-router@5.3.4(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.26.0 + react: 18.3.1 + react-router: 5.3.4(react@18.3.1) + + react-router-dom@5.3.4(react@18.3.1): + dependencies: + '@babel/runtime': 7.26.0 + history: 4.10.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-router: 5.3.4(react@18.3.1) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + react-router@5.3.4(react@18.3.1): + dependencies: + '@babel/runtime': 7.26.0 + history: 4.10.1 + hoist-non-react-statics: 3.3.2 + loose-envify: 1.4.0 + path-to-regexp: 1.9.0 + prop-types: 15.8.1 + react: 18.3.1 + react-is: 16.13.1 + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + reading-time@1.5.0: {} + + rechoir@0.6.2: + dependencies: + resolve: 1.22.8 + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.6 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.0(acorn@8.14.0): + dependencies: + acorn-jsx: 5.3.2(acorn@8.14.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - acorn + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.6 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.6 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + + recursive-readdir@2.2.3: + dependencies: + minimatch: 3.1.2 + + regenerate-unicode-properties@10.2.0: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regenerator-runtime@0.14.1: {} + + regenerator-transform@0.15.2: + dependencies: + '@babel/runtime': 7.26.0 + + regexpu-core@6.2.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.0 + regjsgen: 0.8.0 + regjsparser: 0.12.0 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.0 + + registry-auth-token@5.0.3: + dependencies: + '@pnpm/npm-conf': 2.3.1 + + registry-url@6.0.1: + dependencies: + rc: 1.2.8 + + regjsgen@0.8.0: {} + + regjsparser@0.12.0: + dependencies: + jsesc: 3.0.2 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.6 + '@types/hast': 3.0.4 + hast-util-to-estree: 3.1.0 + transitivePeerDependencies: + - supports-color + + relateurl@0.2.7: {} + + remark-directive@3.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.0.0 + micromark-extension-directive: 3.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-emoji@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + emoticon: 4.1.0 + mdast-util-find-and-replace: 3.0.1 + node-emoji: 2.1.3 + unified: 11.0.5 + + remark-frontmatter@5.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-frontmatter: 2.0.1 + micromark-extension-frontmatter: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.0.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.0: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + micromark-util-types: 2.0.1 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.0 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + renderkid@3.0.0: + dependencies: + css-select: 4.3.0 + dom-converter: 0.2.0 + htmlparser2: 6.1.0 + lodash: 4.17.21 + strip-ansi: 6.0.1 + + repeat-string@1.6.1: {} + + require-from-string@2.0.2: {} + + require-like@0.1.2: {} + + requires-port@1.0.0: {} + + resolve-alpn@1.2.1: {} + + resolve-from@4.0.0: {} + + resolve-pathname@3.0.0: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + responselike@3.0.0: + dependencies: + lowercase-keys: 3.0.0 + + retry@0.13.1: {} + + reusify@1.0.4: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rtl-detect@1.1.2: {} + + rtlcss@4.3.0: + dependencies: + escalade: 3.2.0 + picocolors: 1.1.1 + postcss: 8.4.49 + strip-json-comments: 3.1.1 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sax@1.4.1: {} + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + schema-utils@2.7.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + + schema-utils@4.2.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + ajv-keywords: 5.1.0(ajv@8.17.1) + + search-insights@2.17.3: {} + + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + + select-hose@2.0.0: {} + + selfsigned@2.4.1: + dependencies: + '@types/node-forge': 1.3.11 + node-forge: 1.3.1 + + semver-diff@4.0.0: + dependencies: + semver: 7.6.3 + + semver@6.3.1: {} + + semver@7.6.3: {} + + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + serve-handler@6.1.6: + dependencies: + bytes: 3.0.0 + content-disposition: 0.5.2 + mime-types: 2.1.18 + minimatch: 3.1.2 + path-is-inside: 1.0.2 + path-to-regexp: 3.3.0 + range-parser: 1.2.0 + + serve-index@1.9.1: + dependencies: + accepts: 1.3.8 + batch: 0.6.1 + debug: 2.6.9 + escape-html: 1.0.3 + http-errors: 1.6.3 + mime-types: 2.1.35 + parseurl: 1.3.3 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + gopd: 1.1.0 + has-property-descriptors: 1.0.2 + + setprototypeof@1.1.0: {} + + setprototypeof@1.2.0: {} + + shallow-clone@3.0.1: + dependencies: + kind-of: 6.0.3 + + shallowequal@1.1.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.2: {} + + shelljs@0.8.5: + dependencies: + glob: 7.2.3 + interpret: 1.4.0 + rechoir: 0.6.2 + + side-channel@1.0.6: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + object-inspect: 1.13.3 + + signal-exit@3.0.7: {} + + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.28 + mrmime: 2.0.0 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + sitemap@7.1.2: + dependencies: + '@types/node': 17.0.45 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.4.1 + + skin-tone@2.0.0: + dependencies: + unicode-emoji-modifier-base: 1.0.0 + + slash@3.0.0: {} + + slash@4.0.0: {} + + snake-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + sockjs@0.3.24: + dependencies: + faye-websocket: 0.11.4 + uuid: 8.3.2 + websocket-driver: 0.7.4 + + sort-css-media-queries@2.2.0: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.4: {} + + space-separated-tokens@2.0.2: {} + + spdy-transport@3.0.0: + dependencies: + debug: 4.3.7 + detect-node: 2.1.0 + hpack.js: 2.1.6 + obuf: 1.1.2 + readable-stream: 3.6.2 + wbuf: 1.7.3 + transitivePeerDependencies: + - supports-color + + spdy@4.0.2: + dependencies: + debug: 4.3.7 + handle-thing: 2.0.1 + http-deceiver: 1.2.7 + select-hose: 2.0.0 + spdy-transport: 3.0.0 + transitivePeerDependencies: + - supports-color + + sprintf-js@1.0.3: {} + + srcset@4.0.0: {} + + statuses@1.5.0: {} + + statuses@2.0.1: {} + + std-env@3.8.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + stringify-object@3.3.0: + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + strip-bom-string@1.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + style-to-object@0.4.4: + dependencies: + inline-style-parser: 0.1.1 + + style-to-object@1.0.8: + dependencies: + inline-style-parser: 0.2.4 + + stylehacks@6.1.1(postcss@8.4.49): + dependencies: + browserslist: 4.24.2 + postcss: 8.4.49 + postcss-selector-parser: 6.1.2 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-parser@2.0.4: {} + + svgo@3.3.2: + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 5.1.0 + css-tree: 2.3.1 + css-what: 6.1.0 + csso: 5.0.5 + picocolors: 1.1.1 + + tapable@1.1.3: {} + + tapable@2.2.1: {} + + terser-webpack-plugin@5.3.10(webpack@5.96.1): + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.36.0 + webpack: 5.96.1 + + terser@5.36.0: + dependencies: + '@jridgewell/source-map': 0.3.6 + acorn: 8.14.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + text-table@0.2.0: {} + + thunky@1.1.0: {} + + tiny-invariant@1.3.3: {} + + tiny-warning@1.0.3: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + totalist@3.0.1: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: {} + + type-fest@0.21.3: {} + + type-fest@1.4.0: {} + + type-fest@2.19.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typedarray-to-buffer@3.1.5: + dependencies: + is-typedarray: 1.0.0 + + typescript@5.2.2: {} + + undici-types@6.20.0: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-emoji-modifier-base@1.0.0: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.1.0 + + unicode-match-property-value-ecmascript@2.2.0: {} + + unicode-property-aliases-ecmascript@2.1.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unique-string@3.0.0: + dependencies: + crypto-random-string: 4.0.0 + + unist-util-is@6.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.1: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.1.1(browserslist@4.24.2): + dependencies: + browserslist: 4.24.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + update-notifier@6.0.2: + dependencies: + boxen: 7.1.1 + chalk: 5.3.0 + configstore: 6.0.0 + has-yarn: 3.0.0 + import-lazy: 4.0.0 + is-ci: 3.0.1 + is-installed-globally: 0.4.0 + is-npm: 6.0.0 + is-yarn-global: 0.4.1 + latest-version: 7.0.0 + pupa: 3.1.0 + semver: 7.6.3 + semver-diff: 4.0.0 + xdg-basedir: 5.1.0 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-loader@4.1.1(file-loader@6.2.0(webpack@5.96.1))(webpack@5.96.1): + dependencies: + loader-utils: 2.0.4 + mime-types: 2.1.35 + schema-utils: 3.3.0 + webpack: 5.96.1 + optionalDependencies: + file-loader: 6.2.0(webpack@5.96.1) + + util-deprecate@1.0.2: {} + + utila@0.4.0: {} + + utility-types@3.11.0: {} + + utils-merge@1.0.1: {} + + uuid@8.3.2: {} + + value-equal@1.0.1: {} + + vary@1.1.2: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.2 + + watchpack@2.4.2: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + wbuf@1.7.3: + dependencies: + minimalistic-assert: 1.0.1 + + web-namespaces@2.0.1: {} + + webpack-bundle-analyzer@4.10.2: + dependencies: + '@discoveryjs/json-ext': 0.5.7 + acorn: 8.14.0 + acorn-walk: 8.3.4 + commander: 7.2.0 + debounce: 1.2.1 + escape-string-regexp: 4.0.0 + gzip-size: 6.0.0 + html-escaper: 2.0.2 + opener: 1.5.2 + picocolors: 1.1.1 + sirv: 2.0.4 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + webpack-dev-middleware@5.3.4(webpack@5.96.1): + dependencies: + colorette: 2.0.20 + memfs: 3.5.3 + mime-types: 2.1.35 + range-parser: 1.2.1 + schema-utils: 4.2.0 + webpack: 5.96.1 + + webpack-dev-server@4.15.2(webpack@5.96.1): + dependencies: + '@types/bonjour': 3.5.13 + '@types/connect-history-api-fallback': 1.5.4 + '@types/express': 4.17.21 + '@types/serve-index': 1.9.4 + '@types/serve-static': 1.15.7 + '@types/sockjs': 0.3.36 + '@types/ws': 8.5.13 + ansi-html-community: 0.0.8 + bonjour-service: 1.3.0 + chokidar: 3.6.0 + colorette: 2.0.20 + compression: 1.7.5 + connect-history-api-fallback: 2.0.0 + default-gateway: 6.0.3 + express: 4.21.1 + graceful-fs: 4.2.11 + html-entities: 2.5.2 + http-proxy-middleware: 2.0.7(@types/express@4.17.21) + ipaddr.js: 2.2.0 + launch-editor: 2.9.1 + open: 8.4.2 + p-retry: 4.6.2 + rimraf: 3.0.2 + schema-utils: 4.2.0 + selfsigned: 2.4.1 + serve-index: 1.9.1 + sockjs: 0.3.24 + spdy: 4.0.2 + webpack-dev-middleware: 5.3.4(webpack@5.96.1) + ws: 8.18.0 + optionalDependencies: + webpack: 5.96.1 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + webpack-merge@5.10.0: + dependencies: + clone-deep: 4.0.1 + flat: 5.0.2 + wildcard: 2.0.1 + + webpack-merge@6.0.1: + dependencies: + clone-deep: 4.0.1 + flat: 5.0.2 + wildcard: 2.0.1 + + webpack-sources@3.2.3: {} + + webpack@5.96.1: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.6 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.14.0 + browserslist: 4.24.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.17.1 + es-module-lexer: 1.5.4 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.10(webpack@5.96.1) + watchpack: 2.4.2 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + + webpackbar@6.0.1(webpack@5.96.1): + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + consola: 3.2.3 + figures: 3.2.0 + markdown-table: 2.0.0 + pretty-time: 1.1.0 + std-env: 3.8.0 + webpack: 5.96.1 + wrap-ansi: 7.0.0 + + websocket-driver@0.7.4: + dependencies: + http-parser-js: 0.5.8 + safe-buffer: 5.2.1 + websocket-extensions: 0.1.4 + + websocket-extensions@0.1.4: {} + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + widest-line@4.0.1: + dependencies: + string-width: 5.1.2 + + wildcard@2.0.1: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + write-file-atomic@3.0.3: + dependencies: + imurmurhash: 0.1.4 + is-typedarray: 1.0.0 + signal-exit: 3.0.7 + typedarray-to-buffer: 3.1.5 + + ws@7.5.10: {} + + ws@8.18.0: {} + + xdg-basedir@5.1.0: {} + + xml-js@1.6.11: + dependencies: + sax: 1.4.1 + + yallist@3.1.1: {} + + yaml@1.10.2: {} + + yocto-queue@0.1.0: {} + + yocto-queue@1.1.1: {} + + zwitch@2.0.4: {} diff --git a/docs/sidebars.ts b/docs/sidebars.ts new file mode 100644 index 0000000..bf3f00d --- /dev/null +++ b/docs/sidebars.ts @@ -0,0 +1,31 @@ +import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"; + +/** + * Creating a sidebar enables you to: + - create an ordered group of docs + - render a sidebar for each doc of that group + - provide next/previous navigation + + The sidebars can be generated from the filesystem, or explicitly defined here. + + Create as many sidebars as you want. + */ +const sidebars: SidebarsConfig = { + // By default, Docusaurus generates a sidebar from the docs folder structure + documentationSidebar: [{ type: "autogenerated", dirName: "." }], + + // But you can create a sidebar manually + /* + tutorialSidebar: [ + 'intro', + 'hello', + { + type: 'category', + label: 'Tutorial', + items: ['tutorial-basics/create-a-document'], + }, + ], + */ +}; + +export default sidebars; diff --git a/docs/src/components/load_module.tsx b/docs/src/components/load_module.tsx new file mode 100644 index 0000000..4538866 --- /dev/null +++ b/docs/src/components/load_module.tsx @@ -0,0 +1,160 @@ +import React from "react"; +import CodeBlock from "@theme/CodeBlock"; + +const LoadModuleDocs = ({ module }: { module: "go" | "lua" | "js" }) => { + const module_path = + module === "go" + ? "path/to/module/module.so" + : module === "lua" + ? "path/to/module/module.lua" + : "path/to/module/module.js"; + + return ( +
+

You can load modules in 3 ways:

+ +

1. At startup with the `--loadmodule` flag.

+

+ Upon startup you can provide the flag {module_path}. This is the path to + the module's file. You can pass this flag multiple times to load + multiple modules on startup. +

+ +

2. At runtime with the `MODULE LOAD` command.

+

+ You can load modules dynamically at runtime using the `MODULE LOAD` + command as follows: +

+ {`MODULE LOAD ${module_path}`} +

+ This command only takes one path so if you have multiple modules to + load, You will have to load them one at a time. +

+ +

3. At runtime the `LoadModule` method.

+

+ You can load a module .so file dynamically at runtime using the{" "} + + `LoadModule` + {" "} + method in the embedded API. +

+ {`err = server.LoadModule("${module_path}")`} + +

Loading Module with Args

+

+ You might have notices the `args ...string` variadic parameter when + creating a module. This a list of args that are passed to the module's + key extraction and handler functions. +

+

+ The values passed here are established once when loading the module, and + the same values will be passed to the respective functions everytime the + command is executed. +

+

+ If you don't provide any args, an empty slice will be passed in the args + parameter. Otehrwise, a slice containing your defined args will be used. +

+

To load a module with args using the embedded API:

+ + {`err = server.LoadModule("${module_path}", "list", "of", "args")`} + +

To load a module with args using the `MODULE LOAD` command:

+ + {`MODULE LOAD ${module_path} arg1 arg2 arg3`} + +

+ NOTE: You cannot pass args when loading modules at startup with the + `--loadmodule` flag. +

+ +

List Modules

+

+ You can list the current modules loaded in the SugarDB instance using + both the Client-Server and embedded APIs. +

+

+ To check the loaded modules using the embedded API, use the{" "} + + `ListModules` + {" "} + method: +

+ {`modules := server.ListModules()`} +

+ This method returns a string slice containing all the loaded modules in + the SugarDB instance. +

+

+ You can also list the loaded modules over the TCP API using the `MODULE + LIST` command. +

+

Here's an example response of the loaded modules:

+ {`1) "acl" +2) "admin" +3) "connection" +4) "generic" +5) "hash" +6) "list" +7) "pubsub" +8) "set" +9) "sortedset" +10) "string" +11) "${module_path}"`} +

+ Notice that the modules loaded from .so files have their respective file + names as the module name. +

+ +

Execute Module Command

+

+ Here's an example of executing the `Module.Set` command with the + embedded API: +

+

+ Here's an example of executing the COPYDEFAULT custom command that we + created previously: +

+ {`// Execute the custom COPYDEFAULT command +res, err := server.ExecuteCommand("Module.Set", "key1", "10") +if err != nil { + fmt.Println(err) +} else { + fmt.Println(string(res)) +}`} +

+ Here's how we would exectute the same command over the TCP client-server + interface: +

+ {`Module.Set key1 10`} + +

Unload Module

+

+ You can unload modules from the SugarDB instance using both the embedded + and TCP APIs. +

+

Here's an example of unloading a module using the embedded API:

+ {`// Unload custom module +server.UnloadModule("${module_path}") +// Unload built-in module +server.UnloadModule("sortedset")`} +

Here's an example of unloading a module using the TCP interface:

+ {`MODULE UNLOAD ${module_path}`} +

+ When unloading a module, the name should be equal to what's returned + from the `ListModules` method or the `ModuleList` command. +

+
+ ); +}; + +export default LoadModuleDocs; diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css new file mode 100644 index 0000000..fca6519 --- /dev/null +++ b/docs/src/css/custom.css @@ -0,0 +1,121 @@ +/** + * Any CSS included here will be global. The classic template + * bundles Infima by default. Infima is a CSS framework designed to + * work well for content-centric websites. + */ + + +/* You can override the default Infima variables here. */ +:root { + --ifm-color-primary: #9a9a9a; + --ifm-color-primary-dark: #9a9a9a; + --ifm-color-primary-darker: #9a9a9a; + --ifm-color-primary-darkest: #9a9a9a; + --ifm-color-primary-light: #9a9a9a; + --ifm-color-primary-lighter: #9a9a9a; + --ifm-color-primary-lightest: #9a9a9a; + --ifm-code-font-size: 95%; + --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); +} + +/* For readability concerns, you should choose a lighter palette in dark mode. */ +[data-theme='dark'] { + --ifm-color-primary: #9a9a9a; + --ifm-color-primary-dark: #9a9a9a; + --ifm-color-primary-darker: #9a9a9a; + --ifm-color-primary-darkest: #9a9a9a; + --ifm-color-primary-light: #9a9a9a; + --ifm-color-primary-lighter: #9a9a9a; + --ifm-color-primary-lightest: #9a9a9a; + --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); +} + +.navbar { + background: black; +} + +.footer { + background: black; +} + +.home img { + float: right; +} + +#commandPageContainer { + display: flex; + flex-direction: column; + gap: 16px; +} + +#aclCategoryContainer { + display: inline-flex; + flex-direction: row; + gap: 4px; +} + +.acl-category { + padding: 4px; + padding-left: 8px; + padding-right: 8px; + font-weight: bold; + /* Special styles for acl category badges (default) */ + background-color: black; + color: white; +} + +[data-theme="dark"] .acl-category { + padding: 4px; + padding-left: 8px; + padding-right: 8px; + font-weight: bold; + /* Special styles for acl category badges (dark) */ + background-color: white; + color: black; +} + +#commandExampleContainer { + display: flex; + flex-direction: column; + gap: 16px; +} + +#commandExampleTabHeader{ + display: flex; + flex-direction: row; + gap: 16px; +} + +.command-example-tab { + padding-top: 4px; + padding-bottom: 4px; + padding-left: 8px; + padding-right: 8px; + font-weight: bold; + cursor: pointer; +} + +.command-example-tab.active { + padding-top: 4px; + padding-bottom: 4px; + padding-left: 8px; + padding-right: 8px; + font-weight: bold; + cursor: pointer; + /* Special styles for active tabs (default) */ + background-color: black; + color: white; +} + +[data-theme="dark"] .command-example-tab.active { + padding-top: 4px; + padding-bottom: 4px; + padding-left: 8px; + padding-right: 8px; + font-weight: bold; + cursor: pointer; + /* Special styles for active tabs (dark) */ + background-color: white; + color: black; +} + diff --git a/docs/src/pages/index.mdx b/docs/src/pages/index.mdx new file mode 100644 index 0000000..c5ea670 --- /dev/null +++ b/docs/src/pages/index.mdx @@ -0,0 +1,24 @@ +
+ +# Unleash the Power of Configurable, Distributed In-Memory Storage +SugarDB is a highly configurable, distributed, in-memory data store and cache implemented in Go. It can be imported as a Go library or run as an independent service. + +SugarDB aims to provide a rich set of data structures and functions for manipulating data in memory. These data structures include, but are not limited to: Lists, Sets, Sorted Sets, Hashes, and more. + +SugarDB provides a persistence layer for increased reliability. Both Append-Only files and snapshots can be used to persist data in the disk for recovery in case of unexpected shutdowns. + +Replication is a core feature of SugarDB and is implemented using the RAFT algorithm, allowing you to create a fault-tolerant cluster of SugarDB nodes to improve reliability. If you do not need a replication cluster, you can always run SugarDB in standalone mode and have a fully capable single node. + +SugarDB aims to not only be a server but to be importable to existing projects to enhance them with SugarDB features, this capability is always being worked on and improved. + +# Features +Some key features offered by SugarDB include: + +- TLS and mTLS support for multiple server and client RootCAs. +- Replication cluster support using the RAFT algorithm. +- ACL Layer for user Authentication and Authorization. +- Distributed Pub/Sub functionality with consumer groups. +- Sets, Sorted Sets, Hashes, Lists and more. +- Persistence layer with Snapshots and Append-Only files. +- Key Eviction Policies. +
\ No newline at end of file diff --git a/docs/src/theme/MDXComponents.ts b/docs/src/theme/MDXComponents.ts new file mode 100644 index 0000000..80c9b76 --- /dev/null +++ b/docs/src/theme/MDXComponents.ts @@ -0,0 +1,9 @@ +// Import the original mapper +import MDXComponents from "@theme-original/MDXComponents"; + +export default { + // Re-use the default mapping + ...MDXComponents, + // Map the "" tag to our Highlight component + // `Highlight` will receive all props that were passed to `` in MDX +}; diff --git a/docs/static/.nojekyll b/docs/static/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/static/img/logo.png b/docs/static/img/logo.png new file mode 100644 index 0000000..47b76be Binary files /dev/null and b/docs/static/img/logo.png differ diff --git a/docs/static/img/ram.png b/docs/static/img/ram.png new file mode 100644 index 0000000..2b93101 Binary files /dev/null and b/docs/static/img/ram.png differ diff --git a/docs/tsconfig.json b/docs/tsconfig.json new file mode 100644 index 0000000..7dc3bde --- /dev/null +++ b/docs/tsconfig.json @@ -0,0 +1,8 @@ +{ + // This file is not used in compilation. It is here just for a nice editor experience. + "extends": "@docusaurus/tsconfig", + "compilerOptions": { + "jsx": "react", + "baseUrl": "." + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..95f798c --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module apigo.cc/go/sugardb + +go 1.23.3 + +require ( + github.com/go-test/deep v1.1.1 + github.com/gobwas/glob v0.2.3 + github.com/hashicorp/memberlist v0.5.1 + github.com/hashicorp/raft v1.7.1 + github.com/hashicorp/raft-boltdb v0.0.0-20230125174641-2a8082862702 + github.com/robertkrimen/otto v0.5.1 + github.com/sethvargo/go-retry v0.3.0 + github.com/tidwall/resp v0.1.1 + github.com/yuin/gopher-lua v1.1.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/armon/go-metrics v0.4.1 // indirect + github.com/boltdb/bolt v1.3.1 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-hclog v1.6.2 // indirect + github.com/hashicorp/go-immutable-radix v1.0.0 // indirect + github.com/hashicorp/go-msgpack v0.5.5 // indirect + github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect + github.com/hashicorp/go-multierror v1.0.0 // indirect + github.com/hashicorp/go-sockaddr v1.0.0 // indirect + github.com/hashicorp/golang-lru v0.5.0 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/miekg/dns v1.1.26 // indirect + github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/net v0.16.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + gopkg.in/sourcemap.v1 v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..444d91b --- /dev/null +++ b/go.sum @@ -0,0 +1,188 @@ +github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQhVx52RsWOnlkpikZr01T/yAVN2gn0861vByNg= +github.com/armon/go-metrics v0.3.8/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= +github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c h1:964Od4U6p2jUkFxvCydnIczKteheJEzHRToSGK3Bnlw= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-hclog v0.9.1/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= +github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= +github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack/v2 v2.1.2 h1:4Ee8FTp834e+ewB71RDrQ0VKpyFdrKOjvYtnQ/ltVj0= +github.com/hashicorp/go-msgpack/v2 v2.1.2/go.mod h1:upybraOAblm4S7rx0+jeNy+CWWhzywQsSRV5033mMu4= +github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/memberlist v0.5.1 h1:mk5dRuzeDNis2bi6LLoQIXfMH7JQvAzt3mQD0vNZZUo= +github.com/hashicorp/memberlist v0.5.1/go.mod h1:zGDXV6AqbDTKTM6yxW0I4+JtFzZAJVoIPvss4hV8F24= +github.com/hashicorp/raft v1.1.0/go.mod h1:4Ak7FSPnuvmb0GV6vgIAJ4vYT4bek9bb6Q+7HVbyzqM= +github.com/hashicorp/raft v1.7.1 h1:ytxsNx4baHsRZrhUcbt3+79zc4ly8qm7pi0393pSchY= +github.com/hashicorp/raft v1.7.1/go.mod h1:hUeiEwQQR/Nk2iKDD0dkEhklSsu3jcAcqvPzPoZSAEM= +github.com/hashicorp/raft-boltdb v0.0.0-20230125174641-2a8082862702 h1:RLKEcCuKcZ+qp2VlaaZsYZfLOmIiuJNpEi48Rl8u9cQ= +github.com/hashicorp/raft-boltdb v0.0.0-20230125174641-2a8082862702/go.mod h1:nTakvJ4XYq45UXtn0DbwR4aU9ZdjlnIenpbs6Cd+FM0= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.1.26 h1:gPxPSwALAeHJSjarOs00QjVdV9QoBvc1D2ujQUr5BzU= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0= +github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE= +github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= +golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= +gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 0000000..47b76be Binary files /dev/null and b/images/logo.png differ diff --git a/internal/aof/engine.go b/internal/aof/engine.go new file mode 100644 index 0000000..e010f0e --- /dev/null +++ b/internal/aof/engine.go @@ -0,0 +1,200 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +// Package aof handles AOF logging in standalone mode only. +// Logging in replication clusters is handled in the raft layer. +package aof + +import ( + "fmt" + "apigo.cc/go/sugardb/internal" + logstore "apigo.cc/go/sugardb/internal/aof/log" + "apigo.cc/go/sugardb/internal/aof/preamble" + "apigo.cc/go/sugardb/internal/clock" + "log" + "sync" +) + +type Engine struct { + clock clock.Clock + syncStrategy string + directory string + preambleRW preamble.ReadWriter + appendRW logstore.ReadWriter + + mut sync.Mutex + logCount uint64 + preambleStore *preamble.Store + appendStore *logstore.Store + + startRewriteFunc func() + finishRewriteFunc func() + getStateFunc func() map[int]map[string]internal.KeyData + setKeyDataFunc func(database int, key string, data internal.KeyData) + handleCommand func(database int, command []byte) +} + +func WithClock(clock clock.Clock) func(engine *Engine) { + return func(engine *Engine) { + engine.clock = clock + } +} + +func WithStrategy(strategy string) func(engine *Engine) { + return func(engine *Engine) { + engine.syncStrategy = strategy + } +} + +func WithDirectory(directory string) func(engine *Engine) { + return func(engine *Engine) { + engine.directory = directory + } +} + +func WithStartRewriteFunc(f func()) func(engine *Engine) { + return func(engine *Engine) { + engine.startRewriteFunc = f + } +} + +func WithFinishRewriteFunc(f func()) func(engine *Engine) { + return func(engine *Engine) { + engine.finishRewriteFunc = f + } +} + +func WithGetStateFunc(f func() map[int]map[string]internal.KeyData) func(engine *Engine) { + return func(engine *Engine) { + engine.getStateFunc = f + } +} + +func WithSetKeyDataFunc(f func(database int, key string, data internal.KeyData)) func(engine *Engine) { + return func(engine *Engine) { + engine.setKeyDataFunc = f + } +} + +func WithHandleCommandFunc(f func(database int, command []byte)) func(engine *Engine) { + return func(engine *Engine) { + engine.handleCommand = f + } +} + +func WithPreambleReadWriter(rw preamble.ReadWriter) func(engine *Engine) { + return func(engine *Engine) { + engine.preambleRW = rw + } +} + +func WithAppendReadWriter(rw logstore.ReadWriter) func(engine *Engine) { + return func(engine *Engine) { + engine.appendRW = rw + } +} + +func NewAOFEngine(options ...func(engine *Engine)) (*Engine, error) { + engine := &Engine{ + clock: clock.NewClock(), + syncStrategy: "everysec", + directory: "", + mut: sync.Mutex{}, + logCount: 0, + startRewriteFunc: func() {}, + finishRewriteFunc: func() {}, + getStateFunc: func() map[int]map[string]internal.KeyData { return nil }, + setKeyDataFunc: func(database int, key string, data internal.KeyData) {}, + handleCommand: func(database int, command []byte) {}, + } + + // Setup AOFEngine options first as these options are used + // when setting up the PreambleStore and AppendStore + for _, option := range options { + option(engine) + } + + // Setup Preamble engine + preambleStore, err := preamble.NewPreambleStore( + preamble.WithClock(engine.clock), + preamble.WithDirectory(engine.directory), + preamble.WithReadWriter(engine.preambleRW), + preamble.WithGetStateFunc(engine.getStateFunc), + preamble.WithSetKeyDataFunc(engine.setKeyDataFunc), + ) + if err != nil { + return nil, err + } + engine.preambleStore = preambleStore + + // Setup AOF log store engine + appendStore, err := logstore.NewAppendStore( + logstore.WithClock(engine.clock), + logstore.WithDirectory(engine.directory), + logstore.WithStrategy(engine.syncStrategy), + logstore.WithReadWriter(engine.appendRW), + logstore.WithHandleCommandFunc(engine.handleCommand), + ) + if err != nil { + return nil, err + } + engine.appendStore = appendStore + + return engine, nil +} + +func (engine *Engine) LogCommand(database int, command []byte) { + if err := engine.appendStore.Write(database, command); err != nil { + log.Printf("log command error: %+v\n", err) + } +} + +func (engine *Engine) RewriteLog() error { + engine.mut.Lock() + defer engine.mut.Unlock() + + engine.startRewriteFunc() + defer engine.finishRewriteFunc() + + // Create AOF preamble. + if err := engine.preambleStore.CreatePreamble(); err != nil { + return fmt.Errorf("rewrite log error: create preamble error: %+v", err) + } + + // Truncate the AOF file. + if err := engine.appendStore.Truncate(); err != nil { + return fmt.Errorf("rewrite log error: create aof error: %+v", err) + } + + return nil +} + +func (engine *Engine) Restore() error { + if err := engine.preambleStore.Restore(); err != nil { + return fmt.Errorf("restore aof error: restore preamble error: %+v", err) + } + if err := engine.appendStore.Restore(); err != nil { + return fmt.Errorf("restore aof error: restore aof error: %+v", err) + } + return nil +} + +func (engine *Engine) Close() { + if err := engine.preambleStore.Close(); err != nil { + log.Printf("close preamble store error: %+v\n", engine) + } + if err := engine.appendStore.Close(); err != nil { + log.Printf("close append store error: %+v\n", engine) + } +} diff --git a/internal/aof/engine_test.go b/internal/aof/engine_test.go new file mode 100644 index 0000000..fe36c57 --- /dev/null +++ b/internal/aof/engine_test.go @@ -0,0 +1,221 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package aof_test + +import ( + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/aof" + "apigo.cc/go/sugardb/internal/aof/log" + "apigo.cc/go/sugardb/internal/aof/preamble" + "apigo.cc/go/sugardb/internal/clock" + "os" + "sync/atomic" + "testing" + "time" +) + +func marshalRespCommand(command []string) []byte { + return []byte(fmt.Sprintf( + "*%d\r\n$%d\r\n%s\r\n$%d\r\n%s\r\n$%d\r\n%s\r\n", len(command), + len(command[0]), command[0], + len(command[1]), command[1], + len(command[2]), command[2], + )) +} + +func Test_AOFEngine(t *testing.T) { + strategy := "always" + directory := "./testdata" + + var rewriteInProgress atomic.Bool + startRewriteFunc := func() { + if rewriteInProgress.Load() { + t.Error("expected rewriteInProgress to be false, got true") + } + rewriteInProgress.Store(true) + } + finishRewriteFunc := func() { + if !rewriteInProgress.Load() { + t.Error("expected rewriteInProgress to be true, got false") + rewriteInProgress.Store(false) + } + } + + state := map[int]map[string]internal.KeyData{ + 0: { + "key1": {Value: "value-01", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key2": {Value: "value-02", ExpireAt: clock.NewClock().Now().Add(-10 * time.Second)}, // Should be excluded on restore + "key3": {Value: "value-03", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + }, + 1: { + "key1": {Value: "value-11", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key2": {Value: "value-12", ExpireAt: clock.NewClock().Now().Add(-10 * time.Second)}, // Should be excluded on restore + "key3": {Value: "value-13", ExpireAt: clock.NewClock().Now().Add(-10 * time.Second)}, // Should be excluded on restore + "key4": {Value: "value-14", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + }, + } + restoredState := map[int]map[string]internal.KeyData{} + wantRestoredState := map[int]map[string]internal.KeyData{ + 0: { + "key1": {Value: "value-01", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key3": {Value: "value-03", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key4": {Value: "value-04", ExpireAt: time.Time{}}, + "key5": {Value: "value-05", ExpireAt: time.Time{}}, + "key6": {Value: "value-06", ExpireAt: time.Time{}}, + "key7": {Value: "value-07", ExpireAt: time.Time{}}, + "key8": {Value: "value-08", ExpireAt: time.Time{}}, + "key9": {Value: "value-09", ExpireAt: time.Time{}}, + "key10": {Value: "value-010", ExpireAt: time.Time{}}, + }, + 1: { + "key1": {Value: "value-11", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key4": {Value: "value-14", ExpireAt: time.Time{}}, + "key5": {Value: "value-15", ExpireAt: time.Time{}}, + "key6": {Value: "value-16", ExpireAt: time.Time{}}, + "key7": {Value: "value-17", ExpireAt: time.Time{}}, + "key8": {Value: "value-18", ExpireAt: time.Time{}}, + "key9": {Value: "value-19", ExpireAt: time.Time{}}, + "key10": {Value: "value-110", ExpireAt: time.Time{}}, + }, + } + + getStateFunc := func() map[int]map[string]internal.KeyData { + return state + } + + setKeyDataFunc := func(database int, key string, data internal.KeyData) { + if restoredState[database] == nil { + restoredState[database] = make(map[string]internal.KeyData) + } + restoredState[database][key] = data + } + + handleCommandFunc := func(database int, command []byte) { + cmd, err := internal.Decode(command) + if err != nil { + t.Error(err) + } + restoredState[database][cmd[1]] = internal.KeyData{Value: cmd[2], ExpireAt: time.Time{}} + } + + preambleReadWriter := func() preamble.ReadWriter { + return nil + }() + appendReadWriter := func() log.ReadWriter { + return nil + }() + + engine, err := aof.NewAOFEngine( + aof.WithClock(clock.NewClock()), + aof.WithStrategy(strategy), + aof.WithDirectory(directory), + aof.WithStartRewriteFunc(startRewriteFunc), + aof.WithFinishRewriteFunc(finishRewriteFunc), + aof.WithGetStateFunc(getStateFunc), + aof.WithSetKeyDataFunc(setKeyDataFunc), + aof.WithHandleCommandFunc(handleCommandFunc), + aof.WithPreambleReadWriter(preambleReadWriter), + aof.WithAppendReadWriter(appendReadWriter), + ) + if err != nil { + t.Error(err) + } + + // Log some commands to mutate the state + preRewriteCommands := map[int][][]string{ + 0: { + {"SET", "key4", "value4"}, + {"SET", "key5", "value5"}, + {"SET", "key6", "value6"}, + }, + 1: { + {"SET", "key4", "value4"}, + {"SET", "key5", "value5"}, + {"SET", "key6", "value6"}, + }, + } + + for database, commands := range preRewriteCommands { + for _, command := range commands { + state[database][command[1]] = internal.KeyData{Value: command[2], ExpireAt: time.Time{}} + engine.LogCommand(database, marshalRespCommand(command)) + } + } + + ticker := time.NewTicker(100 * time.Millisecond) + defer func() { + ticker.Stop() + }() + + <-ticker.C + + // Trigger log rewrite + if err = engine.RewriteLog(); err != nil { + t.Error(err) + } + + // Log some more commands + postRewriteCommands := map[int][][]string{ + 0: { + {"SET", "key7", "value7"}, + {"SET", "key8", "value8"}, + {"SET", "key9", "value9"}, + {"SET", "key10", "value10"}, + }, + 1: { + {"SET", "key7", "value7"}, + {"SET", "key8", "value8"}, + {"SET", "key9", "value9"}, + {"SET", "key10", "value10"}, + }, + } + + for database, commands := range postRewriteCommands { + for _, command := range commands { + state[database][command[1]] = internal.KeyData{Value: command[2], ExpireAt: time.Time{}} + engine.LogCommand(database, marshalRespCommand(command)) + } + } + + ticker.Reset(100 * time.Millisecond) + <-ticker.C + + // Restore logs + if err = engine.Restore(); err != nil { + t.Error(err) + } + + if len(wantRestoredState) != len(restoredState) { + t.Errorf("expected restored state to be length %d, got %d", len(wantRestoredState), len(restoredState)) + for database, data := range restoredState { + for key, keyData := range data { + want, ok := wantRestoredState[database][key] + if !ok { + t.Errorf("could not find key %s in expected state", key) + } + if want.Value != keyData.Value { + t.Errorf("expected value %v for key %s, got %v", want.Value, key, keyData.Value) + } + if !want.ExpireAt.Equal(keyData.ExpireAt) { + t.Errorf("expected expiry time of %v for key %s, got %v", want.ExpireAt, key, keyData.ExpireAt) + } + } + } + } + + engine.Close() + _ = os.RemoveAll(directory) +} diff --git a/internal/aof/log/store.go b/internal/aof/log/store.go new file mode 100644 index 0000000..d6eeb9e --- /dev/null +++ b/internal/aof/log/store.go @@ -0,0 +1,263 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package log + +import ( + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/clock" + "github.com/tidwall/resp" + "io" + "log" + "os" + "path" + "strconv" + "strings" + "sync" + "time" +) + +type ReadWriter interface { + io.ReadWriteSeeker + io.Closer + Truncate(size int64) error + Sync() error +} + +type Store struct { + clock clock.Clock + // Keeps track of the current database that we're logging commands for. + currentDatabase int + // Append file sync strategy. Can only be "always", "everysec", or "no". + strategy string + // Store mutex. + mut sync.Mutex + // The ReadWriter used to persist and load the log. + rw ReadWriter + // The directory for the AOF file if we must create one. + directory string + // Function to handle command read from AOF log after restore. + handleCommand func(database int, command []byte) +} + +func WithClock(clock clock.Clock) func(store *Store) { + return func(store *Store) { + store.clock = clock + } +} + +func WithStrategy(strategy string) func(store *Store) { + return func(store *Store) { + store.strategy = strings.ToLower(strategy) + } +} + +func WithReadWriter(rw ReadWriter) func(store *Store) { + return func(store *Store) { + store.rw = rw + } +} + +func WithDirectory(directory string) func(store *Store) { + return func(store *Store) { + store.directory = directory + } +} + +func WithHandleCommandFunc(f func(database int, command []byte)) func(store *Store) { + return func(store *Store) { + store.handleCommand = f + } +} + +func NewAppendStore(options ...func(store *Store)) (*Store, error) { + store := &Store{ + clock: clock.NewClock(), + currentDatabase: -1, + directory: "", + strategy: "everysec", + rw: nil, + mut: sync.Mutex{}, + handleCommand: func(database int, command []byte) {}, + } + + for _, option := range options { + option(store) + } + + // If rw is nil, use a default file at the provided directory + if store.rw == nil && store.directory != "" { + // Create the directory if it does not exist + err := os.MkdirAll(path.Join(store.directory, "aof"), os.ModePerm) + if err != nil { + return nil, fmt.Errorf("new append store -> mkdir error: %+v", err) + } + f, err := os.OpenFile(path.Join(store.directory, "aof", "log.aof"), os.O_RDWR|os.O_CREATE|os.O_APPEND, os.ModePerm) + if err != nil { + return nil, fmt.Errorf("new append store -> open file error: %+v", err) + } + store.rw = f + } + + // Start another goroutine that takes handles syncing the content to the file system. + // No need to start this goroutine if sync strategy is anything other than 'everysec'. + if strings.EqualFold(store.strategy, "everysec") { + go func() { + ticker := time.NewTicker(1 * time.Second) + defer func() { + ticker.Stop() + }() + for { + store.mut.Lock() + if err := store.Sync(); err != nil { + store.mut.Unlock() + log.Println(fmt.Errorf("new append store error: %+v", err)) + break + } + store.mut.Unlock() + <-ticker.C + } + }() + } + + return store, nil +} + +func (store *Store) Write(database int, command []byte) error { + // Skip operation if ReadWriter is not defined. + if store.rw == nil { + return nil + } + + store.mut.Lock() + defer store.mut.Unlock() + + // If the database parameter is different from the current database index, + // log the SELECT command before logging the incoming command. + // This allows us to switch databases appropriately when restoring the state on startup. + if database != store.currentDatabase { + _, err := store.rw.Write([]byte(fmt.Sprintf("*2\r\n$6\r\nSELECT\r\n$1\r\n%s\r\n", strconv.Itoa(database)))) + if err != nil { + return fmt.Errorf("log select error: %+v", err) + } + store.currentDatabase = database + } + + if _, err := store.rw.Write(command); err != nil { + return fmt.Errorf("log command error: %+v", err) + } + + if strings.EqualFold(store.strategy, "always") { + if err := store.Sync(); err != nil { + return fmt.Errorf("log file sync error: %+v", err) + } + } + + return nil +} + +func (store *Store) Sync() error { + if store.rw != nil { + return store.rw.Sync() + } + return nil +} + +func (store *Store) Restore() error { + store.mut.Lock() + defer store.mut.Unlock() + + // Move cursor to the beginning of the file + if _, err := store.rw.Seek(0, 0); err != nil { + return fmt.Errorf("restore aof: %v", err) + } + + r := resp.NewReader(store.rw) + database := 0 + + for { + value, n, err := r.ReadValue() + if err != nil && err != io.EOF { + return err + } + if n == 0 { + // Break out when there are no more bytes to read. + break + } + + command, err := value.MarshalRESP() + if err != nil { + return err + } + + // Decode command. + cmd, err := internal.Decode(command) + if err != nil { + return err + } + // If the command is a SELECT command, set the database value. + if strings.EqualFold(cmd[0], "select") { + database, err = strconv.Atoi(cmd[1]) + if err != nil { + return err + } + // Restart the read loop. + continue + } + + store.handleCommand(database, command) + } + + return nil +} + +func (store *Store) Truncate() error { + store.mut.Lock() + defer store.mut.Unlock() + + if err := store.rw.Truncate(0); err != nil { + return fmt.Errorf("truncate: truncate error: %+v", err) + } + + // Seek to the beginning of the file after truncating. + if _, err := store.rw.Seek(0, 0); err != nil { + return fmt.Errorf("truncate: seek error: %+v", err) + } + + // Add command to select the current database at the top of the file. + _, err := store.rw.Write([]byte( + fmt.Sprintf("*2\r\n$6\r\nSELECT\r\n$1\r\n%s\r\n", strconv.Itoa(store.currentDatabase)))) + if err != nil { + return fmt.Errorf("truncate: log select error: %+v", err) + } + // Immediately sync the file. + if err = store.rw.Sync(); err != nil { + return fmt.Errorf("truncate: sync error: %+v", err) + } + + return nil +} + +func (store *Store) Close() error { + store.mut.Lock() + defer store.mut.Unlock() + if store.rw == nil { + return nil + } + if err := store.rw.Close(); err != nil { + return err + } + return nil +} diff --git a/internal/aof/log/store_test.go b/internal/aof/log/store_test.go new file mode 100644 index 0000000..d61409c --- /dev/null +++ b/internal/aof/log/store_test.go @@ -0,0 +1,149 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package log_test + +import ( + "bytes" + "fmt" + "apigo.cc/go/sugardb/internal/aof/log" + "apigo.cc/go/sugardb/internal/clock" + "os" + "path" + "testing" + "time" +) + +func marshalRespCommand(command []string) []byte { + return []byte(fmt.Sprintf( + "*%d\r\n$%d\r\n%s\r\n$%d\r\n%s\r\n$%d\r\n%s\r\n", len(command), + len(command[0]), command[0], + len(command[1]), command[1], + len(command[2]), command[2], + )) +} + +func Test_AppendStore(t *testing.T) { + t.Cleanup(func() { + _ = os.RemoveAll(path.Join(".", "testdata")) + }) + + tests := []struct { + name string + directory string + strategy string + commands [][]string + appendReadWriter log.ReadWriter + }{ + { + name: "1. Not passing an AppendReadWriter to NewAppendStore should create a new append file", + directory: "./testdata/log/with_no_read_writer", + strategy: "always", + commands: [][]string{ + {"SET", "key1", "value1"}, + {"SET", "key2", "value2"}, + {"SET", "key3", "value3"}, + }, + appendReadWriter: nil, + }, + { + name: "2. Passing an existing AppendReadWriter to NewAppendStore should successfully append and restore", + directory: "./testdata/log/with_read_writer", + strategy: "always", + commands: [][]string{ + {"SET", "key1", "value1"}, + {"SET", "key2", "value2"}, + {"SET", "key3", "value3"}, + }, + appendReadWriter: func() log.ReadWriter { + // Create the directory if it does not exist + if err := os.MkdirAll(path.Join("./testdata/with_read_writer", "aof"), os.ModePerm); err != nil { + t.Error(err) + } + f, err := os.OpenFile(path.Join("./testdata/with_read_writer", "aof", "log.aof"), + os.O_RDWR|os.O_CREATE|os.O_APPEND, os.ModePerm) + if err != nil { + t.Error(err) + } + return f + }(), + }, + { + name: "3. Using everysec strategy should sync the AOF file after one second", + directory: "./testdata/log/with_everysec_strategy", + strategy: "everysec", + commands: [][]string{ + {"SET", "key1", "value1"}, + {"SET", "key2", "value2"}, + {"SET", "key3", "value3"}, + }, + appendReadWriter: nil, + }, + } + + for _, test := range tests { + done := make(chan struct{}, 1) + + options := []func(store *log.Store){ + log.WithClock(clock.NewClock()), + log.WithDirectory(test.directory), + log.WithStrategy(test.strategy), + log.WithHandleCommandFunc(func(database int, command []byte) { + for _, c := range test.commands { + if bytes.Contains(command, marshalRespCommand(c)) { + return + } + } + t.Errorf("could not find command in commands list:\n%s", string(command)) + }), + } + if test.appendReadWriter != nil { + options = append(options, log.WithReadWriter(test.appendReadWriter)) + } + + go func() { + store, err := log.NewAppendStore(options...) + if err != nil { + t.Error(err) + } + + for _, command := range test.commands { + b := marshalRespCommand(command) + if err = store.Write(0, b); err != nil { + t.Error(err) + } + } + + // Restore from AOF file + if err = store.Restore(); err != nil { + t.Error(err) + } + + if err = store.Close(); err != nil { + t.Error(err) + } + + done <- struct{}{} + }() + + ticker := time.NewTicker(200 * time.Millisecond) + + select { + case <-done: + case <-ticker.C: + t.Error("timeout error") + } + } + +} diff --git a/internal/aof/preamble/store.go b/internal/aof/preamble/store.go new file mode 100644 index 0000000..db82213 --- /dev/null +++ b/internal/aof/preamble/store.go @@ -0,0 +1,182 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package preamble + +import ( + "encoding/json" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/clock" + "io" + "os" + "path" + "sync" +) + +type ReadWriter interface { + io.ReadWriteSeeker + io.Closer + Truncate(size int64) error + Sync() error +} + +type Store struct { + clock clock.Clock + rw ReadWriter + mut sync.Mutex + directory string + getStateFunc func() map[int]map[string]internal.KeyData + setKeyDataFunc func(database int, key string, data internal.KeyData) +} + +func WithClock(clock clock.Clock) func(store *Store) { + return func(store *Store) { + store.clock = clock + } +} + +func WithReadWriter(rw ReadWriter) func(store *Store) { + return func(store *Store) { + store.rw = rw + } +} + +func WithGetStateFunc(f func() map[int]map[string]internal.KeyData) func(store *Store) { + return func(store *Store) { + store.getStateFunc = f + } +} + +func WithSetKeyDataFunc(f func(database int, key string, data internal.KeyData)) func(store *Store) { + return func(store *Store) { + store.setKeyDataFunc = f + } +} + +func WithDirectory(directory string) func(store *Store) { + return func(store *Store) { + store.directory = directory + } +} + +func NewPreambleStore(options ...func(store *Store)) (*Store, error) { + store := &Store{ + clock: clock.NewClock(), + rw: nil, + mut: sync.Mutex{}, + directory: "", + getStateFunc: func() map[int]map[string]internal.KeyData { + // No-Op by default + return nil + }, + setKeyDataFunc: func(database int, key string, data internal.KeyData) {}, + } + + for _, option := range options { + option(store) + } + + // If rw is nil, create the default + if store.rw == nil && store.directory != "" { + err := os.MkdirAll(path.Join(store.directory, "aof"), os.ModePerm) + if err != nil { + return nil, fmt.Errorf("new preamble store -> mkdir error: %+v", err) + } + f, err := os.OpenFile(path.Join(store.directory, "aof", "preamble.bin"), os.O_RDWR|os.O_CREATE, os.ModePerm) + if err != nil { + return nil, fmt.Errorf("new preamble store -> open file error: %+v", err) + } + store.rw = f + } + + return store, nil +} + +func (store *Store) CreatePreamble() error { + store.mut.Lock() + store.mut.Unlock() + + // Get current state. + state := internal.FilterExpiredKeys(store.clock.Now(), store.getStateFunc()) + o, err := json.Marshal(state) + if err != nil { + return err + } + + // Truncate the preamble first + if err = store.rw.Truncate(0); err != nil { + return err + } + // Seek to the beginning of the file after truncating + if _, err = store.rw.Seek(0, 0); err != nil { + return err + } + + if _, err = store.rw.Write(o); err != nil { + return err + } + + // Sync the changes + if err = store.rw.Sync(); err != nil { + return err + } + + return nil +} + +func (store *Store) Restore() error { + if store.rw == nil { + return nil + } + + // Seek to the beginning of the file before beginning restore. + if _, err := store.rw.Seek(0, 0); err != nil { + return fmt.Errorf("restore preamble: %v", err) + } + + b, err := io.ReadAll(store.rw) + if err != nil { + return err + } + + if len(b) <= 0 { + return nil + } + + state := make(map[int]map[string]internal.KeyData) + if err = json.Unmarshal(b, &state); err != nil { + return err + } + + for database, data := range internal.FilterExpiredKeys(store.clock.Now(), state) { + for key, keyData := range data { + store.setKeyDataFunc(database, key, keyData) + } + } + + return nil +} + +func (store *Store) Close() error { + store.mut.Lock() + defer store.mut.Unlock() + if store.rw == nil { + return nil + } + if err := store.rw.Close(); err != nil { + return err + } + return nil +} diff --git a/internal/aof/preamble/store_test.go b/internal/aof/preamble/store_test.go new file mode 100644 index 0000000..926f8aa --- /dev/null +++ b/internal/aof/preamble/store_test.go @@ -0,0 +1,171 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package preamble_test + +import ( + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/aof/preamble" + "apigo.cc/go/sugardb/internal/clock" + "os" + "path" + "testing" + "time" +) + +func Test_PreambleStore(t *testing.T) { + directory := "./testdata/preamble" + tests := []struct { + name string + directory string + state map[int]map[string]internal.KeyData + preambleReadWriter preamble.ReadWriter + wantState map[int]map[string]internal.KeyData + }{ + { + name: "1. Preamble store with no preamble read writer passed should trigger one to be created upon initialization", + directory: directory, + state: map[int]map[string]internal.KeyData{ + 0: { + "key1": {Value: "value-01", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key2": {Value: "value-02", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key3": {Value: "value-03", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + }, + 1: { + "key1": {Value: "value-11", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key2": {Value: "value-12", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key3": {Value: "value-13", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + }, + }, + preambleReadWriter: nil, + wantState: map[int]map[string]internal.KeyData{ + 0: { + "key1": {Value: "value-01", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key2": {Value: "value-02", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key3": {Value: "value-03", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + }, + 1: { + "key1": {Value: "value-11", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key2": {Value: "value-12", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key3": {Value: "value-13", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + }, + }, + }, + { + name: "2. Pass a pre-existing preamble read writer to constructor", + directory: directory, + state: map[int]map[string]internal.KeyData{ + 0: { + "key4": {Value: "value-04", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key5": {Value: "value-05", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key6": {Value: "value-06", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + }, + 1: { + "key4": {Value: "value-14", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key5": {Value: "value-15", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key6": {Value: "value-16", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + }, + }, + preambleReadWriter: func() preamble.ReadWriter { + if err := os.MkdirAll(path.Join("./testdata/preamble", "aof"), os.ModePerm); err != nil { + t.Error(err) + } + f, err := os.OpenFile(path.Join("./testdata/preamble", "aof", "preamble.bin"), + os.O_RDWR|os.O_CREATE, os.ModePerm) + if err != nil { + t.Error(err) + } + return f + }(), + wantState: map[int]map[string]internal.KeyData{ + 0: { + "key4": {Value: "value-04", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key5": {Value: "value-05", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key6": {Value: "value-06", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + }, + 1: { + "key4": {Value: "value-14", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key5": {Value: "value-15", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key6": {Value: "value-16", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + }, + }, + }, + { + name: "3. Skip expired keys when saving/loading state from preamble read writer", + directory: directory, + state: map[int]map[string]internal.KeyData{ + 0: { + "key7": {Value: "value-07", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key8": {Value: "value-08", ExpireAt: clock.NewClock().Now().Add(-10 * time.Second)}, + "key9": {Value: "value-09", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key10": {Value: "value-010", ExpireAt: clock.NewClock().Now().Add(-10 * time.Second)}, + }, + 1: { + "key7": {Value: "value-17", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key8": {Value: "value-18", ExpireAt: clock.NewClock().Now().Add(-10 * time.Second)}, + "key9": {Value: "value-19", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key10": {Value: "value-110", ExpireAt: clock.NewClock().Now().Add(-10 * time.Second)}, + }, + }, + preambleReadWriter: nil, + wantState: map[int]map[string]internal.KeyData{ + 0: { + "key7": {Value: "value-07", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key9": {Value: "value-09", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + }, + 1: { + "key7": {Value: "value-17", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + "key9": {Value: "value-19", ExpireAt: clock.NewClock().Now().Add(10 * time.Second)}, + }, + }, + }, + } + + for _, test := range tests { + options := []func(store *preamble.Store){ + preamble.WithClock(clock.NewClock()), + preamble.WithDirectory(test.directory), + preamble.WithGetStateFunc(func() map[int]map[string]internal.KeyData { + return test.state + }), + preamble.WithSetKeyDataFunc(func(database int, key string, data internal.KeyData) { + entry, ok := test.wantState[database][key] + if !ok { + t.Errorf("could not find element: %v", key) + } + if entry.Value != data.Value { + t.Errorf("expected value %v for key %s, got %v", entry.Value, key, data.Value) + } + if !entry.ExpireAt.Equal(data.ExpireAt) { + t.Errorf("expected expireAt %v for key %s, got %v", entry.ExpireAt, key, data.ExpireAt) + } + }), + } + + store, err := preamble.NewPreambleStore(options...) + if err != nil { + t.Error(err) + } + + if err = store.CreatePreamble(); err != nil { + t.Error(err) + } + + if err = store.Restore(); err != nil { + t.Error(err) + } + } + + _ = os.RemoveAll("./testdata") +} diff --git a/internal/clock/clock.go b/internal/clock/clock.go new file mode 100644 index 0000000..70133de --- /dev/null +++ b/internal/clock/clock.go @@ -0,0 +1,41 @@ +package clock + +import ( + "os" + "strings" + "time" +) + +type Clock interface { + Now() time.Time + After(d time.Duration) <-chan time.Time +} + +func NewClock() Clock { + // If we're in a test environment, return the mock clock. + if strings.Contains(os.Args[0], ".test") { + return MockClock{} + } + return RealClock{} +} + +type RealClock struct{} + +func (RealClock) Now() time.Time { + return time.Now() +} + +func (RealClock) After(d time.Duration) <-chan time.Time { + return time.After(d) +} + +type MockClock struct{} + +func (MockClock) Now() time.Time { + t, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00") + return t +} + +func (MockClock) After(d time.Duration) <-chan time.Time { + return time.After(d) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5cb7706 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,261 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package config + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" + "log" + "os" + "path" + "slices" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +type Config struct { + TLS bool `json:"TLS" yaml:"TLS"` + MTLS bool `json:"MTLS" yaml:"MTLS"` + CertKeyPairs [][]string `json:"CertKeyPairs" yaml:"CertKeyPairs"` + ClientCAs []string `json:"ClientCAs" yaml:"ClientCAs"` + Port uint16 `json:"Port" yaml:"Port"` + ServerID string `json:"ServerId" yaml:"ServerId"` + JoinAddr string `json:"JoinAddr" yaml:"JoinAddr"` + BindAddr string `json:"BindAddr" yaml:"BindAddr"` + DataDir string `json:"DataDir" yaml:"DataDir"` + BootstrapCluster bool `json:"BootstrapCluster" yaml:"BootstrapCluster"` + AclConfig string `json:"AclConfig" yaml:"AclConfig"` + ForwardCommand bool `json:"ForwardCommand" yaml:"ForwardCommand"` + RequirePass bool `json:"RequirePass" yaml:"RequirePass"` + Password string `json:"Password" yaml:"Password"` + SnapShotThreshold uint64 `json:"SnapshotThreshold" yaml:"SnapshotThreshold"` + SnapshotInterval time.Duration `json:"SnapshotInterval" yaml:"SnapshotInterval"` + RestoreSnapshot bool `json:"RestoreSnapshot" yaml:"RestoreSnapshot"` + RestoreAOF bool `json:"RestoreAOF" yaml:"RestoreAOF"` + AOFSyncStrategy string `json:"AOFSyncStrategy" yaml:"AOFSyncStrategy"` + MaxMemory uint64 `json:"MaxMemory" yaml:"MaxMemory"` + EvictionPolicy string `json:"EvictionPolicy" yaml:"EvictionPolicy"` + EvictionSample uint `json:"EvictionSample" yaml:"EvictionSample"` + EvictionInterval time.Duration `json:"EvictionInterval" yaml:"EvictionInterval"` + Modules []string `json:"Plugins" yaml:"Plugins"` + DiscoveryPort uint16 `json:"DiscoveryPort" yaml:"DiscoveryPort"` + RaftBindAddr string + RaftBindPort uint16 +} + +func GetConfig() (Config, error) { + var certKeyPairs [][]string + var clientCAs []string + + flag.Func("cert-key-pair", + "A pair of file paths representing the signed certificate and it's corresponding key separated by a comma.", + func(s string) error { + pair := strings.Split(strings.TrimSpace(s), ",") + for i := 0; i < len(pair); i++ { + pair[i] = strings.TrimSpace(pair[i]) + } + if len(pair) != 2 { + return errors.New("certKeyPair must be 2 comma separated strings") + } + certKeyPairs = append(certKeyPairs, pair) + return nil + }) + + flag.Func("client-ca", "Path to certificate authority used to verify client certificates.", func(s string) error { + clientCAs = append(clientCAs, s) + return nil + }) + + aofSyncStrategy := "everysec" + flag.Func("aof-sync-strategy", `How often to flush the file contents written to append only file. +The options are 'always' for syncing on each command, 'everysec' to sync every second, and 'no' to leave it up to the os.`, + func(option string) error { + if !slices.ContainsFunc([]string{"always", "everysec", "no"}, func(s string) bool { + return strings.EqualFold(s, option) + }) { + return errors.New("aofSyncStrategy must be 'always', 'everysec' or 'no'") + } + aofSyncStrategy = strings.ToLower(option) + return nil + }) + + var maxMemory uint64 = 0 + flag.Func("max-memory", `Upper memory limit before triggering eviction. +Supported units (kb, mb, gb, tb, pb). When 0 is passed, there will be no memory limit. +There is no limit by default.`, func(memory string) error { + b, err := internal.ParseMemory(memory) + if err != nil { + return err + } + maxMemory = b + return nil + }) + + evictionPolicy := constants.NoEviction + flag.Func("eviction-policy", + `The eviction policy used to remove keys when max-memory is reached. The options are: +1) noeviction - Do not evict any keys even when max-memory is exceeded. +2) allkeys-lfu - Evict the least frequently used keys. +3) allkeys-lru - Evict the least recently used keys. +4) volatile-lfu - Evict the least frequently used keys with an expiration. +5) volatile-lru - Evict the least recently used keys with an expiration. +6) allkeys-random - Evict random keys until we get under the max-memory limit. +7) volatile-random - Evict random keys with an expiration.`, func(policy string) error { + policies := []string{ + constants.NoEviction, + constants.AllKeysLFU, constants.AllKeysLRU, constants.AllKeysRandom, + constants.VolatileLFU, constants.VolatileLRU, constants.VolatileRandom, + } + policyIdx := slices.Index(policies, strings.ToLower(policy)) + if policyIdx == -1 { + return fmt.Errorf("policy %s is not a valid policy", policy) + } + evictionPolicy = strings.ToLower(policy) + return nil + }) + + var modules []string + flag.Func( + "loadmodule", + `Path to shared object library to extend SugarDB commands (e.g. /path/to/plugin.so)`, + func(p string) error { + if !strings.HasSuffix(p, ".so") { + return fmt.Errorf("\"%s\" is not a .so file", p) + } + modules = append(modules, p) + return nil + }) + + tls := flag.Bool("tls", false, "Start the echovault in TLS mode. Default is false.") + mtls := flag.Bool("mtls", false, "Use mTLS to verify the client.") + port := flag.Int("port", 7480, "Port to use. Default is 7480") + serverId := flag.String("server-id", "1", "SugarDB ID in raft cluster. Leave empty for client.") + joinAddr := flag.String("join-addr", "", "Address of cluster member in a cluster to you want to join.") + bindAddr := flag.String("bind-addr", "127.0.0.1", "Address to bind the echovault to.") + discoveryPort := flag.Uint("discovery-port", 7946, "Port to use for memberlist cluster discovery.") + dataDir := flag.String("data-dir", ".", "Directory to store snapshots and logs.") + bootstrapCluster := flag.Bool("bootstrap-cluster", false, "Whether this instance should bootstrap a new cluster.") + aclConfig := flag.String("acl-config", "", "ACL config file path.") + snapshotThreshold := flag.Uint64("snapshot-threshold", 1000, "The number of entries that trigger a snapshot. Default is 1000.") + snapshotInterval := flag.Duration("snapshot-interval", 5*time.Minute, "The time interval between snapshots (in seconds). Default is 5 minutes.") + restoreSnapshot := flag.Bool("restore-snapshot", false, "This flag prompts the echovault to restore state from snapshot when set to true. Only works in standalone mode. Higher priority than restoreAOF.") + restoreAOF := flag.Bool("restore-aof", false, "This flag prompts the echovault to restore state from append-only logs. Only works in standalone mode. Lower priority than restoreSnapshot.") + evictionSample := flag.Uint("eviction-sample", 20, "An integer specifying the number of keys to sample when checking for expired keys.") + evictionInterval := flag.Duration("eviction-interval", 100*time.Millisecond, "The interval between each sampling of keys to evict.") + forwardCommand := flag.Bool( + "forward-commands", + false, + "If the node is a follower, this flag forwards mutation command to the leader when set to true") + requirePass := flag.Bool( + "require-pass", + false, + "Whether the echovault should require a password before allowing commands. Default is false.", + ) + password := flag.String( + "password", + "", + `The password for the default user. ACL config file will overwrite this value. +It is a plain text value by default but you can provide a SHA256 hash by adding a '#' before the hash.`, + ) + + config := flag.String( + "config", + "", + `File path to a JSON or YAML config file.The values in this config file will override the flag values.`, + ) + + flag.Parse() + + raftBindAddr, e := internal.GetIPAddress() + if e != nil { + return Config{}, e + } + raftBindPort, e := internal.GetFreePort() + if e != nil { + return Config{}, e + } + + conf := Config{ + CertKeyPairs: certKeyPairs, + ClientCAs: clientCAs, + TLS: *tls, + MTLS: *mtls, + Port: uint16(*port), + ServerID: *serverId, + JoinAddr: *joinAddr, + BindAddr: *bindAddr, + DataDir: *dataDir, + BootstrapCluster: *bootstrapCluster, + AclConfig: *aclConfig, + ForwardCommand: *forwardCommand, + RequirePass: *requirePass, + Password: *password, + SnapShotThreshold: *snapshotThreshold, + SnapshotInterval: *snapshotInterval, + RestoreSnapshot: *restoreSnapshot, + RestoreAOF: *restoreAOF, + AOFSyncStrategy: aofSyncStrategy, + MaxMemory: maxMemory, + EvictionPolicy: evictionPolicy, + EvictionSample: *evictionSample, + EvictionInterval: *evictionInterval, + Modules: modules, + DiscoveryPort: uint16(*discoveryPort), + RaftBindAddr: raftBindAddr, + RaftBindPort: uint16(raftBindPort), + } + + if len(*config) > 0 { + // Override configurations from file. + if f, err := os.Open(*config); err != nil { + panic(err) + } else { + defer func() { + if err = f.Close(); err != nil { + log.Println(err) + } + }() + + ext := path.Ext(f.Name()) + + if ext == ".json" { + if err = json.NewDecoder(f).Decode(&conf); err != nil { + return Config{}, nil + } + } + + if ext == ".yaml" || ext == ".yml" { + if err = yaml.NewDecoder(f).Decode(&conf); err != nil { + return Config{}, err + } + } + } + } + + // If requirePass is set to true, then password must be provided as well. + var err error = nil + + if conf.RequirePass && conf.Password == "" { + err = errors.New("password cannot be empty if requirePass is true") + } + + return conf, err +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..6f4391a --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,15 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package config diff --git a/internal/config/default.go b/internal/config/default.go new file mode 100644 index 0000000..527896c --- /dev/null +++ b/internal/config/default.go @@ -0,0 +1,42 @@ +package config + +import ( + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" + "time" +) + +func DefaultConfig() Config { + raftBindAddr, _ := internal.GetIPAddress() + raftBindPort, _ := internal.GetFreePort() + + return Config{ + TLS: false, + MTLS: false, + CertKeyPairs: make([][]string, 0), + ClientCAs: make([]string, 0), + Port: 7480, + ServerID: "", + JoinAddr: "", + BindAddr: "localhost", + RaftBindAddr: raftBindAddr, + RaftBindPort: uint16(raftBindPort), + DiscoveryPort: 7946, + DataDir: ".", + BootstrapCluster: false, + AclConfig: "", + ForwardCommand: false, + RequirePass: false, + Password: "", + SnapShotThreshold: 1000, + SnapshotInterval: 5 * time.Minute, + RestoreAOF: false, + RestoreSnapshot: false, + AOFSyncStrategy: "everysec", + MaxMemory: 0, + EvictionPolicy: constants.NoEviction, + EvictionSample: 20, + EvictionInterval: 100 * time.Millisecond, + Modules: make([]string, 0), + } +} diff --git a/internal/config/default_test.go b/internal/config/default_test.go new file mode 100644 index 0000000..6f4391a --- /dev/null +++ b/internal/config/default_test.go @@ -0,0 +1,15 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package config diff --git a/internal/constants/const.go b/internal/constants/const.go new file mode 100644 index 0000000..852f4ab --- /dev/null +++ b/internal/constants/const.go @@ -0,0 +1,76 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package constants + +const Version = "0.13.1" // Next SugarDB version. Update this before each release. + +const ( + ACLModule = "acl" + AdminModule = "admin" + ConnectionModule = "connection" + GenericModule = "generic" + HashModule = "hash" + ListModule = "list" + PubSubModule = "pubsub" + SetModule = "set" + SortedSetModule = "sortedset" + StringModule = "string" +) + +const ( + AdminCategory = "admin" + BitmapCategory = "bitmap" + BlockingCategory = "blocking" + ConnectionCategory = "connection" + DangerousCategory = "dangerous" + GeoCategory = "geo" + HashCategory = "hash" + HyperLogLogCategory = "hyperloglog" + FastCategory = "fast" + KeyspaceCategory = "keyspace" + ListCategory = "list" + PubSubCategory = "pubsub" + ReadCategory = "read" + ScriptingCategory = "scripting" + SetCategory = "set" + SortedSetCategory = "sortedset" + SlowCategory = "slow" + StreamCategory = "stream" + StringCategory = "string" + TransactionCategory = "transaction" + WriteCategory = "write" +) + +const ( + OkResponse = "+OK\r\n" + WrongArgsResponse = "wrong number of arguments" + MissingArgResponse = "missing argument %s" + InvalidCmdResponse = "invalid command provided" +) + +const ( + NoEviction = "noeviction" + AllKeysLRU = "allkeys-lru" + AllKeysLFU = "allkeys-lfu" + VolatileLRU = "volatile-lru" + VolatileLFU = "volatile-lfu" + AllKeysRandom = "allkeys-random" + VolatileRandom = "volatile-random" +) + +// CompositeTypes are SugarDB KeyData Value types like set, sorted set, etc. +type CompositeType interface { + GetMem() int64 +} diff --git a/internal/eviction/lfu.go b/internal/eviction/lfu.go new file mode 100644 index 0000000..59a0de2 --- /dev/null +++ b/internal/eviction/lfu.go @@ -0,0 +1,138 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package eviction + +import ( + "container/heap" + "errors" + "fmt" + "slices" + "sync" + "time" +) + +type EntryLFU struct { + key string // The key, matching the key in the store + count int // The number of times this key has been accessed + addedTime int64 // The time this entry was added to the cache in unix milliseconds + index int // The index of the entry in the heap +} + +type CacheLFU struct { + keys map[string]bool + entries []*EntryLFU + Mutex *sync.Mutex // Lock for retrieving count +} + +func NewCacheLFU() *CacheLFU { + cache := CacheLFU{ + keys: make(map[string]bool), + entries: make([]*EntryLFU, 0), + Mutex: &sync.Mutex{}, + } + heap.Init(&cache) + return &cache +} + +func (cache *CacheLFU) GetCount(key string) (int, error) { + + entryIdx := slices.IndexFunc(cache.entries, func(e *EntryLFU) bool { + return e.key == key + }) + + if entryIdx > -1 { + entry := cache.entries[entryIdx] + return entry.count, nil + } else { + return -1, errors.New(fmt.Sprintf("Key: %s does not exist.", key)) + } + +} + +func (cache *CacheLFU) Flush() { + clear(cache.keys) + clear(cache.entries) +} + +func (cache *CacheLFU) Len() int { + return len(cache.entries) +} + +func (cache *CacheLFU) Less(i, j int) bool { + // If 2 entries have the same count, swap using addedTime + if cache.entries[i].count == cache.entries[j].count { + return cache.entries[i].addedTime > cache.entries[j].addedTime + } + // Otherwise, swap using count + return cache.entries[i].count < cache.entries[j].count +} + +func (cache *CacheLFU) Swap(i, j int) { + cache.entries[i], cache.entries[j] = cache.entries[j], cache.entries[i] + cache.entries[i].index = i + cache.entries[j].index = j +} + +func (cache *CacheLFU) Push(key any) { + n := len(cache.entries) + cache.entries = append(cache.entries, &EntryLFU{ + key: key.(string), + count: 1, + addedTime: time.Now().UnixMilli(), + index: n, + }) + cache.keys[key.(string)] = true +} + +func (cache *CacheLFU) Pop() any { + old := cache.entries + n := len(old) + entry := old[n-1] + old[n-1] = nil + entry.index = -1 + cache.entries = old[0 : n-1] + delete(cache.keys, entry.key) + return entry.key +} + +func (cache *CacheLFU) Update(key string) { + + // If the key is not contained in the cache, push it. + if !cache.contains(key) { + heap.Push(cache, key) + return + } + // Get the item with key + entryIdx := slices.IndexFunc(cache.entries, func(e *EntryLFU) bool { + return e.key == key + }) + entry := cache.entries[entryIdx] + entry.count += 1 + heap.Fix(cache, entryIdx) +} + +func (cache *CacheLFU) Delete(key string) { + entryIdx := slices.IndexFunc(cache.entries, func(entry *EntryLFU) bool { + return entry.key == key + }) + if entryIdx > -1 { + heap.Remove(cache, cache.entries[entryIdx].index) + } +} + +func (cache *CacheLFU) contains(key string) bool { + _, ok := cache.keys[key] + return ok +} diff --git a/internal/eviction/lfu_test.go b/internal/eviction/lfu_test.go new file mode 100644 index 0000000..c25e22c --- /dev/null +++ b/internal/eviction/lfu_test.go @@ -0,0 +1,66 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package eviction_test + +import ( + "container/heap" + "apigo.cc/go/sugardb/internal/eviction" + "sync" + "testing" +) + +func Test_CacheLFU(t *testing.T) { + entries := []struct { + key string + access int + }{ + {key: "key1", access: 1}, + {key: "key2", access: 5}, + {key: "key5", access: 2}, + {key: "key3", access: 4}, + {key: "key4", access: 3}, + } + + cache := eviction.NewCacheLFU() + mut := sync.RWMutex{} + + wg := sync.WaitGroup{} + for _, entry := range entries { + wg.Add(1) + go func(entry struct { + key string + access int + }) { + for i := 0; i < entry.access; i++ { + mut.Lock() + cache.Update(entry.key) + mut.Unlock() + } + wg.Done() + }(entry) + } + wg.Wait() + + expectedKeys := []string{"key1", "key5", "key4", "key3", "key2"} + + mut.Lock() + for i := 0; i < len(expectedKeys); i++ { + key := heap.Pop(cache).(string) + if key != expectedKeys[i] { + t.Errorf("expected popped key at index %d to be %s, got %s", i, expectedKeys[i], key) + } + } + mut.Unlock() +} diff --git a/internal/eviction/lru.go b/internal/eviction/lru.go new file mode 100644 index 0000000..959bd46 --- /dev/null +++ b/internal/eviction/lru.go @@ -0,0 +1,127 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package eviction + +import ( + "container/heap" + "errors" + "fmt" + "slices" + "sync" + "time" +) + +type EntryLRU struct { + key string // The key, matching the key in the store + unixTime int64 // Unix time in milliseconds when this key was accessed + index int // The index of the entry in the heap +} + +type CacheLRU struct { + keys map[string]bool + entries []*EntryLRU + Mutex *sync.Mutex // Lock for retrieving unixTime +} + +func NewCacheLRU() *CacheLRU { + cache := CacheLRU{ + keys: make(map[string]bool), + entries: make([]*EntryLRU, 0), + Mutex: &sync.Mutex{}, + } + heap.Init(&cache) + return &cache +} + +func (cache *CacheLRU) GetTime(key string) (int64, error) { + + entryIdx := slices.IndexFunc(cache.entries, func(e *EntryLRU) bool { + return e.key == key + }) + if entryIdx > -1 { + entry := cache.entries[entryIdx] + return entry.unixTime, nil + } else { + return -1, errors.New(fmt.Sprintf("Error: key %s does not exist.", key)) + } +} + +func (cache *CacheLRU) Flush() { + clear(cache.keys) + clear(cache.entries) +} + +func (cache *CacheLRU) Len() int { + return len(cache.entries) +} + +func (cache *CacheLRU) Less(i, j int) bool { + return cache.entries[i].unixTime > cache.entries[j].unixTime +} + +func (cache *CacheLRU) Swap(i, j int) { + cache.entries[i], cache.entries[j] = cache.entries[j], cache.entries[i] + cache.entries[i].index = i + cache.entries[j].index = j +} + +func (cache *CacheLRU) Push(key any) { + n := len(cache.entries) + cache.entries = append(cache.entries, &EntryLRU{ + key: key.(string), + unixTime: time.Now().UnixMilli(), + index: n, + }) +} + +func (cache *CacheLRU) Pop() any { + old := cache.entries + n := len(old) + entry := old[n-1] + old[n-1] = nil + entry.index = -1 + cache.entries = old[0 : n-1] + delete(cache.keys, entry.key) + return entry.key +} + +func (cache *CacheLRU) Update(key string) { + + // If the key does not already exist in the cache, then push it + if !cache.contains(key) { + heap.Push(cache, key) + } + // Get the item with key + entryIdx := slices.IndexFunc(cache.entries, func(e *EntryLRU) bool { + return e.key == key + }) + entry := cache.entries[entryIdx] + entry.unixTime = time.Now().UnixMilli() + heap.Fix(cache, entryIdx) +} + +func (cache *CacheLRU) Delete(key string) { + entryIdx := slices.IndexFunc(cache.entries, func(entry *EntryLRU) bool { + return entry.key == key + }) + if entryIdx > -1 { + heap.Remove(cache, cache.entries[entryIdx].index) + } +} + +func (cache *CacheLRU) contains(key string) bool { + _, ok := cache.keys[key] + return ok +} diff --git a/internal/eviction/lru_test.go b/internal/eviction/lru_test.go new file mode 100644 index 0000000..7a48a12 --- /dev/null +++ b/internal/eviction/lru_test.go @@ -0,0 +1,48 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package eviction_test + +import ( + "container/heap" + "apigo.cc/go/sugardb/internal/eviction" + "testing" + "time" +) + +func Test_CacheLRU(t *testing.T) { + keys := []string{"key1", "key2", "key3", "key4", "key5"} + + cache := eviction.NewCacheLRU() + + for _, key := range keys { + cache.Update(key) + } + + access := []string{"key3", "key4", "key1", "key2", "key5"} + ticker := time.NewTicker(200 * time.Millisecond) + for _, key := range access { + cache.Update(key) + // Yield + <-ticker.C + } + ticker.Stop() + + for i := len(access) - 1; i >= 0; i-- { + key := heap.Pop(cache).(string) + if key != access[i] { + t.Errorf("expected key at index %d to be %s, got %s", i, access[i], key) + } + } +} diff --git a/internal/memberlist/broadcast.go b/internal/memberlist/broadcast.go new file mode 100644 index 0000000..74462a9 --- /dev/null +++ b/internal/memberlist/broadcast.go @@ -0,0 +1,66 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package memberlist + +import ( + "encoding/json" + "github.com/hashicorp/memberlist" + "log" +) + +type BroadcastMessage struct { + NodeMeta + Action string `json:"Action"` + Content []byte `json:"Content"` + ContentHash [16]byte `json:"ContentHash"` + ConnId string `json:"ConnId"` +} + +// Invalidates Implements Broadcast interface +func (broadcastMessage *BroadcastMessage) Invalidates(other memberlist.Broadcast) bool { + otherBroadcast, ok := other.(*BroadcastMessage) + + if !ok { + return false + } + + switch broadcastMessage.Action { + case "RaftJoin": + return broadcastMessage.Action == otherBroadcast.Action && + broadcastMessage.ServerID == otherBroadcast.ServerID + case "MutateData": + return broadcastMessage.Action == otherBroadcast.Action && + broadcastMessage.ContentHash == otherBroadcast.ContentHash + default: + return false + } +} + +// Message Implements Broadcast interface +func (broadcastMessage *BroadcastMessage) Message() []byte { + msg, err := json.Marshal(broadcastMessage) + + if err != nil { + log.Println(err) + return []byte{} + } + + return msg +} + +// Finished Implements Broadcast interface +func (broadcastMessage *BroadcastMessage) Finished() { + // No-Op +} diff --git a/internal/memberlist/delegate.go b/internal/memberlist/delegate.go new file mode 100644 index 0000000..a4f65b1 --- /dev/null +++ b/internal/memberlist/delegate.go @@ -0,0 +1,140 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package memberlist + +import ( + "context" + "encoding/json" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "github.com/hashicorp/memberlist" + "github.com/hashicorp/raft" + "log" + "time" +) + +type Delegate struct { + options DelegateOpts +} + +type DelegateOpts struct { + config config.Config + broadcastQueue *memberlist.TransmitLimitedQueue + addVoter func(id raft.ServerID, address raft.ServerAddress, prevIndex uint64, timeout time.Duration) error + isRaftLeader func() bool + applyMutate func(ctx context.Context, cmd []string) ([]byte, error) + applyDeleteKey func(ctx context.Context, key string) error +} + +func NewDelegate(opts DelegateOpts) *Delegate { + return &Delegate{ + options: opts, + } +} + +// NodeMeta implements Delegate interface +func (delegate *Delegate) NodeMeta(limit int) []byte { + meta := NodeMeta{ + ServerID: raft.ServerID(delegate.options.config.ServerID), + RaftAddr: raft.ServerAddress( + fmt.Sprintf("%s:%d", delegate.options.config.RaftBindAddr, delegate.options.config.RaftBindPort)), + MemberlistAddr: fmt.Sprintf("%s:%d", delegate.options.config.BindAddr, delegate.options.config.DiscoveryPort), + } + + b, err := json.Marshal(&meta) + + if err != nil { + return []byte("") + } + + return b +} + +// NotifyMsg implements Delegate interface +func (delegate *Delegate) NotifyMsg(msgBytes []byte) { + var msg BroadcastMessage + if err := json.Unmarshal(msgBytes, &msg); err != nil { + log.Printf("notifymsg: %v", err) + return + } + + switch msg.Action { + case "RaftJoin": + // If the current node is not the cluster leader, re-broadcast the message. + if !delegate.options.isRaftLeader() { + delegate.options.broadcastQueue.QueueBroadcast(&msg) + return + } + err := delegate.options.addVoter(msg.NodeMeta.ServerID, msg.NodeMeta.RaftAddr, 0, 0) + if err != nil { + log.Println(err) + } + + case "DeleteKey": + // If the current node is not a cluster leader, re-broadcast the message. + if !delegate.options.isRaftLeader() { + delegate.options.broadcastQueue.QueueBroadcast(&msg) + return + } + // Current node is the cluster leader, handle the key deletion. + ctx := context.WithValue( + context.WithValue(context.Background(), internal.ContextServerID("ServerID"), string(msg.ServerID)), + internal.ContextConnID("ConnectionID"), msg.ConnId) + + key := string(msg.Content) + + if err := delegate.options.applyDeleteKey(ctx, key); err != nil { + log.Println(err) + } + + case "MutateData": + // If the current node is not a cluster leader, re-broadcast the message. + if !delegate.options.isRaftLeader() { + delegate.options.broadcastQueue.QueueBroadcast(&msg) + return + } + // Current node is the cluster leader, handle the mutation + ctx := context.WithValue( + context.WithValue(context.Background(), internal.ContextServerID("ServerID"), string(msg.ServerID)), + internal.ContextConnID("ConnectionID"), msg.ConnId) + + cmd, err := internal.Decode(msg.Content) + if err != nil { + log.Println(err) + return + } + + if _, err := delegate.options.applyMutate(ctx, cmd); err != nil { + log.Println(err) + } + } +} + +// GetBroadcasts implements Delegate interface +func (delegate *Delegate) GetBroadcasts(overhead, limit int) [][]byte { + return delegate.options.broadcastQueue.GetBroadcasts(overhead, limit) +} + +// LocalState implements Delegate interface +func (delegate *Delegate) LocalState(join bool) []byte { + // No-Op + return []byte("") +} + +// MergeRemoteState implements Delegate interface +func (delegate *Delegate) MergeRemoteState(buf []byte, join bool) { + // No-Op +} diff --git a/internal/memberlist/event_delegate.go b/internal/memberlist/event_delegate.go new file mode 100644 index 0000000..0a60b51 --- /dev/null +++ b/internal/memberlist/event_delegate.go @@ -0,0 +1,67 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package memberlist + +import ( + "encoding/json" + "github.com/hashicorp/memberlist" + "log" +) + +type EventDelegate struct { + options EventDelegateOpts +} + +type EventDelegateOpts struct { + incrementNodes func() + decrementNodes func() + removeRaftServer func(meta NodeMeta) error +} + +func NewEventDelegate(opts EventDelegateOpts) *EventDelegate { + return &EventDelegate{ + options: opts, + } +} + +// NotifyJoin implements EventDelegate interface +func (eventDelegate *EventDelegate) NotifyJoin(node *memberlist.Node) { + eventDelegate.options.incrementNodes() +} + +// NotifyLeave implements EventDelegate interface +func (eventDelegate *EventDelegate) NotifyLeave(node *memberlist.Node) { + eventDelegate.options.decrementNodes() + + var meta NodeMeta + + err := json.Unmarshal(node.Meta, &meta) + + if err != nil { + log.Println("Could not get leaving node's metadata.") + return + } + + err = eventDelegate.options.removeRaftServer(meta) + + if err != nil { + log.Println(err) + } +} + +// NotifyUpdate implements EventDelegate interface +func (eventDelegate *EventDelegate) NotifyUpdate(node *memberlist.Node) { + // No-Op +} diff --git a/internal/memberlist/memberlist.go b/internal/memberlist/memberlist.go new file mode 100644 index 0000000..14ff1b9 --- /dev/null +++ b/internal/memberlist/memberlist.go @@ -0,0 +1,188 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package memberlist + +import ( + "context" + "crypto/md5" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "log" + "sync" + "time" + + "github.com/hashicorp/memberlist" + "github.com/hashicorp/raft" + "github.com/sethvargo/go-retry" +) + +type NodeMeta struct { + ServerID raft.ServerID `json:"ServerID"` + MemberlistAddr string `json:"MemberlistAddr"` + RaftAddr raft.ServerAddress `json:"RaftAddr"` +} + +type Opts struct { + Config config.Config + HasJoinedCluster func() bool + AddVoter func(id raft.ServerID, address raft.ServerAddress, prevIndex uint64, timeout time.Duration) error + RemoveRaftServer func(meta NodeMeta) error + IsRaftLeader func() bool + ApplyMutate func(ctx context.Context, cmd []string) ([]byte, error) + ApplyDeleteKey func(ctx context.Context, key string) error +} + +type MemberList struct { + options Opts + broadcastQueue *memberlist.TransmitLimitedQueue + noOfNodesMut sync.RWMutex + noOfNodes int + memberList *memberlist.Memberlist +} + +func NewMemberList(opts Opts) *MemberList { + return &MemberList{ + options: opts, + broadcastQueue: new(memberlist.TransmitLimitedQueue), + noOfNodesMut: sync.RWMutex{}, + noOfNodes: 0, + } +} + +func (m *MemberList) MemberListInit(ctx context.Context) { + cfg := memberlist.DefaultWANConfig() + cfg.RequireNodeNames = true + cfg.Name = m.options.Config.ServerID + cfg.BindAddr = m.options.Config.BindAddr + cfg.BindPort = int(m.options.Config.DiscoveryPort) + cfg.Delegate = NewDelegate(DelegateOpts{ + config: m.options.Config, + broadcastQueue: m.broadcastQueue, + addVoter: m.options.AddVoter, + isRaftLeader: m.options.IsRaftLeader, + applyMutate: m.options.ApplyMutate, + applyDeleteKey: m.options.ApplyDeleteKey, + }) + cfg.Events = NewEventDelegate(EventDelegateOpts{ + incrementNodes: func() { + m.noOfNodesMut.Lock() + defer m.noOfNodesMut.Unlock() + m.noOfNodes += 1 + }, + decrementNodes: func() { + m.noOfNodesMut.Lock() + defer m.noOfNodesMut.Unlock() + m.noOfNodes -= 1 + }, + removeRaftServer: m.options.RemoveRaftServer, + }) + + m.broadcastQueue.RetransmitMult = 1 + m.broadcastQueue.NumNodes = func() int { + m.noOfNodesMut.RLock() + defer m.noOfNodesMut.RUnlock() + noOfNodes := m.noOfNodes + return noOfNodes + } + + list, err := memberlist.Create(cfg) + m.memberList = list + + if err != nil { + log.Fatal(err) + } + + if m.options.Config.JoinAddr != "" { + backoffPolicy := internal.RetryBackoff(retry.NewFibonacci(1*time.Second), 5, 200*time.Millisecond, 0, 0) + + err = retry.Do(ctx, backoffPolicy, func(ctx context.Context) error { + _, err = list.Join([]string{m.options.Config.JoinAddr}) + if err != nil { + return retry.RetryableError(err) + } + return nil + }) + + if err != nil { + log.Fatal(err) + } + + m.broadcastRaftAddress() + } +} + +func (m *MemberList) broadcastRaftAddress() { + msg := BroadcastMessage{ + Action: "RaftJoin", + NodeMeta: NodeMeta{ + ServerID: raft.ServerID(m.options.Config.ServerID), + RaftAddr: raft.ServerAddress(fmt.Sprintf("%s:%d", + m.options.Config.RaftBindAddr, m.options.Config.RaftBindPort)), + }, + } + m.broadcastQueue.QueueBroadcast(&msg) +} + +// The ForwardDeleteKey function is only called by non-leaders. +// It uses the broadcast queue to forward a key eviction command within the cluster. +func (m *MemberList) ForwardDeleteKey(ctx context.Context, key string) { + connId, _ := ctx.Value(internal.ContextConnID("ConnectionID")).(string) + m.broadcastQueue.QueueBroadcast(&BroadcastMessage{ + Action: "DeleteKey", + Content: []byte(key), + ContentHash: md5.Sum([]byte(key)), + ConnId: connId, + NodeMeta: NodeMeta{ + ServerID: raft.ServerID(m.options.Config.ServerID), + RaftAddr: raft.ServerAddress(fmt.Sprintf("%s:%d", + m.options.Config.BindAddr, m.options.Config.RaftBindPort)), + }, + }) +} + +// The ForwardDataMutation function is only called by non-leaders. +// It uses the broadcast queue to forward a data mutation within the cluster. +func (m *MemberList) ForwardDataMutation(ctx context.Context, cmd []byte) { + connId, _ := ctx.Value(internal.ContextConnID("ConnectionID")).(string) + m.broadcastQueue.QueueBroadcast(&BroadcastMessage{ + Action: "MutateData", + Content: cmd, + ContentHash: md5.Sum(cmd), + ConnId: connId, + NodeMeta: NodeMeta{ + ServerID: raft.ServerID(m.options.Config.ServerID), + RaftAddr: raft.ServerAddress(fmt.Sprintf("%s:%d", + m.options.Config.BindAddr, m.options.Config.RaftBindPort)), + }, + }) +} + +func (m *MemberList) MemberListShutdown() { + // Gracefully leave memberlist cluster + err := m.memberList.Leave(500 * time.Millisecond) + if err != nil { + log.Printf("memberlist leave: %v\n", err) + return + } + + err = m.memberList.Shutdown() + if err != nil { + log.Printf("memberlist shutdown: %v\n", err) + return + } + + log.Println("successfully shutdown memberlist") +} diff --git a/internal/modules/acl/acl.go b/internal/modules/acl/acl.go new file mode 100644 index 0000000..43f3877 --- /dev/null +++ b/internal/modules/acl/acl.go @@ -0,0 +1,511 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package acl + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" + "github.com/gobwas/glob" + "gopkg.in/yaml.v3" + "log" + "net" + "os" + "path" + "reflect" + "slices" + "strings" + "sync" + "time" +) + +type Connection struct { + Authenticated bool // Whether the connection has been authenticated + User *User // The user the connection is associated with +} + +type ACL struct { + Users []*User // List of ACL user profiles + UsersMutex sync.RWMutex // RWMutex for concurrency control when accessing ACL profile list + Connections map[*net.Conn]Connection // Connections to the echovault that are currently registered with the ACL module + Config config.Config // SugarDB configuration that contains the relevant ACL config options + GlobPatterns map[string]glob.Glob +} + +func loadUsersFromConfigFile(filePath string) []*User { + var users []*User + + if filePath != "" { + // Create the directory if it does not exist. + if err := os.MkdirAll(path.Dir(filePath), os.ModePerm); err != nil { + log.Printf("mkdir ACL config: %v\n", err) + return users + } + // Open the config file. Create it if it does not exist. + f, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, os.ModePerm) + if err != nil { + log.Printf("open ACL config: %v\n", err) + return users + } + + defer func() { + if err := f.Close(); err != nil { + log.Printf("close ACL config: %v\n", err) + } + }() + + ext := path.Ext(f.Name()) + + if strings.ToLower(ext) == ".json" { + if err := json.NewDecoder(f).Decode(&users); err != nil { + log.Printf("load ACL config: %v\n", err) + return users + } + } + + if slices.Contains([]string{".yaml", ".yml"}, strings.ToLower(ext)) { + if err := yaml.NewDecoder(f).Decode(&users); err != nil { + log.Printf("load ACL config: %v\n", err) + return users + } + } + } + + return users +} + +func NewACL(config config.Config) *ACL { + var users []*User + + // 1. Initialise default ACL user + defaultUser := CreateUser("default") + if config.RequirePass { + defaultUser.NoPassword = false + defaultUser.Passwords = []Password{ + { + PasswordType: GetPasswordType(config.Password), + PasswordValue: config.Password, + }, + } + } + + // 2. Read and parse the ACL config file + users = loadUsersFromConfigFile(config.AclConfig) + + // 3. If default user was not loaded from file, add the created one + defaultLoaded := false + for _, user := range users { + if user.Username == "default" { + defaultLoaded = true + break + } + } + if !defaultLoaded { + users = append([]*User{defaultUser}, users...) + } + + // 4. Normalise all users + for _, user := range users { + user.Normalise() + } + + acl := ACL{ + Users: users, + UsersMutex: sync.RWMutex{}, + Connections: make(map[*net.Conn]Connection), + Config: config, + GlobPatterns: make(map[string]glob.Glob), + } + + acl.CompileGlobs() + + return &acl +} + +func (acl *ACL) RegisterConnection(conn *net.Conn) { + acl.LockUsers() + defer acl.UnlockUsers() + + // This is called only when a connection is established. + defaultUserIdx := slices.IndexFunc(acl.Users, func(user *User) bool { + return user.Username == "default" + }) + defaultUser := acl.Users[defaultUserIdx] + acl.Connections[conn] = Connection{ + Authenticated: defaultUser.NoPassword, + User: defaultUser, + } +} + +func (acl *ACL) SetUser(cmd []string) error { + acl.LockUsers() + defer acl.UnlockUsers() + + // Check if user with the given username already exists + // If it does, replace user variable with this user + for _, user := range acl.Users { + if user.Username == cmd[0] { + if err := user.UpdateUser(cmd); err != nil { + return err + } else { + acl.CompileGlobs() + return nil + } + } + } + + user := CreateUser(cmd[0]) + if err := user.UpdateUser(cmd); err != nil { + return err + } + + user.Normalise() + + // Add user to ACL + acl.Users = append(acl.Users, user) + + acl.CompileGlobs() + + return nil +} + +func (acl *ACL) DeleteUser(_ context.Context, usernames []string) error { + acl.LockUsers() + defer acl.UnlockUsers() + + var user *User + for _, username := range usernames { + if username == "default" { + // Skip default user + continue + } + // Extract the user + for _, u := range acl.Users { + if username == u.Username { + user = u + } + } + // Skip if the current username was not found in the ACL + if user == nil { + continue + } + // Terminate every connection attached to this user + for connRef, connection := range acl.Connections { + if connection.User.Username == user.Username { + _ = (*connRef).SetReadDeadline(time.Now().Add(-1 * time.Second)) + } + } + // Delete the user from the ACL + acl.Users = slices.DeleteFunc(acl.Users, func(u *User) bool { + return u.Username == user.Username + }) + } + return nil +} + +func (acl *ACL) AuthenticateConnection(_ context.Context, conn *net.Conn, cmd []string) error { + var passwords []Password + var user *User + + if len(cmd) == 2 { + // Process AUTH + h := sha256.New() + h.Write([]byte(cmd[1])) + passwords = []Password{ + {PasswordType: PasswordPlainText, PasswordValue: cmd[1]}, + {PasswordType: PasswordSHA256, PasswordValue: hex.EncodeToString(h.Sum(nil))}, + } + // Authenticate with default user + idx := slices.IndexFunc(acl.Users, func(user *User) bool { + return user.Username == "default" + }) + user = acl.Users[idx] + } + + if len(cmd) == 3 { + // Process AUTH + h := sha256.New() + h.Write([]byte(cmd[2])) + passwords = []Password{ + {PasswordType: PasswordPlainText, PasswordValue: cmd[2]}, + {PasswordType: PasswordSHA256, PasswordValue: hex.EncodeToString(h.Sum(nil))}, + } + // Find user with the specified username + userFound := false + for _, u := range acl.Users { + if u.Username == cmd[1] { + user = u + userFound = true + break + } + } + if !userFound { + return fmt.Errorf("no user with username %s", cmd[1]) + } + } + + // If user is not enabled, return error + if !user.Enabled { + return fmt.Errorf("user %s is disabled", user.Username) + } + + // If user is set to NoPassword, then immediately authenticate connection without considering the password + if user.NoPassword { + acl.Connections[conn] = Connection{ + Authenticated: true, + User: user, + } + return nil + } + + for _, userPassword := range user.Passwords { + for _, password := range passwords { + if userPassword.PasswordType == password.PasswordType && + userPassword.PasswordValue == password.PasswordValue && + user.Enabled { + // Set the current connection to the selected user and set them as authenticated. + acl.Connections[conn] = Connection{ + Authenticated: true, + User: user, + } + return nil + } + } + } + + return errors.New("could not authenticate user") +} + +func (acl *ACL) AuthorizeConnection(conn *net.Conn, cmd []string, command internal.Command, subCommand internal.SubCommand) error { + acl.RLockUsers() + defer acl.RUnlockUsers() + + // Extract command, categories, and keys + comm := command.Command + categories := command.Categories + + keys, err := command.KeyExtractionFunc(cmd) + if err != nil { + return err + } + + channels := keys.Channels + readKeys := keys.ReadKeys + writeKeys := keys.WriteKeys + + if !reflect.DeepEqual(subCommand, internal.SubCommand{}) { + comm = fmt.Sprintf("%s|%s", comm, subCommand.Command) + categories = append(categories, subCommand.Categories...) + keys, err = subCommand.KeyExtractionFunc(cmd) + if err != nil { + return err + } + } + + // Skip ack + if strings.EqualFold(comm, "ack") { + return nil + } + + // Skip certain commands from authorization + if slices.Contains([]string{"ping", "echo", "hello"}, strings.ToLower(comm)) { + return nil + } + + // If the command is 'auth', then return early and allow it + if strings.EqualFold(comm, "auth") { + return nil + } + + // Get current connection ACL details + connection := acl.Connections[conn] + + // If password is not required, allow the connection + if !acl.Config.RequirePass { + return nil + } + + // 1. Check if password is required and if the user is authenticated + if acl.Config.RequirePass && !connection.Authenticated { + return errors.New("user must be authenticated") + } + + var notAllowed []string + + // 2. Check if all categories are in IncludedCategories + count := make(map[string]int, len(categories)) + if !slices.Contains(connection.User.IncludedCategories, "*") { + for _, category := range categories { + count[category] = 0 + } + for _, category := range connection.User.IncludedCategories { + if _, ok := count[category]; ok { + count[category] += 1 + } + } + notAllowed = getUnauthorized(count, "@") + if len(notAllowed) > 0 { + return fmt.Errorf("unauthorized access to the following categories: %+v", notAllowed) + } + } + + // 3. Check if commands category is in ExcludedCategories + if slices.ContainsFunc(categories, func(category string) bool { + return slices.ContainsFunc(connection.User.ExcludedCategories, func(excludedCategory string) bool { + if excludedCategory == "*" || excludedCategory == category { + notAllowed = []string{fmt.Sprintf("@%s", category)} + return true + } + return false + }) + }) { + return fmt.Errorf("unauthorized access to the following categories: %+v", notAllowed) + } + + // 4. Check if commands are in IncludedCommands + if !slices.ContainsFunc(connection.User.IncludedCommands, func(includedCommand string) bool { + return includedCommand == "*" || includedCommand == comm + }) { + return fmt.Errorf("not authorised to run %s command", strings.ToUpper(comm)) + } + + // 5. Check if command are in ExcludedCommands + if slices.ContainsFunc(connection.User.ExcludedCommands, func(excludedCommand string) bool { + return excludedCommand == "*" || excludedCommand == comm + }) { + return fmt.Errorf("not authorised to run %s command", strings.ToUpper(comm)) + } + + // 6. PUBSUB authorisation. + if slices.Contains(categories, constants.PubSubCategory) { + // Loop through each of the channels accessed by this command + for _, channel := range channels { + // 2.1) Check if the channel is in IncludedPubSubChannels + if !slices.ContainsFunc(connection.User.IncludedPubSubChannels, func(includedChannelGlob string) bool { + return acl.GlobPatterns[includedChannelGlob].Match(channel) + }) { + return fmt.Errorf("not authorised to access channel &%s", channel) + } + // 2.2) Check if the channel is in ExcludedPubSubChannels + if slices.ContainsFunc(connection.User.ExcludedPubSubChannels, func(excludedChannelGlob string) bool { + return acl.GlobPatterns[excludedChannelGlob].Match(channel) + }) { + return fmt.Errorf("not authorised to access channel &%s", channel) + } + } + return nil + } + + if len(append(readKeys, writeKeys...)) > 0 { + // 7. Check if nokeys is true + if connection.User.NoKeys { + return errors.New("not authorised to access any keys") + } + + // 8. Check if readKeys are in IncludedReadKeys + if len(readKeys) > 0 && !slices.ContainsFunc(readKeys, func(key string) bool { + return slices.ContainsFunc(connection.User.IncludedReadKeys, func(readKeyGlob string) bool { + if acl.GlobPatterns[readKeyGlob].Match(key) { + return true + } + if !slices.Contains(notAllowed, fmt.Sprintf("%s~%s", "%R", key)) { + notAllowed = append(notAllowed, fmt.Sprintf("%s~%s", "%R", key)) + } + return false + }) + }) { + if len(notAllowed) > 0 { + return fmt.Errorf("not authorised to access the following read keys: %+v", notAllowed) + } + } + + // 9. Check if write keys are in IncludedWriteKeys + if len(writeKeys) > 0 && !slices.ContainsFunc(writeKeys, func(key string) bool { + return slices.ContainsFunc(connection.User.IncludedWriteKeys, func(writeKeyGlob string) bool { + if acl.GlobPatterns[writeKeyGlob].Match(key) { + return true + } + if !slices.Contains(notAllowed, fmt.Sprintf("%s~%s", "%W", key)) { + notAllowed = append(notAllowed, fmt.Sprintf("%s~%s", "%W", key)) + } + return false + }) + }) { + return fmt.Errorf("not authorised to access the following write keys: %+v", notAllowed) + } + } + + return nil +} + +func (acl *ACL) CompileGlobs() { + // Extract all the relevant globs from all the users + var allGlobs []string + var userGlobs []string + for _, user := range acl.Users { + userGlobs = append(userGlobs, user.IncludedPubSubChannels...) + userGlobs = append(userGlobs, user.ExcludedPubSubChannels...) + userGlobs = append(userGlobs, user.IncludedReadKeys...) + userGlobs = append(userGlobs, user.IncludedWriteKeys...) + for _, g := range userGlobs { + if !slices.Contains(allGlobs, g) { + allGlobs = append(allGlobs, g) + } + } + userGlobs = []string{} + } + // Compile the globs that have not been compiled yet + for _, g := range allGlobs { + if acl.GlobPatterns[g] == nil { + acl.GlobPatterns[g] = glob.MustCompile(g) + } + } +} + +func (acl *ACL) LockUsers() { + acl.UsersMutex.Lock() +} + +func (acl *ACL) UnlockUsers() { + acl.UsersMutex.Unlock() +} + +func (acl *ACL) RLockUsers() { + acl.UsersMutex.RLock() +} + +func (acl *ACL) RUnlockUsers() { + acl.UsersMutex.RUnlock() +} + +func getUnauthorized(count map[string]int, prefix string) []string { + var notAllowed []string + for member, c := range count { + if c == 0 { + notAllowed = append(notAllowed, fmt.Sprintf("%s%s", prefix, member)) + } + } + // Sort the members in alphabetical order. + slices.SortStableFunc(notAllowed, func(a, b string) int { + return internal.CompareLex(a, b) + }) + return notAllowed +} diff --git a/internal/modules/acl/commands.go b/internal/modules/acl/commands.go new file mode 100644 index 0000000..d3bfd10 --- /dev/null +++ b/internal/modules/acl/commands.go @@ -0,0 +1,640 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package acl + +import ( + "encoding/json" + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" + "gopkg.in/yaml.v3" + "log" + "os" + "path" + "slices" + "strings" +) + +func handleCat(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) > 3 { + return nil, errors.New(constants.WrongArgsResponse) + } + + categories := make(map[string][]string) + + commands := params.GetAllCommands() + + for _, command := range commands { + if len(command.SubCommands) == 0 { + for _, category := range command.Categories { + categories[category] = append(categories[category], command.Command) + } + continue + } + for _, subcommand := range command.SubCommands { + for _, category := range subcommand.Categories { + categories[category] = append(categories[category], + fmt.Sprintf("%s|%s", command.Command, subcommand.Command)) + } + } + } + + if len(params.Command) == 2 { + var cats []string + length := 0 + for key, _ := range categories { + cats = append(cats, key) + length += 1 + } + res := fmt.Sprintf("*%d", length) + for i, cat := range cats { + res = fmt.Sprintf("%s\r\n+%s", res, cat) + if i == len(cats)-1 { + res = res + "\r\n" + } + } + return []byte(res), nil + } + + if len(params.Command) == 3 { + var res string + for category, commands := range categories { + if strings.EqualFold(category, params.Command[2]) { + res = fmt.Sprintf("*%d", len(commands)) + for i, command := range commands { + res = fmt.Sprintf("%s\r\n+%s", res, command) + if i == len(commands)-1 { + res = res + "\r\n" + } + } + return []byte(res), nil + } + } + } + + return nil, fmt.Errorf("category %s not found", strings.ToUpper(params.Command[2])) +} + +func handleGetUser(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) != 3 { + return nil, errors.New(constants.WrongArgsResponse) + } + + acl, ok := params.GetACL().(*ACL) + if !ok { + return nil, errors.New("could not load ACL") + } + acl.RLockUsers() + defer acl.RUnlockUsers() + + var user *User + userFound := false + for _, u := range acl.Users { + if u.Username == params.Command[2] { + user = u + userFound = true + break + } + } + + if !userFound { + return nil, errors.New("user not found") + } + + // username, + res := fmt.Sprintf("*12\r\n+username\r\n*1\r\n+%s", user.Username) + + // flags + var flags []string + if user.Enabled { + flags = append(flags, "on") + } else { + flags = append(flags, "off") + } + if user.NoPassword { + flags = append(flags, "nopass") + } + if user.NoKeys { + flags = append(flags, "nokeys") + } + + res = res + fmt.Sprintf("\r\n+flags\r\n*%d", len(flags)) + for _, flag := range flags { + res = fmt.Sprintf("%s\r\n+%s", res, flag) + } + + // categories + res = res + fmt.Sprintf("\r\n+categories\r\n*%d", len(user.IncludedCategories)+len(user.ExcludedCategories)) + for _, category := range user.IncludedCategories { + if category == "*" { + res = res + fmt.Sprintf("\r\n++@all") + continue + } + res = res + fmt.Sprintf("\r\n++@%s", category) + } + for _, category := range user.ExcludedCategories { + if category == "*" { + res = res + fmt.Sprintf("\r\n+-@all") + continue + } + res = res + fmt.Sprintf("\r\n+-@%s", category) + } + + // commands + res = res + fmt.Sprintf("\r\n+commands\r\n*%d", len(user.IncludedCommands)+len(user.ExcludedCommands)) + for _, command := range user.IncludedCommands { + if command == "*" { + res = res + fmt.Sprintf("\r\n++all") + continue + } + res = res + fmt.Sprintf("\r\n++%s", command) + } + for _, command := range user.ExcludedCommands { + if command == "*" { + res = res + fmt.Sprintf("\r\n+-all") + continue + } + res = res + fmt.Sprintf("\r\n+-%s", command) + } + + // keys + allKeys := user.IncludedReadKeys + for _, key := range append(user.IncludedWriteKeys, user.IncludedReadKeys...) { + if !slices.Contains(allKeys, key) { + allKeys = append(allKeys, key) + } + } + res = res + fmt.Sprintf("\r\n+keys\r\n*%d", len(allKeys)) + for _, key := range allKeys { + switch { + case slices.Contains(user.IncludedWriteKeys, key) && slices.Contains(user.IncludedReadKeys, key): + // Key is RW + res = res + fmt.Sprintf("\r\n+%s~%s", "%RW", key) + case slices.Contains(user.IncludedWriteKeys, key): + // Keys is W-Only + res = res + fmt.Sprintf("\r\n+%s~%s", "%W", key) + case slices.Contains(user.IncludedReadKeys, key): + // Key is R-Only + res = res + fmt.Sprintf("\r\n+%s~%s", "%R", key) + } + } + + // channels + res = res + fmt.Sprintf("\r\n+channels\r\n*%d", + len(user.IncludedPubSubChannels)+len(user.ExcludedPubSubChannels)) + for _, channel := range user.IncludedPubSubChannels { + res = res + fmt.Sprintf("\r\n++&%s", channel) + } + for _, channel := range user.ExcludedPubSubChannels { + res = res + fmt.Sprintf("\r\n+-&%s", channel) + } + + res += "\r\n" + + return []byte(res), nil +} + +func handleUsers(params internal.HandlerFuncParams) ([]byte, error) { + acl, ok := params.GetACL().(*ACL) + if !ok { + return nil, errors.New("could not load ACL") + } + + res := fmt.Sprintf("*%d", len(acl.Users)) + for _, user := range acl.Users { + res += fmt.Sprintf("\r\n$%d\r\n%s", len(user.Username), user.Username) + } + res += "\r\n" + return []byte(res), nil +} + +func handleSetUser(params internal.HandlerFuncParams) ([]byte, error) { + acl, ok := params.GetACL().(*ACL) + if !ok { + return nil, errors.New("could not load ACL") + } + if err := acl.SetUser(params.Command[2:]); err != nil { + return nil, err + } + return []byte(constants.OkResponse), nil +} + +func handleDelUser(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) < 3 { + return nil, errors.New(constants.WrongArgsResponse) + } + acl, ok := params.GetACL().(*ACL) + if !ok { + return nil, errors.New("could not load ACL") + } + if err := acl.DeleteUser(params.Context, params.Command[2:]); err != nil { + return nil, err + } + return []byte(constants.OkResponse), nil +} + +func handleWhoAmI(params internal.HandlerFuncParams) ([]byte, error) { + acl, ok := params.GetACL().(*ACL) + if !ok { + return nil, errors.New("could not load ACL") + } + acl.RLockUsers() + defer acl.RUnlockUsers() + + connectionInfo := acl.Connections[params.Connection] + return []byte(fmt.Sprintf("+%s\r\n", connectionInfo.User.Username)), nil +} + +func handleList(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) > 2 { + return nil, errors.New(constants.WrongArgsResponse) + } + acl, ok := params.GetACL().(*ACL) + if !ok { + return nil, errors.New("could not load ACL") + } + acl.RLockUsers() + defer acl.RUnlockUsers() + + res := fmt.Sprintf("*%d", len(acl.Users)) + s := "" + for _, user := range acl.Users { + s = user.Username + // User enabled + if user.Enabled { + s += " on" + } else { + s += " off" + } + // NoPassword + if user.NoPassword { + s += " nopass" + } + // No keys + if user.NoKeys { + s += " nokeys" + } + // Passwords + for _, password := range user.Passwords { + if strings.EqualFold(password.PasswordType, "plaintext") { + s += fmt.Sprintf(" >%s", password.PasswordValue) + } + if strings.EqualFold(password.PasswordType, "SHA256") { + s += fmt.Sprintf(" #%s", password.PasswordValue) + } + } + // Included categories + for _, category := range user.IncludedCategories { + if category == "*" { + s += " +@all" + continue + } + s += fmt.Sprintf(" +@%s", category) + } + // Excluded categories + for _, category := range user.ExcludedCategories { + if category == "*" { + s += " -@all" + continue + } + s += fmt.Sprintf(" -@%s", category) + } + // Included commands + for _, command := range user.IncludedCommands { + if command == "*" { + s += " +all" + continue + } + s += fmt.Sprintf(" +%s", command) + } + // Excluded commands + for _, command := range user.ExcludedCommands { + if command == "*" { + s += " -all" + continue + } + s += fmt.Sprintf(" -%s", command) + } + // Included read keys + for _, key := range user.IncludedReadKeys { + if slices.Contains(user.IncludedWriteKeys, key) { + s += fmt.Sprintf(" %s~%s", "%RW", key) + continue + } + s += fmt.Sprintf(" %s~%s", "%R", key) + } + // Included write keys + for _, key := range user.IncludedWriteKeys { + if !slices.Contains(user.IncludedReadKeys, key) { + s += fmt.Sprintf(" %s~%s", "%W", key) + } + } + // Included Pub/Sub channels + for _, channel := range user.IncludedPubSubChannels { + s += fmt.Sprintf(" +&%s", channel) + } + // Excluded Pup/Sub channels + for _, channel := range user.ExcludedPubSubChannels { + s += fmt.Sprintf(" -&%s", channel) + } + res = res + fmt.Sprintf("\r\n$%d\r\n%s", len(s), s) + } + + res = res + "\r\n" + return []byte(res), nil +} + +func handleLoad(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) != 3 { + return nil, errors.New(constants.WrongArgsResponse) + } + + acl, ok := params.GetACL().(*ACL) + if !ok { + return nil, errors.New("could not load ACL") + } + acl.LockUsers() + defer acl.UnlockUsers() + + f, err := os.OpenFile(acl.Config.AclConfig, os.O_RDONLY, os.ModePerm) + if err != nil { + return nil, err + } + + defer func() { + if err := f.Close(); err != nil { + log.Println(err) + } + }() + + ext := path.Ext(f.Name()) + + var users []*User + + if strings.ToLower(ext) == ".json" { + if err := json.NewDecoder(f).Decode(&users); err != nil { + return nil, err + } + } + + if slices.Contains([]string{".yaml", ".yml"}, strings.ToLower(ext)) { + if err := yaml.NewDecoder(f).Decode(&users); err != nil { + return nil, err + } + } + + // Normalise each user + for _, user := range users { + user.Normalise() + // Traverse the list of users. + userFound := false + for _, u := range acl.Users { + if u.Username == user.Username { + userFound = true + // If we have a user with the current username and are in merge mode, merge the two users. + if strings.EqualFold(params.Command[2], "merge") { + u.Merge(user) + } else { + // If we have a user with the current username and are in replace mode, merge the two users. + u.Replace(user) + } + break + } + } + // If there is no user with current loaded username is already in acl list, then append the user to the list + if !userFound { + acl.Users = append(acl.Users, user) + } + } + + return []byte(constants.OkResponse), nil +} + +func handleSave(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) > 2 { + return nil, errors.New(constants.WrongArgsResponse) + } + + acl, ok := params.GetACL().(*ACL) + if !ok { + return nil, errors.New("could not load ACL") + } + acl.RLockUsers() + defer acl.RUnlockUsers() + + f, err := os.OpenFile(acl.Config.AclConfig, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.ModePerm) + if err != nil { + return nil, err + } + + defer func() { + if err := f.Close(); err != nil { + log.Println(err) + } + }() + + ext := path.Ext(f.Name()) + + if strings.ToLower(ext) == ".json" { + // Write to JSON config file + out, err := json.Marshal(acl.Users) + if err != nil { + return nil, err + } + if _, err = f.Write(out); err != nil { + return nil, err + } + } + + if slices.Contains([]string{".yaml", ".yml"}, strings.ToLower(ext)) { + // Write to yaml file + out, err := yaml.Marshal(acl.Users) + if err != nil { + return nil, err + } + if _, err = f.Write(out); err != nil { + return nil, err + } + } + + if err = f.Sync(); err != nil { + return nil, err + } + + return []byte(constants.OkResponse), nil +} + +func Commands() []internal.Command { + return []internal.Command{ + { + Command: "acl", + Module: constants.ACLModule, + Categories: []string{}, + Description: "Access-Control-List commands", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + SubCommands: []internal.SubCommand{ + { + Command: "cat", + Module: constants.ACLModule, + Categories: []string{constants.SlowCategory}, + Description: `(ACL CAT [category]) Lists all the categories. +If the optional category is provided, lists all the commands in the category.`, + Sync: false, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleCat, + }, + { + Command: "users", + Module: constants.ACLModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory, constants.DangerousCategory}, + Description: "(ACL USERS) Lists all usernames of the configured ACL users.", + Sync: false, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleUsers, + }, + { + Command: "setuser", + Module: constants.ACLModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory, constants.DangerousCategory}, + Description: "(ACL SETUSER) Configure a new or existing user", + Sync: true, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleSetUser, + }, + { + Command: "getuser", + Module: constants.ACLModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory, constants.DangerousCategory}, + Description: "(ACL GETUSER username) List the ACL rules of a user.", + Sync: false, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleGetUser, + }, + { + Command: "deluser", + Module: constants.ACLModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory, constants.DangerousCategory}, + Description: `(ACL DELUSER username [username ...]) +Deletes users and terminates their connections. Cannot delete default user.`, + Sync: true, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleDelUser, + }, + { + Command: "whoami", + Module: constants.ACLModule, + Categories: []string{constants.FastCategory}, + Description: "(ACL WHOAMI) Returns the authenticated user of the current connection.", + Sync: true, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleWhoAmI, + }, + { + Command: "list", + Module: constants.ACLModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory, constants.DangerousCategory}, + Description: "(ACL LIST) Dumps effective acl rules in ACL DSL format.", + Sync: true, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleList, + }, + { + Command: "load", + Module: constants.ACLModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory, constants.DangerousCategory}, + Description: ` +(ACL LOAD ) Reloads the rules from the configured ACL config file. +When 'MERGE' is passed, users from config file who share a username with users in memory will be merged. +When 'REPLACE' is passed, users from config file who share a username with users in memory will replace the user in memory.`, + Sync: true, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleLoad, + }, + { + Command: "save", + Module: constants.ACLModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory, constants.DangerousCategory}, + Description: "(ACL SAVE) Saves the effective ACL rules the configured ACL config file.", + Sync: true, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleSave, + }, + }, + }, + } +} diff --git a/internal/modules/acl/commands_test.go b/internal/modules/acl/commands_test.go new file mode 100644 index 0000000..72d94cc --- /dev/null +++ b/internal/modules/acl/commands_test.go @@ -0,0 +1,2114 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package acl_test + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" + "apigo.cc/go/sugardb/sugardb" + "github.com/tidwall/resp" + "os" + "path" + "slices" + "strings" + "sync" + "testing" +) + +func setUpServer(port int, requirePass bool, aclConfig string) (*sugardb.SugarDB, error) { + conf := config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.NoEviction, + RequirePass: requirePass, + Password: "password1", + AclConfig: aclConfig, + } + + mockServer, err := sugardb.NewSugarDB( + sugardb.WithConfig(conf), + ) + if err != nil { + return nil, err + } + + // Add the initial test users to the ACL module. + for _, user := range generateInitialTestUsers() { + // If the user already exists in the server, skip. + existingUsers, err := mockServer.ACLUsers() + if err != nil { + return nil, err + } + if slices.Contains(existingUsers, user.Username) { + continue + } + if _, err := mockServer.ACLSetUser(user); err != nil { + return nil, err + } + } + + return mockServer, nil +} + +func generateInitialTestUsers() []sugardb.User { + return []sugardb.User{ + { + // User with both hash password and plaintext password. + Username: "with_password_user", + Enabled: true, + IncludeCategories: []string{"*"}, + IncludeCommands: []string{"*"}, + AddPlainPasswords: []string{"password2"}, + AddHashPasswords: []string{generateSHA256Password("password3")}, + }, + { + // User with NoPassword option. + Username: "no_password_user", + Enabled: true, + NoPassword: true, + AddPlainPasswords: []string{"password4"}, + }, + { + // Disabled user. + Username: "disabled_user", + Enabled: false, + AddPlainPasswords: []string{"password5"}, + }, + } +} + +// compareSlices compare the elements in 2 slices, it checks if every element is s1 is contained in s2 +// and vice versa. It essentially does a deep equality comparison. +// This is done manually rather than using slices.Equal because it would be ideal to throw an error +// specifying exactly which items are missing in either slice. +func compareSlices[T comparable](res, expected []T) error { + if len(res) != len(expected) { + return fmt.Errorf("expected slice of length %d, got slice of length %d", len(expected), len(res)) + } + // Check whether all elements in res are contained in expected + for _, r := range res { + if !slices.Contains(expected, r) { + return fmt.Errorf("got response item %+v, but it's not contained in expected slices", r) + } + } + // Check whether all elements in expected are contained in res + for _, e := range expected { + if !slices.Contains(res, e) { + return fmt.Errorf("expected element %+v, not found in res slice", e) + } + } + return nil +} + +// compareUsers compares 2 users and checks if all their fields are equal +func compareUsers(user1, user2 map[string][]string) error { + // Compare flags + if user1["username"][0] != user2["username"][0] { + return fmt.Errorf("mismatched usernames \"%s\", and \"%s\"", user1["username"][0], user2["username"][0]) + } + + // Check if both users are enabled. + if slices.Contains(user1["flags"], "on") != slices.Contains(user2["flags"], "on") { + return fmt.Errorf("mismatched enabled flag \"%+v\", and \"%+v\"", + slices.Contains(user1["flags"], "on"), slices.Contains(user2["flags"], "on")) + } + + // Check if "nokeys" is present + if slices.Contains(user1["flags"], "nokeys") != slices.Contains(user2["flags"], "nokeys") { + return fmt.Errorf("mismatched nokeys flag \"%+v\", and \"%+v\"", + slices.Contains(user1["flags"], "nokeys"), slices.Contains(user2["flags"], "nokeys")) + } + + // Check if "nopass" is present + if slices.Contains(user1["flags"], "nopass") != slices.Contains(user1["flags"], "nopass") { + return fmt.Errorf("mismatched nopassword flag \"%+v\", and \"%+v\"", + slices.Contains(user1["flags"], "nopass"), slices.Contains(user1["flags"], "nopass")) + } + + // Compare permissions + permissions := [][][]string{ + {user1["categories"], user2["categories"]}, + {user1["commands"], user2["commands"]}, + {user1["keys"], user2["keys"]}, + {user1["channels"], user2["channels"]}, + } + for _, p := range permissions { + if err := compareSlices(p[0], p[1]); err != nil { + return err + } + } + + return nil +} + +func generateSHA256Password(plain string) string { + h := sha256.New() + h.Write([]byte(plain)) + return hex.EncodeToString(h.Sum(nil)) +} + +func Test_ACL(t *testing.T) { + t.Parallel() + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := setUpServer(port, true, "") + if err != nil { + t.Error(err) + return + } + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + t.Run("Test_Permissions", func(t *testing.T) { + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := setUpServer(port, true, "") + if err != nil { + t.Error(err) + return + } + go func() { + mockServer.Start() + }() + + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client := resp.NewConn(conn) + + t.Cleanup(func() { + _ = conn.Close() + mockServer.ShutDown() + }) + + // Add users to be used in test cases. + users := []sugardb.User{ + { + // User with nokeys flag enables. + Username: "test_nokeys", + Enabled: true, + NoKeys: true, + AddPlainPasswords: []string{"test_nokeys_password"}, + }, + { + // This use will be used to test authorization failure when trying to access resources that are not + // in their "included" rules. + Username: "test_included", + Enabled: true, + AddPlainPasswords: []string{"test_included_password"}, + IncludeCategories: []string{ + constants.WriteCategory, + constants.ReadCategory, + constants.SlowCategory, + constants.PubSubCategory, + constants.ConnectionCategory, + constants.ListCategory, + }, + IncludeCommands: []string{"set", "get", "subscribe", "lrange", "ltrim"}, + IncludeChannels: []string{"channel[12]"}, + IncludeReadWriteKeys: []string{"key1", "key2"}, + }, + { + // This use will be used to test authorization failure when trying to access resources that are + // in their "excluded" rules. + Username: "test_excluded", + Enabled: true, + AddPlainPasswords: []string{"test_excluded_password"}, + IncludeCategories: []string{"*"}, + ExcludeCategories: []string{constants.FastCategory, constants.HashCategory}, + IncludeCommands: []string{"*"}, + ExcludeCommands: []string{"set", "mset"}, + IncludeChannels: []string{"*"}, + ExcludeChannels: []string{"channel[12]"}, + }, + } + for _, user := range users { + if _, err := mockServer.ACLSetUser(user); err != nil { + t.Error(err) + return + } + } + + tests := []struct { + name string + auth []resp.Value + cmd []resp.Value + wantErr string + }{ + { + name: "1. Return error when the connection is not authenticated", + auth: []resp.Value{}, + cmd: []resp.Value{resp.StringValue("SET"), resp.StringValue("key"), resp.StringValue("value")}, + wantErr: "user must be authenticated", + }, + { + name: "2. Return error when the command category is not in the included categories section", + auth: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("test_included"), + resp.StringValue("test_included_password"), + }, + cmd: []resp.Value{ + resp.StringValue("HSET"), + resp.StringValue("hash"), + resp.StringValue("field1"), + resp.StringValue("value1"), + }, + wantErr: fmt.Sprintf("unauthorized access to the following categories: [@%s @%s]", + constants.FastCategory, constants.HashCategory), + }, + { + name: "3. Return error when the command category is in the excluded categories section", + auth: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("test_excluded"), + resp.StringValue("test_excluded_password"), + }, + cmd: []resp.Value{ + resp.StringValue("HSET"), + resp.StringValue("hash"), + resp.StringValue("field1"), + resp.StringValue("value1"), + }, + wantErr: fmt.Sprintf("unauthorized access to the following categories: [@%s]", + constants.HashCategory), + }, + { + name: "4. Return error when the command is not in the included command category", + auth: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("test_included"), + resp.StringValue("test_included_password"), + }, + cmd: []resp.Value{ + resp.StringValue("MSET"), + resp.StringValue("key1"), + resp.StringValue("value1"), + }, + wantErr: "not authorised to run MSET command", + }, + { + name: "5. Return error when the command is in the excluded command category", + auth: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("test_excluded"), + resp.StringValue("test_excluded_password"), + }, + cmd: []resp.Value{ + resp.StringValue("SET"), + resp.StringValue("key1"), + resp.StringValue("value1"), + }, + wantErr: "not authorised to run SET command", + }, + { + name: "6. Return error when subscribing to channel that's not in included channels", + auth: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("test_included"), + resp.StringValue("test_included_password"), + }, + cmd: []resp.Value{ + resp.StringValue("SUBSCRIBE"), + resp.StringValue("channel3"), + }, + wantErr: "not authorised to access channel &channel3", + }, + { + name: "7. Return error when publishing to channel that's in excluded channels", + auth: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("test_excluded"), + resp.StringValue("test_excluded_password"), + }, + cmd: []resp.Value{ + resp.StringValue("SUBSCRIBE"), + resp.StringValue("channel2"), + }, + wantErr: "not authorised to access channel &channel2", + }, + { + name: "8. Return error when the user has nokeys flag", + auth: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("test_nokeys"), + resp.StringValue("test_nokeys_password"), + }, + cmd: []resp.Value{resp.StringValue("GET"), resp.StringValue("key1")}, + }, + { + name: "9. Return error when trying to read from keys that are not in read keys list", + auth: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("test_included"), + resp.StringValue("test_included_password"), + }, + cmd: []resp.Value{ + resp.StringValue("LRANGE"), + resp.StringValue("key3"), + resp.StringValue("0"), + resp.StringValue("-1"), + }, + wantErr: fmt.Sprintf("not authorised to access the following read keys: [%s~%s]", "%R", "key3"), + }, + { + name: "10. Return error when trying to write to keys that are not in write keys list", + auth: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("test_included"), + resp.StringValue("test_included_password"), + }, + cmd: []resp.Value{ + resp.StringValue("LTRIM"), + resp.StringValue("key3"), + resp.StringValue("0"), + resp.StringValue("3"), + }, + wantErr: fmt.Sprintf("not authorised to access the following write keys: [%s~%s]", "%W", "key3"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Authenticate the user if the auth command is provided. + if len(test.auth) > 0 { + err := client.WriteArray(test.auth) + if err != nil { + t.Error(err) + return + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected auth response to be OK, got \"%s\"", res.String()) + } + } + + if err := client.WriteArray(test.cmd); err != nil { + t.Error(err) + return + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + + if !strings.Contains(res.Error().Error(), test.wantErr) { + t.Errorf("expected error to contain string \"%s\", got \"%s\"", + test.wantErr, res.Error().Error()) + return + } + }) + } + }) + + t.Run("Test_HandleCat", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + if conn != nil { + _ = conn.Close() + } + }() + r := resp.NewConn(conn) + + // Authenticate connection + if err = r.WriteArray([]resp.Value{resp.StringValue("AUTH"), resp.StringValue("password1")}); err != nil { + t.Error(err) + } + rv, _, err := r.ReadValue() + if err != nil { + t.Error(err) + } + if rv.String() != "OK" { + t.Error("could not authenticate user") + } + + // Since only ACL commands are loaded in this test suite, this test will only test against the + // list of categories and commands available in the ACL module. + tests := []struct { + cmd []resp.Value + wantRes []string + wantErr string + }{ + { // 1. Return list of categories + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("CAT")}, + wantRes: []string{ + constants.ConnectionCategory, + constants.SlowCategory, + constants.FastCategory, + constants.AdminCategory, + constants.DangerousCategory, + }, + wantErr: "", + }, + { // 2. Return list of commands in connection category + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("CAT"), resp.StringValue(constants.ConnectionCategory)}, + wantRes: []string{"auth"}, + wantErr: "", + }, + { // 3. Return list of commands in slow category + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("CAT"), resp.StringValue(constants.SlowCategory)}, + wantRes: []string{"auth", "acl|cat", "acl|users", "acl|setuser", "acl|getuser", "acl|deluser", "acl|list", "acl|load", "acl|save"}, + wantErr: "", + }, + { // 4. Return list of commands in fast category + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("CAT"), resp.StringValue(constants.FastCategory)}, + wantRes: []string{"acl|whoami"}, + wantErr: "", + }, + { // 5. Return list of commands in admin category + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("CAT"), resp.StringValue(constants.AdminCategory)}, + wantRes: []string{"acl|users", "acl|setuser", "acl|getuser", "acl|deluser", "acl|list", "acl|load", "acl|save"}, + wantErr: "", + }, + { // 6. Return list of commands in dangerous category + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("CAT"), resp.StringValue(constants.DangerousCategory)}, + wantRes: []string{"acl|users", "acl|setuser", "acl|getuser", "acl|deluser", "acl|list", "acl|load", "acl|save"}, + wantErr: "", + }, + { // 7. Return error when category does not exist + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("CAT"), resp.StringValue("non-existent")}, + wantRes: nil, + wantErr: "Error category NON-EXISTENT not found", + }, + { // 8. Command too long + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("CAT"), resp.StringValue("category1"), resp.StringValue("category2")}, + wantRes: nil, + wantErr: fmt.Sprintf("Error %s", constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + if err = r.WriteArray(test.cmd); err != nil { + t.Error(err) + } + rv, _, err = r.ReadValue() + if err != nil { + t.Error(err) + } + if test.wantErr != "" { + if rv.Error().Error() != test.wantErr { + t.Errorf("expected error response \"%s\", got \"%s\"", test.wantErr, rv.Error().Error()) + } + continue + } + resArr := rv.Array() + // Check if all the elements in the expected array are in the response array + for _, expected := range test.wantRes { + if !slices.ContainsFunc(resArr, func(value resp.Value) bool { + return value.String() == expected + }) { + t.Errorf("could not find expected command \"%s\" in the response array for category", expected) + } + } + } + }) + + t.Run("Test_HandleUsers", func(t *testing.T) { + t.Parallel() + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := setUpServer(port, false, "") + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + + defer func() { + if conn != nil { + _ = conn.Close() + } + }() + + r := resp.NewConn(conn) + + users := []string{"default", "with_password_user", "no_password_user", "disabled_user"} + + if err = r.WriteArray([]resp.Value{resp.StringValue("ACL"), resp.StringValue("USERS")}); err != nil { + t.Error(err) + } + + rv, _, err := r.ReadValue() + if err != nil { + t.Error(err) + } + + resArr := rv.Array() + + // Check if all the expected users are in the response array + for _, user := range users { + if !slices.ContainsFunc(resArr, func(value resp.Value) bool { + return value.String() == user + }) { + t.Errorf("could not find expected user \"%s\" in response array", user) + } + } + + // Check if all the users in the response array are in the expected users + for _, value := range resArr { + if !slices.ContainsFunc(users, func(user string) bool { + return value.String() == user + }) { + t.Errorf("could not find response user \"%s\" in expected users array", value.String()) + } + } + }) + + t.Run("Test_HandleSetUser", func(t *testing.T) { + t.Parallel() + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := setUpServer(port, false, "") + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + if conn != nil { + _ = conn.Close() + } + }() + + r := resp.NewConn(conn) + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + tests := []struct { + name string + presetUser *sugardb.User + cmd []resp.Value + wantRes string + wantErr string + wantUser map[string][]string + }{ + { + name: "1. Create new enabled user", + presetUser: nil, + cmd: []resp.Value{ + resp.StringValue("ACL"), + resp.StringValue("SETUSER"), + resp.StringValue("set_user_1"), + resp.StringValue("on"), + }, + wantRes: "OK", + wantErr: "", + wantUser: map[string][]string{ + "username": {"set_user_1"}, + "flags": {"on"}, + "categories": {"+@all"}, + "commands": {"+all"}, + "keys": {"%RW~*"}, + "channels": {"+&*"}, + }, + }, + { + name: "2. Create new disabled user", + presetUser: nil, + cmd: []resp.Value{ + resp.StringValue("ACL"), + resp.StringValue("SETUSER"), + resp.StringValue("set_user_2"), + resp.StringValue("off"), + }, + wantRes: "OK", + wantErr: "", + wantUser: map[string][]string{ + "username": {"set_user_2"}, + "flags": {"off"}, + "categories": {"+@all"}, + "commands": {"+all"}, + "keys": {"%RW~*"}, + "channels": {"+&*"}, + }, + }, + { + name: "3. Create new enabled user with both plaintext and SHA256 passwords", + presetUser: nil, + cmd: []resp.Value{ + resp.StringValue("ACL"), + resp.StringValue("SETUSER"), + resp.StringValue("set_user_3"), + resp.StringValue("on"), + resp.StringValue(">set_user_3_plaintext_password_1"), + resp.StringValue(">set_user_3_plaintext_password_2"), + resp.StringValue(fmt.Sprintf("#%s", generateSHA256Password("set_user_3_hash_password_1"))), + resp.StringValue(fmt.Sprintf("#%s", generateSHA256Password("set_user_3_hash_password_2"))), + }, + wantRes: "OK", + wantErr: "", + wantUser: map[string][]string{ + "username": {"set_user_3"}, + "flags": {"on"}, + "categories": {"+@all"}, + "commands": {"+all"}, + "keys": {"%RW~*"}, + "channels": {"+&*"}, + }, + }, + { + name: "4. Remove plaintext and SHA256 password from existing user", + presetUser: &sugardb.User{ + Username: "set_user_4", + Enabled: true, + AddPlainPasswords: []string{"set_user_4_plaintext_password_1", "set_user_4_plaintext_password_2"}, + AddHashPasswords: []string{ + generateSHA256Password("set_user_4_hash_password_1"), + generateSHA256Password("set_user_4_hash_password_2"), + }, + }, + cmd: []resp.Value{ + resp.StringValue("ACL"), + resp.StringValue("SETUSER"), + resp.StringValue("set_user_4"), + resp.StringValue("on"), + resp.StringValue("password1"), + resp.StringValue(fmt.Sprintf("#%s", generateSHA256Password("password2"))), + }, + wantRes: "OK", + wantErr: "", + wantUser: map[string][]string{ + "username": {"set_user_16"}, + "flags": {"on", "nopass"}, + "categories": {"+@all"}, + "commands": {"+all"}, + "keys": {"%RW~*"}, + "channels": {"+&*"}, + }, + }, + { + name: "17. Delete all existing users passwords using 'nopass'", + presetUser: &sugardb.User{ + Username: "set_user_17", + Enabled: true, + NoPassword: true, + AddPlainPasswords: []string{"password1"}, + AddHashPasswords: []string{generateSHA256Password("password2")}, + }, + cmd: []resp.Value{ + resp.StringValue("ACL"), + resp.StringValue("SETUSER"), + resp.StringValue("set_user_17"), + resp.StringValue("on"), + resp.StringValue("nopass"), + }, + wantRes: "OK", + wantErr: "", + wantUser: map[string][]string{ + "username": {"set_user_17"}, + "flags": {"on", "nopass"}, + "categories": {"+@all"}, + "commands": {"+all"}, + "keys": {"%RW~*"}, + "channels": {"+&*"}, + }, + }, + { + name: "18. Clear all of an existing user's passwords using 'resetpass'", + presetUser: &sugardb.User{ + Username: "set_user_18", + Enabled: true, + NoPassword: true, + AddPlainPasswords: []string{"password1"}, + AddHashPasswords: []string{generateSHA256Password("password2")}, + }, + cmd: []resp.Value{ + resp.StringValue("ACL"), + resp.StringValue("SETUSER"), + resp.StringValue("set_user_18"), + resp.StringValue("on"), + resp.StringValue("nopass"), + }, + wantRes: "OK", + wantErr: "", + wantUser: map[string][]string{ + "username": {"set_user_18"}, + "flags": {"on", "nopass"}, + "categories": {"+@all"}, + "commands": {"+all"}, + "keys": {"%RW~*"}, + "channels": {"+&*"}, + }, + }, + { + name: "19. Clear all of an existing user's command privileges using 'nocommands'", + presetUser: &sugardb.User{ + Username: "set_user_19", + Enabled: true, + IncludeCommands: []string{"acl|getuser", "acl|setuser", "acl|deluser"}, + ExcludeCommands: []string{"rewriteaof", "save"}, + }, + cmd: []resp.Value{ + resp.StringValue("ACL"), + resp.StringValue("SETUSER"), + resp.StringValue("set_user_19"), + resp.StringValue("on"), + resp.StringValue("nocommands"), + }, + wantRes: "OK", + wantErr: "", + wantUser: map[string][]string{ + "username": {"set_user_19"}, + "flags": {"on"}, + "categories": {"-@all"}, + "commands": {"-all"}, + "keys": {"%RW~*"}, + "channels": {"+&*"}, + }, + }, + { + name: "20. Clear all of an existing user's allowed keys using 'resetkeys'", + presetUser: &sugardb.User{ + Username: "set_user_20", + Enabled: true, + IncludeWriteKeys: []string{"key1", "key2", "key3", "key4", "key5", "key6"}, + IncludeReadKeys: []string{"key1", "key2", "key3", "key7", "key8", "key9"}, + }, + cmd: []resp.Value{ + resp.StringValue("ACL"), + resp.StringValue("SETUSER"), + resp.StringValue("set_user_20"), + resp.StringValue("on"), + resp.StringValue("resetkeys"), + }, + wantRes: "OK", + wantErr: "", + wantUser: map[string][]string{ + "username": {"set_user_20"}, + "flags": {"on", "nokeys"}, + "categories": {"+@all"}, + "commands": {"+all"}, + "keys": {}, + "channels": {"+&*"}, + }, + }, + { + name: "21. Allow user to access all channels using 'resetchannels'", + presetUser: &sugardb.User{ + Username: "set_user_21", + Enabled: true, + IncludeChannels: []string{"channel1", "channel2"}, + ExcludeChannels: []string{"channel3", "channel4"}, + }, + cmd: []resp.Value{ + resp.StringValue("ACL"), + resp.StringValue("SETUSER"), + resp.StringValue("set_user_21"), + resp.StringValue("resetchannels"), + }, + wantRes: "OK", + wantErr: "", + wantUser: map[string][]string{ + "username": {"set_user_21"}, + "flags": {"on"}, + "categories": {"+@all"}, + "commands": {"+all"}, + "keys": {"%RW~*"}, + "channels": {"-&*"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetUser != nil { + if _, err := mockServer.ACLSetUser(*test.presetUser); err != nil { + t.Error(err) + return + } + } + if err = r.WriteArray(test.cmd); err != nil { + t.Error(err) + } + v, _, err := r.ReadValue() + if err != nil { + t.Error(err) + } + if test.wantErr != "" { + if v.Error().Error() != test.wantErr { + t.Errorf("expected error response \"%s\", got \"%s\"", test.wantErr, v.Error().Error()) + } + return + } + if v.String() != test.wantRes { + t.Errorf("expected response \"%s\", got \"%s\"", test.wantRes, v.String()) + } + if test.wantUser == nil { + return + } + + user, err := mockServer.ACLGetUser(test.wantUser["username"][0]) + if err != nil { + t.Error(err) + return + } + + if err = compareUsers(test.wantUser, user); err != nil { + t.Error(err) + return + } + }) + } + }) + + t.Run("Test_HandleGetUser", func(t *testing.T) { + t.Parallel() + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := setUpServer(port, false, "") + if err != nil { + t.Error(err) + return + } + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + if conn != nil { + _ = conn.Close() + } + }() + + r := resp.NewConn(conn) + + tests := []struct { + name string + presetUser *sugardb.User + cmd []resp.Value + wantRes []resp.Value + wantErr string + }{ + { + name: "1. Get the user and all their details", + presetUser: &sugardb.User{ + Username: "get_user_1", + Enabled: true, + NoPassword: false, + NoKeys: false, + AddPlainPasswords: []string{"get_user_password_1"}, + AddHashPasswords: []string{generateSHA256Password("get_user_password_2")}, + IncludeCategories: []string{constants.WriteCategory, constants.ReadCategory, constants.PubSubCategory}, + ExcludeCategories: []string{constants.AdminCategory, constants.ConnectionCategory, constants.DangerousCategory}, + IncludeCommands: []string{"acl|setuser", "acl|getuser", "acl|deluser"}, + ExcludeCommands: []string{"rewriteaof", "save", "acl|load", "acl|save"}, + IncludeReadKeys: []string{"key1", "key2", "key3", "key4"}, + IncludeWriteKeys: []string{"key1", "key2", "key5", "key6"}, + IncludeChannels: []string{"channel1", "channel2"}, + ExcludeChannels: []string{"channel3", "channel4"}, + }, + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("GETUSER"), resp.StringValue("get_user_1")}, + wantRes: []resp.Value{ + resp.StringValue("username"), + resp.ArrayValue([]resp.Value{resp.StringValue("get_user_1")}), + resp.StringValue("flags"), + resp.ArrayValue([]resp.Value{ + resp.StringValue("on"), + }), + resp.StringValue("categories"), + resp.ArrayValue([]resp.Value{ + resp.StringValue(fmt.Sprintf("+@%s", constants.WriteCategory)), + resp.StringValue(fmt.Sprintf("+@%s", constants.ReadCategory)), + resp.StringValue(fmt.Sprintf("+@%s", constants.PubSubCategory)), + resp.StringValue(fmt.Sprintf("-@%s", constants.AdminCategory)), + resp.StringValue(fmt.Sprintf("-@%s", constants.ConnectionCategory)), + resp.StringValue(fmt.Sprintf("-@%s", constants.DangerousCategory)), + }), + resp.StringValue("commands"), + resp.ArrayValue([]resp.Value{ + resp.StringValue("+acl|setuser"), + resp.StringValue("+acl|getuser"), + resp.StringValue("+acl|deluser"), + resp.StringValue("-rewriteaof"), + resp.StringValue("-save"), + resp.StringValue("-acl|load"), + resp.StringValue("-acl|save"), + }), + resp.StringValue("keys"), + resp.ArrayValue([]resp.Value{ + // Keys here + resp.StringValue("%RW~key1"), + resp.StringValue("%RW~key2"), + resp.StringValue("%R~key3"), + resp.StringValue("%R~key4"), + resp.StringValue("%W~key5"), + resp.StringValue("%W~key6"), + }), + resp.StringValue("channels"), + resp.ArrayValue([]resp.Value{ + // Channels here + resp.StringValue("+&channel1"), + resp.StringValue("+&channel2"), + resp.StringValue("-&channel3"), + resp.StringValue("-&channel4"), + }), + }, + wantErr: "", + }, + { + name: "2. Return user not found error", + presetUser: nil, + cmd: []resp.Value{ + resp.StringValue("ACL"), + resp.StringValue("GETUSER"), + resp.StringValue("non_existent_user")}, + wantRes: nil, + wantErr: "Error user not found", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetUser != nil { + if _, err := mockServer.ACLSetUser(*test.presetUser); err != nil { + t.Error(err) + return + } + } + if err = r.WriteArray(test.cmd); err != nil { + t.Error(err) + } + v, _, err := r.ReadValue() + if err != nil { + t.Error(err) + } + if test.wantErr != "" { + if v.Error().Error() != test.wantErr { + t.Errorf("expected error response \"%s\", got \"%s\"", test.wantErr, v.Error().Error()) + } + return + } + resArr := v.Array() + for i := 0; i < len(resArr); i++ { + if slices.Contains([]string{"username", "flags", "categories", "commands", "keys", "channels"}, resArr[i].String()) { + // String item + if resArr[i].String() != test.wantRes[i].String() { + t.Errorf("expected response component %+v, got %+v", test.wantRes[i], resArr[i]) + } + } else { + // Array item + var expected []string + for _, item := range test.wantRes[i].Array() { + expected = append(expected, item.String()) + } + + var res []string + for _, item := range resArr[i].Array() { + res = append(res, item.String()) + } + + if err = compareSlices(res, expected); err != nil { + t.Error(err) + } + } + } + }) + } + }) + + t.Run("Test_HandleDelUser", func(t *testing.T) { + t.Parallel() + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := setUpServer(port, false, "") + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + if conn != nil { + _ = conn.Close() + } + }() + r := resp.NewConn(conn) + + tests := []struct { + name string + presetUser *sugardb.User + cmd []resp.Value + wantRes string + wantErr string + }{ + { + name: "1. Delete existing user while skipping default user and non-existent user", + presetUser: &sugardb.User{ + Username: "user_to_delete", + Enabled: true, + }, + cmd: []resp.Value{ + resp.StringValue("ACL"), + resp.StringValue("DELUSER"), + resp.StringValue("default"), + resp.StringValue("user_to_delete"), + resp.StringValue("non_existent_user"), + }, + wantRes: "OK", + wantErr: "", + }, + { + name: "2. Command too short", + presetUser: nil, + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("DELUSER")}, + wantRes: "", + wantErr: fmt.Sprintf("Error %s", constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetUser != nil { + if _, err := mockServer.ACLSetUser(*test.presetUser); err != nil { + t.Error(err) + return + } + } + if err = r.WriteArray(test.cmd); err != nil { + t.Error(err) + } + v, _, err := r.ReadValue() + if err != nil { + t.Error(err) + } + if test.wantErr != "" { + if v.Error().Error() != test.wantErr { + t.Errorf("expected error response \"%s\", got \"%s\"", test.wantErr, v.Error().Error()) + } + return + } + + usernames, err := mockServer.ACLUsers() + if err != nil { + t.Error(err) + return + } + + // Check that default user still exists in the list of users + if !slices.Contains(usernames, "default") { + t.Error("could not find user with username \"default\" in the ACL after deleting user") + return + } + + // Check that the deleted user is no longer in the list + if slices.Contains(usernames, "user_to_delete") { + t.Error("deleted user found in the ACL") + return + } + }) + } + }) + + t.Run("Test_HandleWhoAmI", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + if conn != nil { + _ = conn.Close() + } + }() + + r := resp.NewConn(conn) + + tests := []struct { + name string + username string + password string + wantRes string + }{ + { + name: "1. With default user", + username: "default", + password: "password1", + wantRes: "default", + }, + { + name: "2. With user authenticated by plaintext password", + username: "with_password_user", + password: "password2", + wantRes: "with_password_user", + }, + { + name: "3. With user authenticated by SHA256 password", + username: "with_password_user", + password: "password3", + wantRes: "with_password_user", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Authenticate + if err = r.WriteArray([]resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue(test.username), + resp.StringValue(test.password), + }); err != nil { + t.Error(err) + } + v, _, err := r.ReadValue() + if err != nil { + t.Error(err) + } + if v.String() != "OK" { + t.Errorf("expected response for auth with %s:%s to be \"OK\", got %s", test.username, test.password, v.String()) + } + // Check whoami response value + if err = r.WriteArray([]resp.Value{resp.StringValue("ACL"), resp.StringValue("WHOAMI")}); err != nil { + t.Error(err) + } + v, _, err = r.ReadValue() + if err != nil { + t.Error(err) + } + if v.String() != test.wantRes { + t.Errorf("expected whoami response to be \"%s\", got \"%s\"", test.wantRes, v.String()) + } + }) + } + }) + + t.Run("Test_HandleList", func(t *testing.T) { + t.Parallel() + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := setUpServer(port, false, "") + if err != nil { + t.Error(err) + return + } + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + if conn != nil { + _ = conn.Close() + } + }() + + r := resp.NewConn(conn) + + tests := []struct { + name string + presetUsers []*sugardb.User + cmd []resp.Value + wantRes []string + wantErr string + }{ + { + name: "1. Get the user and all their details", + presetUsers: []*sugardb.User{ + { + Username: "list_user_1", + Enabled: true, + NoPassword: false, + NoKeys: false, + AddPlainPasswords: []string{"list_user_password_1"}, + AddHashPasswords: []string{generateSHA256Password("list_user_password_2")}, + IncludeCategories: []string{constants.WriteCategory, constants.ReadCategory, constants.PubSubCategory}, + ExcludeCategories: []string{constants.AdminCategory, constants.ConnectionCategory, constants.DangerousCategory}, + IncludeCommands: []string{"acl|setuser", "acl|getuser", "acl|deluser"}, + ExcludeCommands: []string{"rewriteaof", "save", "acl|load", "acl|save"}, + IncludeReadKeys: []string{"key1", "key2", "key3", "key4"}, + IncludeWriteKeys: []string{"key1", "key2", "key5", "key6"}, + IncludeChannels: []string{"channel1", "channel2"}, + ExcludeChannels: []string{"channel3", "channel4"}, + }, + { + Username: "list_user_2", + Enabled: true, + NoPassword: true, + NoKeys: true, + IncludeCategories: []string{constants.WriteCategory, constants.ReadCategory, constants.PubSubCategory}, + ExcludeCategories: []string{constants.AdminCategory, constants.ConnectionCategory, constants.DangerousCategory}, + IncludeCommands: []string{"acl|setuser", "acl|getuser", "acl|deluser"}, + ExcludeCommands: []string{"rewriteaof", "save", "acl|load", "acl|save"}, + IncludeReadKeys: []string{}, + IncludeWriteKeys: []string{}, + IncludeChannels: []string{"channel1", "channel2"}, + ExcludeChannels: []string{"channel3", "channel4"}, + }, + { + Username: "list_user_3", + Enabled: true, + NoPassword: false, + NoKeys: false, + AddPlainPasswords: []string{"list_user_password_3"}, + AddHashPasswords: []string{generateSHA256Password("list_user_password_4")}, + IncludeCategories: []string{constants.WriteCategory, constants.ReadCategory, constants.PubSubCategory}, + ExcludeCategories: []string{constants.AdminCategory, constants.ConnectionCategory, constants.DangerousCategory}, + IncludeCommands: []string{"acl|setuser", "acl|getuser", "acl|deluser"}, + ExcludeCommands: []string{"rewriteaof", "save", "acl|load", "acl|save"}, + IncludeReadKeys: []string{"key1", "key2", "key3", "key4"}, + IncludeWriteKeys: []string{"key1", "key2", "key5", "key6"}, + IncludeChannels: []string{"channel1", "channel2"}, + ExcludeChannels: []string{"channel3", "channel4"}, + }, + }, + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("LIST")}, + wantRes: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + fmt.Sprintf(`list_user_1 on >list_user_password_1 #%s +@write +@read +@pubsub -@admin -@connection -@dangerous +acl|setuser +acl|getuser +acl|deluser -rewriteaof -save -acl|load -acl|save %s +&channel1 +&channel2 -&channel3 -&channel4`, + generateSHA256Password("list_user_password_2"), "%RW~key1 %RW~key2 %R~key3 %R~key4 %W~key5 %W~key6"), + fmt.Sprintf(`list_user_2 on nopass nokeys +@write +@read +@pubsub -@admin -@connection -@dangerous +acl|setuser +acl|getuser +acl|deluser -rewriteaof -save -acl|load -acl|save +&channel1 +&channel2 -&channel3 -&channel4`), + fmt.Sprintf(`list_user_3 on >list_user_password_3 #%s +@write +@read +@pubsub -@admin -@connection -@dangerous +acl|setuser +acl|getuser +acl|deluser -rewriteaof -save -acl|load -acl|save %s +&channel1 +&channel2 -&channel3 -&channel4`, + generateSHA256Password("list_user_password_4"), "%RW~key1 %RW~key2 %R~key3 %R~key4 %W~key5 %W~key6"), + }, + wantErr: "", + }, + { + name: "2. Command too long", + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("LIST"), resp.StringValue("USERNAME")}, + wantRes: nil, + wantErr: constants.WrongArgsResponse, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetUsers != nil { + for _, user := range test.presetUsers { + if _, err := mockServer.ACLSetUser(*user); err != nil { + t.Error(err) + return + } + } + } + + if err = r.WriteArray(test.cmd); err != nil { + t.Error(err) + return + } + v, _, err := r.ReadValue() + if err != nil { + t.Error(err) + return + } + if test.wantErr != "" { + if !strings.Contains(v.Error().Error(), test.wantErr) { + t.Errorf("expected error response \"%s\", got \"%s\"", test.wantErr, v.Error().Error()) + } + return + } + resArr := v.Array() + if len(resArr) != len(test.wantRes) { + t.Errorf("expected response of lenght %d, got lenght %d", len(test.wantRes), len(resArr)) + return + } + + var resStr []string + for i := 0; i < len(resArr); i++ { + resStr = strings.Split(resArr[i].String(), " ") + if !slices.ContainsFunc(test.wantRes, func(s string) bool { + expectedUserSlice := strings.Split(s, " ") + return compareSlices(resStr, expectedUserSlice) == nil + }) { + t.Errorf("could not find the following user in expected slice: %+v", resStr) + return + } + } + }) + } + }) + + t.Run("Test_HandleSave", func(t *testing.T) { + t.Parallel() + + baseDir := path.Join(".", "testdata", "save") + + tests := []struct { + name string + path string + want []string // Response from ACL List command. + }{ + { + name: "1. Save ACL config to .json file", + path: path.Join(baseDir, "json_test.json"), + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + }, + }, + { + name: "2. Save ACL config to .yaml file", + path: path.Join(baseDir, "yaml_test.yaml"), + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + }, + }, + { + name: "3. Save ACL config to .yml file", + path: path.Join(baseDir, "yml_test.yml"), + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + }, + }, + } + + servers := make([]*sugardb.SugarDB, len(tests)) + mut := sync.Mutex{} + t.Cleanup(func() { + _ = os.RemoveAll(baseDir) + for _, server := range servers { + if server != nil { + server.ShutDown() + } + } + }) + + for i, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + mut.Lock() + defer mut.Unlock() + // Get free port. + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + // Create new server instance + mockServer, err := setUpServer(port, false, test.path) + if err != nil { + t.Error(err) + return + } + servers[i] = mockServer + go func() { + mockServer.Start() + }() + + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client := resp.NewConn(conn) + + if err = client.WriteArray([]resp.Value{resp.StringValue("ACL"), resp.StringValue("SAVE")}); err != nil { + t.Error(err) + return + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected OK response, got \"%s\"", res.String()) + return + } + + // Close client connection + if err = conn.Close(); err != nil { + t.Error(err) + return + } + + // Shutdown the mock server + mockServer.ShutDown() + + // Restart server and create new client connection + port, err = internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + mockServer, err = setUpServer(port, false, test.path) + if err != nil { + t.Error(err) + return + } + go func() { + mockServer.Start() + }() + + conn, err = internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client = resp.NewConn(conn) + + if err = client.WriteArray([]resp.Value{resp.StringValue("ACL"), resp.StringValue("LIST")}); err != nil { + t.Error(err) + return + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + return + } + + // Check if ACL LIST returns the expected list of users. + resArr := res.Array() + if len(resArr) != len(test.want) { + t.Errorf("expected response of lenght %d, got length %d", len(test.want), len(resArr)) + return + } + + fmt.Println("USER LIST: ") + for j, user := range resArr { + fmt.Printf("%d) %+v\n", j, user) + } + + var resStr []string + for i := 0; i < len(resArr); i++ { + resStr = strings.Split(resArr[i].String(), " ") + if !slices.ContainsFunc(test.want, func(s string) bool { + expectedUserSlice := strings.Split(s, " ") + return compareSlices(resStr, expectedUserSlice) == nil + }) { + t.Errorf("could not find the following user in expected slice: %+v", resStr) + return + } + } + }) + } + }) + + t.Run("Test_HandleLoad", func(t *testing.T) { + t.Parallel() + + baseDir := path.Join(".", "testdata", "load") + + tests := []struct { + name string + path string + users []sugardb.User // Add users after server startup. + cmd []resp.Value // Command to load users from ACL config. + want []string + }{ + { + name: "1. Load config from the .json file", + path: path.Join(baseDir, "json_test.json"), + users: []sugardb.User{ + {Username: "user1", Enabled: true}, + }, + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("LOAD"), resp.StringValue("REPLACE")}, + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + "user1 on +@all +all %RW~* +&*", + }, + }, + { + name: "2. Load users from the .yaml file", + path: path.Join(baseDir, "yaml_test.yaml"), + users: []sugardb.User{ + {Username: "user1", Enabled: true}, + }, + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("LOAD"), resp.StringValue("REPLACE")}, + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + "user1 on +@all +all %RW~* +&*", + }, + }, + { + name: "3. Load users from the .yml file", + path: path.Join(baseDir, "yml_test.yml"), + users: []sugardb.User{ + {Username: "user1", Enabled: true}, + }, + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("LOAD"), resp.StringValue("REPLACE")}, + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + "user1 on +@all +all %RW~* +&*", + }, + }, + { + name: "4. Merge loaded users", + path: path.Join(baseDir, "merge.yml"), + users: []sugardb.User{ + { // Disable user1. + Username: "user1", + Enabled: false, + }, + { // Update with_password_user. This should be merged with the existing user. + Username: "with_password_user", + AddPlainPasswords: []string{"password3", "password4"}, + IncludeReadWriteKeys: []string{"key1", "key2"}, + IncludeWriteKeys: []string{"key3", "key4"}, + IncludeReadKeys: []string{"key5", "key6"}, + IncludeChannels: []string{"channel[12]"}, + ExcludeChannels: []string{"channel[34]"}, + }, + }, + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("LOAD"), resp.StringValue("MERGE")}, + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf(`with_password_user on >password2 >password3 >password4 #%s +@all +all %s~key1 %s~key2 %s~key5 %s~key6 %s~key3 %s~key4 +&channel[12] -&channel[34]`, + generateSHA256Password("password3"), "%RW", "%RW", "%R", "%R", "%W", "%W"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + "user1 off +@all +all %RW~* +&*", + }, + }, + { + name: "5. Replace loaded users", + path: path.Join(baseDir, "replace.yml"), + users: []sugardb.User{ + { // Disable user1. + Username: "user1", + Enabled: false, + }, + { // Update with_password_user. This should be merged with the existing user. + Username: "with_password_user", + AddPlainPasswords: []string{"password3", "password4"}, + IncludeReadWriteKeys: []string{"key1", "key2"}, + IncludeWriteKeys: []string{"key3", "key4"}, + IncludeReadKeys: []string{"key5", "key6"}, + IncludeChannels: []string{"channel[12]"}, + ExcludeChannels: []string{"channel[34]"}, + }, + }, + cmd: []resp.Value{resp.StringValue("ACL"), resp.StringValue("LOAD"), resp.StringValue("REPLACE")}, + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + "user1 off +@all +all %RW~* +&*", + }, + }, + } + + servers := make([]*sugardb.SugarDB, len(tests)) + mut := sync.Mutex{} + t.Cleanup(func() { + _ = os.RemoveAll(baseDir) + for _, server := range servers { + if server != nil { + server.ShutDown() + } + } + }) + + for i, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + mut.Lock() + defer mut.Unlock() + // Create server with pre-generated users. + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + mockServer, err := setUpServer(port, false, test.path) + if err != nil { + t.Error(err) + return + } + servers[i] = mockServer + go func() { + mockServer.Start() + }() + + // Save the current users to the ACL config file. + if _, err := mockServer.ACLSave(); err != nil { + t.Error(err) + return + } + + // Add some users to the ACL. + for _, user := range test.users { + if _, err := mockServer.ACLSetUser(user); err != nil { + t.Error(err) + return + } + } + + // Establish client connection + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client := resp.NewConn(conn) + + // Load the users from the ACL config file. + if err := client.WriteArray(test.cmd); err != nil { + t.Error(err) + return + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + + if !strings.EqualFold(res.String(), "ok") { + t.Error(err) + mockServer.ShutDown() + return + } + + // Get ACL List + if err = client.WriteArray([]resp.Value{resp.StringValue("ACL"), resp.StringValue("LIST")}); err != nil { + t.Error(err) + return + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + return + } + + // Check if ACL LIST returns the expected list of users. + resArr := res.Array() + if len(resArr) != len(test.want) { + t.Errorf("expected response of length %d, got lenght %d", len(test.want), len(resArr)) + return + } + + var resStr []string + for i := 0; i < len(resArr); i++ { + resStr = strings.Split(resArr[i].String(), " ") + if !slices.ContainsFunc(test.want, func(s string) bool { + expectedUserSlice := strings.Split(s, " ") + return compareSlices(resStr, expectedUserSlice) == nil + }) { + t.Errorf("could not find the following user in expected slice: %+v", resStr) + return + } + } + }) + } + }) +} diff --git a/internal/modules/acl/user.go b/internal/modules/acl/user.go new file mode 100644 index 0000000..a887a99 --- /dev/null +++ b/internal/modules/acl/user.go @@ -0,0 +1,327 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package acl + +import ( + "slices" + "strings" +) + +const ( + PasswordPlainText = "plaintext" + PasswordSHA256 = "SHA256" +) + +type Password struct { + PasswordType string `json:"PasswordType" yaml:"PasswordType"` // plaintext, SHA256 + PasswordValue string `json:"PasswordValue" yaml:"PasswordValue"` +} + +type User struct { + Username string `json:"Username" yaml:"Username"` + Enabled bool `json:"Enabled" yaml:"Enabled"` + NoPassword bool `json:"NoPassword" yaml:"NoPassword"` + NoKeys bool `json:"NoKeys" yaml:"NoKeys"` + + Passwords []Password `json:"Passwords" yaml:"Passwords"` + + IncludedCategories []string `json:"IncludedCategories" yaml:"IncludedCategories"` + ExcludedCategories []string `json:"ExcludedCategories" yaml:"ExcludedCategories"` + + IncludedCommands []string `json:"IncludedCommands" yaml:"IncludedCommands"` + ExcludedCommands []string `json:"ExcludedCommands" yaml:"ExcludedCommands"` + + IncludedReadKeys []string `json:"IncludedReadKeys" yaml:"IncludedReadKeys"` + IncludedWriteKeys []string `json:"IncludedWriteKeys" yaml:"IncludedWriteKeys"` + + IncludedPubSubChannels []string `json:"IncludedPubSubChannels" yaml:"IncludedPubSubChannels"` + ExcludedPubSubChannels []string `json:"ExcludedPubSubChannels" yaml:"ExcludedPubSubChannels"` +} + +func (user *User) Normalise() { + user.IncludedCategories = RemoveDuplicateEntries(user.IncludedCategories, "allCategories") + if len(user.IncludedCategories) == 0 { + user.IncludedCategories = []string{"*"} + } + user.ExcludedCategories = RemoveDuplicateEntries(user.ExcludedCategories, "allCategories") + if slices.Contains(user.ExcludedCategories, "*") { + user.IncludedCategories = []string{} + } + + user.IncludedCommands = RemoveDuplicateEntries(user.IncludedCommands, "allCommands") + if len(user.IncludedCommands) == 0 { + user.IncludedCommands = []string{"*"} + } + user.ExcludedCommands = RemoveDuplicateEntries(user.ExcludedCommands, "allCommands") + if slices.Contains(user.ExcludedCommands, "*") { + user.IncludedCommands = []string{} + } + + user.IncludedReadKeys = RemoveDuplicateEntries(user.IncludedReadKeys, "allKeys") + if len(user.IncludedReadKeys) == 0 && !user.NoKeys { + user.IncludedReadKeys = []string{"*"} + } + user.IncludedWriteKeys = RemoveDuplicateEntries(user.IncludedWriteKeys, "allKeys") + if len(user.IncludedWriteKeys) == 0 && !user.NoKeys { + user.IncludedWriteKeys = []string{"*"} + } + + user.IncludedPubSubChannels = RemoveDuplicateEntries(user.IncludedPubSubChannels, "allChannels") + if len(user.IncludedPubSubChannels) == 0 { + user.IncludedPubSubChannels = []string{"*"} + } + user.ExcludedPubSubChannels = RemoveDuplicateEntries(user.ExcludedPubSubChannels, "allChannels") + if slices.Contains(user.ExcludedPubSubChannels, "*") { + user.IncludedPubSubChannels = []string{} + } + + // Sort passwords + slices.SortStableFunc(user.Passwords, func(a, b Password) int { + types := map[string]int{ + PasswordPlainText: 0, + PasswordSHA256: 1, + } + return types[a.PasswordType] - types[b.PasswordType] + }) +} + +func RemoveDuplicateEntries(entries []string, allAlias string) (res []string) { + entriesMap := make(map[string]int) + for _, entry := range entries { + if entry == allAlias { + entriesMap["*"] += 1 + continue + } + entriesMap[entry] += 1 + } + for key, _ := range entriesMap { + if key == "*" && len(entriesMap) == 1 { + res = []string{"*"} + return + } + if key != "*" { + res = append(res, key) + } + } + return +} + +func (user *User) UpdateUser(cmd []string) error { + for _, str := range cmd { + // Parse enabled + if strings.EqualFold(str, "on") { + user.Enabled = true + } + if strings.EqualFold(str, "off") { + user.Enabled = false + } + // Parse passwords + if str[0] == '>' || str[0] == '#' { + user.Passwords = append(user.Passwords, Password{ + PasswordType: GetPasswordType(str), + PasswordValue: str[1:], + }) + user.NoPassword = false + continue + } + if str[0] == '<' { + user.Passwords = slices.DeleteFunc(user.Passwords, func(password Password) bool { + return strings.EqualFold(password.PasswordType, PasswordPlainText) && password.PasswordValue == str[1:] + }) + continue + } + if str[0] == '!' { + user.Passwords = slices.DeleteFunc(user.Passwords, func(password Password) bool { + return strings.EqualFold(password.PasswordType, PasswordSHA256) && password.PasswordValue == str[1:] + }) + continue + } + // Parse categories + if strings.EqualFold(str, "nocommands") { + user.ExcludedCategories = []string{"*"} + user.ExcludedCommands = []string{"*"} + continue + } + if strings.EqualFold(str, "allCategories") { + user.IncludedCategories = []string{"*"} + continue + } + if len(str) > 3 && str[1] == '@' { + if str[0] == '+' { + user.IncludedCategories = append(user.IncludedCategories, str[2:]) + continue + } + if str[0] == '-' { + user.ExcludedCategories = append(user.ExcludedCategories, str[2:]) + continue + } + } + // Parse keys + if strings.EqualFold(str, "allKeys") { + user.IncludedReadKeys = []string{"*"} + user.IncludedWriteKeys = []string{"*"} + user.NoKeys = false + continue + } + if (len(str) > 1 && str[0] == '~') || len(str) > 4 && strings.EqualFold(str[0:4], "%RW~") { + startIndex := strings.Index(str, "~") + 1 + user.IncludedReadKeys = append(user.IncludedReadKeys, str[startIndex:]) + user.IncludedWriteKeys = append(user.IncludedWriteKeys, str[startIndex:]) + user.NoKeys = false + continue + } + if len(str) > 3 && strings.EqualFold(str[0:3], "%R~") { + user.IncludedReadKeys = append(user.IncludedReadKeys, str[3:]) + user.NoKeys = false + continue + } + if len(str) > 3 && strings.EqualFold(str[0:3], "%W~") { + user.IncludedWriteKeys = append(user.IncludedWriteKeys, str[3:]) + user.NoKeys = false + continue + } + // Parse channels + if strings.EqualFold(str, "allChannels") { + user.IncludedPubSubChannels = []string{"*"} + continue + } + if len(str) > 2 && str[1] == '&' { + if str[0] == '+' { + user.IncludedPubSubChannels = append(user.IncludedPubSubChannels, str[2:]) + continue + } + if str[0] == '-' { + user.ExcludedPubSubChannels = append(user.ExcludedPubSubChannels, str[2:]) + continue + } + } + // Parse commands + if strings.EqualFold(str, "allCommands") { + user.IncludedCommands = []string{"*"} + user.ExcludedCommands = []string{} + continue + } + if len(str) > 2 && !slices.Contains([]uint8{'&', '@'}, str[1]) { + if str[0] == '+' { + user.IncludedCommands = append(user.IncludedCommands, str[1:]) + continue + } + if str[0] == '-' { + user.ExcludedCommands = append(user.ExcludedCommands, str[1:]) + continue + } + } + } + + // If nopass is provided, delete all passwords + for _, str := range cmd { + if strings.EqualFold(str, "nopass") { + user.Passwords = []Password{} + user.NoPassword = true + } + } + + for _, str := range cmd { + // If resetpass is provided, delete all passwords and set NoPassword to false + if strings.EqualFold(str, "resetpass") { + user.Passwords = []Password{} + user.NoPassword = false + } + // If nocommands is provided, disable all commands for this user + if strings.EqualFold(str, "nocommands") { + user.IncludedCommands = []string{} + user.ExcludedCommands = []string{"*"} + user.IncludedCategories = []string{} + user.ExcludedCategories = []string{"*"} + } + // If resetkeys or nokeys is provided, reset all keys that the user can access. + if slices.Contains([]string{"resetkeys", "nokeys"}, str) { + user.IncludedReadKeys = []string{} + user.IncludedWriteKeys = []string{} + user.NoKeys = true + } + // If resetchannels is provided, remove all the pub/sub channels that the user can access + if strings.EqualFold(str, "resetchannels") { + user.IncludedPubSubChannels = []string{} + user.ExcludedPubSubChannels = []string{"*"} + } + } + + return nil +} + +func (user *User) Merge(new *User) { + user.Enabled = new.Enabled + user.NoKeys = new.NoKeys + user.NoPassword = new.NoPassword + user.IncludedCategories = append(user.IncludedCategories, new.IncludedCategories...) + user.ExcludedCategories = append(user.ExcludedCategories, new.ExcludedCategories...) + user.IncludedCommands = append(user.IncludedCommands, new.IncludedCommands...) + user.ExcludedCommands = append(user.ExcludedCommands, new.ExcludedCommands...) + user.IncludedReadKeys = append(user.IncludedReadKeys, new.IncludedReadKeys...) + user.IncludedWriteKeys = append(user.IncludedWriteKeys, new.IncludedWriteKeys...) + user.IncludedPubSubChannels = append(user.IncludedPubSubChannels, new.IncludedPubSubChannels...) + user.ExcludedPubSubChannels = append(user.ExcludedPubSubChannels, new.ExcludedPubSubChannels...) + + // Add passwords. + for _, password := range new.Passwords { + if !slices.ContainsFunc(user.Passwords, func(p Password) bool { + return p.PasswordType == password.PasswordType && p.PasswordValue == password.PasswordValue + }) { + user.Passwords = append(user.Passwords, new.Passwords...) + } + } + + user.Normalise() +} + +func (user *User) Replace(new *User) { + user.Enabled = new.Enabled + user.NoKeys = new.NoKeys + user.NoPassword = new.NoPassword + user.Passwords = new.Passwords + user.IncludedCategories = new.IncludedCategories + user.ExcludedCategories = new.ExcludedCategories + user.IncludedCommands = new.IncludedCommands + user.ExcludedCommands = new.ExcludedCommands + user.IncludedReadKeys = new.IncludedReadKeys + user.IncludedWriteKeys = new.IncludedWriteKeys + user.IncludedPubSubChannels = new.IncludedPubSubChannels + user.ExcludedPubSubChannels = new.ExcludedPubSubChannels +} + +func CreateUser(username string) *User { + return &User{ + Username: username, + Enabled: true, + NoPassword: false, + Passwords: []Password{}, + IncludedCategories: []string{}, + ExcludedCategories: []string{}, + IncludedCommands: []string{}, + ExcludedCommands: []string{}, + IncludedReadKeys: []string{}, + IncludedWriteKeys: []string{}, + IncludedPubSubChannels: []string{}, + ExcludedPubSubChannels: []string{}, + } +} + +func GetPasswordType(password string) string { + if password[0] == '#' { + return PasswordSHA256 + } + return PasswordPlainText +} diff --git a/internal/modules/admin/commands.go b/internal/modules/admin/commands.go new file mode 100644 index 0000000..10e165c --- /dev/null +++ b/internal/modules/admin/commands.go @@ -0,0 +1,403 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package admin + +import ( + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" + "github.com/gobwas/glob" + "slices" + "strings" +) + +func handleGetAllCommands(params internal.HandlerFuncParams) ([]byte, error) { + commands := params.GetAllCommands() + + res := "" + commandCount := 0 + + for _, c := range commands { + if c.SubCommands == nil || len(c.SubCommands) <= 0 { + res += "*6\r\n" + // Command name + res += fmt.Sprintf("+command\r\n*1\r\n$%d\r\n%s\r\n", len(c.Command), c.Command) + // Command categories + res += fmt.Sprintf("+categories\r\n*%d\r\n", len(c.Categories)) + for _, category := range c.Categories { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(category), category) + } + // Description + res += fmt.Sprintf("+description\r\n*1\r\n$%d\r\n%s\r\n", len(c.Description), c.Description) + + commandCount += 1 + continue + } + // There are sub-commands + for _, sc := range c.SubCommands { + res += "*6\r\n" + // Command name + command := fmt.Sprintf("%s %s", c.Command, sc.Command) + res += fmt.Sprintf("+command\r\n*1\r\n$%d\r\n%s\r\n", len(command), command) + // Command categories + res += fmt.Sprintf("+categories\r\n*%d\r\n", len(sc.Categories)) + for _, category := range sc.Categories { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(category), category) + } + // Description + res += fmt.Sprintf("+description\r\n*1\r\n$%d\r\n%s\r\n", len(sc.Description), sc.Description) + + commandCount += 1 + } + } + + res = fmt.Sprintf("*%d\r\n%s", commandCount, res) + + return []byte(res), nil +} + +func handleCommandCount(params internal.HandlerFuncParams) ([]byte, error) { + var count int + + commands := params.GetAllCommands() + for _, command := range commands { + if command.SubCommands != nil && len(command.SubCommands) > 0 { + for _, _ = range command.SubCommands { + count += 1 + } + continue + } + count += 1 + } + + return []byte(fmt.Sprintf(":%d\r\n", count)), nil +} + +func handleCommandList(params internal.HandlerFuncParams) ([]byte, error) { + switch len(params.Command) { + case 2: + // Command is COMMAND LIST + var count int + var res string + commands := params.GetAllCommands() + for _, command := range commands { + if command.SubCommands != nil && len(command.SubCommands) > 0 { + for _, subcommand := range command.SubCommands { + comm := fmt.Sprintf("%s %s", command.Command, subcommand.Command) + res += fmt.Sprintf("$%d\r\n%s\r\n", len(comm), comm) + count += 1 + } + continue + } + res += fmt.Sprintf("$%d\r\n%s\r\n", len(command.Command), command.Command) + count += 1 + } + res = fmt.Sprintf("*%d\r\n%s", count, res) + return []byte(res), nil + + case 5: + var count int + var res string + // Command has filter + if !strings.EqualFold("FILTERBY", params.Command[2]) { + return nil, fmt.Errorf("expected FILTERBY, got %s", strings.ToUpper(params.Command[2])) + } + if strings.EqualFold("ACLCAT", params.Command[3]) { + // ACL Category filter + commands := params.GetAllCommands() + category := strings.ToLower(params.Command[4]) + for _, command := range commands { + if command.SubCommands != nil && len(command.SubCommands) > 0 { + for _, subcommand := range command.SubCommands { + if slices.Contains(subcommand.Categories, category) { + comm := fmt.Sprintf("%s %s", command.Command, subcommand.Command) + res += fmt.Sprintf("$%d\r\n%s\r\n", len(comm), comm) + count += 1 + } + } + continue + } + if slices.Contains(command.Categories, category) { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(command.Command), command.Command) + count += 1 + } + } + } else if strings.EqualFold("PATTERN", params.Command[3]) { + // Pattern filter + commands := params.GetAllCommands() + g := glob.MustCompile(params.Command[4]) + for _, command := range commands { + if command.SubCommands != nil && len(command.SubCommands) > 0 { + for _, subcommand := range command.SubCommands { + comm := fmt.Sprintf("%s %s", command.Command, subcommand.Command) + if g.Match(comm) { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(comm), comm) + count += 1 + } + } + continue + } + if g.Match(command.Command) { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(command.Command), command.Command) + count += 1 + } + } + } else if strings.EqualFold("MODULE", params.Command[3]) { + // Module filter + commands := params.GetAllCommands() + module := strings.ToLower(params.Command[4]) + for _, command := range commands { + if command.SubCommands != nil && len(command.SubCommands) > 0 { + for _, subcommand := range command.SubCommands { + if strings.EqualFold(subcommand.Module, module) { + comm := fmt.Sprintf("%s %s", command.Command, subcommand.Command) + res += fmt.Sprintf("$%d\r\n%s\r\n", len(comm), comm) + count += 1 + } + } + continue + } + if strings.EqualFold(command.Module, module) { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(command.Command), command.Command) + count += 1 + } + } + } else { + return nil, fmt.Errorf("expected filter to be ACLCAT or PATTERN, got %s", strings.ToUpper(params.Command[3])) + } + res = fmt.Sprintf("*%d\r\n%s", count, res) + return []byte(res), nil + default: + return nil, errors.New(constants.WrongArgsResponse) + } +} + +func handleCommandDocs(params internal.HandlerFuncParams) ([]byte, error) { + return []byte("*0\r\n"), nil +} + +func Commands() []internal.Command { + return []internal.Command{ + { + Command: "commands", + Module: constants.AdminModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory}, + Description: "Get a list of all the commands in available on the echovault with categories and descriptions.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleGetAllCommands, + }, + { + Command: "command", + Module: constants.AdminModule, + Categories: []string{}, + Description: "Commands pertaining to echovault commands", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + SubCommands: []internal.SubCommand{ + { + Command: "docs", + Module: constants.AdminModule, + Categories: []string{constants.SlowCategory, constants.ConnectionCategory}, + Description: "Get command documentation", + Sync: false, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleCommandDocs, + }, + { + Command: "count", + Module: constants.AdminModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory}, + Description: "Get the dumber of commands in the echovault instance.", + Sync: false, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleCommandCount, + }, + { + Command: "list", + Module: constants.AdminModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory}, + Description: `(COMMAND LIST [FILTERBY ]) +Get the list of command names. Allows for filtering by ACL category or glob pattern.`, + Sync: false, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleCommandList, + }, + }, + }, + { + Command: "save", + Module: constants.AdminModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory, constants.DangerousCategory}, + Description: "(SAVE) Trigger a snapshot save.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: func(params internal.HandlerFuncParams) ([]byte, error) { + if err := params.TakeSnapshot(); err != nil { + return nil, err + } + return []byte(constants.OkResponse), nil + }, + }, + { + Command: "lastsave", + Module: constants.AdminModule, + Categories: []string{constants.AdminCategory, constants.FastCategory, constants.DangerousCategory}, + Description: "(LASTSAVE) Get unix timestamp for the latest snapshot in milliseconds.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: func(params internal.HandlerFuncParams) ([]byte, error) { + msec := params.GetLatestSnapshotTime() + if msec == 0 { + return nil, errors.New("no snapshot") + } + return []byte(fmt.Sprintf(":%d\r\n", msec)), nil + }, + }, + { + Command: "rewriteaof", + Module: constants.AdminModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory, constants.DangerousCategory}, + Description: "(REWRITEAOF) Trigger re-writing of append process.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: func(params internal.HandlerFuncParams) ([]byte, error) { + if err := params.RewriteAOF(); err != nil { + return nil, err + } + return []byte(constants.OkResponse), nil + }, + }, + { + Command: "module", + Module: constants.AdminModule, + Categories: []string{}, + Description: "Module commands", + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + SubCommands: []internal.SubCommand{ + { + Command: "load", + Module: constants.AdminModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory, constants.DangerousCategory}, + Description: `(MODULE LOAD path [arg [arg ...]]) Load a module from a dynamic library at runtime. +The path should be the full path to the module, including the .so filename. Any args will be be passed unmodified to the +module's key extraction and handler functions.`, + Sync: true, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: func(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) < 3 { + return nil, errors.New(constants.WrongArgsResponse) + } + var args []string + if len(params.Command) > 3 { + args = params.Command[3:] + } + if err := params.LoadModule(params.Command[2], args...); err != nil { + return nil, err + } + return []byte(constants.OkResponse), nil + }, + }, + { + Command: "unload", + Module: constants.AdminModule, + Categories: []string{constants.AdminCategory, constants.SlowCategory, constants.DangerousCategory}, + Description: `(MODULE UNLOAD name) +Unloads a module based on the its name as displayed by the MODULE LIST command.`, + Sync: true, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: func(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) != 3 { + return nil, errors.New(constants.WrongArgsResponse) + } + params.UnloadModule(params.Command[2]) + return []byte(constants.OkResponse), nil + }, + }, + { + Command: "list", + Module: constants.AdminModule, + Categories: []string{constants.AdminModule, constants.SlowCategory, constants.DangerousCategory}, + Description: `(MODULE LIST) List all the modules that are currently loaded in the server.`, + Sync: false, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: func(params internal.HandlerFuncParams) ([]byte, error) { + modules := params.ListModules() + res := fmt.Sprintf("*%d\r\n", len(modules)) + for _, module := range modules { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(module), module) + } + return []byte(res), nil + }, + }, + }, + }, + } +} diff --git a/internal/modules/admin/commands_test.go b/internal/modules/admin/commands_test.go new file mode 100644 index 0000000..ae398c7 --- /dev/null +++ b/internal/modules/admin/commands_test.go @@ -0,0 +1,1077 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package admin_test + +import ( + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/clock" + "apigo.cc/go/sugardb/internal/constants" + "apigo.cc/go/sugardb/internal/modules/acl" + "apigo.cc/go/sugardb/internal/modules/admin" + "apigo.cc/go/sugardb/internal/modules/connection" + "apigo.cc/go/sugardb/internal/modules/generic" + "apigo.cc/go/sugardb/internal/modules/hash" + "apigo.cc/go/sugardb/internal/modules/list" + "apigo.cc/go/sugardb/internal/modules/pubsub" + "apigo.cc/go/sugardb/internal/modules/set" + "apigo.cc/go/sugardb/internal/modules/sorted_set" + str "apigo.cc/go/sugardb/internal/modules/string" + "apigo.cc/go/sugardb/sugardb" + "github.com/tidwall/resp" + "os" + "path" + "slices" + "strings" + "testing" + "time" +) + +func setupServer(port uint16) (*sugardb.SugarDB, error) { + cfg := sugardb.DefaultConfig() + cfg.DataDir = "" + cfg.BindAddr = "localhost" + cfg.Port = port + cfg.EvictionPolicy = constants.NoEviction + return sugardb.NewSugarDB(sugardb.WithConfig(cfg)) +} + +func Test_AdminCommands(t *testing.T) { + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := setupServer(uint16(port)) + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + t.Run("Test COMMANDS command", func(t *testing.T) { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + if err = client.WriteArray([]resp.Value{resp.StringValue("COMMANDS")}); err != nil { + t.Error(err) + return + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + + // Get all the commands from the existing modules. + var commands []internal.Command + commands = append(commands, acl.Commands()...) + commands = append(commands, admin.Commands()...) + commands = append(commands, generic.Commands()...) + commands = append(commands, hash.Commands()...) + commands = append(commands, list.Commands()...) + commands = append(commands, connection.Commands()...) + commands = append(commands, pubsub.Commands()...) + commands = append(commands, set.Commands()...) + commands = append(commands, sorted_set.Commands()...) + commands = append(commands, str.Commands()...) + + // Flatten the commands and subcommands. + var allCommands []string + for _, c := range commands { + if c.SubCommands == nil || len(c.SubCommands) == 0 { + allCommands = append(allCommands, c.Command) + continue + } + for _, sc := range c.SubCommands { + allCommands = append(allCommands, fmt.Sprintf("%s|%s", c.Command, sc.Command)) + } + } + + if len(allCommands) != len(res.Array()) { + t.Errorf("expected commands list to be of length %d, got %d", len(allCommands), len(res.Array())) + } + }) + + t.Run("Test COMMAND COUNT command", func(t *testing.T) { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + if err = client.WriteArray([]resp.Value{resp.StringValue("COMMAND"), resp.StringValue("COUNT")}); err != nil { + t.Error(err) + return + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + + // Get all the commands from the existing modules. + var commands []internal.Command + commands = append(commands, acl.Commands()...) + commands = append(commands, admin.Commands()...) + commands = append(commands, generic.Commands()...) + commands = append(commands, hash.Commands()...) + commands = append(commands, list.Commands()...) + commands = append(commands, connection.Commands()...) + commands = append(commands, pubsub.Commands()...) + commands = append(commands, set.Commands()...) + commands = append(commands, sorted_set.Commands()...) + commands = append(commands, str.Commands()...) + + // Flatten the commands and subcommands. + var allCommands []string + for _, c := range commands { + if c.SubCommands == nil || len(c.SubCommands) == 0 { + allCommands = append(allCommands, c.Command) + continue + } + for _, sc := range c.SubCommands { + allCommands = append(allCommands, fmt.Sprintf("%s|%s", c.Command, sc.Command)) + } + } + + if len(allCommands) != res.Integer() { + t.Errorf("expected COMMAND COUNT to return %d, got %d", len(allCommands), res.Integer()) + } + }) + + t.Run("Test COMMAND LIST command", func(t *testing.T) { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + // Get all the commands from the existing modules. + var allCommands []internal.Command + allCommands = append(allCommands, acl.Commands()...) + allCommands = append(allCommands, admin.Commands()...) + allCommands = append(allCommands, generic.Commands()...) + allCommands = append(allCommands, hash.Commands()...) + allCommands = append(allCommands, list.Commands()...) + allCommands = append(allCommands, connection.Commands()...) + allCommands = append(allCommands, pubsub.Commands()...) + allCommands = append(allCommands, set.Commands()...) + allCommands = append(allCommands, sorted_set.Commands()...) + allCommands = append(allCommands, str.Commands()...) + + tests := []struct { + name string + cmd []string + want []string + }{ + { + name: "1. Return all commands with no filter specified", + cmd: []string{"COMMAND", "LIST"}, + want: func() []string { + var commands []string + for _, command := range allCommands { + if command.SubCommands == nil || len(command.SubCommands) == 0 { + commands = append(commands, command.Command) + continue + } + for _, subcommand := range command.SubCommands { + commands = append(commands, fmt.Sprintf("%s %s", command.Command, subcommand.Command)) + } + } + return commands + }(), + }, + { + name: "2. Return all commands that contain the provided ACL category", + cmd: []string{"COMMAND", "LIST", "FILTERBY", "ACLCAT", constants.FastCategory}, + want: func() []string { + var commands []string + for _, command := range allCommands { + if (command.SubCommands == nil || len(command.SubCommands) == 0) && + slices.Contains(command.Categories, constants.FastCategory) { + commands = append(commands, command.Command) + continue + } + for _, subcommand := range command.SubCommands { + if slices.Contains(subcommand.Categories, constants.FastCategory) { + commands = append(commands, fmt.Sprintf("%s %s", command.Command, subcommand.Command)) + } + } + } + return commands + }(), + }, + { + name: "3. Return all commands that match the provided pattern", + cmd: []string{"COMMAND", "LIST", "FILTERBY", "PATTERN", "z*"}, + want: func() []string { + var commands []string + for _, command := range sorted_set.Commands() { + commands = append(commands, command.Command) + } + return commands + }(), + }, + { + name: "4. Return all commands that belong to the specified module", + cmd: []string{"COMMAND", "LIST", "FILTERBY", "MODULE", constants.HashModule}, + want: func() []string { + var commands []string + for _, command := range hash.Commands() { + commands = append(commands, command.Command) + } + return commands + }(), + }, + } + + for _, test := range tests { + command := make([]resp.Value, len(test.cmd)) + for i, c := range test.cmd { + command[i] = resp.StringValue(c) + } + if err = client.WriteArray(command); err != nil { + t.Error(err) + return + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + + if len(res.Array()) != len(test.want) { + t.Errorf("expected response of length %d, got %d", len(test.want), len(res.Array())) + } + + for _, command := range res.Array() { + if !slices.ContainsFunc(test.want, func(c string) bool { + return strings.EqualFold(c, command.String()) + }) { + t.Errorf("command \"%s\" is not expected in response but is returned", command.String()) + } + } + } + }) + + t.Run("Test MODULE LOAD command", func(t *testing.T) { + tests := []struct { + name string + execCommand []resp.Value + wantExecRes string + wantExecErr error + testCommand []resp.Value + wantTestRes string + wantTestErr error + }{ + { + name: "1. Successfully load module_set module and return a response from the module handler", + execCommand: []resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("LOAD"), + resp.StringValue(path.Join(".", "testdata", "modules", "module_set", "module_set.so")), + }, + wantExecRes: "OK", + wantExecErr: nil, + testCommand: []resp.Value{ + resp.StringValue("MODULE.SET"), + resp.StringValue("key1"), + resp.StringValue("20"), + }, + wantTestRes: "OK", + wantTestErr: nil, + }, + { + name: "2. Successfully load module_get module and return a response from the module handler", + execCommand: []resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("LOAD"), + resp.StringValue(path.Join(".", "testdata", "modules", "module_get", "module_get.so")), + resp.StringValue("10"), // With args + }, + wantExecRes: "OK", + wantExecErr: nil, + testCommand: []resp.Value{ + resp.StringValue("MODULE.GET"), + resp.StringValue("key1"), + }, + wantTestRes: "200", + wantTestErr: nil, + }, + { + name: "3. Return error from module_set command handler", + execCommand: make([]resp.Value, 0), + wantExecRes: "", + wantExecErr: nil, + testCommand: []resp.Value{resp.StringValue("MODULE.SET"), resp.StringValue("key2")}, + wantTestRes: "", + wantTestErr: errors.New("wrong no of args for module.set command"), + }, + { + name: "4. Return error from module_get command handler", + execCommand: []resp.Value{ + resp.StringValue("SET"), + resp.StringValue("key2"), + resp.StringValue("value1"), + }, + wantExecRes: "OK", + wantExecErr: nil, + testCommand: []resp.Value{ + resp.StringValue("MODULE.GET"), + resp.StringValue("key2"), + }, + wantTestRes: "", + wantTestErr: errors.New("value at key key2 is not an integer"), + }, + { + name: "5. Return OK when reloading module that is already loaded", + execCommand: []resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("LOAD"), + resp.StringValue(path.Join(".", "testdata", "modules", "module_set", "module_set.so")), + }, + wantExecRes: "OK", + testCommand: []resp.Value{ + resp.StringValue("MODULE.SET"), + resp.StringValue("key3"), + resp.StringValue("20"), + }, + wantTestRes: "OK", + wantTestErr: nil, + }, + { + name: "6. Load LUA example module", + execCommand: []resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("LOAD"), + resp.StringValue(path.Join("..", "..", "volumes", "modules", "lua", "example.lua")), + }, + wantExecRes: "OK", + testCommand: []resp.Value{ + resp.StringValue("LUA.EXAMPLE"), + }, + wantTestRes: "OK", + wantTestErr: nil, + }, + { + name: "7. Load LUA hash module", + execCommand: []resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("LOAD"), + resp.StringValue(path.Join("..", "..", "volumes", "modules", "lua", "hash.lua")), + }, + wantExecRes: "OK", + testCommand: []resp.Value{ + resp.StringValue("LUA.HASH"), + resp.StringValue("LUA.HASH_KEY_1"), + }, + wantTestRes: "OK", + wantTestErr: nil, + }, + { + name: "8. Load LUA set module", + execCommand: []resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("LOAD"), + resp.StringValue(path.Join("..", "..", "volumes", "modules", "lua", "set.lua")), + }, + wantExecRes: "OK", + testCommand: []resp.Value{ + resp.StringValue("LUA.SET"), + resp.StringValue("LUA.SET_KEY_1"), + resp.StringValue("LUA.SET_KEY_2"), + resp.StringValue("LUA.SET_KEY_3"), + }, + wantTestRes: "OK", + wantTestErr: nil, + }, + { + name: "9. Load LUA zset module", + execCommand: []resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("LOAD"), + resp.StringValue(path.Join("..", "..", "volumes", "modules", "lua", "zset.lua")), + }, + wantExecRes: "OK", + testCommand: []resp.Value{ + resp.StringValue("LUA.ZSET"), + resp.StringValue("LUA.ZSET_KEY_1"), + resp.StringValue("LUA.ZSET_KEY_2"), + resp.StringValue("LUA.ZSET_KEY_3"), + }, + wantTestRes: "OK", + wantTestErr: nil, + }, + { + name: "10. Load LUA list module", + execCommand: []resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("LOAD"), + resp.StringValue(path.Join("..", "..", "volumes", "modules", "lua", "list.lua")), + }, + wantExecRes: "OK", + testCommand: []resp.Value{ + resp.StringValue("LUA.LIST"), + resp.StringValue("LUA.LIST_KEY_1"), + }, + wantTestRes: "OK", + wantTestErr: nil, + }, + } + + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + respConn := resp.NewConn(conn) + + for i := 0; i < len(tests); i++ { + t.Log(tests[i].name) + if len(tests[i].wantExecRes) > 0 { + // If the length of execCommand is > 0, write the command to the connection. + if err := respConn.WriteArray(tests[i].execCommand); err != nil { + t.Error(err) + } + // Read the response from the server. + r, _, err := respConn.ReadValue() + if err != nil { + t.Error(err) + } + // If we expect an error, check if the error matches the one we expect. + if tests[i].wantExecErr != nil { + if !strings.Contains(strings.ToLower(r.Error().Error()), strings.ToLower(tests[i].wantExecErr.Error())) { + t.Errorf("expected error to contain \"%s\", got \"%s\"", tests[i].wantExecErr.Error(), r.Error().Error()) + return + } + } + // If there's no expected error, check if the response is what's expected. + if tests[i].wantExecRes != "" { + if r.String() != tests[i].wantExecRes { + t.Errorf("expected exec response \"%s\", got \"%s\"", tests[i].wantExecRes, r.String()) + return + } + } + } + + if len(tests[i].testCommand) > 0 { + // If the length of test command is > 0, write teh command to the connections. + if err := respConn.WriteArray(tests[i].testCommand); err != nil { + t.Error(err) + } + // Read the response from the server. + r, _, err := respConn.ReadValue() + if err != nil { + t.Error(err) + } + // If we expect an error, check if the error is what's expected. + if tests[i].wantTestErr != nil { + if !strings.Contains(strings.ToLower(r.Error().Error()), strings.ToLower(tests[i].wantTestErr.Error())) { + t.Errorf("expected error to contain \"%s\", got \"%s\"", tests[i].wantTestErr.Error(), r.Error().Error()) + return + } + } + // Check if the response is what's expected. + if tests[i].wantTestRes != "" { + if r.String() != tests[i].wantTestRes { + t.Errorf("expected test response \"%s\", got \"%s\"", tests[i].wantTestRes, r.String()) + return + } + } + } + } + }) + + t.Run("Test MODULE UNLOAD command", func(t *testing.T) { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + respConn := resp.NewConn(conn) + + // Load module.set module + if err := respConn.WriteArray([]resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("LOAD"), + resp.StringValue(path.Join(".", "testdata", "modules", "module_set", "module_set.so")), + }); err != nil { + t.Errorf("load module_set: %v", err) + return + } + // Expect OK response + r, _, err := respConn.ReadValue() + if err != nil { + t.Error(err) + return + } + if r.String() != "OK" { + t.Errorf("expected response OK, got \"%s\"", r.String()) + return + } + + // Load module.get module with arg + if err := respConn.WriteArray([]resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("LOAD"), + resp.StringValue(path.Join(".", "testdata", "modules", "module_get", "module_get.so")), + resp.StringValue("10"), + }); err != nil { + t.Errorf("load module_get: %v", err) + return + } + // Expect OK response + r, _, err = respConn.ReadValue() + if err != nil { + t.Error(err) + return + } + if r.String() != "OK" { + t.Errorf("expected response OK, got \"%s\"", r.String()) + return + } + + // Execute module.set command, expect OK response + if err := respConn.WriteArray([]resp.Value{ + resp.StringValue("module.set"), + resp.StringValue("key1"), + resp.StringValue("50"), + }); err != nil { + t.Errorf("exec module.set: %v", err) + return + } + // Expect OK response + r, _, err = respConn.ReadValue() + if err != nil { + t.Error(err) + return + } + if r.String() != "OK" { + t.Errorf("expected response OK, got \"%s\"", r.String()) + return + } + + // Execute module.get command, expect integer response + if err := respConn.WriteArray([]resp.Value{ + resp.StringValue("module.get"), + resp.StringValue("key1"), + }); err != nil { + t.Errorf("exec module.get: %v", err) + return + } + // Expect integer response + r, _, err = respConn.ReadValue() + if err != nil { + t.Error(err) + return + } + if r.Integer() != 500 { + t.Errorf("expected response 500, got \"%d\"", r.Integer()) + return + } + + // Unload module.set module + if err := respConn.WriteArray([]resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("UNLOAD"), + resp.StringValue(path.Join(".", "testdata", "modules", "module_set", "module_set.so")), + }); err != nil { + t.Errorf("unload module_set: %v", err) + return + } + // Expect OK response + r, _, err = respConn.ReadValue() + if err != nil { + t.Error(err) + return + } + if r.String() != "OK" { + t.Errorf("expected response OK, got \"%s\"", r.String()) + return + } + + // Unload module.get module + if err := respConn.WriteArray([]resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("UNLOAD"), + resp.StringValue(path.Join(".", "testdata", "modules", "module_get", "module_get.so")), + }); err != nil { + t.Errorf("unload module_get: %v", err) + return + } + // Expect OK response + r, _, err = respConn.ReadValue() + if err != nil { + t.Error(err) + return + } + if r.String() != "OK" { + t.Errorf("expected response OK, got \"%s\"", r.String()) + return + } + + // Try to execute module.set command, should receive command not supported error + if err := respConn.WriteArray([]resp.Value{ + resp.StringValue("module.set"), + resp.StringValue("key1"), + resp.StringValue("50"), + }); err != nil { + t.Errorf("retry module.set: %v", err) + return + } + // Expect command not supported response + r, _, err = respConn.ReadValue() + if err != nil { + t.Error(err) + return + } + if !strings.Contains(r.Error().Error(), "command module.set not supported") { + t.Errorf("expected error to contain \"command module.set not supported\", got \"%s\"", r.Error().Error()) + return + } + + // Try to execute module.get command, should receive command not supported error + if err := respConn.WriteArray([]resp.Value{ + resp.StringValue("module.get"), + resp.StringValue("key1"), + }); err != nil { + t.Errorf("retry module.get: %v", err) + return + } + // Expect command not supported response + r, _, err = respConn.ReadValue() + if err != nil { + t.Error(err) + return + } + if !strings.Contains(r.Error().Error(), "command module.get not supported") { + t.Errorf("expected error to contain \"command module.get not supported\", got \"%s\"", r.Error().Error()) + return + } + }) + + t.Run("Test MODULE LIST command", func(t *testing.T) { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + respConn := resp.NewConn(conn) + + // Load module.get module with arg + if err := respConn.WriteArray([]resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("LOAD"), + resp.StringValue(path.Join(".", "testdata", "modules", "module_get", "module_get.so")), + }); err != nil { + t.Errorf("load module_get: %v", err) + return + } + // Expect OK response + r, _, err := respConn.ReadValue() + if err != nil { + t.Error(err) + return + } + if r.String() != "OK" { + t.Errorf("expected response OK, got \"%s\"", r.String()) + return + } + + if err := respConn.WriteArray([]resp.Value{ + resp.StringValue("MODULE"), + resp.StringValue("LIST"), + }); err != nil { + t.Errorf("list module: %v", err) + } + r, _, err = respConn.ReadValue() + if err != nil { + t.Error(err) + return + } + + serverModules := mockServer.ListModules() + + if len(r.Array()) != len(serverModules) { + t.Errorf("expected response of length %d, got %d", len(serverModules), len(r.Array())) + return + } + + for _, resModule := range r.Array() { + if !slices.ContainsFunc(serverModules, func(serverModule string) bool { + return resModule.String() == serverModule + }) { + t.Errorf("could not file module \"%s\" in the loaded server modules \"%s\"", resModule, serverModules) + } + } + }) + + t.Run("Test SAVE/LASTSAVE commands", func(t *testing.T) { + t.Parallel() + + dataDir := path.Join(".", "testdata", "test_snapshot") + t.Cleanup(func() { + _ = os.RemoveAll(dataDir) + }) + + tests := []struct { + name string + dataDir string + values map[string]string + snapshotFunc func(mockServer *sugardb.SugarDB, port int) error + lastSaveFunc func(mockServer *sugardb.SugarDB, port int) (int, error) + wantLastSave int + }{ + { + name: "1. Snapshot with TCP connection", + dataDir: path.Join(dataDir, "with_tcp_connection"), + values: map[string]string{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + "key4": "value4", + }, + snapshotFunc: func(mockServer *sugardb.SugarDB, port int) error { + // Start the server's TCP listener + go func() { + mockServer.Start() + }() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + return err + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + if err = client.WriteArray([]resp.Value{resp.StringValue("SAVE")}); err != nil { + return err + } + res, _, err := client.ReadValue() + if err != nil { + return err + } + if !strings.EqualFold(res.String(), "ok") { + return fmt.Errorf("expected save response to be \"OK\", got \"%s\"", res.String()) + } + return nil + }, + lastSaveFunc: func(mockServer *sugardb.SugarDB, port int) (int, error) { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + return 0, err + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + if err = client.WriteArray([]resp.Value{resp.StringValue("LASTSAVE")}); err != nil { + return 0, err + } + res, _, err := client.ReadValue() + if err != nil { + return 0, err + } + return res.Integer(), nil + }, + wantLastSave: int(clock.NewClock().Now().UnixMilli()), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + conf := sugardb.DefaultConfig() + conf.DataDir = test.dataDir + conf.BindAddr = "localhost" + conf.Port = uint16(port) + conf.RestoreSnapshot = true + + mockServer, err := sugardb.NewSugarDB(sugardb.WithConfig(conf)) + if err != nil { + t.Error(err) + return + } + defer func() { + // Shutdown + mockServer.ShutDown() + }() + + // Trigger some write commands + for key, value := range test.values { + if _, _, err = mockServer.Set(key, value, sugardb.SETOptions{}); err != nil { + t.Error(err) + return + } + } + + // Function to trigger snapshot save + if err = test.snapshotFunc(mockServer, port); err != nil { + t.Error(err) + } + + // Yield to allow snapshot to complete sync. + ticker := time.NewTicker(200 * time.Millisecond) + <-ticker.C + ticker.Stop() + + // Restart server with the same config. This should restore the snapshot + mockServer, err = sugardb.NewSugarDB(sugardb.WithConfig(conf)) + if err != nil { + t.Error(err) + return + } + + // Check that all the key/value pairs have been restored into the store. + for key, value := range test.values { + res, err := mockServer.Get(key) + if err != nil { + t.Error(err) + return + } + if res != value { + t.Errorf("expected value at key \"%s\" to be \"%s\", got \"%s\"", key, value, res) + return + } + } + + // Check that the lastsave is the time the last snapshot was taken. + lastSave, err := test.lastSaveFunc(mockServer, port) + if err != nil { + t.Error(err) + return + } + + if lastSave != test.wantLastSave { + t.Errorf("expected lastsave to be %d, got %d", test.wantLastSave, lastSave) + } + }) + } + }) + + t.Run("Test REWRITEAOF command", func(t *testing.T) { + t.Parallel() + + ticker := time.NewTicker(200 * time.Millisecond) + + dataDir := path.Join(".", "testdata", "test_aof") + t.Cleanup(func() { + _ = os.RemoveAll(dataDir) + ticker.Stop() + }) + + // Prepare data for testing. + data := map[string]map[string]string{ + "before-rewrite": { + "key1": "value1", + "key2": "value2", + "key3": "value3", + "key4": "value4", + }, + "after-rewrite": { + "key3": "value3-updated", + "key4": "value4-updated", + "key5": "value5", + "key6": "value6", + }, + "expected-values": { + "key1": "value1", + "key2": "value2", + "key3": "value3-updated", + "key4": "value4-updated", + "key5": "value5", + "key6": "value6", + }, + } + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + conf := sugardb.DefaultConfig() + conf.BindAddr = "localhost" + conf.Port = uint16(port) + conf.RestoreAOF = true + conf.DataDir = dataDir + conf.AOFSyncStrategy = "always" + + // Start new server + mockServer, err := sugardb.NewSugarDB(sugardb.WithConfig(conf)) + if err != nil { + t.Error(err) + return + } + go func() { + mockServer.Start() + }() + + // Get client connection + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client := resp.NewConn(conn) + + // Perform write commands from "before-rewrite" + for key, value := range data["before-rewrite"] { + if err := client.WriteArray([]resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value), + }); err != nil { + t.Error(err) + return + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected response OK, got \"%s\"", res.String()) + } + } + + // Yield + <-ticker.C + + // Rewrite AOF + if err := client.WriteArray([]resp.Value{resp.StringValue("REWRITEAOF")}); err != nil { + t.Error(err) + return + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected response OK, got \"%s\"", res.String()) + } + + // Perform write commands from "after-rewrite" + for key, value := range data["after-rewrite"] { + if err := client.WriteArray([]resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value), + }); err != nil { + t.Error(err) + return + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected response OK, got \"%s\"", res.String()) + } + } + + // Yield + <-ticker.C + + // Shutdown the SugarDB instance and close current client connection + mockServer.ShutDown() + _ = conn.Close() + + // Start another instance of SugarDB + mockServer, err = sugardb.NewSugarDB(sugardb.WithConfig(conf)) + if err != nil { + t.Error(err) + return + } + go func() { + mockServer.Start() + }() + + // Get a new client connection + conn, err = internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client = resp.NewConn(conn) + + // Check if the servers contains the keys and values from "expected-values" + for key, value := range data["expected-values"] { + if err := client.WriteArray([]resp.Value{resp.StringValue("GET"), resp.StringValue(key)}); err != nil { + t.Error(err) + return + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + if res.String() != value { + t.Errorf("expected value at key \"%s\" to be \"%s\", got \"%s\"", key, value, res) + return + } + } + + // Shutdown server and close client connection + _ = conn.Close() + mockServer.ShutDown() + }) +} diff --git a/internal/modules/connection/commands.go b/internal/modules/connection/commands.go new file mode 100644 index 0000000..11ee830 --- /dev/null +++ b/internal/modules/connection/commands.go @@ -0,0 +1,287 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package connection + +import ( + "errors" + "fmt" + "apigo.cc/go/sugardb/internal/modules/acl" + "slices" + "strconv" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" +) + +func handleAuth(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) < 2 || len(params.Command) > 3 { + return nil, errors.New(constants.WrongArgsResponse) + } + accessControlList, ok := params.GetACL().(*acl.ACL) + if !ok { + return nil, errors.New("could not load ACL") + } + accessControlList.LockUsers() + defer accessControlList.UnlockUsers() + + if err := accessControlList.AuthenticateConnection(params.Context, params.Connection, params.Command); err != nil { + return nil, err + } + return []byte(constants.OkResponse), nil +} + +func handlePing(params internal.HandlerFuncParams) ([]byte, error) { + switch len(params.Command) { + default: + return nil, errors.New(constants.WrongArgsResponse) + case 1: + return []byte("+PONG\r\n"), nil + case 2: + return []byte(fmt.Sprintf("$%d\r\n%s\r\n", len(params.Command[1]), params.Command[1])), nil + } +} + +func handleEcho(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) != 2 { + return nil, errors.New(constants.WrongArgsResponse) + } + return []byte(fmt.Sprintf("$%d\r\n%s\r\n", len(params.Command[1]), params.Command[1])), nil +} + +func handleHello(params internal.HandlerFuncParams) ([]byte, error) { + if !slices.Contains([]int{1, 2, 4, 5, 7}, len(params.Command)) { + return nil, errors.New(constants.WrongArgsResponse) + } + + if len(params.Command) == 1 { + serverInfo := params.GetServerInfo() + connectionInfo := params.GetConnectionInfo(params.Connection) + return BuildHelloResponse(serverInfo, connectionInfo), nil + } + + options, err := getHelloOptions( + params.Command[2:], + helloOptions{ + protocol: 2, + clientname: "", + auth: struct { + authenticate bool + username string + password string + }{ + authenticate: false, + username: "", + password: "", + }, + }) + + if err != nil { + return nil, err + } + + // Get protocol version + protocol, err := strconv.Atoi(params.Command[1]) + if err != nil { + return nil, err + } + if !slices.Contains([]int{2, 3}, protocol) { + return nil, errors.New("protocol must be 2 or 3") + } + options.protocol = protocol + + // If AUTH option is provided, authenticate the connection. + if options.auth.authenticate { + accessControlList, ok := params.GetACL().(*acl.ACL) + if !ok { + return nil, errors.New("could not load ACL") + } + accessControlList.LockUsers() + defer accessControlList.UnlockUsers() + if err = accessControlList.AuthenticateConnection( + params.Context, + params.Connection, + []string{"AUTH", options.auth.username, options.auth.password}, + ); err != nil { + return nil, err + } + } + + // Set the connection details. + connectionInfo := params.GetConnectionInfo(params.Connection) + params.SetConnectionInfo(params.Connection, options.clientname, options.protocol, connectionInfo.Database) + + // Get the new connection details and server info to return to the client. + serverInfo := params.GetServerInfo() + connectionInfo = params.GetConnectionInfo(params.Connection) + return BuildHelloResponse(serverInfo, connectionInfo), nil +} + +func handleSelect(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) != 2 { + return nil, errors.New(constants.WrongArgsResponse) + } + + database, err := strconv.Atoi(params.Command[1]) + if err != nil { + return nil, err + } + if database < 0 { + return nil, errors.New("database must be >= 0") + } + + connectionInfo := params.GetConnectionInfo(params.Connection) + params.SetConnectionInfo(params.Connection, connectionInfo.Name, connectionInfo.Protocol, database) + + return []byte(constants.OkResponse), nil +} + +func handleSwapDB(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) != 3 { + return nil, errors.New(constants.WrongArgsResponse) + } + + database1, err := strconv.Atoi(params.Command[1]) + if err != nil { + return nil, errors.New("both database indices must be integers") + } + + database2, err := strconv.Atoi(params.Command[2]) + if err != nil { + return nil, errors.New("both database indices must be integers") + } + + if database1 < 0 || database2 < 0 { + return nil, errors.New("database indices must be >= 0") + } + + params.SwapDBs(database1, database2) + + return []byte(constants.OkResponse), nil +} + +func Commands() []internal.Command { + return []internal.Command{ + { + Command: "auth", + Module: constants.ConnectionModule, + Categories: []string{constants.ConnectionCategory, constants.SlowCategory}, + Description: `(AUTH [username] password) +Authenticates the connection. If the username is not provided, the connection will be authenticated against the +default ACL user. Otherwise, it is authenticated against the ACL user with the provided username.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleAuth, + }, + { + Command: "ping", + Module: constants.ConnectionModule, + Categories: []string{constants.ConnectionCategory, constants.FastCategory}, + Description: `(PING [message]) +Ping the echovault server. If a message is provided, the message will be echoed back to the client. +Otherwise, the server will return "PONG".`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handlePing, + }, + { + Command: "echo", + Module: constants.ConnectionModule, + Categories: []string{constants.ConnectionCategory, constants.FastCategory}, + Description: `(ECHO message) Echo the message back to the client.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleEcho, + }, + { + Command: "hello", + Module: constants.ConnectionModule, + Categories: []string{constants.FastCategory, constants.ConnectionCategory}, + Description: `(HELLO [protover [AUTH username password] [SETNAME clientname]]) +Switch to a different protocol, optionally authenticating and setting the connection's name. +This command returns a contextual client report.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleHello, + }, + { + Command: "select", + Module: constants.ConnectionModule, + Categories: []string{constants.FastCategory, constants.ConnectionCategory}, + Description: `(SELECT index) Change the logical database that the current connection is operating from.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleSelect, + }, + { + Command: "swapdb", + Module: constants.ConnectionModule, + Categories: []string{ + constants.KeyspaceCategory, + constants.SlowCategory, + constants.DangerousCategory, + constants.ConnectionCategory, + }, + Description: `(SWAPDB index1 index2) +This command swaps two databases, +so that immediately all the clients connected to a given database will see the data of the other database, +and the other way around.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleSwapDB, + }, + } +} diff --git a/internal/modules/connection/commands_test.go b/internal/modules/connection/commands_test.go new file mode 100644 index 0000000..6fcc8e3 --- /dev/null +++ b/internal/modules/connection/commands_test.go @@ -0,0 +1,1000 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package connection_test + +import ( + "bufio" + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "apigo.cc/go/sugardb/internal/modules/connection" + "reflect" + "strconv" + "strings" + "testing" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" + "apigo.cc/go/sugardb/sugardb" + "github.com/tidwall/resp" +) + +func setUpServer(port int, requirePass bool, aclConfig string) (*sugardb.SugarDB, error) { + conf := config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.NoEviction, + RequirePass: requirePass, + Password: "password1", + AclConfig: aclConfig, + } + + mockServer, err := sugardb.NewSugarDB( + sugardb.WithConfig(conf), + ) + if err != nil { + return nil, err + } + + // Add the initial test users to the ACL module. + for _, user := range generateInitialTestUsers() { + if _, err := mockServer.ACLSetUser(user); err != nil { + return nil, err + } + } + + return mockServer, nil +} + +func generateInitialTestUsers() []sugardb.User { + return []sugardb.User{ + { + // User with both hash password and plaintext password. + Username: "with_password_user", + Enabled: true, + IncludeCategories: []string{"*"}, + IncludeCommands: []string{"*"}, + AddPlainPasswords: []string{"password2"}, + AddHashPasswords: []string{generateSHA256Password("password3")}, + }, + { + // User with NoPassword option. + Username: "no_password_user", + Enabled: true, + NoPassword: true, + AddPlainPasswords: []string{"password4"}, + }, + { + // Disabled user. + Username: "disabled_user", + Enabled: false, + AddPlainPasswords: []string{"password5"}, + }, + } +} + +func generateSHA256Password(plain string) string { + h := sha256.New() + h.Write([]byte(plain)) + return hex.EncodeToString(h.Sum(nil)) +} + +func Test_Connection(t *testing.T) { + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := setUpServer(port, true, "") + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + t.Run("Test_HandleAuth", func(t *testing.T) { + t.Parallel() + + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + if conn != nil { + _ = conn.Close() + } + }() + + r := resp.NewConn(conn) + + tests := []struct { + name string + cmd []resp.Value + wantRes string + wantErr string + }{ + { + name: "1. Authenticate with default user without specifying username", + cmd: []resp.Value{resp.StringValue("AUTH"), resp.StringValue("password1")}, + wantRes: "OK", + wantErr: "", + }, + { + name: "2. Authenticate with plaintext password", + cmd: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("with_password_user"), + resp.StringValue("password2"), + }, + wantRes: "OK", + wantErr: "", + }, + { + name: "3. Authenticate with SHA256 password", + cmd: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("with_password_user"), + resp.StringValue("password3"), + }, + wantRes: "OK", + wantErr: "", + }, + { + name: "4. Authenticate with no password user", + cmd: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("no_password_user"), + resp.StringValue("password4"), + }, + wantRes: "OK", + wantErr: "", + }, + { + name: "5. Fail to authenticate with disabled user", + cmd: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("disabled_user"), + resp.StringValue("password5"), + }, + wantRes: "", + wantErr: "Error user disabled_user is disabled", + }, + { + name: "6. Fail to authenticate with non-existent user", + cmd: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("non_existent_user"), + resp.StringValue("password6"), + }, + wantRes: "", + wantErr: "Error no user with username non_existent_user", + }, + { + name: "7. Fail to authenticate with the wrong password", + cmd: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("with_password_user"), + resp.StringValue("wrong_password"), + }, + wantRes: "", + wantErr: "Error could not authenticate user", + }, + { + name: "8. Command too short", + cmd: []resp.Value{resp.StringValue("AUTH")}, + wantRes: "", + wantErr: fmt.Sprintf("Error %s", constants.WrongArgsResponse), + }, + { + name: "9. Command too long", + cmd: []resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("user"), + resp.StringValue("password1"), + resp.StringValue("password2"), + }, + wantRes: "", + wantErr: fmt.Sprintf("Error %s", constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err = r.WriteArray(test.cmd); err != nil { + t.Error(err) + } + rv, _, err := r.ReadValue() + if err != nil { + t.Error(err) + } + if test.wantErr != "" { + if rv.Error().Error() != test.wantErr { + t.Errorf("expected error response \"%s\", got \"%s\"", test.wantErr, rv.Error().Error()) + } + return + } + if rv.String() != test.wantRes { + t.Errorf("expected response \"%s\", got \"%s\"", test.wantRes, rv.String()) + } + }) + } + }) + + t.Run("Test_HandlePing", func(t *testing.T) { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + command []resp.Value + expected string + expectedErr error + }{ + { + command: []resp.Value{resp.StringValue("PING")}, + expected: "PONG", + expectedErr: nil, + }, + { + command: []resp.Value{resp.StringValue("PING"), resp.StringValue("Hello, world!")}, + expected: "Hello, world!", + expectedErr: nil, + }, + { + command: []resp.Value{ + resp.StringValue("PING"), + resp.StringValue("Hello, world!"), + resp.StringValue("Once more"), + }, + expected: "", + expectedErr: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + if err = client.WriteArray(test.command); err != nil { + t.Error(err) + return + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedErr != nil { + if !strings.Contains(res.Error().Error(), test.expectedErr.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedErr.Error(), res.Error().Error()) + } + continue + } + + if res.String() != test.expected { + t.Errorf("expected response \"%s\", got \"%s\"", test.expected, res.String()) + } + } + }) + + t.Run("Test_HandleEcho", func(t *testing.T) { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + command []resp.Value + expected string + expectedErr error + }{ + { + command: []resp.Value{resp.StringValue("ECHO"), resp.StringValue("Hello, SugarDB!")}, + expected: "Hello, SugarDB!", + expectedErr: nil, + }, + { + command: []resp.Value{resp.StringValue("ECHO")}, + expected: "", + expectedErr: errors.New(constants.WrongArgsResponse), + }, + { + command: []resp.Value{ + resp.StringValue("ECHO"), + resp.StringValue("Hello, SugarDB!"), + resp.StringValue("Once more"), + }, + expected: "", + expectedErr: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + if err = client.WriteArray(test.command); err != nil { + t.Error(err) + return + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedErr != nil { + if !strings.Contains(res.Error().Error(), test.expectedErr.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedErr.Error(), res.Error().Error()) + } + continue + } + + if res.String() != test.expected { + t.Errorf("expected response \"%s\", got \"%s\"", test.expected, res.String()) + } + } + }) + + t.Run("Test_HandleHello", func(t *testing.T) { + t.Parallel() + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + mockServer, err := setUpServer(port, true, "") + if err != nil { + t.Error(err) + return + } + go func() { + mockServer.Start() + }() + t.Cleanup(func() { + mockServer.ShutDown() + }) + + tests := []struct { + name string + command []resp.Value + wantRes []byte + }{ + { + name: "1. Hello", + command: []resp.Value{resp.StringValue("HELLO")}, + wantRes: connection.BuildHelloResponse( + internal.ServerInfo{ + Server: "sugardb", + Version: constants.Version, + Id: "", + Mode: "standalone", + Role: "master", + Modules: mockServer.ListModules(), + }, + internal.ConnectionInfo{ + Id: 1, + Name: "", + Protocol: 2, + Database: 0, + }, + ), + }, + { + name: "2. Hello 2", + command: []resp.Value{resp.StringValue("HELLO"), resp.StringValue("2")}, + wantRes: connection.BuildHelloResponse( + internal.ServerInfo{ + Server: "sugardb", + Version: constants.Version, + Id: "", + Mode: "standalone", + Role: "master", + Modules: mockServer.ListModules(), + }, + internal.ConnectionInfo{ + Id: 2, + Name: "", + Protocol: 2, + Database: 0, + }, + ), + }, + { + name: "3. Hello 3", + command: []resp.Value{resp.StringValue("HELLO"), resp.StringValue("3")}, + wantRes: connection.BuildHelloResponse( + internal.ServerInfo{ + Server: "sugardb", + Version: constants.Version, + Id: "", + Mode: "standalone", + Role: "master", + Modules: mockServer.ListModules(), + }, + internal.ConnectionInfo{ + Id: 3, + Name: "", + Protocol: 3, + Database: 0, + }, + ), + }, + { + name: "4. Hello with auth success", + command: []resp.Value{ + resp.StringValue("HELLO"), + resp.StringValue("3"), + resp.StringValue("AUTH"), + resp.StringValue("default"), + resp.StringValue("password1"), + }, + wantRes: connection.BuildHelloResponse( + internal.ServerInfo{ + Server: "sugardb", + Version: constants.Version, + Id: "", + Mode: "standalone", + Role: "master", + Modules: mockServer.ListModules(), + }, + internal.ConnectionInfo{ + Id: 4, + Name: "", + Protocol: 3, + Database: 0, + }, + ), + }, + { + name: "5. Hello with auth failure", + command: []resp.Value{ + resp.StringValue("HELLO"), + resp.StringValue("3"), + resp.StringValue("AUTH"), + resp.StringValue("default"), + resp.StringValue("password2"), + }, + wantRes: []byte("-Error could not authenticate user\r\n"), + }, + { + name: "6. Hello with auth and set client name", + command: []resp.Value{ + resp.StringValue("HELLO"), + resp.StringValue("3"), + resp.StringValue("AUTH"), + resp.StringValue("default"), + resp.StringValue("password1"), + resp.StringValue("SETNAME"), + resp.StringValue("client6"), + }, + wantRes: connection.BuildHelloResponse( + internal.ServerInfo{ + Server: "sugardb", + Version: constants.Version, + Id: "", + Mode: "standalone", + Role: "master", + Modules: mockServer.ListModules(), + }, + internal.ConnectionInfo{ + Id: 6, + Name: "", + Protocol: 3, + Database: 0, + }, + ), + }, + { + name: "7. Command too long", + command: []resp.Value{ + resp.StringValue("HELLO"), + resp.StringValue("3"), + resp.StringValue("AUTH"), + resp.StringValue("default"), + resp.StringValue("password1"), + resp.StringValue("SETNAME"), + resp.StringValue("client6"), + resp.StringValue("extra_arg"), + }, + wantRes: []byte(fmt.Sprintf("-Error %s\r\n", constants.WrongArgsResponse)), + }, + } + + for i := 0; i < len(tests); i++ { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client := resp.NewConn(conn) + + if err = client.WriteArray(tests[i].command); err != nil { + t.Error(err) + return + } + + buf := bufio.NewReader(conn) + res, err := internal.ReadMessage(buf) + if err != nil { + t.Error(err) + return + } + + if !bytes.Equal(tests[i].wantRes, res) { + t.Errorf("expected byte resposne:\n%s, \n\ngot:\n%s", string(tests[i].wantRes), string(res)) + return + } + + // Close connection + _ = conn.Close() + } + }) + + t.Run("Test_HandleSelect", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + database int + wantDBErr error + setCommand []resp.Value + getCommand []resp.Value + getWantRes []resp.Value + }{ + { + name: "1. Default database 0", + database: 0, + wantDBErr: nil, + setCommand: []resp.Value{ + resp.StringValue("MSET"), + resp.StringValue("key1"), resp.StringValue("value-01"), + resp.StringValue("key2"), resp.StringValue("value-02"), + resp.StringValue("key3"), resp.StringValue("value-03"), + }, + getCommand: []resp.Value{ + resp.StringValue("MGET"), + resp.StringValue("key1"), + resp.StringValue("key2"), + resp.StringValue("key3"), + }, + getWantRes: []resp.Value{ + resp.StringValue("value-01"), + resp.StringValue("value-02"), + resp.StringValue("value-03"), + }, + }, + { + name: "2. Select database 1", + database: 1, + wantDBErr: nil, + setCommand: []resp.Value{ + resp.StringValue("MSET"), + resp.StringValue("key1"), resp.StringValue("value-11"), + resp.StringValue("key2"), resp.StringValue("value-12"), + resp.StringValue("key3"), resp.StringValue("value-13"), + }, + getCommand: []resp.Value{ + resp.StringValue("MGET"), + resp.StringValue("key1"), + resp.StringValue("key2"), + resp.StringValue("key3"), + }, + getWantRes: []resp.Value{ + resp.StringValue("value-11"), + resp.StringValue("value-12"), + resp.StringValue("value-13"), + }, + }, + { + name: "3. Error when selecting database < 0", + database: -1, + wantDBErr: errors.New("database must be >= 0"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client := resp.NewConn(conn) + + // Authenticate the connection + if err = client.WriteArray([]resp.Value{ + resp.StringValue("AUTH"), + resp.StringValue("password1"), + }); err != nil { + t.Error(err) + return + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected OK auth response, got \"%s\"", res.String()) + return + } + + // If database is not 0, execute the select command + if test.database != 0 { + if err = client.WriteArray([]resp.Value{ + resp.StringValue("SELECT"), + resp.StringValue(strconv.Itoa(test.database)), + }); err != nil { + t.Error(err) + return + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + if test.wantDBErr != nil { + // If we expect a select error, check that it's the expected error. + if !strings.Contains(res.Error().Error(), test.wantDBErr.Error()) { + t.Errorf("expected error response to contain \"%s\", \"%s\"", test.wantDBErr.Error(), res.Error().Error()) + return + } + return + } else { + // We do not expect an error, check if it's an OK response. + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected OK response, got \"%s\"", res.String()) + return + } + } + } + + // Execute command to set values + if err = client.WriteArray(test.setCommand); err != nil { + t.Error(err) + return + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + return + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected OK set response, got \"%s\"", res.String()) + return + } + + // Execute commands to get values. + if err = client.WriteArray(test.getCommand); err != nil { + t.Error(err) + return + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + return + } + if !reflect.DeepEqual(res.Array(), test.getWantRes) { + t.Errorf("expected response %+v, got %+v", test.getWantRes, res.Array()) + return + } + }) + } + }) + + t.Run("Test_HandleSwapDBs", func(t *testing.T) { + t.Parallel() + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + mockServer, err := setUpServer(port, false, "") + if err != nil { + t.Error(err) + return + } + go func() { + mockServer.Start() + }() + t.Cleanup(func() { + mockServer.ShutDown() + }) + + tests := []struct { + name string + presetValues map[int]map[string]string + database0 string + database1 string + getCommand []resp.Value + swapCommand []resp.Value + want0 []resp.Value + want1 []resp.Value + wantErr error + }{ + { + name: "1. Successfully swap databases", + presetValues: map[int]map[string]string{ + 0: {"key1": "value-01", "key2": "value-02", "key3": "value-03", "key4": "value-04", "key5": "value-05"}, + 1: {"key1": "value-11", "key2": "value-12", "key3": "value-13", "key4": "value-14", "key5": "value-15"}, + }, + database0: "0", + database1: "1", + getCommand: []resp.Value{ + resp.StringValue("MGET"), + resp.StringValue("key1"), resp.StringValue("key2"), resp.StringValue("key3"), + resp.StringValue("key4"), resp.StringValue("key5"), + }, + swapCommand: []resp.Value{ + resp.StringValue("SWAPDB"), resp.StringValue("0"), resp.StringValue("1"), + }, + want0: []resp.Value{ + resp.StringValue("value-01"), resp.StringValue("value-02"), resp.StringValue("value-03"), + resp.StringValue("value-04"), resp.StringValue("value-05"), + }, + want1: []resp.Value{ + resp.StringValue("value-11"), resp.StringValue("value-12"), resp.StringValue("value-13"), + resp.StringValue("value-14"), resp.StringValue("value-15"), + }, + wantErr: nil, + }, + { + name: "2. First database index is not an integer", + presetValues: nil, + database0: "index0", + database1: "1", + getCommand: make([]resp.Value, 0), + swapCommand: []resp.Value{ + resp.StringValue("SWAPDB"), resp.StringValue("index0"), resp.StringValue("1"), + }, + want0: make([]resp.Value, 0), + want1: make([]resp.Value, 0), + wantErr: errors.New("both database indices must be integers"), + }, + { + name: "3. Second database index is not an integer", + presetValues: nil, + database0: "0", + database1: "index1", + getCommand: make([]resp.Value, 0), + swapCommand: []resp.Value{ + resp.StringValue("SWAPDB"), resp.StringValue("0"), resp.StringValue("index1"), + }, + want0: make([]resp.Value, 0), + want1: make([]resp.Value, 0), + wantErr: errors.New("both database indices must be integers"), + }, + { + name: "4. First database index is < 0", + presetValues: nil, + database0: "-1", + database1: "1", + getCommand: make([]resp.Value, 0), + swapCommand: []resp.Value{ + resp.StringValue("SWAPDB"), resp.StringValue("-1"), resp.StringValue("1"), + }, + want0: make([]resp.Value, 0), + want1: make([]resp.Value, 0), + wantErr: errors.New("database indices must be >= 0"), + }, + { + name: "5. Second database index is < 0", + presetValues: nil, + database0: "1", + database1: "-1", + getCommand: make([]resp.Value, 0), + swapCommand: []resp.Value{ + resp.StringValue("SWAPDB"), resp.StringValue("0"), resp.StringValue("-1"), + }, + want0: make([]resp.Value, 0), + want1: make([]resp.Value, 0), + wantErr: errors.New("database indices must be >= 0"), + }, + { + name: "6. Command too short", + presetValues: nil, + database0: "-1", + database1: "1", + getCommand: make([]resp.Value, 0), + swapCommand: []resp.Value{resp.StringValue("SWAPDB"), resp.StringValue("0")}, + want0: make([]resp.Value, 0), + want1: make([]resp.Value, 0), + wantErr: errors.New(constants.WrongArgsResponse), + }, + { + name: "7. Command too long", + presetValues: nil, + database0: "-1", + database1: "1", + getCommand: make([]resp.Value, 0), + swapCommand: []resp.Value{ + resp.StringValue("SWAPDB"), resp.StringValue("0"), + resp.StringValue("1"), resp.StringValue("2"), + }, + want0: make([]resp.Value, 0), + want1: make([]resp.Value, 0), + wantErr: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + // Set values for database 0 and 1. + if test.presetValues != nil { + for db, data := range test.presetValues { + _ = mockServer.SelectDB(db) + if _, err = mockServer.MSet(data); err != nil { + t.Error(err) + return + } + } + } + + // Create TPC connection for database 0 + conn1, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client1 := resp.NewConn(conn1) + if len(test.getCommand) > 0 { + // Select database 0 for connection 1 + if err = client1.WriteArray([]resp.Value{ + resp.StringValue("SELECT"), + resp.StringValue(test.database0), + }); err != nil { + t.Error(err) + return + } + res, _, err := client1.ReadValue() + if err != nil { + t.Error(err) + return + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expcted OK response when selecting database, got %s", res.String()) + return + } + + // Check that the connection reads values from database 0 + if err = client1.WriteArray(test.getCommand); err != nil { + t.Error(err) + return + } + res, _, err = client1.ReadValue() + if err != nil { + t.Error(err) + return + } + if !reflect.DeepEqual(test.want0, res.Array()) { + t.Errorf("expected response %+v, got %+v", test.want0, res.Array()) + } + } + + // Create TCP connection for database 1 + conn2, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client2 := resp.NewConn(conn2) + if len(test.getCommand) > 0 { + // Select database 1 for the second connection. + if err = client2.WriteArray([]resp.Value{ + resp.StringValue("SELECT"), + resp.StringValue(test.database1), + }); err != nil { + t.Error(err) + return + } + res, _, err := client2.ReadValue() + if err != nil { + t.Error(err) + return + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expcted OK response when selecting database, got %s", res.String()) + return + } + // Check that the connection reads values from database 1. + if err = client2.WriteArray(test.getCommand); err != nil { + t.Error(err) + return + } + res, _, err = client2.ReadValue() + if err != nil { + t.Error(err) + return + } + if !reflect.DeepEqual(test.want1, res.Array()) { + t.Errorf("expected response %+v, got %+v", test.want1, res.Array()) + } + } + + // Run SWAPDB command + if err = client1.WriteArray(test.swapCommand); err != nil { + t.Error(err) + return + } + res, _, err := client1.ReadValue() + if err != nil { + t.Error(err) + return + } + // If we expect an error check the error. + if test.wantErr != nil { + if !strings.Contains(res.Error().Error(), test.wantErr.Error()) { + t.Errorf("expected error response to contain \"%s\", go \"%s\"", + test.wantErr.Error(), res.Error().Error()) + } + continue + } + // Check if response is OK. + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected OK response from SWAPDB command, got %s", res.String()) + return + } + + // Check that the first connection now reads values from database 1 + if err = client1.WriteArray(test.getCommand); err != nil { + t.Error(err) + return + } + res, _, err = client1.ReadValue() + if err != nil { + t.Error(err) + return + } + if !reflect.DeepEqual(test.want1, res.Array()) { + t.Errorf("expected response %+v, got %+v", test.want1, res.Array()) + } + + // Check that the second connection now reads values from database 0 + if err = client2.WriteArray(test.getCommand); err != nil { + t.Error(err) + return + } + res, _, err = client2.ReadValue() + if err != nil { + t.Error(err) + return + } + if !reflect.DeepEqual(test.want0, res.Array()) { + t.Errorf("expected response %+v, got %+v", test.want0, res.Array()) + } + } + }) +} diff --git a/internal/modules/connection/utils.go b/internal/modules/connection/utils.go new file mode 100644 index 0000000..054706e --- /dev/null +++ b/internal/modules/connection/utils.go @@ -0,0 +1,66 @@ +package connection + +import ( + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" + "strings" +) + +type helloOptions struct { + protocol int + clientname string + auth struct { + authenticate bool + username string + password string + } +} + +func getHelloOptions(cmd []string, options helloOptions) (helloOptions, error) { + if len(cmd) == 0 { + return options, nil + } + switch strings.ToLower(cmd[0]) { + case "auth": + if len(cmd) < 3 { + return options, fmt.Errorf(constants.WrongArgsResponse) + } + options.auth.authenticate = true + options.auth.username = cmd[1] + options.auth.password = cmd[2] + return getHelloOptions(cmd[3:], options) + case "setname": + if len(cmd) < 2 { + return options, fmt.Errorf(constants.WrongArgsResponse) + } + options.clientname = cmd[1] + return getHelloOptions(cmd[2:], options) + default: + return options, fmt.Errorf("unknown keywork %s", strings.ToUpper(cmd[0])) + } +} + +func BuildHelloResponse(serverInfo internal.ServerInfo, connectionInfo internal.ConnectionInfo) []byte { + var res []byte + + if connectionInfo.Protocol == 2 { + // Construct RESP2 response. + res = []byte("*14\r\n") + } else { + // Construct RESP3 response. + res = []byte("%7\r\n") + } + + res = append(res, []byte(fmt.Sprintf("+server\r\n$%d\r\n%s\r\n", len(serverInfo.Server), serverInfo.Server))...) + res = append(res, []byte(fmt.Sprintf("+version\r\n$%d\r\n%s\r\n", len(serverInfo.Version), serverInfo.Version))...) + res = append(res, []byte(fmt.Sprintf("+proto\r\n:%d\r\n", connectionInfo.Protocol))...) + res = append(res, []byte(fmt.Sprintf("+id\r\n:%d\r\n", connectionInfo.Id))...) + res = append(res, []byte(fmt.Sprintf("+mode\r\n$%d\r\n%s\r\n", len(serverInfo.Mode), serverInfo.Mode))...) + res = append(res, []byte(fmt.Sprintf("+role\r\n$%d\r\n%s\r\n", len(serverInfo.Role), serverInfo.Role))...) + res = append(res, []byte(fmt.Sprintf("+modules\r\n*%d\r\n", len(serverInfo.Modules)))...) + for _, module := range serverInfo.Modules { + res = append(res, []byte(fmt.Sprintf("$%d\r\n%s\r\n", len(module), module))...) + } + return res +} diff --git a/internal/modules/generic/commands.go b/internal/modules/generic/commands.go new file mode 100644 index 0000000..c045a56 --- /dev/null +++ b/internal/modules/generic/commands.go @@ -0,0 +1,1425 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package generic + +import ( + "context" + "errors" + "fmt" + "log" + "reflect" + "strconv" + "strings" + "time" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" +) + +type KeyObject struct { + value interface{} + locked bool +} + +func handleSet(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := setKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + value := params.Command[2] + res := []byte(constants.OkResponse) + clock := params.GetClock() + + options, err := getSetCommandOptions(clock, params.Command[3:], SetOptions{}) + if err != nil { + return nil, err + } + + // If Get is provided, the response should be the current stored value. + // If there's no current value, then the response should be nil. + if options.get { + if !keyExists { + res = []byte("$-1\r\n") + } else { + res = []byte(fmt.Sprintf("+%v\r\n", params.GetValues(params.Context, []string{key})[key])) + } + } + + if "xx" == strings.ToLower(options.exists) { + // If XX is specified, make sure the key exists. + if !keyExists { + return nil, fmt.Errorf("key %s does not exist", key) + } + } else if "nx" == strings.ToLower(options.exists) { + // If NX is specified, make sure that the key does not currently exist. + if keyExists { + return nil, fmt.Errorf("key %s already exists", key) + } + } + + if err = params.SetValues(params.Context, map[string]interface{}{ + key: internal.AdaptType(value), + }); err != nil { + return nil, err + } + + // If expiresAt is set, set the key's expiry time as well + if options.expireAt != nil { + params.SetExpiry(params.Context, key, options.expireAt.(time.Time), false) + } + + return res, nil +} + +func handleMSet(params internal.HandlerFuncParams) ([]byte, error) { + _, err := msetKeyFunc(params.Command) + if err != nil { + return nil, err + } + + entries := make(map[string]interface{}) + + // Extract all the key/value pairs + for i, key := range params.Command[1:] { + if i%2 == 0 { + entries[key] = internal.AdaptType(params.Command[1:][i+1]) + } + } + + // Set all the values + if err = params.SetValues(params.Context, entries); err != nil { + return nil, err + } + + return []byte(constants.OkResponse), nil +} + +func handleGet(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := getKeyFunc(params.Command) + if err != nil { + return nil, err + } + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, []string{key})[key] + + if !keyExists { + return []byte("$-1\r\n"), nil + } + + value := params.GetValues(params.Context, []string{key})[key] + + return []byte(fmt.Sprintf("+%v\r\n", value)), nil +} + +func handleMGet(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := mgetKeyFunc(params.Command) + if err != nil { + return nil, err + } + + values := make(map[string]string) + for key, value := range params.GetValues(params.Context, keys.ReadKeys) { + if value == nil { + values[key] = "" + continue + } + values[key] = fmt.Sprintf("%v", value) + } + + bytes := []byte(fmt.Sprintf("*%d\r\n", len(params.Command[1:]))) + + for _, key := range params.Command[1:] { + if values[key] == "" { + bytes = append(bytes, []byte("$-1\r\n")...) + continue + } + bytes = append(bytes, []byte(fmt.Sprintf("$%d\r\n%s\r\n", len(values[key]), values[key]))...) + } + + return bytes, nil +} + +func handleDel(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := delKeyFunc(params.Command) + if err != nil { + return nil, err + } + count := 0 + for key, exists := range params.KeysExist(params.Context, keys.WriteKeys) { + if !exists { + continue + } + err = params.DeleteKey(params.Context, key) + if err != nil { + log.Printf("could not delete key %s due to error: %+v\n", key, err) + continue + } + count += 1 + } + return []byte(fmt.Sprintf(":%d\r\n", count)), nil +} + +func handlePersist(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := persistKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + + if !keyExists { + return []byte(":0\r\n"), nil + } + + expireAt := params.GetExpiry(params.Context, key) + if expireAt == (time.Time{}) { + return []byte(":0\r\n"), nil + } + + params.SetExpiry(params.Context, key, time.Time{}, false) + + return []byte(":1\r\n"), nil +} + +func handleExpireTime(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := expireTimeKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + if !keyExists { + return []byte(":-2\r\n"), nil + } + + expireAt := params.GetExpiry(params.Context, key) + + if expireAt == (time.Time{}) { + return []byte(":-1\r\n"), nil + } + + t := expireAt.Unix() + if strings.ToLower(params.Command[0]) == "pexpiretime" { + t = expireAt.UnixMilli() + } + + return []byte(fmt.Sprintf(":%d\r\n", t)), nil +} + +func handleTTL(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := ttlKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + clock := params.GetClock() + + if !keyExists { + return []byte(":-2\r\n"), nil + } + + expireAt := params.GetExpiry(params.Context, key) + + if expireAt == (time.Time{}) { + return []byte(":-1\r\n"), nil + } + + t := expireAt.Unix() - clock.Now().Unix() + if strings.ToLower(params.Command[0]) == "pttl" { + t = expireAt.UnixMilli() - clock.Now().UnixMilli() + } + + if t <= 0 { + return []byte(":0\r\n"), nil + } + + return []byte(fmt.Sprintf(":%d\r\n", t)), nil +} + +func handleExpire(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := expireKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + + // Extract time + n, err := strconv.ParseInt(params.Command[2], 10, 64) + if err != nil { + return nil, errors.New("expire time must be integer") + } + + var expireAt time.Time + if strings.ToLower(params.Command[0]) == "pexpire" { + expireAt = params.GetClock().Now().Add(time.Duration(n) * time.Millisecond) + } else { + expireAt = params.GetClock().Now().Add(time.Duration(n) * time.Second) + } + + if !keyExists { + return []byte(":0\r\n"), nil + } + + if len(params.Command) == 3 { + params.SetExpiry(params.Context, key, expireAt, true) + return []byte(":1\r\n"), nil + } + + currentExpireAt := params.GetExpiry(params.Context, key) + + switch strings.ToLower(params.Command[3]) { + case "nx": + if currentExpireAt != (time.Time{}) { + return []byte(":0\r\n"), nil + } + params.SetExpiry(params.Context, key, expireAt, false) + case "xx": + if currentExpireAt == (time.Time{}) { + return []byte(":0\r\n"), nil + } + params.SetExpiry(params.Context, key, expireAt, false) + case "gt": + if currentExpireAt == (time.Time{}) { + return []byte(":0\r\n"), nil + } + if expireAt.Before(currentExpireAt) { + return []byte(":0\r\n"), nil + } + params.SetExpiry(params.Context, key, expireAt, false) + case "lt": + if currentExpireAt != (time.Time{}) { + if currentExpireAt.Before(expireAt) { + return []byte(":0\r\n"), nil + } + params.SetExpiry(params.Context, key, expireAt, false) + } + params.SetExpiry(params.Context, key, expireAt, false) + default: + return nil, fmt.Errorf("unknown option %s", strings.ToUpper(params.Command[3])) + } + + return []byte(":1\r\n"), nil +} + +func handleExpireAt(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := expireKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + + // Extract time + n, err := strconv.ParseInt(params.Command[2], 10, 64) + if err != nil { + return nil, errors.New("expire time must be integer") + } + + var expireAt time.Time + if strings.ToLower(params.Command[0]) == "pexpireat" { + expireAt = time.UnixMilli(n) + } else { + expireAt = time.Unix(n, 0) + } + + if !keyExists { + return []byte(":0\r\n"), nil + } + + if len(params.Command) == 3 { + params.SetExpiry(params.Context, key, expireAt, true) + return []byte(":1\r\n"), nil + } + + currentExpireAt := params.GetExpiry(params.Context, key) + + switch strings.ToLower(params.Command[3]) { + case "nx": + if currentExpireAt != (time.Time{}) { + return []byte(":0\r\n"), nil + } + params.SetExpiry(params.Context, key, expireAt, false) + case "xx": + if currentExpireAt == (time.Time{}) { + return []byte(":0\r\n"), nil + } + params.SetExpiry(params.Context, key, expireAt, false) + case "gt": + if currentExpireAt == (time.Time{}) { + return []byte(":0\r\n"), nil + } + if expireAt.Before(currentExpireAt) { + return []byte(":0\r\n"), nil + } + params.SetExpiry(params.Context, key, expireAt, false) + case "lt": + if currentExpireAt != (time.Time{}) { + if currentExpireAt.Before(expireAt) { + return []byte(":0\r\n"), nil + } + params.SetExpiry(params.Context, key, expireAt, false) + } + params.SetExpiry(params.Context, key, expireAt, false) + default: + return nil, fmt.Errorf("unknown option %s", strings.ToUpper(params.Command[3])) + } + + return []byte(":1\r\n"), nil +} + +func handleIncr(params internal.HandlerFuncParams) ([]byte, error) { + // Extract key from command + keys, err := incrKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + values := params.GetValues(params.Context, []string{key}) // Get the current values for the specified keys + currentValue, ok := values[key] // Check if the key exists + + var newValue int64 + var currentValueInt int64 + + // Check if the key exists and its current value + if !ok || currentValue == nil { + // If key does not exist, initialize it with 1 + newValue = 1 + } else { + // Use type switch to handle different types of currentValue + switch v := currentValue.(type) { + case string: + var err error + currentValueInt, err = strconv.ParseInt(v, 10, 64) // Parse the string to int64 + if err != nil { + return nil, errors.New("value is not an integer or out of range") + } + case int: + currentValueInt = int64(v) // Convert int to int64 + case int64: + currentValueInt = v // Use int64 value directly + default: + fmt.Printf("unexpected type for currentValue: %T\n", currentValue) + return nil, errors.New("unexpected type for currentValue") // Handle unexpected types + } + newValue = currentValueInt + 1 // Increment the value + } + + // Set the new incremented value + if err := params.SetValues(params.Context, map[string]interface{}{key: fmt.Sprintf("%d", newValue)}); err != nil { + return nil, err + } + + // Prepare response with the actual new value + return []byte(fmt.Sprintf(":%d\r\n", newValue)), nil +} + +func handleDecr(params internal.HandlerFuncParams) ([]byte, error) { + // Extract key from command + keys, err := decrKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + values := params.GetValues(params.Context, []string{key}) // Get the current values for the specified keys + currentValue, ok := values[key] // Check if the key exists + + var newValue int64 + var currentValueInt int64 + + // Check if the key exists and its current value + if !ok || currentValue == nil { + // If key does not exist, initialize it with 0 + newValue = -1 + } else { + // Use type switch to handle different types of currentValue + switch v := currentValue.(type) { + case string: + var err error + currentValueInt, err = strconv.ParseInt(v, 10, 64) // Parse the string to int64 + if err != nil { + return nil, errors.New("value is not an integer or out of range") + } + case int: + currentValueInt = int64(v) // Convert int to int64 + case int64: + currentValueInt = v // Use int64 value directly + default: + fmt.Printf("unexpected type for currentValue: %T\n", currentValue) + return nil, errors.New("unexpected type for currentValue") // Handle unexpected types + } + newValue = currentValueInt - 1 // Decrement the value + } + + // Set the new incremented value + if err := params.SetValues(params.Context, map[string]interface{}{key: fmt.Sprintf("%d", newValue)}); err != nil { + return nil, err + } + + // Prepare response with the actual new value + return []byte(fmt.Sprintf(":%d\r\n", newValue)), nil +} + +func handleIncrBy(params internal.HandlerFuncParams) ([]byte, error) { + // Extract key from command + keys, err := incrByKeyFunc(params.Command) + if err != nil { + return nil, err + } + + // Parse increment value + incrValue, err := strconv.ParseInt(params.Command[2], 10, 64) + if err != nil { + return nil, errors.New("increment value is not an integer or out of range") + } + + key := keys.WriteKeys[0] + values := params.GetValues(params.Context, []string{key}) // Get the current values for the specified keys + currentValue, ok := values[key] // Check if the key exists + + var newValue int64 + var currentValueInt int64 + + // Check if the key exists and its current value + if !ok || currentValue == nil { + // If key does not exist, initialize it with the increment value + newValue = incrValue + } else { + // Use type switch to handle different types of currentValue + switch v := currentValue.(type) { + case string: + currentValueInt, err = strconv.ParseInt(v, 10, 64) // Parse the string to int64 + if err != nil { + return nil, errors.New("value is not an integer or out of range") + } + case int: + currentValueInt = int64(v) // Convert int to int64 + case int64: + currentValueInt = v // Use int64 value directly + default: + fmt.Printf("unexpected type for currentValue: %T\n", currentValue) + return nil, errors.New("unexpected type for currentValue") // Handle unexpected types + } + newValue = currentValueInt + incrValue // Increment the value by the specified amount + } + + // Set the new incremented value + if err := params.SetValues(params.Context, map[string]interface{}{key: fmt.Sprintf("%d", newValue)}); err != nil { + return nil, err + } + + // Prepare response with the actual new value + return []byte(fmt.Sprintf(":%d\r\n", newValue)), nil +} + +func handleIncrByFloat(params internal.HandlerFuncParams) ([]byte, error) { + // Extract key from command + keys, err := incrByFloatKeyFunc(params.Command) + if err != nil { + return nil, err + } + + // Parse increment value + incrValue, err := strconv.ParseFloat(params.Command[2], 64) + if err != nil { + return nil, errors.New("increment value is not a float or out of range") + } + + key := keys.WriteKeys[0] + values := params.GetValues(params.Context, []string{key}) // Get the current values for the specified keys + currentValue, ok := values[key] // Check if the key exists + + var newValue float64 + var currentValueFloat float64 + + // Check if the key exists and its current value + if !ok || currentValue == nil { + // If key does not exist, initialize it with the increment value + newValue = incrValue + } else { + // Use type switch to handle different types of currentValue + switch v := currentValue.(type) { + case string: + currentValueFloat, err = strconv.ParseFloat(v, 64) // Parse the string to float64 + if err != nil { + currentValueInt, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, errors.New("value is not a float or integer") + } + currentValueFloat = float64(currentValueInt) + } + case float64: + currentValueFloat = v // Use float64 value directly + case int64: + currentValueFloat = float64(v) // Convert int64 to float64 + case int: + currentValueFloat = float64(v) // Convert int to float64 + default: + fmt.Printf("unexpected type for currentValue: %T\n", currentValue) + return nil, errors.New("unexpected type for currentValue") // Handle unexpected types + } + newValue = currentValueFloat + incrValue // Increment the value by the specified amount + } + + // Set the new incremented value + if err := params.SetValues(params.Context, map[string]interface{}{key: fmt.Sprintf("%g", newValue)}); err != nil { + return nil, err + } + + // Prepare response with the actual new value in bulk string format + response := fmt.Sprintf("$%d\r\n%g\r\n", len(fmt.Sprintf("%g", newValue)), newValue) + return []byte(response), nil +} + +func handleDecrBy(params internal.HandlerFuncParams) ([]byte, error) { + // Extract key from command + keys, err := decrByKeyFunc(params.Command) + if err != nil { + return nil, err + } + + // Parse decrement value + decrValue, err := strconv.ParseInt(params.Command[2], 10, 64) + if err != nil { + return nil, errors.New("decrement value is not an integer or out of range") + } + + key := keys.WriteKeys[0] + values := params.GetValues(params.Context, []string{key}) // Get the current values for the specified keys + currentValue, ok := values[key] // Check if the key exists + + var newValue int64 + var currentValueInt int64 + + // Check if the key exists and its current value + if !ok || currentValue == nil { + // If key does not exist, initialize it with the decrement value + newValue = decrValue * -1 + } else { + // Use type switch to handle different types of currentValue + switch v := currentValue.(type) { + case string: + currentValueInt, err = strconv.ParseInt(v, 10, 64) // Parse the string to int64 + if err != nil { + return nil, errors.New("value is not an integer or out of range") + } + case int: + currentValueInt = int64(v) // Convert int to int64 + case int64: + currentValueInt = v // Use int64 value directly + default: + fmt.Printf("unexpected type for currentValue: %T\n", currentValue) + return nil, errors.New("unexpected type for currentValue") // Handle unexpected types + } + newValue = currentValueInt - decrValue // decrement the value by the specified amount + } + + // Set the new incremented value + if err := params.SetValues(params.Context, map[string]interface{}{key: fmt.Sprintf("%d", newValue)}); err != nil { + return nil, err + } + + // Prepare response with the actual new value + return []byte(fmt.Sprintf(":%d\r\n", newValue)), nil +} + +func handleRename(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) != 3 { + return nil, errors.New(constants.WrongArgsResponse) + } + + oldKey := params.Command[1] + newKey := params.Command[2] + + // Get the current value for the old key + values := params.GetValues(params.Context, []string{oldKey}) + oldValue, ok := values[oldKey] + + if !ok || oldValue == nil { + return nil, errors.New("no such key") + } + + // Set the new key with the old value + if err := params.SetValues(params.Context, map[string]interface{}{newKey: oldValue}); err != nil { + return nil, err + } + + // Delete the old key + if err := params.DeleteKey(params.Context, oldKey); err != nil { + return nil, err + } + + return []byte("+OK\r\n"), nil +} + +func handleRenamenx(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) != 3 { + return nil, errors.New(constants.WrongArgsResponse) + } + + newKey := params.Command[2] + + keyExistsCheck := params.KeysExist(params.Context, []string{newKey}) + if keyExistsCheck[newKey] { + return nil, fmt.Errorf("key %s already exists", newKey) + } + + return handleRename(params) +} + +func handleExists(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := existsKeyFunc(params.Command) + if err != nil { + return nil, err + } + + // check if key exists and count + existingKeys := params.KeysExist(params.Context, keys.ReadKeys) + keyCount := 0 + for _, key := range keys.ReadKeys { + if existingKeys[key] { + keyCount++ + } + } + + return []byte(fmt.Sprintf(":%d\r\n", keyCount)), nil +} + +func handleFlush(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) != 1 { + return nil, errors.New(constants.WrongArgsResponse) + } + + if strings.EqualFold(params.Command[0], "flushall") { + params.Flush(-1) + return []byte(constants.OkResponse), nil + } + + database := params.Context.Value("Database").(int) + params.Flush(database) + return []byte(constants.OkResponse), nil +} + +func handleRandomKey(params internal.HandlerFuncParams) ([]byte, error) { + + key := params.RandomKey(params.Context) + + return []byte(fmt.Sprintf("+%v\r\n", key)), nil +} + +func handleDBSize(params internal.HandlerFuncParams) ([]byte, error) { + count := params.DBSize(params.Context) + return []byte(fmt.Sprintf(":%d\r\n", count)), nil +} + +func handleGetdel(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := getDelKeyFunc(params.Command) + if err != nil { + return nil, err + } + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, []string{key})[key] + + if !keyExists { + return []byte("$-1\r\n"), nil + } + + value := params.GetValues(params.Context, []string{key})[key] + delkey := keys.WriteKeys[0] + err = params.DeleteKey(params.Context, delkey) + if err != nil { + return nil, err + } + + return []byte(fmt.Sprintf("+%v\r\n", value)), nil +} + +func handleGetex(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := getExKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, []string{key})[key] + + if !keyExists { + return []byte("$-1\r\n"), nil + } + + value := params.GetValues(params.Context, []string{key})[key] + + exkey := keys.WriteKeys[0] + + cmdLen := len(params.Command) + + // Handle no expire options provided + if cmdLen == 2 { + return []byte(fmt.Sprintf("+%v\r\n", value)), nil + } + + // Handle persist + exCommand := strings.ToUpper(params.Command[2]) + // If time is provided with PERSIST it is effectively ignored + if exCommand == "persist" { + // getValues will update key access so no need here + params.SetExpiry(params.Context, exkey, time.Time{}, false) + return []byte(fmt.Sprintf("+%v\r\n", value)), nil + } + + // Handle exipre command passed but no time provided + if cmdLen == 3 { + return []byte(fmt.Sprintf("+%v\r\n", value)), nil + } + + // Extract time + exTimeString := params.Command[3] + n, err := strconv.ParseInt(exTimeString, 10, 64) + if err != nil { + return []byte("$-1\r\n"), errors.New("expire time must be integer") + } + + var expireAt time.Time + switch exCommand { + case "EX": + expireAt = params.GetClock().Now().Add(time.Duration(n) * time.Second) + case "PX": + expireAt = params.GetClock().Now().Add(time.Duration(n) * time.Millisecond) + case "EXAT": + expireAt = time.Unix(n, 0) + case "PXAT": + expireAt = time.UnixMilli(n) + case "PERSIST": + expireAt = time.Time{} + default: + return nil, fmt.Errorf("unknown option %s -- '%v'", strings.ToUpper(exCommand), params.Command) + } + + params.SetExpiry(params.Context, exkey, expireAt, false) + + return []byte(fmt.Sprintf("+%v\r\n", value)), nil + +} + +func handleType(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := getKeyFunc(params.Command) + if err != nil { + return nil, err + } + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, []string{key})[key] + + if !keyExists { + return nil, fmt.Errorf("key %s does not exist", key) + } + + value := params.GetValues(params.Context, []string{key})[key] + t := reflect.TypeOf(value) + type_string := "" + switch t.Kind() { + case reflect.String: + type_string = "string" + case reflect.Int: + type_string = "integer" + case reflect.Float64: + type_string = "float" + case reflect.Slice: + type_string = "list" + case reflect.Map: + if t.Elem().Name() == "HashValue" { + type_string = "hash" + } else { + type_string = t.Elem().Name() + } + case reflect.Pointer: + if t.Elem().Name() == "Set" { + type_string = "set" + } else if t.Elem().Name() == "SortedSet" { + type_string = "zset" + } else { + type_string = t.Elem().Name() + } + default: + type_string = fmt.Sprintf("%T", value) + } + return []byte(fmt.Sprintf("+%v\r\n", type_string)), nil +} + +func handleTouch(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := touchKeyFunc(params.Command) + if err != nil { + return nil, err + } + + touchedKeys, err := params.TouchKey(params.Context, keys.ReadKeys) + if err != nil { + return nil, err + } + + return []byte(fmt.Sprintf("+%v\r\n", touchedKeys)), nil +} + +func handleObjFreq(params internal.HandlerFuncParams) ([]byte, error) { + key, err := objFreqKeyFunc(params.Command) + if err != nil { + return nil, err + } + + freq, err := params.GetObjectFrequency(params.Context, key.ReadKeys[0]) + + if err != nil { + return nil, err + } + + return []byte(fmt.Sprintf("+%v\r\n", freq)), nil +} + +func handleObjIdleTime(params internal.HandlerFuncParams) ([]byte, error) { + key, err := objIdleTimeKeyFunc(params.Command) + if err != nil { + return nil, err + } + + idletime, err := params.GetObjectIdleTime(params.Context, key.ReadKeys[0]) + if err != nil { + return nil, err + } + + return []byte(fmt.Sprintf("+%v\r\n", idletime)), nil +} + +func handleCopy(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := copyKeyFunc(params.Command) + if err != nil { + return nil, err + } + + options, err := getCopyCommandOptions(params.Command[3:], CopyOptions{}) + if err != nil { + return nil, err + } + sourceKey := keys.ReadKeys[0] + destinationKey := keys.WriteKeys[0] + sourceKeyExists := params.KeysExist(params.Context, []string{sourceKey})[sourceKey] + + if !sourceKeyExists { + return []byte(":0\r\n"), nil + } + + if !options.replace { + destinationKeyExists := params.KeysExist(params.Context, []string{destinationKey})[destinationKey] + + if destinationKeyExists { + return []byte(":0\r\n"), nil + } + } + + value := params.GetValues(params.Context, []string{sourceKey})[sourceKey] + + ctx := context.WithoutCancel(params.Context) + + if options.database != "" { + database, _ := strconv.Atoi(options.database) + ctx = context.WithValue(ctx, "Database", database) + } + + if err = params.SetValues(ctx, map[string]interface{}{ + destinationKey: value, + }); err != nil { + return nil, err + } + + return []byte(":1\r\n"), nil +} + +func handleMove(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := moveKeyFunc(params.Command) + if err != nil { + return nil, err + } + key := keys.WriteKeys[0] + + // get key, destination db and current db + values := params.GetValues(params.Context, []string{key}) + value, _ := values[key] + if value == nil { + return []byte(fmt.Sprintf("+%v\r\n", 0)), nil + } + + newdb, err := strconv.Atoi(params.Command[2]) + if err != nil { + return nil, err + } + if newdb < 0 { + return nil, errors.New("database must be >= 0") + } + + // see if key exists in destination db, if not set key there + ctx := context.WithValue(params.Context, "Database", newdb) + keyExists := params.KeysExist(ctx, keys.WriteKeys)[key] + if !keyExists { + + err = params.SetValues(ctx, map[string]interface{}{key: value}) + if err != nil { + return nil, err + } + + // remove key from source db + err = params.DeleteKey(params.Context, key) + if err != nil { + return nil, err + } + + return []byte(fmt.Sprintf("+%v\r\n", 1)), nil + + } + + return []byte(fmt.Sprintf("+%v\r\n", 0)), nil +} + +func Commands() []internal.Command { + return []internal.Command{ + { + Command: "set", + Module: constants.GenericModule, + Categories: []string{constants.WriteCategory, constants.SlowCategory}, + Description: ` +(SET key value [NX | XX] [GET] [EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds]) +Set the value of a key, considering the value's type. +NX - Only set if the key does not exist. +XX - Only set if the key exists. +GET - Return the old value stored at key, or nil if the value does not exist. +EX - Expire the key after the specified number of seconds (positive integer). +PX - Expire the key after the specified number of milliseconds (positive integer). +EXAT - Expire at the exact time in unix seconds (positive integer). +PXAT - Expire at the exat time in unix milliseconds (positive integer).`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: setKeyFunc, + HandlerFunc: handleSet, + }, + { + Command: "mset", + Module: constants.GenericModule, + Categories: []string{constants.WriteCategory, constants.SlowCategory}, + Description: "(MSET key value [key value ...]) Automatically set or modify multiple key/value pairs.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: msetKeyFunc, + HandlerFunc: handleMSet, + }, + { + Command: "get", + Module: constants.GenericModule, + Categories: []string{constants.ReadCategory, constants.FastCategory}, + Description: "(GET key) Get the value at the specified key.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: getKeyFunc, + HandlerFunc: handleGet, + }, + { + Command: "mget", + Module: constants.GenericModule, + Categories: []string{constants.ReadCategory, constants.FastCategory}, + Description: "(MGET key [key ...]) Get multiple values from the specified keys.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: mgetKeyFunc, + HandlerFunc: handleMGet, + }, + { + Command: "del", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.WriteCategory, constants.FastCategory}, + Description: "(DEL key [key ...]) Removes one or more keys from the store.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: delKeyFunc, + HandlerFunc: handleDel, + }, + { + Command: "persist", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(PERSIST key) Removes the TTl associated with a key, +turning it from a volatile key to a persistent key.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: persistKeyFunc, + HandlerFunc: handlePersist, + }, + { + Command: "expiretime", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.ReadCategory, constants.FastCategory}, + Description: `(EXPIRETIME key) Returns the absolute unix time in seconds when the key will expire. +Return -1 if the key exists but has no associated expiry time. +Returns -2 if the key does not exist.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: expireTimeKeyFunc, + HandlerFunc: handleExpireTime, + }, + { + Command: "pexpiretime", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.ReadCategory, constants.FastCategory}, + Description: `(PEXPIRETIME key) Returns the absolute unix time in milliseconds when the key will expire. +Return -1 if the key exists but has no associated expiry time. +Returns -2 if the key does not exist.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: expireTimeKeyFunc, + HandlerFunc: handleExpireTime, + }, + { + Command: "ttl", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.ReadCategory, constants.FastCategory}, + Description: `(TTL key) Returns the remaining time to live for a key that has an expiry time in seconds. +If the key exists but does not have an associated expiry time, -1 is returned. +If the key does not exist, -2 is returned.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: ttlKeyFunc, + HandlerFunc: handleTTL, + }, + { + Command: "pttl", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.ReadCategory, constants.FastCategory}, + Description: `(PTTL key) Returns the remaining time to live for a key that has an expiry time in milliseconds. +If the key exists but does not have an associated expiry time, -1 is returned. +If the key does not exist, -2 is returned.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: ttlKeyFunc, + HandlerFunc: handleTTL, + }, + { + Command: "expire", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(EXPIRE key seconds [NX | XX | GT | LT]) +Expire the key in the specified number of seconds. This commands turns a key into a volatile one. +NX - Only set the expiry time if the key has no associated expiry. +XX - Only set the expiry time if the key already has an expiry time. +GT - Only set the expiry time if the new expiry time is greater than the current one. +LT - Only set the expiry time if the new expiry time is less than the current one.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: expireKeyFunc, + HandlerFunc: handleExpire, + }, + { + Command: "pexpire", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(PEXPIRE key milliseconds [NX | XX | GT | LT]) +Expire the key in the specified number of milliseconds. This commands turns a key into a volatile one. +NX - Only set the expiry time if the key has no associated expiry. +XX - Only set the expiry time if the key already has an expiry time. +GT - Only set the expiry time if the new expiry time is greater than the current one. +LT - Only set the expiry time if the new expiry time is less than the current one.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: expireKeyFunc, + HandlerFunc: handleExpire, + }, + { + Command: "expireat", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(EXPIREAT key unix-time-seconds [NX | XX | GT | LT]) +Expire the key in at the exact unix time in seconds. +This commands turns a key into a volatile one. +NX - Only set the expiry time if the key has no associated expiry. +XX - Only set the expiry time if the key already has an expiry time. +GT - Only set the expiry time if the new expiry time is greater than the current one. +LT - Only set the expiry time if the new expiry time is less than the current one.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: expireAtKeyFunc, + HandlerFunc: handleExpireAt, + }, + { + Command: "pexpireat", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(PEXPIREAT key unix-time-milliseconds [NX | XX | GT | LT]) +Expire the key in at the exact unix time in milliseconds. +This commands turns a key into a volatile one. +NX - Only set the expiry time if the key has no associated expiry. +XX - Only set the expiry time if the key already has an expiry time. +GT - Only set the expiry time if the new expiry time is greater than the current one. +LT - Only set the expiry time if the new expiry time is less than the current one.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: expireAtKeyFunc, + HandlerFunc: handleExpireAt, + }, + { + Command: "incr", + Module: constants.GenericModule, + Categories: []string{constants.WriteCategory, constants.FastCategory}, + Description: `(INCR key) +Increments the number stored at key by one. If the key does not exist, it is set to 0 before performing the operation. +An error is returned if the key contains a value of the wrong type or contains a string that cannot be represented as integer. +This operation is limited to 64 bit signed integers.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: incrKeyFunc, + HandlerFunc: handleIncr, + }, + { + Command: "decr", + Module: constants.GenericModule, + Categories: []string{constants.WriteCategory, constants.FastCategory}, + Description: `(DECR key) +Decrements the number stored at key by one. +If the key does not exist, it is set to 0 before performing the operation. +An error is returned if the key contains a value of the wrong type or contains a string that cannot be represented as integer. +This operation is limited to 64 bit signed integers.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: decrKeyFunc, + HandlerFunc: handleDecr, + }, + { + Command: "incrby", + Module: constants.GenericModule, + Categories: []string{constants.WriteCategory, constants.FastCategory}, + Description: `(INCRBY key increment) +Increments the number stored at key by increment. If the key does not exist, it is set to 0 before performing the operation. +An error is returned if the key contains a value of the wrong type or contains a string that can not be represented as integer.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: incrByKeyFunc, + HandlerFunc: handleIncrBy, + }, + { + Command: "incrbyfloat", + Module: constants.GenericModule, + Categories: []string{constants.WriteCategory, constants.FastCategory}, + Description: `(INCRBYFLOAT key increment) +Increments the number stored at key by increment. If the key does not exist, it is set to 0 before performing the operation. +An error is returned if the key contains a value of the wrong type or contains a string that cannot be represented as float.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: incrByFloatKeyFunc, + HandlerFunc: handleIncrByFloat, + }, + { + Command: "decrby", + Module: constants.GenericModule, + Categories: []string{constants.WriteCategory, constants.FastCategory}, + Description: `(DECRBY key decrement) +The DECRBY command reduces the value stored at the specified key by the specified decrement. +If the key does not exist, it is initialized with a value of 0 before performing the operation. +If the key's value is not of the correct type or cannot be represented as an integer, an error is returned.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: decrByKeyFunc, + HandlerFunc: handleDecrBy, + }, + { + Command: "rename", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(RENAME key newkey) +Renames key to newkey. If newkey already exists, it is overwritten. If key does not exist, an error is returned.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: renameKeyFunc, + HandlerFunc: handleRename, + }, + { + Command: "flushall", + Module: constants.GenericModule, + Categories: []string{ + constants.KeyspaceCategory, + constants.WriteCategory, + constants.SlowCategory, + constants.DangerousCategory, + }, + Description: `(FLUSHALL) Delete all the keys in all the existing databases. This command is always synchronous.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleFlush, + }, + { + Command: "flushdb", + Module: constants.GenericModule, + Categories: []string{ + constants.KeyspaceCategory, + constants.WriteCategory, + constants.SlowCategory, + constants.DangerousCategory, + }, + Description: `(FLUSHDB) +Delete all the keys in the currently selected database. This command is always synchronous.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), ReadKeys: make([]string, 0), WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleFlush, + }, + { + Command: "randomkey", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.ReadCategory, constants.SlowCategory}, + Description: "(RANDOMKEY) Returns a random key from the current selected database.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: randomKeyFunc, + HandlerFunc: handleRandomKey, + }, + { + Command: "dbsize", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.ReadCategory, constants.FastCategory}, + Description: "(DBSIZE) Return the number of keys in the currently selected database.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: dbSizeKeyFunc, + HandlerFunc: handleDBSize, + }, + { + Command: "getdel", + Module: constants.GenericModule, + Categories: []string{constants.WriteCategory, constants.FastCategory}, + Description: "(GETDEL key) Get the value of key and delete the key. This command is similar to [GET], but deletes key on success.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: getDelKeyFunc, + HandlerFunc: handleGetdel, + }, + { + Command: "getex", + Module: constants.GenericModule, + Categories: []string{constants.WriteCategory, constants.FastCategory}, + Description: "(GETEX key [EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | PERSIST]) Get the value of key and optionally set its expiration. GETEX is similar to [GET], but is a write command with additional options.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: getExKeyFunc, + HandlerFunc: handleGetex, + }, + { + Command: "type", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.ReadCategory, constants.FastCategory}, + Description: "(TYPE key) Returns the string representation of the type of the value stored at key. The different types that can be returned are: string, integer, float, list, set, zset, and hash.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: typeKeyFunc, + HandlerFunc: handleType, + }, + { + Command: "touch", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.ReadCategory, constants.FastCategory}, + Description: `(TOUCH keys [key ...]) Alters the last access time or access count of the key(s) depending on whether LFU or LRU strategy was used. +A key is ignored if it does not exist. This commands returns the number of keys that were touched.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: touchKeyFunc, + HandlerFunc: handleTouch, + }, + { + Command: "objectfreq", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(OBJECTFREQ key) Get the access frequency count of an object stored at . +The command is only available when the maxmemory-policy configuration directive is set to one of the LFU policies.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: objFreqKeyFunc, + HandlerFunc: handleObjFreq, + }, + { + Command: "objectidletime", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(OBJECTIDLETIME key) Get the time in seconds since the last access to the value stored at . +The command is only available when the maxmemory-policy configuration directive is set to one of the LRU policies.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: objIdleTimeKeyFunc, + HandlerFunc: handleObjIdleTime, + }, + { + Command: "copy", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(COPY source destination [DB destination-db] [REPLACE]) +Copies the value stored at the source key to the destination key. +The command returns zero when the destination key already exists. +The REPLACE option removes the destination key before copying the value to it.`, + Sync: false, + KeyExtractionFunc: copyKeyFunc, + HandlerFunc: handleCopy, + }, + { + Command: "move", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(MOVE key db) Moves a key from the selected database to the specified database.`, + Sync: true, + KeyExtractionFunc: moveKeyFunc, + HandlerFunc: handleMove, + }, + { + Command: "renamenx", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.WriteCategory, constants.FastCategory}, + Description: "(RENAMENX key newkey) Renames the specified key with the new name only if the new name does not already exist.", + Sync: true, + KeyExtractionFunc: renamenxKeyFunc, + HandlerFunc: handleRenamenx, + }, + { + Command: "exists", + Module: constants.GenericModule, + Categories: []string{constants.KeyspaceCategory, constants.ReadCategory, constants.FastCategory}, + Description: "(EXISTS key [key ...]) Returns the number of keys that exist from the provided list of keys.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: existsKeyFunc, + HandlerFunc: handleExists, + }, + } +} diff --git a/internal/modules/generic/commands_test.go b/internal/modules/generic/commands_test.go new file mode 100644 index 0000000..db82fca --- /dev/null +++ b/internal/modules/generic/commands_test.go @@ -0,0 +1,4252 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package generic_test + +import ( + "errors" + "fmt" + "strconv" + "strings" + "testing" + "time" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/clock" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" + "apigo.cc/go/sugardb/internal/modules/set" + "apigo.cc/go/sugardb/internal/modules/sorted_set" + "apigo.cc/go/sugardb/sugardb" + "github.com/tidwall/resp" +) + +type KeyData struct { + Value interface{} + ExpireAt time.Time +} + +// Testing against a server with no eviction policy. +func Test_Generic(t *testing.T) { + mockClock := clock.NewClock() + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := sugardb.NewSugarDB( + sugardb.WithConfig(config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.NoEviction, + }), + ) + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + t.Run("Test_HandleSET", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + command []string + presetValues map[string]KeyData + expectedResponse interface{} + expectedValue interface{} + expectedExpiry time.Time + expectedErr error + }{ + { + name: "1. Set normal string value", + command: []string{"SET", "SetKey1", "value1"}, + presetValues: nil, + expectedResponse: "OK", + expectedValue: "value1", + expectedExpiry: time.Time{}, + expectedErr: nil, + }, + { + name: "2. Set normal integer value", + command: []string{"SET", "SetKey2", "1245678910"}, + presetValues: nil, + expectedResponse: "OK", + expectedValue: "1245678910", + expectedExpiry: time.Time{}, + expectedErr: nil, + }, + { + name: "3. Set normal float value", + command: []string{"SET", "SetKey3", "45782.11341"}, + presetValues: nil, + expectedResponse: "OK", + expectedValue: "45782.11341", + expectedExpiry: time.Time{}, + expectedErr: nil, + }, + { + name: "4. Only set the value if the key does not exist", + command: []string{"SET", "SetKey4", "value4", "NX"}, + presetValues: nil, + expectedResponse: "OK", + expectedValue: "value4", + expectedExpiry: time.Time{}, + expectedErr: nil, + }, + { + name: "5. Throw error when value already exists with NX flag passed", + command: []string{"SET", "SetKey5", "value5", "NX"}, + presetValues: map[string]KeyData{ + "SetKey5": { + Value: "preset-value5", + ExpireAt: time.Time{}, + }, + }, + expectedResponse: nil, + expectedValue: "preset-value5", + expectedExpiry: time.Time{}, + expectedErr: errors.New("key SetKey5 already exists"), + }, + { + name: "6. Set new key value when key exists with XX flag passed", + command: []string{"SET", "SetKey6", "value6", "XX"}, + presetValues: map[string]KeyData{ + "SetKey6": { + Value: "preset-value6", + ExpireAt: time.Time{}, + }, + }, + expectedResponse: "OK", + expectedValue: "value6", + expectedExpiry: time.Time{}, + expectedErr: nil, + }, + { + name: "7. Return error when setting non-existent key with XX flag", + command: []string{"SET", "SetKey7", "value7", "XX"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: nil, + expectedExpiry: time.Time{}, + expectedErr: errors.New("key SetKey7 does not exist"), + }, + { + name: "8. Return error when NX flag is provided after XX flag", + command: []string{"SET", "SetKey8", "value8", "XX", "NX"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: nil, + expectedExpiry: time.Time{}, + expectedErr: errors.New("cannot specify NX when XX is already specified"), + }, + { + name: "9. Return error when XX flag is provided after NX flag", + command: []string{"SET", "SetKey9", "value9", "NX", "XX"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: nil, + expectedExpiry: time.Time{}, + expectedErr: errors.New("cannot specify XX when NX is already specified"), + }, + { + name: "10. Set expiry time on the key to 100 seconds from now", + command: []string{"SET", "SetKey10", "value10", "EX", "100"}, + presetValues: nil, + expectedResponse: "OK", + expectedValue: "value10", + expectedExpiry: mockClock.Now().Add(100 * time.Second), + expectedErr: nil, + }, + { + name: "11. Return error when EX flag is passed without seconds value", + command: []string{"SET", "SetKey11", "value11", "EX"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: "", + expectedExpiry: time.Time{}, + expectedErr: errors.New("seconds value required after EX"), + }, + { + name: "12. Return error when EX flag is passed with invalid (non-integer) value", + command: []string{"SET", "SetKey12", "value12", "EX", "seconds"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: "", + expectedExpiry: time.Time{}, + expectedErr: errors.New("seconds value should be an integer"), + }, + { + name: "13. Return error when trying to set expiry seconds when expiry is already set", + command: []string{"SET", "SetKey13", "value13", "PX", "100000", "EX", "100"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: nil, + expectedExpiry: time.Time{}, + expectedErr: errors.New("cannot specify EX when expiry time is already set"), + }, + { + name: "14. Set expiry time on the key in unix milliseconds", + command: []string{"SET", "SetKey14", "value14", "PX", "4096"}, + presetValues: nil, + expectedResponse: "OK", + expectedValue: "value14", + expectedExpiry: mockClock.Now().Add(4096 * time.Millisecond), + expectedErr: nil, + }, + { + name: "15. Return error when PX flag is passed without milliseconds value", + command: []string{"SET", "SetKey15", "value15", "PX"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: "", + expectedExpiry: time.Time{}, + expectedErr: errors.New("milliseconds value required after PX"), + }, + { + name: "16. Return error when PX flag is passed with invalid (non-integer) value", + command: []string{"SET", "SetKey16", "value16", "PX", "milliseconds"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: "", + expectedExpiry: time.Time{}, + expectedErr: errors.New("milliseconds value should be an integer"), + }, + { + name: "17. Return error when trying to set expiry milliseconds when expiry is already provided", + command: []string{"SET", "SetKey17", "value17", "EX", "10", "PX", "1000000"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: nil, + expectedExpiry: time.Time{}, + expectedErr: errors.New("cannot specify PX when expiry time is already set"), + }, + { + name: "18. Set exact expiry time in seconds from unix epoch", + command: []string{ + "SET", "SetKey18", "value18", + "EXAT", fmt.Sprintf("%d", mockClock.Now().Add(200*time.Second).Unix()), + }, + presetValues: nil, + expectedResponse: "OK", + expectedValue: "value18", + expectedExpiry: mockClock.Now().Add(200 * time.Second), + expectedErr: nil, + }, + { + name: "19. Return error when trying to set exact seconds expiry time when expiry time is already provided", + command: []string{ + "SET", "SetKey19", "value19", + "EX", "10", + "EXAT", fmt.Sprintf("%d", mockClock.Now().Add(200*time.Second).Unix()), + }, + presetValues: nil, + expectedResponse: nil, + expectedValue: "", + expectedExpiry: time.Time{}, + expectedErr: errors.New("cannot specify EXAT when expiry time is already set"), + }, + { + name: "20. Return error when no seconds value is provided after EXAT flag", + command: []string{"SET", "SetKey20", "value20", "EXAT"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: "", + expectedExpiry: time.Time{}, + expectedErr: errors.New("seconds value required after EXAT"), + }, + { + name: "21. Return error when invalid (non-integer) value is passed after EXAT flag", + command: []string{"SET", "SekKey21", "value21", "EXAT", "seconds"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: "", + expectedExpiry: time.Time{}, + expectedErr: errors.New("seconds value should be an integer"), + }, + { + name: "22. Set exact expiry time in milliseconds from unix epoch", + command: []string{ + "SET", "SetKey22", "value22", + "PXAT", fmt.Sprintf("%d", mockClock.Now().Add(4096*time.Millisecond).UnixMilli()), + }, + presetValues: nil, + expectedResponse: "OK", + expectedValue: "value22", + expectedExpiry: mockClock.Now().Add(4096 * time.Millisecond), + expectedErr: nil, + }, + { + name: "23. Return error when trying to set exact milliseconds expiry time when expiry time is already provided", + command: []string{ + "SET", "SetKey23", "value23", + "PX", "1000", + "PXAT", fmt.Sprintf("%d", mockClock.Now().Add(4096*time.Millisecond).UnixMilli()), + }, + presetValues: nil, + expectedResponse: nil, + expectedValue: "", + expectedExpiry: time.Time{}, + expectedErr: errors.New("cannot specify PXAT when expiry time is already set"), + }, + { + name: "24. Return error when no milliseconds value is provided after PXAT flag", + command: []string{"SET", "SetKey24", "value24", "PXAT"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: "", + expectedExpiry: time.Time{}, + expectedErr: errors.New("milliseconds value required after PXAT"), + }, + { + name: "25. Return error when invalid (non-integer) value is passed after EXAT flag", + command: []string{"SET", "SetKey25", "value25", "PXAT", "unix-milliseconds"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: "", + expectedExpiry: time.Time{}, + expectedErr: errors.New("milliseconds value should be an integer"), + }, + { + name: "26. Get the previous value when GET flag is passed", + command: []string{"SET", "SetKey26", "value26", "GET", "EX", "1000"}, + presetValues: map[string]KeyData{ + "SetKey26": { + Value: "previous-value", + ExpireAt: time.Time{}, + }, + }, + expectedResponse: "previous-value", + expectedValue: "value26", + expectedExpiry: mockClock.Now().Add(1000 * time.Second), + expectedErr: nil, + }, + { + name: "27. Return nil when GET value is passed and no previous value exists", + command: []string{"SET", "SetKey27", "value27", "GET", "EX", "1000"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: "value27", + expectedExpiry: mockClock.Now().Add(1000 * time.Second), + expectedErr: nil, + }, + { + name: "28. Throw error when unknown optional flag is passed to SET command.", + command: []string{"SET", "SetKey28", "value28", "UNKNOWN-OPTION"}, + presetValues: nil, + expectedResponse: nil, + expectedValue: nil, + expectedExpiry: time.Time{}, + expectedErr: errors.New("unknown option UNKNOWN-OPTION for set command"), + }, + { + name: "29. Command too short", + command: []string{"SET"}, + expectedResponse: nil, + expectedValue: nil, + expectedErr: errors.New(constants.WrongArgsResponse), + }, + { + name: "30. Command too long", + command: []string{"SET", "SetKey30", "value1", "value2", "value3", "value4", "value5", "value6"}, + expectedResponse: nil, + expectedValue: nil, + expectedErr: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + for k, v := range test.presetValues { + cmd := []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(k), + resp.StringValue(v.Value.(string))} + err := client.WriteArray(cmd) + if err != nil { + t.Error(err) + } + rd, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(rd.String(), "ok") { + t.Errorf("expected preset response to be \"OK\", got %s", rd.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for j, c := range test.command { + command[j] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + + if test.expectedErr != nil { + if !strings.Contains(res.Error().Error(), test.expectedErr.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedErr.Error(), err.Error()) + } + return + } + if err != nil { + t.Error(err) + } + + switch test.expectedResponse.(type) { + case string: + if test.expectedResponse != res.String() { + t.Errorf("expected response \"%s\", got \"%s\"", test.expectedResponse, res.String()) + } + case nil: + if !res.IsNull() { + t.Errorf("expcted nil response, got %+v", res) + } + default: + t.Error("test expected result should be nil or string") + } + + key := test.command[1] + + // Compare expected value to response value + if err = client.WriteArray([]resp.Value{resp.StringValue("GET"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + if res.String() != test.expectedValue.(string) { + t.Errorf("expected value %s, got %s", test.expectedValue.(string), res.String()) + } + + // Compare expected expiry to response expiry + if !test.expectedExpiry.Equal(time.Time{}) { + if err = client.WriteArray([]resp.Value{resp.StringValue("EXPIRETIME"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + if res.Integer() != int(test.expectedExpiry.Unix()) { + t.Errorf("expected expiry time %d, got %d", test.expectedExpiry.Unix(), res.Integer()) + } + } + }) + } + }) + + t.Run("Test_HandleMSET", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + command []string + expectedResponse string + expectedValues map[string]interface{} + expectedErr error + }{ + { + name: "1. Set multiple key value pairs", + command: []string{"MSET", "MsetKey1", "value1", "MsetKey2", "10", "MsetKey3", "3.142"}, + expectedResponse: "OK", + expectedValues: map[string]interface{}{"MsetKey1": "value1", "MsetKey2": 10, "MsetKey3": 3.142}, + expectedErr: nil, + }, + { + name: "2. Return error when keys and values are not even", + command: []string{"MSET", "MsetKey1", "value1", "MsetKey2", "10", "MsetKey3"}, + expectedResponse: "", + expectedValues: make(map[string]interface{}), + expectedErr: errors.New("each key must be paired with a value"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + command := make([]resp.Value, len(test.command)) + for j, c := range test.command { + command[j] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedErr != nil { + if !strings.Contains(res.Error().Error(), test.expectedErr.Error()) { + t.Errorf("expected error %s, got %s", test.expectedErr.Error(), err.Error()) + } + return + } + + if res.String() != test.expectedResponse { + t.Errorf("expected response %s, got %s", test.expectedResponse, res.String()) + } + + for key, expectedValue := range test.expectedValues { + // Get value from server + if err = client.WriteArray([]resp.Value{resp.StringValue("GET"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + switch expectedValue.(type) { + default: + t.Error("unexpected type for expectedValue") + case int: + ev, _ := expectedValue.(int) + if res.Integer() != ev { + t.Errorf("expected value %d for key %s, got %d", ev, key, res.Integer()) + } + case float64: + ev, _ := expectedValue.(float64) + if res.Float() != ev { + t.Errorf("expected value %f for key %s, got %f", ev, key, res.Float()) + } + case string: + ev, _ := expectedValue.(string) + if res.String() != ev { + t.Errorf("expected value %s for key %s, got %s", ev, key, res.String()) + } + } + } + }) + } + }) + + t.Run("Test_HandleGET", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + value string + }{ + { + name: "1. String", + key: "GetKey1", + value: "value1", + }, + { + name: "2. Integer", + key: "GetKey2", + value: "10", + }, + { + name: "3. Float", + key: "GetKey3", + value: "3.142", + }, + } + // Test successful Get command + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + func(key, value string) { + // Preset the values + err = client.WriteArray([]resp.Value{resp.StringValue("SET"), resp.StringValue(key), resp.StringValue(value)}) + if err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be \"OK\", got %s", res.String()) + } + + if err = client.WriteArray([]resp.Value{resp.StringValue("GET"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if res.String() != test.value { + t.Errorf("expected value %s, got %s", test.value, res.String()) + } + }(test.key, test.value) + }) + } + + // Test get non-existent key + if err = client.WriteArray([]resp.Value{resp.StringValue("GET"), resp.StringValue("test4")}); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !res.IsNull() { + t.Errorf("expected nil, got: %+v", res) + } + + errorTests := []struct { + name string + command []string + expected string + }{ + { + name: "1. Return error when no GET key is passed", + command: []string{"GET"}, + expected: constants.WrongArgsResponse, + }, + { + name: "2. Return error when too many GET keys are passed", + command: []string{"GET", "GetKey1", "test"}, + expected: constants.WrongArgsResponse, + }, + } + for _, test := range errorTests { + t.Run(test.name, func(t *testing.T) { + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.Contains(res.Error().Error(), test.expected) { + t.Errorf("expected error '%s', got: %s", test.expected, err.Error()) + } + }) + } + }) + + t.Run("Test_HandleMGET", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetKeys []string + presetValues []string + command []string + expected []interface{} + expectedError error + }{ + { + name: "1. MGET multiple existing values", + presetKeys: []string{"MgetKey1", "MgetKey2", "MgetKey3", "MgetKey4"}, + presetValues: []string{"value1", "value2", "value3", "value4"}, + command: []string{"MGET", "MgetKey1", "MgetKey4", "MgetKey2", "MgetKey3", "MgetKey1"}, + expected: []interface{}{"value1", "value4", "value2", "value3", "value1"}, + expectedError: nil, + }, + { + name: "2. MGET multiple values with nil values spliced in", + presetKeys: []string{"MgetKey5", "MgetKey6", "MgetKey7"}, + presetValues: []string{"value5", "value6", "value7"}, + command: []string{"MGET", "MgetKey5", "MgetKey6", "non-existent", "non-existent", "MgetKey7", "non-existent"}, + expected: []interface{}{"value5", "value6", nil, nil, "value7", nil}, + expectedError: nil, + }, + { + name: "3. Return error when MGET is invoked with no keys", + presetKeys: []string{"MgetKey5"}, + presetValues: []string{"value5"}, + command: []string{"MGET"}, + expected: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Set up the values + for i, key := range test.presetKeys { + if err = client.WriteArray([]resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(test.presetValues[i]), + }); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be \"OK\", got \"%s\"", res.String()) + } + } + + // Test the command and its results + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + // If we expect and error, branch out and check error + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error %+v, got: %+v", test.expectedError, err) + } + return + } + + if res.Type().String() != "Array" { + t.Errorf("expected type Array, got: %s", res.Type().String()) + } + for i, value := range res.Array() { + if test.expected[i] == nil { + if !value.IsNull() { + t.Errorf("expected nil value, got %+v", value) + } + continue + } + if value.String() != test.expected[i] { + t.Errorf("expected value %s, got: %s", test.expected[i], value.String()) + } + } + }) + } + }) + + t.Run("Test_HandleDEL", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + command []string + presetValues map[string]string + expectedResponse int + expectToExist map[string]bool + expectedErr error + }{ + { + name: "1. Delete multiple keys", + command: []string{"DEL", "DelKey1", "DelKey2", "DelKey3", "DelKey4", "DelKey5"}, + presetValues: map[string]string{ + "DelKey1": "value1", + "DelKey2": "value2", + "DelKey3": "value3", + "DelKey4": "value4", + }, + expectedResponse: 4, + expectToExist: map[string]bool{ + "DelKey1": false, + "DelKey2": false, + "DelKey3": false, + "DelKey4": false, + "DelKey5": false, + }, + expectedErr: nil, + }, + { + name: "2. Return error when DEL is called with no keys", + command: []string{"DEL"}, + presetValues: nil, + expectedResponse: 0, + expectToExist: nil, + expectedErr: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + for k, v := range test.presetValues { + if err = client.WriteArray([]resp.Value{ + resp.StringValue("SET"), + resp.StringValue(k), + resp.StringValue(v), + }); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be \"OK\", got %s", res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedErr != nil { + if !strings.Contains(res.Error().Error(), test.expectedErr.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedErr.Error(), res.Error().Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + + for key, expected := range test.expectToExist { + if err = client.WriteArray([]resp.Value{resp.StringValue("GET"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + exists := !res.IsNull() + if exists != expected { + t.Errorf("expected existence of key %s to be %v, got %v", key, expected, exists) + } + } + }) + } + }) + + t.Run("Test_HandlePERSIST", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + command []string + presetValues map[string]KeyData + expectedResponse int + expectedValues map[string]KeyData + expectedError error + }{ + { + name: "1. Successfully persist a volatile key", + command: []string{"PERSIST", "PersistKey1"}, + presetValues: map[string]KeyData{ + "PersistKey1": {Value: "value1", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "PersistKey1": {Value: "value1", ExpireAt: time.Time{}}, + }, + expectedError: nil, + }, + { + name: "2. Return 0 when trying to persist a non-existent key", + command: []string{"PERSIST", "PersistKey2"}, + presetValues: nil, + expectedResponse: 0, + expectedValues: nil, + expectedError: nil, + }, + { + name: "3. Return 0 when trying to persist a non-volatile key", + command: []string{"PERSIST", "PersistKey3"}, + presetValues: map[string]KeyData{ + "PersistKey3": {Value: "value3", ExpireAt: time.Time{}}, + }, + expectedResponse: 0, + expectedValues: map[string]KeyData{ + "PersistKey3": {Value: "value3", ExpireAt: time.Time{}}, + }, + expectedError: nil, + }, + { + name: "4. Command too short", + command: []string{"PERSIST"}, + presetValues: nil, + expectedResponse: 0, + expectedValues: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Command too long", + command: []string{"PERSIST", "PersistKey5", "key6"}, + presetValues: nil, + expectedResponse: 0, + expectedValues: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + for k, v := range test.presetValues { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(k), resp.StringValue(v.Value.(string))} + if !v.ExpireAt.Equal(time.Time{}) { + command = append(command, []resp.Value{ + resp.StringValue("PX"), + resp.StringValue(fmt.Sprintf("%d", v.ExpireAt.Sub(mockClock.Now()).Milliseconds())), + }...) + } + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + + if test.expectedValues == nil { + return + } + + for key, expected := range test.expectedValues { + // Compare the value of the key with what's expected + if err = client.WriteArray([]resp.Value{resp.StringValue("GET"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + if res.String() != expected.Value.(string) { + t.Errorf("expected value %s, got %s", expected.Value.(string), res.String()) + } + // Compare the expiry of the key with what's expected + if err = client.WriteArray([]resp.Value{resp.StringValue("PTTL"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + if expected.ExpireAt.Equal(time.Time{}) { + if res.Integer() != -1 { + t.Error("expected key to be persisted, it was not.") + } + continue + } + if res.Integer() != int(expected.ExpireAt.UnixMilli()) { + t.Errorf("expected expiry %d, got %d", expected.ExpireAt.UnixMilli(), res.Integer()) + } + } + }) + } + }) + + t.Run("Test_HandleEXPIRETIME", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + command []string + presetValues map[string]KeyData + expectedResponse int + expectedError error + }{ + { + name: "1. Return expire time in seconds", + command: []string{"EXPIRETIME", "ExpireTimeKey1"}, + presetValues: map[string]KeyData{ + "ExpireTimeKey1": {Value: "value1", ExpireAt: mockClock.Now().Add(100 * time.Second)}, + }, + expectedResponse: int(mockClock.Now().Add(100 * time.Second).Unix()), + expectedError: nil, + }, + { + name: "2. Return expire time in milliseconds", + command: []string{"PEXPIRETIME", "ExpireTimeKey2"}, + presetValues: map[string]KeyData{ + "ExpireTimeKey2": {Value: "value2", ExpireAt: mockClock.Now().Add(4096 * time.Millisecond)}, + }, + expectedResponse: int(mockClock.Now().Add(4096 * time.Millisecond).UnixMilli()), + expectedError: nil, + }, + { + name: "3. If the key is non-volatile, return -1", + command: []string{"PEXPIRETIME", "ExpireTimeKey3"}, + presetValues: map[string]KeyData{ + "ExpireTimeKey3": {Value: "value3", ExpireAt: time.Time{}}, + }, + expectedResponse: -1, + expectedError: nil, + }, + { + name: "4. If the key is non-existent return -2", + command: []string{"PEXPIRETIME", "ExpireTimeKey4"}, + presetValues: nil, + expectedResponse: -2, + expectedError: nil, + }, + { + name: "5. Command too short", + command: []string{"PEXPIRETIME"}, + presetValues: nil, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Command too long", + command: []string{"PEXPIRETIME", "ExpireTimeKey5", "ExpireTimeKey6"}, + presetValues: nil, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + for k, v := range test.presetValues { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(k), resp.StringValue(v.Value.(string))} + if !v.ExpireAt.Equal(time.Time{}) { + command = append(command, []resp.Value{ + resp.StringValue("PX"), + resp.StringValue(fmt.Sprintf("%d", v.ExpireAt.Sub(mockClock.Now()).Milliseconds())), + }...) + } + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleTTL", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + command []string + presetValues map[string]KeyData + expectedResponse int + expectedError error + }{ + { + name: "1. Return TTL time in seconds", + command: []string{"TTL", "TTLKey1"}, + presetValues: map[string]KeyData{ + "TTLKey1": {Value: "value1", ExpireAt: mockClock.Now().Add(100 * time.Second)}, + }, + expectedResponse: 100, + expectedError: nil, + }, + { + name: "2. Return TTL time in milliseconds", + command: []string{"PTTL", "TTLKey2"}, + presetValues: map[string]KeyData{ + "TTLKey2": {Value: "value2", ExpireAt: mockClock.Now().Add(4096 * time.Millisecond)}, + }, + expectedResponse: 4096, + expectedError: nil, + }, + { + name: "3. If the key is non-volatile, return -1", + command: []string{"TTL", "TTLKey3"}, + presetValues: map[string]KeyData{ + "TTLKey3": {Value: "value3", ExpireAt: time.Time{}}, + }, + expectedResponse: -1, + expectedError: nil, + }, + { + name: "4. If the key is non-existent return -2", + command: []string{"TTL", "TTLKey4"}, + presetValues: nil, + expectedResponse: -2, + expectedError: nil, + }, + { + name: "5. Command too short", + command: []string{"TTL"}, + presetValues: nil, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Command too long", + command: []string{"TTL", "TTLKey5", "TTLKey6"}, + presetValues: nil, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + for k, v := range test.presetValues { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(k), resp.StringValue(v.Value.(string))} + if !v.ExpireAt.Equal(time.Time{}) { + command = append(command, []resp.Value{ + resp.StringValue("PX"), + resp.StringValue(fmt.Sprintf("%d", v.ExpireAt.Sub(mockClock.Now()).Milliseconds())), + }...) + } + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleEXPIRE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + command []string + presetValues map[string]KeyData + expectedResponse int + expectedValues map[string]KeyData + expectedError error + }{ + { + name: "1. Set new expire by seconds", + command: []string{"EXPIRE", "ExpireKey1", "100"}, + presetValues: map[string]KeyData{ + "ExpireKey1": {Value: "value1", ExpireAt: time.Time{}}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireKey1": {Value: "value1", ExpireAt: mockClock.Now().Add(100 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "2. Set new expire by milliseconds", + command: []string{"PEXPIRE", "ExpireKey2", "1000"}, + presetValues: map[string]KeyData{ + "ExpireKey2": {Value: "value2", ExpireAt: time.Time{}}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireKey2": {Value: "value2", ExpireAt: mockClock.Now().Add(1000 * time.Millisecond)}, + }, + expectedError: nil, + }, + { + name: "3. Set new expire only when key does not have an expiry time with NX flag", + command: []string{"EXPIRE", "ExpireKey3", "1000", "NX"}, + presetValues: map[string]KeyData{ + "ExpireKey3": {Value: "value3", ExpireAt: time.Time{}}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireKey3": {Value: "value3", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "4. Return 0, when NX flag is provided and key already has an expiry time", + command: []string{"EXPIRE", "ExpireKey4", "1000", "NX"}, + presetValues: map[string]KeyData{ + "ExpireKey4": {Value: "value4", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + expectedResponse: 0, + expectedValues: map[string]KeyData{ + "ExpireKey4": {Value: "value4", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "5. Set new expire time from now key only when the key already has an expiry time with XX flag", + command: []string{"EXPIRE", "ExpireKey5", "1000", "XX"}, + presetValues: map[string]KeyData{ + "ExpireKey5": {Value: "value5", ExpireAt: mockClock.Now().Add(30 * time.Second)}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireKey5": {Value: "value5", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "6. Return 0 when key does not have an expiry and the XX flag is provided", + command: []string{"EXPIRE", "ExpireKey6", "1000", "XX"}, + presetValues: map[string]KeyData{ + "ExpireKey6": {Value: "value6", ExpireAt: time.Time{}}, + }, + expectedResponse: 0, + expectedValues: map[string]KeyData{ + "ExpireKey6": {Value: "value6", ExpireAt: time.Time{}}, + }, + expectedError: nil, + }, + { + name: "7. Set expiry time when the provided time is after the current expiry time when GT flag is provided", + command: []string{"EXPIRE", "ExpireKey7", "1000", "GT"}, + presetValues: map[string]KeyData{ + "ExpireKey7": {Value: "value7", ExpireAt: mockClock.Now().Add(30 * time.Second)}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireKey7": {Value: "value7", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "8. Return 0 when GT flag is passed and current expiry time is greater than provided time", + command: []string{"EXPIRE", "ExpireKey8", "1000", "GT"}, + presetValues: map[string]KeyData{ + "ExpireKey8": {Value: "value8", ExpireAt: mockClock.Now().Add(3000 * time.Second)}, + }, + expectedResponse: 0, + expectedValues: map[string]KeyData{ + "ExpireKey8": {Value: "value8", ExpireAt: mockClock.Now().Add(3000 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "9. Return 0 when GT flag is passed and key does not have an expiry time", + command: []string{"EXPIRE", "ExpireKey9", "1000", "GT"}, + presetValues: map[string]KeyData{ + "ExpireKey9": {Value: "value9", ExpireAt: time.Time{}}, + }, + expectedResponse: 0, + expectedValues: map[string]KeyData{ + "ExpireKey9": {Value: "value9", ExpireAt: time.Time{}}, + }, + expectedError: nil, + }, + { + name: "10. Set expiry time when the provided time is before the current expiry time when LT flag is provided", + command: []string{"EXPIRE", "ExpireKey10", "1000", "LT"}, + presetValues: map[string]KeyData{ + "ExpireKey10": {Value: "value10", ExpireAt: mockClock.Now().Add(3000 * time.Second)}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireKey10": {Value: "value10", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "11. Return 0 when LT flag is passed and current expiry time is less than provided time", + command: []string{"EXPIRE", "ExpireKey11", "5000", "LT"}, + presetValues: map[string]KeyData{ + "ExpireKey11": {Value: "value11", ExpireAt: mockClock.Now().Add(3000 * time.Second)}, + }, + expectedResponse: 0, + expectedValues: map[string]KeyData{ + "ExpireKey11": {Value: "value11", ExpireAt: mockClock.Now().Add(3000 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "12. Return 0 when LT flag is passed and key does not have an expiry time", + command: []string{"EXPIRE", "ExpireKey12", "1000", "LT"}, + presetValues: map[string]KeyData{ + "ExpireKey12": {Value: "value12", ExpireAt: time.Time{}}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireKey12": {Value: "value12", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "13. Return error when unknown flag is passed", + command: []string{"EXPIRE", "ExpireKey13", "1000", "UNKNOWN"}, + presetValues: map[string]KeyData{ + "ExpireKey13": {Value: "value13", ExpireAt: time.Time{}}, + }, + expectedResponse: 0, + expectedValues: nil, + expectedError: errors.New("unknown option UNKNOWN"), + }, + { + name: "14. Return error when expire time is not a valid integer", + command: []string{"EXPIRE", "ExpireKey14", "expire"}, + presetValues: nil, + expectedResponse: 0, + expectedValues: nil, + expectedError: errors.New("expire time must be integer"), + }, + { + name: "15. Command too short", + command: []string{"EXPIRE"}, + presetValues: nil, + expectedResponse: 0, + expectedValues: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "16. Command too long", + command: []string{"EXPIRE", "ExpireKey16", "10", "NX", "GT"}, + presetValues: nil, + expectedResponse: 0, + expectedValues: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + for k, v := range test.presetValues { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(k), resp.StringValue(v.Value.(string))} + if !v.ExpireAt.Equal(time.Time{}) { + command = append(command, []resp.Value{ + resp.StringValue("PX"), + resp.StringValue(fmt.Sprintf("%d", v.ExpireAt.Sub(mockClock.Now()).Milliseconds())), + }...) + } + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + + if test.expectedValues == nil { + return + } + + for key, expected := range test.expectedValues { + // Compare the value of the key with what's expected + if err = client.WriteArray([]resp.Value{resp.StringValue("GET"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + if res.String() != expected.Value.(string) { + t.Errorf("expected value %s, got %s", expected.Value.(string), res.String()) + } + // Compare the expiry of the key with what's expected + if err = client.WriteArray([]resp.Value{resp.StringValue("PTTL"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + if expected.ExpireAt.Equal(time.Time{}) { + if res.Integer() != -1 { + t.Error("expected key to be persisted, it was not.") + } + continue + } + if res.Integer() != int(expected.ExpireAt.Sub(mockClock.Now()).Milliseconds()) { + t.Errorf("expected expiry %d, got %d", expected.ExpireAt.Sub(mockClock.Now()).Milliseconds(), res.Integer()) + } + } + }) + } + }) + + t.Run("Test_HandleEXPIREAT", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + command []string + presetValues map[string]KeyData + expectedResponse int + expectedValues map[string]KeyData + expectedError error + }{ + { + name: "1. Set new expire by unix seconds", + command: []string{"EXPIREAT", "ExpireAtKey1", fmt.Sprintf("%d", mockClock.Now().Add(1000*time.Second).Unix())}, + presetValues: map[string]KeyData{ + "ExpireAtKey1": {Value: "value1", ExpireAt: time.Time{}}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireAtKey1": {Value: "value1", ExpireAt: time.Unix(mockClock.Now().Add(1000*time.Second).Unix(), 0)}, + }, + expectedError: nil, + }, + { + name: "2. Set new expire by milliseconds", + command: []string{"PEXPIREAT", "ExpireAtKey2", fmt.Sprintf("%d", mockClock.Now().Add(1000*time.Second).UnixMilli())}, + presetValues: map[string]KeyData{ + "ExpireAtKey2": {Value: "value2", ExpireAt: time.Time{}}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireAtKey2": {Value: "value2", ExpireAt: time.UnixMilli(mockClock.Now().Add(1000 * time.Second).UnixMilli())}, + }, + expectedError: nil, + }, + { + name: "3. Set new expire only when key does not have an expiry time with NX flag", + command: []string{"EXPIREAT", "ExpireAtKey3", fmt.Sprintf("%d", mockClock.Now().Add(1000*time.Second).Unix()), "NX"}, + presetValues: map[string]KeyData{ + "ExpireAtKey3": {Value: "value3", ExpireAt: time.Time{}}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireAtKey3": {Value: "value3", ExpireAt: time.Unix(mockClock.Now().Add(1000*time.Second).Unix(), 0)}, + }, + expectedError: nil, + }, + { + name: "4. Return 0, when NX flag is provided and key already has an expiry time", + command: []string{"EXPIREAT", "ExpireAtKey4", fmt.Sprintf("%d", mockClock.Now().Add(1000*time.Second).Unix()), "NX"}, + presetValues: map[string]KeyData{ + "ExpireAtKey4": {Value: "value4", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + expectedResponse: 0, + expectedValues: map[string]KeyData{ + "ExpireAtKey4": {Value: "value4", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "5. Set new expire time from now key only when the key already has an expiry time with XX flag", + command: []string{ + "EXPIREAT", "ExpireAtKey5", + fmt.Sprintf("%d", mockClock.Now().Add(1000*time.Second).Unix()), "XX", + }, + presetValues: map[string]KeyData{ + "ExpireAtKey5": {Value: "value5", ExpireAt: mockClock.Now().Add(30 * time.Second)}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireAtKey5": {Value: "value5", ExpireAt: time.Unix(mockClock.Now().Add(1000*time.Second).Unix(), 0)}, + }, + expectedError: nil, + }, + { + name: "6. Return 0 when key does not have an expiry and the XX flag is provided", + command: []string{ + "EXPIREAT", "ExpireAtKey6", + fmt.Sprintf("%d", mockClock.Now().Add(1000*time.Second).Unix()), "XX", + }, + presetValues: map[string]KeyData{ + "ExpireAtKey6": {Value: "value6", ExpireAt: time.Time{}}, + }, + expectedResponse: 0, + expectedValues: map[string]KeyData{ + "ExpireAtKey6": {Value: "value6", ExpireAt: time.Time{}}, + }, + expectedError: nil, + }, + { + name: "7. Set expiry time when the provided time is after the current expiry time when GT flag is provided", + command: []string{ + "EXPIREAT", "ExpireAtKey7", + fmt.Sprintf("%d", mockClock.Now().Add(1000*time.Second).Unix()), "GT", + }, + presetValues: map[string]KeyData{ + "ExpireAtKey7": {Value: "value7", ExpireAt: mockClock.Now().Add(30 * time.Second)}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireAtKey7": {Value: "value7", ExpireAt: time.Unix(mockClock.Now().Add(1000*time.Second).Unix(), 0)}, + }, + expectedError: nil, + }, + { + name: "8. Return 0 when GT flag is passed and current expiry time is greater than provided time", + command: []string{ + "EXPIREAT", "ExpireAtKey8", + fmt.Sprintf("%d", mockClock.Now().Add(1000*time.Second).Unix()), "GT", + }, + presetValues: map[string]KeyData{ + "ExpireAtKey8": {Value: "value8", ExpireAt: mockClock.Now().Add(3000 * time.Second)}, + }, + expectedResponse: 0, + expectedValues: map[string]KeyData{ + "ExpireAtKey8": {Value: "value8", ExpireAt: mockClock.Now().Add(3000 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "9. Return 0 when GT flag is passed and key does not have an expiry time", + command: []string{ + "EXPIREAT", "ExpireAtKey9", + fmt.Sprintf("%d", mockClock.Now().Add(1000*time.Second).Unix()), "GT", + }, + presetValues: map[string]KeyData{ + "ExpireAtKey9": {Value: "value9", ExpireAt: time.Time{}}, + }, + expectedResponse: 0, + expectedValues: map[string]KeyData{ + "ExpireAtKey9": {Value: "value9", ExpireAt: time.Time{}}, + }, + expectedError: nil, + }, + { + name: "10. Set expiry time when the provided time is before the current expiry time when LT flag is provided", + command: []string{ + "EXPIREAT", "ExpireAtKey10", + fmt.Sprintf("%d", mockClock.Now().Add(1000*time.Second).Unix()), "LT", + }, + presetValues: map[string]KeyData{ + "ExpireAtKey10": {Value: "value10", ExpireAt: mockClock.Now().Add(3000 * time.Second)}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireAtKey10": {Value: "value10", ExpireAt: time.Unix(mockClock.Now().Add(1000*time.Second).Unix(), 0)}, + }, + expectedError: nil, + }, + { + name: "11. Return 0 when LT flag is passed and current expiry time is less than provided time", + command: []string{ + "EXPIREAT", "ExpireAtKey11", + fmt.Sprintf("%d", mockClock.Now().Add(3000*time.Second).Unix()), "LT", + }, + presetValues: map[string]KeyData{ + "ExpireAtKey11": {Value: "value11", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + expectedResponse: 0, + expectedValues: map[string]KeyData{ + "ExpireAtKey11": {Value: "value11", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "12. Return 0 when LT flag is passed and key does not have an expiry time", + command: []string{ + "EXPIREAT", "ExpireAtKey12", + fmt.Sprintf("%d", mockClock.Now().Add(1000*time.Second).Unix()), "LT", + }, + presetValues: map[string]KeyData{ + "ExpireAtKey12": {Value: "value12", ExpireAt: time.Time{}}, + }, + expectedResponse: 1, + expectedValues: map[string]KeyData{ + "ExpireAtKey12": {Value: "value12", ExpireAt: time.Unix(mockClock.Now().Add(1000*time.Second).Unix(), 0)}, + }, + expectedError: nil, + }, + { + name: "13. Return error when unknown flag is passed", + command: []string{"EXPIREAT", "ExpireAtKey13", "1000", "UNKNOWN"}, + presetValues: map[string]KeyData{ + "ExpireAtKey13": {Value: "value13", ExpireAt: time.Time{}}, + }, + expectedResponse: 0, + expectedValues: nil, + expectedError: errors.New("unknown option UNKNOWN"), + }, + { + name: "14. Return error when expire time is not a valid integer", + command: []string{"EXPIREAT", "ExpireAtKey14", "expire"}, + presetValues: nil, + expectedResponse: 0, + expectedValues: nil, + expectedError: errors.New("expire time must be integer"), + }, + { + name: "15. Command too short", + command: []string{"EXPIREAT"}, + presetValues: nil, + expectedResponse: 0, + expectedValues: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "16. Command too long", + command: []string{"EXPIREAT", "ExpireAtKey16", "10", "NX", "GT"}, + presetValues: nil, + expectedResponse: 0, + expectedValues: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + for k, v := range test.presetValues { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(k), resp.StringValue(v.Value.(string))} + if !v.ExpireAt.Equal(time.Time{}) { + command = append(command, []resp.Value{ + resp.StringValue("PX"), + resp.StringValue(fmt.Sprintf("%d", v.ExpireAt.Sub(mockClock.Now()).Milliseconds())), + }...) + } + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + + if test.expectedValues == nil { + return + } + + for key, expected := range test.expectedValues { + // Compare the value of the key with what's expected + if err = client.WriteArray([]resp.Value{resp.StringValue("GET"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + if res.String() != expected.Value.(string) { + t.Errorf("expected value %s, got %s", expected.Value.(string), res.String()) + } + // Compare the expiry of the key with what's expected + if err = client.WriteArray([]resp.Value{resp.StringValue("PTTL"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + if expected.ExpireAt.Equal(time.Time{}) { + if res.Integer() != -1 { + t.Error("expected key to be persisted, it was not.") + } + continue + } + if res.Integer() != int(expected.ExpireAt.Sub(mockClock.Now()).Milliseconds()) { + t.Errorf("expected expiry %d, got %d", expected.ExpireAt.Sub(mockClock.Now()).Milliseconds(), res.Integer()) + } + } + }) + } + }) + + t.Run("Test_HandlerINCR", func(t *testing.T) { + t.Parallel() + + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []resp.Value + expectedResponse int64 + expectedError error + }{ + { + name: "1. Increment non-existent key", + key: "IncrKey1", + presetValue: nil, + command: []resp.Value{resp.StringValue("INCR"), resp.StringValue("IncrKey1")}, + expectedResponse: 1, + expectedError: nil, + }, + { + name: "2. Increment existing key with integer value", + key: "IncrKey2", + presetValue: "5", + command: []resp.Value{resp.StringValue("INCR"), resp.StringValue("IncrKey2")}, + expectedResponse: 6, + expectedError: nil, + }, + { + name: "3. Increment existing key with non-integer value", + key: "IncrKey3", + presetValue: "not_an_int", + command: []resp.Value{resp.StringValue("INCR"), resp.StringValue("IncrKey3")}, + expectedResponse: 0, + expectedError: errors.New("value is not an integer or out of range"), + }, + { + name: "4. Increment existing key with int64 value", + key: "IncrKey4", + presetValue: int64(10), + command: []resp.Value{resp.StringValue("INCR"), resp.StringValue("IncrKey4")}, + expectedResponse: 11, + expectedError: nil, + }, + { + name: "5. Command too short", + key: "IncrKey5", + presetValue: nil, + command: []resp.Value{resp.StringValue("INCR")}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Command too long", + key: "IncrKey6", + presetValue: nil, + command: []resp.Value{ + resp.StringValue("INCR"), + resp.StringValue("IncrKey6"), + resp.StringValue("IncrKey6"), + }, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(test.key), resp.StringValue(fmt.Sprintf("%v", test.presetValue))} + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + + if err = client.WriteArray(test.command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if err != nil { + t.Error(err) + } else { + responseInt, err := strconv.ParseInt(res.String(), 10, 64) + if err != nil { + t.Errorf("error parsing response to int64: %s", err) + } + if responseInt != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, responseInt) + } + } + }) + } + }) + + t.Run("Test_HandlerDECR", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []resp.Value + expectedResponse int64 + expectedError error + }{ + { + name: "1. Increment non-existent key", + key: "DecrKey1", + presetValue: nil, + command: []resp.Value{resp.StringValue("DECR"), resp.StringValue("DecrKey1")}, + expectedResponse: -1, + expectedError: nil, + }, + { + name: "2. Decrement existing key with integer value", + key: "DecrKey2", + presetValue: "5", + command: []resp.Value{resp.StringValue("DECR"), resp.StringValue("DecrKey2")}, + expectedResponse: 4, + expectedError: nil, + }, + { + name: "3. Decrement existing key with non-integer value", + key: "DecrKey3", + presetValue: "not_an_int", + command: []resp.Value{resp.StringValue("DECR"), resp.StringValue("DecrKey3")}, + expectedResponse: 0, + expectedError: errors.New("value is not an integer or out of range"), + }, + { + name: "4. Decrement existing key with int64 value", + key: "DecrKey4", + presetValue: int64(10), + command: []resp.Value{resp.StringValue("DECR"), resp.StringValue("DecrKey4")}, + expectedResponse: 9, + expectedError: nil, + }, + { + name: "5. Command too short", + key: "DencrKey5", + presetValue: nil, + command: []resp.Value{resp.StringValue("DECR")}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Command too long", + key: "DecrKey6", + presetValue: nil, + command: []resp.Value{ + resp.StringValue("DECR"), + resp.StringValue("DecrKey6"), + resp.StringValue("DecrKey6"), + }, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(test.key), resp.StringValue(fmt.Sprintf("%v", test.presetValue))} + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + + if err = client.WriteArray(test.command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if err != nil { + t.Error(err) + } else { + responseInt, err := strconv.ParseInt(res.String(), 10, 64) + if err != nil { + t.Errorf("error parsing response to int64: %s", err) + } + if responseInt != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, responseInt) + } + } + }) + } + }) + + t.Run("Test_HandlerINCRBY", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + increment string + presetValue interface{} + command []resp.Value + expectedResponse int64 + expectedError error + }{ + { + name: "1. Increment non-existent key by 4", + key: "IncrByKey1", + increment: "4", + presetValue: nil, + command: []resp.Value{resp.StringValue("INCRBY"), resp.StringValue("IncrByKey1"), resp.StringValue("4")}, + expectedResponse: 4, + expectedError: nil, + }, + { + name: "2. Increment existing key with integer value by 3", + key: "IncrByKey2", + increment: "3", + presetValue: "5", + command: []resp.Value{resp.StringValue("INCRBY"), resp.StringValue("IncrByKey2"), resp.StringValue("3")}, + expectedResponse: 8, + expectedError: nil, + }, + { + name: "3. Increment existing key with non-integer value by 2", + key: "IncrByKey3", + increment: "2", + presetValue: "not_an_int", + command: []resp.Value{resp.StringValue("INCRBY"), resp.StringValue("IncrByKey3"), resp.StringValue("2")}, + expectedResponse: 0, + expectedError: errors.New("value is not an integer or out of range"), + }, + { + name: "4. Increment existing key with int64 value by 7", + key: "IncrByKey4", + increment: "7", + presetValue: int64(10), + command: []resp.Value{resp.StringValue("INCRBY"), resp.StringValue("IncrByKey4"), resp.StringValue("7")}, + expectedResponse: 17, + expectedError: nil, + }, + { + name: "5. Command too short", + key: "IncrByKey5", + increment: "5", + presetValue: nil, + command: []resp.Value{resp.StringValue("INCRBY"), resp.StringValue("IncrByKey5")}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Command too long", + key: "IncrByKey6", + increment: "5", + presetValue: nil, + command: []resp.Value{ + resp.StringValue("INCRBY"), + resp.StringValue("IncrByKey6"), + resp.StringValue("5"), + resp.StringValue("extra_arg"), + }, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(test.key), resp.StringValue(fmt.Sprintf("%v", test.presetValue))} + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "OK") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + + if err = client.WriteArray(test.command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if err != nil { + t.Error(err) + } else { + responseInt, err := strconv.ParseInt(res.String(), 10, 64) + if err != nil { + t.Errorf("error parsing response to int64: %s", err) + } + if responseInt != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, responseInt) + } + } + }) + } + }) + + t.Run("Test_HandlerINCRBYFLOAT", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + increment string + presetValue interface{} + command []resp.Value + expectedResponse float64 + expectedError error + }{ + { + name: "1. Increment non-existent key by 2.5", + key: "IncrByFloatKey1", + increment: "2.5", + presetValue: nil, + command: []resp.Value{resp.StringValue("INCRBYFLOAT"), resp.StringValue("IncrByFloatKey1"), resp.StringValue("2.5")}, + expectedResponse: 2.5, + expectedError: nil, + }, + { + name: "2. Increment existing key with integer value by 1.2", + key: "IncrByFloatKey2", + increment: "1.2", + presetValue: "5", + command: []resp.Value{resp.StringValue("INCRBYFLOAT"), resp.StringValue("IncrByFloatKey2"), resp.StringValue("1.2")}, + expectedResponse: 6.2, + expectedError: nil, + }, + { + name: "3. Increment existing key with float value by 0.7", + key: "IncrByFloatKey4", + increment: "0.7", + presetValue: "10.0", + command: []resp.Value{resp.StringValue("INCRBYFLOAT"), resp.StringValue("IncrByFloatKey4"), resp.StringValue("0.7")}, + expectedResponse: 10.7, + expectedError: nil, + }, + { + name: "4. Command too short", + key: "IncrByFloatKey5", + increment: "5", + presetValue: nil, + command: []resp.Value{resp.StringValue("INCRBYFLOAT"), resp.StringValue("IncrByFloatKey5")}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Command too long", + key: "IncrByFloatKey6", + increment: "5", + presetValue: nil, + command: []resp.Value{ + resp.StringValue("INCRBYFLOAT"), + resp.StringValue("IncrByFloatKey6"), + resp.StringValue("5"), + resp.StringValue("extra_arg"), + }, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(test.key), resp.StringValue(fmt.Sprintf("%v", test.presetValue))} + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "OK") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + + if err = client.WriteArray(test.command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error()) + } + return + } + + if err != nil { + t.Error(err) + } else { + responseFloat, err := strconv.ParseFloat(res.String(), 64) + if err != nil { + t.Errorf("error parsing response to float64: %s", err) + } + if responseFloat != test.expectedResponse { + t.Errorf("expected response %f, got %f", test.expectedResponse, responseFloat) + } + } + }) + } + }) + + t.Run("Test_HandleEXISTS", func(t *testing.T) { + t.Parallel() + + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetKeys map[string]string + checkKeys []string + expectedResponse int + }{ + { + name: "1. Key doesn't exist", + presetKeys: map[string]string{}, + checkKeys: []string{"nonExistentKey"}, + expectedResponse: 0, + }, + { + name: "2. Key exists", + presetKeys: map[string]string{"existentKey": "value"}, + checkKeys: []string{"existentKey"}, + expectedResponse: 1, + }, + { + name: "3. All keys exist", + presetKeys: map[string]string{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + }, + checkKeys: []string{"key1", "key2", "key3"}, + expectedResponse: 3, + }, + { + name: "4. Only some keys exist", + presetKeys: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + checkKeys: []string{"key1", "key2", "nonExistentKey"}, + expectedResponse: 2, + }, + { + name: "5. All keys exist with duplicates", + presetKeys: map[string]string{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + }, + checkKeys: []string{"key1", "key2", "key3", "key1", "key2"}, + expectedResponse: 5, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Preset keys + for key, value := range test.presetKeys { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(key), resp.StringValue(value)} + if err = client.WriteArray(command); err != nil { + t.Error(err) + return + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + if !strings.EqualFold(res.String(), "OK") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + return + } + } + + // Check EXISTS command + existsCommand := []resp.Value{resp.StringValue("EXISTS")} + for _, key := range test.checkKeys { + existsCommand = append(existsCommand, resp.StringValue(key)) + } + + if err = client.WriteArray(existsCommand); err != nil { + t.Error(err) + return + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + + actualCount, err := strconv.Atoi(res.String()) + if err != nil { + t.Errorf("error parsing response to int: %s", err) + return + } + + if actualCount != test.expectedResponse { + t.Errorf("expected %d existing keys, got %d", test.expectedResponse, actualCount) + } + }) + } + }) + + t.Run("Test_HandlerDECRBY", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + decrement string + presetValue interface{} + command []resp.Value + expectedResponse int64 + expectedError error + }{ + { + name: "1. Decrement non-existent key by 4", + key: "DecrByKey1", + decrement: "4", + presetValue: nil, + command: []resp.Value{resp.StringValue("DECRBY"), resp.StringValue("DecrByKey1"), resp.StringValue("4")}, + expectedResponse: -4, + expectedError: nil, + }, + { + name: "2. Decrement existing key with integer value by 3", + key: "DecrByKey2", + decrement: "3", + presetValue: "5", + command: []resp.Value{resp.StringValue("DECRBY"), resp.StringValue("DecrByKey2"), resp.StringValue("3")}, + expectedResponse: 2, + expectedError: nil, + }, + { + name: "3. Decrement existing key with non-integer value by 2", + key: "DecrByKey3", + decrement: "2", + presetValue: "not_an_int", + command: []resp.Value{resp.StringValue("DECRBY"), resp.StringValue("DecrByKey3"), resp.StringValue("2")}, + expectedResponse: 0, + expectedError: errors.New("value is not an integer or out of range"), + }, + { + name: "4. Decrement existing key with int64 value by 7", + key: "DecrByKey4", + decrement: "7", + presetValue: int64(10), + command: []resp.Value{resp.StringValue("DECRBY"), resp.StringValue("DecrByKey4"), resp.StringValue("7")}, + expectedResponse: 3, + expectedError: nil, + }, + { + name: "5. Command too short", + key: "DecrByKey5", + presetValue: nil, + command: []resp.Value{resp.StringValue("DECRBY"), resp.StringValue("DecrByKey5")}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Command too long", + key: "DecrKey6", + presetValue: nil, + command: []resp.Value{ + resp.StringValue("DECRBY"), + resp.StringValue("DecrKey6"), + resp.StringValue("3"), + resp.StringValue("extra_arg"), + }, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(test.key), resp.StringValue(fmt.Sprintf("%v", test.presetValue))} + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "OK") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + + if err = client.WriteArray(test.command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if err != nil { + t.Error(err) + } else { + responseInt, err := strconv.ParseInt(res.String(), 10, 64) + if err != nil { + t.Errorf("error parsing response to int64: %s", err) + } + if responseInt != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, responseInt) + } + } + }) + } + }) + + t.Run("Test_HandlerRENAME", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + oldKey string + newKey string + presetValue interface{} + command []resp.Value + expectedResponse string + expectedError error + }{ + { + name: "1. Rename existing key", + oldKey: "oldKey1", + newKey: "newKey1", + presetValue: "value1", + command: []resp.Value{resp.StringValue("RENAME"), resp.StringValue("oldKey1"), resp.StringValue("newKey1")}, + expectedResponse: "OK", + expectedError: nil, + }, + { + name: "2. Rename non-existent key", + oldKey: "oldKey2", + newKey: "newKey2", + presetValue: nil, + command: []resp.Value{resp.StringValue("RENAME"), resp.StringValue("oldKey2"), resp.StringValue("newKey2")}, + expectedResponse: "", + expectedError: errors.New("no such key"), + }, + { + name: "3. Rename to existing key", + oldKey: "oldKey3", + newKey: "newKey3", + presetValue: "value3", + command: []resp.Value{resp.StringValue("RENAME"), resp.StringValue("oldKey3"), resp.StringValue("newKey3")}, + expectedResponse: "OK", + expectedError: nil, + }, + { + name: "4. Command too short", + command: []resp.Value{resp.StringValue("RENAME"), resp.StringValue("key")}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Command too long", + command: []resp.Value{ + resp.StringValue("RENAME"), + resp.StringValue("key"), + resp.StringValue("newkey"), + resp.StringValue("extra_arg"), + }, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(test.oldKey), resp.StringValue(fmt.Sprintf("%v", test.presetValue))} + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "OK") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + + if err = client.WriteArray(test.command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if err != nil { + t.Error(err) + } else { + if res.String() != test.expectedResponse { + t.Errorf("expected response \"%s\", got \"%s\"", test.expectedResponse, res.String()) + } + } + }) + } + }) + + t.Run("Test_HandlerRENAMENX", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + oldKey string + newKey string + presetValue interface{} + command []resp.Value + expectedResponse string + expectedError error + }{ + { + name: "1. Rename existing key", + oldKey: "renamenxOldKey1", + newKey: "renamenxNewKey1", + presetValue: "value1", + command: []resp.Value{resp.StringValue("RENAMENX"), resp.StringValue("renamenxOldKey1"), resp.StringValue("renamenxNewKey1")}, + expectedResponse: "OK", + expectedError: nil, + }, + { + name: "2. Rename non-existent key", + oldKey: "renamenxOldKey2", + newKey: "renamenxNewKey2", + presetValue: nil, + command: []resp.Value{resp.StringValue("RENAMENX"), resp.StringValue("renamenxOldKey2"), resp.StringValue("renamenxNewKey2")}, + expectedResponse: "", + expectedError: errors.New("no such key"), + }, + { + name: "3. Rename to existing key", + oldKey: "renamenxOldKey3", + newKey: "renamenxNewKey1", + presetValue: "value3", + command: []resp.Value{resp.StringValue("RENAMENX"), resp.StringValue("renamenxOldKey3"), resp.StringValue("renamenxNewKey1")}, + expectedResponse: "", + expectedError: errors.New("key renamenxNewKey1 already exists"), + }, + { + name: "4. Command too short", + command: []resp.Value{resp.StringValue("RENAMENX"), resp.StringValue("key")}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Command too long", + command: []resp.Value{ + resp.StringValue("RENAMENX"), + resp.StringValue("key"), + resp.StringValue("newkey"), + resp.StringValue("extra_arg"), + }, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(test.oldKey), resp.StringValue(fmt.Sprintf("%v", test.presetValue))} + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "OK") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + + if err = client.WriteArray(test.command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if err != nil { + t.Error(err) + } else { + if res.String() != test.expectedResponse { + t.Errorf("expected response \"%s\", got \"%s\"", test.expectedResponse, res.String()) + } + } + }) + } + }) + + t.Run("Test_HandleFlush", func(t *testing.T) { + t.Parallel() + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + mockServer, err := sugardb.NewSugarDB( + sugardb.WithConfig(config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.NoEviction, + }), + ) + if err != nil { + t.Error(err) + return + } + go func() { + mockServer.Start() + }() + t.Cleanup(func() { + mockServer.ShutDown() + }) + + noOfDBs := 4 + + // Set values for 3 different databases. + for i := 0; i < noOfDBs; i++ { + _ = mockServer.SelectDB(i) + for k := 1; k <= 3; k++ { + _, _, _ = mockServer.Set( + fmt.Sprintf("key%d", k), + fmt.Sprintf("value%d", k), + sugardb.SETOptions{}, + ) + } + } + + // Connect to the server + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client := resp.NewConn(conn) + + // Send FLUSHDB command. + // This should flush database 0 as it's the default database. + if err = client.WriteArray([]resp.Value{resp.StringValue("FLUSHDB")}); err != nil { + t.Error(err) + return + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + return + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected OK response, got \"%s\"", res.String()) + return + } + + // Check that database 0 is cleared. + _ = mockServer.SelectDB(0) + for i := 1; i <= 3; i++ { + key := fmt.Sprintf("key%d", i) + val, err := mockServer.Get(key) + if err != nil { + t.Error(err) + return + } + if val != "" { + t.Errorf("expected key %s to be empty, got \"%s\"", key, val) + return + } + } + + // Check that all the other databases still have their values. + for i := 1; i < noOfDBs; i++ { + _ = mockServer.SelectDB(i) + for k := 1; k < 3; k++ { + key := fmt.Sprintf("key%d", k) + value := fmt.Sprintf("value%d", k) + val, err := mockServer.Get(key) + if err != nil { + t.Error(err) + return + } + if val != value { + t.Errorf("expected value for key %s to be \"%s\", got \"%s\"", key, value, val) + return + } + } + } + + // Sent FLUSHALL command. + // This should flush all the databases. + if err = client.WriteArray([]resp.Value{resp.StringValue("FLUSHALL")}); err != nil { + t.Error(err) + return + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + return + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected OK response, got \"%s\"", res.String()) + return + } + + // Check that all the databases are cleared. + for i := 0; i < noOfDBs; i++ { + _ = mockServer.SelectDB(i) + for k := 1; k < 3; k++ { + key := fmt.Sprintf("key%d", k) + val, err := mockServer.Get(key) + if err != nil { + t.Error(err) + return + } + if val != "" { + t.Errorf("expected empty string at key %s, got \"%s\"", key, val) + return + } + } + } + }) + + t.Run("Test_HandleRANDOMKEY", func(t *testing.T) { + t.Parallel() + + // Populate the store with keys first + for i := 0; i < 10; i++ { + _, _, err := mockServer.Set( + fmt.Sprintf("RandomKey%d", i), + fmt.Sprintf("Value%d", i), + sugardb.SETOptions{}, + ) + if err != nil { + t.Error(err) + return + } + } + + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + expected := "key" + if err = client.WriteArray([]resp.Value{resp.StringValue("RANDOMKEY")}); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.Contains(strings.ToLower(res.String()), expected) { + t.Errorf("expected a key containing substring '%s', got %s", expected, res.String()) + } + }) + + t.Run("Test_HandleDBSIZE", func(t *testing.T) { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + // Populate the store with a few keys + expectedSize := 5 + for i := 0; i < expectedSize; i++ { + _, _, err := mockServer.Set( + fmt.Sprintf("DBSizeKey%d", i), + fmt.Sprintf("Value%d", i), + sugardb.SETOptions{}, + ) + if err != nil { + t.Error(err) + return + } + } + + if err = client.WriteArray([]resp.Value{resp.StringValue("DBSIZE")}); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if res.Integer() != int(expectedSize) { + t.Errorf("expected dbsize %d, got %d", expectedSize, res.Integer()) + } + }) + + t.Run("Test_HandleGETDEL", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + value string + }{ + { + name: "1. String", + key: "GetDelKey1", + value: "value1", + }, + { + name: "2. Integer", + key: "GetDelKey2", + value: "10", + }, + { + name: "3. Float", + key: "GetDelKey3", + value: "3.142", + }, + } + // Test successful GETDEL command + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + func(key, value string) { + // Preset the values + err = client.WriteArray([]resp.Value{resp.StringValue("SET"), resp.StringValue(key), resp.StringValue(value)}) + if err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be \"OK\", got %s", res.String()) + } + + // Verify correct value returned + if err = client.WriteArray([]resp.Value{resp.StringValue("GETDEL"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if res.String() != test.value { + t.Errorf("expected value %s, got %s", test.value, res.String()) + } + + // Verify key was deleted + if err = client.WriteArray([]resp.Value{resp.StringValue("GET"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + if !res.IsNull() { + t.Errorf("expected nil, got: %+v", res) + } + }(test.key, test.value) + }) + } + + // Test get non-existent key + if err = client.WriteArray([]resp.Value{resp.StringValue("GETDEL"), resp.StringValue("test4")}); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !res.IsNull() { + t.Errorf("expected nil, got: %+v", res) + } + + errorTests := []struct { + name string + command []string + expected string + }{ + { + name: "1. Return error when no GETDEL key is passed", + command: []string{"GETDEL"}, + expected: constants.WrongArgsResponse, + }, + { + name: "2. Return error when too many GETDEL keys are passed", + command: []string{"GETDEL", "GetKey1", "test"}, + expected: constants.WrongArgsResponse, + }, + } + for _, test := range errorTests { + t.Run(test.name, func(t *testing.T) { + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.Contains(res.Error().Error(), test.expected) { + t.Errorf("expected error '%s', got: %s", test.expected, err.Error()) + } + }) + } + }) + + t.Run("Test_HandleGETEX", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + command []string + presetValues map[string]KeyData + expectedResponse string + expectedValues map[string]KeyData + expectedError error + }{ + { + name: "1. Get key and set new expire by seconds", + command: []string{"GETEX", "GetExKey1", "EX", "100"}, + presetValues: map[string]KeyData{ + "GetExKey1": {Value: "value1", ExpireAt: time.Time{}}, + }, + expectedResponse: "value1", + expectedValues: map[string]KeyData{ + "GetExKey1": {Value: "value1", ExpireAt: mockClock.Now().Add(100 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "2. Get key and set new expire by milliseconds", + command: []string{"GETEX", "GetExKey2", "PX", "1000"}, + presetValues: map[string]KeyData{ + "GetExKey2": {Value: "value2", ExpireAt: time.Time{}}, + }, + expectedResponse: "value2", + expectedValues: map[string]KeyData{ + "GetExKey2": {Value: "value2", ExpireAt: mockClock.Now().Add(1000 * time.Millisecond)}, + }, + expectedError: nil, + }, + { + name: "3. Get key and set new expire at by seconds", + command: []string{"GETEX", "GetExKey3", "EXAT", fmt.Sprintf("%d", mockClock.Now().Add(100*time.Second).Unix())}, + presetValues: map[string]KeyData{ + "GetExKey3": {Value: "value3", ExpireAt: time.Time{}}, + }, + expectedResponse: "value3", + expectedValues: map[string]KeyData{ + "GetExKey3": {Value: "value3", ExpireAt: mockClock.Now().Add(100 * time.Second)}, + }, + expectedError: nil, + }, + { + name: "4. Get key and set new expire at by milliseconds", + command: []string{"GETEX", "GetExKey4", "PXAT", fmt.Sprintf("%d", mockClock.Now().Add(1000*time.Millisecond).UnixMilli())}, + presetValues: map[string]KeyData{ + "GetExKey4": {Value: "value4", ExpireAt: time.Time{}}, + }, + expectedResponse: "value4", + expectedValues: map[string]KeyData{ + "GetExKey4": {Value: "value4", ExpireAt: mockClock.Now().Add(1000 * time.Millisecond)}, + }, + expectedError: nil, + }, + { + name: "5. Get key and persist", + command: []string{"GETEX", "GetExKey5", "PERSIST"}, + presetValues: map[string]KeyData{ + "GetExKey5": {Value: "value5", ExpireAt: time.Time{}}, + }, + expectedResponse: "value5", + expectedValues: map[string]KeyData{ + "GetExKey5": {Value: "value5", ExpireAt: time.Time{}}, + }, + expectedError: nil, + }, + { + name: "6. Get key when no expire options are passed", + command: []string{"GETEX", "GetExKey6"}, + presetValues: map[string]KeyData{ + "GetExKey6": {Value: "value6", ExpireAt: time.Time{}}, + }, + expectedResponse: "value6", + expectedValues: map[string]KeyData{ + "GetExKey6": {Value: "value6", ExpireAt: time.Time{}}, + }, + expectedError: nil, + }, + { + name: "7. Return empty string when key doesn't exist", + command: []string{"GETEX", "GetExKey7", "PXAT", "1000"}, + presetValues: nil, + expectedResponse: "", + expectedValues: nil, + expectedError: nil, + }, + { + name: "8. Get key and don't set expiration when time not provided", + command: []string{"GETEX", "GetExKey8", "PXAT"}, + presetValues: map[string]KeyData{ + "GetExKey8": {Value: "value8", ExpireAt: time.Time{}}, + }, + expectedResponse: "value8", + expectedValues: map[string]KeyData{ + "GetExKey8": {Value: "value8", ExpireAt: time.Time{}}, + }, + expectedError: nil, + }, + { + name: "9. Return error when expire time is not a valid integer", + command: []string{"GETEX", "GetExKey9", "EX", "notAnInt"}, + presetValues: map[string]KeyData{ + "GetExKey9": {Value: "value9", ExpireAt: time.Time{}}, + }, + expectedResponse: "", + expectedValues: nil, + expectedError: errors.New("expire time must be integer"), + }, + { + name: "10. Command too short", + command: []string{"GETEX"}, + presetValues: nil, + expectedResponse: "", + expectedValues: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "11. Command too long", + command: []string{"GETEX", "GetExKey11", "EX", "1000", "PERSIST"}, + presetValues: nil, + expectedResponse: "", + expectedValues: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + for k, v := range test.presetValues { + command := []resp.Value{resp.StringValue("SET"), resp.StringValue(k), resp.StringValue(v.Value.(string))} + if !v.ExpireAt.Equal(time.Time{}) { + command = append(command, []resp.Value{ + resp.StringValue("PX"), + resp.StringValue(fmt.Sprintf("%d", v.ExpireAt.Sub(mockClock.Now()).Milliseconds())), + }...) + } + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if res.Error() == nil || !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%v\"", test.expectedError.Error(), res.Error()) + } + return + } + + if res.String() != test.expectedResponse { + t.Errorf("expected response %s, got %s", test.expectedResponse, res.String()) + } + + if test.expectedValues == nil { + return + } + + for key, expected := range test.expectedValues { + // Compare the expiry of the key with what's expected + if err = client.WriteArray([]resp.Value{resp.StringValue("PTTL"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + if expected.ExpireAt.Equal(time.Time{}) { + if res.Integer() != -1 { + t.Error("expected key to be persisted, it was not.") + } + continue + } + if res.Integer() != int(expected.ExpireAt.Sub(mockClock.Now()).Milliseconds()) { + t.Errorf("expected expiry %d, got %d", expected.ExpireAt.Sub(mockClock.Now()).Milliseconds(), res.Integer()) + } + } + }) + } + }) + + t.Run("Test_HandleType", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + presetCommand string + command []string + expectedResponse string + expectedError error + }{ + { + name: "Test TYPE with preset string value", + key: "TypeKeyString", + presetValue: "Hello", + command: []string{"TYPE", "TypeKeyString"}, + expectedResponse: "string", + expectedError: nil, + }, + { + name: "Test TYPE with preset integer value", + key: "TypeKeyInteger", + presetValue: 1, + command: []string{"TYPE", "TypeKeyInteger"}, + expectedResponse: "integer", + expectedError: nil, + }, + { + name: "Test TYPE with preset float value", + key: "TypeKeyFloat", + presetValue: 1.12, + command: []string{"TYPE", "TypeKeyFloat"}, + expectedResponse: "float", + expectedError: nil, + }, + { + name: "Test TYPE with preset set value", + key: "TypeKeySet", + presetValue: set.NewSet([]string{"one", "two", "three", "four"}), + command: []string{"TYPE", "TypeKeySet"}, + expectedResponse: "set", + expectedError: nil, + }, + { + name: "Test TYPE with preset list value", + key: "TypeKeyList", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"TYPE", "TypeKeyList"}, + expectedResponse: "list", + expectedError: nil, + }, + { + name: "Test TYPE with preset list of integers value", + key: "TypeKeyList2", + presetValue: []int{1, 2, 3, 4}, + command: []string{"TYPE", "TypeKeyList2"}, + expectedResponse: "list", + expectedError: nil, + }, + { + name: "Test TYPE with preset zset of integers value", + key: "TypeKeyZSet", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "member1", Score: sorted_set.Score(5.5)}, + {Value: "member2", Score: sorted_set.Score(67.77)}, + {Value: "member3", Score: sorted_set.Score(10)}, + }), + command: []string{"TYPE", "TypeKeyZSet"}, + expectedResponse: "zset", + expectedError: nil, + }, + { + name: "Test TYPE with preset hash of map[string]string", + key: "TypeKeyHash", + presetValue: map[string]string{"field1": "value1"}, + command: []string{"TYPE", "TypeKeyHash"}, + expectedResponse: "hash", + expectedError: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string, int, float64: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.AnyValue(test.presetValue), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(test.key)} + for _, element := range test.presetValue.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(test.presetValue.(*set.Set).Cardinality()) + case []string: + command = []resp.Value{resp.StringValue("LPUSH"), resp.StringValue(test.key)} + for _, element := range test.presetValue.([]string) { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(len(test.presetValue.([]string))) + case []int: + command = []resp.Value{resp.StringValue("LPUSH"), resp.StringValue(test.key)} + for _, element := range test.presetValue.([]int) { + command = append(command, []resp.Value{resp.IntegerValue(element)}...) + } + expected = strconv.Itoa(len(test.presetValue.([]int))) + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(test.key)} + for _, member := range test.presetValue.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(test.presetValue.(*sorted_set.SortedSet).Cardinality()) + case map[string]string: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(map[string]string) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value)}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(map[string]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.String() != test.expectedResponse { + t.Errorf("expected response \"%s\", got \"%s\"", test.expectedResponse, res.String()) + } + }) + } + }) + + t.Run("Test_HandleCOPY", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + sourceKeyPresetValue interface{} + sourcekey string + destKeyPresetValue interface{} + destinationKey string + database string + replace bool + expectedValue string + expectedResponse string + }{ + { + name: "1. Copy Value into non existing key", + sourceKeyPresetValue: "value1", + sourcekey: "skey1", + destKeyPresetValue: nil, + destinationKey: "dkey1", + database: "0", + replace: false, + expectedValue: "value1", + expectedResponse: "1", + }, + { + name: "2. Copy Value into existing key without replace option", + sourceKeyPresetValue: "value2", + sourcekey: "skey2", + destKeyPresetValue: "dValue2", + destinationKey: "dkey2", + database: "0", + replace: false, + expectedValue: "dValue2", + expectedResponse: "0", + }, + { + name: "3. Copy Value into existing key with replace option", + sourceKeyPresetValue: "value3", + sourcekey: "skey3", + destKeyPresetValue: "dValue3", + destinationKey: "dkey3", + database: "0", + replace: true, + expectedValue: "value3", + expectedResponse: "1", + }, + { + name: "4. Copy Value into different database", + sourceKeyPresetValue: "value4", + sourcekey: "skey4", + destKeyPresetValue: nil, + destinationKey: "dkey4", + database: "1", + replace: true, + expectedValue: "value4", + expectedResponse: "1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.sourceKeyPresetValue != nil { + cmd := []resp.Value{resp.StringValue("Set"), resp.StringValue(tt.sourcekey), resp.StringValue(tt.sourceKeyPresetValue.(string))} + + err := client.WriteArray(cmd) + if err != nil { + t.Error(err) + } + + rd, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(rd.String(), "ok") { + t.Errorf("expected preset response to be \"OK\", got %s", rd.String()) + } + } + + if tt.destKeyPresetValue != nil { + cmd := []resp.Value{resp.StringValue("Set"), resp.StringValue(tt.destinationKey), resp.StringValue(tt.destKeyPresetValue.(string))} + + err := client.WriteArray(cmd) + if err != nil { + t.Error(err) + } + + rd, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(rd.String(), "ok") { + t.Errorf("expected preset response to be \"OK\", got %s", rd.String()) + } + } + + command := []resp.Value{resp.StringValue("COPY"), resp.StringValue(tt.sourcekey), resp.StringValue(tt.destinationKey)} + + if tt.database != "0" { + command = append(command, resp.StringValue("DB"), resp.StringValue(tt.database)) + } + + if tt.replace { + command = append(command, resp.StringValue("REPLACE")) + } + + err := client.WriteArray(command) + if err != nil { + t.Error(err) + } + + rd, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(rd.String(), tt.expectedResponse) { + t.Errorf("expected response to be %s, but got %s", tt.expectedResponse, rd.String()) + } + + if tt.database != "0" { + selectCommand := []resp.Value{resp.StringValue("SELECT"), resp.StringValue(tt.database)} + + err := client.WriteArray(selectCommand) + if err != nil { + t.Error(err) + } + _, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + } + + getCommand := []resp.Value{resp.StringValue("GET"), resp.StringValue(tt.destinationKey)} + + err = client.WriteArray(getCommand) + if err != nil { + t.Error(err) + } + + rd, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + if !strings.EqualFold(rd.String(), tt.expectedValue) { + t.Errorf("expected value in destinaton key to be %s, but got %s", tt.expectedValue, rd.String()) + } + }) + } + + }) + + t.Run("Test_HandleMOVE", func(t *testing.T) { + t.Parallel() + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + mockServer, err := sugardb.NewSugarDB( + sugardb.WithConfig(config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.NoEviction, + }), + ) + if err != nil { + t.Error(err) + return + } + go func() { + mockServer.Start() + }() + t.Cleanup(func() { + mockServer.ShutDown() + }) + + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + value string + preset bool + expected int + }{ + { + name: "1. Move key.", + key: "MoveKey1", + value: "value1", + preset: true, + expected: 1, + }, + { + name: "2. Move key that already exists in new database.", + key: "MoveKey1", + value: "value1", + preset: false, + expected: 0, + }, + { + name: "3. Move key that doesn't exist in current database.", + key: "MoveKey3", + value: "value3", + preset: false, + expected: 0, + }, + } + + for _, tt := range tests { + + t.Log(tt.name) + + // Preset the values + if tt.preset { + err = client.WriteArray([]resp.Value{resp.StringValue("SET"), resp.StringValue(tt.key), resp.StringValue(tt.value)}) + if err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be \"OK\", got %s", res.String()) + } + } + + if err = client.WriteArray([]resp.Value{resp.StringValue("MOVE"), resp.StringValue(tt.key), resp.StringValue("1")}); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if res.Integer() != tt.expected { + t.Errorf("expected value %v, got %v", tt.expected, res.Integer()) + t.Error(mockServer.Get(tt.key)) + } + + // check other db + if tt.expected == 1 { + + actual, err := mockServer.Get(tt.key) + if err != nil { + t.Error(err) + } + + if actual != "" { + t.Errorf("when verifying key was moved from the original db, expected to get empty string but got %q", actual) + } + + err = mockServer.SelectDB(1) + if err != nil { + t.Error(err) + } + + actual, err = mockServer.Get(tt.key) + if err != nil { + t.Error(err) + } + + if actual != tt.value { + t.Errorf("when verifying key was moved to the new db, expected to get value %q, but got %q", tt.value, actual) + } + + err = mockServer.SelectDB(0) + if err != nil { + t.Error(err) + } + + } + + } + }) + +} + +// Certain commands will need to be tested in a server with an eviction policy. +// This is for testing against an LFU eviction policy. +func Test_LFU_Generic(t *testing.T) { + // mockClock := clock.NewClock() + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + duration := time.Duration(30) * time.Second + + mockServer, err := sugardb.NewSugarDB( + sugardb.WithConfig(config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.AllKeysLFU, + EvictionInterval: duration, + MaxMemory: 550, + }), + ) + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + // Tests TOUCH and OBJECT FREQ + t.Run("Test_HandleTOUCH", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + keys []string + setKeys []bool + wantErrs []bool + expected int64 + }{ + { + name: "1. Touch key that exists.", + keys: []string{"TouchKey1"}, + setKeys: []bool{true}, + wantErrs: []bool{false}, + expected: 1, + }, + { + name: "2. Touch multiple keys that exist.", + keys: []string{"TouchKey2", "TouchKey2.1", "TouchKey2.2"}, + setKeys: []bool{true, true, true}, + wantErrs: []bool{false, false, false}, + expected: 3, + }, + { + name: "3. Touch multiple keys, some don't exist.", + keys: []string{"TouchKey3", "TouchKey3.1", "TouchKey3.9", "TouchKey3.0"}, + setKeys: []bool{true, true, false, false}, + wantErrs: []bool{false, false, true, true}, + expected: 2, + }, + { + name: "4. Touch key that doesn't exist.", + keys: []string{"TouchKey4"}, + setKeys: []bool{false}, + wantErrs: []bool{true}, + expected: 0, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + func(keys []string, setKeys, wantErrs []bool, expected int64) { + // Preset the values + for i, k := range keys { + if setKeys[i] { + command := make([]resp.Value, 3) + command[0] = resp.StringValue("SET") + command[1] = resp.StringValue(k) + command[2] = resp.StringValue("___") + err = client.WriteArray(command) + if err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be \"OK\", got %s", res.String()) + } + } + } + + // Verify correct value returned + command := make([]resp.Value, len(keys)+1) + command[0] = resp.StringValue("TOUCH") + for i := 1; i < len(command); i++ { + ki := i - 1 + command[i] = resp.StringValue(keys[ki]) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if int64(res.Integer()) != expected { + t.Errorf("expected value %v, got %v", expected, res.Integer()) + } + + // Touch one more time to test OBJRECT FREQ + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + _, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + // Verify access frequency (count field) was updated in lfu cache + for i, k := range keys { + cmd := []resp.Value{ + resp.StringValue("OBJECTFREQ"), + resp.StringValue(k), + } + + if err = client.WriteArray(cmd); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if wantErrs[i] { + if res.Error() == nil { + t.Errorf("OBJECTFREQ Expected error, got none with value: %v", res.Integer()) + } else { + if res.Integer() != 0 { + t.Errorf("OBJECTFREQ key doesn't exist, expect frequency of 0, go %v", res.Integer()) + } + continue + } + } else if err != nil { + t.Error(err) + } + + if res.Integer() != 3 { + t.Errorf("OBJECTFREQ expected frequency of 3, got %v", res.Integer()) + } + + } + + }(tt.keys, tt.setKeys, tt.wantErrs, tt.expected) + }) + + } + }) + +} + +// Certain commands will need to be tested in a server with an eviction policy. +// This is for testing against an LRU evictiona policy. +func Test_LRU_Generic(t *testing.T) { + // mockClock := clock.NewClock() + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + duration := time.Duration(30) * time.Second + + mockServer, err := sugardb.NewSugarDB( + sugardb.WithConfig(config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.AllKeysLRU, + EvictionInterval: duration, + MaxMemory: 550, + }), + ) + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + // Tests TOUCH and OBJECT IDLETIME + t.Run("Test_HandleTOUCH", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + keys []string + setKeys []bool + wantErrs []bool + expected int64 + }{ + { + name: "1. Touch key that exists.", + keys: []string{"TouchKey1"}, + setKeys: []bool{true}, + wantErrs: []bool{false}, + expected: 1, + }, + { + name: "2. Touch multiple keys that exist.", + keys: []string{"TouchKey2", "TouchKey2.1", "TouchKey2.2"}, + setKeys: []bool{true, true, true}, + wantErrs: []bool{false, false, false}, + expected: 3, + }, + { + name: "3. Touch multiple keys, some don't exist.", + keys: []string{"TouchKey3", "TouchKey3.1", "TouchKey3.9", "TouchKey3.0"}, + setKeys: []bool{true, true, false, false}, + wantErrs: []bool{false, false, true, true}, + expected: 2, + }, + { + name: "4. Touch key that doesn't exist.", + keys: []string{"TouchKey4"}, + setKeys: []bool{false}, + wantErrs: []bool{true}, + expected: 0, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + func(keys []string, setKeys, wantErrs []bool, expected int64) { + // Preset the values + for i, k := range keys { + if setKeys[i] { + command := make([]resp.Value, 3) + command[0] = resp.StringValue("SET") + command[1] = resp.StringValue(k) + command[2] = resp.StringValue("___") + err = client.WriteArray(command) + if err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be \"OK\", got %s", res.String()) + } + } + } + + // Verify correct value returned + command := make([]resp.Value, len(keys)+1) + command[0] = resp.StringValue("TOUCH") + for i := 1; i < len(command); i++ { + ki := i - 1 + command[i] = resp.StringValue(keys[ki]) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if int64(res.Integer()) != expected { + t.Errorf("expected value %v, got %v", expected, res.Integer()) + } + + // Allow some time to easily verify touch command is successful + time.Sleep(3 * time.Second) + + // Verify access frequency (count field) was updated in lfu cache + for i, k := range keys { + cmd := []resp.Value{ + resp.StringValue("OBJECTIDLETIME"), + resp.StringValue(k), + } + + if err = client.WriteArray(cmd); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if wantErrs[i] { + if res.Error() == nil { + t.Errorf("OBJECTIDLETIME Expected error, got none with value: %v", res.Float()) + } else { + if res.Float() != 0 { + t.Errorf("OBJECTIDLETIME key doesn't exist, expect idle time of 0, go %v", res.Float()) + } + continue + } + } else if err != nil { + t.Error(err) + } + + if res.Float() < 3 { + t.Errorf("OBJECTIDLETIME expected idle time of at least 3, got %v", res.Float()) + } + + } + + }(tt.keys, tt.setKeys, tt.wantErrs, tt.expected) + }) + + } + + }) + +} diff --git a/internal/modules/generic/key_funcs.go b/internal/modules/generic/key_funcs.go new file mode 100644 index 0000000..4a9db45 --- /dev/null +++ b/internal/modules/generic/key_funcs.go @@ -0,0 +1,323 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package generic + +import ( + "errors" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" +) + +func setKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 || len(cmd) > 7 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func msetKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd[1:])%2 != 0 { + return internal.KeyExtractionFuncResult{}, errors.New("each key must be paired with a value") + } + var keys []string + for i, key := range cmd[1:] { + if i%2 == 0 { + keys = append(keys, key) + } + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: keys, + }, nil +} + +func getKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func mgetKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func delKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:], + }, nil +} + +func persistKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:], + }, nil +} + +func expireTimeKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func ttlKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func expireKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 || len(cmd) > 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func expireAtKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 || len(cmd) > 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func incrKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + WriteKeys: cmd[1:2], + }, nil +} + +func decrKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + WriteKeys: cmd[1:2], + }, nil +} + +func incrByKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + WriteKeys: []string{cmd[1]}, + }, nil +} + +func incrByFloatKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + WriteKeys: []string{cmd[1]}, + }, nil +} + +func decrByKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + WriteKeys: []string{cmd[1]}, + }, nil +} + +func renameKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + WriteKeys: cmd[1:3], + }, nil +} + +func renamenxKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + WriteKeys: cmd[1:3], + }, nil +} + +func randomKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 1 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil +} + +func dbSizeKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 1 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil +} + +func getDelKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: cmd[1:], + }, nil +} + +func getExKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 || len(cmd) > 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: cmd[1:2], + }, nil +} + +func typeKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func touchKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func objFreqKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func objIdleTimeKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func copyKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 && len(cmd) > 6 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: cmd[2:3], + }, nil +} + +func moveKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: []string{cmd[1]}, + }, nil +} + +func existsKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} diff --git a/internal/modules/generic/utils.go b/internal/modules/generic/utils.go new file mode 100644 index 0000000..33acb19 --- /dev/null +++ b/internal/modules/generic/utils.go @@ -0,0 +1,152 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package generic + +import ( + "errors" + "fmt" + "apigo.cc/go/sugardb/internal/clock" + "strconv" + "strings" + "time" +) + +type SetOptions struct { + exists string + get bool + expireAt interface{} // Exact expireAt time un unix milliseconds +} + +type CopyOptions struct { + database string + replace bool +} + +func getSetCommandOptions(clock clock.Clock, cmd []string, options SetOptions) (SetOptions, error) { + if len(cmd) == 0 { + return options, nil + } + switch strings.ToLower(cmd[0]) { + case "get": + options.get = true + return getSetCommandOptions(clock, cmd[1:], options) + + case "nx": + if options.exists != "" { + return SetOptions{}, fmt.Errorf("cannot specify NX when %s is already specified", strings.ToUpper(options.exists)) + } + options.exists = "NX" + return getSetCommandOptions(clock, cmd[1:], options) + + case "xx": + if options.exists != "" { + return SetOptions{}, fmt.Errorf("cannot specify XX when %s is already specified", strings.ToUpper(options.exists)) + } + options.exists = "XX" + return getSetCommandOptions(clock, cmd[1:], options) + + case "ex": + if len(cmd) < 2 { + return SetOptions{}, errors.New("seconds value required after EX") + } + if options.expireAt != nil { + return SetOptions{}, errors.New("cannot specify EX when expiry time is already set") + } + secondsStr := cmd[1] + seconds, err := strconv.ParseInt(secondsStr, 10, 64) + if err != nil { + return SetOptions{}, errors.New("seconds value should be an integer") + } + options.expireAt = clock.Now().Add(time.Duration(seconds) * time.Second) + return getSetCommandOptions(clock, cmd[2:], options) + + case "px": + if len(cmd) < 2 { + return SetOptions{}, errors.New("milliseconds value required after PX") + } + if options.expireAt != nil { + return SetOptions{}, errors.New("cannot specify PX when expiry time is already set") + } + millisecondsStr := cmd[1] + milliseconds, err := strconv.ParseInt(millisecondsStr, 10, 64) + if err != nil { + return SetOptions{}, errors.New("milliseconds value should be an integer") + } + options.expireAt = clock.Now().Add(time.Duration(milliseconds) * time.Millisecond) + return getSetCommandOptions(clock, cmd[2:], options) + + case "exat": + if len(cmd) < 2 { + return SetOptions{}, errors.New("seconds value required after EXAT") + } + if options.expireAt != nil { + return SetOptions{}, errors.New("cannot specify EXAT when expiry time is already set") + } + secondsStr := cmd[1] + seconds, err := strconv.ParseInt(secondsStr, 10, 64) + if err != nil { + return SetOptions{}, errors.New("seconds value should be an integer") + } + options.expireAt = time.Unix(seconds, 0) + return getSetCommandOptions(clock, cmd[2:], options) + + case "pxat": + if len(cmd) < 2 { + return SetOptions{}, errors.New("milliseconds value required after PXAT") + } + if options.expireAt != nil { + return SetOptions{}, errors.New("cannot specify PXAT when expiry time is already set") + } + millisecondsStr := cmd[1] + milliseconds, err := strconv.ParseInt(millisecondsStr, 10, 64) + if err != nil { + return SetOptions{}, errors.New("milliseconds value should be an integer") + } + options.expireAt = time.UnixMilli(milliseconds) + return getSetCommandOptions(clock, cmd[2:], options) + + default: + return SetOptions{}, fmt.Errorf("unknown option %s for set command", strings.ToUpper(cmd[0])) + } +} + +func getCopyCommandOptions(cmd []string, options CopyOptions) (CopyOptions, error) { + if len(cmd) == 0 { + return options, nil + } + + switch strings.ToLower(cmd[0]){ + case "replace": + options.replace = true + return getCopyCommandOptions(cmd[1:], options) + + case "db": + if len(cmd) < 2 { + return CopyOptions{}, errors.New("syntax error") + } + + _, err := strconv.Atoi(cmd[1]) + if err != nil { + return CopyOptions{}, errors.New("value is not an integer or out of range") + } + + options.database = cmd [1] + return getCopyCommandOptions(cmd[2:], options) + + + default: + return CopyOptions{}, fmt.Errorf("unknown option %s for copy command", strings.ToUpper(cmd[0])) + } +} \ No newline at end of file diff --git a/internal/modules/hash/commands.go b/internal/modules/hash/commands.go new file mode 100644 index 0000000..84cb51a --- /dev/null +++ b/internal/modules/hash/commands.go @@ -0,0 +1,998 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package hash + +import ( + "errors" + "fmt" + "math/rand" + "slices" + "strconv" + "strings" + "time" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" +) + +func handleHSET(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hsetKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + entries := Hash{} + + if len(params.Command[2:])%2 != 0 { + return nil, errors.New("each field must have a corresponding value") + } + + for i := 2; i <= len(params.Command)-2; i += 2 { + k := params.Command[i] + entries[k] = HashValue{Value: internal.AdaptType(params.Command[i+1])} + } + + if !keyExists { + if err = params.SetValues(params.Context, map[string]interface{}{key: entries}); err != nil { + return nil, err + } + return []byte(fmt.Sprintf(":%d\r\n", len(entries))), nil + } + + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + // Not hash, save the entries map directly. + if err = params.SetValues(params.Context, map[string]interface{}{key: entries}); err != nil { + return nil, err + } + return []byte(fmt.Sprintf(":%d\r\n", len(entries))), nil + } + + count := 0 + switch strings.ToLower(params.Command[0]) { + case "hsetnx": + // Handle HSETNX + for field, _ := range entries { + if _, ok := hash[field]; !ok { + count += 1 + } + } + + for field, value := range hash { + entries[field] = value + } + default: + // Handle HSET + for field, value := range hash { + if entries[field].Value == nil { + entries[field] = HashValue{Value: value} + } + } + count = len(entries) + } + + if err = params.SetValues(params.Context, map[string]interface{}{key: entries}); err != nil { + return nil, err + } + + return []byte(fmt.Sprintf(":%d\r\n", count)), nil +} + +func handleHGET(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hgetKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + fields := params.Command[2:] + + if !keyExists { + return []byte("$-1\r\n"), nil + } + + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value at %s is not a hash", key) + } + + var value HashValue + + res := fmt.Sprintf("*%d\r\n", len(fields)) + for _, field := range fields { + value = hash[field] + if value.Value == nil { + res += "$-1\r\n" + continue + } + if s, ok := value.Value.(string); ok { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(s), s) + continue + } + if d, ok := value.Value.(int); ok { + res += fmt.Sprintf(":%d\r\n", d) + continue + } + if f, ok := value.Value.(float64); ok { + fs := strconv.FormatFloat(f, 'f', -1, 64) + res += fmt.Sprintf("$%d\r\n%s\r\n", len(fs), fs) + continue + } + res += fmt.Sprintf("$-1\r\n") + } + + return []byte(res), nil +} + +func handleHMGET(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hmgetKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + if !keyExists { + return []byte("$-1\r\n"), nil + } + + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value at %s is not a hash", key) + } + + fields := params.Command[2:] + + var value HashValue + + res := fmt.Sprintf("*%d\r\n", len(fields)) + for _, field := range fields { + value, ok = hash[field] + if !ok { + res += "$-1\r\n" + continue + } + + if s, ok := value.Value.(string); ok { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(s), s) + continue + } + if d, ok := value.Value.(int); ok { + res += fmt.Sprintf(":%d\r\n", d) + continue + } + if f, ok := value.Value.(float64); ok { + fs := strconv.FormatFloat(f, 'f', -1, 64) + res += fmt.Sprintf("$%d\r\n%s\r\n", len(fs), fs) + continue + } + res += fmt.Sprintf("$-1\r\n") + + } + return []byte(res), nil +} + +func handleHSTRLEN(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hstrlenKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + fields := params.Command[2:] + + if !keyExists { + return []byte("$-1\r\n"), nil + } + + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value at %s is not a hash", key) + } + + var value HashValue + + res := fmt.Sprintf("*%d\r\n", len(fields)) + for _, field := range fields { + value = hash[field] + if value.Value == nil { + res += ":0\r\n" + continue + } + if s, ok := value.Value.(string); ok { + res += fmt.Sprintf(":%d\r\n", len(s)) + continue + } + if f, ok := value.Value.(float64); ok { + fs := strconv.FormatFloat(f, 'f', -1, 64) + res += fmt.Sprintf(":%d\r\n", len(fs)) + continue + } + if d, ok := value.Value.(int); ok { + res += fmt.Sprintf(":%d\r\n", len(strconv.Itoa(d))) + continue + } + res += ":0\r\n" + } + + return []byte(res), nil +} + +func handleHVALS(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hvalsKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + if !keyExists { + return []byte("*0\r\n"), nil + } + + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value at %s is not a hash", key) + } + + res := fmt.Sprintf("*%d\r\n", len(hash)) + for _, val := range hash { + if s, ok := val.Value.(string); ok { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(s), s) + continue + } + if f, ok := val.Value.(float64); ok { + fs := strconv.FormatFloat(f, 'f', -1, 64) + res += fmt.Sprintf("$%d\r\n%s\r\n", len(fs), fs) + continue + } + if d, ok := val.Value.(int); ok { + res += fmt.Sprintf(":%d\r\n", d) + } + } + + return []byte(res), nil +} + +func handleHRANDFIELD(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hrandfieldKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + count := 1 + if len(params.Command) >= 3 { + c, err := strconv.Atoi(params.Command[2]) + if err != nil { + return nil, errors.New("count must be an integer") + } + if c == 0 { + return []byte("*0\r\n"), nil + } + count = c + } + + withvalues := false + if len(params.Command) == 4 { + if strings.EqualFold(params.Command[3], "withvalues") { + withvalues = true + } else { + return nil, errors.New("result modifier must be withvalues") + } + } + + if !keyExists { + return []byte("*0\r\n"), nil + } + + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value at %s is not a hash", key) + } + + // If count is the >= hash length, then return the entire hash + if count >= len(hash) { + res := fmt.Sprintf("*%d\r\n", len(hash)) + if withvalues { + res = fmt.Sprintf("*%d\r\n", len(hash)*2) + } + for field, value := range hash { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(field), field) + if withvalues { + if s, ok := value.Value.(string); ok { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(s), s) + continue + } + if f, ok := value.Value.(float64); ok { + fs := strconv.FormatFloat(f, 'f', -1, 64) + res += fmt.Sprintf("$%d\r\n%s\r\n", len(fs), fs) + continue + } + if d, ok := value.Value.(int); ok { + res += fmt.Sprintf(":%d\r\n", d) + continue + } + } + } + return []byte(res), nil + } + + // Get all the fields + var fields []string + for field, _ := range hash { + fields = append(fields, field) + } + + // Pluck fields and return them + var pluckedFields []string + var n int + for i := 0; i < internal.AbsInt(count); i++ { + n = rand.Intn(len(fields)) + pluckedFields = append(pluckedFields, fields[n]) + // If count is positive, remove the current field from list of fields + if count > 0 { + fields = slices.DeleteFunc(fields, func(s string) bool { + return s == fields[n] + }) + } + } + + res := fmt.Sprintf("*%d\r\n", len(pluckedFields)) + if withvalues { + res = fmt.Sprintf("*%d\r\n", len(pluckedFields)*2) + } + for _, field := range pluckedFields { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(field), field) + if withvalues { + if s, ok := hash[field].Value.(string); ok { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(s), s) + continue + } + if f, ok := hash[field].Value.(float64); ok { + fs := strconv.FormatFloat(f, 'f', -1, 64) + res += fmt.Sprintf("$%d\r\n%s\r\n", len(fs), fs) + continue + } + if d, ok := hash[field].Value.(int); ok { + res += fmt.Sprintf(":%d\r\n", d) + continue + } + } + } + + return []byte(res), nil +} + +func handleHLEN(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hlenKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + if !keyExists { + return []byte(":0\r\n"), nil + } + + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value at %s is not a hash", key) + } + + return []byte(fmt.Sprintf(":%d\r\n", len(hash))), nil +} + +func handleHKEYS(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hkeysKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + if !keyExists { + return []byte("*0\r\n"), nil + } + + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value at %s is not a hash", key) + } + + res := fmt.Sprintf("*%d\r\n", len(hash)) + for field, _ := range hash { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(field), field) + } + + return []byte(res), nil +} + +func handleHINCRBY(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hincrbyKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + field := params.Command[2] + + var intIncrement int + var floatIncrement float64 + + if strings.EqualFold(params.Command[0], "hincrbyfloat") { + f, err := strconv.ParseFloat(params.Command[3], 64) + if err != nil { + return nil, errors.New("increment must be a float") + } + floatIncrement = f + } else { + i, err := strconv.Atoi(params.Command[3]) + if err != nil { + return nil, errors.New("increment must be an integer") + } + intIncrement = i + } + + if !keyExists { + hash := make(Hash) + if strings.EqualFold(params.Command[0], "hincrbyfloat") { + hash[field] = HashValue{Value: floatIncrement} + if err = params.SetValues(params.Context, map[string]interface{}{key: hash}); err != nil { + return nil, err + } + return []byte(fmt.Sprintf("+%s\r\n", strconv.FormatFloat(floatIncrement, 'f', -1, 64))), nil + } else { + hash[field] = HashValue{Value: intIncrement} + if err = params.SetValues(params.Context, map[string]interface{}{key: hash}); err != nil { + return nil, err + } + return []byte(fmt.Sprintf(":%d\r\n", intIncrement)), nil + } + } + + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value at %s is not a hash", key) + } + + if hash[field].Value == nil { + hash[field] = HashValue{Value: 0} + } + + switch hash[field].Value.(type) { + default: + return nil, fmt.Errorf("value at field %s is not a number", field) + case int: + i, _ := hash[field].Value.(int) + if strings.EqualFold(params.Command[0], "hincrbyfloat") { + hash[field] = HashValue{Value: float64(i) + floatIncrement} + } else { + hash[field] = HashValue{Value: i + intIncrement} + } + case float64: + f, _ := hash[field].Value.(float64) + if strings.EqualFold(params.Command[0], "hincrbyfloat") { + hash[field] = HashValue{Value: f + floatIncrement} + } else { + hash[field] = HashValue{Value: f + float64(intIncrement)} + } + } + + if err = params.SetValues(params.Context, map[string]interface{}{key: hash}); err != nil { + return nil, err + } + + if f, ok := hash[field].Value.(float64); ok { + return []byte(fmt.Sprintf("+%s\r\n", strconv.FormatFloat(f, 'f', -1, 64))), nil + } + + i, _ := hash[field].Value.(int) + return []byte(fmt.Sprintf(":%d\r\n", i)), nil +} + +func handleHGETALL(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hgetallKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + if !keyExists { + return []byte("*0\r\n"), nil + } + + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value at %s is not a hash", key) + } + + res := fmt.Sprintf("*%d\r\n", len(hash)*2) + for field, value := range hash { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(field), field) + if s, ok := value.Value.(string); ok { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(s), s) + } + + if f, ok := value.Value.(float64); ok { + fs := strconv.FormatFloat(f, 'f', -1, 64) + res += fmt.Sprintf("$%d\r\n%s\r\n", len(fs), fs) + } + + if d, ok := value.Value.(int); ok { + res += fmt.Sprintf(":%d\r\n", d) + } + } + + return []byte(res), nil +} + +func handleHEXISTS(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hexistsKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + field := params.Command[2] + + if !keyExists { + return []byte(":0\r\n"), nil + } + + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value at %s is not a hash", key) + } + + if hash[field].Value != nil { + return []byte(":1\r\n"), nil + } + + return []byte(":0\r\n"), nil +} + +func handleHDEL(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hdelKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + fields := params.Command[2:] + + if !keyExists { + return []byte(":0\r\n"), nil + } + + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value at %s is not a hash", key) + } + + count := 0 + + for _, field := range fields { + if hash[field].Value != nil { + delete(hash, field) + count += 1 + } + } + + if err = params.SetValues(params.Context, map[string]interface{}{key: hash}); err != nil { + return nil, err + } + + return []byte(fmt.Sprintf(":%d\r\n", count)), nil +} + +func handleHEXPIRE(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := hexpireKeyFunc(params.Command) + if err != nil { + return nil, err + } + key := keys.WriteKeys[0] + + // HEXPIRE key seconds [NX | XX | GT | LT] FIELDS numfields field + cmdargs := keys.WriteKeys[1:] + seconds, err := strconv.ParseInt(cmdargs[0], 10, 64) + if err != nil { + return nil, errors.New(fmt.Sprintf("seconds must be integer, was provided %q", cmdargs[0])) + } + + // FIELDS argument provides starting index to work off of to grab fields + var fieldsIdx int + if cmdargs[1] == "FIELDS" { + fieldsIdx = 1 + } else if cmdargs[2] == "FIELDS" { + fieldsIdx = 2 + } else { + return nil, errors.New(fmt.Sprintf(constants.MissingArgResponse, "FIELDS")) + } + + // index through numfields + numfields, err := strconv.ParseInt(cmdargs[fieldsIdx+1], 10, 64) + if err != nil { + return nil, errors.New(fmt.Sprintf("numberfields must be integer, was provided %q", cmdargs[fieldsIdx+1])) + } + endIdx := fieldsIdx + 2 + int(numfields) + fields := cmdargs[fieldsIdx+2 : endIdx] + + expireAt := params.GetClock().Now().Add(time.Duration(seconds) * time.Second) + + // build out response + resp := "*" + fmt.Sprintf("%v", len(fields)) + "\r\n" + + // handle not hash or bad key + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + if !keyExists { + for i := numfields; i > 0; i-- { + resp = resp + ":-2\r\n" + } + return []byte(resp), nil + } + + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value of key %s is not a hash", key) + } + + // handle expire time of 0 seconds + if seconds == 0 { + for i := numfields; i > 0; i-- { + resp = resp + ":2\r\n" + } + return []byte(resp), nil + } + + if fieldsIdx == 2 { + // Handle expire options + switch strings.ToLower(cmdargs[1]) { + case "nx": + for _, f := range fields { + _, ok := hash[f] + if !ok { + resp = resp + ":-2\r\n" + continue + } + currentExpireAt := hash[f].ExpireAt + if currentExpireAt != (time.Time{}) { + resp = resp + ":0\r\n" + continue + } + err = params.SetHashExpiry(params.Context, key, f, expireAt) + if err != nil { + return []byte(resp), err + } + + resp = resp + ":1\r\n" + + } + case "xx": + for _, f := range fields { + _, ok := hash[f] + if !ok { + resp = resp + ":-2\r\n" + continue + } + currentExpireAt := hash[f].ExpireAt + if currentExpireAt == (time.Time{}) { + resp = resp + ":0\r\n" + continue + } + err = params.SetHashExpiry(params.Context, key, f, expireAt) + if err != nil { + return []byte(resp), err + } + + resp = resp + ":1\r\n" + + } + case "gt": + for _, f := range fields { + _, ok := hash[f] + if !ok { + resp = resp + ":-2\r\n" + continue + } + currentExpireAt := hash[f].ExpireAt + //TODO + if currentExpireAt == (time.Time{}) || expireAt.Before(currentExpireAt) { + resp = resp + ":0\r\n" + continue + } + err = params.SetHashExpiry(params.Context, key, f, expireAt) + if err != nil { + return []byte(resp), err + } + + resp = resp + ":1\r\n" + + } + case "lt": + for _, f := range fields { + _, ok := hash[f] + if !ok { + resp = resp + ":-2\r\n" + continue + } + currentExpireAt := hash[f].ExpireAt + if currentExpireAt != (time.Time{}) && currentExpireAt.Before(expireAt) { + resp = resp + ":0\r\n" + continue + } + err = params.SetHashExpiry(params.Context, key, f, expireAt) + if err != nil { + return []byte(resp), err + } + + resp = resp + ":1\r\n" + + } + default: + return nil, fmt.Errorf("unknown option %s, must be one of 'NX', 'XX', 'GT', 'LT'.", strings.ToUpper(params.Command[3])) + } + } else { + for _, f := range fields { + _, ok := hash[f] + if !ok { + resp = resp + ":-2\r\n" + continue + } + err = params.SetHashExpiry(params.Context, key, f, expireAt) + if err != nil { + return []byte(resp), err + } + + resp = resp + ":1\r\n" + + } + } + + // Array resp + return []byte(resp), nil +} + +func handleHTTL(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := httlKeyFunc(params.Command) + if err != nil { + return nil, err + } + + cmdargs := keys.ReadKeys[2:] + numfields, err := strconv.ParseInt(cmdargs[0], 10, 64) + if err != nil { + return nil, errors.New(fmt.Sprintf("expire time must be integer, was provided %q", cmdargs[0])) + } + + fields := cmdargs[1 : numfields+1] + // init array response + resp := "*" + fmt.Sprintf("%v", len(fields)) + "\r\n" + + // handle bad key + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + if !keyExists { + resp = resp + ":-2\r\n" + return []byte(resp), nil + } + + // handle not a hash + hash, ok := params.GetValues(params.Context, []string{key})[key].(Hash) + if !ok { + return nil, fmt.Errorf("value at %s is not a hash", key) + } + + // build out response + for _, field := range fields { + f, ok := hash[field] + if !ok { + resp = resp + ":-2\r\n" + continue + } + if f.ExpireAt == (time.Time{}) { + resp = resp + ":-1\r\n" + continue + } + resp = resp + fmt.Sprintf(":%d\r\n", int(f.ExpireAt.Sub(params.GetClock().Now()).Round(time.Second).Seconds())) + + } + + // array response + return []byte(resp), nil +} + +func Commands() []internal.Command { + return []internal.Command{ + { + Command: "hset", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(HSET key field value [field value ...]) +Set update each field of the hash with the corresponding value.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: hsetKeyFunc, + HandlerFunc: handleHSET, + }, + { + Command: "hsetnx", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(HSETNX key field value [field value ...]) +Set hash field value only if the field does not exist.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: hsetnxKeyFunc, + HandlerFunc: handleHSET, + }, + { + Command: "hget", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.ReadCategory, constants.FastCategory}, + Description: `(HGET key field [field ...]) +Retrieve the value of each of the listed fields from the hash.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: hgetKeyFunc, + HandlerFunc: handleHGET, + }, + { + Command: "hmget", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.ReadCategory, constants.FastCategory}, + Description: `(HMGET key field [field ...]) +Retrieve the value of each of the listed fields from the hash.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: hmgetKeyFunc, + HandlerFunc: handleHMGET, + }, + { + Command: "hstrlen", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.ReadCategory, constants.FastCategory}, + Description: `(HSTRLEN key field [field ...]) +Return the string length of the values stored at the specified fields. 0 if the value does not exist.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: hstrlenKeyFunc, + HandlerFunc: handleHSTRLEN, + }, + { + Command: "hvals", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(HVALS key) Returns all the values of the hash at key.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: hvalsKeyFunc, + HandlerFunc: handleHVALS, + }, + { + Command: "hrandfield", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(HRANDFIELD key [count [WITHVALUES]]) Returns one or more random fields from the hash.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: hrandfieldKeyFunc, + HandlerFunc: handleHRANDFIELD, + }, + { + Command: "hlen", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.ReadCategory, constants.FastCategory}, + Description: `(HLEN key) Returns the number of fields in the hash.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: hlenKeyFunc, + HandlerFunc: handleHLEN, + }, + { + Command: "hkeys", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(HKEYS key) Returns all the fields in a hash.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: hkeysKeyFunc, + HandlerFunc: handleHKEYS, + }, + { + Command: "hincrbyfloat", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(HINCRBYFLOAT key field increment) Increment the hash value by the float increment.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: hincrbyKeyFunc, + HandlerFunc: handleHINCRBY, + }, + { + Command: "hincrby", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(HINCRBY key field increment) Increment the hash value by the integer increment`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: hincrbyKeyFunc, + HandlerFunc: handleHINCRBY, + }, + { + Command: "hgetall", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(HGETALL key) Get all fields and values of a hash.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: hgetallKeyFunc, + HandlerFunc: handleHGETALL, + }, + { + Command: "hexists", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.ReadCategory, constants.FastCategory}, + Description: `(HEXISTS key field) Returns if field is an existing field in the hash.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: hexistsKeyFunc, + HandlerFunc: handleHEXISTS, + }, + { + Command: "hdel", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(HDEL key field [field ...]) Deletes the specified fields from the hash.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: hdelKeyFunc, + HandlerFunc: handleHDEL, + }, + { + Command: "hexpire", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(HEXPIRE key seconds [NX | XX | GT | LT] FIELDS numfields field [field ...]) Sets the expiration, in seconds, of a field in a hash.`, + Sync: true, + KeyExtractionFunc: hexpireKeyFunc, + HandlerFunc: handleHEXPIRE, + }, + { + Command: "httl", + Module: constants.HashModule, + Categories: []string{constants.HashCategory, constants.ReadCategory, constants.FastCategory}, + Description: `HTTL key FIELDS numfields field [field ...] Returns the remaining TTL (time to live) of a hash key's field(s) that have a set expiration.`, + Sync: true, + KeyExtractionFunc: httlKeyFunc, + HandlerFunc: handleHTTL, + }, + } +} diff --git a/internal/modules/hash/commands_test.go b/internal/modules/hash/commands_test.go new file mode 100644 index 0000000..199befb --- /dev/null +++ b/internal/modules/hash/commands_test.go @@ -0,0 +1,2495 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package hash_test + +import ( + "errors" + "fmt" + "slices" + "strconv" + "strings" + "testing" + "time" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/clock" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" + "apigo.cc/go/sugardb/internal/modules/hash" + "apigo.cc/go/sugardb/sugardb" + "github.com/tidwall/resp" +) + +func Test_Hash(t *testing.T) { + mockClock := clock.NewClock() + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := sugardb.NewSugarDB( + sugardb.WithConfig(config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.NoEviction, + }), + ) + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + t.Run("Test_HandleHSET", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + // Tests for both HSet and HSetNX + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse int // Change count + expectedValue map[string]string + expectedError error + }{ + { + name: "1. HSETNX set field on non-existent hash map", + key: "HsetKey1", + presetValue: nil, + command: []string{"HSETNX", "HsetKey1", "field1", "value1"}, + expectedResponse: 1, + expectedValue: map[string]string{"field1": "value1"}, + expectedError: nil, + }, + { + name: "2. HSETNX set field on existing hash map", + key: "HsetKey2", + presetValue: map[string]string{"field1": "value1"}, + command: []string{"HSETNX", "HsetKey2", "field2", "value2"}, + expectedResponse: 1, + expectedValue: map[string]string{"field1": "value1", "field2": "value2"}, + expectedError: nil, + }, + { + name: "3. HSETNX skips operation when setting on existing field", + key: "HsetKey3", + presetValue: map[string]string{"field1": "value1"}, + command: []string{"HSETNX", "HsetKey3", "field1", "value1-new"}, + expectedResponse: 0, + expectedValue: map[string]string{"field1": "value1"}, + expectedError: nil, + }, + { + name: "4. Regular HSET command on non-existent hash map", + key: "HsetKey4", + presetValue: nil, + command: []string{"HSET", "HsetKey4", "field1", "value1", "field2", "value2"}, + expectedResponse: 2, + expectedValue: map[string]string{"field1": "value1", "field2": "value2"}, + expectedError: nil, + }, + { + name: "5. Regular HSET update on existing hash map", + key: "HsetKey5", + presetValue: map[string]string{"field1": "value1", "field2": "value2"}, + command: []string{"HSET", "HsetKey5", "field1", "value1-new", "field2", "value2-ne2", "field3", "value3"}, + expectedResponse: 3, + expectedValue: map[string]string{"field1": "value1-new", "field2": "value2-ne2", "field3": "value3"}, + expectedError: nil, + }, + { + name: "6. HSET overwrites when the target key is not a map", + key: "HsetKey6", + presetValue: "Default preset value", + command: []string{"HSET", "HsetKey6", "field1", "value1"}, + expectedResponse: 1, + expectedValue: map[string]string{"field1": "value1"}, + expectedError: nil, + }, + { + name: "7. HSET returns error when there's a mismatch in key/values", + key: "HsetKey7", + presetValue: nil, + command: []string{"HSET", "HsetKey7", "field1", "value1", "field2"}, + expectedResponse: 0, + expectedValue: map[string]string{}, + expectedError: errors.New("each field must have a corresponding value"), + }, + { + name: "8. Command too short", + key: "HsetKey8", + presetValue: nil, + command: []string{"HSET", "field1"}, + expectedResponse: 0, + expectedValue: map[string]string{}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case map[string]string: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(map[string]string) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value)}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(map[string]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + + // Check that all the values are what is expected + if err := client.WriteArray([]resp.Value{ + resp.StringValue("HGETALL"), + resp.StringValue(test.key), + }); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + for idx, field := range res.Array() { + if idx%2 == 0 { + if res.Array()[idx+1].String() != test.expectedValue[field.String()] { + t.Errorf( + "expected value \"%+v\" for field \"%s\", got \"%+v\"", + test.expectedValue[field.String()], field.String(), res.Array()[idx+1].String(), + ) + } + } + } + }) + } + }) + + t.Run("Test_HandleHINCRBY", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + // Tests for both HIncrBy and HIncrByFloat + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse string // Change count + expectedValue map[string]string + expectedError error + }{ + { + name: "1. Increment by integer on non-existent hash should create a new one", + key: "HincrbyKey1", + presetValue: nil, + command: []string{"HINCRBY", "HincrbyKey1", "field1", "1"}, + expectedResponse: "1", + expectedValue: map[string]string{"field1": "1"}, + expectedError: nil, + }, + { + name: "2. Increment by float on non-existent hash should create one", + key: "HincrbyKey2", + presetValue: nil, + command: []string{"HINCRBYFLOAT", "HincrbyKey2", "field1", "3.142"}, + expectedResponse: "3.142", + expectedValue: map[string]string{"field1": "3.142"}, + expectedError: nil, + }, + { + name: "3. Increment by integer on existing hash", + key: "HincrbyKey3", + presetValue: map[string]string{"field1": "1"}, + command: []string{"HINCRBY", "HincrbyKey3", "field1", "10"}, + expectedResponse: "11", + expectedValue: map[string]string{"field1": "11"}, + expectedError: nil, + }, + { + name: "4. Increment by float on an existing hash", + key: "HincrbyKey4", + presetValue: map[string]string{"field1": "3.142"}, + command: []string{"HINCRBYFLOAT", "HincrbyKey4", "field1", "3.142"}, + expectedResponse: "6.284", + expectedValue: map[string]string{"field1": "6.284"}, + expectedError: nil, + }, + { + name: "5. Command too short", + key: "HincrbyKey5", + presetValue: nil, + command: []string{"HINCRBY", "HincrbyKey5"}, + expectedResponse: "0", + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Command too long", + key: "HincrbyKey6", + presetValue: nil, + command: []string{"HINCRBY", "HincrbyKey6", "field1", "23", "45"}, + expectedResponse: "0", + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "7. Error when increment by float does not pass valid float", + key: "HincrbyKey7", + presetValue: nil, + command: []string{"HINCRBYFLOAT", "HincrbyKey7", "field1", "three point one four two"}, + expectedResponse: "0", + expectedValue: nil, + expectedError: errors.New("increment must be a float"), + }, + { + name: "8. Error when increment does not pass valid integer", + key: "HincrbyKey8", + presetValue: nil, + command: []string{"HINCRBY", "HincrbyKey8", "field1", "three"}, + expectedResponse: "0", + expectedValue: nil, + expectedError: errors.New("increment must be an integer"), + }, + { + name: "9. Error when trying to increment on a key that is not a hash", + key: "HincrbyKey9", + presetValue: "Default value", + command: []string{"HINCRBY", "HincrbyKey9", "field1", "3"}, + expectedResponse: "0", + expectedValue: nil, + expectedError: errors.New("value at HincrbyKey9 is not a hash"), + }, + { + name: "10. Error when trying to increment a hash field that is not a number", + key: "HincrbyKey10", + presetValue: map[string]string{"field1": "value1"}, + command: []string{"HINCRBY", "HincrbyKey10", "field1", "3"}, + expectedResponse: "0", + expectedValue: nil, + expectedError: errors.New("value at field field1 is not a number"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case map[string]string: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(map[string]string) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value)}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(map[string]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.String() != test.expectedResponse { + t.Errorf("expected response \"%s\", got \"%s\"", test.expectedResponse, res.String()) + } + + // Check that all the values are what is expected + if err := client.WriteArray([]resp.Value{ + resp.StringValue("HGETALL"), + resp.StringValue(test.key), + }); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + for idx, field := range res.Array() { + if idx%2 == 0 { + if res.Array()[idx+1].String() != test.expectedValue[field.String()] { + t.Errorf( + "expected value \"%+v\" for field \"%s\", got \"%+v\"", + test.expectedValue[field.String()], field.String(), res.Array()[idx+1].String(), + ) + } + } + } + }) + } + }) + + t.Run("Test_HandleHGET", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse []string // Change count + expectedValue map[string]string + expectedError error + }{ + { + name: "1. Get values from existing hash.", + key: "HgetKey1", + presetValue: map[string]string{"field1": "value1", "field2": "365", "field3": "3.142"}, + command: []string{"HGET", "HgetKey1", "field1", "field2", "field3", "field4"}, + expectedResponse: []string{"value1", "365", "3.142", ""}, + expectedValue: map[string]string{"field1": "value1", "field2": "365", "field3": "3.142"}, + expectedError: nil, + }, + { + name: "2. Return nil when attempting to get from non-existed key", + key: "HgetKey2", + presetValue: nil, + command: []string{"HGET", "HgetKey2", "field1"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: nil, + }, + { + name: "3. Error when trying to get from a value that is not a hash map", + key: "HgetKey3", + presetValue: "Default Value", + command: []string{"HGET", "HgetKey3", "field1"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: errors.New("value at HgetKey3 is not a hash"), + }, + { + name: "4. Command too short", + key: "HgetKey4", + presetValue: nil, + command: []string{"HGET", "HgetKey4"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case map[string]string: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(map[string]string) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value)}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(map[string]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if test.expectedResponse == nil { + if !res.IsNull() { + t.Errorf("expected nil response, got %+v", res) + } + return + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.String()) { + t.Errorf("unexpected element \"%s\" in response", item.String()) + } + } + + // Check that all the values are what is expected + if err := client.WriteArray([]resp.Value{ + resp.StringValue("HGETALL"), + resp.StringValue(test.key), + }); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + for idx, field := range res.Array() { + if idx%2 == 0 { + if res.Array()[idx+1].String() != test.expectedValue[field.String()] { + t.Errorf( + "expected value \"%+v\" for field \"%s\", got \"%+v\"", + test.expectedValue[field.String()], field.String(), res.Array()[idx+1].String(), + ) + } + } + } + }) + } + }) + + t.Run("Test_HandleHMGET", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse []string // Change count + expectedValue map[string]string + expectedError error + }{ + { + name: "1. Get values from existing hash.", + key: "HmgetKey1", + presetValue: map[string]string{"field1": "value1", "field2": "365", "field3": "3.142"}, + command: []string{"HMGET", "HmgetKey1", "field1", "field2", "field3", "field4"}, + expectedResponse: []string{"value1", "365", "3.142", ""}, + expectedValue: map[string]string{"field1": "value1", "field2": "365", "field3": "3.142"}, + expectedError: nil, + }, + { + name: "2. Return nil when attempting to get from non-existed key", + key: "HmgetKey2", + presetValue: nil, + command: []string{"HMGET", "HmgetKey2", "field1"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: nil, + }, + { + name: "3. Error when trying to get from a value that is not a hash map", + key: "HmgetKey3", + presetValue: "Default Value", + command: []string{"HMGET", "HmgetKey3", "field1"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: errors.New("value at HmgetKey3 is not a hash"), + }, + { + name: "4. Command too short", + key: "HmgetKey4", + presetValue: nil, + command: []string{"HMGET", "HmgetKey4"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case map[string]string: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(map[string]string) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value)}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(map[string]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if test.expectedResponse == nil { + if !res.IsNull() { + t.Errorf("expected nil response, got %+v", res) + } + return + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.String()) { + t.Errorf("unexpected element \"%s\" in response", item.String()) + } + } + + // Check that all the values are what is expected + if err := client.WriteArray([]resp.Value{ + resp.StringValue("HGETALL"), + resp.StringValue(test.key), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + for idx, field := range res.Array() { + if idx%2 == 0 { + if res.Array()[idx+1].String() != test.expectedValue[field.String()] { + t.Errorf( + "expected value \"%+v\" for field \"%s\", got \"%+v\"", + test.expectedValue[field.String()], field.String(), res.Array()[idx+1].String(), + ) + } + } + } + }) + } + }) + + t.Run("Test_HandleHSTRLEN", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse []int // Change count + expectedValue map[string]string + expectedError error + }{ + { + // Return lengths of field values. + // If the key does not exist, its length should be 0. + name: "1. Return lengths of field values.", + key: "HstrlenKey1", + presetValue: map[string]string{"field1": "value1", "field2": "123456789", "field3": "3.142"}, + command: []string{"HSTRLEN", "HstrlenKey1", "field1", "field2", "field3", "field4"}, + expectedResponse: []int{len("value1"), len("123456789"), len("3.142"), 0}, + expectedValue: map[string]string{"field1": "value1", "field2": "123456789", "field3": "3.142"}, + expectedError: nil, + }, + { + name: "2. Nil response when trying to get HSTRLEN non-existent key", + key: "HstrlenKey2", + presetValue: nil, + command: []string{"HSTRLEN", "HstrlenKey2", "field1"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: nil, + }, + { + name: "3. Command too short", + key: "HstrlenKey3", + presetValue: nil, + command: []string{"HSTRLEN", "HstrlenKey3"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "4. Trying to get lengths on a non hash map returns error", + key: "HstrlenKey4", + presetValue: "Default value", + command: []string{"HSTRLEN", "HstrlenKey4", "field1"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: errors.New("value at HstrlenKey4 is not a hash"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case map[string]string: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(map[string]string) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value)}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(map[string]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if test.expectedResponse == nil { + if !res.IsNull() { + t.Errorf("expected nil response, got %+v", res) + } + return + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.Integer()) { + t.Errorf("unexpected element \"%d\" in response", item.Integer()) + } + } + + // Check that all the values are what is expected + if err := client.WriteArray([]resp.Value{ + resp.StringValue("HGETALL"), + resp.StringValue(test.key), + }); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + for idx, field := range res.Array() { + if idx%2 == 0 { + if res.Array()[idx+1].String() != test.expectedValue[field.String()] { + t.Errorf( + "expected value \"%+v\" for field \"%s\", got \"%+v\"", + test.expectedValue[field.String()], field.String(), res.Array()[idx+1].String(), + ) + } + } + } + }) + } + }) + + t.Run("Test_HandleHVALS", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse []string + expectedValue map[string]string + expectedError error + }{ + { + name: "1. Return all the values from a hash", + key: "HvalsKey1", + presetValue: map[string]string{"field1": "value1", "field2": "123456789", "field3": "3.142"}, + command: []string{"HVALS", "HvalsKey1"}, + expectedResponse: []string{"value1", "123456789", "3.142"}, + expectedValue: map[string]string{"field1": "value1", "field2": "123456789", "field3": "3.142"}, + expectedError: nil, + }, + { + name: "2. Empty array response when trying to get HSTRLEN non-existent key", + key: "HvalsKey2", + presetValue: nil, + command: []string{"HVALS", "HvalsKey2"}, + expectedResponse: []string{}, + expectedValue: nil, + expectedError: nil, + }, + { + name: "3. Command too short", + key: "HvalsKey3", + presetValue: nil, + command: []string{"HVALS"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "4. Command too long", + key: "HvalsKey4", + presetValue: nil, + command: []string{"HVALS", "HvalsKey4", "HvalsKey4"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Trying to get lengths on a non hash map returns error", + key: "HvalsKey5", + presetValue: "Default value", + command: []string{"HVALS", "HvalsKey5"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: errors.New("value at HvalsKey5 is not a hash"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case map[string]string: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(map[string]string) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value)}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(map[string]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if test.expectedResponse == nil { + if !res.IsNull() { + t.Errorf("expected nil response, got %+v", res) + } + return + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.String()) { + t.Errorf("unexpected element \"%s\" in response", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleHRANDFIELD", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse []string + expectedError error + }{ + { + name: "1. Get a random field", + key: "HrandfieldKey1", + presetValue: map[string]string{"field1": "value1", "field2": "123456789", "field3": "3.142"}, + command: []string{"HRANDFIELD", "HrandfieldKey1"}, + expectedResponse: []string{"field1", "field2", "field3"}, + expectedError: nil, + }, + { + name: "2. Get a random field with a value", + key: "HrandfieldKey2", + presetValue: map[string]string{"field1": "value1", "field2": "123456789", "field3": "3.142"}, + command: []string{"HRANDFIELD", "HrandfieldKey2", "1", "WITHVALUES"}, + expectedResponse: []string{"field1", "value1", "field2", "123456789", "field3", "3.142"}, + expectedError: nil, + }, + { + name: "3. Get several random fields", + key: "HrandfieldKey3", + presetValue: map[string]string{ + "field1": "value1", + "field2": "123456789", + "field3": "3.142", + "field4": "value4", + "field5": "value5", + }, + command: []string{"HRANDFIELD", "HrandfieldKey3", "3"}, + expectedResponse: []string{"field1", "field2", "field3", "field4", "field5"}, + expectedError: nil, + }, + { + name: "4. Get several random fields with their corresponding values", + key: "HrandfieldKey4", + presetValue: map[string]string{ + "field1": "value1", + "field2": "123456789", + "field3": "3.142", + "field4": "value4", + "field5": "value5", + }, + command: []string{"HRANDFIELD", "HrandfieldKey4", "3", "WITHVALUES"}, + expectedResponse: []string{ + "field1", "value1", "field2", "123456789", "field3", + "3.142", "field4", "value4", "field5", "value5", + }, + expectedError: nil, + }, + { + name: "5. Get the entire hash", + key: "HrandfieldKey5", + presetValue: map[string]string{ + "field1": "value1", + "field2": "123456789", + "field3": "3.142", + "field4": "value4", + "field5": "value5", + }, + command: []string{"HRANDFIELD", "HrandfieldKey5", "5"}, + expectedResponse: []string{"field1", "field2", "field3", "field4", "field5"}, + expectedError: nil, + }, + { + name: "6. Get the entire hash with values", + key: "HrandfieldKey5", + presetValue: map[string]string{ + "field1": "value1", + "field2": "123456789", + "field3": "3.142", + "field4": "value4", + "field5": "value5", + }, + command: []string{"HRANDFIELD", "HrandfieldKey5", "5", "WITHVALUES"}, + expectedResponse: []string{ + "field1", "value1", "field2", "123456789", "field3", + "3.142", "field4", "value4", "field5", "value5", + }, + expectedError: nil, + }, + { + name: "7. Command too short", + key: "HrandfieldKey10", + presetValue: nil, + command: []string{"HRANDFIELD"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "8. Command too long", + key: "HrandfieldKey11", + presetValue: nil, + command: []string{"HRANDFIELD", "HrandfieldKey11", "HrandfieldKey11", "HrandfieldKey11", "HrandfieldKey11"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "9. Trying to get random field on a non hash map returns error", + key: "HrandfieldKey12", + presetValue: "Default value", + command: []string{"HRANDFIELD", "HrandfieldKey12"}, + expectedError: errors.New("value at HrandfieldKey12 is not a hash"), + }, + { + name: "10. Throw error when count provided is not an integer", + key: "HrandfieldKey12", + presetValue: "Default value", + command: []string{"HRANDFIELD", "HrandfieldKey12", "COUNT"}, + expectedError: errors.New("count must be an integer"), + }, + { + name: "11. If fourth argument is provided, it must be \"WITHVALUES\"", + key: "HrandfieldKey12", + presetValue: "Default value", + command: []string{"HRANDFIELD", "HrandfieldKey12", "10", "FLAG"}, + expectedError: errors.New("result modifier must be withvalues"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case map[string]string: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(map[string]string) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value)}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(map[string]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if test.expectedResponse == nil { + if !res.IsNull() { + t.Errorf("expected nil response, got %+v", res) + } + return + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.String()) { + t.Errorf("unexpected element \"%s\" in response", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleHLEN", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse int // Change count + expectedError error + }{ + { + name: "1. Return the correct length of the hash", + key: "HlenKey1", + presetValue: map[string]string{"field1": "value1", "field2": "123456789", "field3": "3.142"}, + command: []string{"HLEN", "HlenKey1"}, + expectedResponse: 3, + expectedError: nil, + }, + { + name: "2. 0 response when trying to call HLEN on non-existent key", + key: "HlenKey2", + presetValue: nil, + command: []string{"HLEN", "HlenKey2"}, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "3. Command too short", + key: "HlenKey3", + presetValue: nil, + command: []string{"HLEN"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "4. Command too long", + presetValue: nil, + command: []string{"HLEN", "HlenKey4", "HlenKey4"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Trying to get lengths on a non hash map returns error", + key: "HlenKey5", + presetValue: "Default value", + command: []string{"HLEN", "HlenKey5"}, + expectedResponse: 0, + expectedError: errors.New("value at HlenKey5 is not a hash"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case map[string]string: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(map[string]string) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value)}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(map[string]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleHKeys", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse []string + expectedError error + }{ + { + name: "1. Return an array containing all the keys of the hash", + key: "HkeysKey1", + presetValue: map[string]string{"field1": "value1", "field2": "123456789", "field3": "3.142"}, + command: []string{"HKEYS", "HkeysKey1"}, + expectedResponse: []string{"field1", "field2", "field3"}, + expectedError: nil, + }, + { + name: "2. Empty array response when trying to call HKEYS on non-existent key", + key: "HkeysKey2", + presetValue: nil, + command: []string{"HKEYS", "HkeysKey2"}, + expectedResponse: []string{}, + expectedError: nil, + }, + { + name: "3. Command too short", + key: "HkeysKey3", + presetValue: nil, + command: []string{"HKEYS"}, + expectedResponse: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "4. Command too long", + key: "HkeysKey4", + presetValue: nil, + command: []string{"HKEYS", "HkeysKey4", "HkeysKey4"}, + expectedResponse: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Trying to get lengths on a non hash map returns error", + key: "HkeysKey5", + presetValue: "Default value", + command: []string{"HKEYS", "HkeysKey5"}, + expectedError: errors.New("value at HkeysKey5 is not a hash"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case map[string]string: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(map[string]string) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value)}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(map[string]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.String()) { + t.Errorf("unexpected value \"%s\" in response", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleHGETALL", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse hash.Hash + expectedError error + }{ + { + name: "1. Return an array containing all the fields and values of the hash", + key: "HGetAllKey1", + presetValue: hash.Hash{"field1": hash.HashValue{Value: "value1"}, "field2": hash.HashValue{Value: "123456789"}, "field3": hash.HashValue{Value: "3.142"}}, + command: []string{"HGETALL", "HGetAllKey1"}, + expectedResponse: hash.Hash{"field1": hash.HashValue{Value: "value1"}, "field2": hash.HashValue{Value: "123456789"}, "field3": hash.HashValue{Value: "3.142"}}, + expectedError: nil, + }, + { + name: "2. Empty array response when trying to call HGETALL on non-existent key", + key: "HGetAllKey2", + presetValue: nil, + command: []string{"HGETALL", "HGetAllKey2"}, + expectedResponse: nil, + expectedError: nil, + }, + { + name: "3. Command too short", + key: "HGetAllKey3", + presetValue: nil, + command: []string{"HGETALL"}, + expectedResponse: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "4. Command too long", + key: "HGetAllKey4", + presetValue: nil, + command: []string{"HGETALL", "HGetAllKey4", "HGetAllKey4"}, + expectedResponse: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Trying to get lengths on a non hash map returns error", + key: "HGetAllKey5", + presetValue: "Default value", + command: []string{"HGETALL", "HGetAllKey5"}, + expectedResponse: nil, + expectedError: errors.New("value at HGetAllKey5 is not a hash"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case hash.Hash: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(hash.Hash) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value.Value.(string))}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(hash.Hash))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if test.expectedResponse == nil { + if len(res.Array()) != 0 { + t.Errorf("expected response to be empty array, got %+v", res) + } + return + } + + for i, item := range res.Array() { + if i%2 == 0 { + field := item.String() + value := hash.HashValue{Value: res.Array()[i+1].String()} + + if test.expectedResponse[field] != value { + t.Errorf("expected value at field \"%s\" to be \"%s\", got \"%s\"", field, test.expectedResponse[field], value) + } + } + } + + }) + } + }) + + t.Run("Test_HandleHEXISTS", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse bool + expectedError error + }{ + { + name: "1. Return 1 if the field exists in the hash", + key: "HexistsKey1", + presetValue: map[string]string{"field1": "value1", "field2": "123456789", "field3": "3.142"}, + command: []string{"HEXISTS", "HexistsKey1", "field1"}, + expectedResponse: true, + expectedError: nil, + }, + { + name: "2. 0 response when trying to call HEXISTS on non-existent key", + key: "HexistsKey2", + presetValue: nil, + command: []string{"HEXISTS", "HexistsKey2", "field1"}, + expectedResponse: false, + expectedError: nil, + }, + { + name: "3. Command too short", + key: "HexistsKey3", + presetValue: nil, + command: []string{"HEXISTS", "HexistsKey3"}, + expectedResponse: false, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "4. Command too long", + key: "HexistsKey4", + presetValue: nil, + command: []string{"HEXISTS", "HexistsKey4", "field1", "field2"}, + expectedResponse: false, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Trying to get lengths on a non hash map returns error", + key: "HexistsKey5", + presetValue: "Default value", + command: []string{"HEXISTS", "HexistsKey5", "field1"}, + expectedResponse: false, + expectedError: errors.New("value at HexistsKey5 is not a hash"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case map[string]string: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(map[string]string) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value)}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(map[string]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Bool() != test.expectedResponse { + t.Errorf("expected response to be %v, got %v", test.expectedResponse, res.Bool()) + } + }) + } + }) + + t.Run("Test_HandleHDEL", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse int + expectedValue map[string]string + expectedError error + }{ + { + name: "1. Return count of deleted fields in the specified hash", + key: "HdelKey1", + presetValue: map[string]string{"field1": "value1", "field2": "123456789", "field3": "3.142", "field7": "value7"}, + command: []string{"HDEL", "HdelKey1", "field1", "field2", "field3", "field4", "field5", "field6"}, + expectedResponse: 3, + expectedValue: map[string]string{"field7": "value7"}, + expectedError: nil, + }, + { + name: "2. 0 response when passing delete fields that are non-existent on valid hash", + key: "HdelKey2", + presetValue: map[string]string{"field1": "value1", "field2": "value2", "field3": "value3"}, + command: []string{"HDEL", "HdelKey2", "field4", "field5", "field6"}, + expectedResponse: 0, + expectedValue: map[string]string{"field1": "value1", "field2": "value2", "field3": "value3"}, + expectedError: nil, + }, + { + name: "3. 0 response when trying to call HDEL on non-existent key", + key: "HdelKey3", + presetValue: nil, + command: []string{"HDEL", "HdelKey3", "field1"}, + expectedResponse: 0, + expectedValue: nil, + expectedError: nil, + }, + { + name: "4. Command too short", + key: "HdelKey4", + presetValue: nil, + command: []string{"HDEL", "HdelKey4"}, + expectedResponse: 0, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Trying to get lengths on a non hash map returns error", + key: "HdelKey5", + presetValue: "Default value", + command: []string{"HDEL", "HdelKey5", "field1"}, + expectedResponse: 0, + expectedValue: nil, + expectedError: errors.New("value at HdelKey5 is not a hash"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case map[string]string: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(map[string]string) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value)}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(map[string]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + + for idx, field := range res.Array() { + if idx%2 == 0 { + if res.Array()[idx+1].String() != test.expectedValue[field.String()] { + t.Errorf( + "expected value \"%+v\" for field \"%s\", got \"%+v\"", + test.expectedValue[field.String()], field.String(), res.Array()[idx+1].String(), + ) + } + } + } + }) + } + }) + + t.Run("Test_HandleHEXPIRE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue hash.Hash + command []string + expectedValue string + expectedError error + }{ + + { + name: "1. Set expiration for all keys in hash, no options.", + key: "HexpireKey1", + presetValue: hash.Hash{ + "HexpireK1Field1": hash.HashValue{ + Value: "default1", + }, + "HexpireK1Field2": hash.HashValue{ + Value: "default2", + }, + "HexpireK1Field3": hash.HashValue{ + Value: "default3", + }, + }, + command: []string{"HEXPIRE", "HexpireKey1", "5", "FIELDS", "3", "HexpireK1Field1", "HexpireK1Field2", "HexpireK1Field3"}, + expectedValue: "[1 1 1]", + expectedError: nil, + }, + { + name: "2. Set expiration for one key in hash, no options.", + key: "HexpireKey2", + presetValue: hash.Hash{ + "HexpireK2Field1": hash.HashValue{ + Value: "default1", + }, + }, + command: []string{"HEXPIRE", "HexpireKey2", "5", "FIELDS", "1", "HexpireK2Field1"}, + expectedValue: "[1]", + expectedError: nil, + }, + { + name: "3. Set expiration, expireTime already populated, no options.", + key: "HexpireKey3", + presetValue: hash.Hash{ + "HexpireK3Field1": hash.HashValue{ + Value: "default1", + ExpireAt: mockClock.Now().Add(500 * time.Second), + }, + }, + command: []string{"HEXPIRE", "HexpireKey3", "100", "FIELDS", "1", "HexpireK3Field1"}, + expectedValue: "[1]", + expectedError: nil, + }, + { + name: "4. Set expiration, option NX with no expire time currently set.", + key: "HexpireKey4", + presetValue: hash.Hash{ + "HexpireK4Field1": hash.HashValue{ + Value: "default1", + }, + }, + command: []string{"HEXPIRE", "HexpireKey4", "5", "NX", "FIELDS", "1", "HexpireK4Field1"}, + expectedValue: "[1]", + expectedError: nil, + }, + { + name: "5. Set expiration, option NX with an expire time already set.", + key: "HexpireKey5", + presetValue: hash.Hash{ + "HexpireK5Field1": hash.HashValue{ + Value: "default1", + ExpireAt: mockClock.Now().Add(500 * time.Second), + }, + }, + command: []string{"HEXPIRE", "HexpireKey5", "100", "NX", "FIELDS", "1", "HexpireK5Field1"}, + expectedValue: "[0]", + expectedError: nil, + }, + { + name: "6. Set expiration, option XX with no expire time currently set.", + key: "HexpireKey6", + presetValue: hash.Hash{ + "HexpireK6Field1": hash.HashValue{ + Value: "default1", + }, + }, + command: []string{"HEXPIRE", "HexpireKey6", "5", "XX", "FIELDS", "1", "HexpireK6Field1"}, + expectedValue: "[0]", + expectedError: nil, + }, + { + name: "7. Set expiration, option XX with expire time already set.", + key: "HexpireKey7", + presetValue: hash.Hash{ + "HexpireK7Field1": hash.HashValue{ + Value: "default1", + ExpireAt: mockClock.Now().Add(500 * time.Second), + }, + }, + command: []string{"HEXPIRE", "HexpireKey7", "100", "XX", "FIELDS", "1", "HexpireK7Field1"}, + expectedValue: "[1]", + expectedError: nil, + }, + { + name: "8. Set expiration, option GT with expire time less than one provided.", + key: "HexpireKey8", + presetValue: hash.Hash{ + "HexpireK8Field1": hash.HashValue{ + Value: "default1", + ExpireAt: mockClock.Now().Add(500 * time.Second), + }, + }, + command: []string{"HEXPIRE", "HexpireKey8", "1000", "GT", "FIELDS", "1", "HexpireK8Field1"}, + expectedValue: "[1]", + expectedError: nil, + }, + { + name: "9. Set expiration, option GT with expire time greater than one provided.", + key: "HexpireKey9", + presetValue: hash.Hash{ + "HexpireK9Field1": hash.HashValue{ + Value: "default1", + ExpireAt: mockClock.Now().Add(500 * time.Second), + }, + }, + command: []string{"HEXPIRE", "HexpireKey9", "100", "GT", "FIELDS", "1", "HexpireK9Field1"}, + expectedValue: "[0]", + expectedError: nil, + }, + { + name: "10. Set expiration, option LT with expire time less than one provided.", + key: "HexpireKey10", + presetValue: hash.Hash{ + "HexpireK10Field1": hash.HashValue{ + Value: "default1", + ExpireAt: mockClock.Now().Add(500 * time.Second), + }, + }, + command: []string{"HEXPIRE", "HexpireKey10", "1000", "LT", "FIELDS", "1", "HexpireK10Field1"}, + expectedValue: "[0]", + expectedError: nil, + }, + { + name: "11. Set expiration, option LT with expire time greater than one provided.", + key: "HexpireKey11", + presetValue: hash.Hash{ + "HexpireK11Field1": hash.HashValue{ + Value: "default1", + ExpireAt: mockClock.Now().Add(500 * time.Second), + }, + }, + command: []string{"HEXPIRE", "HexpireKey11", "100", "LT", "FIELDS", "1", "HexpireK11Field1"}, + expectedValue: "[1]", + expectedError: nil, + }, + { + name: "12. Set expiration, provide 0 seconds.", + key: "HexpireKey12", + presetValue: hash.Hash{ + "HexpireK12Field1": hash.HashValue{ + Value: "default1", + }, + }, + command: []string{"HEXPIRE", "HexpireKey12", "0", "FIELDS", "1", "HexpireK12Field1"}, + expectedValue: "[2]", + expectedError: nil, + }, + { + name: "13. Attempt to set expiration for non existent key.", + key: "HexpireKeyNOTEXIST", + presetValue: nil, + command: []string{"HEXPIRE", "HexpireKeyNOTEXIST", "100", "FIELDS", "1", "HexpireKNEField1"}, + expectedValue: "[-2]", + expectedError: nil, + }, + { + name: "14. Attempt to set expiration for field that doesn't exist.", + key: "HexpireKey14", + presetValue: hash.Hash{ + "HexpireK14Field1": hash.HashValue{ + Value: "default1", + }, + }, + command: []string{"HEXPIRE", "HexpireKey14", "100", "FIELDS", "2", "HexpireK14BadField1", "HexpireK14Field1"}, + expectedValue: "[-2 1]", + expectedError: nil, + }, + { + name: "15. Set expiration, command wrong length.", + key: "HexpireKey15", + presetValue: hash.Hash{ + "HexpireK15Field1": hash.HashValue{ + Value: "default1", + }, + }, + command: []string{"HEXPIRE", "HexpireKey15", "100", "1", "HexpireK15Field1"}, + expectedError: errors.New("Error wrong number of arguments"), + }, + { + name: "16. Set expiration, command filed numfields is not a number.", + key: "HexpireKey16", + presetValue: hash.Hash{ + "HexpireK16Field1": hash.HashValue{ + Value: "default1", + }, + }, + command: []string{"HEXPIRE", "HexpireKey16", "100", "FIELDS", "one", "HexpireK16Field1"}, + expectedError: errors.New("Error numberfields must be integer, was provided \"one\""), + }, + } + + for _, test := range tests { + + t.Run(test.name, func(t *testing.T) { + // set key with preset value + if test.presetValue != nil { + var command []resp.Value + var expected string + + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value.Value.(string))}..., + ) + } + expected = strconv.Itoa(len(test.presetValue)) + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + + } + + // preset Expire Time + for field, value := range test.presetValue { + if value.ExpireAt != (time.Time{}) { + cmd := []resp.Value{ + resp.StringValue("HEXPIRE"), + resp.StringValue(test.key), + resp.StringValue("500"), + resp.StringValue("FIELDS"), + resp.StringValue("1"), + resp.StringValue(field), + } + + if err = client.WriteArray(cmd); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if res.String() != "[1]" { + t.Errorf("Error presetting expire time - Key: %s, Field: %s, response: %s", test.key, field, res.String()) + } + } + } + + // run HEXPIRE command + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error()) + } + return + } + + if res.String() != test.expectedValue { + t.Errorf("expected response %q, got %q", test.expectedValue, res.String()) + } + + }) + + } + + }) + + t.Run("Test_HandleHTTL", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + command []string + key string + presetValue interface{} + setExpire bool + expectedValue string + expectedError error + }{ + { + name: "1. Get TTL for one field when expireTime is set.", + key: "HTTLKey1", + command: []string{"HTTL", "HTTLKey1", "FIELDS", "1", "HTTLK1Field1"}, + presetValue: hash.Hash{ + "HTTLK1Field1": hash.HashValue{ + Value: "default1", + }, + }, + setExpire: true, + expectedValue: "[5]", + expectedError: nil, + }, + { + name: "2. Get TTL for multiple fields when expireTime is set.", + key: "HTTLKey2", + command: []string{"HTTL", "HTTLKey2", "FIELDS", "3", "HTTLK2Field1", "HTTLK2Field2", "HTTLK2Field3"}, + presetValue: hash.Hash{ + "HTTLK2Field1": hash.HashValue{ + Value: "default1", + }, + "HTTLK2Field2": hash.HashValue{ + Value: "default1", + }, + "HTTLK2Field3": hash.HashValue{ + Value: "default1", + }, + }, + setExpire: true, + expectedValue: "[5 5 5]", + expectedError: nil, + }, + { + name: "3. Get TTL for one field when expireTime is not set.", + key: "HTTLKey3", + command: []string{"HTTL", "HTTLKey3", "FIELDS", "1", "HTTLK3Field1"}, + presetValue: hash.Hash{ + "HTTLK3Field1": hash.HashValue{ + Value: "default1", + }, + }, + setExpire: false, + expectedValue: "[-1]", + expectedError: nil, + }, + { + name: "4. Get TTL for multiple fields when expireTime is not set.", + key: "HTTLKey4", + command: []string{"HTTL", "HTTLKey4", "FIELDS", "3", "HTTLK4Field1", "HTTLK4Field2", "HTTLK4Field3"}, + presetValue: hash.Hash{ + "HTTLK4Field1": hash.HashValue{ + Value: "default1", + }, + "HTTLK4Field2": hash.HashValue{ + Value: "default1", + }, + "HTTLK4Field3": hash.HashValue{ + Value: "default1", + }, + }, + setExpire: false, + expectedValue: "[-1 -1 -1]", + expectedError: nil, + }, + { + name: "5. Try to get TTL for key that doesn't exist.", + key: "HTTLKeyNOTEXIST", + command: []string{"HTTL", "HTTLKeyNOTEXIST", "FIELDS", "1", "HTTLK1Field1"}, + presetValue: nil, + setExpire: false, + expectedValue: "[-2]", + expectedError: nil, + }, + { + name: "6. Try to get TTL for key that isn't a hash.", + key: "HTTLKey6", + command: []string{"HTTL", "HTTLKey6", "FIELDS", "1", "HTTLK6Field1"}, + presetValue: "NotaHash", + setExpire: false, + expectedError: errors.New("Error value at HTTLKey6 is not a hash"), + }, + { + name: "7. Command missing 'FIELDS'.", + key: "HTTLKey7", + command: []string{"HTTL", "HTTLKey7", "1", "HTTLK7Field1"}, + presetValue: hash.Hash{ + "HTTLK7Field1": hash.HashValue{ + Value: "default1", + }, + }, + setExpire: false, + expectedError: errors.New("Error wrong number of arguments"), + }, + { + name: "8. Command numfields provided isn't a number.", + key: "HTTLKey8", + command: []string{"HTTL", "HTTLKey8", "FIELDS", "one", "HTTLK8Field1"}, + presetValue: hash.Hash{ + "HTTLK8Field1": hash.HashValue{ + Value: "default1", + }, + }, + setExpire: false, + expectedError: errors.New("Error expire time must be integer, was provided \"one\""), + }, + { + name: "9. Command missing numfields.", + key: "HTTLKey9", + command: []string{"HTTL", "HTTLKey9", "FIELDS", "HTTLK9Field1"}, + presetValue: hash.Hash{ + "HTTLK9Field1": hash.HashValue{ + Value: "default1", + }, + }, + setExpire: false, + expectedError: errors.New("Error wrong number of arguments"), + }, + { + name: "10. Command FIELDS index contains something else.", + key: "HTTLKey10", + command: []string{"HTTL", "HTTLKey10", "NOTFIELDS", "1", "HTTLK10Field1"}, + presetValue: hash.Hash{ + "HTTLK10Field1": hash.HashValue{ + Value: "default1", + }, + }, + setExpire: false, + expectedError: errors.New("Error invalid command provided"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // set preset values + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case hash.Hash: + command = []resp.Value{resp.StringValue("HSET"), resp.StringValue(test.key)} + for key, value := range test.presetValue.(hash.Hash) { + command = append(command, []resp.Value{ + resp.StringValue(key), + resp.StringValue(value.Value.(string))}..., + ) + } + expected = strconv.Itoa(len(test.presetValue.(hash.Hash))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + + } + + if test.setExpire { + // set expire times + command := make([]resp.Value, len(test.presetValue.(hash.Hash))+5) + command[0] = resp.StringValue("HEXPIRE") + command[1] = resp.StringValue(test.key) + command[2] = resp.StringValue("5") + command[3] = resp.StringValue("FIELDS") + command[4] = resp.StringValue(fmt.Sprintf("%v", (len(test.presetValue.(hash.Hash))))) + + i := 0 + for k, _ := range test.presetValue.(hash.Hash) { + command[5+i] = resp.StringValue(k) + i++ + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + _, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + } + + // read TTL + command := make([]resp.Value, len(test.command)) + for i, v := range test.command { + command[i] = resp.StringValue(v) + } + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + resp, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(resp.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), resp.Error()) + } + + return + } + + if resp.String() != test.expectedValue { + t.Errorf("Expected value %v but got %v", test.expectedValue, resp) + } + + }) + + } + + }) +} diff --git a/internal/modules/hash/hash.go b/internal/modules/hash/hash.go new file mode 100644 index 0000000..982058e --- /dev/null +++ b/internal/modules/hash/hash.go @@ -0,0 +1,48 @@ +package hash + +import ( + "time" + "unsafe" + + "apigo.cc/go/sugardb/internal/constants" +) + +type HashValue struct { + Value interface{} + ExpireAt time.Time +} + +type Hash map[string]HashValue + +func (h Hash) GetMem() int64 { + + var size int64 + // Map headers + size += int64(unsafe.Sizeof(h)) + + for key, val := range h { + + size += int64(unsafe.Sizeof(key)) + size += int64(len(key)) + + size += int64(unsafe.Sizeof(val)) + size += int64(unsafe.Sizeof(val.ExpireAt)) + + switch vt := val.Value.(type) { + + // AdaptType() will always ensure data type is of string, float64 or int. + case nil: + size += 0 + case int: + size += int64(unsafe.Sizeof(vt)) + case float64, int64: + size += 8 + case string: + size += int64(unsafe.Sizeof(vt)) + size += int64(len(vt)) + } + } + return size +} + +var _ constants.CompositeType = (*Hash)(nil) diff --git a/internal/modules/hash/key_funcs.go b/internal/modules/hash/key_funcs.go new file mode 100644 index 0000000..fba13c4 --- /dev/null +++ b/internal/modules/hash/key_funcs.go @@ -0,0 +1,200 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package hash + +import ( + "errors" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" +) + +func hsetKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func hsetnxKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func hgetKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func hmgetKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func hstrlenKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func hvalsKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func hrandfieldKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 || len(cmd) > 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + if len(cmd) == 2 { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func hlenKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func hkeysKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func hincrbyKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func hgetallKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func hexistsKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func hdelKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func hexpireKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 6 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:], + }, nil +} + +func httlKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 5 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + + if cmd[2] != "FIELDS" { + return internal.KeyExtractionFuncResult{}, errors.New(constants.InvalidCmdResponse) + } + + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} diff --git a/internal/modules/list/commands.go b/internal/modules/list/commands.go new file mode 100644 index 0000000..757fc83 --- /dev/null +++ b/internal/modules/list/commands.go @@ -0,0 +1,643 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package list + +import ( + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" + "slices" + "strconv" + "strings" +) + +func handleLLen(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := llenKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + if !keyExists { + // If key does not exist, return 0 + return []byte(":0\r\n"), nil + } + + if list, ok := params.GetValues(params.Context, []string{key})[key].([]string); ok { + return []byte(fmt.Sprintf(":%d\r\n", len(list))), nil + } + + return nil, errors.New("LLEN command on non-list item") +} + +func handleLIndex(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := lindexKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + if !keyExists { + return []byte(fmt.Sprintf("$-1\r\n")), nil + } + + list, ok := params.GetValues(params.Context, []string{key})[key].([]string) + if !ok { + return nil, errors.New("LINDEX command on non-list item") + } + + index, err := strconv.Atoi(params.Command[2]) + if err != nil { + return nil, errors.New("index must be an integer") + } + // If index is less than 0, calculate index from the end of the list + if index < 0 { + index = len(list) + index + } + + if index >= len(list) || index < 0 { + return []byte(fmt.Sprintf("$-1\r\n")), nil + } + + return []byte(fmt.Sprintf("$%d\r\n%s\r\n", len(list[index]), list[index])), nil +} + +func handleLRange(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := lrangeKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + if !keyExists { + return []byte("*0\r\n"), nil + } + + list, ok := params.GetValues(params.Context, []string{key})[key].([]string) + if !ok { + return nil, errors.New("LRANGE command on non-list item") + } + + start, err := strconv.Atoi(params.Command[2]) + if err != nil { + return nil, fmt.Errorf("start index must be an integer") + } + // If start is < 0, calculate it from the end of the list + if start < 0 { + start = len(list) + start + } + + end, err := strconv.Atoi(params.Command[3]) + if err != nil { + return nil, fmt.Errorf("end index must be an integer") + } + // If end is < 0, calculate it from the end of the list + if end < 0 { + end = len(list) - end + } + // If end is greater than list length, set it to the last element of the list + if end > len(list) { + end = len(list) - 1 + } + + if start > end || start > len(list) { + return []byte("*0\r\n"), nil + } + + res := fmt.Sprintf("*%d\r\n", end-start+1) + for i := start; i <= end; i++ { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(list[i]), list[i]) + } + + return []byte(res), nil +} + +func handleLSet(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := lsetKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + if !keyExists { + return nil, errors.New("LSET command on non-list item") + } + + index, err := strconv.Atoi(params.Command[2]) + if err != nil { + return nil, errors.New("index must be an integer") + } + + list, ok := params.GetValues(params.Context, []string{key})[key].([]string) + if !ok { + return nil, errors.New("LSET command on non-list item") + } + + // If index is negative set index to length - index + if index < 0 { + index = len(list) + index + } + + if !(index >= 0 && index < len(list)) { + return nil, errors.New("index must be within list range") + } + + list[index] = params.Command[3] + if err = params.SetValues(params.Context, map[string]interface{}{key: list}); err != nil { + return nil, err + } + + return []byte(constants.OkResponse), nil +} + +func handleLTrim(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := ltrimKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + if !keyExists { + return []byte(constants.OkResponse), nil + } + + start, err := strconv.Atoi(params.Command[2]) + if err != nil { + return nil, fmt.Errorf("start index must be an integer") + } + end, err := strconv.Atoi(params.Command[3]) + if err != nil { + return nil, fmt.Errorf("end index must be an integer") + } + + list, ok := params.GetValues(params.Context, []string{key})[key].([]string) + if !ok { + return nil, errors.New("LTRIM command on non-list item") + } + + // If start and end indices are negative, calculate them from the end of the list + if start < 0 { + start = len(list) + start + } + if end < 0 { + end = len(list) + end + } + + // If start index is greater than end index or greater than the index of the last element, delete the key. + if start > end || start > len(list)-1 { + if err = params.DeleteKey(params.Context, key); err != nil { + return nil, err + } + return []byte(constants.OkResponse), nil + } + + // If end is greater than the length of the list, set it to the length of the list + if end > len(list) { + end = len(list) + } + // In order to include end element, if the end index is within range, add 1 + if end <= len(list)-1 { + end += 1 + } + + if err = params.SetValues(params.Context, map[string]interface{}{key: list[start:end]}); err != nil { + return nil, err + } + + return []byte(constants.OkResponse), nil +} + +func handleLRem(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := lremKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + + value := params.Command[3] + count, err := strconv.Atoi(params.Command[2]) + if err != nil { + return nil, errors.New("count must be an integer") + } + absoluteCount := internal.AbsInt(count) + + if !keyExists { + return []byte(":0\r\n"), nil + } + + list, ok := params.GetValues(params.Context, []string{key})[key].([]string) + if !ok { + return nil, errors.New("LREM command on non-list item") + } + + removedCount := len(list) + + switch { + default: + // Count is zero, remove all instances of the element from the list. + for i := 0; i < len(list); i++ { + if list[i] == value { + list = append(list[:i], list[i+1:]...) + absoluteCount += 1 + } + } + case count > 0: + // Start from the head + for i := 0; i < len(list); i++ { + if absoluteCount == 0 { + break + } + if list[i] == value { + list = append(list[:i], list[i+1:]...) + absoluteCount -= 1 + } + } + case count < 0: + // Start from the tail + for i := len(list) - 1; i >= 0; i-- { + if absoluteCount == 0 { + break + } + if list[i] == value { + list = append(list[:i], list[i+1:]...) + absoluteCount -= 1 + removedCount += 0 + } + } + } + + if err = params.SetValues(params.Context, map[string]interface{}{key: list}); err != nil { + return nil, err + } + + removedCount = removedCount - len(list) + return []byte(fmt.Sprintf(":%d\r\n", removedCount)), nil +} + +func handleLMove(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := lmoveKeyFunc(params.Command) + if err != nil { + return nil, err + } + + keysExist := params.KeysExist(params.Context, keys.WriteKeys) + source, destination := keys.WriteKeys[0], keys.WriteKeys[1] + whereFrom := strings.ToLower(params.Command[3]) + whereTo := strings.ToLower(params.Command[4]) + + if !slices.Contains([]string{"left", "right"}, whereFrom) || !slices.Contains([]string{"left", "right"}, whereTo) { + return nil, errors.New("wherefrom and whereto arguments must be either LEFT or RIGHT") + } + + if !keysExist[source] || !keysExist[destination] { + return nil, errors.New("both source and destination must be lists") + } + + lists := params.GetValues(params.Context, keys.WriteKeys) + sourceList, sourceOk := lists[source].([]string) + destinationList, destinationOk := lists[destination].([]string) + + if !sourceOk || !destinationOk { + return nil, errors.New("both source and destination must be lists") + } + + switch whereFrom { + case "left": + err = params.SetValues(params.Context, map[string]interface{}{ + source: append([]string{}, sourceList[1:]...), + destination: func() []string { + if whereTo == "left" { + return append(sourceList[0:1], destinationList...) + } + // whereTo == "right" + return append(destinationList, sourceList[0]) + }(), + }) + case "right": + err = params.SetValues(params.Context, map[string]interface{}{ + source: append([]string{}, sourceList[:len(sourceList)-1]...), + destination: func() []string { + if whereTo == "left" { + return append(sourceList[len(sourceList)-1:], destinationList...) + } + // whereTo == "right" + return append(destinationList, sourceList[len(sourceList)-1]) + }(), + }) + } + + if err != nil { + return nil, err + } + + return []byte(constants.OkResponse), nil +} + +func handleLPush(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := lpushKeyFunc(params.Command) + if err != nil { + return nil, err + } + + var newElems []string + + for _, elem := range params.Command[2:] { + newElems = append(newElems, elem) + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + + if !keyExists { + switch strings.ToLower(params.Command[0]) { + case "lpushx": + return nil, errors.New("LPUSHX command on non-existent key") + default: + if err = params.SetValues(params.Context, map[string]interface{}{key: []string{}}); err != nil { + return nil, err + } + } + } + + currentList := params.GetValues(params.Context, []string{key})[key] + l, ok := currentList.([]string) + if !ok { + return nil, errors.New("LPUSH command on non-list item") + } + + if err = params.SetValues(params.Context, map[string]interface{}{key: append(newElems, l...)}); err != nil { + return nil, err + } + + return []byte(fmt.Sprintf(":%d\r\n", len(l)+len(newElems))), nil +} + +func handleRPush(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := rpushKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + + var newElems []string + + for _, elem := range params.Command[2:] { + newElems = append(newElems, elem) + } + + if !keyExists { + switch strings.ToLower(params.Command[0]) { + case "rpushx": + return nil, errors.New("RPUSHX command on non-existent key") + default: + if err = params.SetValues(params.Context, map[string]interface{}{key: []string{}}); err != nil { + return nil, err + } + } + } + + currentList := params.GetValues(params.Context, []string{key})[key] + l, ok := currentList.([]string) + if !ok { + return nil, errors.New("RPUSH command on non-list item") + } + + if err = params.SetValues(params.Context, map[string]interface{}{key: append(l, newElems...)}); err != nil { + return nil, err + } + return []byte(fmt.Sprintf(":%d\r\n", len(l)+len(newElems))), nil +} + +func handlePop(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := popKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + if !keyExists { + return []byte("$-1\r\n"), nil + } + + list, ok := params.GetValues(params.Context, []string{key})[key].([]string) + if !ok { + return nil, fmt.Errorf("%s command on non-list item", strings.ToUpper(params.Command[0])) + } + + withCount := false + count := 1 + // Parse count + if len(params.Command) == 3 { + withCount = true + count, err = strconv.Atoi(params.Command[2]) + if err != nil { + return nil, fmt.Errorf("count must be an integer") + } + // Set absolute value for count + count = internal.AbsInt(count) + // If count is greater than the length of the list, set count to the length of the list. + if count > len(list) { + count = len(list) + } + } + + // Return nil if list is empty + if len(list) == 0 { + return []byte("$-1\r\n"), nil + } + + var popped []string + for i := 0; i < count; i++ { + if strings.EqualFold(params.Command[0], "lpop") { + // Pop from the left + popped = append(popped, list[0]) + list = list[1:] + } else { + // Pop from the right + popped = append(popped, list[len(list)-1]) + list = list[:len(list)-1] + } + } + if err = params.SetValues(params.Context, map[string]interface{}{key: list}); err != nil { + return nil, err + } + + // If withCount is false, return a bulk string of the popped element. + if !withCount { + return []byte(fmt.Sprintf("$%d\r\n%s\r\n", len(popped[0]), popped[0])), nil + } + // Return an array of the popped elements. + res := fmt.Sprintf("*%d\r\n", len(popped)) + for i := 0; i < len(popped); i++ { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(popped[i]), popped[i]) + } + return []byte(res), nil +} + +func Commands() []internal.Command { + return []internal.Command{ + { + Command: "lpush", + Module: constants.ListModule, + Categories: []string{constants.ListCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(LPUSH key element [element ...]) +Prepends one or more values to the beginning of a list, creates the list if it does not exist.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: lpushKeyFunc, + HandlerFunc: handleLPush, + }, + { + Command: "lpushx", + Module: constants.ListModule, + Categories: []string{constants.ListCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(LPUSHX key element [element ...]) +Prepends a value to the beginning of a list only if the list exists.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: lpushKeyFunc, + HandlerFunc: handleLPush, + }, + { + Command: "lpop", + Module: constants.ListModule, + Categories: []string{constants.ListCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(LPOP key [count]) +Removes count elements from the beginning of the list and returns an array of the elements removed. +Returns a bulk string of the first element when called without count. +Returns an array of n elements from the beginning of the list when called with a count when n=count. `, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: popKeyFunc, + HandlerFunc: handlePop, + }, + { + Command: "llen", + Module: constants.ListModule, + Categories: []string{constants.ListCategory, constants.ReadCategory, constants.FastCategory}, + Description: "(LLEN key) Return the length of a list.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: llenKeyFunc, + HandlerFunc: handleLLen, + }, + { + Command: "lrange", + Module: constants.ListModule, + Categories: []string{constants.ListCategory, constants.ReadCategory, constants.SlowCategory}, + Description: "(LRANGE key start end) Return a range of elements between the given indices.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: lrangeKeyFunc, + HandlerFunc: handleLRange, + }, + { + Command: "lindex", + Module: constants.ListModule, + Categories: []string{constants.ListCategory, constants.ReadCategory, constants.FastCategory}, + Description: "(LINDEX key index) Gets list element by index.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: lindexKeyFunc, + HandlerFunc: handleLIndex, + }, + { + Command: "lset", + Module: constants.ListModule, + Categories: []string{constants.ListCategory, constants.WriteCategory, constants.FastCategory}, + Description: "(LSET key index element) Sets the value of an element in a list by its index.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: lsetKeyFunc, + HandlerFunc: handleLSet, + }, + { + Command: "ltrim", + Module: constants.ListModule, + Categories: []string{constants.ListCategory, constants.WriteCategory, constants.SlowCategory}, + Description: "(LTRIM key start end) Trims a list using the specified range.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: ltrimKeyFunc, + HandlerFunc: handleLTrim, + }, + { + Command: "lrem", + Module: constants.ListModule, + Categories: []string{constants.ListCategory, constants.WriteCategory, constants.SlowCategory}, + Description: "(LREM key count element) Remove elements from list.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: lremKeyFunc, + HandlerFunc: handleLRem, + }, + { + Command: "lmove", + Module: constants.ListModule, + Categories: []string{constants.ListCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(LMOVE source destination ) +Move element from one list to the other specifying left/right for both lists.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: lmoveKeyFunc, + HandlerFunc: handleLMove, + }, + { + Command: "rpop", + Module: constants.ListModule, + Categories: []string{constants.ListCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(RPOP key [count]) +Removes count elements from the end of the list and returns an array of the elements removed. +Returns a bulk string of the last element when called without count. +Returns an array of n elements from the end of the list when called with a count when n=count.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: popKeyFunc, + HandlerFunc: handlePop, + }, + { + Command: "rpush", + Module: constants.ListModule, + Categories: []string{constants.ListCategory, constants.WriteCategory, constants.FastCategory}, + Description: "(RPUSH key element [element ...]) Appends one or multiple elements to the end of a list.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: rpushKeyFunc, + HandlerFunc: handleRPush, + }, + { + Command: "rpushx", + Module: constants.ListModule, + Categories: []string{constants.ListCategory, constants.WriteCategory, constants.FastCategory}, + Description: "(RPUSHX key element [element ...]) Appends an element to the end of a list, only if the list exists.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: rpushKeyFunc, + HandlerFunc: handleRPush, + }, + } +} diff --git a/internal/modules/list/commands_test.go b/internal/modules/list/commands_test.go new file mode 100644 index 0000000..b2a7442 --- /dev/null +++ b/internal/modules/list/commands_test.go @@ -0,0 +1,1929 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package list_test + +import ( + "errors" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" + "apigo.cc/go/sugardb/sugardb" + "github.com/tidwall/resp" + "go/types" + "slices" + "strconv" + "strings" + "testing" +) + +func Test_List(t *testing.T) { + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := sugardb.NewSugarDB( + sugardb.WithConfig(config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.NoEviction, + }), + ) + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + t.Run("Test_HandleLLEN", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse int + expectedError error + }{ + { + name: "1. If key exists and is a list, return the lists length", + key: "LlenKey1", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"LLEN", "LlenKey1"}, + expectedResponse: 4, + expectedError: nil, + }, + { + name: "2. If key does not exist, return 0", + key: "LlenKey2", + presetValue: nil, + command: []string{"LLEN", "LlenKey2"}, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "3. Command too short", + key: "LlenKey3", + presetValue: nil, + command: []string{"LLEN"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "4. Command too long", + key: "LlenKey4", + presetValue: nil, + command: []string{"LLEN", "LlenKey4", "LlenKey4"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Trying to get lengths on a non-list returns error", + key: "LlenKey5", + presetValue: "Default value", + command: []string{"LLEN", "LlenKey5"}, + expectedResponse: 0, + expectedError: errors.New("LLEN command on non-list item"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case []string: + command = []resp.Value{resp.StringValue("LPUSH"), resp.StringValue(test.key)} + for _, element := range test.presetValue.([]string) { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(len(test.presetValue.([]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response to be %d, got %d", test.expectedResponse, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleLINDEX", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse interface{} + expectedError error + }{ + { + name: "1. Return last element within range", + key: "LindexKey1", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"LINDEX", "LindexKey1", "3"}, + expectedResponse: "value4", + expectedError: nil, + }, + { + name: "2. Return first element within range", + key: "LindexKey2", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"LINDEX", "LindexKey1", "0"}, + expectedResponse: "value1", + expectedError: nil, + }, + { + name: "3. Return middle element within range", + key: "LindexKey3", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"LINDEX", "LindexKey1", "1"}, + expectedResponse: "value2", + expectedError: nil, + }, + { + name: "4. If key does not exist, return nil", + key: "LindexKey4", + presetValue: nil, + command: []string{"LINDEX", "LindexKey4", "0"}, + expectedResponse: nil, + expectedError: nil, + }, + { + name: "5. If the index is -1, return the element from the end of the list", + key: "LindexKey5", + presetValue: []string{"value1", "value2", "value3", "value4", "value5"}, + command: []string{"LINDEX", "LindexKey5", "-1"}, + expectedResponse: "value5", + expectedError: nil, + }, + { + name: "6. If index is -3, return the 3 element from the end of the list.", + key: "LindexKey6", + presetValue: []string{"value1", "value2", "value3", "value4", "value5"}, + command: []string{"LINDEX", "LindexKey6", "-3"}, + expectedResponse: "value3", + expectedError: nil, + }, + { + name: "7. When negative index absolute value is greater than the lenght of the list, return nil", + key: "LindexKey7", + presetValue: []string{"value1", "value2", "value3", "value4", "value5"}, + command: []string{"LINDEX", "LindexKey7", "-10"}, + expectedResponse: nil, + expectedError: nil, + }, + { + name: "8. Trying to get element by index on a non-list returns error", + key: "LindexKey8", + presetValue: "Default value", + command: []string{"LINDEX", "LindexKey8", "0"}, + expectedResponse: "", + expectedError: errors.New("LINDEX command on non-list item"), + }, + { + name: "9. Trying to get index out of range index beyond last index", + key: "LindexKey9", + presetValue: []string{"value1", "value2", "value3"}, + command: []string{"LINDEX", "LindexKey9", "3"}, + expectedResponse: nil, + expectedError: nil, + }, + { + name: " 10. Return error when index is not an integer", + key: "LindexKey10", + presetValue: []string{"value1", "value2", "value3"}, + command: []string{"LINDEX", "LindexKey10", "index"}, + expectedResponse: nil, + expectedError: errors.New("index must be an integer"), + }, + { + name: "11. Command too short", + key: "LindexKey11", + presetValue: nil, + command: []string{"LINDEX", "LindexKey11"}, + expectedResponse: "", + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "12. Command too long", + key: "LindexKey12", + presetValue: nil, + command: []string{"LINDEX", "LindexKey12", "0", "20"}, + expectedResponse: "", + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case []string: + command = []resp.Value{resp.StringValue("LPUSH"), resp.StringValue(test.key)} + for _, element := range test.presetValue.([]string) { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(len(test.presetValue.([]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if test.expectedResponse == nil { + if !res.IsNull() { + t.Errorf("expected nil response, for %+v", res) + } + return + } + + if res.String() != test.expectedResponse { + t.Errorf("expected response \"%s\", got \"%s\"", test.expectedResponse, res.String()) + } + }) + } + }) + + t.Run("Test_HandleLRANGE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse []string + expectedError error + }{ + { + // Return sub-list within range. + // Both start and end indices are positive. + // End index is greater than start index. + name: "1. Return sub-list within range.", + key: "LrangeKey1", + presetValue: []string{"value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8"}, + command: []string{"LRANGE", "LrangeKey1", "3", "6"}, + expectedResponse: []string{"value4", "value5", "value6", "value7"}, + expectedError: nil, + }, + { + name: "2. Return sub-list from start index to the end of the list when end index is -1", + key: "LrangeKey2", + presetValue: []string{"value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8"}, + command: []string{"LRANGE", "LrangeKey2", "3", "-1"}, + expectedResponse: []string{"value4", "value5", "value6", "value7", "value8"}, + expectedError: nil, + }, + { + name: "3. Return empty array when the start index is greater than the end index.", + key: "LrangeKey3", + presetValue: []string{"value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8"}, + command: []string{"LRANGE", "LrangeKey3", "3", "0"}, + expectedResponse: []string{}, + expectedError: nil, + }, + { + name: "4. If key does not exist, return an empty array", + key: "LrangeKey4", + presetValue: nil, + command: []string{"LRANGE", "LrangeKey4", "0", "2"}, + expectedResponse: []string{}, + expectedError: nil, + }, + { + name: "5. Error when executing command on non-list command", + key: "LrangeKey5", + presetValue: "Default value", + command: []string{"LRANGE", "LrangeKey5", "0", "3"}, + expectedResponse: nil, + expectedError: errors.New("LRANGE command on non-list item"), + }, + { + name: "6. Return sub-list when start index is < 0", + key: "LrangeKey6", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"LRANGE", "LrangeKey6", "-3", "3"}, + expectedResponse: []string{"value2", "value3", "value4"}, + expectedError: nil, + }, + { + name: "7. Empty array when start index is higher than the length of the list", + key: "LrangeKey7", + presetValue: []string{"value1", "value2", "value3"}, + command: []string{"LRANGE", "LrangeKey7", "10", "11"}, + expectedResponse: []string{}, + expectedError: nil, + }, + { + name: "8. Return error when start index is not an integer", + key: "LrangeKey8", + presetValue: []string{"value1", "value2", "value3"}, + command: []string{"LRANGE", "LrangeKey8", "start", "7"}, + expectedResponse: nil, + expectedError: errors.New("start index must be an integer"), + }, + { + name: "9. Return error when end index is not an integer", + key: "LrangeKey9", + presetValue: []string{"value1", "value2", "value3"}, + command: []string{"LRANGE", "LrangeKey9", "0", "end"}, + expectedResponse: nil, + expectedError: errors.New("end index must be an integer"), + }, + { + name: "10. Return 1 element when start and end indices are equal", + key: "LrangeKey10", + presetValue: []string{"value1", "value2", "value3"}, + command: []string{"LRANGE", "LrangeKey10", "1", "1"}, + expectedResponse: []string{"value2"}, + expectedError: nil, + }, + { + name: "11. Command too short", + key: "LrangeKey11", + presetValue: nil, + command: []string{"LRANGE", "LrangeKey11"}, + expectedResponse: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "12. Command too long", + key: "LrangeKey12", + presetValue: nil, + command: []string{"LRANGE", "LrangeKey12", "0", "element", "element"}, + expectedResponse: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case []string: + command = []resp.Value{resp.StringValue("LPUSH"), resp.StringValue(test.key)} + for _, element := range test.presetValue.([]string) { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(len(test.presetValue.([]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response of length %d, got length %d", len(test.expectedResponse), len(res.Array())) + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.String()) { + t.Errorf("unexpected element \"%s\" in response", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleLSET", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedValue []string + expectedError error + }{ + { + name: "1. Return last element within range", + key: "LsetKey1", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"LSET", "LsetKey1", "3", "new-value"}, + expectedValue: []string{"value1", "value2", "value3", "new-value"}, + expectedError: nil, + }, + { + name: "2. Return first element within range", + key: "LsetKey2", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"LSET", "LsetKey2", "0", "new-value"}, + expectedValue: []string{"new-value", "value2", "value3", "value4"}, + expectedError: nil, + }, + { + name: "3. Return middle element within range", + key: "LsetKey3", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"LSET", "LsetKey3", "1", "new-value"}, + expectedValue: []string{"value1", "new-value", "value3", "value4"}, + expectedError: nil, + }, + { + name: "4. If key does not exist, return error", + key: "LsetKey4", + presetValue: nil, + command: []string{"LSET", "LsetKey4", "0", "element"}, + expectedValue: nil, + expectedError: errors.New("LSET command on non-list item"), + }, + { + name: "5. Trying to get element by index on a non-list returns error", + key: "LsetKey5", + presetValue: "Default value", + command: []string{"LSET", "LsetKey5", "0", "element"}, + expectedValue: nil, + expectedError: errors.New("LSET command on non-list item"), + }, + { + name: "6. Trying to get index out of range index beyond last index", + key: "LsetKey6", + presetValue: []string{"value1", "value2", "value3"}, + command: []string{"LSET", "LsetKey6", "3", "element"}, + expectedValue: nil, + expectedError: errors.New("index must be within list range"), + }, + { + name: "7. Set last element with -1 index", + key: "LsetKey7", + presetValue: []string{"value1", "value2", "value3"}, + command: []string{"LSET", "LsetKey7", "-1", "element"}, + expectedValue: []string{"value1", "value2", "element"}, + expectedError: nil, + }, + { + name: "8. Return error when index is not an integer", + key: "LsetKey8", + presetValue: []string{"value1", "value2", "value3"}, + command: []string{"LSET", "LsetKey8", "index", "element"}, + expectedValue: nil, + expectedError: errors.New("index must be an integer"), + }, + { + name: "9. Command too short", + key: "LsetKey9", + presetValue: nil, + command: []string{"LSET", "LsetKey5"}, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "10. Command too long", + key: "LsetKey10", + presetValue: nil, + command: []string{"LSET", "LsetKey10", "0", "element", "element"}, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case []string: + command = []resp.Value{resp.StringValue("LPUSH"), resp.StringValue(test.key)} + for _, element := range test.presetValue.([]string) { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(len(test.presetValue.([]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected response OK, got \"%s\"", res.String()) + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("LRANGE"), + resp.StringValue(test.key), + resp.StringValue("0"), + resp.StringValue("-1"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != len(test.expectedValue) { + t.Errorf("expected list at key \"%s\" to be length %d, got %d", + test.key, len(test.expectedValue), len(res.Array())) + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedValue, item.String()) { + t.Errorf("unexpected value \"%s\" in updated list", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleLTRIM", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedValue []string + expectedError error + }{ + { + // Return trim within range. + // Both start and end indices are positive. + // End index is greater than start index. + name: "1. Return trim within range.", + key: "LtrimKey1", + presetValue: []string{"value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8"}, + command: []string{"LTRIM", "LtrimKey1", "3", "6"}, + expectedValue: []string{"value4", "value5", "value6", "value7"}, + expectedError: nil, + }, + { + name: "2. Return element from start index to end index when end index is greater than length of the list", + key: "LtrimKey2", + presetValue: []string{"value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8"}, + command: []string{"LTRIM", "LtrimKey2", "5", "-1"}, + expectedValue: []string{"value6", "value7", "value8"}, + expectedError: nil, + }, + { + name: "3. Delete the key when end index is smaller than start index.", + key: "LtrimKey3", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"LTRIM", "LtrimKey3", "3", "1"}, + expectedValue: nil, + expectedError: nil, + }, + { + name: "4. If key does not exist, return error", + key: "LtrimKey4", + presetValue: nil, + command: []string{"LTRIM", "LtrimKey4", "0", "2"}, + expectedValue: nil, + expectedError: nil, + }, + { + name: "5. Trying to get element by index on a non-list returns error", + key: "LtrimKey5", + presetValue: "Default value", + command: []string{"LTRIM", "LtrimKey5", "0", "3"}, + expectedValue: nil, + expectedError: errors.New("LTRIM command on non-list item"), + }, + { + name: "6. Trim from the end of the list when start index is less than 0", + key: "LtrimKey6", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"LTRIM", "LtrimKey6", "-3", "3"}, + expectedValue: []string{"value2", "value3", "value4"}, + expectedError: nil, + }, + { + name: "7. Delete list when start index is higher than the length of the list", + key: "LtrimKey7", + presetValue: []string{"value1", "value2", "value3"}, + command: []string{"LTRIM", "LtrimKey7", "10", "11"}, + expectedValue: nil, + expectedError: nil, + }, + { + name: "8. Return error when start index is not an integer", + key: "LtrimKey8", + presetValue: []string{"value1", "value2", "value3"}, + command: []string{"LTRIM", "LtrimKey8", "start", "7"}, + expectedValue: nil, + expectedError: errors.New("start index must be an integer"), + }, + { + name: "9. Return error when end index is not an integer", + key: "LtrimKey9", + presetValue: []string{"value1", "value2", "value3"}, + command: []string{"LTRIM", "LtrimKey9", "0", "end"}, + expectedValue: nil, + expectedError: errors.New("end index must be an integer"), + }, + { + name: "10. Command too short", + key: "LtrimKey10", + presetValue: nil, + command: []string{"LTRIM", "LtrimKey10"}, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "11. Command too long", + key: "LtrimKey11", + presetValue: nil, + command: []string{"LTRIM", "LtrimKey11", "0", "element", "element"}, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case []string: + command = []resp.Value{resp.StringValue("LPUSH"), resp.StringValue(test.key)} + for _, element := range test.presetValue.([]string) { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(len(test.presetValue.([]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected response OK, got \"%s\"", res.String()) + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("LRANGE"), + resp.StringValue(test.key), + resp.StringValue("0"), + resp.StringValue("-1"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != len(test.expectedValue) { + t.Errorf("expected list at key \"%s\" to be length %d, got %d", + test.key, len(test.expectedValue), len(res.Array())) + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedValue, item.String()) { + t.Errorf("unexpected value \"%s\" in updated list", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleLREM", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse int + expectedValue []string + expectedError error + }{ + { + name: "1. Remove the first 3 elements that appear in the list", + key: "LremKey1", + presetValue: []string{"1", "2", "4", "4", "5", "6", "7", "4", "8", "4", "9", "10", "5", "4"}, + command: []string{"LREM", "LremKey1", "3", "4"}, + expectedResponse: 3, + expectedValue: []string{"1", "2", "5", "6", "7", "8", "4", "9", "10", "5", "4"}, + expectedError: nil, + }, + { + name: "2. Remove the last 3 elements that appear in the list", + key: "LremKey2", + presetValue: []string{"1", "2", "4", "4", "5", "6", "7", "4", "8", "4", "9", "10", "5", "4"}, + command: []string{"LREM", "LremKey2", "-3", "4"}, + expectedResponse: 3, + expectedValue: []string{"1", "2", "4", "4", "5", "6", "7", "8", "9", "10", "5"}, + expectedError: nil, + }, + { + name: "3. Throw error when count is not an integer", + key: "LremKey3", + presetValue: nil, + command: []string{"LREM", "LremKey3", "count", "value1"}, + expectedValue: nil, + expectedError: errors.New("count must be an integer"), + }, + { + name: "4. Throw error on non-list item", + key: "LremKey4", + presetValue: "Default value", + command: []string{"LREM", "LremKey4", "0", "value1"}, + expectedValue: nil, + expectedError: errors.New("LREM command on non-list item"), + }, + { + name: "5. Return 0 on non-existent key", + key: "LremKey5", + presetValue: nil, + command: []string{"LREM", "LremKey5", "0", "value1"}, + expectedResponse: 0, + expectedValue: nil, + expectedError: nil, + }, + { + name: "6. Command too short", + key: "LremKey6", + presetValue: nil, + command: []string{"LREM", "LremKey6"}, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "7. Command too long", + key: "LremKey7", + presetValue: nil, + command: []string{"LREM", "LremKey7", "0", "element", "element"}, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case []string: + command = []resp.Value{resp.StringValue("LPUSH"), resp.StringValue(test.key)} + for _, element := range test.presetValue.([]string) { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(len(test.presetValue.([]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + return + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("LRANGE"), + resp.StringValue(test.key), + resp.StringValue("0"), + resp.StringValue("-1"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != len(test.expectedValue) { + t.Errorf("expected list at key \"%s\" to be length %d, got %d", + test.key, len(test.expectedValue), len(res.Array())) + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedValue, item.String()) { + t.Errorf("unexpected value \"%s\" in updated list", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleLMOVE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValue map[string]interface{} + command []string + expectedValue map[string][]string + expectedError error + }{ + { + name: "1. Move element from LEFT of left list to LEFT of right list", + presetValue: map[string]interface{}{ + "source1": []string{"one", "two", "three"}, + "destination1": []string{"one", "two", "three"}, + }, + command: []string{"LMOVE", "source1", "destination1", "LEFT", "LEFT"}, + expectedValue: map[string][]string{ + "source1": {"two", "three"}, + "destination1": {"one", "one", "two", "three"}, + }, + expectedError: nil, + }, + { + name: "2. Move element from LEFT of left list to RIGHT of right list", + presetValue: map[string]interface{}{ + "source2": []string{"one", "two", "three"}, + "destination2": []string{"one", "two", "three"}, + }, + command: []string{"LMOVE", "source2", "destination2", "LEFT", "RIGHT"}, + expectedValue: map[string][]string{ + "source2": {"two", "three"}, + "destination2": {"one", "two", "three", "one"}, + }, + expectedError: nil, + }, + { + name: "3. Move element from RIGHT of left list to LEFT of right list", + presetValue: map[string]interface{}{ + "source3": []string{"one", "two", "three"}, + "destination3": []string{"one", "two", "three"}, + }, + command: []string{"LMOVE", "source3", "destination3", "RIGHT", "LEFT"}, + expectedValue: map[string][]string{ + "source3": {"one", "two"}, + "destination3": {"three", "one", "two", "three"}, + }, + expectedError: nil, + }, + { + name: "4. Move element from RIGHT of left list to RIGHT of right list", + presetValue: map[string]interface{}{ + "source4": []string{"one", "two", "three"}, + "destination4": []string{"one", "two", "three"}, + }, + command: []string{"LMOVE", "source4", "destination4", "RIGHT", "RIGHT"}, + expectedValue: map[string][]string{ + "source4": {"one", "two"}, + "destination4": {"one", "two", "three", "three"}, + }, + expectedError: nil, + }, + { + name: "5. Throw error when the right list is non-existent", + presetValue: map[string]interface{}{ + "source5": []string{"one", "two", "three"}, + }, + command: []string{"LMOVE", "source5", "destination5", "LEFT", "LEFT"}, + expectedValue: nil, + expectedError: errors.New("both source and destination must be lists"), + }, + { + name: "6. Throw error when right list in not a list", + presetValue: map[string]interface{}{ + "source6": []string{"one", "two", "tree"}, + "destination6": "Default value", + }, + command: []string{"LMOVE", "source6", "destination6", "LEFT", "LEFT"}, + expectedValue: nil, + expectedError: errors.New("both source and destination must be lists"), + }, + { + name: "7. Throw error when left list is non-existent", + presetValue: map[string]interface{}{ + "destination7": []string{"one", "two", "three"}, + }, + command: []string{"LMOVE", "source7", "destination7", "LEFT", "LEFT"}, + expectedValue: nil, + expectedError: errors.New("both source and destination must be lists"), + }, + { + name: "8. Throw error when left list is not a list", + presetValue: map[string]interface{}{ + "source8": "Default value", + "destination8": []string{"one", "two", "three"}, + }, + command: []string{"LMOVE", "source8", "destination8", "LEFT", "LEFT"}, + expectedValue: nil, + expectedError: errors.New("both source and destination must be lists"), + }, + { + name: "9. Throw error when command is too short", + presetValue: map[string]interface{}{}, + command: []string{"LMOVE", "source9", "destination9"}, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "10. Throw error when command is too long", + presetValue: map[string]interface{}{}, + command: []string{"LMOVE", "source10", "destination10", "LEFT", "LEFT", "RIGHT"}, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "11. Throw error when WHEREFROM argument is not LEFT/RIGHT", + presetValue: map[string]interface{}{}, + command: []string{"LMOVE", "source11", "destination11", "UP", "RIGHT"}, + expectedValue: nil, + expectedError: errors.New("wherefrom and whereto arguments must be either LEFT or RIGHT"), + }, + { + name: "12. Throw error when WHERETO argument is not LEFT/RIGHT", + presetValue: map[string]interface{}{}, + command: []string{"LMOVE", "source11", "destination11", "LEFT", "DOWN"}, + expectedValue: nil, + expectedError: errors.New("wherefrom and whereto arguments must be either LEFT or RIGHT"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + for key, value := range test.presetValue { + + var command []resp.Value + var expected string + + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case []string: + command = []resp.Value{resp.StringValue("LPUSH"), resp.StringValue(key)} + for _, element := range value.([]string) { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(len(value.([]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected response OK, got \"%s\"", res.String()) + } + + for key, list := range test.expectedValue { + if err = client.WriteArray([]resp.Value{ + resp.StringValue("LRANGE"), + resp.StringValue(key), + resp.StringValue("0"), + resp.StringValue("-1"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != len(list) { + t.Errorf("expected list at key \"%s\" to be length %d, got %d", + key, len(test.expectedValue), len(res.Array())) + } + + for _, item := range res.Array() { + if !slices.Contains(list, item.String()) { + t.Errorf("unexpected value \"%s\" in updated list %s", item.String(), key) + } + } + } + }) + } + }) + + t.Run("Test_HandleLPUSH", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse int + expectedValue []string + expectedError error + }{ + { + name: "1. LPUSHX to existing list prepends the element to the list", + key: "LpushKey1", + presetValue: []string{"1", "2", "4", "5"}, + command: []string{"LPUSHX", "LpushKey1", "value1", "value2"}, + expectedResponse: 6, + expectedValue: []string{"value1", "value2", "1", "2", "4", "5"}, + expectedError: nil, + }, + { + name: "2. LPUSH on existing list prepends the elements to the list", + key: "LpushKey2", + presetValue: []string{"1", "2", "4", "5"}, + command: []string{"LPUSH", "LpushKey2", "value1", "value2"}, + expectedResponse: 6, + expectedValue: []string{"value1", "value2", "1", "2", "4", "5"}, + expectedError: nil, + }, + { + name: "3. LPUSH on non-existent list creates the list", + key: "LpushKey3", + presetValue: nil, + command: []string{"LPUSH", "LpushKey3", "value1", "value2"}, + expectedResponse: 2, + expectedValue: []string{"value1", "value2"}, + expectedError: nil, + }, + { + name: "4. Command too short", + key: "LpushKey5", + presetValue: nil, + command: []string{"LPUSH", "LpushKey5"}, + expectedResponse: 0, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. LPUSHX command returns error on non-existent list", + key: "LpushKey6", + presetValue: nil, + command: []string{"LPUSHX", "LpushKey7", "count", "value1"}, + expectedResponse: 0, + expectedValue: nil, + expectedError: errors.New("LPUSHX command on non-existent key"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case []string: + command = []resp.Value{resp.StringValue("LPUSH"), resp.StringValue(test.key)} + for _, element := range test.presetValue.([]string) { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(len(test.presetValue.([]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("LRANGE"), + resp.StringValue(test.key), + resp.StringValue("0"), + resp.StringValue("-1"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != len(test.expectedValue) { + t.Errorf("expected list at key \"%s\" to be length %d, got %d", + test.key, len(test.expectedValue), len(res.Array())) + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedValue, item.String()) { + t.Errorf("unexpected value \"%s\" in updated list", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleRPUSH", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse int + expectedValue []string + expectedError error + }{ + { + name: "1. RPUSHX to existing list prepends the element to the list", + key: "RpushKey1", + presetValue: []string{"1", "2", "4", "5"}, + command: []string{"RPUSHX", "RpushKey1", "value1", "value2"}, + expectedResponse: 6, + expectedValue: []string{"1", "2", "4", "5", "value1", "value2"}, + expectedError: nil, + }, + { + name: "2. RPUSH on existing list prepends the elements to the list", + key: "RpushKey2", + presetValue: []string{"1", "2", "4", "5"}, + command: []string{"RPUSH", "RpushKey2", "value1", "value2"}, + expectedResponse: 6, + expectedValue: []string{"1", "2", "4", "5", "value1", "value2"}, + expectedError: nil, + }, + { + name: "3. RPUSH on non-existent list creates the list", + key: "RpushKey3", + presetValue: nil, + command: []string{"RPUSH", "RpushKey3", "value1", "value2"}, + expectedResponse: 2, + expectedValue: []string{"value1", "value2"}, + expectedError: nil, + }, + { + name: "4. Command too short", + key: "RpushKey5", + presetValue: nil, + command: []string{"RPUSH", "RpushKey5"}, + expectedResponse: 0, + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. RPUSHX command returns error on non-existent list", + key: "RpushKey6", + presetValue: nil, + command: []string{"RPUSHX", "RpushKey7", "count", "value1"}, + expectedResponse: 0, + expectedValue: nil, + expectedError: errors.New("RPUSHX command on non-existent key"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case []string: + command = []resp.Value{resp.StringValue("LPUSH"), resp.StringValue(test.key)} + for _, element := range test.presetValue.([]string) { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(len(test.presetValue.([]string))) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("LRANGE"), + resp.StringValue(test.key), + resp.StringValue("0"), + resp.StringValue("-1"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != len(test.expectedValue) { + t.Errorf("expected list at key \"%s\" to be length %d, got %d", + test.key, len(test.expectedValue), len(res.Array())) + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedValue, item.String()) { + t.Errorf("unexpected value \"%s\" in updated list", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandlePOP", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse interface{} + expectedValue []string + expectedError error + }{ + { + name: "1. LPOP returns last element and removed first element from the list", + key: "PopKey1", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"LPOP", "PopKey1"}, + expectedResponse: "value1", + expectedValue: []string{"value2", "value3", "value4"}, + expectedError: nil, + }, + { + name: "2. RPOP returns last element and removed last element from the list", + key: "PopKey2", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"RPOP", "PopKey2"}, + expectedResponse: "value4", + expectedValue: []string{"value1", "value2", "value3"}, + expectedError: nil, + }, + { + name: "3. Pop 3 elements from the beginning of the list", + key: "PopKey3", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"LPOP", "PopKey3", "3"}, + expectedResponse: []string{"value1", "value2", "value3"}, + expectedValue: []string{"value4"}, + expectedError: nil, + }, + { + name: "4. Pop 3 elements from the end of the list", + key: "PopKey4", + presetValue: []string{"value1", "value2", "value3", "value4"}, + command: []string{"RPOP", "PopKey4", "3"}, + expectedResponse: []string{"value2", "value3", "value4"}, + expectedValue: []string{"value1"}, + expectedError: nil, + }, + { + name: "5. LPOP on a non-existent key should return nil", + key: "PopKey5", + presetValue: nil, + command: []string{"LPOP", "PopKey5"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: nil, + }, + { + name: "6. RPOP on a non-existent key should return nil", + key: "PopKey6", + presetValue: nil, + command: []string{"RPOP", "PopKey6"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: nil, + }, + { + name: "7. LPOP on a non-existent key should return nil", + key: "PopKey7", + presetValue: nil, + command: []string{"LPOP", "PopKey7"}, + expectedResponse: nil, + expectedValue: nil, + expectedError: nil, + }, + { + name: "8. RPOP on an empty list key should return nil", + key: "PopKey8", + presetValue: []string{}, + command: []string{"RPOP", "PopKey8"}, + expectedResponse: nil, + expectedValue: []string{}, + expectedError: nil, + }, + { + name: "9. LPOP empties the list when count = length of the list", + key: "PopKey9", + presetValue: []string{"value1", "value2", "value3", "value4", "value5"}, + command: []string{"LPOP", "PopKey9", "5"}, + expectedResponse: []string{"value1", "value2", "value3", "value4", "value5"}, + expectedValue: []string{}, + expectedError: nil, + }, + { + name: "10. RPOP empties the list when count = length of the list", + key: "PopKey10", + presetValue: []string{"value1", "value2", "value3", "value4", "value5"}, + command: []string{"LPOP", "PopKey10", "5"}, + expectedResponse: []string{"value1", "value2", "value3", "value4", "value5"}, + expectedValue: []string{}, + expectedError: nil, + }, + { + name: "11. Trying to execute LPOP from a non-list item return an error", + key: "PopKey11", + presetValue: "Default value", + command: []string{"LPOP", "PopKey11"}, + expectedResponse: "", + expectedValue: nil, + expectedError: errors.New("LPOP command on non-list item"), + }, + { + name: "12. Trying to execute RPOP from a non-list item return an error", + key: "PopKey12", + presetValue: "Default value", + command: []string{"RPOP", "PopKey12"}, + expectedResponse: "", + expectedValue: nil, + expectedError: errors.New("RPOP command on non-list item"), + }, + { + name: "13. Command too short", + key: "PopKey13", + presetValue: nil, + command: []string{"LPOP"}, + expectedResponse: "", + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "14. Command too long", + key: "PopKey14", + presetValue: nil, + command: []string{"LPOP", "PopKey14", "5", "extra-arg"}, + expectedResponse: "", + expectedValue: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case []string: + elements := test.presetValue.([]string) + if len(elements) == 0 { + // If the length of the preset array is 0, set with a default value + elements = []string{"default"} + expected = "1" + } else { + expected = strconv.Itoa(len(test.presetValue.([]string))) + } + // Prepare the commands + command = []resp.Value{resp.StringValue("LPUSH"), resp.StringValue(test.key)} + for _, element := range elements { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + + // If preset value is an empty array, pop the default element that we pushed earlier to make the list empty. + if preset, ok := test.presetValue.([]string); ok && len(preset) == 0 { + if err = client.WriteArray([]resp.Value{resp.StringValue("LPOP"), resp.StringValue(test.key)}); err != nil { + t.Error(err) + } + _, _, _ = client.ReadValue() + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + switch test.expectedResponse.(type) { + case string: + if res.String() != test.expectedResponse.(string) { + t.Errorf("expected response %s, got %s", test.expectedResponse.(string), res.String()) + return + } + case types.Nil: + if !res.IsNull() { + t.Errorf("expected nil response, got %+v", res) + return + } + case []string: + if len(res.Array()) != len(test.expectedResponse.([]string)) { + t.Errorf("expected list at key \"%s\" to be length %d, got %d", + test.key, len(test.expectedValue), len(res.Array())) + } + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse.([]string), item.String()) { + t.Errorf("unexpected value \"%s\" in updated list", item.String()) + } + } + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("LRANGE"), + resp.StringValue(test.key), + resp.StringValue("0"), + resp.StringValue("-1"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != len(test.expectedValue) { + t.Errorf("expected list at key \"%s\" to be length %d, got %d", + test.key, len(test.expectedValue), len(res.Array())) + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedValue, item.String()) { + t.Errorf("unexpected value \"%s\" in updated list", item.String()) + } + } + }) + } + }) +} diff --git a/internal/modules/list/key_funcs.go b/internal/modules/list/key_funcs.go new file mode 100644 index 0000000..052e1c1 --- /dev/null +++ b/internal/modules/list/key_funcs.go @@ -0,0 +1,131 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package list + +import ( + "errors" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" +) + +func lpushKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func popKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 || len(cmd) > 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:], + }, nil +} + +func llenKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func lrangeKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func lindexKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func lsetKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func ltrimKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func lremKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func rpushKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func lmoveKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 5 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:3], + }, nil +} diff --git a/internal/modules/pubsub/channel.go b/internal/modules/pubsub/channel.go new file mode 100644 index 0000000..8200113 --- /dev/null +++ b/internal/modules/pubsub/channel.go @@ -0,0 +1,150 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package pubsub + +import ( + "github.com/gobwas/glob" + "github.com/tidwall/resp" + "log" + "net" + "sync" +) + +type Channel struct { + name string // Channel name. This can be a glob pattern string. + pattern glob.Glob // Compiled glob pattern. This is nil if the channel is not a pattern channel. + subscribersRWMut sync.RWMutex // RWMutex to concurrency control when accessing channel subscribers. + subscribers map[*net.Conn]*resp.Conn // Map containing the channel subscribers. + messageChan *chan string // Messages published to this channel will be sent to this channel. +} + +// WithName option sets the channels name. +func WithName(name string) func(channel *Channel) { + return func(channel *Channel) { + channel.name = name + } +} + +// WithPattern option sets the compiled glob pattern for the channel if it's a pattern channel. +func WithPattern(pattern string) func(channel *Channel) { + return func(channel *Channel) { + channel.name = pattern + channel.pattern = glob.MustCompile(pattern) + } +} + +func NewChannel(options ...func(channel *Channel)) *Channel { + messageChan := make(chan string, 4096) + + channel := &Channel{ + name: "", + pattern: nil, + subscribersRWMut: sync.RWMutex{}, + subscribers: make(map[*net.Conn]*resp.Conn), + messageChan: &messageChan, + } + + for _, option := range options { + option(channel) + } + + return channel +} + +func (ch *Channel) Start() { + go func() { + for { + message := <-*ch.messageChan + + ch.subscribersRWMut.RLock() + + for _, conn := range ch.subscribers { + go func(conn *resp.Conn) { + if err := conn.WriteArray([]resp.Value{ + resp.StringValue("message"), + resp.StringValue(ch.name), + resp.StringValue(message), + }); err != nil { + log.Println(err) + } + }(conn) + } + + ch.subscribersRWMut.RUnlock() + } + }() +} + +func (ch *Channel) Name() string { + return ch.name +} + +func (ch *Channel) Pattern() glob.Glob { + return ch.pattern +} + +func (ch *Channel) Subscribe(conn *net.Conn) bool { + ch.subscribersRWMut.Lock() + defer ch.subscribersRWMut.Unlock() + if _, ok := ch.subscribers[conn]; !ok { + ch.subscribers[conn] = resp.NewConn(*conn) + } + _, ok := ch.subscribers[conn] + return ok +} + +func (ch *Channel) Unsubscribe(conn *net.Conn) bool { + ch.subscribersRWMut.Lock() + defer ch.subscribersRWMut.Unlock() + if _, ok := ch.subscribers[conn]; !ok { + return false + } + delete(ch.subscribers, conn) + return true +} + +func (ch *Channel) Publish(message string) { + *ch.messageChan <- message +} + +func (ch *Channel) IsActive() bool { + ch.subscribersRWMut.RLock() + defer ch.subscribersRWMut.RUnlock() + + active := len(ch.subscribers) > 0 + + return active +} + +func (ch *Channel) NumSubs() int { + ch.subscribersRWMut.RLock() + defer ch.subscribersRWMut.RUnlock() + + n := len(ch.subscribers) + + return n +} + +func (ch *Channel) Subscribers() map[*net.Conn]*resp.Conn { + ch.subscribersRWMut.RLock() + defer ch.subscribersRWMut.RUnlock() + + subscribers := make(map[*net.Conn]*resp.Conn, len(ch.subscribers)) + for k, v := range ch.subscribers { + subscribers[k] = v + } + + return subscribers +} diff --git a/internal/modules/pubsub/commands.go b/internal/modules/pubsub/commands.go new file mode 100644 index 0000000..dcba9e9 --- /dev/null +++ b/internal/modules/pubsub/commands.go @@ -0,0 +1,271 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package pubsub + +import ( + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" + "strings" +) + +func handleSubscribe(params internal.HandlerFuncParams) ([]byte, error) { + pubsub, ok := params.GetPubSub().(*PubSub) + if !ok { + return nil, errors.New("could not load pubsub module") + } + + channels := params.Command[1:] + + if len(channels) == 0 { + return nil, errors.New(constants.WrongArgsResponse) + } + + withPattern := strings.EqualFold(params.Command[0], "psubscribe") + pubsub.Subscribe(params.Context, params.Connection, channels, withPattern) + + return nil, nil +} + +func handleUnsubscribe(params internal.HandlerFuncParams) ([]byte, error) { + pubsub, ok := params.GetPubSub().(*PubSub) + if !ok { + return nil, errors.New("could not load pubsub module") + } + + channels := params.Command[1:] + + withPattern := strings.EqualFold(params.Command[0], "punsubscribe") + + return pubsub.Unsubscribe(params.Context, params.Connection, channels, withPattern), nil +} + +func handlePublish(params internal.HandlerFuncParams) ([]byte, error) { + pubsub, ok := params.GetPubSub().(*PubSub) + if !ok { + return nil, errors.New("could not load pubsub module") + } + if len(params.Command) != 3 { + return nil, errors.New(constants.WrongArgsResponse) + } + pubsub.Publish(params.Context, params.Command[2], params.Command[1]) + return []byte(constants.OkResponse), nil +} + +func handlePubSubChannels(params internal.HandlerFuncParams) ([]byte, error) { + if len(params.Command) > 3 { + return nil, errors.New(constants.WrongArgsResponse) + } + + pubsub, ok := params.GetPubSub().(*PubSub) + if !ok { + return nil, errors.New("could not load pubsub module") + } + + pattern := "" + if len(params.Command) == 3 { + pattern = params.Command[2] + } + + return pubsub.Channels(pattern), nil +} + +func handlePubSubNumPat(params internal.HandlerFuncParams) ([]byte, error) { + pubsub, ok := params.GetPubSub().(*PubSub) + if !ok { + return nil, errors.New("could not load pubsub module") + } + num := pubsub.NumPat() + return []byte(fmt.Sprintf(":%d\r\n", num)), nil +} + +func handlePubSubNumSubs(params internal.HandlerFuncParams) ([]byte, error) { + pubsub, ok := params.GetPubSub().(*PubSub) + if !ok { + return nil, errors.New("could not load pubsub module") + } + return pubsub.NumSub(params.Command[2:]), nil +} + +func Commands() []internal.Command { + return []internal.Command{ + { + Command: "subscribe", + Module: constants.PubSubModule, + Categories: []string{constants.PubSubCategory, constants.ConnectionCategory, constants.SlowCategory}, + Description: "(SUBSCRIBE channel [channel ...]) Subscribe to one or more channels.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + // Treat the channels as keys + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: cmd[1:], + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleSubscribe, + }, + { + Command: "psubscribe", + Module: constants.PubSubModule, + Categories: []string{constants.PubSubCategory, constants.ConnectionCategory, constants.SlowCategory}, + Description: "(PSUBSCRIBE pattern [pattern ...]) Subscribe to one or more glob patterns.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + // Treat the patterns as keys + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: cmd[1:], + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleSubscribe, + }, + { + Command: "publish", + Module: constants.PubSubModule, + Categories: []string{constants.PubSubCategory, constants.FastCategory}, + Description: "(PUBLISH channel message) Publish a message to the specified channel.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + // Treat the channel as a key + if len(cmd) != 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: cmd[1:2], + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handlePublish, + }, + { + Command: "unsubscribe", + Module: constants.PubSubModule, + Categories: []string{constants.PubSubCategory, constants.ConnectionCategory, constants.SlowCategory}, + Description: `(UNSUBSCRIBE [channel [channel ...]]) Unsubscribe from a list of channels. +If the channel list is not provided, then the connection will be unsubscribed from all the channels that +it's currently subscribe to.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + // Treat the channels as keys + return internal.KeyExtractionFuncResult{ + Channels: cmd[1:], + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleUnsubscribe, + }, + { + Command: "punsubscribe", + Module: constants.PubSubModule, + Categories: []string{constants.PubSubCategory, constants.ConnectionCategory, constants.SlowCategory}, + Description: `(PUNSUBSCRIBE [pattern [pattern ...]]) Unsubscribe from a list of channels using patterns. +If the pattern list is not provided, then the connection will be unsubscribed from all the patterns that +it's currently subscribe to.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: cmd[1:], + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handleUnsubscribe, + }, + { + Command: "pubsub", + Module: constants.PubSubModule, + Categories: []string{}, + Description: "", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: func(_ internal.HandlerFuncParams) ([]byte, error) { + return nil, errors.New("provide CHANNELS, NUMPAT, or NUMSUB subcommand") + }, + SubCommands: []internal.SubCommand{ + { + Command: "channels", + Module: constants.PubSubModule, + Categories: []string{constants.PubSubCategory, constants.SlowCategory}, + Description: `(PUBSUB CHANNELS [pattern]) Returns an array containing the list of channels that +match the given pattern. If no pattern is provided, all active channels are returned. Active channels are +channels with 1 or more subscribers.`, + Sync: false, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handlePubSubChannels, + }, + { + Command: "numpat", + Module: constants.PubSubModule, + Categories: []string{constants.PubSubCategory, constants.SlowCategory}, + Description: `(PUBSUB NUMPAT) Return the number of patterns that are currently subscribed to by clients.`, + Sync: false, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handlePubSubNumPat, + }, + { + Command: "numsub", + Module: constants.PubSubModule, + Categories: []string{constants.PubSubCategory, constants.SlowCategory}, + Description: `(PUBSUB NUMSUB [channel [channel ...]]) Return an array of arrays containing the provided +channel name and how many clients are currently subscribed to the channel.`, + Sync: false, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{ + Channels: cmd[2:], + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + }, + HandlerFunc: handlePubSubNumSubs, + }, + }, + }, + } +} diff --git a/internal/modules/pubsub/commands_test.go b/internal/modules/pubsub/commands_test.go new file mode 100644 index 0000000..02c32a6 --- /dev/null +++ b/internal/modules/pubsub/commands_test.go @@ -0,0 +1,1013 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package pubsub_test + +import ( + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" + "apigo.cc/go/sugardb/sugardb" + "github.com/tidwall/resp" + "net" + "slices" + "strings" + "sync" + "testing" +) + +func setUpServer(port int) (*sugardb.SugarDB, error) { + return sugardb.NewSugarDB( + sugardb.WithConfig(config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.NoEviction, + }), + ) +} + +func Test_PubSub(t *testing.T) { + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := setUpServer(port) + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + t.Run("Test_HandleSubscribe", func(t *testing.T) { + t.Parallel() + + // Establish connections. + numOfConnections := 20 + rawConnections := make([]net.Conn, numOfConnections) + connections := make([]*resp.Conn, numOfConnections) + for i := 0; i < numOfConnections; i++ { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + rawConnections[i] = conn + connections[i] = resp.NewConn(conn) + } + defer func() { + for _, conn := range rawConnections { + _ = conn.Close() + } + }() + + // Test subscribe to channels + channels := []string{"sub_channel1", "sub_channel2", "sub_channel3"} + command := []resp.Value{resp.StringValue("SUBSCRIBE")} + for _, channel := range channels { + command = append(command, resp.StringValue(channel)) + } + for _, conn := range connections { + if err := conn.WriteArray(command); err != nil { + t.Error(err) + return + } + for i := 0; i < len(channels); i++ { + // Read all the subscription confirmations from the connection. + if _, _, err := conn.ReadValue(); err != nil { + t.Error(err) + return + } + } + } + activeChannels, err := mockServer.PubSubChannels("*") + if err != nil { + t.Error(err) + return + } + numSubs, err := mockServer.PubSubNumSub(channels...) + if err != nil { + t.Error(err) + return + } + for _, channel := range channels { + // Check if the channel exists in the pubsub module. + if !slices.Contains(activeChannels, channel) { + t.Errorf("expected pubsub to contain channel \"%s\" but it was not found", channel) + return + } + // Check if the channel has the right number of subscribers. + if numSubs[channel] != len(connections) { + t.Errorf("expected channel \"%s\" to have %d subscribers, got %d", + channel, len(connections), numSubs[channel]) + return + } + } + + // Test subscribe to patterns + patterns := []string{"psub_channel*"} + command = []resp.Value{resp.StringValue("PSUBSCRIBE")} + for _, pattern := range patterns { + command = append(command, resp.StringValue(pattern)) + } + for _, conn := range connections { + if err := conn.WriteArray(command); err != nil { + t.Error(err) + return + } + for i := 0; i < len(patterns); i++ { + // Read all the pattern subscription confirmations from the connection. + if _, _, err := conn.ReadValue(); err != nil { + t.Error(err) + return + } + } + } + numSubs, err = mockServer.PubSubNumSub(patterns...) + if err != nil { + t.Error(err) + return + } + for _, pattern := range patterns { + activePatterns, err := mockServer.PubSubChannels(pattern) + if err != nil { + t.Error(err) + return + } + // Check if pattern channel exists in pubsub module. + if !slices.Contains(activePatterns, pattern) { + t.Errorf("expected pubsub to contain pattern channel \"%s\" but it was not found", pattern) + return + } + // Check if the channel has all the connections from above. + if numSubs[pattern] != len(connections) { + t.Errorf("expected pattern channel \"%s\" to have %d subscribers, got %d", + pattern, len(connections), numSubs[pattern]) + return + } + } + }) + + t.Run("Test_HandleUnsubscribe", func(t *testing.T) { + t.Parallel() + + var rawConnections []net.Conn + generateConnections := func(noOfConnections int) []*resp.Conn { + connections := make([]*resp.Conn, noOfConnections) + for i := 0; i < noOfConnections; i++ { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + } + rawConnections = append(rawConnections, conn) + connections[i] = resp.NewConn(conn) + } + return connections + } + defer func() { + for _, conn := range rawConnections { + _ = conn.Close() + } + }() + + verifyResponse := func(res resp.Value, expectedResponse [][]string) { + v := res.Array() + if len(v) != len(expectedResponse) { + t.Errorf("expected subscribe response of length %d, but got %d", len(expectedResponse), len(v)) + } + for _, item := range v { + arr := item.Array() + if len(arr) != 3 { + t.Errorf("expected subscribe response item to be length %d, but got %d", 3, len(arr)) + } + if !slices.ContainsFunc(expectedResponse, func(strings []string) bool { + return strings[0] == arr[0].String() && strings[1] == arr[1].String() && strings[2] == arr[2].String() + }) { + t.Errorf("expected to find item \"%s\" in response, did not find it.", arr[1].String()) + } + } + } + + tests := []struct { + subChannels []string // All channels to subscribe to + subPatterns []string // All patterns to subscribe to + unSubChannels []string // Channels to unsubscribe from + unSubPatterns []string // Patterns to unsubscribe from + remainChannels []string // Channels to remain subscribed to + remainPatterns []string // Patterns to remain subscribed to + targetConn *resp.Conn // Connection used to test unsubscribe functionality + otherConnections []*resp.Conn // Connections to fill the subscribers list for channels and patterns + expectedResponses map[string][][]string // The expected response from the handler + }{ + { // 1. Unsubscribe from channels and patterns + subChannels: []string{"xx_channel_one", "xx_channel_two", "xx_channel_three", "xx_channel_four"}, + subPatterns: []string{"xx_pattern_[ab]", "xx_pattern_[cd]", "xx_pattern_[ef]", "xx_pattern_[gh]"}, + unSubChannels: []string{"xx_channel_one", "xx_channel_two"}, + unSubPatterns: []string{"xx_pattern_[ab]"}, + remainChannels: []string{"xx_channel_three", "xx_channel_four"}, + remainPatterns: []string{"xx_pattern_[cd]", "xx_pattern_[ef]", "xx_pattern_[gh]"}, + targetConn: generateConnections(1)[0], + otherConnections: generateConnections(20), + expectedResponses: map[string][][]string{ + "channel": { + {"unsubscribe", "xx_channel_one", "1"}, + {"unsubscribe", "xx_channel_two", "2"}, + }, + "pattern": { + {"punsubscribe", "xx_pattern_[ab]", "1"}, + }, + }, + }, + { // 2. Unsubscribe from all channels no channel or pattern is passed to command + subChannels: []string{"xx_channel_one", "xx_channel_two", "xx_channel_three", "xx_channel_four"}, + subPatterns: []string{"xx_pattern_[ab]", "xx_pattern_[cd]", "xx_pattern_[ef]", "xx_pattern_[gh]"}, + unSubChannels: []string{}, + unSubPatterns: []string{}, + remainChannels: []string{}, + remainPatterns: []string{}, + targetConn: generateConnections(1)[0], + otherConnections: generateConnections(20), + expectedResponses: map[string][][]string{ + "channel": { + {"unsubscribe", "xx_channel_one", "1"}, + {"unsubscribe", "xx_channel_two", "2"}, + {"unsubscribe", "xx_channel_three", "3"}, + {"unsubscribe", "xx_channel_four", "4"}, + }, + "pattern": { + {"punsubscribe", "xx_pattern_[ab]", "1"}, + {"punsubscribe", "xx_pattern_[cd]", "2"}, + {"punsubscribe", "xx_pattern_[ef]", "3"}, + {"punsubscribe", "xx_pattern_[gh]", "4"}, + }, + }, + }, + { // 3. Don't unsubscribe from any channels or patterns if the provided ones are non-existent + subChannels: []string{"xx_channel_one", "xx_channel_two", "xx_channel_three", "xx_channel_four"}, + subPatterns: []string{"xx_pattern_[ab]", "xx_pattern_[cd]", "xx_pattern_[ef]", "xx_pattern_[gh]"}, + unSubChannels: []string{"xx_channel_non_existent_channel"}, + unSubPatterns: []string{"xx_channel_non_existent_pattern_[ae]"}, + remainChannels: []string{"xx_channel_one", "xx_channel_two", "xx_channel_three", "xx_channel_four"}, + remainPatterns: []string{"xx_pattern_[ab]", "xx_pattern_[cd]", "xx_pattern_[ef]", "xx_pattern_[gh]"}, + targetConn: generateConnections(1)[0], + otherConnections: generateConnections(20), + expectedResponses: map[string][][]string{ + "channel": {}, + "pattern": {}, + }, + }, + } + + for _, test := range tests { + // Subscribe to channels. + for _, conn := range append(test.otherConnections, test.targetConn) { + command := []resp.Value{resp.StringValue("SUBSCRIBE")} + for _, channel := range test.subChannels { + command = append(command, resp.StringValue(channel)) + } + if err := conn.WriteArray(command); err != nil { + t.Error(err) + return + } + for i := 0; i < len(test.subChannels); i++ { + // Read channel subscription confirmations from connection. + if _, _, err := conn.ReadValue(); err != nil { + t.Error(err) + } + } + + // Subscribe to patterns. + command = []resp.Value{resp.StringValue("PSUBSCRIBE")} + for _, pattern := range test.subPatterns { + command = append(command, resp.StringValue(pattern)) + } + if err := conn.WriteArray(command); err != nil { + t.Error(err) + return + } + for i := 0; i < len(test.subPatterns); i++ { + // Read pattern subscription confirmations from connection. + if _, _, err := conn.ReadValue(); err != nil { + t.Error(err) + } + } + + } + + // Unsubscribe the target connection from the unsub channels. + command := []resp.Value{resp.StringValue("UNSUBSCRIBE")} + for _, channel := range test.unSubChannels { + command = append(command, resp.StringValue(channel)) + } + if err := test.targetConn.WriteArray(command); err != nil { + t.Error(err) + return + } + res, _, err := test.targetConn.ReadValue() + if err != nil { + t.Error(err) + return + } + verifyResponse(res, test.expectedResponses["channel"]) + + // Unsubscribe the target connection from the unsub patterns. + command = []resp.Value{resp.StringValue("PUNSUBSCRIBE")} + for _, pattern := range test.unSubPatterns { + command = append(command, resp.StringValue(pattern)) + } + if err = test.targetConn.WriteArray(command); err != nil { + t.Error(err) + return + } + res, _, err = test.targetConn.ReadValue() + if err != nil { + t.Error(err) + return + } + verifyResponse(res, test.expectedResponses["pattern"]) + } + }) + + t.Run("Test_HandlePublish", func(t *testing.T) { + t.Parallel() + + var rawConnections []net.Conn + establishConnection := func() *resp.Conn { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + } + rawConnections = append(rawConnections, conn) + return resp.NewConn(conn) + } + defer func() { + for _, conn := range rawConnections { + _ = conn.Close() + } + }() + + // verifyChannelMessage reads the message from the connection and asserts whether + // it's the message we expect to read as a subscriber of a channel or pattern. + verifyEvent := func(client *resp.Conn, expected []string) { + rv, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + v := rv.Array() + for i := 0; i < len(v); i++ { + if v[i].String() != expected[i] { + t.Errorf("expected item at index %d to be \"%s\", got \"%s\"", i, expected[i], v[i].String()) + } + } + } + + // The subscribe function handles subscribing the connection to the given + // channels and patterns and reading/verifying the message sent by the server after + // subscription. + subscribe := func(client *resp.Conn, channels []string, patterns []string) { + // Subscribe to channels + command := []resp.Value{resp.StringValue("SUBSCRIBE")} + for _, channel := range channels { + command = append(command, resp.StringValue(channel)) + } + if err := client.WriteArray(command); err != nil { + t.Error(err) + } + for i := 0; i < len(channels); i++ { + // Read channel subscription confirmations. + if _, _, err := client.ReadValue(); err != nil { + t.Error(err) + } + } + + // Subscribe to all the patterns + command = []resp.Value{resp.StringValue("PSUBSCRIBE")} + for _, pattern := range patterns { + command = append(command, resp.StringValue(pattern)) + } + if err := client.WriteArray(command); err != nil { + t.Error(err) + } + for i := 0; i < len(patterns); i++ { + // Read pattern subscription confirmations. + if _, _, err := client.ReadValue(); err != nil { + t.Error(err) + } + } + } + + subscriptions := []struct { + client *resp.Conn + channels []string + patterns []string + }{ + { + client: establishConnection(), + channels: []string{"pub_channel_1", "pub_channel_2", "pub_channel_3"}, + patterns: []string{"pub_channel_[456]"}, + }, + { + client: establishConnection(), + channels: []string{"pub_channel_6", "pub_channel_7"}, + patterns: []string{"pub_channel_[891]"}, + }, + } + for _, subscription := range subscriptions { + // Subscribe to channels and patterns. + subscribe(subscription.client, subscription.channels, subscription.patterns) + } + + type Subscriber struct { + client *resp.Conn + channel string + } + + tests := []struct { + channel string + message string + subscribers []Subscriber + }{ + { + channel: "pub_channel_1", + message: "Test both subscribers 1", + subscribers: []Subscriber{ + {client: subscriptions[0].client, channel: "pub_channel_1"}, + {client: subscriptions[1].client, channel: "pub_channel_[891]"}, + }, + }, + { + channel: "pub_channel_6", + message: "Test both subscribers 2", + subscribers: []Subscriber{ + {client: subscriptions[0].client, channel: "pub_channel_[456]"}, + {client: subscriptions[1].client, channel: "pub_channel_6"}, + }, + }, + { + channel: "pub_channel_2", + message: "Test subscriber 1 1", + subscribers: []Subscriber{ + {client: subscriptions[0].client, channel: "pub_channel_2"}, + }, + }, + { + channel: "pub_channel_3", + message: "Test subscriber 1 2", + subscribers: []Subscriber{ + {client: subscriptions[0].client, channel: "pub_channel_3"}, + }, + }, + { + channel: "pub_channel_4", + message: "Test both subscribers 2", + subscribers: []Subscriber{ + {client: subscriptions[0].client, channel: "pub_channel_[456]"}, + }, + }, + { + channel: "pub_channel_5", + message: "Test subscriber 1 3", + subscribers: []Subscriber{ + {client: subscriptions[0].client, channel: "pub_channel_[456]"}, + }, + }, + { + channel: "pub_channel_7", + message: "Test subscriber 2 1", + subscribers: []Subscriber{ + {client: subscriptions[1].client, channel: "pub_channel_7"}, + }, + }, + { + channel: "pub_channel_8", + message: "Test subscriber 2 2", + subscribers: []Subscriber{ + {client: subscriptions[1].client, channel: "pub_channel_[891]"}, + }, + }, + { + channel: "pub_channel_9", + message: "Test subscriber 2 3", + subscribers: []Subscriber{ + {client: subscriptions[1].client, channel: "pub_channel_[891]"}, + }, + }, + } + + // Dial echovault to make publisher connection + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + publisher := resp.NewConn(conn) + + for _, test := range tests { + err = publisher.WriteArray([]resp.Value{ + resp.StringValue("PUBLISH"), + resp.StringValue(test.channel), + resp.StringValue(test.message), + }) + if err != nil { + t.Error(err) + } + + rv, _, err := publisher.ReadValue() + if err != nil { + t.Error(err) + } + if rv.String() != "OK" { + t.Errorf("Expected publish response to be \"OK\", got \"%s\"", rv.String()) + } + + for _, sub := range test.subscribers { + verifyEvent(sub.client, []string{"message", sub.channel, test.message}) + } + } + }) + + t.Run("Test_HandlePubSubChannels", func(t *testing.T) { + t.Parallel() + + verifyExpectedResponse := func(res resp.Value, expected []string) { + if len(res.Array()) != len(expected) { + t.Errorf("expected response array of length %d, got %d", len(expected), len(res.Array())) + } + for _, e := range expected { + if !slices.ContainsFunc(res.Array(), func(v resp.Value) bool { + return e == v.String() + }) { + t.Errorf("expected to find element \"%s\" in response array, could not find it", e) + } + } + } + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + mockServer, err := setUpServer(port) + if err != nil { + t.Error(err) + return + } + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + wg.Done() + mockServer.Start() + }() + wg.Wait() + + subscribers := make([]*resp.Conn, 2) + for i := 0; i < len(subscribers); i++ { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + subscribers[i] = resp.NewConn(conn) + } + + channels := []string{"channel_1", "channel_2", "channel_3"} + patterns := []string{"channel_[123]", "channel_[456]"} + + subscriptions := []struct { + client *resp.Conn + action string + channels []string + patterns []string + }{ + { + client: subscribers[0], + action: "SUBSCRIBE", + channels: channels, + patterns: make([]string, 0), + }, + { + client: subscribers[1], + action: "PSUBSCRIBE", + channels: make([]string, 0), + patterns: patterns, + }, + } + for _, subscription := range subscriptions { + command := []resp.Value{resp.StringValue(subscription.action)} + if len(subscription.channels) > 0 { + for _, channel := range subscription.channels { + command = append(command, resp.StringValue(channel)) + } + } else if len(subscription.patterns) > 0 { + for _, pattern := range subscription.patterns { + command = append(command, resp.StringValue(pattern)) + } + } + if err := subscription.client.WriteArray(command); err != nil { + t.Error(err) + } + if len(subscription.channels) > 0 { + for i := 0; i < len(subscription.channels); i++ { + _, _, _ = subscription.client.ReadValue() + } + return + } + for i := 0; i < len(subscription.patterns); i++ { + _, _, _ = subscription.client.ReadValue() + } + } + + // Get fresh connection for the next phase. + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client := resp.NewConn(conn) + + // Check if all subscriptions are returned. + if err = client.WriteArray([]resp.Value{resp.StringValue("PUBSUB"), resp.StringValue("CHANNELS")}); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + verifyExpectedResponse(res, append(channels, patterns...)) + + // Unsubscribe from one pattern and one channel before checking against a new slice of + // expected channels/patterns in the response of the "PUBSUB CHANNELS" command. + for _, unsubscribe := range []struct { + client *resp.Conn + command []resp.Value + }{ + { + client: subscribers[0], + command: []resp.Value{resp.StringValue("UNSUBSCRIBE"), resp.StringValue("channel_2"), resp.StringValue("channel_3")}, + }, + { + client: subscribers[1], + command: []resp.Value{resp.StringValue("UNSUBSCRIBE"), resp.StringValue("channel_[456]")}, + }, + } { + if err = unsubscribe.client.WriteArray(unsubscribe.command); err != nil { + t.Error(err) + } + for i := 0; i < len(unsubscribe.command[1:]); i++ { + _, _, err = unsubscribe.client.ReadValue() + if err != nil { + t.Error(err) + } + } + } + + // Return all the remaining channels. + if err = client.WriteArray([]resp.Value{resp.StringValue("PUBSUB"), resp.StringValue("CHANNELS")}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + verifyExpectedResponse(res, []string{"channel_1", "channel_[123]"}) + + // Return only one of the remaining channels when passed a pattern that matches it. + if err = client.WriteArray([]resp.Value{ + resp.StringValue("PUBSUB"), + resp.StringValue("CHANNELS"), + resp.StringValue("channel_[189]"), + }); err != nil { + t.Error(err) + } + verifyExpectedResponse(res, []string{"channel_1"}) + + // Return both remaining channels when passed a pattern that matches them. + if err := client.WriteArray([]resp.Value{ + resp.StringValue("PUBSUB"), + resp.StringValue("CHANNELS"), + resp.StringValue("channel_[123]"), + }); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + verifyExpectedResponse(res, []string{"channel_1", "channel_[123]"}) + + // Return no channels when passed a pattern that does not match either channel. + if err = client.WriteArray([]resp.Value{ + resp.StringValue("PUBSUB"), + resp.StringValue("CHANNELS"), + resp.StringValue("channel_[456]"), + }); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + verifyExpectedResponse(res, []string{}) + }) + + t.Run("Test_HandleNumPat", func(t *testing.T) { + t.Parallel() + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := setUpServer(port) + if err != nil { + t.Error(err) + return + } + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + wg.Done() + mockServer.Start() + }() + wg.Wait() + + // Create subscribers. + subscribers := make([]*resp.Conn, 3) + for i := 0; i < len(subscribers); i++ { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + subscribers[i] = resp.NewConn(conn) + } + + patterns := []string{"pattern_[123]", "pattern_[456]", "pattern_[789]"} + + // Subscribe to all patterns + for _, client := range subscribers { + command := []resp.Value{resp.StringValue("PSUBSCRIBE")} + for _, pattern := range patterns { + command = append(command, resp.StringValue(pattern)) + } + if err := client.WriteArray(command); err != nil { + t.Error(err) + } + // Read subscription responses to make sure we've subscribed to all the channels. + for i := 0; i < len(patterns); i++ { + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if len(res.Array()) != 3 { + t.Errorf("expected array response of length %d, got %d", 3, len(res.Array())) + } + if !strings.EqualFold(res.Array()[0].String(), "psubscribe") { + t.Errorf("expected the first array item to be \"psubscribe\", got \"%s\"", res.Array()[0].String()) + } + if !slices.Contains(patterns, res.Array()[1].String()) { + t.Errorf("unexpected channel name \"%s\", expected %v", res.Array()[1].String(), patterns) + } + } + } + + // Get fresh connection for the next phase. + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client := resp.NewConn(conn) + + // Check that we receive all the patterns with NUMPAT commands. + if err = client.WriteArray([]resp.Value{resp.StringValue("PUBSUB"), resp.StringValue("NUMPAT")}); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if res.Integer() != len(patterns) { + t.Errorf("expected response \"%d\", got \"%d\"", len(patterns), res.Integer()) + } + + // Unsubscribe all subscribers from one pattern and check if the response is updated. + for _, subscriber := range subscribers { + if err = subscriber.WriteArray([]resp.Value{ + resp.StringValue("PUNSUBSCRIBE"), + resp.StringValue(patterns[0]), + }); err != nil { + t.Error(err) + } + res, _, err = subscriber.ReadValue() + if err != nil { + t.Error(err) + } + if len(res.Array()[0].Array()) != 3 { + t.Errorf("expected array response of length %d, got %d", 3, len(res.Array()[0].Array())) + } + if !strings.EqualFold(res.Array()[0].Array()[0].String(), "punsubscribe") { + t.Errorf("expected the first array item to be \"punsubscribe\", got \"%s\"", res.Array()[0].Array()[0].String()) + } + if res.Array()[0].Array()[1].String() != patterns[0] { + t.Errorf("unexpected channel name \"%s\", expected %s", res.Array()[0].Array()[1].String(), patterns[0]) + } + } + if err = client.WriteArray([]resp.Value{resp.StringValue("PUBSUB"), resp.StringValue("NUMPAT")}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if res.Integer() != len(patterns)-1 { + t.Errorf("expected response \"%d\", got \"%d\"", len(patterns)-1, res.Integer()) + } + + // Unsubscribe from all the channels and check if we get a 0 response + for _, subscriber := range subscribers { + for _, pattern := range patterns[1:] { + if err = subscriber.WriteArray([]resp.Value{ + resp.StringValue("PUNSUBSCRIBE"), + resp.StringValue(pattern), + }); err != nil { + t.Error(err) + } + res, _, err = subscriber.ReadValue() + if err != nil { + t.Error(err) + } + if len(res.Array()[0].Array()) != 3 { + t.Errorf("expected array response of length %d, got %d", 3, + len(res.Array()[0].Array())) + } + if !strings.EqualFold(res.Array()[0].Array()[0].String(), "punsubscribe") { + t.Errorf("expected the first array item to be \"punsubscribe\", got \"%s\"", + res.Array()[0].Array()[0].String()) + } + if res.Array()[0].Array()[1].String() != pattern { + t.Errorf("unexpected channel name \"%s\", expected %s", + res.Array()[0].Array()[1].String(), pattern) + } + } + } + if err = client.WriteArray([]resp.Value{resp.StringValue("PUBSUB"), resp.StringValue("NUMPAT")}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if res.Integer() != 0 { + t.Errorf("expected response \"%d\", got \"%d\"", 0, res.Integer()) + } + }) + + t.Run("Test_HandleNumSub", func(t *testing.T) { + t.Parallel() + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := setUpServer(port) + if err != nil { + t.Error(err) + return + } + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + wg.Done() + mockServer.Start() + }() + wg.Wait() + + channels := []string{"channel_1", "channel_2", "channel_3"} + + for i := 0; i < 3; i++ { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client := resp.NewConn(conn) + command := []resp.Value{ + resp.StringValue("SUBSCRIBE"), + } + for _, channel := range channels { + command = append(command, resp.StringValue(channel)) + } + err = client.WriteArray(command) + if err != nil { + t.Error(err) + } + + // Read subscription responses to make sure we've subscribed to all the channels. + for i := 0; i < len(channels); i++ { + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + if len(res.Array()) != 3 { + t.Errorf("expected array response of length %d, got %d", 3, len(res.Array())) + } + if !strings.EqualFold(res.Array()[0].String(), "subscribe") { + t.Errorf("expected the first array item to be \"subscribe\", got \"%s\"", res.Array()[0].String()) + } + if !slices.Contains(channels, res.Array()[1].String()) { + t.Errorf("unexpected channel name \"%s\", expected %v", res.Array()[1].String(), channels) + } + } + } + + // Get fresh connection for the next phase. + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client := resp.NewConn(conn) + + tests := []struct { + name string + cmd []string + expectedResponse [][]string + }{ + { + name: "1. Get all subscriptions on existing channels", + cmd: append([]string{"PUBSUB", "NUMSUB"}, channels...), + expectedResponse: [][]string{{"channel_1", "3"}, {"channel_2", "3"}, {"channel_3", "3"}}, + }, + { + name: "2. Get all the subscriptions of on existing channels and a few non-existent ones", + cmd: append([]string{"PUBSUB", "NUMSUB", "non_existent_channel_1", "non_existent_channel_2"}, channels...), + expectedResponse: [][]string{ + {"non_existent_channel_1", "0"}, + {"non_existent_channel_2", "0"}, + {"channel_1", "3"}, + {"channel_2", "3"}, + {"channel_3", "3"}, + }, + }, + { + name: "3. Get an empty array when channels are not provided in the command", + cmd: []string{"PUBSUB", "NUMSUB"}, + expectedResponse: make([][]string, 0), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var command []resp.Value + for _, token := range test.cmd { + command = append(command, resp.StringValue(token)) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + arr := res.Array() + if len(arr) != len(test.expectedResponse) { + t.Errorf("expected response array of length %d, got %d", len(test.expectedResponse), len(arr)) + } + + for _, item := range arr { + itemArr := item.Array() + if len(itemArr) != 2 { + t.Errorf("expected each response item to be of length 2, got %d", len(itemArr)) + } + if !slices.ContainsFunc(test.expectedResponse, func(expected []string) bool { + return expected[0] == itemArr[0].String() && expected[1] == itemArr[1].String() + }) { + t.Errorf("could not find entry with channel \"%s\", with %d subscribers in expected response", + itemArr[0].String(), itemArr[1].Integer()) + } + } + }) + } + }) +} diff --git a/internal/modules/pubsub/pubsub.go b/internal/modules/pubsub/pubsub.go new file mode 100644 index 0000000..49f94bc --- /dev/null +++ b/internal/modules/pubsub/pubsub.go @@ -0,0 +1,277 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package pubsub + +import ( + "context" + "fmt" + "log" + "net" + "slices" + "sync" + + "github.com/gobwas/glob" + "github.com/tidwall/resp" +) + +type PubSub struct { + channels []*Channel + channelsRWMut sync.RWMutex +} + +func NewPubSub() *PubSub { + return &PubSub{ + channels: []*Channel{}, + channelsRWMut: sync.RWMutex{}, + } +} + +func (ps *PubSub) Subscribe(_ context.Context, conn *net.Conn, channels []string, withPattern bool) { + ps.channelsRWMut.Lock() + defer ps.channelsRWMut.Unlock() + + r := resp.NewConn(*conn) + + action := "subscribe" + if withPattern { + action = "psubscribe" + } + + for i := 0; i < len(channels); i++ { + // Check if channel with given name exists + // If it does, subscribe the connection to the channel + // If it does not, create the channel and subscribe to it + channelIdx := slices.IndexFunc(ps.channels, func(channel *Channel) bool { + return channel.name == channels[i] + }) + + if channelIdx == -1 { + // Create new channel, start it, and subscribe to it + var newChan *Channel + if withPattern { + newChan = NewChannel(WithPattern(channels[i])) + } else { + newChan = NewChannel(WithName(channels[i])) + } + newChan.Start() + if newChan.Subscribe(conn) { + if err := r.WriteArray([]resp.Value{ + resp.StringValue(action), + resp.StringValue(newChan.name), + resp.IntegerValue(i + 1), + }); err != nil { + log.Println(err) + } + ps.channels = append(ps.channels, newChan) + } + } else { + // Subscribe to existing channel + if ps.channels[channelIdx].Subscribe(conn) { + if err := r.WriteArray([]resp.Value{ + resp.StringValue(action), + resp.StringValue(ps.channels[channelIdx].name), + resp.IntegerValue(i + 1), + }); err != nil { + log.Println(err) + } + } + } + } +} + +func (ps *PubSub) Unsubscribe(_ context.Context, conn *net.Conn, channels []string, withPattern bool) []byte { + ps.channelsRWMut.RLock() + defer ps.channelsRWMut.RUnlock() + + action := "unsubscribe" + if withPattern { + action = "punsubscribe" + } + + unsubscribed := make(map[int]string) + idx := 1 + + if len(channels) <= 0 { + if !withPattern { + // If the channels slice is empty, and no pattern is provided + // unsubscribe from all channels. + for _, channel := range ps.channels { + if channel.pattern != nil { // Skip pattern channels + continue + } + if channel.Unsubscribe(conn) { + unsubscribed[idx] = channel.name + idx += 1 + } + } + } else { + // If the channels slice is empty, and pattern is provided + // unsubscribe from all patterns. + for _, channel := range ps.channels { + if channel.pattern == nil { // Skip non-pattern channels + continue + } + if channel.Unsubscribe(conn) { + unsubscribed[idx] = channel.name + idx += 1 + } + } + } + } + + // Unsubscribe from channels where the name exactly matches channel name. + // If unsubscribing from a pattern, also unsubscribe from all channel whose + // names exactly matches the pattern name. + for _, channel := range ps.channels { // For each channel in PubSub + for _, c := range channels { // For each channel name provided + if channel.name == c && channel.Unsubscribe(conn) { + unsubscribed[idx] = channel.name + idx += 1 + } + } + } + + // If withPattern is true, unsubscribe from channels where pattern matches pattern provided, + // also unsubscribe from channels where the name matches the given pattern. + if withPattern { + for _, pattern := range channels { + g := glob.MustCompile(pattern) + for _, channel := range ps.channels { + // If it's a pattern channel, directly compare the patterns + if channel.pattern != nil && channel.name == pattern { + if channel.Unsubscribe(conn) { + unsubscribed[idx] = channel.name + idx += 1 + } + continue + } + // If this is a regular channel, check if the channel name matches the pattern given + if g.Match(channel.name) { + if channel.Unsubscribe(conn) { + unsubscribed[idx] = channel.name + idx += 1 + } + } + } + } + } + + res := fmt.Sprintf("*%d\r\n", len(unsubscribed)) + for key, value := range unsubscribed { + res += fmt.Sprintf("*3\r\n+%s\r\n$%d\r\n%s\r\n:%d\r\n", action, len(value), value, key) + } + + return []byte(res) +} + +func (ps *PubSub) Publish(_ context.Context, message string, channelName string) { + ps.channelsRWMut.RLock() + defer ps.channelsRWMut.RUnlock() + + for _, channel := range ps.channels { + // If it's a regular channel, check if the channel name matches the name given + if channel.pattern == nil { + if channel.name == channelName { + channel.Publish(message) + } + continue + } + // If it's a glob pattern channel, check if the name matches the pattern + if channel.pattern.Match(channelName) { + channel.Publish(message) + } + } +} + +func (ps *PubSub) Channels(pattern string) []byte { + ps.channelsRWMut.RLock() + defer ps.channelsRWMut.RUnlock() + + var count int + var res string + + if pattern == "" { + for _, channel := range ps.channels { + if channel.IsActive() { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(channel.name), channel.name) + count += 1 + } + } + res = fmt.Sprintf("*%d\r\n%s", count, res) + return []byte(res) + } + + g := glob.MustCompile(pattern) + + for _, channel := range ps.channels { + // If channel is a pattern channel, then directly compare the channel name to pattern + if channel.pattern != nil && channel.name == pattern && channel.IsActive() { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(channel.name), channel.name) + count += 1 + continue + } + // Channel is not a pattern channel. Check if the channel name matches the provided glob pattern + if g.Match(channel.name) && channel.IsActive() { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(channel.name), channel.name) + count += 1 + } + } + + return []byte(fmt.Sprintf("*%d\r\n%s", count, res)) +} + +func (ps *PubSub) NumPat() int { + ps.channelsRWMut.RLock() + defer ps.channelsRWMut.RUnlock() + + var count int + for _, channel := range ps.channels { + if channel.pattern != nil && channel.IsActive() { + count += 1 + } + } + return count +} + +func (ps *PubSub) NumSub(channels []string) []byte { + ps.channelsRWMut.RLock() + defer ps.channelsRWMut.RUnlock() + + res := fmt.Sprintf("*%d\r\n", len(channels)) + for _, channel := range channels { + // If it's a pattern channel, skip it + chanIdx := slices.IndexFunc(ps.channels, func(c *Channel) bool { + return c.name == channel + }) + if chanIdx == -1 { + res += fmt.Sprintf("*2\r\n$%d\r\n%s\r\n:0\r\n", len(channel), channel) + continue + } + res += fmt.Sprintf("*2\r\n$%d\r\n%s\r\n:%d\r\n", len(channel), channel, ps.channels[chanIdx].NumSubs()) + } + return []byte(res) +} + +func (ps *PubSub) GetAllChannels() []*Channel { + ps.channelsRWMut.RLock() + defer ps.channelsRWMut.RUnlock() + + channels := make([]*Channel, len(ps.channels)) + for i, channel := range ps.channels { + channels[i] = channel + } + + return channels +} diff --git a/internal/modules/set/commands.go b/internal/modules/set/commands.go new file mode 100644 index 0000000..959dc57 --- /dev/null +++ b/internal/modules/set/commands.go @@ -0,0 +1,739 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package set + +import ( + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" + "slices" + "strings" +) + +func handleSADD(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := saddKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + + var set *Set + + if !keyExists { + set = NewSet(params.Command[2:]) + if err = params.SetValues(params.Context, map[string]interface{}{key: set}); err != nil { + return nil, err + } + return []byte(fmt.Sprintf(":%d\r\n", len(params.Command[2:]))), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*Set) + if !ok { + return nil, fmt.Errorf("value at key %s is not a set", key) + } + + count := set.Add(params.Command[2:]) + + return []byte(fmt.Sprintf(":%d\r\n", count)), nil +} + +func handleSCARD(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := scardKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + if !keyExists { + return []byte(fmt.Sprintf(":0\r\n")), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*Set) + if !ok { + return nil, fmt.Errorf("value at key %s is not a set", key) + } + + cardinality := set.Cardinality() + + return []byte(fmt.Sprintf(":%d\r\n", cardinality)), nil +} + +func handleSDIFF(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := sdiffKeyFunc(params.Command) + if err != nil { + return nil, err + } + + keyExists := params.KeysExist(params.Context, keys.ReadKeys) + + // Extract base set first + if !keyExists[keys.ReadKeys[0]] { + return nil, fmt.Errorf("key for base set \"%s\" does not exist", keys.ReadKeys[0]) + } + + baseSet, ok := params.GetValues(params.Context, []string{keys.ReadKeys[0]})[keys.ReadKeys[0]].(*Set) + if !ok { + return nil, fmt.Errorf("value at key %s is not a set", keys.ReadKeys[0]) + } + + var sets []*Set + for _, key := range params.Command[2:] { + set, ok := params.GetValues(params.Context, []string{key})[key].(*Set) + if !ok { + continue + } + sets = append(sets, set) + } + + diff := baseSet.Subtract(sets) + elems := diff.GetAll() + + res := fmt.Sprintf("*%d", len(elems)) + for i, e := range elems { + res = fmt.Sprintf("%s\r\n$%d\r\n%s", res, len(e), e) + if i == len(elems)-1 { + res += "\r\n" + } + } + + return []byte(res), nil +} + +func handleSDIFFSTORE(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := sdiffstoreKeyFunc(params.Command) + if err != nil { + return nil, err + } + + destination := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, append(keys.WriteKeys, keys.ReadKeys...)) + + // Extract base set first + if !keyExists[keys.ReadKeys[0]] { + return nil, fmt.Errorf("key for base set \"%s\" does not exist", keys.ReadKeys[0]) + } + + baseSet, ok := params.GetValues(params.Context, []string{keys.ReadKeys[0]})[keys.ReadKeys[0]].(*Set) + if !ok { + return nil, fmt.Errorf("value at key %s is not a set", keys.ReadKeys[0]) + } + + var sets []*Set + for _, key := range keys.ReadKeys[1:] { + set, ok := params.GetValues(params.Context, []string{key})[key].(*Set) + if !ok { + continue + } + sets = append(sets, set) + } + + diff := baseSet.Subtract(sets) + elems := diff.GetAll() + + res := fmt.Sprintf(":%d\r\n", len(elems)) + + if err = params.SetValues(params.Context, map[string]interface{}{destination: diff}); err != nil { + return nil, err + } + + return []byte(res), nil +} + +func handleSINTER(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := sinterKeyFunc(params.Command) + if err != nil { + return nil, err + } + + keyExists := params.KeysExist(params.Context, keys.ReadKeys) + + var sets []*Set + + for key, exists := range keyExists { + if !exists { + return []byte("*0\r\n"), nil + } + set, ok := params.GetValues(params.Context, []string{key})[key].(*Set) + if !ok { + // If the value at the key is not a set, return error + return nil, fmt.Errorf("value at key %s is not a set", key) + } + sets = append(sets, set) + } + + if len(sets) <= 0 { + return nil, fmt.Errorf("not enough sets in the keys provided") + } + + intersect, _ := Intersection(0, sets...) + elems := intersect.GetAll() + + res := fmt.Sprintf("*%d", len(elems)) + for i, e := range elems { + res = fmt.Sprintf("%s\r\n$%d\r\n%s", res, len(e), e) + if i == len(elems)-1 { + res += "\r\n" + } + } + + return []byte(res), nil +} + +func handleSINTERCARD(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := sintercardKeyFunc(params.Command) + if err != nil { + return nil, err + } + + keyExists := params.KeysExist(params.Context, keys.ReadKeys) + + // Extract the limit from the command + var limit int + limitIdx := slices.IndexFunc(params.Command, func(s string) bool { + return strings.EqualFold(s, "limit") + }) + if limitIdx >= 0 && limitIdx < 2 { + return nil, errors.New(constants.WrongArgsResponse) + } + if limitIdx != -1 { + limitIdx += 1 + if limitIdx >= len(params.Command) { + return nil, errors.New("provide limit after LIMIT keyword") + } + + if l, ok := internal.AdaptType(params.Command[limitIdx]).(int); !ok { + return nil, errors.New("limit must be an integer") + } else { + limit = l + } + } + + var sets []*Set + + for key, exists := range keyExists { + if !exists { + return []byte(":0\r\n"), nil + } + set, ok := params.GetValues(params.Context, []string{key})[key].(*Set) + if !ok { + // If the value at the key is not a set, return error + return nil, fmt.Errorf("value at key %s is not a set", key) + } + sets = append(sets, set) + } + + if len(sets) <= 0 { + return nil, fmt.Errorf("not enough sets in the keys provided") + } + + intersect, _ := Intersection(limit, sets...) + + return []byte(fmt.Sprintf(":%d\r\n", intersect.Cardinality())), nil +} + +func handleSINTERSTORE(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := sinterstoreKeyFunc(params.Command) + if err != nil { + return nil, err + } + + keyExists := params.KeysExist(params.Context, keys.ReadKeys) + + var sets []*Set + + for key, exists := range keyExists { + if !exists { + return []byte(":0\r\n"), err + } + set, ok := params.GetValues(params.Context, []string{key})[key].(*Set) + if !ok { + // If the value at the key is not a set, return error + return nil, fmt.Errorf("value at key %s is not a set", key) + } + sets = append(sets, set) + } + + intersect, _ := Intersection(0, sets...) + destination := keys.WriteKeys[0] + + if err = params.SetValues(params.Context, map[string]interface{}{destination: intersect}); err != nil { + return nil, err + } + + return []byte(fmt.Sprintf(":%d\r\n", intersect.Cardinality())), nil +} + +func handleSISMEMBER(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := sismemberKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + if !keyExists { + return []byte(":0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*Set) + if !ok { + return nil, fmt.Errorf("value at key %s is not a set", key) + } + + if !set.Contains(params.Command[2]) { + return []byte(":0\r\n"), nil + } + + return []byte(":1\r\n"), nil +} + +func handleSMEMBERS(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := smembersKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + if !keyExists { + return []byte("*0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*Set) + if !ok { + return nil, fmt.Errorf("value at key %s is not a set", key) + } + + elems := set.GetAll() + + res := fmt.Sprintf("*%d", len(elems)) + for i, e := range elems { + res = fmt.Sprintf("%s\r\n$%d\r\n%s", res, len(e), e) + if i == len(elems)-1 { + res += "\r\n" + } + } + + return []byte(res), nil +} + +func handleSMISMEMBER(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := smismemberKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + members := params.Command[2:] + + if !keyExists { + res := fmt.Sprintf("*%d", len(members)) + for i, _ := range members { + res = fmt.Sprintf("%s\r\n:0", res) + if i == len(members)-1 { + res += "\r\n" + } + } + return []byte(res), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*Set) + if !ok { + return nil, fmt.Errorf("value at key %s is not a set", key) + } + + res := fmt.Sprintf("*%d", len(members)) + for i := 0; i < len(members); i++ { + if set.Contains(members[i]) { + res += "\r\n:1" + } else { + res += "\r\n:0" + } + } + res += "\r\n" + + return []byte(res), nil +} + +func handleSMOVE(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := smoveKeyFunc(params.Command) + if err != nil { + return nil, err + } + + source, destination := keys.WriteKeys[0], keys.WriteKeys[1] + keyExists := params.KeysExist(params.Context, keys.WriteKeys) + member := params.Command[3] + + if !keyExists[source] { + return []byte(":0\r\n"), nil + } + + sets := params.GetValues(params.Context, keys.WriteKeys) + + sourceSet, ok := sets[source].(*Set) + if !ok { + return nil, errors.New("source is not a set") + } + + destinationSet, ok := sets[destination].(*Set) + if !ok { + return nil, errors.New("destination is not a set") + } + + res := sourceSet.Move(destinationSet, member) + + return []byte(fmt.Sprintf(":%d\r\n", res)), nil +} + +func handleSPOP(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := spopKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + count := 1 + + if len(params.Command) == 3 { + c, ok := internal.AdaptType(params.Command[2]).(int) + if !ok { + return nil, errors.New("count must be an integer") + } + count = c + } + + if !keyExists { + return []byte("*-1\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*Set) + if !ok { + return nil, fmt.Errorf("value at %s is not a set", key) + } + + members := set.Pop(count) + + res := fmt.Sprintf("*%d", len(members)) + for i, m := range members { + res = fmt.Sprintf("%s\r\n$%d\r\n%s", res, len(m), m) + if i == len(members)-1 { + res += "\r\n" + } + } + + return []byte(res), nil +} + +func handleSRANDMEMBER(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := srandmemberKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + count := 1 + + if len(params.Command) == 3 { + c, ok := internal.AdaptType(params.Command[2]).(int) + if !ok { + return nil, errors.New("count must be an integer") + } + count = c + } + + if !keyExists { + return []byte("*-1\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*Set) + if !ok { + return nil, fmt.Errorf("value at %s is not a set", key) + } + + members := set.GetRandom(count) + + res := fmt.Sprintf("*%d", len(members)) + for i, m := range members { + res = fmt.Sprintf("%s\r\n$%d\r\n%s", res, len(m), m) + if i == len(members)-1 { + res += "\r\n" + } + } + + return []byte(res), nil +} + +func handleSREM(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := sremKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + members := params.Command[2:] + + if !keyExists { + return []byte(":0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*Set) + if !ok { + return nil, fmt.Errorf("value at key %s is not a set", key) + } + + count := set.Remove(members) + + return []byte(fmt.Sprintf(":%d\r\n", count)), nil +} + +func handleSUNION(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := sunionKeyFunc(params.Command) + if err != nil { + return nil, err + } + + var sets []*Set + + values := params.GetValues(params.Context, keys.ReadKeys) + for key, value := range values { + set, ok := value.(*Set) + if !ok { + return nil, fmt.Errorf("value at key %s is not a set", key) + } + sets = append(sets, set) + } + + union := Union(sets...) + + res := fmt.Sprintf("*%d", union.Cardinality()) + for i, e := range union.GetAll() { + res = fmt.Sprintf("%s\r\n$%d\r\n%s", res, len(e), e) + if i == len(union.GetAll())-1 { + res += "\r\n" + } + } + + return []byte(res), nil +} + +func handleSUNIONSTORE(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := sunionstoreKeyFunc(params.Command) + if err != nil { + return nil, err + } + + destination := keys.WriteKeys[0] + + var sets []*Set + + values := params.GetValues(params.Context, keys.ReadKeys) + for key, value := range values { + set, ok := value.(*Set) + if !ok { + return nil, fmt.Errorf("value at key %s is not a set", key) + } + sets = append(sets, set) + } + + union := Union(sets...) + + if err = params.SetValues(params.Context, map[string]interface{}{destination: union}); err != nil { + return nil, err + } + return []byte(fmt.Sprintf(":%d\r\n", union.Cardinality())), nil +} + +func Commands() []internal.Command { + return []internal.Command{ + { + Command: "sadd", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(SADD key member [member...]) +Add one or more members to the set. If the set does not exist, it's created.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: saddKeyFunc, + HandlerFunc: handleSADD, + }, + { + Command: "scard", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.WriteCategory, constants.FastCategory}, + Description: "(SCARD key) Returns the cardinality of the set.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: scardKeyFunc, + HandlerFunc: handleSCARD, + }, + { + Command: "sdiff", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(SDIFF key [key...]) Returns the difference between all the sets in the given keys. +If the first key provided is the only valid set, then this key's set will be returned as the result. +All keys that are non-existed or hold values that are not sets will be skipped.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: sdiffKeyFunc, + HandlerFunc: handleSDIFF, + }, + { + Command: "sdiffstore", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(SDIFFSTORE destination key [key...]) Works the same as SDIFF but also stores the result at 'destination'. +Returns the cardinality of the new set.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: sdiffstoreKeyFunc, + HandlerFunc: handleSDIFFSTORE, + }, + { + Command: "sinter", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: "(SINTER key [key...]) Returns the intersection of multiple sets.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: sinterKeyFunc, + HandlerFunc: handleSINTER, + }, + { + Command: "sintercard", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(SINTERCARD key [key...] [LIMIT limit]) +Returns the cardinality of the intersection between multiple sets.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: sintercardKeyFunc, + HandlerFunc: handleSINTERCARD, + }, + { + Command: "sinterstore", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: "(SINTERSTORE destination key [key...]) Stores the intersection of multiple sets at the destination key.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: sinterstoreKeyFunc, + HandlerFunc: handleSINTERSTORE, + }, + { + Command: "sismember", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.ReadCategory, constants.FastCategory}, + Description: "(SISMEMBER key member) Returns if member is contained in the set.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: sismemberKeyFunc, + HandlerFunc: handleSISMEMBER, + }, + { + Command: "smembers", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: "(SMEMBERS key) Returns all members of a set.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: smembersKeyFunc, + HandlerFunc: handleSMEMBERS, + }, + { + Command: "smismember", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.ReadCategory, constants.FastCategory}, + Description: "(SMISMEMBER key member [member...]) Returns if multiple members are in the set.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: smismemberKeyFunc, + HandlerFunc: handleSMISMEMBER, + }, + + { + Command: "smove", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.WriteCategory, constants.FastCategory}, + Description: "(SMOVE source destination member) Moves a member from source set to destination set.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: smoveKeyFunc, + HandlerFunc: handleSMOVE, + }, + { + Command: "spop", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: "(SPOP key [count]) Returns and removes one or more random members from the set.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: spopKeyFunc, + HandlerFunc: handleSPOP, + }, + { + Command: "srandmember", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: "(SRANDMEMBER key [count]) Returns one or more random members from the set without removing them.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: srandmemberKeyFunc, + HandlerFunc: handleSRANDMEMBER, + }, + { + Command: "srem", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.WriteCategory, constants.FastCategory}, + Description: "(SREM key member [member...]) Remove one or more members from a set.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: sremKeyFunc, + HandlerFunc: handleSREM, + }, + { + Command: "sunion", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: "(SUNION key [key...]) Returns the members of the set resulting from the union of the provided sets.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: sunionKeyFunc, + HandlerFunc: handleSUNION, + }, + { + Command: "sunionstore", + Module: constants.SetModule, + Categories: []string{constants.SetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: "(SUNIONSTORE destination key [key...]) Stores the union of the given sets into destination.", + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: sunionstoreKeyFunc, + HandlerFunc: handleSUNIONSTORE, + }, + } +} diff --git a/internal/modules/set/commands_test.go b/internal/modules/set/commands_test.go new file mode 100644 index 0000000..fefb448 --- /dev/null +++ b/internal/modules/set/commands_test.go @@ -0,0 +1,2475 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package set_test + +import ( + "errors" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" + "apigo.cc/go/sugardb/internal/modules/set" + "apigo.cc/go/sugardb/sugardb" + "github.com/tidwall/resp" + "slices" + "strconv" + "strings" + "testing" +) + +func Test_Set(t *testing.T) { + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := sugardb.NewSugarDB( + sugardb.WithConfig(config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.NoEviction, + }), + ) + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + t.Run("Test_HandleSADD", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + command []string + expectedValue *set.Set + expectedResponse int + expectedError error + }{ + { + name: "1. Create new set on a non-existent key, return count of added elements", + preset: false, + presetValue: nil, + key: "SaddKey1", + command: []string{"SADD", "SaddKey1", "one", "two", "three", "four"}, + expectedValue: set.NewSet([]string{"one", "two", "three", "four"}), + expectedResponse: 4, + expectedError: nil, + }, + { + name: "2. Add members to an exiting set, skip members that already exist in the set, return added count.", + preset: true, + presetValue: set.NewSet([]string{"one", "two", "three", "four"}), + key: "SaddKey2", + command: []string{"SADD", "SaddKey2", "three", "four", "five", "six", "seven"}, + expectedValue: set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven"}), + expectedResponse: 3, + expectedError: nil, + }, + { + name: "3. Throw error when trying to add to a key that does not hold a set", + preset: true, + presetValue: "Default value", + key: "SaddKey3", + command: []string{"SADD", "SaddKey3", "member"}, + expectedResponse: 0, + expectedError: errors.New("value at key SaddKey3 is not a set"), + }, + { + name: "4. Command too short", + preset: false, + key: "SaddKey4", + command: []string{"SADD", "SaddKey4"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(test.key)} + for _, element := range test.presetValue.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(test.presetValue.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + + // Check if the resulting set(s) contain the expected members. + if test.expectedValue == nil { + return + } + + if err := client.WriteArray([]resp.Value{resp.StringValue("SMEMBERS"), resp.StringValue(test.key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != test.expectedValue.Cardinality() { + t.Errorf("expected set at key \"%s\" to have cardinality %d, got %d", + test.key, test.expectedValue.Cardinality(), len(res.Array())) + } + + for _, item := range res.Array() { + if !test.expectedValue.Contains(item.String()) { + t.Errorf("unexpected memeber \"%s\", in response", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleSCARD", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValue interface{} + key string + command []string + expectedValue *set.Set + expectedResponse int + expectedError error + }{ + { + name: "1. Get cardinality of valid set.", + presetValue: set.NewSet([]string{"one", "two", "three", "four"}), + key: "ScardKey1", + command: []string{"SCARD", "ScardKey1"}, + expectedValue: nil, + expectedResponse: 4, + expectedError: nil, + }, + { + name: "2. Return 0 when trying to get cardinality on non-existent key", + presetValue: nil, + key: "ScardKey2", + command: []string{"SCARD", "ScardKey2"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "3. Throw error when trying to get cardinality of a value that is not a set", + presetValue: "Default value", + key: "ScardKey3", + command: []string{"SCARD", "ScardKey3"}, + expectedResponse: 0, + expectedError: errors.New("value at key ScardKey3 is not a set"), + }, + { + name: "4. Command too short", + key: "ScardKey4", + command: []string{"SCARD"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Command too long", + key: "ScardKey5", + command: []string{"SCARD", "ScardKey5", "ScardKey5"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(test.key)} + for _, element := range test.presetValue.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(test.presetValue.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleSDIFF", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedResponse []string + expectedError error + }{ + { + name: "1. Get the difference between 2 sets.", + presetValues: map[string]interface{}{ + "SdiffKey1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "SdiffKey2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + command: []string{"SDIFF", "SdiffKey1", "SdiffKey2"}, + expectedResponse: []string{"one", "two"}, + expectedError: nil, + }, + { + name: "2. Get the difference between 3 sets.", + presetValues: map[string]interface{}{ + "SdiffKey3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SdiffKey4": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "SdiffKey5": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + command: []string{"SDIFF", "SdiffKey3", "SdiffKey4", "SdiffKey5"}, + expectedResponse: []string{"three", "four", "five", "six"}, + expectedError: nil, + }, + { + name: "3. Return base set element if base set is the only valid set", + presetValues: map[string]interface{}{ + "SdiffKey6": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SdiffKey7": "Default value", + "SdiffKey8": "123456789", + }, + command: []string{"SDIFF", "SdiffKey6", "SdiffKey7", "SdiffKey8"}, + expectedResponse: []string{"one", "two", "three", "four", "five", "six", "seven", "eight"}, + expectedError: nil, + }, + { + name: "4. Throw error when base set is not a set.", + presetValues: map[string]interface{}{ + "SdiffKey9": "Default value", + "SdiffKey10": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "SdiffKey11": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + command: []string{"SDIFF", "SdiffKey9", "SdiffKey10", "SdiffKey11"}, + expectedResponse: nil, + expectedError: errors.New("value at key SdiffKey9 is not a set"), + }, + { + name: "5. Throw error when base set is non-existent.", + presetValues: map[string]interface{}{ + "SdiffKey12": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "SdiffKey13": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + command: []string{"SDIFF", "non-existent", "SdiffKey7", "SdiffKey8"}, + expectedResponse: nil, + expectedError: errors.New("key for base set \"non-existent\" does not exist"), + }, + { + name: "6. Command too short", + command: []string{"SDIFF"}, + expectedResponse: []string{}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(key)} + for _, element := range value.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(value.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response array of length \"%d\", got \"%d\"", + len(test.expectedResponse), len(res.Array())) + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.String()) { + t.Errorf("unexpected element \"%s\" in response", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleSDIFFSTORE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + destination string + command []string + expectedValue *set.Set + expectedResponse int + expectedError error + }{ + { + name: "1. Get the difference between 2 sets.", + presetValues: map[string]interface{}{ + "SdiffStoreKey1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "SdiffStoreKey2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + destination: "SdiffStoreDestination1", + command: []string{"SDIFFSTORE", "SdiffStoreDestination1", "SdiffStoreKey1", "SdiffStoreKey2"}, + expectedValue: set.NewSet([]string{"one", "two"}), + expectedResponse: 2, + expectedError: nil, + }, + { + name: "2. Get the difference between 3 sets.", + presetValues: map[string]interface{}{ + "SdiffStoreKey3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SdiffStoreKey4": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "SdiffStoreKey5": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + destination: "SdiffStoreDestination2", + command: []string{"SDIFFSTORE", "SdiffStoreDestination2", "SdiffStoreKey3", "SdiffStoreKey4", "SdiffStoreKey5"}, + expectedValue: set.NewSet([]string{"three", "four", "five", "six"}), + expectedResponse: 4, + expectedError: nil, + }, + { + name: "3. Return base set element if base set is the only valid set", + presetValues: map[string]interface{}{ + "SdiffStoreKey6": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SdiffStoreKey7": "Default value", + "SdiffStoreKey8": "123456789", + }, + destination: "SdiffStoreDestination3", + command: []string{"SDIFFSTORE", "SdiffStoreDestination3", "SdiffStoreKey6", "SdiffStoreKey7", "SdiffStoreKey8"}, + expectedValue: set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + expectedResponse: 8, + expectedError: nil, + }, + { + name: "4. Throw error when base set is not a set.", + presetValues: map[string]interface{}{ + "SdiffStoreKey9": "Default value", + "SdiffStoreKey10": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "SdiffStoreKey11": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + destination: "SdiffStoreDestination4", + command: []string{"SDIFFSTORE", "SdiffStoreDestination4", "SdiffStoreKey9", "SdiffStoreKey10", "SdiffStoreKey11"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: errors.New("value at key SdiffStoreKey9 is not a set"), + }, + { + name: "5. Throw error when base set is non-existent.", + destination: "SdiffStoreDestination5", + presetValues: map[string]interface{}{ + "SdiffStoreKey12": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "SdiffStoreKey13": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + command: []string{"SDIFFSTORE", "SdiffStoreDestination5", "non-existent", "SdiffStoreKey7", "SdiffStoreKey8"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: errors.New("key for base set \"non-existent\" does not exist"), + }, + { + name: "6. Command too short", + command: []string{"SDIFFSTORE", "SdiffStoreDestination6"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(key)} + for _, element := range value.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(value.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + + // Check if the resulting set(s) contain the expected members. + if test.expectedValue == nil { + return + } + + if err := client.WriteArray([]resp.Value{ + resp.StringValue("SMEMBERS"), + resp.StringValue(test.destination), + }); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != test.expectedValue.Cardinality() { + t.Errorf("expected set at key \"%s\" to have cardinality %d, got %d", + test.destination, test.expectedValue.Cardinality(), len(res.Array())) + } + + for _, item := range res.Array() { + if !test.expectedValue.Contains(item.String()) { + t.Errorf("unexpected memeber \"%s\", in response", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleSINTER", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedResponse []string + expectedError error + }{ + { + name: "1. Get the intersection between 2 sets.", + presetValues: map[string]interface{}{ + "SinterKey1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "SinterKey2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + command: []string{"SINTER", "SinterKey1", "SinterKey2"}, + expectedResponse: []string{"three", "four", "five"}, + expectedError: nil, + }, + { + name: "2. Get the intersection between 3 sets.", + presetValues: map[string]interface{}{ + "SinterKey3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SinterKey4": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven", "eight"}), + "SinterKey5": set.NewSet([]string{"one", "eight", "nine", "ten", "twelve"}), + }, + command: []string{"SINTER", "SinterKey3", "SinterKey4", "SinterKey5"}, + expectedResponse: []string{"one", "eight"}, + expectedError: nil, + }, + { + name: "3. Throw an error if any of the provided keys are not sets", + presetValues: map[string]interface{}{ + "SinterKey6": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SinterKey7": "Default value", + "SinterKey8": set.NewSet([]string{"one"}), + }, + command: []string{"SINTER", "SinterKey6", "SinterKey7", "SinterKey8"}, + expectedResponse: nil, + expectedError: errors.New("value at key SinterKey7 is not a set"), + }, + { + name: "4. Throw error when base set is not a set.", + presetValues: map[string]interface{}{ + "SinterKey9": "Default value", + "SinterKey10": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "SinterKey11": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + command: []string{"SINTER", "SinterKey9", "SinterKey10", "SinterKey11"}, + expectedResponse: nil, + expectedError: errors.New("value at key SinterKey9 is not a set"), + }, + { + name: "5. If any of the keys does not exist, return an empty array.", + presetValues: map[string]interface{}{ + "SinterKey12": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "SinterKey13": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + command: []string{"SINTER", "non-existent", "SinterKey7", "SinterKey8"}, + expectedResponse: []string{}, + expectedError: nil, + }, + { + name: "6. Command too short", + command: []string{"SINTER"}, + expectedResponse: []string{}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(key)} + for _, element := range value.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(value.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response array of length \"%d\", got \"%d\"", + len(test.expectedResponse), len(res.Array())) + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.String()) { + t.Errorf("unexpected element \"%s\" in response", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleSINTERCARD", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedResponse int + expectedError error + }{ + { + name: "1. Get the full intersect cardinality between 2 sets.", + presetValues: map[string]interface{}{ + "SinterCardKey1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "SinterCardKey2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + command: []string{"SINTERCARD", "SinterCardKey1", "SinterCardKey2"}, + expectedResponse: 3, + expectedError: nil, + }, + { + name: "2. Get an intersect cardinality between 2 sets with a limit", + presetValues: map[string]interface{}{ + "SinterCardKey3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"}), + "SinterCardKey4": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve"}), + }, + command: []string{"SINTERCARD", "SinterCardKey3", "SinterCardKey4", "LIMIT", "3"}, + expectedResponse: 3, + expectedError: nil, + }, + { + name: "3. Get the full intersect cardinality between 3 sets.", + presetValues: map[string]interface{}{ + "SinterCardKey5": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SinterCardKey6": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven", "eight"}), + "SinterCardKey7": set.NewSet([]string{"one", "seven", "eight", "nine", "ten", "twelve"}), + }, + command: []string{"SINTERCARD", "SinterCardKey5", "SinterCardKey6", "SinterCardKey7"}, + expectedResponse: 2, + expectedError: nil, + }, + { + name: "4. Get the intersection of 3 sets with a limit", + presetValues: map[string]interface{}{ + "SinterCardKey8": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SinterCardKey9": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven", "eight"}), + "SinterCardKey10": set.NewSet([]string{"one", "two", "seven", "eight", "nine", "ten", "twelve"}), + }, + command: []string{"SINTERCARD", "SinterCardKey8", "SinterCardKey9", "SinterCardKey10", "LIMIT", "2"}, + expectedResponse: 2, + expectedError: nil, + }, + { + name: "5. Return 0 if any of the keys does not exist", + presetValues: map[string]interface{}{ + "SinterCardKey11": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SinterCardKey12": "Default value", + "SinterCardKey13": set.NewSet([]string{"one"}), + }, + command: []string{"SINTERCARD", "SinterCardKey11", "SinterCardKey12", "SinterCardKey13", "non-existent"}, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "6. Throw error when one of the keys is not a valid set.", + presetValues: map[string]interface{}{ + "SinterCardKey14": "Default value", + "SinterCardKey15": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "SinterCardKey16": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + command: []string{"SINTERCARD", "SinterCardKey14", "SinterCardKey15", "SinterCardKey16"}, + expectedResponse: 0, + expectedError: errors.New("value at key SinterCardKey14 is not a set"), + }, + { + name: "7. Command too short", + command: []string{"SINTERCARD"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(key)} + for _, element := range value.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(value.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response array of length \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + }) + } + + }) + + t.Run("Test_HandleSINTERSTORE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + destination string + command []string + expectedValue *set.Set + expectedResponse int + expectedError error + }{ + { + name: "1. Get the intersection between 2 sets and store it at the destination.", + presetValues: map[string]interface{}{ + "SinterStoreKey1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "SinterStoreKey2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + destination: "SinterStoreDestination1", + command: []string{"SINTERSTORE", "SinterStoreDestination1", "SinterStoreKey1", "SinterStoreKey2"}, + expectedValue: set.NewSet([]string{"three", "four", "five"}), + expectedResponse: 3, + expectedError: nil, + }, + { + name: "2. Get the intersection between 3 sets and store it at the destination key.", + presetValues: map[string]interface{}{ + "SinterStoreKey3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SinterStoreKey4": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven", "eight"}), + "SinterStoreKey5": set.NewSet([]string{"one", "seven", "eight", "nine", "ten", "twelve"}), + }, + destination: "SinterStoreDestination2", + command: []string{"SINTERSTORE", "SinterStoreDestination2", "SinterStoreKey3", "SinterStoreKey4", "SinterStoreKey5"}, + expectedValue: set.NewSet([]string{"one", "eight"}), + expectedResponse: 2, + expectedError: nil, + }, + { + name: "3. Throw error when any of the keys is not a set", + presetValues: map[string]interface{}{ + "SinterStoreKey6": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SinterStoreKey7": "Default value", + "SinterStoreKey8": set.NewSet([]string{"one"}), + }, + destination: "SinterStoreDestination3", + command: []string{"SINTERSTORE", "SinterStoreDestination3", "SinterStoreKey6", "SinterStoreKey7", "SinterStoreKey8"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: errors.New("value at key SinterStoreKey7 is not a set"), + }, + { + name: "4. Throw error when base set is not a set.", + presetValues: map[string]interface{}{ + "SinterStoreKey9": "Default value", + "SinterStoreKey10": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "SinterStoreKey11": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + destination: "SinterStoreDestination4", + command: []string{"SINTERSTORE", "SinterStoreDestination4", "SinterStoreKey9", "SinterStoreKey10", "SinterStoreKey11"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: errors.New("value at key SinterStoreKey9 is not a set"), + }, + { + name: "5. Return an empty intersection if one of the keys does not exist.", + destination: "SinterStoreDestination5", + presetValues: map[string]interface{}{ + "SinterStoreKey12": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "SinterStoreKey13": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + command: []string{"SINTERSTORE", "SinterStoreDestination5", "non-existent", "SinterStoreKey7", "SinterStoreKey8"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "6. Command too short", + command: []string{"SINTERSTORE", "SinterStoreDestination6"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(key)} + for _, element := range value.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(value.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + + // Check if the resulting set(s) contain the expected members. + if test.expectedValue == nil { + return + } + + if err := client.WriteArray([]resp.Value{ + resp.StringValue("SMEMBERS"), + resp.StringValue(test.destination), + }); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != test.expectedValue.Cardinality() { + t.Errorf("expected set at key \"%s\" to have cardinality %d, got %d", + test.destination, test.expectedValue.Cardinality(), len(res.Array())) + } + + for _, item := range res.Array() { + if !test.expectedValue.Contains(item.String()) { + t.Errorf("unexpected memeber \"%s\", in response", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleSISMEMBER", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValue interface{} + key string + command []string + expectedResponse int + expectedError error + }{ + { + name: "1. Return 1 when element is a member of the set", + presetValue: set.NewSet([]string{"one", "two", "three", "four"}), + key: "SIsMemberKey1", + command: []string{"SISMEMBER", "SIsMemberKey1", "three"}, + expectedResponse: 1, + expectedError: nil, + }, + { + name: "2. Return 0 when element is not a member of the set", + presetValue: set.NewSet([]string{"one", "two", "three", "four"}), + key: "SIsMemberKey2", + command: []string{"SISMEMBER", "SIsMemberKey2", "five"}, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "3. Throw error when trying to assert membership when the key does not hold a valid set", + presetValue: "Default value", + key: "SIsMemberKey3", + command: []string{"SISMEMBER", "SIsMemberKey3", "one"}, + expectedResponse: 0, + expectedError: errors.New("value at key SIsMemberKey3 is not a set"), + }, + { + name: "4. Command too short", + key: "SIsMemberKey4", + command: []string{"SISMEMBER", "SIsMemberKey4"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Command too long", + key: "SIsMemberKey5", + command: []string{"SISMEMBER", "SIsMemberKey5", "one", "two", "three"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(test.key)} + for _, element := range test.presetValue.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(test.presetValue.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleSMEMBERS", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse []string + expectedError error + }{ + { + name: "1. Return all the members of the set.", + key: "SmembersKey1", + presetValue: set.NewSet([]string{"one", "two", "three", "four", "five"}), + command: []string{"SMEMBERS", "SmembersKey1"}, + expectedResponse: []string{"one", "two", "three", "four", "five"}, + expectedError: nil, + }, + { + name: "2. If the key does not exist, return an empty array.", + key: "SmembersKey2", + presetValue: nil, + command: []string{"SMEMBERS", "SmembersKey2"}, + expectedResponse: nil, + expectedError: nil, + }, + { + name: "3. Throw error when the provided key is not a set.", + key: "SmembersKey3", + presetValue: "Default value", + command: []string{"SMEMBERS", "SmembersKey3"}, + expectedResponse: nil, + expectedError: errors.New("value at key SmembersKey3 is not a set"), + }, + { + name: "4. Command too short", + command: []string{"SMEMBERS"}, + expectedResponse: []string{}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Command too long", + command: []string{"SMEMBERS", "SmembersKey5", "SmembersKey6"}, + expectedResponse: []string{}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(test.key)} + for _, element := range test.presetValue.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(test.presetValue.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response array of length \"%d\", got \"%d\"", + len(test.expectedResponse), len(res.Array())) + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.String()) { + t.Errorf("unexpected element \"%s\" in response", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleSMISMEMBER", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValue interface{} + key string + command []string + expectedResponse []int + expectedError error + }{ + { + // 1. Return set membership status for multiple elements + // Return 1 for present and 0 for absent + // The placement of the membership status flag should me consistent with the order the elements + // are in within the original command + name: "1. Return set membership status for multiple elements", + presetValue: set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven"}), + key: "SmismemberKey1", + command: []string{"SMISMEMBER", "SmismemberKey1", "three", "four", "five", "six", "eight", "nine", "seven"}, + expectedResponse: []int{1, 1, 1, 1, 0, 0, 1}, + expectedError: nil, + }, + { + name: "2. If the set key does not exist, return an array of zeroes as long as the list of members", + presetValue: nil, + key: "SmismemberKey2", + command: []string{"SMISMEMBER", "SmismemberKey2", "one", "two", "three", "four"}, + expectedResponse: []int{0, 0, 0, 0}, + expectedError: nil, + }, + { + name: "3. Throw error when trying to assert membership when the key does not hold a valid set", + presetValue: "Default value", + key: "SmismemberKey3", + command: []string{"SMISMEMBER", "SmismemberKey3", "one"}, + expectedResponse: nil, + expectedError: errors.New("value at key SmismemberKey3 is not a set"), + }, + { + name: "4. Command too short", + presetValue: nil, + key: "SmismemberKey4", + command: []string{"SMISMEMBER", "SmismemberKey4"}, + expectedResponse: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(test.key)} + for _, element := range test.presetValue.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(test.presetValue.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response array of length \"%d\", got \"%d\"", + len(test.expectedResponse), len(res.Array())) + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.Integer()) { + t.Errorf("unexpected element \"%d\" in response", item.Integer()) + } + } + }) + } + }) + + t.Run("Test_HandleSMOVE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedValues map[string]interface{} + expectedResponse int + expectedError error + }{ + { + name: "1. Return 1 after a successful move of a member from source set to destination set", + presetValues: map[string]interface{}{ + "SmoveSource1": set.NewSet([]string{"one", "two", "three", "four"}), + "SmoveDestination1": set.NewSet([]string{"five", "six", "seven", "eight"}), + }, + command: []string{"SMOVE", "SmoveSource1", "SmoveDestination1", "four"}, + expectedValues: map[string]interface{}{ + "SmoveSource1": set.NewSet([]string{"one", "two", "three"}), + "SmoveDestination1": set.NewSet([]string{"four", "five", "six", "seven", "eight"}), + }, + expectedResponse: 1, + expectedError: nil, + }, + { + name: "2. Return 0 when trying to move a member from source set to destination set when it doesn't exist in source", + presetValues: map[string]interface{}{ + "SmoveSource2": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "SmoveDestination2": set.NewSet([]string{"five", "six", "seven", "eight"}), + }, + command: []string{"SMOVE", "SmoveSource2", "SmoveDestination2", "six"}, + expectedValues: map[string]interface{}{ + "SmoveSource2": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "SmoveDestination2": set.NewSet([]string{"five", "six", "seven", "eight"}), + }, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "3. Return error when the source key is not a set", + presetValues: map[string]interface{}{ + "SmoveSource3": "Default value", + "SmoveDestination3": set.NewSet([]string{"five", "six", "seven", "eight"}), + }, + command: []string{"SMOVE", "SmoveSource3", "SmoveDestination3", "five"}, + expectedValues: map[string]interface{}{ + "SmoveSource3": "Default value", + "SmoveDestination3": set.NewSet([]string{"five", "six", "seven", "eight"}), + }, + expectedResponse: 0, + expectedError: errors.New("source is not a set"), + }, + { + name: "4. Return error when the destination key is not a set", + presetValues: map[string]interface{}{ + "SmoveSource4": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "SmoveDestination4": "Default value", + }, + command: []string{"SMOVE", "SmoveSource4", "SmoveDestination4", "five"}, + expectedValues: map[string]interface{}{ + "SmoveSource4": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "SmoveDestination4": "Default value", + }, + expectedResponse: 0, + expectedError: errors.New("destination is not a set"), + }, + { + name: "5. Command too short", + presetValues: nil, + command: []string{"SMOVE", "SmoveSource5", "SmoveSource6"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Command too long", + presetValues: nil, + command: []string{"SMOVE", "SmoveSource5", "SmoveSource6", "member1", "member2"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(key)} + for _, element := range value.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(value.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + + // Check if the resulting set(s) contain the expected members. + if test.expectedValues == nil { + return + } + + for key, value := range test.expectedValues { + switch value.(type) { + case string: + if err := client.WriteArray([]resp.Value{resp.StringValue("GET"), resp.StringValue(key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + if res.String() != value.(string) { + t.Errorf("expected value at key \"%s\" to be \"%s\", got \"%s\"", key, value.(string), res.String()) + } + case *set.Set: + if err := client.WriteArray([]resp.Value{ + resp.StringValue("SMEMBERS"), + resp.StringValue(key), + }); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != value.(*set.Set).Cardinality() { + t.Errorf("expected set at key \"%s\" to have cardinality %d, got %d", + key, value.(*set.Set).Cardinality(), len(res.Array())) + } + + for _, item := range res.Array() { + if !value.(*set.Set).Contains(item.String()) { + t.Errorf("unexpected memeber \"%s\", in response", item.String()) + } + } + } + } + }) + } + }) + + t.Run("Test_HandleSPOP", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedValue int // The final cardinality of the resulting set + expectedResponse []string + expectedError error + }{ + { + name: "1. Return multiple popped elements and modify the set", + key: "SpopKey1", + presetValue: set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + command: []string{"SPOP", "SpopKey1", "3"}, + expectedValue: 5, + expectedResponse: []string{"one", "two", "three", "four", "five", "six", "seven", "eight"}, + expectedError: nil, + }, + { + name: "2. Return error when the source key is not a set", + key: "SpopKey2", + presetValue: "Default value", + command: []string{"SPOP", "SpopKey2"}, + expectedValue: 0, + expectedResponse: nil, + expectedError: errors.New("value at SpopKey2 is not a set"), + }, + { + name: "3. Command too short", + presetValue: nil, + command: []string{"SPOP"}, + expectedValue: 0, + expectedResponse: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "4. Command too long", + presetValue: nil, + command: []string{"SPOP", "SpopSource5", "SpopSource6", "member1", "member2"}, + expectedValue: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Throw error when count is not an integer", + presetValue: nil, + command: []string{"SPOP", "SpopKey1", "count"}, + expectedValue: 0, + expectedError: errors.New("count must be an integer"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(test.key)} + for _, element := range test.presetValue.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(test.presetValue.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + // Check that each returned element is in the list of expected elements. + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.String()) { + t.Errorf("unexpected element \"%s\" in response", item.String()) + } + } + + // Check if the resulting set's cardinality is as expected. + if err := client.WriteArray([]resp.Value{resp.StringValue("SCARD"), resp.StringValue(test.key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if res.Integer() != test.expectedValue { + t.Errorf("expected set at key \"%s\" to have cardinality %d, got %d", + test.key, test.expectedValue, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleSRANDMEMBER", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedValue int // The final cardinality of the resulting set + allowRepeat bool + expectedResponse []string + expectedError error + }{ + { + // 1. Return multiple random elements without removing them + // Count is positive, do not allow repeated elements + name: "1. Return multiple random elements without removing them", + key: "SRandMemberKey1", + presetValue: set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + command: []string{"SRANDMEMBER", "SRandMemberKey1", "3"}, + expectedValue: 8, + allowRepeat: false, + expectedResponse: []string{"one", "two", "three", "four", "five", "six", "seven", "eight"}, + expectedError: nil, + }, + { + // 2. Return multiple random elements without removing them + // Count is negative, so allow repeated numbers + name: "2. Return multiple random elements without removing them", + key: "SRandMemberKey2", + presetValue: set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + command: []string{"SRANDMEMBER", "SRandMemberKey2", "-5"}, + expectedValue: 8, + allowRepeat: true, + expectedResponse: []string{"one", "two", "three", "four", "five", "six", "seven", "eight"}, + expectedError: nil, + }, + { + name: "3. Return error when the source key is not a set", + key: "SRandMemberKey3", + presetValue: "Default value", + command: []string{"SRANDMEMBER", "SRandMemberKey3"}, + expectedValue: 0, + expectedResponse: []string{}, + expectedError: errors.New("value at SRandMemberKey3 is not a set"), + }, + { + name: "4. Command too short", + command: []string{"SRANDMEMBER"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Command too long", + command: []string{"SRANDMEMBER", "SRandMemberSource5", "SRandMemberSource6", "member1", "member2"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Throw error when count is not an integer", + command: []string{"SRANDMEMBER", "SRandMemberKey1", "count"}, + expectedError: errors.New("count must be an integer"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(test.key)} + for _, element := range test.presetValue.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(test.presetValue.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + // Check that each returned element is in the list of expected elements. + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.String()) { + t.Errorf("unexpected element \"%s\" in response", item.String()) + } + } + + // If no repeats are allowed, check if the response contains any repeated elements + if !test.allowRepeat { + s := set.NewSet(func() []string { + elements := make([]string, len(res.Array())) + for i, item := range res.Array() { + elements[i] = item.String() + } + return elements + }()) + if s.Cardinality() != len(res.Array()) { + t.Error("response has repeated elements, expected only unique elements.") + } + } + + // Check if the resulting set's cardinality is as expected. + if err := client.WriteArray([]resp.Value{resp.StringValue("SCARD"), resp.StringValue(test.key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if res.Integer() != test.expectedValue { + t.Errorf("expected set at key \"%s\" to have cardinality %d, got %d", + test.key, test.expectedValue, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleSREM", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedValue *set.Set // The final cardinality of the resulting set + expectedResponse int + expectedError error + }{ + { + name: "1. Remove multiple elements and return the number of elements removed", + key: "SremKey1", + presetValue: set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + command: []string{"SREM", "SremKey1", "one", "two", "three", "nine"}, + expectedValue: set.NewSet([]string{"four", "five", "six", "seven", "eight"}), + expectedResponse: 3, + expectedError: nil, + }, + { + name: "2. If key does not exist, return 0", + key: "SremKey2", + presetValue: nil, + command: []string{"SREM", "SremKey1", "one", "two", "three", "nine"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "3. Return error when the source key is not a set", + key: "SremKey3", + presetValue: "Default value", + command: []string{"SREM", "SremKey3", "one"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: errors.New("value at key SremKey3 is not a set"), + }, + { + name: "4. Command too short", + command: []string{"SREM", "SremKey"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(test.key)} + for _, element := range test.presetValue.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(test.presetValue.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + + // Check if the resulting set(s) contain the expected members. + if test.expectedValue == nil { + return + } + + if err := client.WriteArray([]resp.Value{resp.StringValue("SMEMBERS"), resp.StringValue(test.key)}); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != test.expectedValue.Cardinality() { + t.Errorf("expected set at key \"%s\" to have cardinality %d, got %d", + test.key, test.expectedValue.Cardinality(), len(res.Array())) + } + + for _, item := range res.Array() { + if !test.expectedValue.Contains(item.String()) { + t.Errorf("unexpected memeber \"%s\", in response", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleSUNION", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedResponse []string + expectedError error + }{ + { + name: "1. Get the union between 2 sets.", + presetValues: map[string]interface{}{ + "SunionKey1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "SunionKey2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + command: []string{"SUNION", "SunionKey1", "SunionKey2"}, + expectedResponse: []string{"one", "two", "three", "four", "five", "six", "seven", "eight"}, + expectedError: nil, + }, + { + name: "2. Get the union between 3 sets.", + presetValues: map[string]interface{}{ + "SunionKey3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SunionKey4": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven", "eight"}), + "SunionKey5": set.NewSet([]string{"one", "eight", "nine", "ten", "twelve"}), + }, + command: []string{"SUNION", "SunionKey3", "SunionKey4", "SunionKey5"}, + expectedResponse: []string{ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", + "ten", "eleven", "twelve", "thirty-six", + }, + expectedError: nil, + }, + { + name: "3. Throw an error if any of the provided keys are not sets", + presetValues: map[string]interface{}{ + "SunionKey6": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SunionKey7": "Default value", + "SunionKey8": set.NewSet([]string{"one"}), + }, + command: []string{"SUNION", "SunionKey6", "SunionKey7", "SunionKey8"}, + expectedResponse: nil, + expectedError: errors.New("value at key SunionKey7 is not a set"), + }, + { + name: "4. Throw error any of the keys does not hold a set.", + presetValues: map[string]interface{}{ + "SunionKey9": "Default value", + "SunionKey10": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "SunionKey11": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + command: []string{"SUNION", "SunionKey9", "SunionKey10", "SunionKey11"}, + expectedResponse: nil, + expectedError: errors.New("value at key SunionKey9 is not a set"), + }, + { + name: "6. Command too short", + command: []string{"SUNION"}, + expectedResponse: []string{}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(key)} + for _, element := range value.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(value.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response array of length \"%d\", got \"%d\"", + len(test.expectedResponse), len(res.Array())) + } + + for _, item := range res.Array() { + if !slices.Contains(test.expectedResponse, item.String()) { + t.Errorf("unexpected element \"%s\" in response", item.String()) + } + } + }) + } + }) + + t.Run("Test_HandleSUNIONSTORE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + destination string + command []string + expectedValue *set.Set + expectedResponse int + expectedError error + }{ + { + name: "1. Get the intersection between 2 sets and store it at the destination.", + presetValues: map[string]interface{}{ + "SunionStoreKey1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "SunionStoreKey2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + destination: "SunionStoreDestination1", + command: []string{"SUNIONSTORE", "SunionStoreDestination1", "SunionStoreKey1", "SunionStoreKey2"}, + expectedValue: set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + expectedResponse: 8, + expectedError: nil, + }, + { + name: "2. Get the intersection between 3 sets and store it at the destination key.", + presetValues: map[string]interface{}{ + "SunionStoreKey3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SunionStoreKey4": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven", "eight"}), + "SunionStoreKey5": set.NewSet([]string{"one", "seven", "eight", "nine", "ten", "twelve"}), + }, + destination: "SunionStoreDestination2", + command: []string{"SUNIONSTORE", "SunionStoreDestination2", "SunionStoreKey3", "SunionStoreKey4", "SunionStoreKey5"}, + expectedValue: set.NewSet([]string{ + "one", "two", "three", "four", "five", "six", "seven", "eight", + "nine", "ten", "eleven", "twelve", "thirty-six", + }), + expectedResponse: 13, + expectedError: nil, + }, + { + name: "3. Throw error when any of the keys is not a set", + presetValues: map[string]interface{}{ + "SunionStoreKey6": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "SunionStoreKey7": "Default value", + "SunionStoreKey8": set.NewSet([]string{"one"}), + }, + destination: "SunionStoreDestination3", + command: []string{"SUNIONSTORE", "SunionStoreDestination3", "SunionStoreKey6", "SunionStoreKey7", "SunionStoreKey8"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: errors.New("value at key SunionStoreKey7 is not a set"), + }, + { + name: "5. Command too short", + command: []string{"SUNIONSTORE", "SunionStoreDestination6"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *set.Set: + command = []resp.Value{resp.StringValue("SADD"), resp.StringValue(key)} + for _, element := range value.(*set.Set).GetAll() { + command = append(command, []resp.Value{resp.StringValue(element)}...) + } + expected = strconv.Itoa(value.(*set.Set).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + + // Check if the resulting set(s) contain the expected members. + if test.expectedValue == nil { + return + } + + if err := client.WriteArray([]resp.Value{ + resp.StringValue("SMEMBERS"), + resp.StringValue(test.destination), + }); err != nil { + t.Error(err) + } + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != test.expectedValue.Cardinality() { + t.Errorf("expected set at key \"%s\" to have cardinality %d, got %d", + test.destination, test.expectedValue.Cardinality(), len(res.Array())) + } + + for _, item := range res.Array() { + if !test.expectedValue.Contains(item.String()) { + t.Errorf("unexpected memeber \"%s\", in response", item.String()) + } + } + }) + } + }) +} diff --git a/internal/modules/set/key_funcs.go b/internal/modules/set/key_funcs.go new file mode 100644 index 0000000..192ff5a --- /dev/null +++ b/internal/modules/set/key_funcs.go @@ -0,0 +1,212 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package set + +import ( + "errors" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" + "slices" + "strings" +) + +func saddKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func scardKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func sdiffKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func sdiffstoreKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[2:], + WriteKeys: cmd[1:2], + }, nil +} + +func sinterKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func sintercardKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + + limitIdx := slices.IndexFunc(cmd, func(s string) bool { + return strings.EqualFold(s, "limit") + }) + + if limitIdx == -1 { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil + } + + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:limitIdx], + WriteKeys: make([]string, 0), + }, nil +} + +func sinterstoreKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[2:], + WriteKeys: cmd[1:2], + }, nil +} + +func sismemberKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func smembersKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func smismemberKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func smoveKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:3], + }, nil +} + +func spopKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 || len(cmd) > 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func srandmemberKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 || len(cmd) > 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func sremKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func sunionKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func sunionstoreKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[2:], + WriteKeys: cmd[1:2], + }, nil +} diff --git a/internal/modules/set/set.go b/internal/modules/set/set.go new file mode 100644 index 0000000..3aacfce --- /dev/null +++ b/internal/modules/set/set.go @@ -0,0 +1,216 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package set + +import ( + "math/rand" + "slices" + "unsafe" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" +) + +type Set struct { + members map[string]interface{} + length int +} + +func (set *Set) GetMem() int64 { + var size int64 + size += int64(unsafe.Sizeof(set)) + // above only gives us the size of the pointer to the map, so we need to add its headers and contents + size += int64(unsafe.Sizeof(set.members)) + for k, v := range set.members { + size += int64(unsafe.Sizeof(k)) + size += int64(len(k)) + size += int64(unsafe.Sizeof(v)) + } + return size +} + +// compile time interface check +var _ constants.CompositeType = (*Set)(nil) + +func NewSet(elems []string) *Set { + set := &Set{ + members: make(map[string]interface{}), + length: 0, + } + set.Add(elems) + return set +} + +func (set *Set) Add(elems []string) int { + count := 0 + for _, e := range elems { + if !set.Contains(e) { + set.members[e] = struct{}{} + count += 1 + } + } + set.length += count + return count +} + +func (set *Set) get(e string) interface{} { + return set.members[e] +} + +func (set *Set) GetAll() []string { + var res []string + for e, _ := range set.members { + res = append(res, e) + } + return res +} + +func (set *Set) Cardinality() int { + return set.length +} + +func (set *Set) GetRandom(count int) []string { + keys := set.GetAll() + + if count == 0 { + return []string{} + } + + if internal.AbsInt(count) >= set.Cardinality() { + return keys + } + + res := []string{} + + var n int + + if count < 0 { + // If count is negative, allow repeat elements + for i := 0; i < internal.AbsInt(count); i++ { + n = rand.Intn(len(keys)) + res = append(res, keys[n]) + } + } else { + // Count is positive, do not allow repeat elements + for i := 0; i < internal.AbsInt(count); { + n = rand.Intn(len(keys)) + if !slices.Contains(res, keys[n]) { + res = append(res, keys[n]) + keys = slices.DeleteFunc(keys, func(elem string) bool { + return elem == keys[n] + }) + i++ + } + } + } + + return res +} + +func (set *Set) Remove(elems []string) int { + count := 0 + for _, e := range elems { + if set.get(e) != nil { + delete(set.members, e) + count += 1 + } + } + set.length -= count + return count +} + +func (set *Set) Pop(count int) []string { + keys := set.GetRandom(count) + set.Remove(keys) + return keys +} + +func (set *Set) Contains(e string) bool { + return set.get(e) != nil +} + +// Subtract received a list of sets and finds the difference between sets provided +func (set *Set) Subtract(others []*Set) *Set { + diff := NewSet(set.GetAll()) + var remove []string + for _, s := range others { + for k, _ := range s.members { + if diff.Contains(k) { + remove = append(remove, k) + } + } + } + diff.Remove(remove) + return diff +} + +func (set *Set) Move(destination *Set, e string) int { + if !set.Contains(e) { + return 0 + } + set.Remove([]string{e}) + destination.Add([]string{e}) + return 1 +} + +// The Intersection accepts limit parameter of type int and a list of sets whose intersects are to be calculated. +// When limit is greater than 0, then the calculation will stop once the intersect cardinality reaches limit without +// calculating the full intersect. +func Intersection(limit int, sets ...*Set) (*Set, bool) { + // Use divide & conquer to get the set intersections + switch len(sets) { + case 1: + return sets[0], false + case 2: + intersection := NewSet([]string{}) + var limitReached bool + for _, member := range sets[0].GetAll() { + if limit > 0 && intersection.Cardinality() >= limit { + limitReached = true + break + } + if sets[1].Contains(member) { + intersection.Add([]string{member}) + } + } + return intersection, limitReached + default: + left, stop := Intersection(limit, sets[0:len(sets)/2]...) + if stop { // Check if limit is reached by left, if it is, return left + return left, stop + } + right, stop := Intersection(limit, sets[len(sets)/2:]...) + if stop { // Check if limit is reached by right, if it is, return right + return right, stop + } + return Intersection(limit, left, right) + } +} + +// Union takes a slice of sets and generates a union +func Union(sets ...*Set) *Set { + switch len(sets) { + case 1: + return sets[0] + case 2: + union := sets[0] + union.Add(sets[1].GetAll()) + return union + default: + left := Union(sets[0 : len(sets)/2]...) + right := Union(sets[len(sets)/2:]...) + return Union(left, right) + } +} diff --git a/internal/modules/sorted_set/commands.go b/internal/modules/sorted_set/commands.go new file mode 100644 index 0000000..2d10f35 --- /dev/null +++ b/internal/modules/sorted_set/commands.go @@ -0,0 +1,1666 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sorted_set + +import ( + "cmp" + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" + "math" + "slices" + "strconv" + "strings" +) + +func handleZADD(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zaddKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + + var updatePolicy interface{} = nil + var comparison interface{} = nil + var changed interface{} = nil + var incr interface{} = nil + + // Find the first valid score and this will be the start of the score/member pairs + var membersStartIndex int + for i := 0; i < len(params.Command); i++ { + if membersStartIndex != 0 { + break + } + switch internal.AdaptType(params.Command[i]).(type) { + case string: + if slices.Contains([]string{"-inf", "+inf"}, strings.ToLower(params.Command[i])) { + membersStartIndex = i + } + case float64: + membersStartIndex = i + case int: + membersStartIndex = i + } + } + + if membersStartIndex < 2 || len(params.Command[membersStartIndex:])%2 != 0 { + return nil, errors.New("score/member pairs must be float/string") + } + + var members []MemberParam + + for i := 0; i < len(params.Command[membersStartIndex:]); i++ { + if i%2 != 0 { + continue + } + score := internal.AdaptType(params.Command[membersStartIndex:][i]) + switch score.(type) { + default: + return nil, errors.New("invalid score in score/member list") + case string: + var s float64 + if strings.ToLower(score.(string)) == "-inf" { + s = math.Inf(-1) + members = append(members, MemberParam{ + Value: Value(params.Command[membersStartIndex:][i+1]), + Score: Score(s), + }) + } + if strings.ToLower(score.(string)) == "+inf" { + s = math.Inf(1) + members = append(members, MemberParam{ + Value: Value(params.Command[membersStartIndex:][i+1]), + Score: Score(s), + }) + } + case float64: + s, _ := score.(float64) + members = append(members, MemberParam{ + Value: Value(params.Command[membersStartIndex:][i+1]), + Score: Score(s), + }) + case int: + s, _ := score.(int) + members = append(members, MemberParam{ + Value: Value(params.Command[membersStartIndex:][i+1]), + Score: Score(s), + }) + } + } + + // Parse options using membersStartIndex as the upper limit + if membersStartIndex > 2 { + options := params.Command[2:membersStartIndex] + for _, option := range options { + if slices.Contains([]string{"xx", "nx"}, strings.ToLower(option)) { + updatePolicy = option + // If option is "NX" and comparison is not nil, return an error + if strings.EqualFold(option, "NX") && comparison != nil { + return nil, errors.New("GT/LT flags not allowed if NX flag is provided") + } + continue + } + if slices.Contains([]string{"gt", "lt"}, strings.ToLower(option)) { + comparison = option + // If updatePolicy is "NX", return an error + up, _ := updatePolicy.(string) + if strings.EqualFold(up, "NX") { + return nil, errors.New("GT/LT flags not allowed if NX flag is provided") + } + continue + } + if strings.EqualFold(option, "ch") { + changed = option + continue + } + if strings.EqualFold(option, "incr") { + incr = option + // If members length is more than 1, return an error + if len(members) > 1 { + return nil, errors.New("cannot pass more than one score/member pair when INCR flag is provided") + } + continue + } + return nil, fmt.Errorf("invalid option %s", option) + } + } + + if keyExists { + // Key exists + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + count, err := set.AddOrUpdate(members, updatePolicy, comparison, changed, incr) + if err != nil { + return nil, err + } + // If INCR option is provided, return the new score value + if incr != nil { + m := set.Get(members[0].Value) + return []byte(fmt.Sprintf("+%f\r\n", m.Score)), nil + } + + return []byte(fmt.Sprintf(":%d\r\n", count)), nil + } + + // Key does not exist. + set := NewSortedSet(members) + if err = params.SetValues(params.Context, map[string]interface{}{key: set}); err != nil { + return nil, err + } + + return []byte(fmt.Sprintf(":%d\r\n", set.Cardinality())), nil +} + +func handleZCARD(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zcardKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + if !keyExists { + return []byte(":0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + + return []byte(fmt.Sprintf(":%d\r\n", set.Cardinality())), nil +} + +func handleZCOUNT(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zcountKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + minimum := Score(math.Inf(-1)) + switch internal.AdaptType(params.Command[2]).(type) { + default: + return nil, errors.New("min constraint must be a double") + case string: + if strings.ToLower(params.Command[2]) == "+inf" { + minimum = Score(math.Inf(1)) + } else { + return nil, errors.New("min constraint must be a double") + } + case float64: + s, _ := internal.AdaptType(params.Command[2]).(float64) + minimum = Score(s) + case int: + s, _ := internal.AdaptType(params.Command[2]).(int) + minimum = Score(s) + } + + maximum := Score(math.Inf(1)) + switch internal.AdaptType(params.Command[3]).(type) { + default: + return nil, errors.New("max constraint must be a double") + case string: + if strings.ToLower(params.Command[3]) == "-inf" { + maximum = Score(math.Inf(-1)) + } else { + return nil, errors.New("max constraint must be a double") + } + case float64: + s, _ := internal.AdaptType(params.Command[3]).(float64) + maximum = Score(s) + case int: + s, _ := internal.AdaptType(params.Command[3]).(int) + maximum = Score(s) + } + + if !keyExists { + return []byte(":0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + + var members []MemberParam + for _, m := range set.GetAll() { + if m.Score >= minimum && m.Score <= maximum { + members = append(members, m) + } + } + + return []byte(fmt.Sprintf(":%d\r\n", len(members))), nil +} + +func handleZLEXCOUNT(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zlexcountKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + minimum := params.Command[2] + maximum := params.Command[3] + + if !keyExists { + return []byte(":0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + + members := set.GetAll() + + // Check if all members has the same score + for i := 0; i < len(members)-2; i++ { + if members[i].Score != members[i+1].Score { + return []byte(":0\r\n"), nil + } + } + + count := 0 + + for _, m := range members { + if slices.Contains([]int{1, 0}, internal.CompareLex(string(m.Value), minimum)) && + slices.Contains([]int{-1, 0}, internal.CompareLex(string(m.Value), maximum)) { + count += 1 + } + } + + return []byte(fmt.Sprintf(":%d\r\n", count)), nil +} + +func handleZDIFF(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zdiffKeyFunc(params.Command) + if err != nil { + return nil, err + } + + keyExists := params.KeysExist(params.Context, keys.ReadKeys) + + withscoresIndex := slices.IndexFunc(params.Command, func(s string) bool { + return strings.EqualFold(s, "withscores") + }) + if withscoresIndex > -1 && withscoresIndex < 2 { + return nil, errors.New(constants.WrongArgsResponse) + } + + // Extract base set + if !keyExists[keys.ReadKeys[0]] { + // If base set does not exist, return an empty array + return []byte("*0\r\n"), nil + } + + baseSortedSet, ok := params.GetValues(params.Context, []string{keys.ReadKeys[0]})[keys.ReadKeys[0]].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", keys.ReadKeys[0]) + } + + // Extract the remaining sets + var sets []*SortedSet + + for i := 1; i < len(keys.ReadKeys); i++ { + if !keyExists[keys.ReadKeys[i]] { + continue + } + set, ok := params.GetValues(params.Context, []string{keys.ReadKeys[i]})[keys.ReadKeys[i]].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", keys.ReadKeys[i]) + } + sets = append(sets, set) + } + + var diff = baseSortedSet.Subtract(sets) + + res := fmt.Sprintf("*%d", diff.Cardinality()) + includeScores := withscoresIndex != -1 && withscoresIndex >= 2 + + for _, m := range diff.GetAll() { + if includeScores { + res += fmt.Sprintf("\r\n*2\r\n$%d\r\n%s\r\n+%s", + len(m.Value), m.Value, strconv.FormatFloat(float64(m.Score), 'f', -1, 64)) + } else { + res += fmt.Sprintf("\r\n*1\r\n$%d\r\n%s", len(m.Value), m.Value) + } + } + + res += "\r\n" + + return []byte(res), nil +} + +func handleZDIFFSTORE(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zdiffstoreKeyFunc(params.Command) + if err != nil { + return nil, err + } + + keyExists := params.KeysExist(params.Context, keys.ReadKeys) + destination := keys.WriteKeys[0] + + // Extract base set + if !keyExists[keys.ReadKeys[0]] { + // If base set does not exist, return 0 + return []byte(":0\r\n"), nil + } + + baseSortedSet, ok := params.GetValues(params.Context, []string{keys.ReadKeys[0]})[keys.ReadKeys[0]].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", keys.ReadKeys[0]) + } + + var sets []*SortedSet + + for i := 1; i < len(keys.ReadKeys); i++ { + if keyExists[keys.ReadKeys[i]] { + set, ok := params.GetValues(params.Context, []string{keys.ReadKeys[i]})[keys.ReadKeys[i]].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", keys.ReadKeys[i]) + } + sets = append(sets, set) + } + } + + diff := baseSortedSet.Subtract(sets) + if err = params.SetValues(params.Context, map[string]interface{}{destination: diff}); err != nil { + return nil, err + } + + return []byte(fmt.Sprintf(":%d\r\n", diff.Cardinality())), nil +} + +func handleZINCRBY(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zincrbyKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + + member := Value(params.Command[3]) + var increment Score + + switch internal.AdaptType(params.Command[2]).(type) { + default: + return nil, errors.New("increment must be a double") + case string: + if strings.EqualFold("-inf", strings.ToLower(params.Command[2])) { + increment = Score(math.Inf(-1)) + } else if strings.EqualFold("+inf", strings.ToLower(params.Command[2])) { + increment = Score(math.Inf(1)) + } else { + return nil, errors.New("increment must be a double") + } + case float64: + s, _ := internal.AdaptType(params.Command[2]).(float64) + increment = Score(s) + case int: + s, _ := internal.AdaptType(params.Command[2]).(int) + increment = Score(s) + } + + if !keyExists { + // If the key does not exist, create a new sorted set at the key with + // the member and increment as the first value + if err = params.SetValues( + params.Context, + map[string]interface{}{ + key: NewSortedSet([]MemberParam{{Value: member, Score: increment}}), + }, + ); err != nil { + return nil, err + } + return []byte(fmt.Sprintf("+%s\r\n", strconv.FormatFloat(float64(increment), 'f', -1, 64))), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + if _, err = set.AddOrUpdate( + []MemberParam{ + {Value: member, Score: increment}}, + "xx", + nil, + nil, + "incr"); err != nil { + return nil, err + } + return []byte(fmt.Sprintf("+%s\r\n", + strconv.FormatFloat(float64(set.Get(member).Score), 'f', -1, 64))), nil +} + +func handleZINTER(params internal.HandlerFuncParams) ([]byte, error) { + _, err := zinterKeyFunc(params.Command) + if err != nil { + return nil, err + } + + keys, weights, aggregate, withscores, err := extractKeysWeightsAggregateWithScores(params.Command) + if err != nil { + return nil, err + } + keyExists := params.KeysExist(params.Context, keys) + + var setParams []SortedSetParam + + values := params.GetValues(params.Context, keys) + for i := 0; i < len(keys); i++ { + if !keyExists[keys[i]] { + // If any of the keys is non-existent, return an empty array as there's no intersect + return []byte("*0\r\n"), nil + } + set, ok := values[keys[i]].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", keys[i]) + } + setParams = append(setParams, SortedSetParam{ + Set: set, + Weight: weights[i], + }) + } + + intersect := Intersect(aggregate, setParams...) + + res := fmt.Sprintf("*%d", intersect.Cardinality()) + + if intersect.Cardinality() > 0 { + for _, m := range intersect.GetAll() { + if withscores { + res += fmt.Sprintf("\r\n*2\r\n$%d\r\n%s\r\n+%s", len(m.Value), m.Value, strconv.FormatFloat(float64(m.Score), 'f', -1, 64)) + } else { + res += fmt.Sprintf("\r\n*1\r\n$%d\r\n%s", len(m.Value), m.Value) + } + } + } + + res += "\r\n" + + return []byte(res), nil +} + +func handleZINTERSTORE(params internal.HandlerFuncParams) ([]byte, error) { + k, err := zinterstoreKeyFunc(params.Command) + if err != nil { + return nil, err + } + + keyExists := params.KeysExist(params.Context, k.ReadKeys) + destination := k.WriteKeys[0] + + // Remove the destination keys from the command before parsing it + cmd := slices.DeleteFunc(params.Command, func(s string) bool { + return s == destination + }) + + keys, weights, aggregate, _, err := extractKeysWeightsAggregateWithScores(cmd) + if err != nil { + return nil, err + } + + var setParams []SortedSetParam + + values := params.GetValues(params.Context, keys) + for i := 0; i < len(keys); i++ { + if !keyExists[keys[i]] { + return []byte(":0\r\n"), nil + } + set, ok := values[keys[i]].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", keys[i]) + } + setParams = append(setParams, SortedSetParam{ + Set: set, + Weight: weights[i], + }) + } + + intersect := Intersect(aggregate, setParams...) + if err = params.SetValues(params.Context, map[string]interface{}{ + destination: intersect, + }); err != nil { + return nil, err + } + + return []byte(fmt.Sprintf(":%d\r\n", intersect.Cardinality())), nil +} + +func handleZMPOP(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zmpopKeyFunc(params.Command) + if err != nil { + return nil, err + } + + keyExists := params.KeysExist(params.Context, keys.WriteKeys) + + count := 1 + policy := "min" + modifierIdx := -1 + + // Parse COUNT from command + countIdx := slices.IndexFunc(params.Command, func(s string) bool { + return strings.ToLower(s) == "count" + }) + if countIdx != -1 { + if countIdx < 2 { + return nil, errors.New(constants.WrongArgsResponse) + } + if countIdx == len(params.Command)-1 { + return nil, errors.New("count must be a positive integer") + } + c, err := strconv.Atoi(params.Command[countIdx+1]) + if err != nil { + return nil, err + } + if c <= 0 { + return nil, errors.New("count must be a positive integer") + } + count = c + modifierIdx = countIdx + } + + // Parse MIN/MAX from the command + policyIdx := slices.IndexFunc(params.Command, func(s string) bool { + return slices.Contains([]string{"min", "max"}, strings.ToLower(s)) + }) + if policyIdx != -1 { + if policyIdx < 2 { + return nil, errors.New(constants.WrongArgsResponse) + } + policy = strings.ToLower(params.Command[policyIdx]) + if modifierIdx == -1 || (policyIdx < modifierIdx) { + modifierIdx = policyIdx + } + } + + for i := 0; i < len(keys.WriteKeys); i++ { + if keyExists[keys.WriteKeys[i]] { + v, ok := params.GetValues(params.Context, []string{keys.WriteKeys[i]})[keys.WriteKeys[i]].(*SortedSet) + if !ok || v.Cardinality() == 0 { + continue + } + popped, err := v.Pop(count, policy) + if err != nil { + return nil, err + } + + res := fmt.Sprintf("*%d", popped.Cardinality()) + + for _, m := range popped.GetAll() { + res += fmt.Sprintf("\r\n*2\r\n$%d\r\n%s\r\n+%s", len(m.Value), m.Value, strconv.FormatFloat(float64(m.Score), 'f', -1, 64)) + } + + res += "\r\n" + + return []byte(res), nil + } + } + + return []byte("*0\r\n"), nil +} + +func handleZPOP(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zpopKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + count := 1 + policy := "min" + + if strings.EqualFold(params.Command[0], "zpopmax") { + policy = "max" + } + + if len(params.Command) == 3 { + c, err := strconv.Atoi(params.Command[2]) + if err != nil { + return nil, err + } + if c > 0 { + count = c + } + } + + if !keyExists { + return []byte("*0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at key %s is not a sorted set", key) + } + + popped, err := set.Pop(count, policy) + if err != nil { + return nil, err + } + + res := fmt.Sprintf("*%d", popped.Cardinality()) + for _, m := range popped.GetAll() { + res += fmt.Sprintf("\r\n*2\r\n$%d\r\n%s\r\n+%s", + len(m.Value), m.Value, strconv.FormatFloat(float64(m.Score), 'f', -1, 64)) + } + + res += "\r\n" + + return []byte(res), nil +} + +func handleZMSCORE(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zmscoreKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + if !keyExists { + return []byte("*0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + + members := params.Command[2:] + + res := fmt.Sprintf("*%d", len(members)) + + var member MemberObject + + for i := 0; i < len(members); i++ { + member = set.Get(Value(members[i])) + if !member.Exists { + res = fmt.Sprintf("%s\r\n$-1", res) + } else { + res = fmt.Sprintf("%s\r\n+%s", res, strconv.FormatFloat(float64(member.Score), 'f', -1, 64)) + } + } + + res += "\r\n" + + return []byte(res), nil +} + +func handleZRANDMEMBER(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zrandmemberKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + count := 1 + if len(params.Command) >= 3 { + c, err := strconv.Atoi(params.Command[2]) + if err != nil { + return nil, errors.New("count must be an integer") + } + if c != 0 { + count = c + } + } + + withscores := false + if len(params.Command) == 4 { + if strings.EqualFold(params.Command[3], "withscores") { + withscores = true + } else { + return nil, errors.New("last option must be WITHSCORES") + } + } + + if !keyExists { + return []byte("$-1\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + + members := set.GetRandom(count) + + res := fmt.Sprintf("*%d", len(members)) + for _, m := range members { + if withscores { + res += fmt.Sprintf("\r\n*2\r\n$%d\r\n%s\r\n+%s", len(m.Value), m.Value, strconv.FormatFloat(float64(m.Score), 'f', -1, 64)) + } else { + res += fmt.Sprintf("\r\n*1\r\n$%d\r\n%s", len(m.Value), m.Value) + } + } + + res += "\r\n" + + return []byte(res), nil +} + +func handleZRANK(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zrankKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + member := params.Command[2] + withscores := false + + if len(params.Command) == 4 && strings.EqualFold(params.Command[3], "withscores") { + withscores = true + } + + if !keyExists { + return []byte("$-1\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + + members := set.GetAll() + slices.SortFunc(members, func(a, b MemberParam) int { + if strings.EqualFold(params.Command[0], "zrevrank") { + return cmp.Compare(b.Score, a.Score) + } + return cmp.Compare(a.Score, b.Score) + }) + + for i := 0; i < len(members); i++ { + if members[i].Value == Value(member) { + if withscores { + score := strconv.FormatFloat(float64(members[i].Score), 'f', -1, 64) + return []byte(fmt.Sprintf("*2\r\n:%d\r\n$%d\r\n%s\r\n", i, len(score), score)), nil + } else { + return []byte(fmt.Sprintf("*1\r\n:%d\r\n", i)), nil + } + } + } + + return []byte("$-1\r\n"), nil +} + +func handleZREM(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zremKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + + if !keyExists { + return []byte(":0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + + deletedCount := 0 + for _, m := range params.Command[2:] { + if set.Remove(Value(m)) { + deletedCount += 1 + } + } + + return []byte(fmt.Sprintf(":%d\r\n", deletedCount)), nil +} + +func handleZSCORE(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zscoreKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + if !keyExists { + return []byte("$-1\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + member := set.Get(Value(params.Command[2])) + if !member.Exists { + return []byte("$-1\r\n"), nil + } + + score := strconv.FormatFloat(float64(member.Score), 'f', -1, 64) + + return []byte(fmt.Sprintf("$%d\r\n%s\r\n", len(score), score)), nil +} + +func handleZREMRANGEBYSCORE(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zremrangebyscoreKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + + deletedCount := 0 + + minimum, err := strconv.ParseFloat(params.Command[2], 64) + if err != nil { + return nil, err + } + + maximum, err := strconv.ParseFloat(params.Command[3], 64) + if err != nil { + return nil, err + } + + if !keyExists { + return []byte(":0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + + for _, m := range set.GetAll() { + if m.Score >= Score(minimum) && m.Score <= Score(maximum) { + set.Remove(m.Value) + deletedCount += 1 + } + } + + return []byte(fmt.Sprintf(":%d\r\n", deletedCount)), nil +} + +func handleZREMRANGEBYRANK(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zremrangebyrankKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + + start, err := strconv.Atoi(params.Command[2]) + if err != nil { + return nil, err + } + + stop, err := strconv.Atoi(params.Command[3]) + if err != nil { + return nil, err + } + + if !keyExists { + return []byte(":0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + + if start < 0 { + start = start + set.Cardinality() + } + if stop < 0 { + stop = stop + set.Cardinality() + } + + if start < 0 || start > set.Cardinality()-1 || stop < 0 || stop > set.Cardinality()-1 { + return nil, errors.New("indices out of bounds") + } + + members := set.GetAll() + slices.SortFunc(members, func(a, b MemberParam) int { + return cmp.Compare(a.Score, b.Score) + }) + + deletedCount := 0 + + if start < stop { + for i := start; i <= stop; i++ { + set.Remove(members[i].Value) + deletedCount += 1 + } + } else { + for i := stop; i <= start; i++ { + set.Remove(members[i].Value) + deletedCount += 1 + } + } + + return []byte(fmt.Sprintf(":%d\r\n", deletedCount)), nil +} + +func handleZREMRANGEBYLEX(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zremrangebylexKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + minimum := params.Command[2] + maximum := params.Command[3] + + if !keyExists { + return []byte(":0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + + members := set.GetAll() + + // Check if all the members have the same score. If not, return 0 + for i := 0; i < len(members)-1; i++ { + if members[i].Score != members[i+1].Score { + return []byte(":0\r\n"), nil + } + } + + deletedCount := 0 + + // All the members have the same score + for _, m := range members { + if slices.Contains([]int{1, 0}, internal.CompareLex(string(m.Value), minimum)) && + slices.Contains([]int{-1, 0}, internal.CompareLex(string(m.Value), maximum)) { + set.Remove(m.Value) + deletedCount += 1 + } + } + + return []byte(fmt.Sprintf(":%d\r\n", deletedCount)), nil +} + +func handleZRANGE(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zrangeKeyCount(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + policy := "byscore" + scoreStart := math.Inf(-1) // Lower bound if policy is "byscore" + scoreStop := math.Inf(1) // Upper bound if policy is "byscore" + lexStart := params.Command[2] // Lower bound if policy is "bylex" + lexStop := params.Command[3] // Upper bound if policy is "bylex" + offset := 0 + count := -1 + + withscores := slices.ContainsFunc(params.Command[4:], func(s string) bool { + return strings.EqualFold(s, "withscores") + }) + + reverse := slices.ContainsFunc(params.Command[4:], func(s string) bool { + return strings.EqualFold(s, "rev") + }) + + if slices.ContainsFunc(params.Command[4:], func(s string) bool { + return strings.EqualFold(s, "bylex") + }) { + policy = "bylex" + } else { + // policy is "byscore" make sure start and stop are valid float values + scoreStart, err = strconv.ParseFloat(params.Command[2], 64) + if err != nil { + return nil, err + } + scoreStop, err = strconv.ParseFloat(params.Command[3], 64) + if err != nil { + return nil, err + } + } + + if slices.ContainsFunc(params.Command[4:], func(s string) bool { + return strings.EqualFold(s, "limit") + }) { + limitIdx := slices.IndexFunc(params.Command[4:], func(s string) bool { + return strings.EqualFold(s, "limit") + }) + if limitIdx != -1 && limitIdx > len(params.Command[4:])-3 { + return nil, errors.New("limit should contain offset and count as integers") + } + offset, err = strconv.Atoi(params.Command[4:][limitIdx+1]) + if err != nil { + return nil, errors.New("limit offset must be integer") + } + if offset < 0 { + return nil, errors.New("limit offset must be >= 0") + } + count, err = strconv.Atoi(params.Command[4:][limitIdx+2]) + if err != nil { + return nil, errors.New("limit count must be integer") + } + } + + if !keyExists { + return []byte("*0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{key})[key].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", key) + } + + if offset > set.Cardinality() { + return []byte("*0\r\n"), nil + } + if count < 0 { + count = set.Cardinality() - offset + } + + members := set.GetAll() + if strings.EqualFold(policy, "byscore") { + slices.SortFunc(members, func(a, b MemberParam) int { + // Do a score sort + if reverse { + return cmp.Compare(b.Score, a.Score) + } + return cmp.Compare(a.Score, b.Score) + }) + } + if strings.EqualFold(policy, "bylex") { + // If policy is BYLEX, all the elements must have the same score + for i := 0; i < len(members)-1; i++ { + if members[i].Score != members[i+1].Score { + return []byte("*0\r\n"), nil + } + } + slices.SortFunc(members, func(a, b MemberParam) int { + if reverse { + return internal.CompareLex(string(b.Value), string(a.Value)) + } + return internal.CompareLex(string(a.Value), string(b.Value)) + }) + } + + var resultMembers []MemberParam + + for i := offset; i <= count; i++ { + if i >= len(members) { + break + } + if strings.EqualFold(policy, "byscore") { + if members[i].Score >= Score(scoreStart) && members[i].Score <= Score(scoreStop) { + resultMembers = append(resultMembers, members[i]) + } + continue + } + if slices.Contains([]int{1, 0}, internal.CompareLex(string(members[i].Value), lexStart)) && + slices.Contains([]int{-1, 0}, internal.CompareLex(string(members[i].Value), lexStop)) { + resultMembers = append(resultMembers, members[i]) + } + } + + res := fmt.Sprintf("*%d", len(resultMembers)) + + for _, m := range resultMembers { + if withscores { + res += fmt.Sprintf("\r\n*2\r\n$%d\r\n%s\r\n+%s", len(m.Value), m.Value, strconv.FormatFloat(float64(m.Score), 'f', -1, 64)) + } else { + res += fmt.Sprintf("\r\n*1\r\n$%d\r\n%s", len(m.Value), m.Value) + } + } + + res += "\r\n" + + return []byte(res), nil +} + +func handleZRANGESTORE(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := zrangeStoreKeyFunc(params.Command) + if err != nil { + return nil, err + } + + destination := keys.WriteKeys[0] + source := keys.ReadKeys[0] + sourceExists := params.KeysExist(params.Context, keys.ReadKeys)[source] + policy := "byscore" + scoreStart := math.Inf(-1) // Lower bound if policy is "byscore" + scoreStop := math.Inf(1) // Upper bound if policy is "byfloat" + lexStart := params.Command[3] // Lower bound if policy is "bylex" + lexStop := params.Command[4] // Upper bound if policy is "bylex" + offset := 0 + count := -1 + + reverse := slices.ContainsFunc(params.Command[5:], func(s string) bool { + return strings.EqualFold(s, "rev") + }) + + if slices.ContainsFunc(params.Command[5:], func(s string) bool { + return strings.EqualFold(s, "bylex") + }) { + policy = "bylex" + } else { + // policy is "byscore" make sure start and stop are valid float values + scoreStart, err = strconv.ParseFloat(params.Command[3], 64) + if err != nil { + return nil, err + } + scoreStop, err = strconv.ParseFloat(params.Command[4], 64) + if err != nil { + return nil, err + } + } + + if slices.ContainsFunc(params.Command[5:], func(s string) bool { + return strings.EqualFold(s, "limit") + }) { + limitIdx := slices.IndexFunc(params.Command[5:], func(s string) bool { + return strings.EqualFold(s, "limit") + }) + if limitIdx != -1 && limitIdx > len(params.Command[5:])-3 { + return nil, errors.New("limit should contain offset and count as integers") + } + offset, err = strconv.Atoi(params.Command[5:][limitIdx+1]) + if err != nil { + return nil, errors.New("limit offset must be integer") + } + if offset < 0 { + return nil, errors.New("limit offset must be >= 0") + } + count, err = strconv.Atoi(params.Command[5:][limitIdx+2]) + if err != nil { + return nil, errors.New("limit count must be integer") + } + } + + if !sourceExists { + return []byte("*0\r\n"), nil + } + + set, ok := params.GetValues(params.Context, []string{source})[source].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", source) + } + + if offset > set.Cardinality() { + return []byte(":0\r\n"), nil + } + if count < 0 { + count = set.Cardinality() - offset + } + + members := set.GetAll() + if strings.EqualFold(policy, "byscore") { + slices.SortFunc(members, func(a, b MemberParam) int { + // Do a score sort + if reverse { + return cmp.Compare(b.Score, a.Score) + } + return cmp.Compare(a.Score, b.Score) + }) + } + if strings.EqualFold(policy, "bylex") { + // If policy is BYLEX, all the elements must have the same score + for i := 0; i < len(members)-1; i++ { + if members[i].Score != members[i+1].Score { + return []byte(":0\r\n"), nil + } + } + slices.SortFunc(members, func(a, b MemberParam) int { + if reverse { + return internal.CompareLex(string(b.Value), string(a.Value)) + } + return internal.CompareLex(string(a.Value), string(b.Value)) + }) + } + + var resultMembers []MemberParam + + for i := offset; i <= count; i++ { + if i >= len(members) { + break + } + if strings.EqualFold(policy, "byscore") { + if members[i].Score >= Score(scoreStart) && members[i].Score <= Score(scoreStop) { + resultMembers = append(resultMembers, members[i]) + } + continue + } + if slices.Contains([]int{1, 0}, internal.CompareLex(string(members[i].Value), lexStart)) && + slices.Contains([]int{-1, 0}, internal.CompareLex(string(members[i].Value), lexStop)) { + resultMembers = append(resultMembers, members[i]) + } + } + + newSortedSet := NewSortedSet(resultMembers) + if err = params.SetValues(params.Context, map[string]interface{}{ + destination: newSortedSet, + }); err != nil { + return nil, err + } + + return []byte(fmt.Sprintf(":%d\r\n", newSortedSet.Cardinality())), nil +} + +func handleZUNION(params internal.HandlerFuncParams) ([]byte, error) { + if _, err := zunionKeyFunc(params.Command); err != nil { + return nil, err + } + + keys, weights, aggregate, withscores, err := extractKeysWeightsAggregateWithScores(params.Command) + if err != nil { + return nil, err + } + + keyExists := params.KeysExist(params.Context, keys) + + var setParams []SortedSetParam + + values := params.GetValues(params.Context, keys) + for i := 0; i < len(keys); i++ { + if keyExists[keys[i]] { + set, ok := values[keys[i]].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", keys[i]) + } + setParams = append(setParams, SortedSetParam{ + Set: set, + Weight: weights[i], + }) + } + } + + union := Union(aggregate, setParams...) + + res := fmt.Sprintf("*%d", union.Cardinality()) + for _, m := range union.GetAll() { + if withscores { + res += fmt.Sprintf("\r\n*2\r\n$%d\r\n%s\r\n+%s", len(m.Value), m.Value, strconv.FormatFloat(float64(m.Score), 'f', -1, 64)) + } else { + res += fmt.Sprintf("\r\n*1\r\n$%d\r\n%s", len(m.Value), m.Value) + } + } + + res += "\r\n" + + return []byte(res), nil +} + +func handleZUNIONSTORE(params internal.HandlerFuncParams) ([]byte, error) { + k, err := zunionstoreKeyFunc(params.Command) + if err != nil { + return nil, err + } + + destination := k.WriteKeys[0] + + // Remove destination key from list of keys + params.Command = slices.DeleteFunc(params.Command, func(s string) bool { + return s == destination + }) + + keys, weights, aggregate, _, err := extractKeysWeightsAggregateWithScores(params.Command) + if err != nil { + return nil, err + } + + keyExists := params.KeysExist(params.Context, keys) + + var setParams []SortedSetParam + + values := params.GetValues(params.Context, keys) + for i := 0; i < len(keys); i++ { + if keyExists[keys[i]] { + set, ok := values[keys[i]].(*SortedSet) + if !ok { + return nil, fmt.Errorf("value at %s is not a sorted set", keys[i]) + } + setParams = append(setParams, SortedSetParam{ + Set: set, + Weight: weights[i], + }) + } + } + + union := Union(aggregate, setParams...) + if err = params.SetValues(params.Context, map[string]interface{}{ + destination: union, + }); err != nil { + return nil, err + } + + return []byte(fmt.Sprintf(":%d\r\n", union.Cardinality())), nil +} + +func Commands() []internal.Command { + return []internal.Command{ + { + Command: "zadd", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(ZADD key [NX | XX] [GT | LT] [CH] [INCR] score member [score member...]) +Adds all the specified members with the specified scores to the sorted set at the key. +"NX" only adds the member if it currently does not exist in the sorted set. +"XX" only updates the scores of members that exist in the sorted set. +"GT"" only updates the score if the new score is greater than the current score. +"LT" only updates the score if the new score is less than the current score. +"CH" modifies the result to return total number of members changed + added, instead of only new members added. +"INCR" modifies the command to act like ZINCRBY, only one score/member pair can be specified in this mode.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: zaddKeyFunc, + HandlerFunc: handleZADD, + }, + { + Command: "zcard", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(ZCARD key) Returns the set cardinality of the sorted set at key. +If the key does not exist, 0 is returned, otherwise the cardinality of the sorted set is returned. +If the key holds a value that is not a sorted set, this command will return an error.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: zcardKeyFunc, + HandlerFunc: handleZCARD, + }, + { + Command: "zcount", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(ZCOUNT key min max) +Returns the number of elements in the sorted set key with scores in the range of min and max. +If the key does not exist, a count of 0 is returned, otherwise return the count. +If the key holds a value that is not a sorted set, an error is returned.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: zcountKeyFunc, + HandlerFunc: handleZCOUNT, + }, + { + Command: "zdiff", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(ZDIFF key [key...] [WITHSCORES]) +Computes the difference between all the sorted sets specified in the list of keys and returns the result.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: zdiffKeyFunc, + HandlerFunc: handleZDIFF, + }, + { + Command: "zdiffstore", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(ZDIFFSTORE destination key [key...]). +Computes the difference between all the sorted sets specifies in the list of keys. Stores the result in destination. +If the base set (first key) does not exist, return 0, otherwise, return the cardinality of the diff.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: zdiffstoreKeyFunc, + HandlerFunc: handleZDIFFSTORE, + }, + { + Command: "zincrby", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(ZINCRBY key increment member). +Increments the score of the specified sorted set's member by the increment. If the member does not exist, it is created. +If the key does not exist, it is created with new sorted set and the member added with the increment as its score.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: zincrbyKeyFunc, + HandlerFunc: handleZINCRBY, + }, + { + Command: "zinter", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(ZINTER key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE ] [WITHSCORES]). +Computes the intersection of the sets in the keys, with weights, aggregate and scores`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: zinterKeyFunc, + HandlerFunc: handleZINTER, + }, + { + Command: "zinterstore", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: ` +(ZINTERSTORE destination key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE ] [WITHSCORES]). +Computes the intersection of the sets in the keys, with weights, aggregate and scores. The result is stored in destination.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: zinterstoreKeyFunc, + HandlerFunc: handleZINTERSTORE, + }, + { + Command: "zmpop", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(ZMPOP key [key ...] [COUNT count]) +Pop a 'count' elements from multiple sorted sets. MIN or MAX determines whether to pop elements with the lowest or highest scores +respectively.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: zmpopKeyFunc, + HandlerFunc: handleZMPOP, + }, + { + Command: "zmscore", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.ReadCategory, constants.FastCategory}, + Description: `(ZMSCORE key member [member ...]) +Returns the associated scores of the specified member in the sorted set. +Returns nil for members that do not exist in the set`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: zmscoreKeyFunc, + HandlerFunc: handleZMSCORE, + }, + { + Command: "zpopmax", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(ZPOPMAX key [count]) +Removes and returns 'count' number of members in the sorted set with the highest scores. Default count is 1.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: zpopKeyFunc, + HandlerFunc: handleZPOP, + }, + { + Command: "zpopmin", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(ZPOPMIN key [count]) +Removes and returns 'count' number of members in the sorted set with the lowest scores. Default count is 1.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: zpopKeyFunc, + HandlerFunc: handleZPOP, + }, + { + Command: "zrandmember", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(ZRANDMEMBER key [count [WITHSCORES]]) +Return a list of length equivalent to count containing random members of the sorted set. +If count is negative, repeated elements are allowed. If count is positive, the returned elements will be distinct. +WITHSCORES modifies the result to include scores in the result.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: zrandmemberKeyFunc, + HandlerFunc: handleZRANDMEMBER, + }, + { + Command: "zrank", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(ZRANK key member [WITHSCORE]) +Returns the rank of the specified member in the sorted set. WITHSCORE modifies the result to also return the score.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: zrankKeyFunc, + HandlerFunc: handleZRANK, + }, + { + Command: "zrevrank", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(ZREVRANK key member [WITHSCORE]) +Returns the rank of the member in the sorted set in reverse order. +WITHSCORE modifies the result to include the score.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: zrevrankKeyFunc, + HandlerFunc: handleZRANK, + }, + { + Command: "zrem", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.WriteCategory, constants.FastCategory}, + Description: `(ZREM key member [member ...]) Removes the listed members from the sorted set. +Returns the number of elements removed.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: zremKeyFunc, + HandlerFunc: handleZREM, + }, + { + Command: "zscore", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.ReadCategory, constants.FastCategory}, + Description: `(ZSCORE key member) Returns the score of the member in the sorted set.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: zscoreKeyFunc, + HandlerFunc: handleZSCORE, + }, + { + Command: "zremrangebylex", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(ZREMRANGEBYLEX key min max) Removes the elements in the lexicographical range between min and max`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: zremrangebylexKeyFunc, + HandlerFunc: handleZREMRANGEBYLEX, + }, + { + Command: "zremrangebyrank", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(ZREMRANGEBYRANK key start stop) Removes the elements in the rank range between start and stop. +The elements are ordered from lowest score to highest score`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: zremrangebyrankKeyFunc, + HandlerFunc: handleZREMRANGEBYRANK, + }, + { + Command: "zremrangebyscore", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(ZREMRANGEBYSCORE key min max) Removes the elements whose scores are in the range between min and max`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: zremrangebyscoreKeyFunc, + HandlerFunc: handleZREMRANGEBYSCORE, + }, + { + Command: "zlexcount", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(ZLEXCOUNT key min max) Returns the number of elements in within the sorted set within the +lexicographical range between min and max. Returns 0, if the keys does not exist or if all the members do not have +the same score. If the value held at key is not a sorted set, an error is returned.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: zlexcountKeyFunc, + HandlerFunc: handleZLEXCOUNT, + }, + { + Command: "zrange", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(ZRANGE key start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count] + [WITHSCORES]) Returns the range of elements in the sorted set.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: zrangeKeyCount, + HandlerFunc: handleZRANGE, + }, + { + Command: "zrangestore", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `ZRANGESTORE destination source start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count] + [WITHSCORES] Retrieve the range of elements in the sorted set and store it in destination.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: zrangeStoreKeyFunc, + HandlerFunc: handleZRANGESTORE, + }, + { + Command: "zunion", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.ReadCategory, constants.SlowCategory}, + Description: `(ZUNION key [key ...] [WEIGHTS weight [weight ...]] +[AGGREGATE ] [WITHSCORES]) Return the union of the sorted sets in keys. The scores of each member of +a sorted set are multiplied by the corresponding weight in WEIGHTS. Aggregate determines how the scores are combined. +WITHSCORES option determines whether to return the result with scores included.`, + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: zunionKeyFunc, + HandlerFunc: handleZUNION, + }, + { + Command: "zunionstore", + Module: constants.SortedSetModule, + Categories: []string{constants.SortedSetCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(ZUNIONSTORE destination key [key ...] [WEIGHTS weight [weight ...]] +[AGGREGATE ] [WITHSCORES]) Return the union of the sorted sets in keys. The scores of each member of +a sorted set are multiplied by the corresponding weight in WEIGHTS. Aggregate determines how the scores are combined. +The resulting union is stored at the destination key.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: zunionstoreKeyFunc, + HandlerFunc: handleZUNIONSTORE, + }, + } +} diff --git a/internal/modules/sorted_set/commands_test.go b/internal/modules/sorted_set/commands_test.go new file mode 100644 index 0000000..7b15418 --- /dev/null +++ b/internal/modules/sorted_set/commands_test.go @@ -0,0 +1,5748 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sorted_set_test + +import ( + "errors" + "math" + "slices" + "strconv" + "strings" + "testing" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" + "apigo.cc/go/sugardb/internal/modules/sorted_set" + "apigo.cc/go/sugardb/sugardb" + "github.com/tidwall/resp" +) + +func Test_SortedSet(t *testing.T) { + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := sugardb.NewSugarDB( + sugardb.WithConfig(config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.NoEviction, + }), + ) + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + t.Run("Test_HandleZADD", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValue *sorted_set.SortedSet + key string + command []string + expectedResponse int + expectedError error + }{ + { + name: "1. Create new sorted set and return the cardinality of the new sorted set", + presetValue: nil, + key: "ZaddKey1", + command: []string{"ZADD", "ZaddKey1", "5.5", "member1", "67.77", "member2", "10", "member3", "-inf", "member4", "+inf", "member5"}, + expectedResponse: 5, + expectedError: nil, + }, + { + name: "2. Only add the elements that do not currently exist in the sorted set when NX flag is provided", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "member1", Score: sorted_set.Score(5.5)}, + {Value: "member2", Score: sorted_set.Score(67.77)}, + {Value: "member3", Score: sorted_set.Score(10)}, + }), + key: "ZaddKey2", + command: []string{"ZADD", "ZaddKey2", "NX", "5.5", "member1", "67.77", "member4", "10", "member5"}, + expectedResponse: 2, + expectedError: nil, + }, + { + name: "3. Do not add any elements when providing existing members with NX flag", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "member1", Score: sorted_set.Score(5.5)}, + {Value: "member2", Score: sorted_set.Score(67.77)}, + {Value: "member3", Score: sorted_set.Score(10)}, + }), + key: "ZaddKey3", + command: []string{"ZADD", "ZaddKey3", "NX", "5.5", "member1", "67.77", "member2", "10", "member3"}, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "4. Successfully add elements to an existing set when XX flag is provided with existing elements", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "member1", Score: sorted_set.Score(5.5)}, + {Value: "member2", Score: sorted_set.Score(67.77)}, + {Value: "member3", Score: sorted_set.Score(10)}, + }), + key: "ZaddKey4", + command: []string{"ZADD", "ZaddKey4", "XX", "CH", "55", "member1", "1005", "member2", "15", "member3", "99.75", "member4"}, + expectedResponse: 3, + expectedError: nil, + }, + { + name: "5. Fail to add element when providing XX flag with elements that do not exist in the sorted set.", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "member1", Score: sorted_set.Score(5.5)}, + {Value: "member2", Score: sorted_set.Score(67.77)}, + {Value: "member3", Score: sorted_set.Score(10)}, + }), + key: "ZaddKey5", + command: []string{"ZADD", "ZaddKey5", "XX", "5.5", "member4", "100.5", "member5", "15", "member6"}, + expectedResponse: 0, + expectedError: nil, + }, + { + // 6. Only update the elements where provided score is greater than current score and GT flag is provided + // Return only the new elements added by default + name: "6. Only update the elements where provided score is greater than current score and GT flag is provided", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "member1", Score: sorted_set.Score(5.5)}, + {Value: "member2", Score: sorted_set.Score(67.77)}, + {Value: "member3", Score: sorted_set.Score(10)}, + }), + key: "ZaddKey6", + command: []string{"ZADD", "ZaddKey6", "XX", "CH", "GT", "7.5", "member1", "100.5", "member4", "15", "member5"}, + expectedResponse: 1, + expectedError: nil, + }, + { + // 7. Only update the elements where provided score is less than current score if LT flag is provided + // Return only the new elements added by default. + name: "7. Only update the elements where provided score is less than current score if LT flag is provided", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "member1", Score: sorted_set.Score(5.5)}, + {Value: "member2", Score: sorted_set.Score(67.77)}, + {Value: "member3", Score: sorted_set.Score(10)}, + }), + key: "ZaddKey7", + command: []string{"ZADD", "ZaddKey7", "XX", "LT", "3.5", "member1", "100.5", "member4", "15", "member5"}, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "8. Return all the elements that were updated AND added when CH flag is provided", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "member1", Score: sorted_set.Score(5.5)}, + {Value: "member2", Score: sorted_set.Score(67.77)}, + {Value: "member3", Score: sorted_set.Score(10)}, + }), + key: "ZaddKey8", + command: []string{"ZADD", "ZaddKey8", "XX", "LT", "CH", "3.5", "member1", "100.5", "member4", "15", "member5"}, + expectedResponse: 1, + expectedError: nil, + }, + { + name: "9. Increment the member by score", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "member1", Score: sorted_set.Score(5.5)}, + {Value: "member2", Score: sorted_set.Score(67.77)}, + {Value: "member3", Score: sorted_set.Score(10)}, + }), + key: "ZaddKey9", + command: []string{"ZADD", "ZaddKey9", "INCR", "5.5", "member3"}, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "10. Fail when GT/LT flag is provided alongside NX flag", + presetValue: nil, + key: "ZaddKey10", + command: []string{"ZADD", "ZaddKey10", "NX", "LT", "CH", "3.5", "member1", "100.5", "member4", "15", "member5"}, + expectedResponse: 0, + expectedError: errors.New("GT/LT flags not allowed if NX flag is provided"), + }, + { + name: "11. Command is too short", + presetValue: nil, + key: "ZaddKey11", + command: []string{"ZADD", "ZaddKey11"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "12. Throw error when score/member entries are do not match", + presetValue: nil, + key: "ZaddKey11", + command: []string{"ZADD", "ZaddKey12", "10.5", "member1", "12.5"}, + expectedResponse: 0, + expectedError: errors.New("score/member pairs must be float/string"), + }, + { + name: "13. Throw error when INCR flag is passed with more than one score/member pair", + presetValue: nil, + key: "ZaddKey13", + command: []string{"ZADD", "ZaddKey13", "INCR", "10.5", "member1", "12.5", "member2"}, + expectedResponse: 0, + expectedError: errors.New("cannot pass more than one score/member pair when INCR flag is provided"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(test.key)} + for _, member := range test.presetValue.GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if res.Integer() != test.presetValue.Cardinality() { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleZCARD", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValue interface{} + key string + command []string + expectedResponse int + expectedError error + }{ + { + name: "1. Get cardinality of valid sorted set.", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "member1", Score: sorted_set.Score(5.5)}, + {Value: "member2", Score: sorted_set.Score(67.77)}, + {Value: "member3", Score: sorted_set.Score(10)}, + }), + key: "ZcardKey1", + command: []string{"ZCARD", "ZcardKey1"}, + expectedResponse: 3, + expectedError: nil, + }, + { + name: "2. Return 0 when trying to get cardinality from non-existent key", + presetValue: nil, + key: "ZcardKey2", + command: []string{"ZCARD", "ZcardKey2"}, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "3. Command is too short", + presetValue: nil, + key: "ZcardKey3", + command: []string{"ZCARD"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "4. Command too long", + presetValue: nil, + key: "ZcardKey4", + command: []string{"ZCARD", "ZcardKey4", "ZcardKey5"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Return error when not a sorted set", + presetValue: "Default value", + key: "ZcardKey5", + command: []string{"ZCARD", "ZcardKey5"}, + expectedResponse: 0, + expectedError: errors.New("value at ZcardKey5 is not a sorted set"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(test.key)} + for _, member := range test.presetValue.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(test.presetValue.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleZCOUNT", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValue interface{} + key string + command []string + expectedResponse int + expectedError error + }{ + { + name: "1. Get entire count using infinity boundaries", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "member1", Score: sorted_set.Score(5.5)}, + {Value: "member2", Score: sorted_set.Score(67.77)}, + {Value: "member3", Score: sorted_set.Score(10)}, + {Value: "member4", Score: sorted_set.Score(1083.13)}, + {Value: "member5", Score: sorted_set.Score(11)}, + {Value: "member6", Score: sorted_set.Score(math.Inf(-1))}, + {Value: "member7", Score: sorted_set.Score(math.Inf(1))}, + }), + key: "ZcountKey1", + command: []string{"ZCOUNT", "ZcountKey1", "-inf", "+inf"}, + expectedResponse: 7, + expectedError: nil, + }, + { + name: "2. Get count of sub-set from -inf to limit", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "member1", Score: sorted_set.Score(5.5)}, + {Value: "member2", Score: sorted_set.Score(67.77)}, + {Value: "member3", Score: sorted_set.Score(10)}, + {Value: "member4", Score: sorted_set.Score(1083.13)}, + {Value: "member5", Score: sorted_set.Score(11)}, + {Value: "member6", Score: sorted_set.Score(math.Inf(-1))}, + {Value: "member7", Score: sorted_set.Score(math.Inf(1))}, + }), + key: "ZcountKey2", + command: []string{"ZCOUNT", "ZcountKey2", "-inf", "90"}, + expectedResponse: 5, + expectedError: nil, + }, + { + name: "3. Get count of sub-set from bottom boundary to +inf limit", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "member1", Score: sorted_set.Score(5.5)}, + {Value: "member2", Score: sorted_set.Score(67.77)}, + {Value: "member3", Score: sorted_set.Score(10)}, + {Value: "member4", Score: sorted_set.Score(1083.13)}, + {Value: "member5", Score: sorted_set.Score(11)}, + {Value: "member6", Score: sorted_set.Score(math.Inf(-1))}, + {Value: "member7", Score: sorted_set.Score(math.Inf(1))}, + }), + key: "ZcountKey3", + command: []string{"ZCOUNT", "ZcountKey3", "1000", "+inf"}, + expectedResponse: 2, + expectedError: nil, + }, + { + name: "4. Return error when bottom boundary is not a valid double/float", + presetValue: nil, + key: "ZcountKey4", + command: []string{"ZCOUNT", "ZcountKey4", "min", "10"}, + expectedResponse: 0, + expectedError: errors.New("min constraint must be a double"), + }, + { + name: "5. Return error when top boundary is not a valid double/float", + presetValue: nil, + key: "ZcountKey5", + command: []string{"ZCOUNT", "ZcountKey5", "-10", "max"}, + expectedResponse: 0, + expectedError: errors.New("max constraint must be a double"), + }, + { + name: "6. Command is too short", + presetValue: nil, + key: "ZcountKey6", + command: []string{"ZCOUNT"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "7. Command too long", + presetValue: nil, + key: "ZcountKey7", + command: []string{"ZCOUNT", "ZcountKey4", "min", "max", "count"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "8. Throw error when value at the key is not a sorted set", + presetValue: "Default value", + key: "ZcountKey8", + command: []string{"ZCOUNT", "ZcountKey8", "1", "10"}, + expectedResponse: 0, + expectedError: errors.New("value at ZcountKey8 is not a sorted set"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(test.key)} + for _, member := range test.presetValue.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(test.presetValue.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleZLEXCOUNT", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValue interface{} + key string + command []string + expectedResponse int + expectedError error + }{ + { + name: "1. Get entire count using infinity boundaries", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "e", Score: sorted_set.Score(1)}, + {Value: "f", Score: sorted_set.Score(1)}, + {Value: "g", Score: sorted_set.Score(1)}, + {Value: "h", Score: sorted_set.Score(1)}, + {Value: "i", Score: sorted_set.Score(1)}, + {Value: "j", Score: sorted_set.Score(1)}, + {Value: "k", Score: sorted_set.Score(1)}, + }), + key: "ZlexCountKey1", + command: []string{"ZLEXCOUNT", "ZlexCountKey1", "f", "j"}, + expectedResponse: 5, + expectedError: nil, + }, + { + name: "2. Return 0 when the members do not have the same score", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: sorted_set.Score(5.5)}, + {Value: "b", Score: sorted_set.Score(67.77)}, + {Value: "c", Score: sorted_set.Score(10)}, + {Value: "d", Score: sorted_set.Score(1083.13)}, + {Value: "e", Score: sorted_set.Score(11)}, + {Value: "f", Score: sorted_set.Score(math.Inf(-1))}, + {Value: "g", Score: sorted_set.Score(math.Inf(1))}, + }), + key: "ZlexCountKey2", + command: []string{"ZLEXCOUNT", "ZlexCountKey2", "a", "b"}, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "3. Return 0 when the key does not exist", + presetValue: nil, + key: "ZlexCountKey3", + command: []string{"ZLEXCOUNT", "ZlexCountKey3", "a", "z"}, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "4. Return error when the value at the key is not a sorted set", + presetValue: "Default value", + key: "ZlexCountKey4", + command: []string{"ZLEXCOUNT", "ZlexCountKey4", "a", "z"}, + expectedResponse: 0, + expectedError: errors.New("value at ZlexCountKey4 is not a sorted set"), + }, + { + name: "5. Command is too short", + presetValue: nil, + key: "ZlexCountKey5", + command: []string{"ZLEXCOUNT"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Command too long", + presetValue: nil, + key: "ZlexCountKey6", + command: []string{"ZLEXCOUNT", "ZlexCountKey6", "min", "max", "count"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(test.key)} + for _, member := range test.presetValue.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(test.presetValue.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleZDIFF", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedResponse [][]string + expectedError error + }{ + { + name: "1. Get the difference between 2 sorted sets without scores.", + presetValues: map[string]interface{}{ + "ZdiffKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, + {Value: "two", Score: 2}, + {Value: "three", Score: 3}, + {Value: "four", Score: 4}, + }), + "ZdiffKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "three", Score: 3}, + {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, + {Value: "eight", Score: 8}, + }), + }, + command: []string{"ZDIFF", "ZdiffKey1", "ZdiffKey2"}, + expectedResponse: [][]string{{"one"}, {"two"}}, + expectedError: nil, + }, + { + name: "2. Get the difference between 2 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZdiffKey3": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, + {Value: "two", Score: 2}, + {Value: "three", Score: 3}, + {Value: "four", Score: 4}, + }), + "ZdiffKey4": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "three", Score: 3}, + {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, + {Value: "eight", Score: 8}, + }), + }, + command: []string{"ZDIFF", "ZdiffKey3", "ZdiffKey4", "WITHSCORES"}, + expectedResponse: [][]string{{"one", "1"}, {"two", "2"}}, + expectedError: nil, + }, + { + name: "3. Get the difference between 3 sets with scores.", + presetValues: map[string]interface{}{ + "ZdiffKey5": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZdiffKey6": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "ZdiffKey7": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZDIFF", "ZdiffKey5", "ZdiffKey6", "ZdiffKey7", "WITHSCORES"}, + expectedResponse: [][]string{{"three", "3"}, {"four", "4"}, {"five", "5"}, {"six", "6"}}, + expectedError: nil, + }, + { + name: "4. Return sorted set if only one key exists and is a sorted set", + presetValues: map[string]interface{}{ + "ZdiffKey8": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + command: []string{"ZDIFF", "ZdiffKey8", "ZdiffKey9", "ZdiffKey10", "WITHSCORES"}, + expectedResponse: [][]string{ + {"one", "1"}, {"two", "2"}, {"three", "3"}, {"four", "4"}, {"five", "5"}, + {"six", "6"}, {"seven", "7"}, {"eight", "8"}, + }, + expectedError: nil, + }, + { + name: "5. Throw error when one of the keys is not a sorted set.", + presetValues: map[string]interface{}{ + "ZdiffKey11": "Default value", + "ZdiffKey12": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "ZdiffKey13": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZDIFF", "ZdiffKey11", "ZdiffKey12", "ZdiffKey13"}, + expectedResponse: nil, + expectedError: errors.New("value at ZdiffKey11 is not a sorted set"), + }, + { + name: "6. Command too short", + command: []string{"ZDIFF"}, + expectedResponse: [][]string{}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response array of length %d, got %d", len(test.expectedResponse), len(res.Array())) + } + + for _, item := range res.Array() { + value := item.Array()[0].String() + score := func() string { + if len(item.Array()) == 2 { + return item.Array()[1].String() + } + return "" + }() + if !slices.ContainsFunc(test.expectedResponse, func(expected []string) bool { + return expected[0] == value + }) { + t.Errorf("unexpected member \"%s\" in response", value) + } + if score != "" { + for _, expected := range test.expectedResponse { + if expected[0] == value && expected[1] != score { + t.Errorf("expected score for member \"%s\" to be %s, got %s", value, expected[1], score) + } + } + } + } + }) + } + }) + + t.Run("Test_HandleZDIFFSTORE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + destination string + command []string + expectedValue *sorted_set.SortedSet + expectedResponse int + expectedError error + }{ + { + name: "1. Get the difference between 2 sorted sets.", + presetValues: map[string]interface{}{ + "ZdiffStoreKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + "ZdiffStoreKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "ZdiffStoreDestinationKey1", + command: []string{"ZDIFFSTORE", "ZdiffStoreDestinationKey1", "ZdiffStoreKey1", "ZdiffStoreKey2"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}, {Value: "two", Score: 2}}), + expectedResponse: 2, + expectedError: nil, + }, + { + name: "2. Get the difference between 3 sorted sets.", + presetValues: map[string]interface{}{ + "ZdiffStoreKey3": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZdiffStoreKey4": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "ZdiffStoreKey5": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "ZdiffStoreDestinationKey2", + command: []string{"ZDIFFSTORE", "ZdiffStoreDestinationKey2", "ZdiffStoreKey3", "ZdiffStoreKey4", "ZdiffStoreKey5"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + }), + expectedResponse: 4, + expectedError: nil, + }, + { + name: "3. Return base sorted set element if base set is the only existing key provided and is a valid sorted set", + presetValues: map[string]interface{}{ + "ZdiffStoreKey6": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "ZdiffStoreDestinationKey3", + command: []string{"ZDIFFSTORE", "ZdiffStoreDestinationKey3", "ZdiffStoreKey6", "ZdiffStoreKey7", "ZdiffStoreKey8"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + expectedResponse: 8, + expectedError: nil, + }, + { + name: "4. Throw error when base sorted set is not a set.", + presetValues: map[string]interface{}{ + "ZdiffStoreKey9": "Default value", + "ZdiffStoreKey10": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "ZdiffStoreKey11": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "ZdiffStoreDestinationKey4", + command: []string{"ZDIFFSTORE", "ZdiffStoreDestinationKey4", "ZdiffStoreKey9", "ZdiffStoreKey10", "ZdiffStoreKey11"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: errors.New("value at ZdiffStoreKey9 is not a sorted set"), + }, + { + name: "5. Return 0 when base set is non-existent.", + destination: "ZdiffStoreDestinationKey5", + presetValues: map[string]interface{}{ + "ZdiffStoreKey12": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "ZdiffStoreKey13": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZDIFFSTORE", "ZdiffStoreDestinationKey5", "non-existent", "ZdiffStoreKey12", "ZdiffStoreKey13"}, + expectedValue: nil, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "6. Command too short", + command: []string{"ZDIFFSTORE", "ZdiffStoreDestinationKey6"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + + // Check if the resulting sorted set has the expected members/scores + if test.expectedValue == nil { + return + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("ZRANGE"), + resp.StringValue(test.destination), + resp.StringValue("-inf"), + resp.StringValue("+inf"), + resp.StringValue("BYSCORE"), + resp.StringValue("WITHSCORES"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != test.expectedValue.Cardinality() { + t.Errorf("expected resulting set %s to have cardinality %d, got %d", + test.destination, test.expectedValue.Cardinality(), len(res.Array())) + } + + for _, member := range res.Array() { + value := sorted_set.Value(member.Array()[0].String()) + score := sorted_set.Score(member.Array()[1].Float()) + if !test.expectedValue.Contains(value) { + t.Errorf("unexpected value %s in resulting sorted set", value) + } + if test.expectedValue.Get(value).Score != score { + t.Errorf("expected value %s to have score %v, got %v", value, test.expectedValue.Get(value).Score, score) + } + } + }) + } + }) + + t.Run("Test_HandleZINCRBY", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValue interface{} + key string + command []string + expectedValue *sorted_set.SortedSet + expectedResponse string + expectedError error + }{ + { + name: "1. Successfully increment by int. Return the new score", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "ZincrbyKey1", + command: []string{"ZINCRBY", "ZincrbyKey1", "5", "one"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 6}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + expectedResponse: "6", + expectedError: nil, + }, + { + name: "2. Successfully increment by float. Return new score", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "ZincrbyKey2", + command: []string{"ZINCRBY", "ZincrbyKey2", "346.785", "one"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 347.785}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + expectedResponse: "347.785", + expectedError: nil, + }, + { + name: "3. Increment on non-existent sorted set will create the set with the member and increment as its score", + presetValue: nil, + key: "ZincrbyKey3", + command: []string{"ZINCRBY", "ZincrbyKey3", "346.785", "one"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 346.785}, + }), + expectedResponse: "346.785", + expectedError: nil, + }, + { + name: "4. Increment score to +inf", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "ZincrbyKey4", + command: []string{"ZINCRBY", "ZincrbyKey4", "+inf", "one"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: sorted_set.Score(math.Inf(1))}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + expectedResponse: "+Inf", + expectedError: nil, + }, + { + name: "5. Increment score to -inf", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "ZincrbyKey5", + command: []string{"ZINCRBY", "ZincrbyKey5", "-inf", "one"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: sorted_set.Score(math.Inf(-1))}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + expectedResponse: "-Inf", + expectedError: nil, + }, + { + name: "6. Incrementing score by negative increment should lower the score", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "ZincrbyKey6", + command: []string{"ZINCRBY", "ZincrbyKey6", "-2.5", "five"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 2.5}, + }), + expectedResponse: "2.5", + expectedError: nil, + }, + { + name: "7. Return error when attempting to increment on a value that is not a valid sorted set", + presetValue: "Default value", + key: "ZincrbyKey7", + command: []string{"ZINCRBY", "ZincrbyKey7", "-2.5", "five"}, + expectedValue: nil, + expectedResponse: "", + expectedError: errors.New("value at ZincrbyKey7 is not a sorted set"), + }, + { + name: "8. Return error when trying to increment a member that already has score -inf", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: sorted_set.Score(math.Inf(-1))}, + }), + key: "ZincrbyKey8", + command: []string{"ZINCRBY", "ZincrbyKey8", "2.5", "one"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: sorted_set.Score(math.Inf(-1))}, + }), + expectedResponse: "", + expectedError: errors.New("cannot increment -inf or +inf"), + }, + { + name: "9. Return error when trying to increment a member that already has score +inf", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: sorted_set.Score(math.Inf(1))}, + }), + key: "ZincrbyKey9", + command: []string{"ZINCRBY", "ZincrbyKey9", "2.5", "one"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: sorted_set.Score(math.Inf(-1))}, + }), + expectedResponse: "", + expectedError: errors.New("cannot increment -inf or +inf"), + }, + { + name: "10. Return error when increment is not a valid number", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, + }), + key: "ZincrbyKey10", + command: []string{"ZINCRBY", "ZincrbyKey10", "increment", "one"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, + }), + expectedResponse: "", + expectedError: errors.New("increment must be a double"), + }, + { + name: "11. Command too short", + key: "ZincrbyKey11", + command: []string{"ZINCRBY", "ZincrbyKey11", "one"}, + expectedResponse: "", + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "12. Command too long", + key: "ZincrbyKey12", + command: []string{"ZINCRBY", "ZincrbyKey12", "one", "1", "2"}, + expectedResponse: "", + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(test.key)} + for _, member := range test.presetValue.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(test.presetValue.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.String() != test.expectedResponse { + t.Errorf("expected response \"%s\", got \"%s\"", test.expectedResponse, res.String()) + } + + // Check if the resulting sorted set has the expected members/scores + if test.expectedValue == nil { + return + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("ZRANGE"), + resp.StringValue(test.key), + resp.StringValue("-inf"), + resp.StringValue("+inf"), + resp.StringValue("BYSCORE"), + resp.StringValue("WITHSCORES"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != test.expectedValue.Cardinality() { + t.Errorf("expected resulting set %s to have cardinality %d, got %d", + test.key, test.expectedValue.Cardinality(), len(res.Array())) + } + + for _, member := range res.Array() { + value := sorted_set.Value(member.Array()[0].String()) + score := sorted_set.Score(member.Array()[1].Float()) + if !test.expectedValue.Contains(value) { + t.Errorf("unexpected value %s in resulting sorted set", value) + } + if test.expectedValue.Get(value).Score != score { + t.Errorf("expected value %s to have score %v, got %v", value, test.expectedValue.Get(value).Score, score) + } + } + }) + } + }) + + t.Run("Test_HandleZMPOP", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + preset bool + presetValues map[string]interface{} + command []string + expectedValues map[string]*sorted_set.SortedSet + expectedResponse [][]string + expectedError error + }{ + { + name: "1. Successfully pop one min element by default", + preset: true, + presetValues: map[string]interface{}{ + "ZmpopKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + }, + command: []string{"ZMPOP", "ZmpopKey1"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZmpopKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + }, + expectedResponse: [][]string{ + {"one", "1"}, + }, + expectedError: nil, + }, + { + name: "2. Successfully pop one min element by specifying MIN", + preset: true, + presetValues: map[string]interface{}{ + "ZmpopKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + }, + command: []string{"ZMPOP", "ZmpopKey2", "MIN"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZmpopKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + }, + expectedResponse: [][]string{ + {"one", "1"}, + }, + expectedError: nil, + }, + { + name: "3. Successfully pop one max element by specifying MAX modifier", + preset: true, + presetValues: map[string]interface{}{ + "ZmpopKey3": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + }, + command: []string{"ZMPOP", "ZmpopKey3", "MAX"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZmpopKey3": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + }), + }, + expectedResponse: [][]string{ + {"five", "5"}, + }, + expectedError: nil, + }, + { + name: "4. Successfully pop multiple min elements", + preset: true, + presetValues: map[string]interface{}{ + "ZmpopKey4": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + }), + }, + command: []string{"ZMPOP", "ZmpopKey4", "MIN", "COUNT", "5"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZmpopKey4": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "six", Score: 6}, + }), + }, + expectedResponse: [][]string{ + {"one", "1"}, {"two", "2"}, {"three", "3"}, + {"four", "4"}, {"five", "5"}, + }, + expectedError: nil, + }, + { + name: "5. Successfully pop multiple max elements", + preset: true, + presetValues: map[string]interface{}{ + "ZmpopKey5": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + }), + }, + command: []string{"ZMPOP", "ZmpopKey5", "MAX", "COUNT", "5"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZmpopKey5": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, + }), + }, + expectedResponse: [][]string{{"two", "2"}, {"three", "3"}, {"four", "4"}, {"five", "5"}, {"six", "6"}}, + expectedError: nil, + }, + { + name: "6. Successfully pop elements from the first set which is non-empty", + preset: true, + presetValues: map[string]interface{}{ + "ZmpopKey7": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + }), + }, + command: []string{"ZMPOP", "ZmpopKey6", "ZmpopKey7", "MAX", "COUNT", "5"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZmpopKey6": sorted_set.NewSortedSet([]sorted_set.MemberParam{}), + "ZmpopKey7": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, + }), + }, + expectedResponse: [][]string{{"two", "2"}, {"three", "3"}, {"four", "4"}, {"five", "5"}, {"six", "6"}}, + expectedError: nil, + }, + { + name: "7. Skip the non-set items and pop elements from the first non-empty sorted set found", + preset: true, + presetValues: map[string]interface{}{ + "ZmpopKey8": "Default value", + "ZmpopKey9": "56", + "ZmpopKey11": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + }), + }, + command: []string{"ZMPOP", "ZmpopKey8", "ZmpopKey9", "ZmpopKey10", "ZmpopKey11", "MIN", "COUNT", "5"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZmpopKey10": sorted_set.NewSortedSet([]sorted_set.MemberParam{}), + "ZmpopKey11": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "six", Score: 6}, + }), + }, + expectedResponse: [][]string{{"one", "1"}, {"two", "2"}, {"three", "3"}, {"four", "4"}, {"five", "5"}}, + expectedError: nil, + }, + { + name: "9. Return error when count is a negative integer", + preset: false, + command: []string{"ZMPOP", "ZmpopKey8", "MAX", "COUNT", "-20"}, + expectedError: errors.New("count must be a positive integer"), + }, + { + name: "9. Command too short", + preset: false, + command: []string{"ZMPOP"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response array of length %d, got %d", len(test.expectedResponse), len(res.Array())) + } + + for _, item := range res.Array() { + value := item.Array()[0].String() + score := func() string { + if len(item.Array()) == 2 { + return item.Array()[1].String() + } + return "" + }() + if !slices.ContainsFunc(test.expectedResponse, func(expected []string) bool { + return expected[0] == value + }) { + t.Errorf("unexpected member \"%s\" in response", value) + } + if score != "" { + for _, expected := range test.expectedResponse { + if expected[0] == value && expected[1] != score { + t.Errorf("expected score for member \"%s\" to be %s, got %s", value, expected[1], score) + } + } + } + } + + // Check if the resulting sorted set has the expected members/scores + for key, expectedSortedSet := range test.expectedValues { + if expectedSortedSet == nil { + continue + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("ZRANGE"), + resp.StringValue(key), + resp.StringValue("-inf"), + resp.StringValue("+inf"), + resp.StringValue("BYSCORE"), + resp.StringValue("WITHSCORES"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != expectedSortedSet.Cardinality() { + t.Errorf("expected resulting set %s to have cardinality %d, got %d", + key, expectedSortedSet.Cardinality(), len(res.Array())) + } + + for _, member := range res.Array() { + value := sorted_set.Value(member.Array()[0].String()) + score := sorted_set.Score(member.Array()[1].Float()) + if !expectedSortedSet.Contains(value) { + t.Errorf("unexpected value %s in resulting sorted set", value) + } + if expectedSortedSet.Get(value).Score != score { + t.Errorf("expected value %s to have score %v, got %v", + value, expectedSortedSet.Get(value).Score, score) + } + } + } + }) + } + }) + + t.Run("Test_HandleZPOP", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + preset bool + presetValues map[string]interface{} + command []string + expectedValues map[string]*sorted_set.SortedSet + expectedResponse [][]string + expectedError error + }{ + { + name: "1. Successfully pop one min element by default", + preset: true, + presetValues: map[string]interface{}{ + "ZmpopMinKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + }, + command: []string{"ZPOPMIN", "ZmpopMinKey1"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZmpopMinKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + }, + expectedResponse: [][]string{ + {"one", "1"}, + }, + expectedError: nil, + }, + { + name: "2. Successfully pop one max element by default", + preset: true, + presetValues: map[string]interface{}{ + "ZmpopMaxKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + }, + command: []string{"ZPOPMAX", "ZmpopMaxKey2"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZmpopMaxKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + }), + }, + expectedResponse: [][]string{ + {"five", "5"}, + }, + expectedError: nil, + }, + { + name: "3. Successfully pop multiple min elements", + preset: true, + presetValues: map[string]interface{}{ + "ZmpopMinKey3": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + }), + }, + command: []string{"ZPOPMIN", "ZmpopMinKey3", "5"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZmpopMinKey3": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "six", Score: 6}, + }), + }, + expectedResponse: [][]string{ + {"one", "1"}, {"two", "2"}, {"three", "3"}, + {"four", "4"}, {"five", "5"}, + }, + expectedError: nil, + }, + { + name: "4. Successfully pop multiple max elements", + preset: true, + presetValues: map[string]interface{}{ + "ZmpopMaxKey4": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + }), + }, + command: []string{"ZPOPMAX", "ZmpopMaxKey4", "5"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZmpopMaxKey4": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, + }), + }, + expectedResponse: [][]string{{"two", "2"}, {"three", "3"}, {"four", "4"}, {"five", "5"}, {"six", "6"}}, + expectedError: nil, + }, + { + name: "5. Throw an error when trying to pop from an element that's not a sorted set", + preset: true, + presetValues: map[string]interface{}{ + "ZmpopMinKey5": "Default value", + }, + command: []string{"ZPOPMIN", "ZmpopMinKey5"}, + expectedValues: nil, + expectedResponse: nil, + expectedError: errors.New("value at key ZmpopMinKey5 is not a sorted set"), + }, + { + name: "6. Command too short", + preset: false, + command: []string{"ZPOPMAX"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "7. Command too long", + preset: false, + command: []string{"ZPOPMAX", "ZmpopMaxKey7", "6", "3"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response array of length %d, got %d", len(test.expectedResponse), len(res.Array())) + } + + for _, item := range res.Array() { + value := item.Array()[0].String() + score := func() string { + if len(item.Array()) == 2 { + return item.Array()[1].String() + } + return "" + }() + if !slices.ContainsFunc(test.expectedResponse, func(expected []string) bool { + return expected[0] == value + }) { + t.Errorf("unexpected member \"%s\" in response", value) + } + if score != "" { + for _, expected := range test.expectedResponse { + if expected[0] == value && expected[1] != score { + t.Errorf("expected score for member \"%s\" to be %s, got %s", value, expected[1], score) + } + } + } + } + + // Check if the resulting sorted set has the expected members/scores + for key, expectedSortedSet := range test.expectedValues { + if expectedSortedSet == nil { + continue + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("ZRANGE"), + resp.StringValue(key), + resp.StringValue("-inf"), + resp.StringValue("+inf"), + resp.StringValue("BYSCORE"), + resp.StringValue("WITHSCORES"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != expectedSortedSet.Cardinality() { + t.Errorf("expected resulting set %s to have cardinality %d, got %d", + key, expectedSortedSet.Cardinality(), len(res.Array())) + } + + for _, member := range res.Array() { + value := sorted_set.Value(member.Array()[0].String()) + score := sorted_set.Score(member.Array()[1].Float()) + if !expectedSortedSet.Contains(value) { + t.Errorf("unexpected value %s in resulting sorted set", value) + } + if expectedSortedSet.Get(value).Score != score { + t.Errorf("expected value %s to have score %v, got %v", + value, expectedSortedSet.Get(value).Score, score) + } + } + } + }) + } + }) + + t.Run("Test_HandleZMSCORE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedResponse []string + expectedError error + }{ + { + // 1. Return multiple scores from the sorted set. + // Return nil for elements that do not exist in the sorted set. + name: "1. Return multiple scores from the sorted set.", + presetValues: map[string]interface{}{ + "ZmScoreKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1.1}, {Value: "two", Score: 245}, + {Value: "three", Score: 3}, {Value: "four", Score: 4.055}, + {Value: "five", Score: 5}, + }), + }, + command: []string{"ZMSCORE", "ZmScoreKey1", "one", "none", "two", "one", "three", "four", "none", "five"}, + expectedResponse: []string{"1.1", "", "245", "1.1", "3", "4.055", "", "5"}, + expectedError: nil, + }, + { + name: "2. If key does not exist, return empty array", + presetValues: nil, + command: []string{"ZMSCORE", "ZmScoreKey2", "one", "two", "three", "four"}, + expectedResponse: []string{}, + expectedError: nil, + }, + { + name: "3. Throw error when trying to find scores from elements that are not sorted sets", + presetValues: map[string]interface{}{"ZmScoreKey3": "Default value"}, + command: []string{"ZMSCORE", "ZmScoreKey3", "one", "two", "three"}, + expectedError: errors.New("value at ZmScoreKey3 is not a sorted set"), + }, + { + name: "9. Command too short", + command: []string{"ZMSCORE"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response array of length %d, got %d", len(test.expectedResponse), len(res.Array())) + } + + for i := 0; i < len(res.Array()); i++ { + if test.expectedResponse[i] != res.Array()[i].String() { + t.Errorf("expected element at index %d to be \"%s\", got %s", + i, test.expectedResponse[i], res.Array()[i].String()) + } + } + }) + } + }) + + t.Run("Test_HandleZSCORE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedResponse string + expectedError error + }{ + { + name: "1. Return score from a sorted set.", + presetValues: map[string]interface{}{ + "ZscoreKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1.1}, {Value: "two", Score: 245}, + {Value: "three", Score: 3}, {Value: "four", Score: 4.055}, + {Value: "five", Score: 5}, + }), + }, + command: []string{"ZSCORE", "ZscoreKey1", "four"}, + expectedResponse: "4.055", + expectedError: nil, + }, + { + name: "2. If key does not exist, return nil value", + presetValues: nil, + command: []string{"ZSCORE", "ZscoreKey2", "one"}, + expectedResponse: "", + expectedError: nil, + }, + { + name: "3. If key exists and is a sorted set, but the member does not exist, return nil", + presetValues: map[string]interface{}{ + "ZscoreKey3": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1.1}, {Value: "two", Score: 245}, + {Value: "three", Score: 3}, {Value: "four", Score: 4.055}, + {Value: "five", Score: 5}, + }), + }, + command: []string{"ZSCORE", "ZscoreKey3", "non-existent"}, + expectedResponse: "", + expectedError: nil, + }, + { + name: "4. Throw error when trying to find scores from elements that are not sorted sets", + presetValues: map[string]interface{}{"ZscoreKey4": "Default value"}, + command: []string{"ZSCORE", "ZscoreKey4", "one"}, + expectedError: errors.New("value at ZscoreKey4 is not a sorted set"), + }, + { + name: "5. Command too short", + command: []string{"ZSCORE"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Command too long", + command: []string{"ZSCORE", "ZscoreKey5", "one", "two"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if res.String() != test.expectedResponse { + t.Errorf("expected response \"%s\", got \"%s\"", test.expectedResponse, res.String()) + } + }) + } + }) + + t.Run("Test_HandleZRANDMEMBER", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedValue int // The final cardinality of the resulting set + allowRepeat bool + expectedResponse [][]string + expectedError error + }{ + { + // 1. Return multiple random elements without removing them. + // Count is positive, do not allow repeated elements + name: "1. Return multiple random elements without removing them.", + key: "ZrandMemberKey1", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + command: []string{"ZRANDMEMBER", "ZrandMemberKey1", "3"}, + expectedValue: 8, + allowRepeat: false, + expectedResponse: [][]string{ + {"one"}, {"two"}, {"three"}, {"four"}, + {"five"}, {"six"}, {"seven"}, {"eight"}, + }, + expectedError: nil, + }, + { + // 2. Return multiple random elements and their scores without removing them. + // Count is negative, so allow repeated numbers. + name: "2. Return multiple random elements and their scores without removing them.", + key: "ZrandMemberKey2", + presetValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + command: []string{"ZRANDMEMBER", "ZrandMemberKey2", "-5", "WITHSCORES"}, + expectedValue: 8, + allowRepeat: true, + expectedResponse: [][]string{ + {"one", "1"}, {"two", "2"}, {"three", "3"}, {"four", "4"}, + {"five", "5"}, {"six", "6"}, {"seven", "7"}, {"eight", "8"}, + }, + expectedError: nil, + }, + { + name: "3. Return error when the source key is not a sorted set.", + key: "ZrandMemberKey3", + presetValue: "Default value", + command: []string{"ZRANDMEMBER", "ZrandMemberKey3"}, + expectedValue: 0, + expectedError: errors.New("value at ZrandMemberKey3 is not a sorted set"), + }, + { + name: "5. Command too short", + command: []string{"ZRANDMEMBER"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Command too long", + command: []string{"ZRANDMEMBER", "source5", "source6", "member1", "member2"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "7. Throw error when count is not an integer", + command: []string{"ZRANDMEMBER", "ZrandMemberKey1", "count"}, + expectedError: errors.New("count must be an integer"), + }, + { + name: "8. Throw error when the fourth argument is not WITHSCORES", + command: []string{"ZRANDMEMBER", "ZrandMemberKey1", "8", "ANOTHER"}, + expectedError: errors.New("last option must be WITHSCORES"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != nil { + var command []resp.Value + var expected string + + switch test.presetValue.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(test.key)} + for _, member := range test.presetValue.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(test.presetValue.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + // Check that each of the returned elements is in the expected response. + for _, item := range res.Array() { + value := sorted_set.Value(item.Array()[0].String()) + if !slices.ContainsFunc(test.expectedResponse, func(expected []string) bool { + return expected[0] == string(value) + }) { + t.Errorf("unexected element \"%s\" in response", value) + } + for _, expected := range test.expectedResponse { + if len(item.Array()) != len(expected) { + t.Errorf("expected response for element \"%s\" to have length %d, got %d", + value, len(expected), len(item.Array())) + } + if expected[0] != string(value) { + continue + } + if len(expected) == 2 { + score := item.Array()[1].String() + if expected[1] != score { + t.Errorf("expected score for memebr \"%s\" to be %s, got %s", value, expected[1], score) + } + } + } + } + + // Check that allowRepeat determines whether elements are repeated or not. + if !test.allowRepeat { + ss := sorted_set.NewSortedSet([]sorted_set.MemberParam{}) + for _, item := range res.Array() { + member := sorted_set.Value(item.Array()[0].String()) + score := func() sorted_set.Score { + if len(item.Array()) == 2 { + return sorted_set.Score(item.Array()[1].Float()) + } + return sorted_set.Score(0) + }() + _, err = ss.AddOrUpdate( + []sorted_set.MemberParam{{member, score}}, + nil, nil, nil, nil) + if err != nil { + t.Error(err) + } + } + if len(res.Array()) != ss.Cardinality() { + t.Error("unexpected repeated elements in response") + } + } + }) + } + }) + + t.Run("Test_HandleZRANK", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedResponse []string + expectedError error + }{ + { + name: "1. Return element's rank from a sorted set.", + presetValues: map[string]interface{}{ + "ZrankKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + }, + command: []string{"ZRANK", "ZrankKey1", "four"}, + expectedResponse: []string{"3"}, + expectedError: nil, + }, + { + name: "2. Return element's rank from a sorted set with its score.", + presetValues: map[string]interface{}{ + "ZrankKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100.1}, {Value: "two", Score: 245}, + {Value: "three", Score: 305.43}, {Value: "four", Score: 411.055}, + {Value: "five", Score: 500}, + }), + }, + command: []string{"ZRANK", "ZrankKey1", "four", "WITHSCORES"}, + expectedResponse: []string{"3", "411.055"}, + expectedError: nil, + }, + { + name: "3. If key does not exist, return nil value", + presetValues: nil, + command: []string{"ZRANK", "ZrankKey3", "one"}, + expectedResponse: nil, + expectedError: nil, + }, + { + name: "4. If key exists and is a sorted set, but the member does not exist, return nil", + presetValues: map[string]interface{}{ + "ZrankKey4": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1.1}, {Value: "two", Score: 245}, + {Value: "three", Score: 3}, {Value: "four", Score: 4.055}, + {Value: "five", Score: 5}, + }), + }, + command: []string{"ZRANK", "ZrankKey4", "non-existent"}, + expectedResponse: nil, + expectedError: nil, + }, + { + name: "5. Throw error when trying to find scores from elements that are not sorted sets", + presetValues: map[string]interface{}{"ZrankKey5": "Default value"}, + command: []string{"ZRANK", "ZrankKey5", "one"}, + expectedError: errors.New("value at ZrankKey5 is not a sorted set"), + }, + { + name: "5. Command too short", + command: []string{"ZRANK"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Command too long", + command: []string{"ZRANK", "ZrankKey5", "one", "WITHSCORES", "two"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response array of length %d, got %d", len(test.expectedResponse), len(res.Array())) + } + + for i := 0; i < len(res.Array()); i++ { + if test.expectedResponse[i] != res.Array()[i].String() { + t.Errorf("expected element at index %d to be \"%s\", got %s", + i, test.expectedResponse[i], res.Array()[i].String()) + } + } + }) + } + }) + + t.Run("Test_HandleZREM", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedValues map[string]*sorted_set.SortedSet + expectedResponse int + expectedError error + }{ + { + // Successfully remove multiple elements from sorted set, skipping non-existent members. + // Return deleted count. + name: "1. Successfully remove multiple elements from sorted set, skipping non-existent members.", + presetValues: map[string]interface{}{ + "ZremKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + }, + command: []string{"ZREM", "ZremKey1", "three", "four", "five", "none", "six", "none", "seven"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZremKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + }, + expectedResponse: 5, + expectedError: nil, + }, + { + name: "2. If key does not exist, return 0", + presetValues: nil, + command: []string{"ZREM", "ZremKey2", "member"}, + expectedValues: nil, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "3. Return error key is not a sorted set", + presetValues: map[string]interface{}{ + "ZremKey3": "Default value", + }, + command: []string{"ZREM", "ZremKey3", "member"}, + expectedError: errors.New("value at ZremKey3 is not a sorted set"), + }, + { + name: "9. Command too short", + command: []string{"ZREM"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response array of length %d, got %d", test.expectedResponse, res.Integer()) + } + + // Check if the resulting sorted set has the expected members/scores + for key, expectedSortedSet := range test.expectedValues { + if expectedSortedSet == nil { + continue + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("ZRANGE"), + resp.StringValue(key), + resp.StringValue("-inf"), + resp.StringValue("+inf"), + resp.StringValue("BYSCORE"), + resp.StringValue("WITHSCORES"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != expectedSortedSet.Cardinality() { + t.Errorf("expected resulting set %s to have cardinality %d, got %d", + key, expectedSortedSet.Cardinality(), len(res.Array())) + } + + for _, member := range res.Array() { + value := sorted_set.Value(member.Array()[0].String()) + score := sorted_set.Score(member.Array()[1].Float()) + if !expectedSortedSet.Contains(value) { + t.Errorf("unexpected value %s in resulting sorted set", value) + } + if expectedSortedSet.Get(value).Score != score { + t.Errorf("expected value %s to have score %v, got %v", + value, expectedSortedSet.Get(value).Score, score) + } + } + } + }) + } + }) + + t.Run("Test_HandleZREMRANGEBYSCORE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedValues map[string]*sorted_set.SortedSet + expectedResponse int + expectedError error + }{ + { + name: "1. Successfully remove multiple elements with scores inside the provided range", + presetValues: map[string]interface{}{ + "ZremRangeByScoreKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + }, + command: []string{"ZREMRANGEBYSCORE", "ZremRangeByScoreKey1", "3", "7"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZremRangeByScoreKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + }, + expectedResponse: 5, + expectedError: nil, + }, + { + name: "2. If key does not exist, return 0", + presetValues: nil, + command: []string{"ZREMRANGEBYSCORE", "ZremRangeByScoreKey2", "2", "4"}, + expectedValues: nil, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "3. Return error key is not a sorted set", + presetValues: map[string]interface{}{ + "ZremRangeByScoreKey3": "Default value", + }, + command: []string{"ZREMRANGEBYSCORE", "ZremRangeByScoreKey3", "4", "4"}, + expectedError: errors.New("value at ZremRangeByScoreKey3 is not a sorted set"), + }, + { + name: "4. Command too short", + command: []string{"ZREMRANGEBYSCORE", "ZremRangeByScoreKey4", "3"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "5. Command too long", + command: []string{"ZREMRANGEBYSCORE", "ZremRangeByScoreKey5", "4", "5", "8"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response array of length %d, got %d", test.expectedResponse, res.Integer()) + } + + // Check if the resulting sorted set has the expected members/scores + for key, expectedSortedSet := range test.expectedValues { + if expectedSortedSet == nil { + continue + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("ZRANGE"), + resp.StringValue(key), + resp.StringValue("-inf"), + resp.StringValue("+inf"), + resp.StringValue("BYSCORE"), + resp.StringValue("WITHSCORES"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != expectedSortedSet.Cardinality() { + t.Errorf("expected resulting set %s to have cardinality %d, got %d", + key, expectedSortedSet.Cardinality(), len(res.Array())) + } + + for _, member := range res.Array() { + value := sorted_set.Value(member.Array()[0].String()) + score := sorted_set.Score(member.Array()[1].Float()) + if !expectedSortedSet.Contains(value) { + t.Errorf("unexpected value %s in resulting sorted set", value) + } + if expectedSortedSet.Get(value).Score != score { + t.Errorf("expected value %s to have score %v, got %v", + value, expectedSortedSet.Get(value).Score, score) + } + } + } + }) + } + }) + + t.Run("Test_HandleZREMRANGEBYRANK", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedValues map[string]*sorted_set.SortedSet + expectedResponse int + expectedError error + }{ + { + name: "1. Successfully remove multiple elements within range", + presetValues: map[string]interface{}{ + "ZremRangeByRankKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + }, + command: []string{"ZREMRANGEBYRANK", "ZremRangeByRankKey1", "0", "5"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZremRangeByRankKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + }, + expectedResponse: 6, + expectedError: nil, + }, + { + name: "2. Establish boundaries from the end of the set when negative boundaries are provided", + presetValues: map[string]interface{}{ + "ZremRangeByRankKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + }, + command: []string{"ZREMRANGEBYRANK", "ZremRangeByRankKey2", "-6", "-3"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZremRangeByRankKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + }, + expectedResponse: 4, + expectedError: nil, + }, + { + name: "3. If key does not exist, return 0", + presetValues: nil, + command: []string{"ZREMRANGEBYRANK", "ZremRangeByRankKey3", "2", "4"}, + expectedValues: nil, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "4. Return error key is not a sorted set", + presetValues: map[string]interface{}{ + "ZremRangeByRankKey3": "Default value", + }, + command: []string{"ZREMRANGEBYRANK", "ZremRangeByRankKey3", "4", "4"}, + expectedError: errors.New("value at ZremRangeByRankKey3 is not a sorted set"), + }, + { + name: "5. Return error when start index is out of bounds", + presetValues: map[string]interface{}{ + "ZremRangeByRankKey5": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + }, + command: []string{"ZREMRANGEBYRANK", "ZremRangeByRankKey5", "-12", "5"}, + expectedValues: nil, + expectedResponse: 0, + expectedError: errors.New("indices out of bounds"), + }, + { + name: "6. Return error when end index is out of bounds", + presetValues: map[string]interface{}{ + "ZremRangeByRankKey6": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + }, + command: []string{"ZREMRANGEBYRANK", "ZremRangeByRankKey6", "0", "11"}, + expectedValues: nil, + expectedResponse: 0, + expectedError: errors.New("indices out of bounds"), + }, + { + name: "7. Command too short", + command: []string{"ZREMRANGEBYRANK", "ZremRangeByRankKey4", "3"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "8. Command too long", + command: []string{"ZREMRANGEBYRANK", "ZremRangeByRankKey7", "4", "5", "8"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response array of length %d, got %d", test.expectedResponse, res.Integer()) + } + + // Check if the resulting sorted set has the expected members/scores + for key, expectedSortedSet := range test.expectedValues { + if expectedSortedSet == nil { + continue + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("ZRANGE"), + resp.StringValue(key), + resp.StringValue("-inf"), + resp.StringValue("+inf"), + resp.StringValue("BYSCORE"), + resp.StringValue("WITHSCORES"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != expectedSortedSet.Cardinality() { + t.Errorf("expected resulting set %s to have cardinality %d, got %d", + key, expectedSortedSet.Cardinality(), len(res.Array())) + } + + for _, member := range res.Array() { + value := sorted_set.Value(member.Array()[0].String()) + score := sorted_set.Score(member.Array()[1].Float()) + if !expectedSortedSet.Contains(value) { + t.Errorf("unexpected value %s in resulting sorted set", value) + } + if expectedSortedSet.Get(value).Score != score { + t.Errorf("expected value %s to have score %v, got %v", + value, expectedSortedSet.Get(value).Score, score) + } + } + } + }) + } + }) + + t.Run("Test_HandleZREMRANGEBYLEX", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedValues map[string]*sorted_set.SortedSet + expectedResponse int + expectedError error + }{ + { + name: "1. Successfully remove multiple elements with scores inside the provided range", + presetValues: map[string]interface{}{ + "ZremRangeByLexKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 1}, + {Value: "c", Score: 1}, {Value: "d", Score: 1}, + {Value: "e", Score: 1}, {Value: "f", Score: 1}, + {Value: "g", Score: 1}, {Value: "h", Score: 1}, + {Value: "i", Score: 1}, {Value: "j", Score: 1}, + }), + }, + command: []string{"ZREMRANGEBYLEX", "ZremRangeByLexKey1", "a", "d"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZremRangeByLexKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "e", Score: 1}, {Value: "f", Score: 1}, + {Value: "g", Score: 1}, {Value: "h", Score: 1}, + {Value: "i", Score: 1}, {Value: "j", Score: 1}, + }), + }, + expectedResponse: 4, + expectedError: nil, + }, + { + name: "2. Return 0 if the members do not have the same score", + presetValues: map[string]interface{}{ + "ZremRangeByLexKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 2}, + {Value: "c", Score: 3}, {Value: "d", Score: 4}, + {Value: "e", Score: 5}, {Value: "f", Score: 6}, + {Value: "g", Score: 7}, {Value: "h", Score: 8}, + {Value: "i", Score: 9}, {Value: "j", Score: 10}, + }), + }, + command: []string{"ZREMRANGEBYLEX", "ZremRangeByLexKey2", "d", "g"}, + expectedValues: map[string]*sorted_set.SortedSet{ + "ZremRangeByLexKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 2}, + {Value: "c", Score: 3}, {Value: "d", Score: 4}, + {Value: "e", Score: 5}, {Value: "f", Score: 6}, + {Value: "g", Score: 7}, {Value: "h", Score: 8}, + {Value: "i", Score: 9}, {Value: "j", Score: 10}, + }), + }, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "3. If key does not exist, return 0", + presetValues: nil, + command: []string{"ZREMRANGEBYLEX", "ZremRangeByLexKey3", "2", "4"}, + expectedValues: nil, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "4. Return error key is not a sorted set", + presetValues: map[string]interface{}{ + "ZremRangeByLexKey3": "Default value", + }, + command: []string{"ZREMRANGEBYLEX", "ZremRangeByLexKey3", "a", "d"}, + expectedError: errors.New("value at ZremRangeByLexKey3 is not a sorted set"), + }, + { + name: "5. Command too short", + command: []string{"ZREMRANGEBYLEX", "ZremRangeByLexKey4", "a"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "6. Command too long", + command: []string{"ZREMRANGEBYLEX", "ZremRangeByLexKey5", "a", "b", "c"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response array of length %d, got %d", test.expectedResponse, res.Integer()) + } + + // Check if the resulting sorted set has the expected members/scores + for key, expectedSortedSet := range test.expectedValues { + if expectedSortedSet == nil { + continue + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("ZRANGE"), + resp.StringValue(key), + resp.StringValue("-inf"), + resp.StringValue("+inf"), + resp.StringValue("BYSCORE"), + resp.StringValue("WITHSCORES"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != expectedSortedSet.Cardinality() { + t.Errorf("expected resulting set %s to have cardinality %d, got %d", + key, expectedSortedSet.Cardinality(), len(res.Array())) + } + + for _, member := range res.Array() { + value := sorted_set.Value(member.Array()[0].String()) + score := sorted_set.Score(member.Array()[1].Float()) + if !expectedSortedSet.Contains(value) { + t.Errorf("unexpected value %s in resulting sorted set", value) + } + if expectedSortedSet.Get(value).Score != score { + t.Errorf("expected value %s to have score %v, got %v", + value, expectedSortedSet.Get(value).Score, score) + } + } + } + }) + } + }) + + t.Run("Test_HandleZRANGE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedResponse [][]string + expectedError error + }{ + { + name: "1. Get elements withing score range without score.", + presetValues: map[string]interface{}{ + "ZrangeKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + command: []string{"ZRANGE", "ZrangeKey1", "3", "7", "BYSCORE"}, + expectedResponse: [][]string{{"three"}, {"four"}, {"five"}, {"six"}, {"seven"}}, + expectedError: nil, + }, + { + name: "2. Get elements within score range with score.", + presetValues: map[string]interface{}{ + "ZrangeKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + command: []string{"ZRANGE", "ZrangeKey2", "3", "7", "BYSCORE", "WITHSCORES"}, + expectedResponse: [][]string{ + {"three", "3"}, {"four", "4"}, {"five", "5"}, + {"six", "6"}, {"seven", "7"}}, + expectedError: nil, + }, + { + // 3. Get elements within score range with offset and limit. + // Offset and limit are in where we start and stop counting in the original sorted set (NOT THE RESULT). + name: "3. Get elements within score range with offset and limit.", + presetValues: map[string]interface{}{ + "ZrangeKey3": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + command: []string{"ZRANGE", "ZrangeKey3", "3", "7", "BYSCORE", "WITHSCORES", "LIMIT", "2", "4"}, + expectedResponse: [][]string{{"three", "3"}, {"four", "4"}, {"five", "5"}}, + expectedError: nil, + }, + { + // 4. Get elements within score range with offset and limit + reverse the results. + // Offset and limit are in where we start and stop counting in the original sorted set (NOT THE RESULT). + // REV reverses the original set before getting the range. + name: "4. Get elements within score range with offset and limit + reverse the results.", + presetValues: map[string]interface{}{ + "ZrangeKey4": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + command: []string{"ZRANGE", "ZrangeKey4", "3", "7", "BYSCORE", "WITHSCORES", "LIMIT", "2", "4", "REV"}, + expectedResponse: [][]string{{"six", "6"}, {"five", "5"}, {"four", "4"}}, + expectedError: nil, + }, + { + name: "5. Get elements within lex range without score.", + presetValues: map[string]interface{}{ + "ZrangeKey5": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "e", Score: 1}, + {Value: "b", Score: 1}, {Value: "f", Score: 1}, + {Value: "c", Score: 1}, {Value: "g", Score: 1}, + {Value: "d", Score: 1}, {Value: "h", Score: 1}, + }), + }, + command: []string{"ZRANGE", "ZrangeKey5", "c", "g", "BYLEX"}, + expectedResponse: [][]string{{"c"}, {"d"}, {"e"}, {"f"}, {"g"}}, + expectedError: nil, + }, + { + name: "6. Get elements within lex range with score.", + presetValues: map[string]interface{}{ + "ZrangeKey6": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "e", Score: 1}, + {Value: "b", Score: 1}, {Value: "f", Score: 1}, + {Value: "c", Score: 1}, {Value: "g", Score: 1}, + {Value: "d", Score: 1}, {Value: "h", Score: 1}, + }), + }, + command: []string{"ZRANGE", "ZrangeKey6", "a", "f", "BYLEX", "WITHSCORES"}, + expectedResponse: [][]string{ + {"a", "1"}, {"b", "1"}, {"c", "1"}, + {"d", "1"}, {"e", "1"}, {"f", "1"}}, + expectedError: nil, + }, + { + // 7. Get elements within lex range with offset and limit. + // Offset and limit are in where we start and stop counting in the original sorted set (NOT THE RESULT). + name: "7. Get elements within lex range with offset and limit.", + presetValues: map[string]interface{}{ + "ZrangeKey7": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 1}, + {Value: "c", Score: 1}, {Value: "d", Score: 1}, + {Value: "e", Score: 1}, {Value: "f", Score: 1}, + {Value: "g", Score: 1}, {Value: "h", Score: 1}, + }), + }, + command: []string{"ZRANGE", "ZrangeKey7", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "2", "4"}, + expectedResponse: [][]string{{"c", "1"}, {"d", "1"}, {"e", "1"}}, + expectedError: nil, + }, + { + // 8. Get elements within lex range with offset and limit + reverse the results. + // Offset and limit are in where we start and stop counting in the original sorted set (NOT THE RESULT). + // REV reverses the original set before getting the range. + name: "8. Get elements within lex range with offset and limit + reverse the results.", + presetValues: map[string]interface{}{ + "ZrangeKey8": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 1}, + {Value: "c", Score: 1}, {Value: "d", Score: 1}, + {Value: "e", Score: 1}, {Value: "f", Score: 1}, + {Value: "g", Score: 1}, {Value: "h", Score: 1}, + }), + }, + command: []string{"ZRANGE", "ZrangeKey8", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "2", "4", "REV"}, + expectedResponse: [][]string{{"f", "1"}, {"e", "1"}, {"d", "1"}}, + expectedError: nil, + }, + { + name: "9. Return an empty slice when we use BYLEX while elements have different scores", + presetValues: map[string]interface{}{ + "ZrangeKey9": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 5}, + {Value: "c", Score: 2}, {Value: "d", Score: 6}, + {Value: "e", Score: 3}, {Value: "f", Score: 7}, + {Value: "g", Score: 4}, {Value: "h", Score: 8}, + }), + }, + command: []string{"ZRANGE", "ZrangeKey9", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "2", "4"}, + expectedResponse: [][]string{}, + expectedError: nil, + }, + { + name: "10. Throw error when limit does not provide both offset and limit", + presetValues: nil, + command: []string{"ZRANGE", "ZrangeKey10", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "2"}, + expectedResponse: [][]string{}, + expectedError: errors.New("limit should contain offset and count as integers"), + }, + { + name: "11. Throw error when offset is not a valid integer", + presetValues: nil, + command: []string{"ZRANGE", "ZrangeKey11", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "offset", "4"}, + expectedResponse: [][]string{}, + expectedError: errors.New("limit offset must be integer"), + }, + { + name: "12. Throw error when limit is not a valid integer", + presetValues: nil, + command: []string{"ZRANGE", "ZrangeKey12", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "4", "limit"}, + expectedResponse: [][]string{}, + expectedError: errors.New("limit count must be integer"), + }, + { + name: "13. Throw error when offset is negative", + presetValues: nil, + command: []string{"ZRANGE", "ZrangeKey13", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "-4", "9"}, + expectedResponse: [][]string{}, + expectedError: errors.New("limit offset must be >= 0"), + }, + { + name: "14. Throw error when the key does not hold a sorted set", + presetValues: map[string]interface{}{ + "ZrangeKey14": "Default value", + }, + command: []string{"ZRANGE", "ZrangeKey14", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "2", "4"}, + expectedResponse: [][]string{}, + expectedError: errors.New("value at ZrangeKey14 is not a sorted set"), + }, + { + name: "15. Command too short", + presetValues: nil, + command: []string{"ZRANGE", "ZrangeKey15", "1"}, + expectedResponse: [][]string{}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "16. Command too long", + presetValues: nil, + command: []string{"ZRANGE", "ZrangeKey16", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "-4", "9", "REV", "WITHSCORES"}, + expectedResponse: [][]string{}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response array of length %d, got %d", len(test.expectedResponse), len(res.Array())) + } + + for _, item := range res.Array() { + value := item.Array()[0].String() + score := func() string { + if len(item.Array()) == 2 { + return item.Array()[1].String() + } + return "" + }() + if !slices.ContainsFunc(test.expectedResponse, func(expected []string) bool { + return expected[0] == value + }) { + t.Errorf("unexpected member \"%s\" in response", value) + } + if score != "" { + for _, expected := range test.expectedResponse { + if expected[0] == value && expected[1] != score { + t.Errorf("expected score for member \"%s\" to be %s, got %s", value, expected[1], score) + } + } + } + } + }) + } + }) + + t.Run("Test_HandleZRANGESTORE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + destination string + command []string + expectedValue *sorted_set.SortedSet + expectedResponse int + expectedError error + }{ + { + name: "1. Get elements withing score range without score.", + presetValues: map[string]interface{}{ + "ZrangeStoreKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "ZrangeStoreDestinationKey1", + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey1", "ZrangeStoreKey1", "3", "7", "BYSCORE"}, + expectedResponse: 5, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, {Value: "five", Score: 5}, + {Value: "six", Score: 6}, {Value: "seven", Score: 7}, + }), + expectedError: nil, + }, + { + name: "2. Get elements within score range with score.", + presetValues: map[string]interface{}{ + "ZrangeStoreKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "ZrangeStoreDestinationKey2", + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey2", "ZrangeStoreKey2", "3", "7", "BYSCORE", "WITHSCORES"}, + expectedResponse: 5, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, {Value: "five", Score: 5}, + {Value: "six", Score: 6}, {Value: "seven", Score: 7}, + }), + expectedError: nil, + }, + { + // 3. Get elements within score range with offset and limit. + // Offset and limit are in where we start and stop counting in the original sorted set (NOT THE RESULT). + name: "3. Get elements within score range with offset and limit.", + presetValues: map[string]interface{}{ + "ZrangeStoreKey3": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "ZrangeStoreDestinationKey3", + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey3", "ZrangeStoreKey3", "3", "7", "BYSCORE", "WITHSCORES", "LIMIT", "2", "4"}, + expectedResponse: 3, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, {Value: "five", Score: 5}, + }), + expectedError: nil, + }, + { + // 4. Get elements within score range with offset and limit + reverse the results. + // Offset and limit are in where we start and stop counting in the original sorted set (NOT THE RESULT). + // REV reverses the original set before getting the range. + name: "4. Get elements within score range with offset and limit + reverse the results.", + presetValues: map[string]interface{}{ + "ZrangeStoreKey4": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "ZrangeStoreDestinationKey4", + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey4", "ZrangeStoreKey4", "3", "7", "BYSCORE", "WITHSCORES", "LIMIT", "2", "4", "REV"}, + expectedResponse: 3, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "six", Score: 6}, {Value: "five", Score: 5}, {Value: "four", Score: 4}, + }), + expectedError: nil, + }, + { + name: "5. Get elements within lex range without score.", + presetValues: map[string]interface{}{ + "ZrangeStoreKey5": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "e", Score: 1}, + {Value: "b", Score: 1}, {Value: "f", Score: 1}, + {Value: "c", Score: 1}, {Value: "g", Score: 1}, + {Value: "d", Score: 1}, {Value: "h", Score: 1}, + }), + }, + destination: "ZrangeStoreDestinationKey5", + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey5", "ZrangeStoreKey5", "c", "g", "BYLEX"}, + expectedResponse: 5, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "c", Score: 1}, {Value: "d", Score: 1}, {Value: "e", Score: 1}, + {Value: "f", Score: 1}, {Value: "g", Score: 1}, + }), + expectedError: nil, + }, + { + name: "6. Get elements within lex range with score.", + presetValues: map[string]interface{}{ + "ZrangeStoreKey6": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "e", Score: 1}, + {Value: "b", Score: 1}, {Value: "f", Score: 1}, + {Value: "c", Score: 1}, {Value: "g", Score: 1}, + {Value: "d", Score: 1}, {Value: "h", Score: 1}, + }), + }, + destination: "ZrangeStoreDestinationKey6", + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey6", "ZrangeStoreKey6", "a", "f", "BYLEX", "WITHSCORES"}, + expectedResponse: 6, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 1}, {Value: "c", Score: 1}, + {Value: "d", Score: 1}, {Value: "e", Score: 1}, {Value: "f", Score: 1}, + }), + expectedError: nil, + }, + { + // 7. Get elements within lex range with offset and limit. + // Offset and limit are in where we start and stop counting in the original sorted set (NOT THE RESULT). + name: "7. Get elements within lex range with offset and limit.", + presetValues: map[string]interface{}{ + "ZrangeStoreKey7": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 1}, + {Value: "c", Score: 1}, {Value: "d", Score: 1}, + {Value: "e", Score: 1}, {Value: "f", Score: 1}, + {Value: "g", Score: 1}, {Value: "h", Score: 1}, + }), + }, + destination: "ZrangeStoreDestinationKey7", + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey7", "ZrangeStoreKey7", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "2", "4"}, + expectedResponse: 3, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "c", Score: 1}, {Value: "d", Score: 1}, {Value: "e", Score: 1}, + }), + expectedError: nil, + }, + { + // 8. Get elements within lex range with offset and limit + reverse the results. + // Offset and limit are in where we start and stop counting in the original sorted set (NOT THE RESULT). + // REV reverses the original set before getting the range. + name: "8. Get elements within lex range with offset and limit + reverse the results.", + presetValues: map[string]interface{}{ + "ZrangeStoreKey8": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 1}, + {Value: "c", Score: 1}, {Value: "d", Score: 1}, + {Value: "e", Score: 1}, {Value: "f", Score: 1}, + {Value: "g", Score: 1}, {Value: "h", Score: 1}, + }), + }, + destination: "ZrangeStoreDestinationKey8", + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey8", "ZrangeStoreKey8", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "2", "4", "REV"}, + expectedResponse: 3, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "f", Score: 1}, {Value: "e", Score: 1}, {Value: "d", Score: 1}, + }), + expectedError: nil, + }, + { + name: "9. Return an empty slice when we use BYLEX while elements have different scores", + presetValues: map[string]interface{}{ + "ZrangeStoreKey9": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 5}, + {Value: "c", Score: 2}, {Value: "d", Score: 6}, + {Value: "e", Score: 3}, {Value: "f", Score: 7}, + {Value: "g", Score: 4}, {Value: "h", Score: 8}, + }), + }, + destination: "ZrangeStoreDestinationKey9", + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey9", "ZrangeStoreKey9", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "2", "4"}, + expectedResponse: 0, + expectedValue: nil, + expectedError: nil, + }, + { + name: "10. Throw error when limit does not provide both offset and limit", + presetValues: nil, + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey10", "ZrangeStoreKey10", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "2"}, + expectedResponse: 0, + expectedError: errors.New("limit should contain offset and count as integers"), + }, + { + name: "11. Throw error when offset is not a valid integer", + presetValues: nil, + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey11", "ZrangeStoreKey11", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "offset", "4"}, + expectedResponse: 0, + expectedError: errors.New("limit offset must be integer"), + }, + { + name: "12. Throw error when limit is not a valid integer", + presetValues: nil, + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey12", "ZrangeStoreKey12", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "4", "limit"}, + expectedResponse: 0, + expectedError: errors.New("limit count must be integer"), + }, + { + name: "13. Throw error when offset is negative", + presetValues: nil, + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey13", "ZrangeStoreKey13", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "-4", "9"}, + expectedResponse: 0, + expectedError: errors.New("limit offset must be >= 0"), + }, + { + name: "14. Throw error when the key does not hold a sorted set", + presetValues: map[string]interface{}{ + "ZrangeStoreKey14": "Default value", + }, + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey14", "ZrangeStoreKey14", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "2", "4"}, + expectedResponse: 0, + expectedError: errors.New("value at ZrangeStoreKey14 is not a sorted set"), + }, + { + name: "15. Command too short", + presetValues: nil, + command: []string{"ZRANGESTORE", "ZrangeStoreKey15", "1"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "16 Command too long", + presetValues: nil, + command: []string{"ZRANGESTORE", "ZrangeStoreDestinationKey16", "ZrangeStoreKey16", "a", "h", "BYLEX", "WITHSCORES", "LIMIT", "-4", "9", "REV", "WITHSCORES"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + + // Check if the resulting sorted set has the expected members/scores + if test.expectedValue == nil { + return + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("ZRANGE"), + resp.StringValue(test.destination), + resp.StringValue("-inf"), + resp.StringValue("+inf"), + resp.StringValue("BYSCORE"), + resp.StringValue("WITHSCORES"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != test.expectedValue.Cardinality() { + t.Errorf("expected resulting set %s to have cardinality %d, got %d", + test.destination, test.expectedValue.Cardinality(), len(res.Array())) + } + + for _, member := range res.Array() { + value := sorted_set.Value(member.Array()[0].String()) + score := sorted_set.Score(member.Array()[1].Float()) + if !test.expectedValue.Contains(value) { + t.Errorf("unexpected value %s in resulting sorted set", value) + } + if test.expectedValue.Get(value).Score != score { + t.Errorf("expected value %s to have score %v, got %v", value, test.expectedValue.Get(value).Score, score) + } + } + }) + } + }) + + t.Run("Test_HandleZINTER", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedResponse [][]string + expectedError error + }{ + { + name: "1. Get the intersection between 2 sorted sets.", + presetValues: map[string]interface{}{ + "ZinterKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + "ZinterKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + command: []string{"ZINTER", "ZinterKey1", "ZinterKey2"}, + expectedResponse: [][]string{{"three"}, {"four"}, {"five"}}, + expectedError: nil, + }, + { + // 2. Get the intersection between 3 sorted sets with scores. + // By default, the SUM aggregate will be used. + name: "2. Get the intersection between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZinterKey3": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterKey4": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 8}, + }), + "ZinterKey5": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZINTER", "ZinterKey3", "ZinterKey4", "ZinterKey5", "WITHSCORES"}, + expectedResponse: [][]string{{"one", "3"}, {"eight", "24"}}, + expectedError: nil, + }, + { + // 3. Get the intersection between 3 sorted sets with scores. + // Use MIN aggregate. + name: "3. Get the intersection between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZinterKey6": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterKey7": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZinterKey8": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZINTER", "ZinterKey6", "ZinterKey7", "ZinterKey8", "WITHSCORES", "AGGREGATE", "MIN"}, + expectedResponse: [][]string{{"one", "1"}, {"eight", "8"}}, + expectedError: nil, + }, + { + // 4. Get the intersection between 3 sorted sets with scores. + // Use MAX aggregate. + name: "4. Get the intersection between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZinterKey9": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterKey10": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZinterKey11": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZINTER", "ZinterKey9", "ZinterKey10", "ZinterKey11", "WITHSCORES", "AGGREGATE", "MAX"}, + expectedResponse: [][]string{{"one", "1000"}, {"eight", "800"}}, + expectedError: nil, + }, + { + // 5. Get the intersection between 3 sorted sets with scores. + // Use SUM aggregate with weights modifier. + name: "5. Get the intersection between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZinterKey12": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterKey13": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZinterKey14": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZINTER", "ZinterKey12", "ZinterKey13", "ZinterKey14", "WITHSCORES", "AGGREGATE", "SUM", "WEIGHTS", "1", "5", "3"}, + expectedResponse: [][]string{{"one", "3105"}, {"eight", "2808"}}, + expectedError: nil, + }, + { + // 6. Get the intersection between 3 sorted sets with scores. + // Use MAX aggregate with added weights. + name: "6. Get the intersection between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZinterKey15": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterKey16": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZinterKey17": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZINTER", "ZinterKey15", "ZinterKey16", "ZinterKey17", "WITHSCORES", "AGGREGATE", "MAX", "WEIGHTS", "1", "5", "3"}, + expectedResponse: [][]string{{"one", "3000"}, {"eight", "2400"}}, + expectedError: nil, + }, + { + // 7. Get the intersection between 3 sorted sets with scores. + // Use MIN aggregate with added weights. + name: "7. Get the intersection between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZinterKey18": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterKey19": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZinterKey20": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZINTER", "ZinterKey18", "ZinterKey19", "ZinterKey20", "WITHSCORES", "AGGREGATE", "MIN", "WEIGHTS", "1", "5", "3"}, + expectedResponse: [][]string{{"one", "5"}, {"eight", "8"}}, + expectedError: nil, + }, + { + name: "8. Throw an error if there are more weights than keys", + presetValues: map[string]interface{}{ + "ZinterKey21": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterKey22": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + command: []string{"ZINTER", "ZinterKey21", "ZinterKey22", "WEIGHTS", "1", "2", "3"}, + expectedResponse: nil, + expectedError: errors.New("number of weights should match number of keys"), + }, + { + name: "9. Throw an error if there are fewer weights than keys", + presetValues: map[string]interface{}{ + "ZinterKey23": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterKey24": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + }), + "ZinterKey25": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + command: []string{"ZINTER", "ZinterKey23", "ZinterKey24", "ZinterKey25", "WEIGHTS", "5", "4"}, + expectedResponse: nil, + expectedError: errors.New("number of weights should match number of keys"), + }, + { + name: "10. Throw an error if there are no keys provided", + presetValues: map[string]interface{}{ + "ZinterKey26": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + "ZinterKey27": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + "ZinterKey28": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + command: []string{"ZINTER", "WEIGHTS", "5", "4"}, + expectedResponse: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "11. Throw an error if any of the provided keys are not sorted sets", + presetValues: map[string]interface{}{ + "ZinterKey29": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterKey30": "Default value", + "ZinterKey31": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + command: []string{"ZINTER", "ZinterKey29", "ZinterKey30", "ZinterKey31"}, + expectedResponse: nil, + expectedError: errors.New("value at ZinterKey30 is not a sorted set"), + }, + { + name: "12. If any of the keys does not exist, return an empty array.", + presetValues: map[string]interface{}{ + "ZinterKey32": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "ZinterKey33": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZINTER", "non-existent", "ZinterKey32", "ZinterKey33"}, + expectedResponse: [][]string{}, + expectedError: nil, + }, + { + name: "13. Command too short", + command: []string{"ZINTER"}, + expectedResponse: [][]string{}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response array of length %d, got %d", len(test.expectedResponse), len(res.Array())) + } + + for _, item := range res.Array() { + value := item.Array()[0].String() + score := func() string { + if len(item.Array()) == 2 { + return item.Array()[1].String() + } + return "" + }() + if !slices.ContainsFunc(test.expectedResponse, func(expected []string) bool { + return expected[0] == value + }) { + t.Errorf("unexpected member \"%s\" in response", value) + } + if score != "" { + for _, expected := range test.expectedResponse { + if expected[0] == value && expected[1] != score { + t.Errorf("expected score for member \"%s\" to be %s, got %s", value, expected[1], score) + } + } + } + } + }) + } + }) + + t.Run("Test_HandleZINTERSTORE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + destination string + command []string + expectedValue *sorted_set.SortedSet + expectedResponse int + expectedError error + }{ + { + name: "1. Get the intersection between 2 sorted sets.", + presetValues: map[string]interface{}{ + "ZinterStoreKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + "ZinterStoreKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "ZinterStoreDestinationKey1", + command: []string{"ZINTERSTORE", "ZinterStoreDestinationKey1", "ZinterStoreKey1", "ZinterStoreKey2"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "three", Score: 6}, {Value: "four", Score: 8}, + {Value: "five", Score: 10}, + }), + expectedResponse: 3, + expectedError: nil, + }, + { + // 2. Get the intersection between 3 sorted sets with scores. + // By default, the SUM aggregate will be used. + name: "2. Get the intersection between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZinterStoreKey3": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterStoreKey4": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 8}, + }), + "ZinterStoreKey5": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "ZinterStoreDestinationKey2", + command: []string{ + "ZINTERSTORE", "ZinterStoreDestinationKey2", "ZinterStoreKey3", "ZinterStoreKey4", "ZinterStoreKey5", "WITHSCORES", + }, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 3}, {Value: "eight", Score: 24}, + }), + expectedResponse: 2, + expectedError: nil, + }, + { + // 3. Get the intersection between 3 sorted sets with scores. + // Use MIN aggregate. + name: "3. Get the intersection between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZinterStoreKey6": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterStoreKey7": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZinterStoreKey8": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "ZinterStoreDestinationKey3", + command: []string{"ZINTERSTORE", "ZinterStoreDestinationKey3", "ZinterStoreKey6", "ZinterStoreKey7", "ZinterStoreKey8", "WITHSCORES", "AGGREGATE", "MIN"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "eight", Score: 8}, + }), + expectedResponse: 2, + expectedError: nil, + }, + { + // 4. Get the intersection between 3 sorted sets with scores. + // Use MAX aggregate. + name: "4. Get the intersection between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZinterStoreKey9": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterStoreKey10": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZinterStoreKey11": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "ZinterStoreDestinationKey4", + command: []string{"ZINTERSTORE", "ZinterStoreDestinationKey4", "ZinterStoreKey9", "ZinterStoreKey10", "ZinterStoreKey11", "WITHSCORES", "AGGREGATE", "MAX"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + }), + expectedResponse: 2, + expectedError: nil, + }, + { + // 5. Get the intersection between 3 sorted sets with scores. + // Use SUM aggregate with weights modifier. + name: "5. Get the intersection between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZinterStoreKey12": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterStoreKey13": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZinterStoreKey14": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "ZinterStoreDestinationKey5", + command: []string{"ZINTERSTORE", "ZinterStoreDestinationKey5", "ZinterStoreKey12", "ZinterStoreKey13", "ZinterStoreKey14", "WITHSCORES", "AGGREGATE", "SUM", "WEIGHTS", "1", "5", "3"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 3105}, {Value: "eight", Score: 2808}, + }), + expectedResponse: 2, + expectedError: nil, + }, + { + // 6. Get the intersection between 3 sorted sets with scores. + // Use MAX aggregate with added weights. + name: "6. Get the intersection between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZinterStoreKey15": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterStoreKey16": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZinterStoreKey17": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "ZinterStoreDestinationKey6", + command: []string{"ZINTERSTORE", "ZinterStoreDestinationKey6", "ZinterStoreKey15", "ZinterStoreKey16", "ZinterStoreKey17", "WITHSCORES", "AGGREGATE", "MAX", "WEIGHTS", "1", "5", "3"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 3000}, {Value: "eight", Score: 2400}, + }), + expectedResponse: 2, + expectedError: nil, + }, + { + // 7. Get the intersection between 3 sorted sets with scores. + // Use MIN aggregate with added weights. + name: "7. Get the intersection between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZinterStoreKey18": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterStoreKey19": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZinterStoreKey20": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "ZinterStoreDestinationKey7", + command: []string{"ZINTERSTORE", "ZinterStoreDestinationKey7", "ZinterStoreKey18", "ZinterStoreKey19", "ZinterStoreKey20", "WITHSCORES", "AGGREGATE", "MIN", "WEIGHTS", "1", "5", "3"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 5}, {Value: "eight", Score: 8}, + }), + expectedResponse: 2, + expectedError: nil, + }, + { + name: "8. Throw an error if there are more weights than keys", + presetValues: map[string]interface{}{ + "ZinterStoreKey21": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterStoreKey22": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + command: []string{"ZINTERSTORE", "ZinterStoreDestinationKey8", "ZinterStoreKey21", "ZinterStoreKey22", "WEIGHTS", "1", "2", "3"}, + expectedResponse: 0, + expectedError: errors.New("number of weights should match number of keys"), + }, + { + name: "9. Throw an error if there are fewer weights than keys", + presetValues: map[string]interface{}{ + "ZinterStoreKey23": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterStoreKey24": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + }), + "ZinterStoreKey25": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + command: []string{"ZINTERSTORE", "ZinterStoreDestinationKey9", "ZinterStoreKey23", "ZinterStoreKey24", "ZinterStoreKey25", "WEIGHTS", "5", "4"}, + expectedResponse: 0, + expectedError: errors.New("number of weights should match number of keys"), + }, + { + name: "10. Throw an error if there are no keys provided", + presetValues: map[string]interface{}{ + "ZinterStoreKey26": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + "ZinterStoreKey27": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + "ZinterStoreKey28": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + command: []string{"ZINTERSTORE", "WEIGHTS", "5", "4"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "11. Throw an error if any of the provided keys are not sorted sets", + presetValues: map[string]interface{}{ + "ZinterStoreKey29": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZinterStoreKey30": "Default value", + "ZinterStoreKey31": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + command: []string{"ZINTERSTORE", "ZinterStoreKey29", "ZinterStoreKey30", "ZinterStoreKey31"}, + expectedResponse: 0, + expectedError: errors.New("value at ZinterStoreKey30 is not a sorted set"), + }, + { + name: "12. If any of the keys does not exist, return an empty array.", + presetValues: map[string]interface{}{ + "ZinterStoreKey32": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "ZinterStoreKey33": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZINTERSTORE", "ZinterStoreDestinationKey12", "non-existent", "ZinterStoreKey32", "ZinterStoreKey33"}, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "13. Command too short", + command: []string{"ZINTERSTORE"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + + // Check if the resulting sorted set has the expected members/scores + if test.expectedValue == nil { + return + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("ZRANGE"), + resp.StringValue(test.destination), + resp.StringValue("-inf"), + resp.StringValue("+inf"), + resp.StringValue("BYSCORE"), + resp.StringValue("WITHSCORES"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != test.expectedValue.Cardinality() { + t.Errorf("expected resulting set %s to have cardinality %d, got %d", + test.destination, test.expectedValue.Cardinality(), len(res.Array())) + } + + for _, member := range res.Array() { + value := sorted_set.Value(member.Array()[0].String()) + score := sorted_set.Score(member.Array()[1].Float()) + if !test.expectedValue.Contains(value) { + t.Errorf("unexpected value %s in resulting sorted set", value) + } + if test.expectedValue.Get(value).Score != score { + t.Errorf("expected value %s to have score %v, got %v", value, test.expectedValue.Get(value).Score, score) + } + } + }) + } + }) + + t.Run("Test_HandleZUNION", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + presetValues map[string]interface{} + command []string + expectedResponse [][]string + expectedError error + }{ + { + name: "1. Get the union between 2 sorted sets.", + presetValues: map[string]interface{}{ + "ZunionKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + "ZunionKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + command: []string{"ZUNION", "ZunionKey1", "ZunionKey2"}, + expectedResponse: [][]string{{"one"}, {"two"}, {"three"}, {"four"}, {"five"}, {"six"}, {"seven"}, {"eight"}}, + expectedError: nil, + }, + { + // 2. Get the union between 3 sorted sets with scores. + // By default, the SUM aggregate will be used. + name: "2. Get the union between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZunionKey3": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionKey4": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 8}, + }), + "ZunionKey5": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 36}, + }), + }, + command: []string{"ZUNION", "ZunionKey3", "ZunionKey4", "ZunionKey5", "WITHSCORES"}, + expectedResponse: [][]string{ + {"one", "3"}, {"two", "4"}, {"three", "3"}, {"four", "4"}, {"five", "5"}, {"six", "6"}, + {"seven", "7"}, {"eight", "24"}, {"nine", "9"}, {"ten", "10"}, {"eleven", "11"}, + {"twelve", "24"}, {"thirty-six", "72"}, + }, + expectedError: nil, + }, + { + // 3. Get the union between 3 sorted sets with scores. + // Use MIN aggregate. + name: "3. Get the union between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZunionKey6": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionKey7": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZunionKey8": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 72}, + }), + }, + command: []string{"ZUNION", "ZunionKey6", "ZunionKey7", "ZunionKey8", "WITHSCORES", "AGGREGATE", "MIN"}, + expectedResponse: [][]string{ + {"one", "1"}, {"two", "2"}, {"three", "3"}, {"four", "4"}, {"five", "5"}, {"six", "6"}, + {"seven", "7"}, {"eight", "8"}, {"nine", "9"}, {"ten", "10"}, {"eleven", "11"}, + {"twelve", "12"}, {"thirty-six", "36"}, + }, + expectedError: nil, + }, + { + // 4. Get the union between 3 sorted sets with scores. + // Use MAX aggregate. + name: "4. Get the union between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZunionKey9": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionKey10": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZunionKey11": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 72}, + }), + }, + command: []string{"ZUNION", "ZunionKey9", "ZunionKey10", "ZunionKey11", "WITHSCORES", "AGGREGATE", "MAX"}, + expectedResponse: [][]string{ + {"one", "1000"}, {"two", "2"}, {"three", "3"}, {"four", "4"}, {"five", "5"}, {"six", "6"}, + {"seven", "7"}, {"eight", "800"}, {"nine", "9"}, {"ten", "10"}, {"eleven", "11"}, + {"twelve", "12"}, {"thirty-six", "72"}, + }, + expectedError: nil, + }, + { + // 5. Get the union between 3 sorted sets with scores. + // Use SUM aggregate with weights modifier. + name: "5. Get the union between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZunionKey12": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionKey13": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZunionKey14": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZUNION", "ZunionKey12", "ZunionKey13", "ZunionKey14", "WITHSCORES", "AGGREGATE", "SUM", "WEIGHTS", "1", "2", "3"}, + expectedResponse: [][]string{ + {"one", "3102"}, {"two", "6"}, {"three", "3"}, {"four", "4"}, {"five", "5"}, {"six", "6"}, + {"seven", "7"}, {"eight", "2568"}, {"nine", "27"}, {"ten", "30"}, {"eleven", "22"}, + {"twelve", "60"}, {"thirty-six", "72"}, + }, + expectedError: nil, + }, + { + // 6. Get the union between 3 sorted sets with scores. + // Use MAX aggregate with added weights. + name: "6. Get the union between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZunionKey15": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionKey16": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZunionKey17": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZUNION", "ZunionKey15", "ZunionKey16", "ZunionKey17", "WITHSCORES", "AGGREGATE", "MAX", "WEIGHTS", "1", "2", "3"}, + expectedResponse: [][]string{ + {"one", "3000"}, {"two", "4"}, {"three", "3"}, {"four", "4"}, {"five", "5"}, {"six", "6"}, + {"seven", "7"}, {"eight", "2400"}, {"nine", "27"}, {"ten", "30"}, {"eleven", "22"}, + {"twelve", "36"}, {"thirty-six", "72"}, + }, + expectedError: nil, + }, + { + // 7. Get the union between 3 sorted sets with scores. + // Use MIN aggregate with added weights. + name: "7. Get the union between 3 sorted sets with scores.", + presetValues: map[string]interface{}{ + "ZunionKey18": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionKey19": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZunionKey20": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZUNION", "ZunionKey18", "ZunionKey19", "ZunionKey20", "WITHSCORES", "AGGREGATE", "MIN", "WEIGHTS", "1", "2", "3"}, + expectedResponse: [][]string{ + {"one", "2"}, {"two", "2"}, {"three", "3"}, {"four", "4"}, {"five", "5"}, {"six", "6"}, {"seven", "7"}, + {"eight", "8"}, {"nine", "27"}, {"ten", "30"}, {"eleven", "22"}, {"twelve", "24"}, {"thirty-six", "72"}, + }, + expectedError: nil, + }, + { + name: "8. Throw an error if there are more weights than keys", + presetValues: map[string]interface{}{ + "ZunionKey21": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionKey22": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + command: []string{"ZUNION", "ZunionKey21", "ZunionKey22", "WEIGHTS", "1", "2", "3"}, + expectedResponse: nil, + expectedError: errors.New("number of weights should match number of keys"), + }, + { + name: "9. Throw an error if there are fewer weights than keys", + presetValues: map[string]interface{}{ + "ZunionKey23": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionKey24": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + }), + "ZunionKey25": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + command: []string{"ZUNION", "ZunionKey23", "ZunionKey24", "ZunionKey25", "WEIGHTS", "5", "4"}, + expectedResponse: nil, + expectedError: errors.New("number of weights should match number of keys"), + }, + { + name: "10. Throw an error if there are no keys provided", + presetValues: map[string]interface{}{ + "ZunionKey26": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + "ZunionKey27": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + "ZunionKey28": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + command: []string{"ZUNION", "WEIGHTS", "5", "4"}, + expectedResponse: nil, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "11. Throw an error if any of the provided keys are not sorted sets", + presetValues: map[string]interface{}{ + "ZunionKey29": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionKey30": "Default value", + "ZunionKey31": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + command: []string{"ZUNION", "ZunionKey29", "ZunionKey30", "ZunionKey31"}, + expectedResponse: nil, + expectedError: errors.New("value at ZunionKey30 is not a sorted set"), + }, + { + name: "12. If any of the keys does not exist, skip it.", + presetValues: map[string]interface{}{ + "ZunionKey32": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "ZunionKey33": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + command: []string{"ZUNION", "non-existent", "ZunionKey32", "ZunionKey33"}, + expectedResponse: [][]string{ + {"one"}, {"two"}, {"thirty-six"}, {"twelve"}, {"eleven"}, + {"seven"}, {"eight"}, {"nine"}, {"ten"}, + }, + expectedError: nil, + }, + { + name: "13. Command too short", + command: []string{"ZUNION"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if len(res.Array()) != len(test.expectedResponse) { + t.Errorf("expected response array of length %d, got %d", len(test.expectedResponse), len(res.Array())) + } + + for _, item := range res.Array() { + value := item.Array()[0].String() + score := func() string { + if len(item.Array()) == 2 { + return item.Array()[1].String() + } + return "" + }() + if !slices.ContainsFunc(test.expectedResponse, func(expected []string) bool { + return expected[0] == value + }) { + t.Errorf("unexpected member \"%s\" in response", value) + } + if score != "" { + for _, expected := range test.expectedResponse { + if expected[0] == value && expected[1] != score { + t.Errorf("expected score for member \"%s\" to be %s, got %s", value, expected[1], score) + } + } + } + } + }) + } + }) + + t.Run("Test_HandleZUNIONSTORE", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error() + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + preset bool + presetValues map[string]interface{} + destination string + command []string + expectedValue *sorted_set.SortedSet + expectedResponse int + expectedError error + }{ + { + name: "1. Get the union between 2 sorted sets.", + preset: true, + presetValues: map[string]interface{}{ + "ZunionStoreKey1": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + "ZunionStoreKey2": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "ZunionStoreDestinationKey1", + command: []string{"ZUNIONSTORE", "ZunionStoreDestinationKey1", "ZunionStoreKey1", "ZunionStoreKey2"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 6}, {Value: "four", Score: 8}, + {Value: "five", Score: 10}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + expectedResponse: 8, + expectedError: nil, + }, + { + // 2. Get the union between 3 sorted sets with scores. + // By default, the SUM aggregate will be used. + name: "2. Get the union between 3 sorted sets with scores.", + preset: true, + presetValues: map[string]interface{}{ + "ZunionStoreKey3": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionStoreKey4": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 8}, + }), + "ZunionStoreKey5": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 36}, + }), + }, + destination: "ZunionStoreDestinationKey2", + command: []string{"ZUNIONSTORE", "ZunionStoreDestinationKey2", "ZunionStoreKey3", "ZunionStoreKey4", "ZunionStoreKey5", "WITHSCORES"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 3}, {Value: "two", Score: 4}, {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, {Value: "seven", Score: 7}, {Value: "eight", Score: 24}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, {Value: "eleven", Score: 11}, + {Value: "twelve", Score: 24}, {Value: "thirty-six", Score: 72}, + }), + expectedResponse: 13, + expectedError: nil, + }, + { + // 3. Get the union between 3 sorted sets with scores. + // Use MIN aggregate. + name: "3. Get the union between 3 sorted sets with scores.", + preset: true, + presetValues: map[string]interface{}{ + "ZunionStoreKey6": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionStoreKey7": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZunionStoreKey8": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 72}, + }), + }, + destination: "ZunionStoreDestinationKey3", + command: []string{"ZUNIONSTORE", "ZunionStoreDestinationKey3", "ZunionStoreKey6", "ZunionStoreKey7", "ZunionStoreKey8", "WITHSCORES", "AGGREGATE", "MIN"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, {Value: "eleven", Score: 11}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 36}, + }), + expectedResponse: 13, + expectedError: nil, + }, + { + // 4. Get the union between 3 sorted sets with scores. + // Use MAX aggregate. + name: "4. Get the union between 3 sorted sets with scores.", + preset: true, + presetValues: map[string]interface{}{ + "ZunionStoreKey9": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionStoreKey10": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZunionStoreKey11": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 72}, + }), + }, + destination: "ZunionStoreDestinationKey4", + command: []string{ + "ZUNIONSTORE", "ZunionStoreDestinationKey4", "ZunionStoreKey9", "ZunionStoreKey10", "ZunionStoreKey11", "WITHSCORES", "AGGREGATE", "MAX", + }, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "two", Score: 2}, {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, {Value: "seven", Score: 7}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, {Value: "eleven", Score: 11}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 72}, + }), + expectedResponse: 13, + expectedError: nil, + }, + { + // 5. Get the union between 3 sorted sets with scores. + // Use SUM aggregate with weights modifier. + name: "5. Get the union between 3 sorted sets with scores.", + preset: true, + presetValues: map[string]interface{}{ + "ZunionStoreKey12": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionStoreKey13": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZunionStoreKey14": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "ZunionStoreDestinationKey5", + command: []string{ + "ZUNIONSTORE", "ZunionStoreDestinationKey5", "ZunionStoreKey12", "ZunionStoreKey13", "ZunionStoreKey14", + "WITHSCORES", "AGGREGATE", "SUM", "WEIGHTS", "1", "2", "3", + }, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 3102}, {Value: "two", Score: 6}, {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, {Value: "seven", Score: 7}, {Value: "eight", Score: 2568}, + {Value: "nine", Score: 27}, {Value: "ten", Score: 30}, {Value: "eleven", Score: 22}, + {Value: "twelve", Score: 60}, {Value: "thirty-six", Score: 72}, + }), + expectedResponse: 13, + expectedError: nil, + }, + { + // 6. Get the union between 3 sorted sets with scores. + // Use MAX aggregate with added weights. + name: "6. Get the union between 3 sorted sets with scores.", + preset: true, + presetValues: map[string]interface{}{ + "ZunionStoreKey15": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionStoreKey16": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZunionStoreKey17": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "ZunionStoreDestinationKey6", + command: []string{ + "ZUNIONSTORE", "ZunionStoreDestinationKey6", "ZunionStoreKey15", "ZunionStoreKey16", "ZunionStoreKey17", + "WITHSCORES", "AGGREGATE", "MAX", "WEIGHTS", "1", "2", "3"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 3000}, {Value: "two", Score: 4}, {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, {Value: "seven", Score: 7}, {Value: "eight", Score: 2400}, + {Value: "nine", Score: 27}, {Value: "ten", Score: 30}, {Value: "eleven", Score: 22}, + {Value: "twelve", Score: 36}, {Value: "thirty-six", Score: 72}, + }), + expectedResponse: 13, + expectedError: nil, + }, + { + // 7. Get the union between 3 sorted sets with scores. + // Use MIN aggregate with added weights. + name: "7. Get the union between 3 sorted sets with scores.", + preset: true, + presetValues: map[string]interface{}{ + "ZunionStoreKey18": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionStoreKey19": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "ZunionStoreKey20": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "ZunionStoreDestinationKey7", + command: []string{ + "ZUNIONSTORE", "ZunionStoreDestinationKey7", "ZunionStoreKey18", "ZunionStoreKey19", "ZunionStoreKey20", + "WITHSCORES", "AGGREGATE", "MIN", "WEIGHTS", "1", "2", "3", + }, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 2}, {Value: "two", Score: 2}, {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 27}, {Value: "ten", Score: 30}, {Value: "eleven", Score: 22}, + {Value: "twelve", Score: 24}, {Value: "thirty-six", Score: 72}, + }), + expectedResponse: 13, + expectedError: nil, + }, + { + name: "8. Throw an error if there are more weights than keys", + preset: true, + presetValues: map[string]interface{}{ + "ZunionStoreKey21": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionStoreKey22": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + destination: "ZunionStoreDestinationKey8", + command: []string{"ZUNIONSTORE", "ZunionStoreDestinationKey8", "ZunionStoreKey21", "ZunionStoreKey22", "WEIGHTS", "1", "2", "3"}, + expectedResponse: 0, + expectedError: errors.New("number of weights should match number of keys"), + }, + { + name: "9. Throw an error if there are fewer weights than keys", + preset: true, + presetValues: map[string]interface{}{ + "ZunionStoreKey23": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionStoreKey24": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + }), + "ZunionStoreKey25": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + destination: "ZunionStoreDestinationKey9", + command: []string{"ZUNIONSTORE", "ZunionStoreDestinationKey9", "ZunionStoreKey23", "ZunionStoreKey24", "ZunionStoreKey25", "WEIGHTS", "5", "4"}, + expectedResponse: 0, + expectedError: errors.New("number of weights should match number of keys"), + }, + { + name: "10. Throw an error if there are no keys provided", + preset: true, + presetValues: map[string]interface{}{ + "ZunionStoreKey26": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + "ZunionStoreKey27": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + "ZunionStoreKey28": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + command: []string{"ZUNIONSTORE", "WEIGHTS", "5", "4"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "11. Throw an error if any of the provided keys are not sorted sets", + preset: true, + presetValues: map[string]interface{}{ + "ZunionStoreKey29": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "ZunionStoreKey30": "Default value", + "ZunionStoreKey31": sorted_set.NewSortedSet([]sorted_set.MemberParam{{Value: "one", Score: 1}}), + }, + destination: "ZunionStoreDestinationKey11", + command: []string{"ZUNIONSTORE", "ZunionStoreDestinationKey11", "ZunionStoreKey29", "ZunionStoreKey30", "ZunionStoreKey31"}, + expectedResponse: 0, + expectedError: errors.New("value at ZunionStoreKey30 is not a sorted set"), + }, + { + name: "12. If any of the keys does not exist, skip it.", + preset: true, + presetValues: map[string]interface{}{ + "ZunionStoreKey32": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "ZunionStoreKey33": sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "ZunionStoreDestinationKey12", + command: []string{"ZUNIONSTORE", "ZunionStoreDestinationKey12", "non-existent", "ZunionStoreKey32", "ZunionStoreKey33"}, + expectedValue: sorted_set.NewSortedSet([]sorted_set.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, {Value: "eleven", Score: 11}, {Value: "twelve", Score: 24}, + {Value: "thirty-six", Score: 36}, + }), + expectedResponse: 9, + expectedError: nil, + }, + { + name: "13. Command too short", + preset: false, + command: []string{"ZUNIONSTORE"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValues != nil { + var command []resp.Value + var expected string + for key, value := range test.presetValues { + switch value.(type) { + case string: + command = []resp.Value{ + resp.StringValue("SET"), + resp.StringValue(key), + resp.StringValue(value.(string)), + } + expected = "ok" + case *sorted_set.SortedSet: + command = []resp.Value{resp.StringValue("ZADD"), resp.StringValue(key)} + for _, member := range value.(*sorted_set.SortedSet).GetAll() { + command = append(command, []resp.Value{ + resp.StringValue(strconv.FormatFloat(float64(member.Score), 'f', -1, 64)), + resp.StringValue(string(member.Value)), + }...) + } + expected = strconv.Itoa(value.(*sorted_set.SortedSet).Cardinality()) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), expected) { + t.Errorf("expected preset response to be \"%s\", got %s", expected, res.String()) + } + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), res.Error().Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response %d, got %d", test.expectedResponse, res.Integer()) + } + + // Check if the resulting sorted set has the expected members/scores + if test.expectedValue == nil { + return + } + + if err = client.WriteArray([]resp.Value{ + resp.StringValue("ZRANGE"), + resp.StringValue(test.destination), + resp.StringValue("-inf"), + resp.StringValue("+inf"), + resp.StringValue("BYSCORE"), + resp.StringValue("WITHSCORES"), + }); err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if len(res.Array()) != test.expectedValue.Cardinality() { + t.Errorf("expected resulting set %s to have cardinality %d, got %d", + test.destination, test.expectedValue.Cardinality(), len(res.Array())) + } + + for _, member := range res.Array() { + value := sorted_set.Value(member.Array()[0].String()) + score := sorted_set.Score(member.Array()[1].Float()) + if !test.expectedValue.Contains(value) { + t.Errorf("unexpected value %s in resulting sorted set", value) + } + if test.expectedValue.Get(value).Score != score { + t.Errorf("expected value %s to have score %v, got %v", value, test.expectedValue.Get(value).Score, score) + } + } + }) + } + }) +} diff --git a/internal/modules/sorted_set/key_funcs.go b/internal/modules/sorted_set/key_funcs.go new file mode 100644 index 0000000..be47736 --- /dev/null +++ b/internal/modules/sorted_set/key_funcs.go @@ -0,0 +1,385 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sorted_set + +import ( + "errors" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" + "slices" + "strings" +) + +func zaddKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func zcardKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil +} + +func zcountKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func zdiffKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + + withscoresIndex := slices.IndexFunc(cmd, func(s string) bool { + return strings.EqualFold(s, "withscores") + }) + + if withscoresIndex == -1 { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil + } + + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:withscoresIndex], + WriteKeys: make([]string, 0), + }, nil +} + +func zdiffstoreKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[2:], + WriteKeys: cmd[1:2], + }, nil +} + +func zincrbyKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func zinterKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + endIdx := slices.IndexFunc(cmd[1:], func(s string) bool { + if strings.EqualFold(s, "WEIGHTS") || + strings.EqualFold(s, "AGGREGATE") || + strings.EqualFold(s, "WITHSCORES") { + return true + } + return false + }) + if endIdx == -1 { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil + } + if endIdx >= 1 { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:endIdx], + WriteKeys: make([]string, 0), + }, nil + } + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) +} + +func zinterstoreKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + endIdx := slices.IndexFunc(cmd[1:], func(s string) bool { + return strings.EqualFold(s, "WEIGHTS") || + strings.EqualFold(s, "AGGREGATE") || + strings.EqualFold(s, "WITHSCORES") + }) + + if endIdx == -1 { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[2:], + WriteKeys: cmd[1:2], + }, nil + } + + if endIdx >= 3 { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[2 : endIdx+1], + WriteKeys: cmd[1:2], + }, nil + } + + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) +} + +func zmpopKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + endIdx := slices.IndexFunc(cmd, func(s string) bool { + return slices.Contains([]string{"MIN", "MAX", "COUNT"}, strings.ToUpper(s)) + }) + if endIdx == -1 { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:], + }, nil + } + if endIdx >= 2 { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:endIdx], + }, nil + } + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) +} + +func zmscoreKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func zpopKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 || len(cmd) > 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func zrandmemberKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 || len(cmd) > 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func zrankKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 || len(cmd) > 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func zremKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func zrevrankKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func zscoreKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func zremrangebylexKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func zremrangebyrankKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func zremrangebyscoreKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func zlexcountKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func zrangeKeyCount(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 4 || len(cmd) > 10 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func zrangeStoreKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 5 || len(cmd) > 11 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[2:3], + WriteKeys: cmd[1:2], + }, nil +} + +func zunionKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + endIdx := slices.IndexFunc(cmd[1:], func(s string) bool { + if strings.EqualFold(s, "WEIGHTS") || + strings.EqualFold(s, "AGGREGATE") || + strings.EqualFold(s, "WITHSCORES") { + return true + } + return false + }) + if endIdx == -1 { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:], + WriteKeys: make([]string, 0), + }, nil + } + if endIdx >= 1 { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:endIdx], + WriteKeys: cmd[1:endIdx], + }, nil + } + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) +} + +func zunionstoreKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) < 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + endIdx := slices.IndexFunc(cmd[1:], func(s string) bool { + if strings.EqualFold(s, "WEIGHTS") || + strings.EqualFold(s, "AGGREGATE") || + strings.EqualFold(s, "WITHSCORES") { + return true + } + return false + }) + if endIdx == -1 { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[2:], + WriteKeys: cmd[1:2], + }, nil + } + if endIdx >= 1 { + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[2 : endIdx+1], + WriteKeys: cmd[1:2], + }, nil + } + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) +} diff --git a/internal/modules/sorted_set/sorted_set.go b/internal/modules/sorted_set/sorted_set.go new file mode 100644 index 0000000..d20f1cb --- /dev/null +++ b/internal/modules/sorted_set/sorted_set.go @@ -0,0 +1,476 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sorted_set + +import ( + "cmp" + "errors" + "math" + "math/rand" + "slices" + "strings" + "unsafe" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" +) + +type Value string + +type Score float64 + +// MemberObject is the shape of the object as it's stored in the map that represents the Set +type MemberObject struct { + Value Value + Score Score + Exists bool +} + +// MemberParam is the shape of the object passed as a parameter to NewSortedSet and the Add method +type MemberParam struct { + Value Value + Score Score +} + +type SortedSet struct { + members map[Value]MemberObject +} + +func (s *SortedSet) GetMem() int64 { + var size int64 + // map header + size += int64(unsafe.Sizeof(s)) + // map contents + for k, v := range s.members { + // string header + size += int64(unsafe.Sizeof(k)) + // string + size += int64(len(k)) + // MemberObject + size += int64(unsafe.Sizeof(v)) + // value field + size += int64(unsafe.Sizeof(v.Value)) + size += int64(len(v.Value)) + } + + return size +} + +// compile time interface check +var _ constants.CompositeType = (*SortedSet)(nil) + +func NewSortedSet(members []MemberParam) *SortedSet { + s := &SortedSet{ + members: make(map[Value]MemberObject), + } + for _, m := range members { + s.members[m.Value] = MemberObject{ + Value: m.Value, + Score: m.Score, + Exists: true, + } + } + return s +} + +func (set *SortedSet) Contains(m Value) bool { + return set.members[m].Exists +} + +func (set *SortedSet) Get(v Value) MemberObject { + return set.members[v] +} + +func (set *SortedSet) GetRandom(count int) []MemberParam { + var res []MemberParam + + members := set.GetAll() + + if internal.AbsInt(count) >= len(members) { + return members + } + + var n int + + if count < 0 { + // If count is negative, allow repeat numbers + for i := 0; i < internal.AbsInt(count); i++ { + n = rand.Intn(len(members)) + res = append(res, members[n]) + } + } else { + // If count is positive only allow unique values + + for i := 0; i < internal.AbsInt(count); { + n = rand.Intn(len(members)) + if !slices.ContainsFunc(res, func(m MemberParam) bool { + return m.Value == members[n].Value + }) { + res = append(res, members[n]) + members[n] = members[len(members)-1] + members = members[:len(members)-1] + i++ + } + } + } + + return res +} + +func (set *SortedSet) GetAll() []MemberParam { + var res []MemberParam + for k, v := range set.members { + res = append(res, MemberParam{ + Value: k, + Score: v.Score, + }) + } + return res +} + +func (set *SortedSet) Cardinality() int { + return len(set.GetAll()) +} + +func (set *SortedSet) AddOrUpdate( + members []MemberParam, updatePolicy interface{}, comparison interface{}, changed interface{}, incr interface{}, +) (int, error) { + policy, err := validateUpdatePolicy(updatePolicy) + if err != nil { + return 0, err + } + comp, err := validateComparison(comparison) + if err != nil { + return 0, err + } + ch, err := validateChanged(changed) + if err != nil { + return 0, err + } + inc, err := validateIncr(incr) + if err != nil { + return 0, err + } + if strings.EqualFold(policy, "nx") && comp != "" { + return 0, errors.New("cannot use GT or LT when update policy is NX") + } + if strings.EqualFold(inc, "incr") && len(members) != 1 { + return 0, errors.New("INCR can only be used with one member/Score pair") + } + + count := 0 + + if strings.EqualFold(inc, "incr") { + for _, m := range members { + if !set.Contains(m.Value) { + // If the member is not contained, add it with the increment as its Score + set.members[m.Value] = MemberObject{ + Value: m.Value, + Score: m.Score, + Exists: true, + } + // Always add count because this is the addition of a new element + count += 1 + return count, err + } + if slices.Contains([]Score{Score(math.Inf(-1)), Score(math.Inf(1))}, set.members[m.Value].Score) { + return count, errors.New("cannot increment -inf or +inf") + } + set.members[m.Value] = MemberObject{ + Value: m.Value, + Score: set.members[m.Value].Score + m.Score, + Exists: true, + } + if strings.EqualFold(ch, "ch") { + count += 1 + } + } + return count, nil + } + + for _, m := range members { + if strings.EqualFold(policy, "xx") { + // Only update existing elements, do not add new elements + if set.Contains(m.Value) { + set.members[m.Value] = MemberObject{ + Value: m.Value, + Score: compareScores(set.members[m.Value].Score, m.Score, comp), + Exists: true, + } + if strings.EqualFold(ch, "ch") { + count += 1 + } + } + continue + } + if strings.EqualFold(policy, "nx") { + // Only add new elements, do not update existing elements + if !set.Contains(m.Value) { + set.members[m.Value] = MemberObject{ + Value: m.Value, + Score: m.Score, + Exists: true, + } + count += 1 + } + continue + } + // Policy not specified, just Set the elements and scores + if set.members[m.Value].Score != m.Score || !set.members[m.Value].Exists { + count += 1 + } + set.members[m.Value] = MemberObject{ + Value: m.Value, + Score: compareScores(set.members[m.Value].Score, m.Score, comp), + Exists: true, + } + } + return count, nil +} + +func (set *SortedSet) Remove(v Value) bool { + if set.Contains(v) { + delete(set.members, v) + return true + } + return false +} + +func (set *SortedSet) Pop(count int, policy string) (*SortedSet, error) { + popped := NewSortedSet([]MemberParam{}) + if !slices.Contains([]string{"min", "max"}, strings.ToLower(policy)) { + return nil, errors.New("policy must be MIN or MAX") + } + if count < 0 { + return nil, errors.New("count must be a positive integer") + } + if count == 0 { + return popped, nil + } + + members := set.GetAll() + + slices.SortFunc(members, func(a, b MemberParam) int { + if strings.EqualFold(policy, "min") { + return cmp.Compare(a.Score, b.Score) + } + return cmp.Compare(b.Score, a.Score) + }) + + for i := 0; i < count; i++ { + if i >= len(members) { + break + } + set.Remove(members[i].Value) + _, err := popped.AddOrUpdate([]MemberParam{members[i]}, nil, nil, nil, nil) + if err != nil { + return nil, err + } + } + + return popped, nil +} + +func (set *SortedSet) Subtract(others []*SortedSet) *SortedSet { + res := NewSortedSet(set.GetAll()) + for _, ss := range others { + for _, m := range ss.GetAll() { + if res.Contains(m.Value) { + res.Remove(m.Value) + } + } + } + return res +} + +// SortedSetParam is a composite object used for Intersect and Union function +type SortedSetParam struct { + Set *SortedSet + Weight int +} + +// Union uses divided & conquer to calculate the union of multiple sets +func Union(aggregate string, setParams ...SortedSetParam) *SortedSet { + switch len(setParams) { + case 0: + return NewSortedSet([]MemberParam{}) + case 1: + var params []MemberParam + for _, member := range setParams[0].Set.GetAll() { + params = append(params, MemberParam{ + Value: member.Value, + Score: member.Score * Score(setParams[0].Weight), + }) + } + return NewSortedSet(params) + case 2: + var params []MemberParam + // Traverse the params in the left sorted Set + for _, member := range setParams[0].Set.GetAll() { + // If the member does not exist in the other sorted Set, add it to params along with the appropriate Weight + if !setParams[1].Set.Contains(member.Value) { + params = append(params, MemberParam{ + Value: member.Value, + Score: member.Score * Score(setParams[0].Weight), + }) + continue + } + // If the member Exists, get both elements and apply the Weight + param := MemberParam{ + Value: member.Value, + Score: func(left, right Score) Score { + // Choose which param to add to params depending on the aggregate + switch aggregate { + case "sum": + return left + right + case "min": + return compareScores(left, right, "lt") + default: + // Aggregate is "max" + return compareScores(left, right, "gt") + } + }( + member.Score*Score(setParams[0].Weight), + setParams[1].Set.Get(member.Value).Score*Score(setParams[1].Weight), + ), + } + params = append(params, param) + } + // Traverse the params on the right sorted Set and add all the elements that are not + // already contained in params with their respective weights applied. + for _, member := range setParams[1].Set.GetAll() { + if !slices.ContainsFunc(params, func(param MemberParam) bool { + return param.Value == member.Value + }) { + params = append(params, MemberParam{ + Value: member.Value, + Score: member.Score * Score(setParams[1].Weight), + }) + } + } + return NewSortedSet(params) + default: + // Divide the sets into 2 and return the unions + left := Union(aggregate, setParams[0:len(setParams)/2]...) + right := Union(aggregate, setParams[len(setParams)/2:]...) + + var params []MemberParam + // Traverse left sub-Set and add the union elements to params + for _, member := range left.GetAll() { + if !right.Contains(member.Value) { + // If the right Set does not contain the current element, just add it to params + params = append(params, member) + continue + } + params = append(params, MemberParam{ + Value: member.Value, + Score: func(left, right Score) Score { + switch aggregate { + case "sum": + return left + right + case "min": + return compareScores(left, right, "lt") + default: + // Aggregate is "max" + return compareScores(left, right, "gt") + } + }(member.Score, right.Get(member.Value).Score), + }) + } + // Traverse the right sub-Set and add any remaining elements to params + for _, member := range right.GetAll() { + if !slices.ContainsFunc(params, func(param MemberParam) bool { + return param.Value == member.Value + }) { + params = append(params, member) + } + } + return NewSortedSet(params) + } +} + +// Intersect uses divide & conquer to calculate the intersection of multiple sets +func Intersect(aggregate string, setParams ...SortedSetParam) *SortedSet { + switch len(setParams) { + case 0: + return NewSortedSet([]MemberParam{}) + case 1: + var params []MemberParam + for _, member := range setParams[0].Set.GetAll() { + params = append(params, MemberParam{ + Value: member.Value, + Score: member.Score * Score(setParams[0].Weight), + }) + } + return NewSortedSet(params) + case 2: + var params []MemberParam + // Traverse the params in the left sorted Set + for _, member := range setParams[0].Set.GetAll() { + // Check if the member Exists in the right sorted Set + if !setParams[1].Set.Contains(member.Value) { + continue + } + // If the member Exists, get both elements and apply the Weight + param := MemberParam{ + Value: member.Value, + Score: func(left, right Score) Score { + // Choose which param to add to params depending on the aggregate + switch aggregate { + case "sum": + return left + right + case "min": + return compareScores(left, right, "lt") + default: + // Aggregate is "max" + return compareScores(left, right, "gt") + } + }( + member.Score*Score(setParams[0].Weight), + setParams[1].Set.Get(member.Value).Score*Score(setParams[1].Weight), + ), + } + params = append(params, param) + } + return NewSortedSet(params) + default: + // Divide the sets into 2 and return the intersection + left := Intersect(aggregate, setParams[0:len(setParams)/2]...) + right := Intersect(aggregate, setParams[len(setParams)/2:]...) + + var params []MemberParam + for _, member := range left.GetAll() { + if !right.Contains(member.Value) { + continue + } + params = append(params, MemberParam{ + Value: member.Value, + Score: func(left, right Score) Score { + switch aggregate { + case "sum": + return left + right + case "min": + return compareScores(left, right, "lt") + default: + // Aggregate is "max" + return compareScores(left, right, "gt") + } + }(member.Score, right.Get(member.Value).Score), + }) + } + + return NewSortedSet(params) + } +} diff --git a/internal/modules/sorted_set/utils.go b/internal/modules/sorted_set/utils.go new file mode 100644 index 0000000..11638e2 --- /dev/null +++ b/internal/modules/sorted_set/utils.go @@ -0,0 +1,169 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sorted_set + +import ( + "errors" + "slices" + "strconv" + "strings" +) + +func extractKeysWeightsAggregateWithScores(cmd []string) ([]string, []int, string, bool, error) { + var weights []int + weightsIndex := slices.IndexFunc(cmd, func(s string) bool { + return strings.EqualFold(s, "weights") + }) + if weightsIndex != -1 { + for i := weightsIndex + 1; i < len(cmd); i++ { + if slices.Contains([]string{"aggregate", "withscores"}, strings.ToLower(cmd[i])) { + break + } + w, err := strconv.Atoi(cmd[i]) + if err != nil { + return []string{}, []int{}, "", false, err + } + weights = append(weights, w) + } + } + + aggregate := "sum" + aggregateIndex := slices.IndexFunc(cmd, func(s string) bool { + return strings.EqualFold(s, "aggregate") + }) + if aggregateIndex != -1 { + if !slices.Contains([]string{"sum", "min", "max"}, strings.ToLower(cmd[aggregateIndex+1])) { + return []string{}, []int{}, "", false, errors.New("aggregate must be SUM, MIN, or MAX") + } + aggregate = strings.ToLower(cmd[aggregateIndex+1]) + } + + withscores := false + withscoresIndex := slices.IndexFunc(cmd, func(s string) bool { + return strings.EqualFold(s, "withscores") + }) + if withscoresIndex != -1 { + withscores = true + } + + // Get the first modifier index as this will be the upper boundary when extracting the keys + firstModifierIndex := -1 + for _, modifierIndex := range []int{weightsIndex, aggregateIndex, withscoresIndex} { + if modifierIndex == -1 { + continue + } + if firstModifierIndex == -1 { + firstModifierIndex = modifierIndex + continue + } + if modifierIndex < firstModifierIndex { + firstModifierIndex = modifierIndex + } + } + + var keys []string + if firstModifierIndex == -1 { + keys = cmd[1:] + } else { + keys = cmd[1:firstModifierIndex] + } + + if weightsIndex != -1 && (len(keys) != len(weights)) { + return []string{}, []int{}, "", false, errors.New("number of weights should match number of keys") + } else if weightsIndex == -1 { + for i := 0; i < len(keys); i++ { + weights = append(weights, 1) + } + } + + return keys, weights, aggregate, withscores, nil +} + +func validateUpdatePolicy(updatePolicy interface{}) (string, error) { + if updatePolicy == nil { + return "", nil + } + err := errors.New("update policy must be a string of Value NX or XX") + policy, ok := updatePolicy.(string) + if !ok { + return "", err + } + if !slices.Contains([]string{"nx", "xx"}, strings.ToLower(policy)) { + return "", err + } + return policy, nil +} + +func validateComparison(comparison interface{}) (string, error) { + if comparison == nil { + return "", nil + } + err := errors.New("comparison condition must be a string of Value LT or GT") + comp, ok := comparison.(string) + if !ok { + return "", err + } + if !slices.Contains([]string{"lt", "gt"}, strings.ToLower(comp)) { + return "", err + } + return comp, nil +} + +func validateChanged(changed interface{}) (string, error) { + if changed == nil { + return "", nil + } + err := errors.New("changed condition should be a string of Value CH") + ch, ok := changed.(string) + if !ok { + return "", err + } + if !strings.EqualFold(ch, "ch") { + return "", err + } + return ch, nil +} + +func validateIncr(incr interface{}) (string, error) { + if incr == nil { + return "", nil + } + err := errors.New("incr condition should be a string of Value INCR") + i, ok := incr.(string) + if !ok { + return "", err + } + if !strings.EqualFold(i, "incr") { + return "", err + } + return i, nil +} + +func compareScores(old Score, new Score, comp string) Score { + switch strings.ToLower(comp) { + default: + return new + case "lt": + if new < old { + return new + } + return old + case "gt": + if new > old { + return new + } + return old + } +} diff --git a/internal/modules/string/commands.go b/internal/modules/string/commands.go new file mode 100644 index 0000000..f92c5b5 --- /dev/null +++ b/internal/modules/string/commands.go @@ -0,0 +1,254 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package str + +import ( + "errors" + "fmt" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" +) + +func handleSetRange(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := setRangeKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + + offset, ok := internal.AdaptType(params.Command[2]).(int) + if !ok { + return nil, errors.New("offset must be an integer") + } + + newStr := params.Command[3] + + if !keyExists { + return []byte(fmt.Sprintf(":%d\r\n", len(newStr))), nil + } + + str, ok := params.GetValues(params.Context, []string{key})[key].(string) + if !ok { + return nil, fmt.Errorf("value at key %s is not a string", key) + } + + // If the offset >= length of the string, append the new string to the old one. + if offset >= len(str) { + newStr = str + newStr + if err = params.SetValues(params.Context, map[string]interface{}{key: newStr}); err != nil { + return nil, err + } + return []byte(fmt.Sprintf(":%d\r\n", len(newStr))), nil + } + + // If the offset is < 0, prepend the new string to the old one. + if offset < 0 { + newStr = newStr + str + if err = params.SetValues(params.Context, map[string]interface{}{key: newStr}); err != nil { + return nil, err + } + return []byte(fmt.Sprintf(":%d\r\n", len(newStr))), nil + } + + strRunes := []rune(str) + + for i := 0; i < len(newStr); i++ { + // If we're still withing the length of the original string, replace the rune in strRunes + if offset < len(str) { + strRunes[offset] = rune(newStr[i]) + offset += 1 + continue + } + // We are past the length of the original string, append the remainder of newStr to strRunes + strRunes = append(strRunes, []rune(newStr)[i:]...) + break + } + + if err = params.SetValues(params.Context, map[string]interface{}{key: string(strRunes)}); err != nil { + return nil, err + } + + return []byte(fmt.Sprintf(":%d\r\n", len(strRunes))), nil +} + +func handleStrLen(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := strLenKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + if !keyExists { + return []byte(":0\r\n"), nil + } + + value, ok := params.GetValues(params.Context, []string{key})[key].(string) + + if !ok { + return nil, fmt.Errorf("value at key %s is not a string", key) + } + + return []byte(fmt.Sprintf(":%d\r\n", len(value))), nil +} + +func handleSubStr(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := subStrKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.ReadKeys[0] + keyExists := params.KeysExist(params.Context, keys.ReadKeys)[key] + + start, startOk := internal.AdaptType(params.Command[2]).(int) + end, endOk := internal.AdaptType(params.Command[3]).(int) + reversed := false + + if !startOk || !endOk { + return nil, errors.New("start and end indices must be integers") + } + + if !keyExists { + return nil, fmt.Errorf("key %s does not exist", key) + } + + value, ok := params.GetValues(params.Context, []string{key})[key].(string) + if !ok { + return nil, fmt.Errorf("value at key %s is not a string", key) + } + + if start < 0 { + start = len(value) - internal.AbsInt(start) + } + if end < 0 { + end = len(value) - internal.AbsInt(end) + } + + if end >= 0 && end >= start { + end += 1 + } + + if end > len(value) { + end = len(value) + } + + if start > end { + reversed = true + start, end = end, start + } + + str := value[start:end] + + if reversed { + res := "" + for i := len(str) - 1; i >= 0; i-- { + res = res + string(str[i]) + } + str = res + } + + return []byte(fmt.Sprintf("$%d\r\n%s\r\n", len(str), str)), nil +} + +func handleAppend(params internal.HandlerFuncParams) ([]byte, error) { + keys, err := appendKeyFunc(params.Command) + if err != nil { + return nil, err + } + + key := keys.WriteKeys[0] + keyExists := params.KeysExist(params.Context, keys.WriteKeys)[key] + value := params.Command[2] + if !keyExists { + if err = params.SetValues(params.Context, map[string]interface{}{ + key: internal.AdaptType(value), + }); err != nil { + return nil, err + } + return []byte(fmt.Sprintf(":%d\r\n", len(value))), nil + } + currentValue, ok := params.GetValues(params.Context, []string{key})[key].(string) + if !ok { + return nil, fmt.Errorf("Value at key %s is not a string", key) + } + newValue := fmt.Sprintf("%v%s", currentValue, value) + if err = params.SetValues(params.Context, map[string]interface{}{ + key: internal.AdaptType(newValue), + }); err != nil { + return nil, err + } + return []byte(fmt.Sprintf(":%d\r\n", len(newValue))), nil +} + +func Commands() []internal.Command { + return []internal.Command{ + { + Command: "setrange", + Module: constants.StringModule, + Categories: []string{constants.StringCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(SETRANGE key offset value) +Overwrites part of a string value with another by offset. Creates the key if it doesn't exist.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: setRangeKeyFunc, + HandlerFunc: handleSetRange, + }, + { + Command: "strlen", + Module: constants.StringModule, + Categories: []string{constants.StringCategory, constants.ReadCategory, constants.FastCategory}, + Description: "(STRLEN key) Returns length of the key's value if it's a string.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: strLenKeyFunc, + HandlerFunc: handleStrLen, + }, + { + Command: "substr", + Module: constants.StringModule, + Categories: []string{constants.StringCategory, constants.ReadCategory, constants.SlowCategory}, + Description: "(SUBSTR key start end) Returns a substring from the string value.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: subStrKeyFunc, + HandlerFunc: handleSubStr, + }, + { + Command: "getrange", + Module: constants.StringModule, + Categories: []string{constants.StringCategory, constants.ReadCategory, constants.SlowCategory}, + Description: "(GETRANGE key start end) Returns a substring from the string value.", + Sync: false, + Type: "BUILT_IN", + KeyExtractionFunc: subStrKeyFunc, + HandlerFunc: handleSubStr, + }, + { + Command: "append", + Module: constants.StringModule, + Categories: []string{constants.StringCategory, constants.WriteCategory, constants.SlowCategory}, + Description: `(APPEND key value) If key already exists and is a string, this command appends the value at the end of the string. If key does not exist it is created and set as an empty string, so APPEND will be similar to [SET] in this special case.`, + Sync: true, + Type: "BUILT_IN", + KeyExtractionFunc: appendKeyFunc, + HandlerFunc: handleAppend, + }, + } +} diff --git a/internal/modules/string/commands_test.go b/internal/modules/string/commands_test.go new file mode 100644 index 0000000..6867cb5 --- /dev/null +++ b/internal/modules/string/commands_test.go @@ -0,0 +1,556 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package str_test + +import ( + "errors" + "strconv" + "strings" + "testing" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" + "apigo.cc/go/sugardb/sugardb" + "github.com/tidwall/resp" +) + +func Test_String(t *testing.T) { + port, err := internal.GetFreePort() + if err != nil { + t.Error() + return + } + + mockServer, err := sugardb.NewSugarDB( + sugardb.WithConfig(config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.NoEviction, + }), + ) + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + t.Run("Test_HandleSetRange", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue string + command []string + expectedValue string + expectedResponse int + expectedError error + }{ + { + name: "Test that SETRANGE on non-existent string creates new string", + key: "SetRangeKey1", + presetValue: "", + command: []string{"SETRANGE", "SetRangeKey1", "10", "New String Value"}, + expectedValue: "New String Value", + expectedResponse: len("New String Value"), + expectedError: nil, + }, + { + name: "Test SETRANGE with an offset that leads to a longer resulting string", + key: "SetRangeKey2", + presetValue: "Original String Value", + command: []string{"SETRANGE", "SetRangeKey2", "16", "Portion Replaced With This New String"}, + expectedValue: "Original String Portion Replaced With This New String", + expectedResponse: len("Original String Portion Replaced With This New String"), + expectedError: nil, + }, + { + name: "SETRANGE with negative offset prepends the string", + key: "SetRangeKey3", + presetValue: "This is a preset value", + command: []string{"SETRANGE", "SetRangeKey3", "-10", "Prepended "}, + expectedValue: "Prepended This is a preset value", + expectedResponse: len("Prepended This is a preset value"), + expectedError: nil, + }, + { + name: "SETRANGE with offset that embeds new string inside the old string", + key: "SetRangeKey4", + presetValue: "This is a preset value", + command: []string{"SETRANGE", "SetRangeKey4", "0", "That"}, + expectedValue: "That is a preset value", + expectedResponse: len("That is a preset value"), + expectedError: nil, + }, + { + name: "SETRANGE with offset longer than original lengths appends the string", + key: "SetRangeKey5", + presetValue: "This is a preset value", + command: []string{"SETRANGE", "SetRangeKey5", "100", " Appended"}, + expectedValue: "This is a preset value Appended", + expectedResponse: len("This is a preset value Appended"), + expectedError: nil, + }, + { + name: "SETRANGE with offset on the last character replaces last character with new string", + key: "SetRangeKey6", + presetValue: "This is a preset value", + command: []string{"SETRANGE", "SetRangeKey6", strconv.Itoa(len("This is a preset value") - 1), " replaced"}, + expectedValue: "This is a preset valu replaced", + expectedResponse: len("This is a preset valu replaced"), + expectedError: nil, + }, + { + name: " Offset not integer", + command: []string{"SETRANGE", "key", "offset", "value"}, + expectedResponse: 0, + expectedError: errors.New("offset must be an integer"), + }, + { + name: "SETRANGE target is not a string", + key: "test-int", + presetValue: "10", + command: []string{"SETRANGE", "test-int", "10", "value"}, + expectedResponse: 0, + expectedError: errors.New("value at key test-int is not a string"), + }, + { + name: "Command too short", + command: []string{"SETRANGE", "key"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "Command too long", + command: []string{"SETRANGE", "key", "offset", "value", "value1"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != "" { + if err = client.WriteArray([]resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue), + }); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleStrLen", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue string + command []string + expectedResponse int + expectedError error + }{ + { + name: "Return the correct string length for an existing string", + key: "StrLenKey1", + presetValue: "Test String", + command: []string{"STRLEN", "StrLenKey1"}, + expectedResponse: len("Test String"), + expectedError: nil, + }, + { + name: "If the string does not exist, return 0", + key: "StrLenKey2", + presetValue: "", + command: []string{"STRLEN", "StrLenKey2"}, + expectedResponse: 0, + expectedError: nil, + }, + { + name: "Too few args", + key: "StrLenKey3", + presetValue: "", + command: []string{"STRLEN"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "Too many args", + key: "StrLenKey4", + presetValue: "", + command: []string{"STRLEN", "StrLenKey4", "StrLenKey5"}, + expectedResponse: 0, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != "" { + if err = client.WriteArray([]resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue), + }); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + }) + } + }) + + t.Run("Test_HandleSubStr", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue string + command []string + expectedResponse string + expectedError error + }{ + { + name: "Return substring within the range of the string", + key: "SubStrKey1", + presetValue: "Test String One", + command: []string{"SUBSTR", "SubStrKey1", "5", "10"}, + expectedResponse: "String", + expectedError: nil, + }, + { + name: "Return substring at the end of the string with exact end index", + key: "SubStrKey2", + presetValue: "Test String Two", + command: []string{"SUBSTR", "SubStrKey2", "12", "14"}, + expectedResponse: "Two", + expectedError: nil, + }, + { + name: "Return substring at the end of the string with end index greater than length", + key: "SubStrKey3", + presetValue: "Test String Three", + command: []string{"SUBSTR", "SubStrKey3", "12", "75"}, + expectedResponse: "Three", + expectedError: nil, + }, + { + name: "Return the substring at the start of the string with 0 start index", + key: "SubStrKey4", + presetValue: "Test String Four", + command: []string{"SUBSTR", "SubStrKey4", "0", "3"}, + expectedResponse: "Test", + expectedError: nil, + }, + { + // Return the substring with negative start index. + // Substring should begin abs(start) from the end of the string when start is negative. + name: "Return the substring with negative start index", + key: "SubStrKey5", + presetValue: "Test String Five", + command: []string{"SUBSTR", "SubStrKey5", "-11", "10"}, + expectedResponse: "String", + expectedError: nil, + }, + { + // Return reverse substring with end index smaller than start index. + // When end index is smaller than start index, the 2 indices are reversed. + name: "Return reverse substring with end index smaller than start index", + key: "SubStrKey6", + presetValue: "Test String Six", + command: []string{"SUBSTR", "SubStrKey6", "4", "0"}, + expectedResponse: "tseT", + expectedError: nil, + }, + { + name: "Command too short", + command: []string{"SUBSTR", "key", "10"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "Command too long", + command: []string{"SUBSTR", "key", "10", "15", "20"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "Start index is not an integer", + command: []string{"SUBSTR", "key", "start", "10"}, + expectedError: errors.New("start and end indices must be integers"), + }, + { + name: "End index is not an integer", + command: []string{"SUBSTR", "key", "0", "end"}, + expectedError: errors.New("start and end indices must be integers"), + }, + { + name: "Non-existent key", + command: []string{"SUBSTR", "non-existent-key", "0", "10"}, + expectedError: errors.New("key non-existent-key does not exist"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != "" { + if err = client.WriteArray([]resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.presetValue), + }); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.String() != test.expectedResponse { + t.Errorf("expected response \"%s\", got \"%s\"", test.expectedResponse, res.String()) + } + }) + } + }) + + t.Run("Test_HandleAppend", func(t *testing.T) { + t.Parallel() + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + tests := []struct { + name string + key string + presetValue interface{} + command []string + expectedResponse int + expectedError error + }{ + { + name: "Test APPEND with no preset value", + key: "AppendKey1", + command: []string{"APPEND", "AppendKey1", "Hello"}, + expectedResponse: 5, + expectedError: nil, + }, + { + name: "Test APPEND with preset value", + key: "AppendKey2", + presetValue: "Hello ", + command: []string{"APPEND", "AppendKey2", "World"}, + expectedResponse: 11, + expectedError: nil, + }, + { + name: "Test APPEND with integer preset value", + key: "AppendKey4", + presetValue: 10, + command: []string{"APPEND", "AppendKey4", "World"}, + expectedResponse: 0, + expectedError: errors.New("Value at key AppendKey4 is not a string"), + }, + { + name: "Command too short", + command: []string{"APPEND", "AppendKey5"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + { + name: "Command too long", + command: []string{"APPEND", "AppendKey5", "new value", "extra value"}, + expectedError: errors.New(constants.WrongArgsResponse), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.presetValue != "" { + if err = client.WriteArray([]resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.AnyValue(test.presetValue), + }); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected preset response to be OK, got %s", res.String()) + } + } + + command := make([]resp.Value, len(test.command)) + for i, c := range test.command { + command[i] = resp.StringValue(c) + } + + if err = client.WriteArray(command); err != nil { + t.Error(err) + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if test.expectedError != nil { + if !strings.Contains(res.Error().Error(), test.expectedError.Error()) { + t.Errorf("expected error \"%s\", got \"%s\"", test.expectedError.Error(), err.Error()) + } + return + } + + if res.Integer() != test.expectedResponse { + t.Errorf("expected response \"%d\", got \"%d\"", test.expectedResponse, res.Integer()) + } + }) + } + }) +} diff --git a/internal/modules/string/key_funcs.go b/internal/modules/string/key_funcs.go new file mode 100644 index 0000000..79f8ec8 --- /dev/null +++ b/internal/modules/string/key_funcs.go @@ -0,0 +1,66 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package str + +import ( + "errors" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" +) + +func setRangeKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} + +func strLenKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 2 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func subStrKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: cmd[1:2], + WriteKeys: make([]string, 0), + }, nil +} + +func appendKeyFunc(cmd []string) (internal.KeyExtractionFuncResult, error) { + if len(cmd) != 3 { + return internal.KeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: cmd[1:2], + }, nil +} diff --git a/internal/raft/fsm.go b/internal/raft/fsm.go new file mode 100644 index 0000000..4ae2fb9 --- /dev/null +++ b/internal/raft/fsm.go @@ -0,0 +1,179 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package raft + +import ( + "context" + "encoding/json" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "github.com/hashicorp/raft" + "io" + "log" + "net" + "strings" + "time" +) + +type FSMOpts struct { + Config config.Config + GetState func() map[int]map[string]internal.KeyData + GetCommand func(command string) (internal.Command, error) + SetValues func(ctx context.Context, entries map[string]interface{}) error + SetExpiry func(ctx context.Context, key string, expire time.Time, touch bool) + DeleteKey func(ctx context.Context, key string) error + StartSnapshot func() + FinishSnapshot func() + SetLatestSnapshotTime func(msec int64) + GetHandlerFuncParams func(ctx context.Context, cmd []string, conn *net.Conn) internal.HandlerFuncParams +} + +type FSM struct { + options FSMOpts +} + +func NewFSM(opts FSMOpts) raft.FSM { + return raft.FSM(&FSM{ + options: opts, + }) +} + +// Apply Implements raft.FSM interface +func (fsm *FSM) Apply(log *raft.Log) interface{} { + switch log.Type { + default: + // No-Op + case raft.LogCommand: + var request internal.ApplyRequest + + if err := json.Unmarshal(log.Data, &request); err != nil { + return internal.ApplyResponse{ + Error: err, + Response: nil, + } + } + + ctx := context.WithValue(context.Background(), internal.ContextServerID("ServerID"), request.ServerID) + ctx = context.WithValue(ctx, internal.ContextConnID("ConnectionID"), request.ConnectionID) + ctx = context.WithValue(ctx, "Protocol", request.Protocol) + ctx = context.WithValue(ctx, "Database", request.Database) + + switch strings.ToLower(request.Type) { + default: + return internal.ApplyResponse{ + Error: fmt.Errorf("unsupported raft command type %s", request.Type), + Response: nil, + } + + case "delete-key": + if err := fsm.options.DeleteKey(ctx, request.Key); err != nil { + return internal.ApplyResponse{ + Error: err, + Response: nil, + } + } + return internal.ApplyResponse{ + Error: nil, + Response: []byte("OK"), + } + + case "command": + // Handle command + command, err := fsm.options.GetCommand(request.CMD[0]) + if err != nil { + return internal.ApplyResponse{ + Error: err, + Response: nil, + } + } + + handler := command.HandlerFunc + + sc, err := internal.GetSubCommand(command, request.CMD) + if err != nil { + return internal.ApplyResponse{ + Error: err, + Response: nil, + } + } + subCommand, ok := sc.(internal.SubCommand) + if ok { + handler = subCommand.HandlerFunc + } + + if res, err := handler(fsm.options.GetHandlerFuncParams(ctx, request.CMD, nil)); err != nil { + return internal.ApplyResponse{ + Error: err, + Response: nil, + } + } else { + return internal.ApplyResponse{ + Error: nil, + Response: res, + } + } + } + } + + return nil +} + +// Snapshot implements raft.FSM interface +func (fsm *FSM) Snapshot() (raft.FSMSnapshot, error) { + return NewFSMSnapshot(SnapshotOpts{ + config: fsm.options.Config, + startSnapshot: fsm.options.StartSnapshot, + finishSnapshot: fsm.options.FinishSnapshot, + setLatestSnapshotTime: fsm.options.SetLatestSnapshotTime, + data: fsm.options.GetState(), + }), nil +} + +// Restore implements raft.FSM interface +func (fsm *FSM) Restore(snapshot io.ReadCloser) error { + b, err := io.ReadAll(snapshot) + + if err != nil { + log.Fatal(err) + return err + } + + data := internal.SnapshotObject{ + State: make(map[int]map[string]internal.KeyData), + LatestSnapshotMilliseconds: 0, + } + + if err = json.Unmarshal(b, &data); err != nil { + log.Fatal(err) + return err + } + + // Set state + for database, data := range internal.FilterExpiredKeys(time.Now(), data.State) { + ctx := context.WithValue(context.Background(), "Database", database) + for key, keyData := range data { + if err = fsm.options.SetValues(ctx, map[string]interface{}{key: keyData.Value}); err != nil { + log.Fatal(err) + } + fsm.options.SetExpiry(ctx, key, keyData.ExpireAt, false) + } + } + + // Set latest snapshot milliseconds. + fsm.options.SetLatestSnapshotTime(data.LatestSnapshotMilliseconds) + + return nil +} diff --git a/internal/raft/fsm_snapshot.go b/internal/raft/fsm_snapshot.go new file mode 100644 index 0000000..1a5f638 --- /dev/null +++ b/internal/raft/fsm_snapshot.go @@ -0,0 +1,80 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package raft + +import ( + "encoding/json" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "github.com/hashicorp/raft" + "strconv" + "strings" + "time" +) + +type SnapshotOpts struct { + config config.Config + data map[int]map[string]internal.KeyData + startSnapshot func() + finishSnapshot func() + setLatestSnapshotTime func(msec int64) +} + +type Snapshot struct { + options SnapshotOpts +} + +func NewFSMSnapshot(opts SnapshotOpts) *Snapshot { + return &Snapshot{ + options: opts, + } +} + +// Persist implements FSMSnapshot interface +func (s *Snapshot) Persist(sink raft.SnapshotSink) error { + s.options.startSnapshot() + + msec, err := strconv.Atoi(strings.Split(sink.ID(), "-")[2]) + if err != nil { + _ = sink.Cancel() + return err + } + + snapshotObject := internal.SnapshotObject{ + State: internal.FilterExpiredKeys(time.Now(), s.options.data), + LatestSnapshotMilliseconds: int64(msec), + } + + o, err := json.Marshal(snapshotObject) + + if err != nil { + _ = sink.Cancel() + return err + } + + if _, err = sink.Write(o); err != nil { + _ = sink.Cancel() + return err + } + + s.options.setLatestSnapshotTime(int64(msec)) + + return nil +} + +// Release implements FSMSnapshot interface +func (s *Snapshot) Release() { + s.options.finishSnapshot() +} diff --git a/internal/raft/raft.go b/internal/raft/raft.go new file mode 100644 index 0000000..9f17775 --- /dev/null +++ b/internal/raft/raft.go @@ -0,0 +1,228 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package raft + +import ( + "context" + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/memberlist" + "log" + "net" + "os" + "path/filepath" + "time" + + "github.com/hashicorp/raft" + raftboltdb "github.com/hashicorp/raft-boltdb" +) + +type Opts struct { + Config config.Config + SetValues func(ctx context.Context, entries map[string]interface{}) error + SetExpiry func(ctx context.Context, key string, expire time.Time, touch bool) + GetState func() map[int]map[string]internal.KeyData + GetCommand func(command string) (internal.Command, error) + DeleteKey func(ctx context.Context, key string) error + StartSnapshot func() + FinishSnapshot func() + SetLatestSnapshotTime func(msec int64) + GetHandlerFuncParams func(ctx context.Context, cmd []string, conn *net.Conn) internal.HandlerFuncParams +} + +type Raft struct { + options Opts + raft *raft.Raft +} + +func NewRaft(opts Opts) *Raft { + return &Raft{ + options: opts, + } +} + +func (r *Raft) RaftInit(ctx context.Context) { + conf := r.options.Config + + raftConfig := raft.DefaultConfig() + raftConfig.LocalID = raft.ServerID(conf.ServerID) + raftConfig.SnapshotThreshold = conf.SnapShotThreshold + raftConfig.SnapshotInterval = conf.SnapshotInterval + + var logStore raft.LogStore + var stableStore raft.StableStore + var snapshotStore raft.SnapshotStore + + if conf.DataDir == "" { + // No data directory provided, use in memory stores. + logStore = raft.NewInmemStore() + stableStore = raft.NewInmemStore() + snapshotStore = raft.NewInmemSnapshotStore() + } else { + boltdb, err := raftboltdb.NewBoltStore(filepath.Join(conf.DataDir, "logs.db")) + if err != nil { + log.Fatal(err) + } + + logStore, err = raft.NewLogCache(512, boltdb) + if err != nil { + log.Fatal(err) + } + + stableStore = raft.StableStore(boltdb) + + snapshotStore, err = raft.NewFileSnapshotStore(conf.DataDir, 2, os.Stdout) + if err != nil { + log.Fatal(err) + } + } + + bindAddr := fmt.Sprintf("%s:%d", conf.RaftBindAddr, conf.RaftBindPort) + advertiseAddr, err := net.ResolveTCPAddr("tcp", bindAddr) + if err != nil { + log.Fatal(err) + } + + raftTransport, err := raft.NewTCPTransport( + bindAddr, + advertiseAddr, + 10, + 5*time.Second, + os.Stdout, + ) + + if err != nil { + log.Fatal(err) + } + + // Start raft echovault + raftServer, err := raft.NewRaft( + raftConfig, + NewFSM(FSMOpts{ + Config: r.options.Config, + GetState: r.options.GetState, + GetCommand: r.options.GetCommand, + SetValues: r.options.SetValues, + SetExpiry: r.options.SetExpiry, + DeleteKey: r.options.DeleteKey, + StartSnapshot: r.options.StartSnapshot, + FinishSnapshot: r.options.FinishSnapshot, + SetLatestSnapshotTime: r.options.SetLatestSnapshotTime, + GetHandlerFuncParams: r.options.GetHandlerFuncParams, + }), + logStore, + stableStore, + snapshotStore, + raftTransport, + ) + + if err != nil { + log.Fatalf("could not start node with error; %s", err) + } + + if conf.BootstrapCluster { + // Error can be safely ignored if we're already leader + _ = raftServer.BootstrapCluster(raft.Configuration{ + Servers: []raft.Server{ + { + Suffrage: raft.Voter, + ID: raft.ServerID(conf.ServerID), + Address: raft.ServerAddress(conf.RaftBindAddr), + }, + }, + }).Error() + } + + r.raft = raftServer +} + +func (r *Raft) Apply(cmd []byte, timeout time.Duration) raft.ApplyFuture { + return r.raft.Apply(cmd, timeout) +} + +func (r *Raft) IsRaftLeader() bool { + return r.raft.State() == raft.Leader +} + +func (r *Raft) isRaftFollower() bool { + return r.raft.State() == raft.Follower +} + +func (r *Raft) HasJoinedCluster() bool { + isFollower := r.isRaftFollower() + + leaderAddr, leaderID := r.raft.LeaderWithID() + hasLeader := leaderAddr != "" && leaderID != "" + + return isFollower && hasLeader +} + +func (r *Raft) AddVoter( + id raft.ServerID, + address raft.ServerAddress, + prevIndex uint64, + timeout time.Duration, +) error { + if r.IsRaftLeader() { + raftConfig := r.raft.GetConfiguration() + if err := raftConfig.Error(); err != nil { + return errors.New("could not retrieve raft config") + } + + for _, s := range raftConfig.Configuration().Servers { + // Check if a node already exists with the current attributes. + if s.ID == id && s.Address == address { + return fmt.Errorf("node with id %s and address %s already exists", id, address) + } + } + + err := r.raft.AddVoter(id, address, prevIndex, timeout).Error() + if err != nil { + return err + } + } + + return nil +} + +func (r *Raft) RemoveServer(meta memberlist.NodeMeta) error { + if !r.IsRaftLeader() { + return errors.New("not leader, could not remove node") + } + + if err := r.raft.RemoveServer(meta.ServerID, 0, 0).Error(); err != nil { + return err + } + + return nil +} + +func (r *Raft) TakeSnapshot() error { + return r.raft.Snapshot().Error() +} + +func (r *Raft) RaftShutdown() { + // Leadership transfer if current node is the leader. + if r.IsRaftLeader() { + err := r.raft.LeadershipTransfer().Error() + if err != nil { + log.Printf("raft shutdown: %v\n", err) + return + } + log.Println("leadership transfer successful.") + } +} diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go new file mode 100644 index 0000000..7fc837a --- /dev/null +++ b/internal/snapshot/snapshot.go @@ -0,0 +1,383 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package snapshot + +import ( + "crypto/md5" + "encoding/json" + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/clock" + "io" + "io/fs" + "log" + "os" + "path" + "sync/atomic" + "time" +) + +// This package contains the snapshot engine for standalone mode. +// Snapshots in cluster mode will be handled using the raft package in the raft layer. + +type Manifest struct { + LatestSnapshotMilliseconds int64 + LatestSnapshotHash [16]byte +} + +type Engine struct { + clock clock.Clock + changeCount atomic.Uint64 + directory string + snapshotInterval time.Duration + snapshotThreshold uint64 + startSnapshotFunc func() + finishSnapshotFunc func() + getStateFunc func() map[int]map[string]internal.KeyData + setLatestSnapshotTimeFunc func(msec int64) + getLatestSnapshotTimeFunc func() int64 + setKeyDataFunc func(database int, key string, data internal.KeyData) +} + +func WithClock(clock clock.Clock) func(engine *Engine) { + return func(engine *Engine) { + engine.clock = clock + } +} + +func WithDirectory(directory string) func(engine *Engine) { + return func(engine *Engine) { + engine.directory = directory + } +} + +func WithInterval(interval time.Duration) func(engine *Engine) { + return func(engine *Engine) { + engine.snapshotInterval = interval + } +} + +func WithThreshold(threshold uint64) func(engine *Engine) { + return func(engine *Engine) { + engine.snapshotThreshold = threshold + } +} + +func WithStartSnapshotFunc(f func()) func(engine *Engine) { + return func(engine *Engine) { + engine.startSnapshotFunc = f + } +} + +func WithFinishSnapshotFunc(f func()) func(engine *Engine) { + return func(engine *Engine) { + engine.finishSnapshotFunc = f + } +} + +func WithGetStateFunc(f func() map[int]map[string]internal.KeyData) func(engine *Engine) { + return func(engine *Engine) { + engine.getStateFunc = f + } +} + +func WithSetLatestSnapshotTimeFunc(f func(mset int64)) func(engine *Engine) { + return func(engine *Engine) { + engine.setLatestSnapshotTimeFunc = f + } +} + +func WithGetLatestSnapshotTimeFunc(f func() int64) func(engine *Engine) { + return func(engine *Engine) { + engine.getLatestSnapshotTimeFunc = f + } +} + +func WithSetKeyDataFunc(f func(database int, key string, data internal.KeyData)) func(engine *Engine) { + return func(engine *Engine) { + engine.setKeyDataFunc = f + } +} + +func NewSnapshotEngine(options ...func(engine *Engine)) *Engine { + engine := &Engine{ + clock: clock.NewClock(), + changeCount: atomic.Uint64{}, + directory: "", + snapshotInterval: 5 * time.Minute, + snapshotThreshold: 1000, + startSnapshotFunc: func() {}, + finishSnapshotFunc: func() {}, + getStateFunc: func() map[int]map[string]internal.KeyData { + return make(map[int]map[string]internal.KeyData) + }, + setKeyDataFunc: func(database int, key string, data internal.KeyData) {}, + setLatestSnapshotTimeFunc: func(msec int64) {}, + getLatestSnapshotTimeFunc: func() int64 { + return 0 + }, + } + + for _, option := range options { + option(engine) + } + + if engine.snapshotInterval != 0 { + go func() { + ticker := time.NewTicker(engine.snapshotInterval) + defer func() { + ticker.Stop() + }() + for { + <-ticker.C + if engine.changeCount.Load() == engine.snapshotThreshold { + if err := engine.TakeSnapshot(); err != nil { + log.Println(err) + } + } + } + }() + } + + return engine +} + +func (engine *Engine) TakeSnapshot() error { + engine.startSnapshotFunc() + defer engine.finishSnapshotFunc() + + // Extract current time + msec := engine.clock.Now().UnixMilli() + + // Update manifest file to indicate the latest snapshot. + // If manifest file does not exist, create it. + // Manifest object will contain the following information: + // 1. Hash of the snapshot contents. + // 2. Unix time of the latest snapshot taken. + // The information above will be used to determine whether a snapshot should be taken. + // If the hash of the current state equals the hash in the manifest file, skip the snapshot. + // Otherwise, take the snapshot and update the latest snapshot timestamp and hash in the manifest file. + + var firstSnapshot bool // Tracks whether the snapshot being attempted is the first one + + dirname := path.Join(engine.directory, "snapshots") + if err := os.MkdirAll(dirname, os.ModePerm); err != nil { + log.Println(err) + return err + } + + // Open manifest file + var mf *os.File + mf, err := os.Open(path.Join(dirname, "manifest.bin")) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + // Create file if it does not exist + mf, err = os.Create(path.Join(dirname, "manifest.bin")) + if err != nil { + log.Println(err) + return err + } + firstSnapshot = true + } else { + log.Println(err) + return err + } + } + + md, err := io.ReadAll(mf) + if err != nil { + log.Println(err) + return err + } + if err := mf.Close(); err != nil { + log.Println(err) + return err + } + + manifest := new(Manifest) + + if !firstSnapshot { + if err = json.Unmarshal(md, manifest); err != nil { + log.Println(err) + return err + } + } + + // Get current state + snapshotObject := internal.SnapshotObject{ + State: internal.FilterExpiredKeys(engine.clock.Now(), engine.getStateFunc()), + LatestSnapshotMilliseconds: engine.getLatestSnapshotTimeFunc(), + } + out, err := json.Marshal(snapshotObject) + if err != nil { + log.Println(err) + return err + } + + snapshotHash := md5.Sum(out) + if snapshotHash == manifest.LatestSnapshotHash { + return errors.New("nothing new to snapshot") + } + + // Update the snapshotObject + snapshotObject.LatestSnapshotMilliseconds = msec + // Marshal the updated snapshotObject + out, err = json.Marshal(snapshotObject) + if err != nil { + log.Println(err) + return err + } + + // os.Create will replace the old manifest file + mf, err = os.Create(path.Join(dirname, "manifest.bin")) + if err != nil { + log.Println(err) + return err + } + + // Write the latest manifest data + manifest = &Manifest{ + LatestSnapshotHash: md5.Sum(out), + LatestSnapshotMilliseconds: msec, + } + mo, err := json.Marshal(manifest) + if err != nil { + log.Println(err) + return err + } + if _, err = mf.Write(mo); err != nil { + log.Println(err) + return err + } + if err = mf.Sync(); err != nil { + log.Println(err) + } + if err = mf.Close(); err != nil { + log.Println(err) + return err + } + + // Create snapshot directory + dirname = path.Join(engine.directory, "snapshots", fmt.Sprintf("%d", msec)) + if err := os.MkdirAll(dirname, os.ModePerm); err != nil { + return err + } + + // Create snapshot file + f, err := os.OpenFile(path.Join(dirname, "state.bin"), os.O_WRONLY|os.O_CREATE, os.ModePerm) + if err != nil { + log.Println(err) + return err + } + defer func() { + if err := f.Close(); err != nil { + log.Println(err) + } + }() + + // Write state to file + if _, err = f.Write(out); err != nil { + return err + } + if err = f.Sync(); err != nil { + log.Println(err) + } + + // Set the latest snapshot in unix milliseconds + engine.setLatestSnapshotTimeFunc(msec) + + // Reset the change count + engine.resetChangeCount() + + return nil +} + +func (engine *Engine) Restore() error { + mf, err := os.Open(path.Join(engine.directory, "snapshots", "manifest.bin")) + if err != nil && errors.Is(err, fs.ErrNotExist) { + return errors.New("no snapshot manifest, skipping snapshot restore") + } + if err != nil { + return err + } + defer func() { + if err := mf.Close(); err != nil { + log.Println(err) + } + }() + + manifest := new(Manifest) + + md, err := io.ReadAll(mf) + if err != nil { + return err + } + + if err = json.Unmarshal(md, manifest); err != nil { + return err + } + + if manifest.LatestSnapshotMilliseconds == 0 { + return errors.New("no snapshot to restore") + } + + sf, err := os.Open(path.Join( + engine.directory, + "snapshots", + fmt.Sprintf("%d", manifest.LatestSnapshotMilliseconds), + "state.bin")) + if err != nil && errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("snapshot file %d/state.bin not found, skipping snapshot", manifest.LatestSnapshotMilliseconds) + } + if err != nil { + return err + } + defer func() { + if err := sf.Close(); err != nil { + log.Println(err) + } + }() + + sd, err := io.ReadAll(sf) + if err != nil { + return nil + } + + snapshotObject := new(internal.SnapshotObject) + if err = json.Unmarshal(sd, snapshotObject); err != nil { + return err + } + + engine.setLatestSnapshotTimeFunc(snapshotObject.LatestSnapshotMilliseconds) + + for database, data := range internal.FilterExpiredKeys(engine.clock.Now(), snapshotObject.State) { + for key, keyData := range data { + engine.setKeyDataFunc(database, key, keyData) + } + } + + log.Println("successfully restored latest snapshot") + + return nil +} + +func (engine *Engine) IncrementChangeCount() { + engine.changeCount.Add(1) +} + +func (engine *Engine) resetChangeCount() { + engine.changeCount.Store(0) +} diff --git a/internal/snapshot/snapshot_test.go b/internal/snapshot/snapshot_test.go new file mode 100644 index 0000000..caa01da --- /dev/null +++ b/internal/snapshot/snapshot_test.go @@ -0,0 +1,136 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package snapshot_test + +import ( + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/clock" + "apigo.cc/go/sugardb/internal/snapshot" + "os" + "sync/atomic" + "testing" + "time" +) + +func Test_SnapshotEngine(t *testing.T) { + mockClock := clock.NewClock() + directory := "./testdata" + var threshold uint64 = 5 + + var snapshotInProgress atomic.Bool + startSnapshotFunc := func() { + if snapshotInProgress.Load() { + t.Error("expected snapshotInProgress to be false, got true") + } + snapshotInProgress.Store(true) + } + finishSnapshotFunc := func() { + if !snapshotInProgress.Load() { + t.Error("expected snapshotInProgress to be true, got false") + } + snapshotInProgress.Store(false) + } + + state := map[int]map[string]internal.KeyData{ + 0: { + "key1": {Value: "value-01", ExpireAt: clock.NewClock().Now().Add(13 * time.Second)}, + "key2": {Value: "value-02", ExpireAt: clock.NewClock().Now().Add(43 * time.Minute)}, + "key3": {Value: "value-03", ExpireAt: clock.NewClock().Now().Add(112 * time.Millisecond)}, + "key4": {Value: "value-04", ExpireAt: clock.NewClock().Now().Add(23 * time.Second)}, + "key5": {Value: "value-45", ExpireAt: clock.NewClock().Now().Add(121 * time.Millisecond)}, + }, + 1: { + "key1": {Value: "value1", ExpireAt: clock.NewClock().Now().Add(13 * time.Second)}, + "key2": {Value: "value2", ExpireAt: clock.NewClock().Now().Add(43 * time.Minute)}, + "key3": {Value: "value3", ExpireAt: clock.NewClock().Now().Add(112 * time.Millisecond)}, + "key4": {Value: "value4", ExpireAt: clock.NewClock().Now().Add(23 * time.Second)}, + "key5": {Value: "value5", ExpireAt: clock.NewClock().Now().Add(121 * time.Millisecond)}, + }, + } + + getStateFunc := func() map[int]map[string]internal.KeyData { + return state + } + + restoredState := make(map[int]map[string]internal.KeyData) + setKeyDataFunc := func(database int, key string, data internal.KeyData) { + if restoredState[database] == nil { + restoredState[database] = make(map[string]internal.KeyData) + } + restoredState[database][key] = data + } + + var latestSnapshotTime int64 + setLatestSnapshotTimeFunc := func(msec int64) { + latestSnapshotTime = msec + } + getLatestSnapshotTimeFunc := func() int64 { + return latestSnapshotTime + } + + snapshotEngine := snapshot.NewSnapshotEngine( + snapshot.WithClock(mockClock), + snapshot.WithDirectory(directory), + snapshot.WithInterval(10*time.Millisecond), + snapshot.WithThreshold(threshold), + snapshot.WithStartSnapshotFunc(startSnapshotFunc), + snapshot.WithFinishSnapshotFunc(finishSnapshotFunc), + snapshot.WithGetStateFunc(getStateFunc), + snapshot.WithSetKeyDataFunc(setKeyDataFunc), + snapshot.WithSetLatestSnapshotTimeFunc(setLatestSnapshotTimeFunc), + snapshot.WithGetLatestSnapshotTimeFunc(getLatestSnapshotTimeFunc), + ) + + if err := snapshotEngine.TakeSnapshot(); err != nil { + t.Error(err) + } + + // Add more records to each database in the state + for database, _ := range state { + for i := 0; i < 5; i++ { + state[database][fmt.Sprintf("key%d", i)] = internal.KeyData{ + Value: fmt.Sprintf("value%d", i), + ExpireAt: clock.NewClock().Now().Add(time.Duration(i) * time.Second), + } + } + } + + // Take another snapshot + if err := snapshotEngine.TakeSnapshot(); err != nil { + t.Error(err) + } + + if err := snapshotEngine.Restore(); err != nil { + t.Error(err) + } + + if len(restoredState) != len(state) { + t.Errorf("expected restored state to be length %d, got %d", len(state), len(restoredState)) + } + + for database, data := range restoredState { + for key, keyData := range data { + if state[database][key].Value != keyData.Value { + t.Errorf("expected value %v for key %s, got %v", state[database][key].Value, key, keyData.Value) + } + if !state[database][key].ExpireAt.Equal(keyData.ExpireAt) { + t.Errorf("expected expiry time %v for key %s, got %v", state[database][key].ExpireAt, key, keyData.ExpireAt) + } + } + } + + _ = os.RemoveAll(directory) +} diff --git a/internal/types.go b/internal/types.go new file mode 100644 index 0000000..dd38454 --- /dev/null +++ b/internal/types.go @@ -0,0 +1,244 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package internal + +import ( + "context" + "errors" + "fmt" + "net" + "reflect" + "time" + "unsafe" + + "apigo.cc/go/sugardb/internal/clock" + "apigo.cc/go/sugardb/internal/constants" +) + +type KeyData struct { + Value interface{} + ExpireAt time.Time +} + +func (k *KeyData) GetMem() (int64, error) { + var size int64 + size = int64(unsafe.Sizeof(k.ExpireAt)) + + // check type of Value field + switch v := k.Value.(type) { + case nil: + size += 0 + // AdaptType() will always ensure data type is of string, float64 or int. + case int: + size += int64(unsafe.Sizeof(v)) + // int64 data type used with module.SET + case float64, int64: + size += 8 + case string: + // Add the size of the header and the number of bytes of the string + size += int64(unsafe.Sizeof(v)) + size += int64(len(v)) + + // handle list + case []string: + for _, s := range v { + size += int64(unsafe.Sizeof(s)) + size += int64(len(s)) + } + + // handle non primitive datatypes like hash, set, and sorted set + case constants.CompositeType: + size += k.Value.(constants.CompositeType).GetMem() + + default: + return 0, errors.New(fmt.Sprintf("ERROR: type %v is not supported in method KeyData.GetMem()", reflect.TypeOf(v))) + } + + return size, nil +} + +type ContextServerID string +type ContextConnID string + +type ApplyRequest struct { + Type string `json:"Type"` // command | delete-key + ServerID string `json:"ServerID"` + ConnectionID string `json:"ConnectionID"` + Protocol int `json:"Protocol"` + Database int `json:"Database"` + CMD []string `json:"CMD"` + Key string `json:"Key"` // Optional: Used with delete-key type to specify which key to delete. +} + +type ApplyResponse struct { + Error error + Response []byte +} + +type SnapshotObject struct { + State map[int]map[string]KeyData + LatestSnapshotMilliseconds int64 +} + +// ServerInfo holds information about the server/node. +type ServerInfo struct { + Server string + Version string + Id string + Mode string + Role string + Modules []string + MemoryUsed int64 + MaxMemory uint64 +} + +// ConnectionInfo holds information about the connection +type ConnectionInfo struct { + Id uint64 // Connection id. + Name string // Alias name for this connection. + Protocol int // The RESP protocol used by the client. Can be either 2 or 3. + Database int // Database index currently being used by the connection. +} + +// KeyExtractionFuncResult is the return type of the KeyExtractionFunc for the command/subcommand. +type KeyExtractionFuncResult struct { + Channels []string // The pubsub channels the command accesses. For non pubsub commands, this should be an empty slice. + ReadKeys []string // The keys the command reads from. If no keys are read, this should be an empty slice. + WriteKeys []string // The keys the command writes to. If no keys are written to, this should be an empty slice. +} + +// KeyExtractionFunc is included with every command/subcommand. This function returns a KeyExtractionFuncResult object. +// The return value of this function is used in the ACL layer to determine whether the connection is allowed to +// execute this command. +// The cmd parameter is a string slice of the command. All the keys are extracted from this command. +type KeyExtractionFunc func(cmd []string) (KeyExtractionFuncResult, error) + +// HandlerFuncParams is the object passed to a command handler when a command is triggered. +// These params are provided to commands by the SugarDB engine to help the command hook into functions from the +// echovault package. +type HandlerFuncParams struct { + // Context is the context passed from the SugarDB instance. + Context context.Context + // Command is the string slice contains the command (e.g []string{"SET", "key", "value"}) + Command []string + // Connection is the connection that triggered this command. + // Do not write the response directly to the connection, return it from the function. + Connection *net.Conn + // KeysExist returns a map that specifies which keys exist in the keyspace. + KeysExist func(ctx context.Context, keys []string) map[string]bool + // GetExpiry returns the expiry time of a key. + GetExpiry func(ctx context.Context, key string) time.Time + // GetHashExpiry returns the expiry time of a field in a key whose value is a hash. + GetHashExpiry func(ctx context.Context, key string, field string) time.Time + // DeleteKey deletes the specified key. Returns an error if the deletion was unsuccessful. + DeleteKey func(ctx context.Context, key string) error + // GetValues retrieves the values from the specified keys. + // Non-existent keys will be nil. + GetValues func(ctx context.Context, keys []string) map[string]interface{} + // SetValues sets each of the keys with their corresponding values in the provided map. + SetValues func(ctx context.Context, entries map[string]interface{}) error + // SetExpiry sets the expiry time of the key. + SetExpiry func(ctx context.Context, key string, expire time.Time, touch bool) + // SetHashExpiry sets the expiry time of a field in a key whose value is a hash. + SetHashExpiry func(ctx context.Context, key string, field string, expire time.Time) error + // GetClock gets the clock used by the server. + // Use this when making use of time methods like .Now and .After. + // This inversion of control is a helper for testing as the clock is automatically mocked in tests. + GetClock func() clock.Clock + // GetAllCommands returns all the commands loaded in the SugarDB instance. + GetAllCommands func() []Command + // GetACL returns the SugarDB instance's ACL engine. + // There's no need to use this outside of the acl package, + // ACL authorizations for all commands will be handled automatically by the SugarDB instance as long as the + // commands KeyExtractionFunc returns the correct keys. + GetACL func() interface{} + // GetPubSub returns the SugarDB instance's PubSub engine. + // There's no need to use this outside of the pubsub package. + GetPubSub func() interface{} + // TakeSnapshot triggers a snapshot by the SugarDB instance. + TakeSnapshot func() error + // RewriteAOF triggers a compaction of the commands logs by the SugarDB instance. + RewriteAOF func() error + // GetLatestSnapshotTime returns the latest snapshot timestamp. + GetLatestSnapshotTime func() int64 + // LoadModule loads the provided module with the given args passed to the module's + // key extraction and handler functions. + LoadModule func(path string, args ...string) error + // UnloadModule removes the specified module. + // This unloads both custom modules and internal modules. + UnloadModule func(module string) + // ListModules returns the list of modules loaded in the SugarDB instance. + ListModules func() []string + // SetConnectionInfo sets the connection's protocol and clientname. + SetConnectionInfo func(conn *net.Conn, clientname string, protocol int, database int) + // GetConnectionInfo returns information about the current connection. + GetConnectionInfo func(conn *net.Conn) ConnectionInfo + // GetServerInfo returns information about the server when requested by commands such as HELLO. + GetServerInfo func() ServerInfo + // SwapDBs swaps two databases, + // so that immediately all the clients connected to a given database will see the data of the other database, + // and the other way around. + SwapDBs func(database1, database2 int) + // FlushDB flushes the specified database keys. It accepts the integer index of the database to be flushed. + // If -1 is passed as the index, then all databases will be flushed. + Flush func(database int) + // RandomKey returns a random key + RandomKey func(ctx context.Context) string + // DBSize returns the number of keys in the currently selected database. + DBSize func(ctx context.Context) int + // (TOUCH key [key ...]) Alters the last access time or access count of the key(s) + // depending on whether LFU or LRU strategy was used. + // A key is ignored if it does not exist. + TouchKey func(ctx context.Context, keys []string) (int64, error) + // GetObjectFrequency retrieves the access frequency count of a key. Can only be used with LFU type eviction policies. + GetObjectFrequency func(ctx context.Context, keys string) (int, error) + // GetObjectIdleTime retrieves the time in seconds since the last access of a key. + // Can only be used with LRU type eviction policies. + GetObjectIdleTime func(ctx context.Context, keys string) (float64, error) + // AddScript adds a script to SugarDB that isn't associated with a command. + // This script is triggered using the EVAL or EVALSHA commands. + // engine defines the interpreter to be used. Possible values: "LUA" + // scriptType is either "FILE" or "RAW". + // content contains the file path if scriptType is "FILE" and the raw script if scriptType is "RAW" + AddScript func(engine string, scriptType string, content string, args []string) error +} + +// HandlerFunc is a functions described by a command where the bulk of the command handling is done. +// This function returns a byte slice which contains a RESP2 response. The response from this function +// is forwarded directly to the client connection that triggered the command. +// In embedded mode, the response is parsed and a native Go type is returned to the caller. +type HandlerFunc func(params HandlerFuncParams) ([]byte, error) + +type Command struct { + Command string // The command keyword (e.g. "set", "get", "hset"). + Module string // The module this command belongs to. All the available modules are in the `constants` package. + Categories []string // The ACL categories this command belongs to. All the available categories are in the `constants` package. + Description string // The description of the command. Includes the command syntax. + SubCommands []SubCommand // The list of subcommands for this command. Empty if the command has no subcommands. + Sync bool // Specifies if command should be synced across replication cluster. + Type string // The type of command ("BUILT_IN", "GO_MODULE", "LUA_SCRIPT", "JS_SCRIPT"). + KeyExtractionFunc + HandlerFunc +} + +type SubCommand struct { + Command string // The keyword for this subcommand. (Check the acl module for an example of subcommands within a command). + Module string // The module this subcommand belongs to. Should be the same as the parent command. + Categories []string // The ACL categories the subcommand belongs to. + Description string // The description of the subcommand. Includes syntax. + Sync bool // Specifies if sub-command should be synced across replication cluster. + KeyExtractionFunc + HandlerFunc +} diff --git a/internal/utils.go b/internal/utils.go new file mode 100644 index 0000000..9582e86 --- /dev/null +++ b/internal/utils.go @@ -0,0 +1,496 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package internal + +import ( + "bufio" + "bytes" + "cmp" + "crypto/tls" + "errors" + "fmt" + "io" + "log" + "math/big" + "net" + "reflect" + "runtime" + "slices" + "strconv" + "strings" + "syscall" + "time" + + "apigo.cc/go/sugardb/internal/constants" + "github.com/sethvargo/go-retry" + "github.com/tidwall/resp" +) + +func AdaptType(s string) interface{} { + // Adapt the type of the parameter to string, float64 or int + n, _, err := big.ParseFloat(s, 10, 256, big.RoundingMode(big.Exact)) + + if err != nil { + return s + } + + if n.IsInt() { + i, _ := n.Int64() + return int(i) + } + + f, _ := n.Float64() + + return f +} + +func Decode(raw []byte) ([]string, error) { + reader := resp.NewReader(bytes.NewReader(raw)) + + value, _, err := reader.ReadValue() + if err != nil { + return nil, err + } + + var res []string + for i := 0; i < len(value.Array()); i++ { + res = append(res, value.Array()[i].String()) + } + + return res, nil +} + +func ReadMessage(r io.Reader) ([]byte, error) { + reader := bufio.NewReader(r) + + var res []byte + + chunk := make([]byte, 8192) + + for { + n, err := reader.Read(chunk) + if err != nil && errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + res = append(res, chunk...) + if n < len(chunk) { + break + } + clear(chunk) + } + + return bytes.Trim(res, "\x00"), nil +} + +func RetryBackoff(b retry.Backoff, maxRetries uint64, jitter, cappedDuration, maxDuration time.Duration) retry.Backoff { + backoff := b + if maxRetries > 0 { + backoff = retry.WithMaxRetries(maxRetries, backoff) + } + if jitter > 0 { + backoff = retry.WithJitter(jitter, backoff) + } + if cappedDuration > 0 { + backoff = retry.WithCappedDuration(cappedDuration, backoff) + } + if maxDuration > 0 { + backoff = retry.WithMaxDuration(maxDuration, backoff) + } + return backoff +} + +func GetIPAddress() (string, error) { + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + return "", err + } + defer func() { + if err = conn.Close(); err != nil { + log.Println(err) + } + }() + + localAddr := strings.Split(conn.LocalAddr().String(), ":")[0] + + return localAddr, nil +} + +func GetSubCommand(command Command, cmd []string) (interface{}, error) { + if command.SubCommands == nil || len(command.SubCommands) == 0 { + // If the command has no sub-commands, return nil + return nil, nil + } + if len(cmd) < 2 { + // If the cmd provided by the user has less than 2 tokens, there's no need to search for a subcommand + return nil, nil + } + for _, subCommand := range command.SubCommands { + if strings.EqualFold(subCommand.Command, cmd[1]) { + return subCommand, nil + } + } + return nil, fmt.Errorf("command %s %s not supported", cmd[0], cmd[1]) +} + +func IsWriteCommand(command Command, subCommand SubCommand) bool { + return slices.Contains(append(command.Categories, subCommand.Categories...), constants.WriteCategory) +} + +func AbsInt(n int) int { + if n < 0 { + return -n + } + return n +} + +// ParseMemory returns an integer representing the bytes in the memory string +func ParseMemory(memory string) (uint64, error) { + // Parse memory strings such as "100mb", "16gb" + memString := memory[0 : len(memory)-2] + bytesInt, err := strconv.ParseInt(memString, 10, 64) + if err != nil { + return 0, err + } + + memUnit := strings.ToLower(memory[len(memory)-2:]) + switch memUnit { + case "kb": + bytesInt *= 1024 + case "mb": + bytesInt *= 1024 * 1024 + case "gb": + bytesInt *= 1024 * 1024 * 1024 + case "tb": + bytesInt *= 1024 * 1024 * 1024 * 1024 + case "pb": + bytesInt *= 1024 * 1024 * 1024 * 1024 * 1024 + default: + return 0, fmt.Errorf("memory unit %s not supported, use (kb, mb, gb, tb, pb) ", memUnit) + } + + return uint64(bytesInt), nil +} + +// IsMaxMemoryExceeded checks whether we have exceeded the current maximum memory limit. +func IsMaxMemoryExceeded(memUsed int64, maxMemory uint64) bool { + if maxMemory == 0 { + return false + } + + // If we're currently using less than the configured max memory, return false. + if uint64(memUsed) < maxMemory { + return false + } + + // If we're currently using more than max memory, force a garbage collection before we start deleting keys. + // This measure is to prevent deleting keys that may be important when some memory can be reclaimed + // by just collecting garbage. + runtime.GC() + + // Return true when whe are above or equal to max memory. + return uint64(memUsed) >= maxMemory +} + +// FilterExpiredKeys filters out keys that are already expired, so they are not persisted. +func FilterExpiredKeys(now time.Time, state map[int]map[string]KeyData) map[int]map[string]KeyData { + for database, data := range state { + var keysToDelete []string + for k, v := range data { + // Skip keys with no expiry time. + if v.ExpireAt == (time.Time{}) { + continue + } + // If the key is already expired, mark it for deletion. + if v.ExpireAt.Before(now) { + keysToDelete = append(keysToDelete, k) + } + } + for _, key := range keysToDelete { + delete(state[database], key) + } + } + return state +} + +// CompareLex returns -1 when s2 is lexicographically greater than s1, +// 0 if they're equal and 1 if s2 is lexicographically less than s1. +func CompareLex(s1 string, s2 string) int { + if s1 == s2 { + return 0 + } + if strings.Contains(s1, s2) { + return 1 + } + if strings.Contains(s2, s1) { + return -1 + } + + limit := len(s1) + if len(s2) < limit { + limit = len(s2) + } + + var c int + for i := 0; i < limit; i++ { + c = cmp.Compare(s1[i], s2[i]) + if c != 0 { + break + } + } + + return c +} + +func EncodeCommand(cmd []string) []byte { + res := fmt.Sprintf("*%d\r\n", len(cmd)) + for _, token := range cmd { + res += fmt.Sprintf("$%d\r\n%s\r\n", len(token), token) + } + return []byte(res) +} + +func ParseNilResponse(b []byte) (bool, error) { + r := resp.NewReader(bytes.NewReader(b)) + v, _, err := r.ReadValue() + if err != nil { + return false, err + } + return v.IsNull(), nil +} + +func ParseStringResponse(b []byte) (string, error) { + r := resp.NewReader(bytes.NewReader(b)) + v, _, err := r.ReadValue() + if err != nil { + return "", err + } + return v.String(), nil +} + +func ParseIntegerResponse(b []byte) (int, error) { + r := resp.NewReader(bytes.NewReader(b)) + v, _, err := r.ReadValue() + if err != nil { + return 0, err + } + return v.Integer(), nil +} + +func ParseFloatResponse(b []byte) (float64, error) { + r := resp.NewReader(bytes.NewReader(b)) + v, _, err := r.ReadValue() + if err != nil { + return 0, err + } + return v.Float(), nil +} + +func ParseBooleanResponse(b []byte) (bool, error) { + r := resp.NewReader(bytes.NewReader(b)) + v, _, err := r.ReadValue() + if err != nil { + return false, err + } + return v.Bool(), nil +} + +func ParseStringArrayResponse(b []byte) ([]string, error) { + r := resp.NewReader(bytes.NewReader(b)) + + v, _, err := r.ReadValue() + if err != nil { + return nil, err + } + + if v.IsNull() { + return []string{}, nil + } + + if v.Type().String() == "BulkString" { + return []string{v.String()}, nil + } + + arr := make([]string, len(v.Array())) + for i, e := range v.Array() { + if e.IsNull() { + arr[i] = "" + continue + } + arr[i] = e.String() + } + return arr, nil +} + +func ParseNestedStringArrayResponse(b []byte) ([][]string, error) { + r := resp.NewReader(bytes.NewReader(b)) + v, _, err := r.ReadValue() + if err != nil { + return nil, err + } + if v.IsNull() { + return [][]string{}, nil + } + arr := make([][]string, len(v.Array())) + for i, e1 := range v.Array() { + if e1.IsNull() { + arr[i] = []string{} + continue + } + entry := make([]string, len(e1.Array())) + for j, e2 := range e1.Array() { + entry[j] = e2.String() + } + arr[i] = entry + } + return arr, nil +} + +func ParseIntegerArrayResponse(b []byte) ([]int, error) { + r := resp.NewReader(bytes.NewReader(b)) + v, _, err := r.ReadValue() + if err != nil { + return nil, err + } + if v.IsNull() { + return []int{}, nil + } + arr := make([]int, len(v.Array())) + for i, e := range v.Array() { + if e.IsNull() { + arr[i] = 0 + continue + } + arr[i] = e.Integer() + } + return arr, nil +} + +func ParseBooleanArrayResponse(b []byte) ([]bool, error) { + r := resp.NewReader(bytes.NewReader(b)) + v, _, err := r.ReadValue() + if err != nil { + return nil, err + } + if v.IsNull() { + return []bool{}, nil + } + arr := make([]bool, len(v.Array())) + for i, e := range v.Array() { + if e.IsNull() { + arr[i] = false + continue + } + arr[i] = e.Bool() + } + return arr, nil +} + +func CompareNestedStringArrays(got [][]string, want [][]string) bool { + for _, wantItem := range want { + if !slices.ContainsFunc(got, func(gotItem []string) bool { + return reflect.DeepEqual(wantItem, gotItem) + }) { + return false + } + } + for _, gotItem := range got { + if !slices.ContainsFunc(want, func(wantItem []string) bool { + return reflect.DeepEqual(wantItem, gotItem) + }) { + return false + } + } + return true +} + +func GetFreePort() (int, error) { + addr, err := net.ResolveTCPAddr("tcp", "localhost:0") + if err != nil { + return 0, err + } + + l, err := net.ListenTCP("tcp", addr) + if err != nil { + return 0, err + } + defer func() { + _ = l.Close() + }() + + return l.Addr().(*net.TCPAddr).Port, nil +} + +func GetConnection(addr string, port int) (net.Conn, error) { + var conn net.Conn + var err error + done := make(chan struct{}) + + go func() { + for { + conn, err = net.Dial("tcp", fmt.Sprintf("%s:%d", addr, port)) + if err != nil && errors.Is(err.(*net.OpError), syscall.ECONNREFUSED) { + // If we get a "connection refused error, try again." + continue + } + break + } + done <- struct{}{} + }() + + ticker := time.NewTicker(10 * time.Second) + defer func() { + ticker.Stop() + }() + + select { + case <-ticker.C: + return nil, errors.New("connection timeout") + case <-done: + return conn, err + } +} + +func GetTLSConnection(addr string, port int, config *tls.Config) (net.Conn, error) { + var conn net.Conn + var err error + done := make(chan struct{}) + + go func() { + for { + conn, err = tls.Dial("tcp", fmt.Sprintf("%s:%d", addr, port), config) + if err != nil && errors.Is(err.(*net.OpError), syscall.ECONNREFUSED) { + // If we get a "connection refused error, try again." + continue + } + break + } + done <- struct{}{} + }() + + ticker := time.NewTicker(10 * time.Second) + defer func() { + ticker.Stop() + }() + + select { + case <-ticker.C: + return nil, errors.New("connection timeout") + case <-done: + return conn, err + } +} diff --git a/internal/volumes/config/acl.json b/internal/volumes/config/acl.json new file mode 100644 index 0000000..d53446a --- /dev/null +++ b/internal/volumes/config/acl.json @@ -0,0 +1,68 @@ +[ + { + "Username": "user1", + "Enabled": true, + "NoPassword": false, + "NoKeys": false, + "Passwords": [ + { + "PasswordType": "plaintext", + "PasswordValue": "password1" + }, + { + "PasswordType": "SHA256", + "PasswordValue": "6cf615d5bcaac778352a8f1f3360d23f02f34ec182e259897fd6ce485d7870d4" + } + ], + "IncludedCategories": [ + "*" + ], + "ExcludedCategories": [], + "IncludedReadKeys": [ + "*" + ], + "IncludedWriteKeys": [ + "*" + ], + "IncludedPubSubChannels": [ + "*" + ], + "ExcludedPubSubChannels": [] + }, + { + "Username": "user2", + "Enabled": true, + "NoPassword": false, + "NoKeys": false, + "Passwords": [ + { + "PasswordType": "plaintext", + "PasswordValue": "password4" + }, + { + "PasswordType": "SHA256", + "PasswordValue": "8b2c86ea9cf2ea4eb517fd1e06b74f399e7fec0fef92e3b482a6cf2e2b092023" + } + ], + "IncludedCategories": [ + "hash", + "set", + "sortedset", + "list", + "generic" + ], + "ExcludedCategories": [], + "IncludedReadKeys": [ + "*" + ], + "IncludedWriteKeys": [ + "*" + ], + "IncludedPubSubChannels": [ + "user:channel:*" + ], + "ExcludedPubSubChannels": [ + "admin:channel:*" + ] + } +] \ No newline at end of file diff --git a/internal/volumes/config/acl.yml b/internal/volumes/config/acl.yml new file mode 100644 index 0000000..5f4c7f9 --- /dev/null +++ b/internal/volumes/config/acl.yml @@ -0,0 +1,31 @@ +- Username: "user1" + Enabled: true + NoPassword: false + NoKeys: false + Passwords: + - PasswordType: "plaintext" + PasswordValue: "password1" + - PasswordType: "SHA256" + PasswordValue: "6cf615d5bcaac778352a8f1f3360d23f02f34ec182e259897fd6ce485d7870d4" + IncludedCategories: ["*"] + ExcludedCategories: [] + IncludedReadKeys: ["*"] + IncludedWriteKeys: ["*"] + IncludedPubSubChannels: ["*"] + ExcludedPubSubChannels: [] + +- Username: "user2" + Enabled: true + NoPassword: false + NoKeys: false + Passwords: + - PasswordType: "plaintext" + PasswordValue: "password4" + - PasswordType: "SHA256" + PasswordValue: "8b2c86ea9cf2ea4eb517fd1e06b74f399e7fec0fef92e3b482a6cf2e2b092023" + IncludedCategories: ["hash", "set", "sortedset", "list", "generic"] + ExcludedCategories: [] + IncludedReadKeys: ["*"] + IncludedWriteKeys: ["*"] + IncludedPubSubChannels: ["user:channel:*"] + ExcludedPubSubChannels: ["admin:channel:*"] diff --git a/internal/volumes/modules/go/module_get/module_get.go b/internal/volumes/modules/go/module_get/module_get.go new file mode 100644 index 0000000..5969d42 --- /dev/null +++ b/internal/volumes/modules/go/module_get/module_get.go @@ -0,0 +1,73 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package main + +import ( + "context" + "errors" + "fmt" + "strconv" +) + +var Command string = "Module.Get" + +var Categories []string = []string{"read", "fast"} + +var Description string = `(Module.Get key) This module fetches the integer value from the key and returns the value ^ 2. +0 is returned if the key does not exist. An error is returned if the value is not an integer.` + +var Sync bool = false + +func KeyExtractionFunc(cmd []string, args ...string) ([]string, []string, error) { + if len(cmd) != 2 { + return nil, nil, fmt.Errorf("wrong no of args for %s command", Command) + } + return cmd[1:], []string{}, nil +} + +func HandlerFunc( + ctx context.Context, + command []string, + keysExist func(ctx context.Context, keys []string) map[string]bool, + getValues func(ctx context.Context, keys []string) map[string]interface{}, + setValues func(ctx context.Context, entries map[string]interface{}) error, + args ...string) ([]byte, error) { + + readKeys, _, err := KeyExtractionFunc(command, args...) + if err != nil { + return nil, err + } + key := readKeys[0] + exists := keysExist(ctx, readKeys)[key] + + if !exists { + return []byte(":0\r\n"), nil + } + + val, ok := getValues(ctx, []string{key})[key].(int64) + if !ok { + return nil, fmt.Errorf("value at key %s is not an integer", key) + } + + factor := val + if len(args) >= 1 { + factor, err = strconv.ParseInt(args[0], 10, 64) + if err != nil { + return nil, errors.New("first value of args must be an integer") + } + } + + return []byte(fmt.Sprintf(":%d\r\n", val*factor)), nil +} diff --git a/internal/volumes/modules/go/module_set/module_set.go b/internal/volumes/modules/go/module_set/module_set.go new file mode 100644 index 0000000..5531c5b --- /dev/null +++ b/internal/volumes/modules/go/module_set/module_set.go @@ -0,0 +1,65 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package main + +import ( + "context" + "fmt" + "strconv" + "strings" +) + +var Command string = "Module.Set" + +var Categories []string = []string{"write", "fast"} + +var Description string = `(Module.Set key value) This module stores the given value at the specified key. +The value must be an integer` + +var Sync bool = true + +func KeyExtractionFunc(cmd []string, args ...string) ([]string, []string, error) { + if len(cmd) != 3 { + return nil, nil, fmt.Errorf("wrong no of args for %s command", strings.ToLower(Command)) + } + return []string{}, cmd[1:2], nil +} + +func HandlerFunc( + ctx context.Context, + command []string, + keysExist func(ctx context.Context, keys []string) map[string]bool, + getValues func(ctx context.Context, keys []string) map[string]interface{}, + setValues func(ctx context.Context, entries map[string]interface{}) error, + args ...string) ([]byte, error) { + + _, writeKeys, err := KeyExtractionFunc(command, args...) + if err != nil { + return nil, err + } + key := writeKeys[0] + + value, err := strconv.ParseInt(command[2], 10, 64) + if err != nil { + return nil, err + } + + err = setValues(ctx, map[string]interface{}{key: value}) + if err != nil { + return nil, err + } + + return []byte("+OK\r\n"), nil +} diff --git a/internal/volumes/modules/js/example.js b/internal/volumes/modules/js/example.js new file mode 100644 index 0000000..d4c4336 --- /dev/null +++ b/internal/volumes/modules/js/example.js @@ -0,0 +1,113 @@ + +// The keyword to trigger the command +var command = "JS.EXAMPLE" + +// The string array of categories this command belongs to. +// This array can contain both built-in categories and new custom categories. +var categories = ["generic", "write", "fast"] + +// The description of the command. +var description = "(JS.EXAMPLE) Example JS command that sets various data types to keys" + +// Whether the command should be synced across the RAFT cluster. +var sync = true + +/** + * keyExtractionFunc is a function that extracts the keys from the command and returns them to SugarDB.keyExtractionFunc + * The returned data from this function is used in the Access Control Layer to determine if the current connection is + * authorized to execute this command. The function must return a table that specifies which keys in this command + * are read keys and which ones are write keys. + * Example return: {readKeys: ["key1", "key2"], writeKeys: ["key3", "key4", "key5"]} + * + * 1. "command" is a string array representing the command that triggered this key extraction function. + * + * 2. "args" is a string array of the modifier args that were passed when loading the module into SugarDB. + * These args are passed to the key extraction function everytime it's invoked. +*/ +function keyExtractionFunc(command, args) { + if (command.length > 1) { + throw "wrong number of args, expected 0" + } + return { + readKeys: [], + writeKeys: [] + } +} + +/** + * handlerFunc is the command's handler function. The function is passed some arguments that allow it to interact with + * SugarDB. The function must return a valid RESP response or throw an error. + * The handler function accepts the following args: + * + * 1. "context" is a table that contains some information about the environment this command has been executed in. + * Example: {protocol: 2, database: 0} + * This object contains the following properties: + * i) protocol - the protocol version of the client that executed the command (either 2 or 3). + * ii) database - the active database index of the client that executed the command. + * + * 2. "command" is the string array representing the command that triggered this handler function. + * + * 3. "keyExists" is a function that can be called to check if a list of keys exists in the SugarDB store database. + * This function accepts a string array of keys to check and returns a table with each key having a corresponding + * boolean value indicating whether it exists. + * Examples: + * i) Example invocation: keyExists(["key1", "key2", "key3"]) + * ii) Example return: {key1: true, key2: false, key3: true} + * + * 4. "getValues" is a function that can be called to retrieve values from the SugarDB store database. + * The function accepts a string array of keys whose values we would like to fetch, and returns a table with each key + * containing the corresponding value from the store. + * The possible data types for the values are: number, string, nil, hash, set, zset + * Examples: + * i) Example invocation: getValues(["key1", "key2", "key3"]) + * ii) Example return: {key1: 3.142, key2: nil, key3: "Pi"} + * + * 5. "setValues" is a function that can be called to set values in the active database in the SugarDB store. + * This function accepts a table with keys and the corresponding values to set for each key in the active database + * in the store. + * The accepted data types for the values are: number, string, nil, hash, set, zset. + * The setValues function does not return anything. + * Examples: + * i) Example invocation: setValues({key1: 3.142, key2: nil, key3: "Pi"}) + * + * 6. "args" is a string array of the modifier args passed to the module at load time. These args are passed to the + * handler everytime it's invoked. + */ +function handlerFunc(ctx, command, keysExist, getValues, setValues, args) { + // Set various data types to keys + var keyValues = { + "numberKey": 42, + "stringKey": "Hello, SugarDB!", + "floatKey": 3.142, + "nilKey": null, + } + + // Store the values in the database + setValues(keyValues) + + // Verify the values have been set correctly + var keysToGet = ["numberKey", "stringKey", "floatKey", "nilKey"] + var retrievedValues = getValues(keysToGet) + + // Create an array to track mismatches + var mismatches = []; + for (var key in keyValues) { + if (Object.prototype.hasOwnProperty.call(keyValues, key)) { + var expectedValue = keyValues[key]; + var retrievedValue = retrievedValues[key]; + if (retrievedValue !== expectedValue) { + var msg = "Key " + key + ": expected " + expectedValue + ", got " + retrievedValue + mismatches.push(msg); + console.log(msg) + } + } + } + + // If mismatches exist, return an error + if (mismatches.length > 0) { + throw "values mismatch" + } + + // If all values match, return OK + return "+OK\r\n" +} \ No newline at end of file diff --git a/internal/volumes/modules/js/hash.js b/internal/volumes/modules/js/hash.js new file mode 100644 index 0000000..51f33e4 --- /dev/null +++ b/internal/volumes/modules/js/hash.js @@ -0,0 +1,136 @@ + +// The keyword to trigger the command +var command = "JS.HASH" + +// The string array of categories this command belongs to. +// This array can contain both built-in categories and new custom categories. +var categories = ["hash", "write", "fast"] + +// The description of the command. +var description = "(JS.HASH key) This is an example of working with SugarDB hashes/maps in js scripts." + +// Whether the command should be synced across the RAFT cluster. +var sync = true + +/** + * keyExtractionFunc is a function that extracts the keys from the command and returns them to SugarDB.keyExtractionFunc + * The returned data from this function is used in the Access Control Layer to determine if the current connection is + * authorized to execute this command. The function must return a table that specifies which keys in this command + * are read keys and which ones are write keys. + * Example return: {readKeys: ["key1", "key2"], writeKeys: ["key3", "key4", "key5"]} + * + * 1. "command" is a string array representing the command that triggered this key extraction function. + * + * 2. "args" is a string array of the modifier args that were passed when loading the module into SugarDB. + * These args are passed to the key extraction function everytime it's invoked. + */ +function keyExtractionFunc(command, args) { + if (command.length !== 2) { + throw "wrong number of args, expected 1." + } + return { + "readKeys": [], + "writeKeys": [command[1]] + } +} + +/** + * handlerFunc is the command's handler function. The function is passed some arguments that allow it to interact with + * SugarDB. The function must return a valid RESP response or throw an error. + * The handler function accepts the following args: + * + * 1. "context" is a table that contains some information about the environment this command has been executed in. + * Example: {protocol: 2, database: 0} + * This object contains the following properties: + * i) protocol - the protocol version of the client that executed the command (either 2 or 3). + * ii) database - the active database index of the client that executed the command. + * + * 2. "command" is the string array representing the command that triggered this handler function. + * + * 3. "keyExists" is a function that can be called to check if a list of keys exists in the SugarDB store database. + * This function accepts a string array of keys to check and returns a table with each key having a corresponding + * boolean value indicating whether it exists. + * Examples: + * i) Example invocation: keyExists(["key1", "key2", "key3"]) + * ii) Example return: {key1: true, key2: false, key3: true} + * + * 4. "getValues" is a function that can be called to retrieve values from the SugarDB store database. + * The function accepts a string array of keys whose values we would like to fetch, and returns a table with each key + * containing the corresponding value from the store. + * The possible data types for the values are: number, string, nil, hash, set, zset + * Examples: + * i) Example invocation: getValues(["key1", "key2", "key3"]) + * ii) Example return: {key1: 3.142, key2: nil, key3: "Pi"} + * + * 5. "setValues" is a function that can be called to set values in the active database in the SugarDB store. + * This function accepts a table with keys and the corresponding values to set for each key in the active database + * in the store. + * The accepted data types for the values are: number, string, nil, hash, set, zset. + * The setValues function does not return anything. + * Examples: + * i) Example invocation: setValues({key1: 3.142, key2: nil, key3: "Pi"}) + * + * 6. "args" is a string array of the modifier args passed to the module at load time. These args are passed to the + * handler everytime it's invoked. + */ +function handlerFunc(ctx, command, keysExist, getValues, setValues, args) { + // Initialize a new hash + var h = new Hash(); + + // Set values in the hash + h.set({ + "field1": "value1", + "field2": "value2", + "field3": "value3", + "field4": "value4" + }); + + // Set hash in the store + var setVals = {} + setVals[command[1]] = h + setValues(setVals); + + // Check that the fields were correctly set in the database + var hashValue = getValues([command[1]])[command[1]]; + console.assert(hashValue.get(["field1"]).field1 === "value1", "field1 not set correctly"); + console.assert(hashValue.get(["field2"]).field2 === "value2", "field2 not set correctly"); + console.assert(hashValue.get(["field3"]).field3 === "value3", "field3 not set correctly"); + console.assert(hashValue.get(["field4"]).field4 === "value4", "field4 not set correctly"); + + // Test get method + var retrieved = h.get(["field1", "field2"]); + console.assert(retrieved.field1 === "value1", "get method failed for field1"); + console.assert(retrieved.field2 === "value2", "get method failed for field2"); + + // Test exists method + var exists = h.exists(["field1", "fieldX"]); + console.assert(exists.field1 === true, "exists method failed for field1"); + console.assert(exists.fieldX === false, "exists method failed for fieldX"); + + // Test setnx method + var setnxCount = h.setnx({ + "field1": "new_value1", // Should not overwrite + "field5": "value5" // Should set + }); + console.assert(setnxCount === 1, "setnx did not set the correct number of fields"); + console.assert(h.get(["field1"]).field1 === "value1", "setnx overwrote field1"); + console.assert(h.get(["field5"]).field5 === "value5", "setnx failed to set field5"); + + // Test del method + var delCount = h.del(["field2", "field3"]); + console.assert(delCount === 2, "del did not delete the correct number of fields"); + console.assert(h.exists(["field2"]).field2 === false, "del failed to delete field2"); + console.assert(h.exists(["field3"]).field3 === false, "del failed to delete field3"); + + // Test len method + console.assert(h.len() === 3, "len method returned incorrect value"); + + // Retrieve and verify all remaining fields + var remainingFields = h.all(); + console.assert(remainingFields.field1 === "value1", "field1 missing after deletion"); + console.assert(remainingFields.field4 === "value4", "field4 missing after deletion"); + console.assert(remainingFields.field5 === "value5", "field5 missing after deletion"); + + // Return RESP response + return "+OK\r\n"; +} \ No newline at end of file diff --git a/internal/volumes/modules/js/list.js b/internal/volumes/modules/js/list.js new file mode 100644 index 0000000..922ee2e --- /dev/null +++ b/internal/volumes/modules/js/list.js @@ -0,0 +1,128 @@ + +// The keyword to trigger the command +var command = "JS.LIST" + +// The string array of categories this command belongs to. +// This array can contain both built-in categories and new custom categories. +var categories = ["list", "write", "fast"] + +// The description of the command. +var description = "(JS.LIST key) This is an example of working with SugarDB lists in js scripts." + +// Whether the command should be synced across the RAFT cluster. +var sync = true + +/** + * keyExtractionFunc is a function that extracts the keys from the command and returns them to SugarDB.keyExtractionFunc + * The returned data from this function is used in the Access Control Layer to determine if the current connection is + * authorized to execute this command. The function must return a table that specifies which keys in this command + * are read keys and which ones are write keys. + * Example return: {readKeys: ["key1", "key2"], writeKeys: ["key3", "key4", "key5"]} + * + * 1. "command" is a string array representing the command that triggered this key extraction function. + * + * 2. "args" is a string array of the modifier args that were passed when loading the module into SugarDB. + * These args are passed to the key extraction function everytime it's invoked. + */ +function keyExtractionFunc(command, args) { + if (command.length !== 2) { + throw "wrong number of args, expected 4." + } + return { + "readKeys": [], + "writeKeys": [command[1]] + } +} + +/** + * handlerFunc is the command's handler function. The function is passed some arguments that allow it to interact with + * SugarDB. The function must return a valid RESP response or throw an error. + * The handler function accepts the following args: + * + * 1. "context" is a table that contains some information about the environment this command has been executed in. + * Example: {protocol: 2, database: 0} + * This object contains the following properties: + * i) protocol - the protocol version of the client that executed the command (either 2 or 3). + * ii) database - the active database index of the client that executed the command. + * + * 2. "command" is the string array representing the command that triggered this handler function. + * + * 3. "keyExists" is a function that can be called to check if a list of keys exists in the SugarDB store database. + * This function accepts a string array of keys to check and returns a table with each key having a corresponding + * boolean value indicating whether it exists. + * Examples: + * i) Example invocation: keyExists(["key1", "key2", "key3"]) + * ii) Example return: {key1: true, key2: false, key3: true} + * + * 4. "getValues" is a function that can be called to retrieve values from the SugarDB store database. + * The function accepts a string array of keys whose values we would like to fetch, and returns a table with each key + * containing the corresponding value from the store. + * The possible data types for the values are: number, string, nil, hash, set, zset + * Examples: + * i) Example invocation: getValues(["key1", "key2", "key3"]) + * ii) Example return: {key1: 3.142, key2: nil, key3: "Pi"} + * + * 5. "setValues" is a function that can be called to set values in the active database in the SugarDB store. + * This function accepts a table with keys and the corresponding values to set for each key in the active database + * in the store. + * The accepted data types for the values are: number, string, nil, hash, set, zset. + * The setValues function does not return anything. + * Examples: + * i) Example invocation: setValues({key1: 3.142, key2: nil, key3: "Pi"}) + * + * 6. "args" is a string array of the modifier args passed to the module at load time. These args are passed to the + * handler everytime it's invoked. + */ +function handlerFunc(ctx, command, keysExist, getValues, setValues, args) { + // Helper function to compare lists + function compareLists(expected, actual) { + if (expected.length !== actual.length) { + return { + isValid: false, + errorMessage: "Length mismatch: expected " + expected.length + ", got " + actual.length + }; + } + for (var i = 0; i < expected.length; i++) { + if (expected[i] !== actual[i]) { + return { + isValid: false, + errorMessage: "Mismatch at index " + (i + 1) + ": expected " + expected[i] + ", got " + actual[i] + }; + } + } + return { isValid: true }; + } + + var key = command[1]; // Adjusted for JavaScript's 0-based indexing + + // First list to set + var initialList = ["apple", "banana", "cherry"]; + var setVals = {} + setVals[key] = initialList + setValues(setVals); + + // Retrieve and verify the first list + var retrievedValues = getValues([key]); + var retrievedList = retrievedValues[key]; + var result = compareLists(initialList, retrievedList); + if (!result.isValid) { + throw new Error(result.errorMessage); + } + + // Update the list with new values + var updatedList = ["orange", "grape", "watermelon"]; + setVals = {} + setVals[key] = updatedList + setValues(setVals); + + // Retrieve and verify the updated list + retrievedValues = getValues([key]); + retrievedList = retrievedValues[key]; + result = compareLists(updatedList, retrievedList); + if (!result.isValid) { + throw result.errorMessage; + } + + // If all assertions pass + return "+OK\r\n"; +} \ No newline at end of file diff --git a/internal/volumes/modules/js/set.js b/internal/volumes/modules/js/set.js new file mode 100644 index 0000000..f33436c --- /dev/null +++ b/internal/volumes/modules/js/set.js @@ -0,0 +1,177 @@ + +// The keyword to trigger the command +var command = "JS.SET" + +// The string array of categories this command belongs to. +// This array can contain both built-in categories and new custom categories. +var categories = ["set", "write", "fast"] + +// The description of the command. +var description = "(JS.SET key member [member ...]]) " + + "This is an example of working with SugarDB sets in js scripts." + +// Whether the command should be synced across the RAFT cluster. +var sync = true + +/** + * keyExtractionFunc is a function that extracts the keys from the command and returns them to SugarDB.keyExtractionFunc + * The returned data from this function is used in the Access Control Layer to determine if the current connection is + * authorized to execute this command. The function must return a table that specifies which keys in this command + * are read keys and which ones are write keys. + * Example return: {readKeys: ["key1", "key2"], writeKeys: ["key3", "key4", "key5"]} + * + * 1. "command" is a string array representing the command that triggered this key extraction function. + * + * 2. "args" is a string array of the modifier args that were passed when loading the module into SugarDB. + * These args are passed to the key extraction function everytime it's invoked. + */ +function keyExtractionFunc(command, args) { + // Check the length of the command array + if (command.length < 3) { + throw new Error("wrong number of args, expected 2 or more"); + } + // Return the result object + return { + readKeys: [], + writeKeys: [command[1], command[2], command[3]] + }; +} + +/** + * handlerFunc is the command's handler function. The function is passed some arguments that allow it to interact with + * SugarDB. The function must return a valid RESP response or throw an error. + * The handler function accepts the following args: + * + * 1. "context" is a table that contains some information about the environment this command has been executed in. + * Example: {protocol: 2, database: 0} + * This object contains the following properties: + * i) protocol - the protocol version of the client that executed the command (either 2 or 3). + * ii) database - the active database index of the client that executed the command. + * + * 2. "command" is the string array representing the command that triggered this handler function. + * + * 3. "keyExists" is a function that can be called to check if a list of keys exists in the SugarDB store database. + * This function accepts a string array of keys to check and returns a table with each key having a corresponding + * boolean value indicating whether it exists. + * Examples: + * i) Example invocation: keyExists(["key1", "key2", "key3"]) + * ii) Example return: {key1: true, key2: false, key3: true} + * + * 4. "getValues" is a function that can be called to retrieve values from the SugarDB store database. + * The function accepts a string array of keys whose values we would like to fetch, and returns a table with each key + * containing the corresponding value from the store. + * The possible data types for the values are: number, string, nil, hash, set, zset + * Examples: + * i) Example invocation: getValues(["key1", "key2", "key3"]) + * ii) Example return: {key1: 3.142, key2: nil, key3: "Pi"} + * + * 5. "setValues" is a function that can be called to set values in the active database in the SugarDB store. + * This function accepts a table with keys and the corresponding values to set for each key in the active database + * in the store. + * The accepted data types for the values are: number, string, nil, hash, set, zset. + * The setValues function does not return anything. + * Examples: + * i) Example invocation: setValues({key1: 3.142, key2: nil, key3: "Pi"}) + * + * 6. "args" is a string array of the modifier args passed to the module at load time. These args are passed to the + * handler everytime it's invoked. + */ +function handlerFunc(ctx, command, keysExist, getValues, setValues, args) { + // Ensure there are enough arguments + if (command.length < 3) { + throw "wrong number of arguments, expected at least 3"; + } + + // Extract the keys + var key1 = command[1]; + var key2 = command[2]; + var key3 = command[3]; + + // Create two sets for testing `move` and `subtract` + var set1 = new Set(["elem1", "elem2", "elem3"]); + var set2 = new Set(["elem4", "elem5"]); + + // Add elements to set1 + set1.add(["elem6", "elem7"]); + + // Check if an element exists in set1 + var containsElem1 = set1.contains("elem1"); + console.assert(containsElem1, "set1 does not contain expected element elem1") + var containsElemUnknown = set1.contains("unknown"); + console.assert(!containsElemUnknown, "set1 contains unknown element") + + // Get the size of set1 + var set1Cardinality = set1.cardinality(); + console.assert(set1Cardinality, "set1 cardinality expected 3, got " + set1Cardinality) + + // Remove elements from set1 + set1.remove(["elem1", "elem2"]); + var removedCount = 2; // Manually track removed count + + // Pop elements from set1 + set1.add(["elem1", "elem2"]); + var poppedElements = set1.pop(2); + console.assert( + poppedElements.length === 2, + "popped elements length must be 2, got " + poppedElements.length + ) + + // Get random elements from set1 + var randomElements = set1.random(2); + console.assert( + randomElements.length === 2, + "random elements length must be 2, got " + randomElements.length + ) + + + // Retrieve all elements from set1 + var allElements = set1.all(); + console.assert( + allElements.length === set1.cardinality(), + "all elements length must be " + set1.cardinality() + ", got " + allElements.length + ) + + // Move an element from set1 to set2 + set1.add(["elem3"]) + var moveSuccess = false; + if (set1.contains("elem3")) { + moveSuccess = set1.move(set2, "elem3"); + } + console.assert(moveSuccess, "element not moved from set1 to set2") + + // Verify that the element was moved + var set2ContainsMoved = set2.contains("elem3"); + console.assert(set2ContainsMoved, "set2 does not contain expected element after move") + var set1NoLongerContainsMoved = !set1.contains("elem3"); + console.assert(set1NoLongerContainsMoved, "set1 still contains unexpected element after move") + + // Subtract set2 from set1 + var resultSet = set1.subtract([set2]); + + // Store the modified sets + var setVals = {} + setVals[key1] = set1 + setVals[key2] = set2 + setVals[key3] = resultSet + setValues(setVals); + + // Retrieve the sets back to verify storage + var storedValues = getValues([key1, key2, key3]); + var storedSet1 = storedValues[key1]; + var storedSet2 = storedValues[key2]; + var storedResultSet = storedValues[key3]; + + // Perform additional checks to ensure consistency + if (!storedSet1 || storedSet1.size !== set1.size) { + throw "Stored set1 does not match the modified set1"; + } + if (!storedSet2 || storedSet2.size !== set2.size) { + throw "Stored set2 does not match the modified set2"; + } + if (!storedResultSet || storedResultSet.size !== resultSet.size) { + throw "Stored result set does not match the computed result set"; + } + + // If all operations succeed, return "OK" + return "+OK\r\n"; +} \ No newline at end of file diff --git a/internal/volumes/modules/js/zset.js b/internal/volumes/modules/js/zset.js new file mode 100644 index 0000000..de6b0b5 --- /dev/null +++ b/internal/volumes/modules/js/zset.js @@ -0,0 +1,170 @@ + +// The keyword to trigger the command +var command = "JS.ZSET" + +// The string array of categories this command belongs to. +// This array can contain both built-in categories and new custom categories. +var categories = ["sortedset", "write", "fast"] + +// The description of the command. +var description = "(JS.ZSET key member score [member score ...]) " + + "This is an example of working with SugarDB sorted sets in js scripts." + +// Whether the command should be synced across the RAFT cluster. +var sync = true + +/** + * keyExtractionFunc is a function that extracts the keys from the command and returns them to SugarDB.keyExtractionFunc + * The returned data from this function is used in the Access Control Layer to determine if the current connection is + * authorized to execute this command. The function must return a table that specifies which keys in this command + * are read keys and which ones are write keys. + * Example return: {readKeys: ["key1", "key2"], writeKeys: ["key3", "key4", "key5"]} + * + * 1. "command" is a string array representing the command that triggered this key extraction function. + * + * 2. "args" is a string array of the modifier args that were passed when loading the module into SugarDB. + * These args are passed to the key extraction function everytime it's invoked. + */ +function keyExtractionFunc(command, args) { + if (command.length < 4) { + throw "wrong number of args, expected 3 or more"; + } + return { + readKeys: [], + writeKeys: [command[1], command[2], command[3]] + }; +} + +/** + * handlerFunc is the command's handler function. The function is passed some arguments that allow it to interact with + * SugarDB. The function must return a valid RESP response or throw an error. + * The handler function accepts the following args: + * + * 1. "context" is a table that contains some information about the environment this command has been executed in. + * Example: {protocol: 2, database: 0} + * This object contains the following properties: + * i) protocol - the protocol version of the client that executed the command (either 2 or 3). + * ii) database - the active database index of the client that executed the command. + * + * 2. "command" is the string array representing the command that triggered this handler function. + * + * 3. "keyExists" is a function that can be called to check if a list of keys exists in the SugarDB store database. + * This function accepts a string array of keys to check and returns a table with each key having a corresponding + * boolean value indicating whether it exists. + * Examples: + * i) Example invocation: keyExists(["key1", "key2", "key3"]) + * ii) Example return: {key1: true, key2: false, key3: true} + * + * 4. "getValues" is a function that can be called to retrieve values from the SugarDB store database. + * The function accepts a string array of keys whose values we would like to fetch, and returns a table with each key + * containing the corresponding value from the store. + * The possible data types for the values are: number, string, nil, hash, set, zset + * Examples: + * i) Example invocation: getValues(["key1", "key2", "key3"]) + * ii) Example return: {key1: 3.142, key2: nil, key3: "Pi"} + * + * 5. "setValues" is a function that can be called to set values in the active database in the SugarDB store. + * This function accepts a table with keys and the corresponding values to set for each key in the active database + * in the store. + * The accepted data types for the values are: number, string, nil, hash, set, zset. + * The setValues function does not return anything. + * Examples: + * i) Example invocation: setValues({key1: 3.142, key2: nil, key3: "Pi"}) + * + * 6. "args" is a string array of the modifier args passed to the module at load time. These args are passed to the + * handler everytime it's invoked. + */ +function handlerFunc(ctx, command, keysExist, getValues, setValues, args) { + // Ensure there are enough arguments + if (command.length < 4) { + throw new Error("wrong number of arguments, expected at least 3"); + } + + var key1 = command[1]; + var key2 = "key2"; + var key3 = "key3"; + + // Create `ZMember` instances + var member1 = new ZMember({ value: "member1", score: 10 }); + var member2 = new ZMember({ value: "member2", score: 20 }); + var member3 = new ZMember({ value: "member3", score: 30 }); + + // Create a `ZSet` and add initial members + var zset1 = new ZSet(member1, member2); + + // Test `add` method with a new member + zset1.add([member3]); + + // Test `update` method by modifying an existing member + zset1.update([new ZMember({ value: "member1", score: 15 })]); + + // Test `remove` method + zset1.remove("member2"); + + // Test `cardinality` method + var zset1Cardinality = zset1.cardinality(); + console.assert(zset1Cardinality === 2, "zset1 expected cardinality is 2, got " + zset1Cardinality) + + // Test `contains` method + var containsMember3 = zset1.contains("member3"); + console.assert(containsMember3, "zset1 does not contain expected member member3") + var containsNonExistent = zset1.contains("nonexistent"); + console.assert(!containsNonExistent, "zset1 contains unexpected element 'nonexistent'") + + // Test `random` method + var randomMembers = zset1.random(2); + console.assert( + randomMembers.length === 2, + "zset1 random members result should be length 2, got " + randomMembers.length + ) + + // Test `all` method + var allMembers = zset1.all(); + console.assert( + allMembers.length === zset1.cardinality(), + "zset1 'all' did not return expected cardinality of " + zset1.cardinality + ", got " + allMembers.length + ) + + // Create another `ZSet` to test `subtract` manually + var zset2 = new ZSet(new ZMember({ value: "member3", score: 30 })); + // Subtract the zset2 from zset1 + var resultZSet = zset1.subtract([zset2]) + + // Store the `ZSet` objects in SugarDB + var setVals = {} + setVals[key1] = zset1 + setVals[key2] = zset2 + setVals[key3] = resultZSet + setValues(setVals); + + // Retrieve the stored `ZSet` objects to verify storage + var storedValues = getValues([key1, key2, key3]); + var storedZset1 = storedValues[key1]; + var storedZset2 = storedValues[key2]; + var storedZset3 = storedValues[key3]; + + // Perform consistency checks + if (!storedZset1 || storedZset1.cardinality() !== zset1.cardinality()) { + throw "Stored zset1 does not match the modified zset1"; + } + if (!storedZset2 || storedZset2.cardinality() !== zset2.cardinality()) { + throw "Stored zset2 does not match the modified zset2"; + } + if (!storedZset3 || storedZset3.cardinality() !== resultZSet.cardinality()) { + throw "Stored result zset does not match the computed result zset" + } + + // Test `ZMember` methods + var memberValue = member1.value(); + member1.value("updated_member1"); + var updatedValue = member1.value(); + console.assert(updatedValue !== memberValue, "updated member value still the same as old value") + + var memberScore = member1.score(); + member1.score(50); + var updatedScore = member1.score(); + console.assert(updatedScore !== memberScore, "updated member score still the same as old score") + + // Return an "OK" response + return "+OK\r\n"; +} \ No newline at end of file diff --git a/internal/volumes/modules/lua/example.lua b/internal/volumes/modules/lua/example.lua new file mode 100644 index 0000000..2c4b0c6 --- /dev/null +++ b/internal/volumes/modules/lua/example.lua @@ -0,0 +1,106 @@ + +-- The keyword to trigger the command +command = "LUA.EXAMPLE" + +--[[ +The string array of categories this command belongs to. +This array can contain both built-in categories and new custom categories. +]] +categories = {"generic", "write", "fast"} + +-- The description of the command +description = "(LUA.EXAMPLE) Example lua command that sets various data types to keys" + +-- Whether the command should be synced across the RAFT cluster +sync = true + +--[[ +keyExtractionFunc is a function that extracts the keys from the command and returns them to SugarDB.keyExtractionFunc +The returned data from this function is used in the Access Control Layer to determine if the current connection is +authorized to execute this command. The function must return a table that specifies which keys in this command +are read keys and which ones are write keys. +Example return: {["readKeys"] = {"key1", "key2"}, ["writeKeys"] = {"key3", "key4", "key5"}} + +1. "command" is a string array representing the command that triggered this key extraction function. + +2. "args" is a string array of the modifier args that were passed when loading the module into SugarDB. + These args are passed to the key extraction function everytime it's invoked. +]] +function keyExtractionFunc (command, args) + if (#command ~= 1) then + error("wrong number of args, expected 0") + end + return { ["readKeys"] = {}, ["writeKeys"] = {} } +end + +--[[ +handlerFunc is the command's handler function. The function is passed some arguments that allow it to interact with +SugarDB. The function must return a valid RESP response or throw an error. +The handler function accepts the following args: + +1. "context" is a table that contains some information about the environment this command has been executed in. + Example: {["protocol"] = 2, ["database"] = 0} + This object contains the following properties: + i) protocol - the protocol version of the client that executed the command (either 2 or 3). + ii) database - the active database index of the client that executed the command. + +2. "command" is the string array representing the command that triggered this handler function. + +3. "keyExists" is a function that can be called to check if a list of keys exists in the SugarDB store database. + This function accepts a string array of keys to check and returns a table with each key having a corresponding + boolean value indicating whether it exists. + Examples: + i) Example invocation: keyExists({"key1", "key2", "key3"}) + ii) Example return: {["key1"] = true, ["key2"] = false, ["key3"] = true} + +4. "getValues" is a function that can be called to retrieve values from the SugarDB store database. + The function accepts a string array of keys whose values we would like to fetch, and returns a table with each key + containing the corresponding value from the store. + The possible data types for the values are: number, string, nil, hash, set, zset + Examples: + i) Example invocation: getValues({"key1", "key2", "key3"}) + ii) Example return: {["key1"] = 3.142, ["key2"] = nil, ["key3"] = "Pi"} + +5. "setValues" is a function that can be called to set values in the active database in the SugarDB store. + This function accepts a table with keys and the corresponding values to set for each key in the active database + in the store. + The accepted data types for the values are: number, string, nil, hash, set, zset. + The setValues function does not return anything. + Examples: + i) Example invocation: setValues({["key1"] = 3.142, ["key2"] = nil, ["key3"] = "Pi"}) + +6. "args" is a string array of the modifier args passed to the module at load time. These args are passed to the + handler everytime it's invoked. +]] +function handlerFunc(ctx, command, keysExist, getValues, setValues, args) + -- Set various data types to keys + local keyValues = { + ["numberKey"] = 42, + ["stringKey"] = "Hello, SugarDB!", + ["nilKey"] = nil, + } + + -- Store the values in the database + setValues(keyValues) + + -- Verify the values have been set correctly + local keysToGet = {"numberKey", "stringKey", "nilKey"} + local retrievedValues = getValues(keysToGet) + + -- Create a table to track mismatches + local mismatches = {} + for key, expectedValue in pairs(keyValues) do + local retrievedValue = retrievedValues[key] + if retrievedValue ~= expectedValue then + table.insert(mismatches, string.format("Key '%s': expected '%s', got '%s'", key, tostring(expectedValue), tostring(retrievedValue))) + end + end + + -- If mismatches exist, return an error + if #mismatches > 0 then + error("values mismatch") + end + + -- If all values match, return OK + return "+OK\r\n" +end \ No newline at end of file diff --git a/internal/volumes/modules/lua/hash.lua b/internal/volumes/modules/lua/hash.lua new file mode 100644 index 0000000..b8d2f07 --- /dev/null +++ b/internal/volumes/modules/lua/hash.lua @@ -0,0 +1,134 @@ + +-- The keyword to trigger the command +command = "LUA.HASH" + +--[[ +The string array of categories this command belongs to. +This array can contain both built-in categories and new custom categories. +]] +categories = {"hash", "write", "fast"} + +-- The description of the command +description = "(LUA.HASH key) \ +This is an example of working with SugarDB hashes/maps in lua scripts." + +-- Whether the command should be synced across the RAFT cluster +sync = true + +--[[ +keyExtractionFunc is a function that extracts the keys from the command and returns them to SugarDB.keyExtractionFunc +The returned data from this function is used in the Access Control Layer to determine if the current connection is +authorized to execute this command. The function must return a table that specifies which keys in this command +are read keys and which ones are write keys. +Example return: {["readKeys"] = {"key1", "key2"}, ["writeKeys"] = {"key3", "key4", "key5"}} + +1. "command" is a string array representing the command that triggered this key extraction function. + +2. "args" is a string array of the modifier args that were passed when loading the module into SugarDB. + These args are passed to the key extraction function everytime it's invoked. +]] +function keyExtractionFunc (command, args) + if (#command < 2) then + error("wrong number of args, expected 1") + end + return { ["readKeys"] = {command[2]}, ["writeKeys"] = {} } +end + +--[[ +handlerFunc is the command's handler function. The function is passed some arguments that allow it to interact with +SugarDB. The function must return a valid RESP response or throw an error. +The handler function accepts the following args: + +1. "context" is a table that contains some information about the environment this command has been executed in. + Example: {["protocol"] = 2, ["database"] = 0} + This object contains the following properties: + i) protocol - the protocol version of the client that executed the command (either 2 or 3). + ii) database - the active database index of the client that executed the command. + +2. "command" is the string array representing the command that triggered this handler function. + +3. "keyExists" is a function that can be called to check if a list of keys exists in the SugarDB store database. + This function accepts a string array of keys to check and returns a table with each key having a corresponding + boolean value indicating whether it exists. + Examples: + i) Example invocation: keyExists({"key1", "key2", "key3"}) + ii) Example return: {["key1"] = true, ["key2"] = false, ["key3"] = true} + +4. "getValues" is a function that can be called to retrieve values from the SugarDB store database. + The function accepts a string array of keys whose values we would like to fetch, and returns a table with each key + containing the corresponding value from the store. + The possible data types for the values are: number, string, nil, hash, set, zset + Examples: + i) Example invocation: getValues({"key1", "key2", "key3"}) + ii) Example return: {["key1"] = 3.142, ["key2"] = nil, ["key3"] = "Pi"} + +5. "setValues" is a function that can be called to set values in the active database in the SugarDB store. + This function accepts a table with keys and the corresponding values to set for each key in the active database + in the store. + The accepted data types for the values are: number, string, nil, hash, set, zset. + The setValues function does not return anything. + Examples: + i) Example invocation: setValues({["key1"] = 3.142, ["key2"] = nil, ["key3"] = "Pi"}) + +6. "args" is a string array of the modifier args passed to the module at load time. These args are passed to the + handler everytime it's invoked. +]] +function handlerFunc(context, command, keysExist, getValues, setValues, args) + -- Initialize a new hash + local h = hash.new() + + -- Set values in the hash + h:set({ + {["field1"] = "value1"}, + {["field2"] = "value2"}, + {["field3"] = "value3"}, + {["field4"] = "value4"}, + }) + + -- Set hash in the store + setValues({[command[2]] = h}) + + -- Check that the fields were correctly set in the database + local hashValue = getValues({command[2]})[command[2]] + assert(hashValue:get({"field1"})["field1"] == "value1", "field1 not set correctly") + assert(hashValue:get({"field2"})["field2"] == "value2", "field2 not set correctly") + assert(hashValue:get({"field3"})["field3"] == "value3", "field3 not set correctly") + assert(hashValue:get({"field4"})["field4"] == "value4", "field4 not set correctly") + + -- Test get method + local retrieved = h:get({"field1", "field2"}) + assert(retrieved["field1"] == "value1", "get method failed for field1") + assert(retrieved["field2"] == "value2", "get method failed for field2") + + -- Test exists method + local exists = h:exists({"field1", "fieldX"}) + assert(exists["field1"] == true, "exists method failed for field1") + assert(exists["fieldX"] == false, "exists method failed for fieldX") + + -- Test setnx method + local setnxCount = h:setnx({ + {["field1"] = "new_value1"}, -- Should not overwrite + {["field5"] = "value5"}, -- Should set + }) + assert(setnxCount == 1, "setnx did not set the correct number of fields") + assert(h:get({"field1"})["field1"] == "value1", "setnx overwrote field1") + assert(h:get({"field5"})["field5"] == "value5", "setnx failed to set field5") + + -- Test del method + local delCount = h:del({"field2", "field3"}) + assert(delCount == 2, "del did not delete the correct number of fields") + assert(h:exists({"field2"})["field2"] == false, "del failed to delete field2") + assert(h:exists({"field3"})["field3"] == false, "del failed to delete field3") + + -- Test len method + assert(h:len() == 3, "len method returned incorrect value") + + -- Retrieve and verify all remaining fields + local remainingFields = h:all() + assert(remainingFields["field1"] == "value1", "field1 missing after deletion") + assert(remainingFields["field4"] == "value4", "field4 missing after deletion") + assert(remainingFields["field5"] == "value5", "field5 missing after deletion") + + -- Return RESP response + return "+OK\r\n" +end \ No newline at end of file diff --git a/internal/volumes/modules/lua/list.lua b/internal/volumes/modules/lua/list.lua new file mode 100644 index 0000000..77f8700 --- /dev/null +++ b/internal/volumes/modules/lua/list.lua @@ -0,0 +1,117 @@ +-- The keyword to trigger the command +command = "LUA.LIST" + +--[[ +The string array of categories this command belongs to. +This array can contain both built-in categories and new custom categories. +]] +categories = {"list", "write", "fast"} + +-- The description of the command +description = "(LUA.LIST key) \ +This is an example of working with SugarDB lists in lua scripts." + +-- Whether the command should be synced across the RAFT cluster +sync = true + +--[[ +keyExtractionFunc is a function that extracts the keys from the command and returns them to SugarDB.keyExtractionFunc +The returned data from this function is used in the Access Control Layer to determine if the current connection is +authorized to execute this command. The function must return a table that specifies which keys in this command +are read keys and which ones are write keys. +Example return: {["readKeys"] = {"key1", "key2"}, ["writeKeys"] = {"key3", "key4", "key5"}} + +1. "command" is a string array representing the command that triggered this key extraction function. + +2. "args" is a string array of the modifier args that were passed when loading the module into SugarDB. + These args are passed to the key extraction function everytime it's invoked. +]] +function keyExtractionFunc (command, args) + if (#command < 2) then + error("wrong number of args, expected 1") + end + return { ["readKeys"] = {command[2]}, ["writeKeys"] = {} } +end + +--[[ +handlerFunc is the command's handler function. The function is passed some arguments that allow it to interact with +SugarDB. The function must return a valid RESP response or throw an error. +The handler function accepts the following args: + +1. "context" is a table that contains some information about the environment this command has been executed in. + Example: {["protocol"] = 2, ["database"] = 0} + This object contains the following properties: + i) protocol - the protocol version of the client that executed the command (either 2 or 3). + ii) database - the active database index of the client that executed the command. + +2. "command" is the string array representing the command that triggered this handler function. + +3. "keyExists" is a function that can be called to check if a list of keys exists in the SugarDB store database. + This function accepts a string array of keys to check and returns a table with each key having a corresponding + boolean value indicating whether it exists. + Examples: + i) Example invocation: keyExists({"key1", "key2", "key3"}) + ii) Example return: {["key1"] = true, ["key2"] = false, ["key3"] = true} + +4. "getValues" is a function that can be called to retrieve values from the SugarDB store database. + The function accepts a string array of keys whose values we would like to fetch, and returns a table with each key + containing the corresponding value from the store. + The possible data types for the values are: number, string, nil, hash, set, zset + Examples: + i) Example invocation: getValues({"key1", "key2", "key3"}) + ii) Example return: {["key1"] = 3.142, ["key2"] = nil, ["key3"] = "Pi"} + +5. "setValues" is a function that can be called to set values in the active database in the SugarDB store. + This function accepts a table with keys and the corresponding values to set for each key in the active database + in the store. + The accepted data types for the values are: number, string, nil, hash, set, zset. + The setValues function does not return anything. + Examples: + i) Example invocation: setValues({["key1"] = 3.142, ["key2"] = nil, ["key3"] = "Pi"}) + +6. "args" is a string array of the modifier args passed to the module at load time. These args are passed to the + handler everytime it's invoked. +]] +function handlerFunc(ctx, command, keysExist, getValues, setValues, args) + -- Helper function to compare lists + local function compareLists(expected, actual) + if #expected ~= #actual then + return false, string.format("Length mismatch: expected %d, got %d", #expected, #actual) + end + for i = 1, #expected do + if expected[i] ~= actual[i] then + return false, string.format("Mismatch at index %d: expected '%s', got '%s'", i, expected[i], actual[i]) + end + end + return true + end + + local key = command[2] + + -- First list to set + local initialList = {"apple", "banana", "cherry"} + setValues({[key] = initialList}) + + -- Retrieve and verify the first list + local retrievedValues = getValues({key}) + local retrievedList = retrievedValues[key] + local isValid, errorMessage = compareLists(initialList, retrievedList) + if not isValid then + error(errorMessage) + end + + -- Update the list with new values + local updatedList = {"orange", "grape", "watermelon"} + setValues({[key] = updatedList}) + + -- Retrieve and verify the updated list + retrievedValues = getValues({key}) + retrievedList = retrievedValues[key] + isValid, errorMessage = compareLists(updatedList, retrievedList) + if not isValid then + error(errorMessage) + end + + -- If all assertions pass + return "+OK\r\n" +end \ No newline at end of file diff --git a/internal/volumes/modules/lua/set.lua b/internal/volumes/modules/lua/set.lua new file mode 100644 index 0000000..df28eda --- /dev/null +++ b/internal/volumes/modules/lua/set.lua @@ -0,0 +1,145 @@ + +-- The keyword to trigger the command +command = "LUA.SET" + +--[[ +The string array of categories this command belongs to. +This array can contain both built-in categories and new custom categories. +]] +categories = {"set", "write", "fast"} + +-- The description of the command +description = "([LUA.SET key member [member ...]]) \ +This is an example of working with SugarDB sets in lua scripts" + +-- Whether the command should be synced across the RAFT cluster +sync = true + +--[[ +keyExtractionFunc is a function that extracts the keys from the command and returns them to SugarDB.keyExtractionFunc +The returned data from this function is used in the Access Control Layer to determine if the current connection is +authorized to execute this command. The function must return a table that specifies which keys in this command +are read keys and which ones are write keys. +Example return: {["readKeys"] = {"key1", "key2"}, ["writeKeys"] = {"key3", "key4", "key5"}} + +1. "command" is a string array representing the command that triggered this key extraction function. + +2. "args" is a string array of the modifier args that were passed when loading the module into SugarDB. + These args are passed to the key extraction function everytime it's invoked. +]] +function keyExtractionFunc (command, args) + if (#command < 3) then + error("wrong number of args, expected 2 or more") + end + return { ["readKeys"] = {}, ["writeKeys"] = {command[2], command[3], command[4]} } +end + +--[[ +handlerFunc is the command's handler function. The function is passed some arguments that allow it to interact with +SugarDB. The function must return a valid RESP response or throw an error. +The handler function accepts the following args: + +1. "context" is a table that contains some information about the environment this command has been executed in. + Example: {["protocol"] = 2, ["database"] = 0} + This object contains the following properties: + i) protocol - the protocol version of the client that executed the command (either 2 or 3). + ii) database - the active database index of the client that executed the command. + +2. "command" is the string array representing the command that triggered this handler function. + +3. "keyExists" is a function that can be called to check if a list of keys exists in the SugarDB store database. + This function accepts a string array of keys to check and returns a table with each key having a corresponding + boolean value indicating whether it exists. + Examples: + i) Example invocation: keyExists({"key1", "key2", "key3"}) + ii) Example return: {["key1"] = true, ["key2"] = false, ["key3"] = true} + +4. "getValues" is a function that can be called to retrieve values from the SugarDB store database. + The function accepts a string array of keys whose values we would like to fetch, and returns a table with each key + containing the corresponding value from the store. + The possible data types for the values are: number, string, nil, hash, set, zset + Examples: + i) Example invocation: getValues({"key1", "key2", "key3"}) + ii) Example return: {["key1"] = 3.142, ["key2"] = nil, ["key3"] = "Pi"} + +5. "setValues" is a function that can be called to set values in the active database in the SugarDB store. + This function accepts a table with keys and the corresponding values to set for each key in the active database + in the store. + The accepted data types for the values are: number, string, nil, hash, set, zset. + The setValues function does not return anything. + Examples: + i) Example invocation: setValues({["key1"] = 3.142, ["key2"] = nil, ["key3"] = "Pi"}) + +6. "args" is a string array of the modifier args passed to the module at load time. These args are passed to the + handler everytime it's invoked. +]] +function handlerFunc(ctx, command, keyExists, getValues, setValues, args) + -- Ensure there are enough arguments + if #command < 4 then + error("wrong number of arguments, expected at least 3") + end + + -- Extract the key + local key1 = command[2] + local key2 = command[3] + local key3 = command[4] + + -- Create two sets for testing `move` and `subtract` + local set1 = set.new({"elem1", "elem2", "elem3"}) + local set2 = set.new({"elem4", "elem5"}) + + -- Call `add` to add elements to set1 + set1:add({"elem6", "elem7"}) + + -- Call `contains` to check if an element exists in set1 + local containsElem1 = set1:contains("elem1") + local containsElemUnknown = set1:contains("unknown") + + -- Call `cardinality` to get the size of set1 + local set1Cardinality = set1:cardinality() + + -- Call `remove` to remove elements from set1 + local removedCount = set1:remove({"elem1", "elem2"}) + + -- Call `pop` to remove and retrieve elements from set1 + local poppedElements = set1:pop(2) + + -- Call `random` to get random elements from set1 + local randomElements = set1:random(1) + + -- Call `all` to retrieve all elements from set1 + local allElements = set1:all() + + -- Test `move` method: move an element from set1 to set2 + local moveSuccess = set1:move(set2, "elem3") + + -- Verify that the element was moved + local set2ContainsMoved = set2:contains("elem3") + local set1NoLongerContainsMoved = not set1:contains("elem3") + + -- Test `subtract` method: subtract set2 from set1 + local resultSet = set1:subtract({set2}) + + -- Store the modified sets in SugarDB using setValues + setValues({[key1] = set1, [key2] = set2, [key3] = resultSet}) + + -- Retrieve the sets back from SugarDB to verify storage + local storedValues = getValues({key1, key2, key3}) + local storedSet1 = storedValues[key1] + local storedSet2 = storedValues[key2] + local storedResultSet = storedValues[key3] + + -- Perform additional checks to ensure consistency + if not storedSet1 or storedSet1:cardinality() ~= set1:cardinality() then + error("Stored set1 does not match the modified set1") + end + if not storedSet2 or storedSet2:cardinality() ~= set2:cardinality() then + error("Stored set2 does not match the modified set2") + end + if not storedResultSet or storedResultSet:cardinality() ~= resultSet:cardinality() then + error("Stored result set does not match the computed result set") + end + + -- If all operations succeed, return "+OK\r\n" + return "+OK\r\n" +end \ No newline at end of file diff --git a/internal/volumes/modules/lua/zset.lua b/internal/volumes/modules/lua/zset.lua new file mode 100644 index 0000000..88278e6 --- /dev/null +++ b/internal/volumes/modules/lua/zset.lua @@ -0,0 +1,155 @@ + +-- The keyword to trigger the command +command = "LUA.ZSET" + +--[[ +The string array of categories this command belongs to. +This array can contain both built-in categories and new custom categories. +]] +categories = {"sortedset", "write", "fast"} + +-- The description of the command +description = "(LUA.ZSET key member score [member score ...]) \ +This is an example of working with sorted sets in lua scripts" + +-- Whether the command should be synced across the RAFT cluster +sync = true + +--[[ +keyExtractionFunc is a function that extracts the keys from the command and returns them to SugarDB.keyExtractionFunc +The returned data from this function is used in the Access Control Layer to determine if the current connection is +authorized to execute this command. The function must return a table that specifies which keys in this command +are read keys and which ones are write keys. +Example return: {["readKeys"] = {"key1", "key2"}, ["writeKeys"] = {"key3", "key4", "key5"}} + +1. "command" is a string array representing the command that triggered this key extraction function. + +2. "args" is a string array of the modifier args that were passed when loading the module into SugarDB. + These args are passed to the key extraction function everytime it's invoked. +]] +function keyExtractionFunc (command, args) + if (#command < 4) then + error("wrong number of args, expected 2") + end + return { ["readKeys"] = {}, ["writeKeys"] = {command[2], command[3], command[4]} } +end + +--[[ +handlerFunc is the command's handler function. The function is passed some arguments that allow it to interact with +SugarDB. The function must return a valid RESP response or throw an error. +The handler function accepts the following args: + +1. "context" is a table that contains some information about the environment this command has been executed in. + Example: {["protocol"] = 2, ["database"] = 0} + This object contains the following properties: + i) protocol - the protocol version of the client that executed the command (either 2 or 3). + ii) database - the active database index of the client that executed the command. + +2. "command" is the string array representing the command that triggered this handler function. + +3. "keyExists" is a function that can be called to check if a list of keys exists in the SugarDB store database. + This function accepts a string array of keys to check and returns a table with each key having a corresponding + boolean value indicating whether it exists. + Examples: + i) Example invocation: keyExists({"key1", "key2", "key3"}) + ii) Example return: {["key1"] = true, ["key2"] = false, ["key3"] = true} + +4. "getValues" is a function that can be called to retrieve values from the SugarDB store database. + The function accepts a string array of keys whose values we would like to fetch, and returns a table with each key + containing the corresponding value from the store. + The possible data types for the values are: number, string, nil, hash, set, zset + Examples: + i) Example invocation: getValues({"key1", "key2", "key3"}) + ii) Example return: {["key1"] = 3.142, ["key2"] = nil, ["key3"] = "Pi"} + +5. "setValues" is a function that can be called to set values in the active database in the SugarDB store. + This function accepts a table with keys and the corresponding values to set for each key in the active database + in the store. + The accepted data types for the values are: number, string, nil, hash, set, zset. + The setValues function does not return anything. + Examples: + i) Example invocation: setValues({["key1"] = 3.142, ["key2"] = nil, ["key3"] = "Pi"}) + +6. "args" is a string array of the modifier args passed to the module at load time. These args are passed to the + handler everytime it's invoked. +]] +function handlerFunc(ctx, command, keyExists, getValues, setValues, args) + -- Ensure there are enough arguments + if #command < 4 then + error("wrong number of arguments, expected at least 3") + end + + local key1 = command[2] + local key2 = command[3] + local key3 = command[4] + + -- Create `zmember` instances + local member1 = zmember.new({value = "member1", score = 10}) + local member2 = zmember.new({value = "member2", score = 20}) + local member3 = zmember.new({value = "member3", score = 30}) + + -- Create a `zset` and add initial members + local zset1 = zset.new({member1, member2}) + + -- Test `add` method with a new member + zset1:add({member3}) + + -- Test `update` method by modifying an existing member + zset1:update({zmember.new({value = "member1", score = 15})}) + + -- Test `remove` method + zset1:remove("member2") + + -- Test `cardinality` method + local zset1Cardinality = zset1:cardinality() + + -- Test `contains` method + local containsMember3 = zset1:contains("member3") + local containsNonExistent = zset1:contains("nonexistent") + + -- Test `random` method + local randomMembers = zset1:random(2) + + -- Test `all` method + local allMembers = zset1:all() + + -- Create another `zset` to test `subtract` + local zset2 = zset.new({zmember.new({value = "member3", score = 30})}) + local zsetSubtracted = zset1:subtract({zset2}) + + -- Store the `zset` objects in SugarDB + setValues({ + [key1] = zset1, + [key2] = zset2, + [key3] = zsetSubtracted + }) + + -- Retrieve the stored `zset` objects to verify storage + local storedValues = getValues({key1, key2, key3}) + local storedZset1 = storedValues[key1] + local storedZset2 = storedValues[key2] + local storedSubtractedZset = storedValues[key3] + + -- Perform consistency checks + if not storedZset1 or storedZset1:cardinality() ~= zset1:cardinality() then + error("Stored zset1 does not match the modified zset1") + end + if not storedZset2 or storedZset2:cardinality() ~= zset2:cardinality() then + error("Stored zset2 does not match the modified zset2") + end + if not storedSubtractedZset or storedSubtractedZset:cardinality() ~= zsetSubtracted:cardinality() then + error("Stored subtracted zset does not match the computed result") + end + + -- Test `zmember` methods + local memberValue = member1:value() + member1:value("updated_member1") + local updatedValue = member1:value() + + local memberScore = member1:score() + member1:score(50) + local updatedScore = member1:score() + + -- Return an "OK" response + return "+OK\r\n" +end \ No newline at end of file diff --git a/openssl/client/cert.conf b/openssl/client/cert.conf new file mode 100644 index 0000000..9e348e3 --- /dev/null +++ b/openssl/client/cert.conf @@ -0,0 +1,8 @@ +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +subjectAltName = @alt_names + +[alt_names] +DNS.1 = localhost + diff --git a/openssl/client/client1.crt b/openssl/client/client1.crt new file mode 100644 index 0000000..f7598a2 --- /dev/null +++ b/openssl/client/client1.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDsTCCApmgAwIBAgIUceiEXLKJyPYbsI8+rOECsoAjXMMwDQYJKoZIhvcNAQEL +BQAwODESMBAGA1UEAwwJbG9jYWxob3N0MQswCQYDVQQGEwJNWTEVMBMGA1UEBwwM +S3VhbGEgTHVtcHVyMB4XDTI0MDIwMjIxNDczMloXDTM0MDEzMDIxNDczMlowezEL +MAkGA1UEBhMCTVkxFTATBgNVBAgMDEt1YWxhIEx1bXB1cjEVMBMGA1UEBwwMS3Vh +bGEgTHVtcHVyMRIwEAYDVQQKDAlFY2hvVmF1bHQxFjAUBgNVBAsMDUVjaG9WYXVs +dCBEZXYxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBANYPfUU/CyxkEK8jUCyRfXLhVWBvqTKqYojprhOEzmfizxs/osMA +8XQPHciuBCNIReOv7RfVd7os2mOpnjlubyiIEdJ18A6WefbjyDOiTFIZtpVxaEsb +du2/t7wPuyDdkfXC0l0agG8EbpcguKWnD0H+b9gwLX+CB046xNZlJm/rTAUycH4N +f7chr4awTp1ulag2AV7o+zgqZUpy4YpxDGkYJ42H24ehdZ7/l4JUDvJvt7aH49cP +BaT8LpUe8vIPeNlV1VnxS0499zZBYm9hbYAutOoKpsHXMwfVBqNB067oZi5xy14z +vROZ9U4FumkByZ+LPqv7gBHgmmC7HzPzqrcCAwEAAaNwMG4wHwYDVR0jBBgwFoAU +0ew0XSAHAYVSt4ncqBEWecMHZ2AwCQYDVR0TBAIwADALBgNVHQ8EBAMCBPAwFAYD +VR0RBA0wC4IJbG9jYWxob3N0MB0GA1UdDgQWBBR4DSnn//dXMdhXhMtCWjY1GSDI +7jANBgkqhkiG9w0BAQsFAAOCAQEASOCH15wtjq9fJ7yVbjRRYnhS8mP7VS9s51m9 +G1PbgsV1P4xuElt4Md77xLthit45jW3rQuF+iMXpRYu9Fo5o1OQoIX4FE7fFofBg +Q/We+xVy8lrzDLbmg0MV0zg4urfYOrMqj6eYXSXzOOnzgvhH20yc+/KSXELv0YUP +a9kQgpf/4VA3yINH/+EcLkR2fP2ktzfL2l4PsfXFEzcKzOWIsXtMUo/wyJzLDjl1 +3fmUaMmNqBrKX+PM8uuXoT4/yBVHFA6vr96E5tZP6rid7xOgsonQ1Zf37ffhfXi/ +Umq6bGQK1eTfdq74pTWbNU59QfbWfR7LI+M32kQla//Ca3D/4g== +-----END CERTIFICATE----- diff --git a/openssl/client/client1.key b/openssl/client/client1.key new file mode 100644 index 0000000..f715cc1 --- /dev/null +++ b/openssl/client/client1.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWD31FPwssZBCv +I1AskX1y4VVgb6kyqmKI6a4ThM5n4s8bP6LDAPF0Dx3IrgQjSEXjr+0X1Xe6LNpj +qZ45bm8oiBHSdfAOlnn248gzokxSGbaVcWhLG3btv7e8D7sg3ZH1wtJdGoBvBG6X +ILilpw9B/m/YMC1/ggdOOsTWZSZv60wFMnB+DX+3Ia+GsE6dbpWoNgFe6Ps4KmVK +cuGKcQxpGCeNh9uHoXWe/5eCVA7yb7e2h+PXDwWk/C6VHvLyD3jZVdVZ8UtOPfc2 +QWJvYW2ALrTqCqbB1zMH1QajQdOu6GYuccteM70TmfVOBbppAcmfiz6r+4AR4Jpg +ux8z86q3AgMBAAECggEAEeE6u1rgBSXmKnYLWQjdm7r/nyD+I7NaRjzCltj8E269 +K6j9xuA5/gUFncDcEdH6tUesfAlCfJeitdPLcz+d44Ea5mOGGPai542wjl0kyjzH +yyUtKwzduukaVAggvy0fgN6N8X61S9CcDiqKnprDXWWyLbKEfy1Uv+K/yBESpbyW +28s/QPmk5kd8a3YrcemHUQGp/QwjV1We0+TrCqdos4uaEZn6cCnrjQoXYRkMtGwZ +m9N+tHxAq5u7ZGoQnA9pHkAq+COyj03v4yP9tTSp4MJ3U/9auV2C+EyF8uYtM+qa +IU1pf3jp0D3BAEatGr/+/Yaz19GZx7w3FpA1NbHBPQKBgQDrbZoQQJS64IREGwRH +omqDubv+OT8ffuC2+7LEPsNbUabNLrV/FzAumRlI3Y7Eog8Hu1hg2HT6sJVzsR3S +u4f/OwR1a6I+6NU9ZLmx/s9qI0Lu7ah2x5QpKGBJo0VBGU/TWCl51cIAS9iXIcZg +OsdWDCzqHct4xKedYZsIjbpGwwKBgQDow+fKisofp/xMQPO3/7LJCBTsgK15TB6s +GX8InMqLKX1QpJpv1LvCrGNd+oYGit85fC8SFZuVXT2P65BRCQLfO5KioxoNuDGf +S0V+q/3+8BcsKUZVMChbau4nXhBeUI9twGwYHpbzFQEhgcrU8aXl/dS0fB/pwhJR +DxPKVRGU/QKBgQCMvv138eP4xPjN7ojkeojLL2LgXUELh0K4okkBYbRRB8N8rwv6 +atZ3RTgEg9AyZeAucyYm38EvjhoLDDwUG+D2CUZlHG/mxDOXfHw3mWpOvb3qMVKh +kDdXU7gczes9O/CpHO/O0qgknTNjRuHd7cX1/1lqrV1TWd4LDKsutexDGQKBgBsl +NbQGSZo1ghP2gzXTKSuOuLn4K8L4oJ8bfhgoCOr/1LCB8czW92q1pgUAwX6j1XKj +y+2E/ZcGv7Y4F6WLsn0MOoajFNfCwm68XYdvUXjY0SsCSUSIEDzRFKMcsjX9mSyI +g1KwxpPkwDQDKf95iwpuds7xptshGffAFWPEVf+VAoGBAObaWaeVibXnJbX/cAC0 +HT+NrAGMCITEoOD790SzFO+jsQAx9wDjbl3IkObHjSCgNmkqMk3GoVbgL7GeuiYE +dTp2G+308sQEIxASypUo/oyoZO+gbGToenK/JVTzTlh5+nrvdZFrfPCkO7Ljp5qr +NeZg9WiVOkpMQHPiPOw95asI +-----END PRIVATE KEY----- diff --git a/openssl/client/client2.crt b/openssl/client/client2.crt new file mode 100644 index 0000000..fb191b4 --- /dev/null +++ b/openssl/client/client2.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDsTCCApmgAwIBAgIUceiEXLKJyPYbsI8+rOECsoAjXMQwDQYJKoZIhvcNAQEL +BQAwODESMBAGA1UEAwwJbG9jYWxob3N0MQswCQYDVQQGEwJNWTEVMBMGA1UEBwwM +S3VhbGEgTHVtcHVyMB4XDTI0MDIwMjIxNDc1NFoXDTM0MDEzMDIxNDc1NFowezEL +MAkGA1UEBhMCTVkxFTATBgNVBAgMDEt1YWxhIEx1bXB1cjEVMBMGA1UEBwwMS3Vh +bGEgTHVtcHVyMRIwEAYDVQQKDAlFY2hvVmF1bHQxFjAUBgNVBAsMDUVjaG9WYXVs +dCBEZXYxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBALojRky3CuIqbm03hSBCBefxCRE2XGk9ApZQILojt44BeoybJtxQ +01sbwXLHtr2SEd5ykr2/S9MOa5Rd3KXD2o2TuJ/RekBKHIn7KAVkf+5i+/PKmdlU +bMLPkdtSNTSfQCKXkoprJZe2kryrhu2pxJL18zu5ueaxuMEHY1//2wfeGJQG5DJ/ +6jR7SMl3ZVzv7wdX20sQgDijddYUzKderOHWxglIjPNxvLJIgPPa6zUYeE6LQZPH +dBwCYgd/BJzclXovof4+meUFWMs7T+ZQ+g6n9mv2QPOq95Ut+wxa8rfyyouQtzXj +7nKxTyiFtaEwIVIHWnefegZfgwbZCBle3KcCAwEAAaNwMG4wHwYDVR0jBBgwFoAU +0ew0XSAHAYVSt4ncqBEWecMHZ2AwCQYDVR0TBAIwADALBgNVHQ8EBAMCBPAwFAYD +VR0RBA0wC4IJbG9jYWxob3N0MB0GA1UdDgQWBBRg/JsrbGLJKuPV1aCLvBVNgadS +GDANBgkqhkiG9w0BAQsFAAOCAQEAIs59eJnjq41PkevqcOaNJe0c/9GN+y8pWFfd +k9YZPu4HytTwylRaCOuPzGDpFtqBoVC4B1D7frw0oURd4zNjfxlwNTH2kfV8Uoz4 +GnWHHGvMrlzZLlGJnkuu8ciYjb2r2VEkLeSn8VDHpVIgLyxlHEOTkM866vZWD8WH +HWutpfc0cNPOMopQyWMe2S/jXmDwSA8t48iWlXAxLMEUA1OkF+jgz4CZ/c7Um64k +jsuSOSJgg6P12RFia2UDD9hzQWTgvxERuG9DjxJ8QKKLUKLRQ96EWpqamDoYb7vA +Kld5s3/EGd/zcrGzCt4mzdiMRVNOhskbWnifpRqNZouwvFlC4Q== +-----END CERTIFICATE----- diff --git a/openssl/client/client2.key b/openssl/client/client2.key new file mode 100644 index 0000000..478d1ec --- /dev/null +++ b/openssl/client/client2.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC6I0ZMtwriKm5t +N4UgQgXn8QkRNlxpPQKWUCC6I7eOAXqMmybcUNNbG8Fyx7a9khHecpK9v0vTDmuU +Xdylw9qNk7if0XpAShyJ+ygFZH/uYvvzypnZVGzCz5HbUjU0n0Ail5KKayWXtpK8 +q4btqcSS9fM7ubnmsbjBB2Nf/9sH3hiUBuQyf+o0e0jJd2Vc7+8HV9tLEIA4o3XW +FMynXqzh1sYJSIzzcbyySIDz2us1GHhOi0GTx3QcAmIHfwSc3JV6L6H+PpnlBVjL +O0/mUPoOp/Zr9kDzqveVLfsMWvK38sqLkLc14+5ysU8ohbWhMCFSB1p3n3oGX4MG +2QgZXtynAgMBAAECggEAArtSByVZU0WMEZAwYAMWz1S5XojMbxZ9yOHzUQHeB6TD +hwHkXnStrCr5OiuCPntKvBLSdkNGt8sKk64dbSP6gGhpeWzRoiPUTSmj8nOyobeA +Z1RX5K00eWqsNG9zsZkz8kG15wtMW/pd/8diWeVo+HoXzbYDCQHQAwzm0qYLk/tg ++k4hIlhEesf1SimyNmsEo9JOuzRcvM58+OvPmbUILcpT8HCOJPQ2XIZDJV6ln2o+ +AoXvF/01d7i/F0Iu+LUg1gVokvThcXZQhV00gkdZHZ1ENUj09hi2LPVCZ56PoWCT +WG93S50c6KGd1DhZ3AxjJM4TX9Y0XVcI39GuY8cuBQKBgQDthU0ZLrH8qKNPKL6j +lsC57I6+1bpVvbcvKvGflYP0LWUSIBHa86BLafKNH2/zumvZx6bQBCNQTo7T+FJV +IuD3oQlzA4L0trCbiQZxUftt2wNbL6DblnysDjWtiU1HbZFgcD5pW+vYxvHTG/I0 +BaCteGvS70ixRzZnYROQsr//gwKBgQDInpSD6iKn/hNvG4+TTEKaA4fTPTjxsNfh +j1w+CcCV8ibcAqJJI0wXcJs+QnCzlytinCjbo2ZzwKjOpN7spEQyTu8XtzNQNBO8 +7KfyuvyYOEqTvRfge2VRn9UvWtndOSXWJYGmnewHkh4jLt+19NgoHaBFzYLCr0oD +uKog/WUhDQKBgQDf8NSWL56ElwMSeVn0pwgiw9R6PMyoVmzGPfj9+1wj9kDa6/2p +sBWrxMJ5J/DHnTZeaIzwh1Y8OzUSyYfm2TG+h8h+9gqcazrsCi9W3HLwSpRJfwhs +wN/e4K7fZRrFg5qTkIBnmdEt27TY0/px7fRmWalfgVfKPVgf9DkcLkwzvQKBgQCq +chC8ArBvCe5493GEM8ZiE53SWrGGpjjD6oj0LFTzEEjzo0k92j9LquA6hTg7XLP/ +k60i7jCdJ5JD/s9nPiiylV2NSJjQC265lFccYsE4kprJ6l3e2ve54ZG+KfHvgh4j +UrpUVNezlvED808dyGfdrU3+AByYS1UW1E22uZKyAQKBgQCpy7evEHTwA+GeAAuq +3pLtyzQHO9bCk+TbccJbdlKQ6py9Cm4WSO3NuQLy5yW/+WnGpR4HQegGoUmxKcww +u3GGmxlPWqbz9NVGcs09FBwzyB0VTNOnp5RkFq3jS7YOFC5j55g4d3OpAsoeipP0 +OSEceMqSURM/FRFkgSh1Dy4C4A== +-----END PRIVATE KEY----- diff --git a/openssl/client/csr.conf b/openssl/client/csr.conf new file mode 100644 index 0000000..2988f27 --- /dev/null +++ b/openssl/client/csr.conf @@ -0,0 +1,24 @@ +[ req ] +default_bits = 2048 +prompt = no +default_md = sha256 +req_extensions = req_ext +distinguished_name = dn + +[ dn ] +C = MY +ST = Kuala Lumpur +L = Kuala Lumpur +O = EchoVault +OU = EchoVault Dev +CN = localhost + +[ req_ext ] +subjectAltName = @alt_names + +[ alt_names ] +DNS.1 = localhost +DNS.2 = localhost +IP.1 = 192.168.1.5 +IP.2 = 192.168.1.6 + diff --git a/openssl/client/rootCA.crt b/openssl/client/rootCA.crt new file mode 100644 index 0000000..29dfb16 --- /dev/null +++ b/openssl/client/rootCA.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUTCCAjmgAwIBAgIUYEq8SoSfeaJZt32PKfPoBVBUeVswDQYJKoZIhvcNAQEL +BQAwODESMBAGA1UEAwwJbG9jYWxob3N0MQswCQYDVQQGEwJNWTEVMBMGA1UEBwwM +S3VhbGEgTHVtcHVyMB4XDTI0MDIwMjIxMTgzOVoXDTM0MDEzMDIxMTgzOVowODES +MBAGA1UEAwwJbG9jYWxob3N0MQswCQYDVQQGEwJNWTEVMBMGA1UEBwwMS3VhbGEg +THVtcHVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2YHqodqIKVnB +1jIY4YsjIWHEZbT3YnDdNOQu23iMv8fB2dhwBJGAlnvX3xZ+UTkbKsBfMK6JfHfI +1X7LcPaxs93x8iKs9nUp3wdyCTuzb4HpH/Ke1tW6x3kGlW+hLENC1YRgGM3STYuT +QctW2EKgQJPB+nGkkp4joa1Dc+ShdKWCfXryXoVpi8ljQxaiqY8kQ8Tp/FsDjK2t +HtGyPG00XfIrp9wPaqlgHa8UTEdp4gSjPxu2pF9TkkSdpny5I+j7fTroyQ2Pk+mP +K8EaBj0jqxC7GiOlHETttDmn84oOzDxKZXV9Z9O1+r/TmERB49+p7M6Ab3ooEDUk +0VmZi/axhQIDAQABo1MwUTAdBgNVHQ4EFgQU0ew0XSAHAYVSt4ncqBEWecMHZ2Aw +HwYDVR0jBBgwFoAU0ew0XSAHAYVSt4ncqBEWecMHZ2AwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEAU0CAPpenSBavJnJ0Dh6d8BxuvHu6Mcg3xQoE +cMeITxRieTz2nEj1Z/j9EENERy72C5s+kDl+RaI5kEjm/bAjH2gfELbtXA4SkyZq +2JhZ4hgMjqAPMx/mb+dGOjh5gu8kh2DJKOBOFiP9TVuBpofkhABkk93OLHGOgfDY +jCifQFqee7sHJbOU3wpACXaydSQXUihP9JLqVNAP5DQzoFDPu8mtst0gP8EOoltu +UWEvdLyMFzJbAGv1EMIalgUuaCV34r+OLs0mg/c04YsqRXzH1YDMpQZjbfneMBJT +hYUQdXjPLKvaHPaj1TErbQC1yn74sP04OMXjhJnfdLumOZn3EQ== +-----END CERTIFICATE----- diff --git a/openssl/client/rootCA.key b/openssl/client/rootCA.key new file mode 100644 index 0000000..e2ec82c --- /dev/null +++ b/openssl/client/rootCA.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDZgeqh2ogpWcHW +MhjhiyMhYcRltPdicN005C7beIy/x8HZ2HAEkYCWe9ffFn5RORsqwF8wrol8d8jV +fstw9rGz3fHyIqz2dSnfB3IJO7Nvgekf8p7W1brHeQaVb6EsQ0LVhGAYzdJNi5NB +y1bYQqBAk8H6caSSniOhrUNz5KF0pYJ9evJehWmLyWNDFqKpjyRDxOn8WwOMra0e +0bI8bTRd8iun3A9qqWAdrxRMR2niBKM/G7akX1OSRJ2mfLkj6Pt9OujJDY+T6Y8r +wRoGPSOrELsaI6UcRO20Oafzig7MPEpldX1n07X6v9OYREHj36nszoBveigQNSTR +WZmL9rGFAgMBAAECggEAPuCVNRPpD9cgN20FD1J7Jd/O+D3v0/fforYiK5T2T0yO +aAzvGQr8+sOzXIzymEVjaqDxA7A5E4/HMZy1cCMIrQAIvOA0Uwz8vTo4R54IGcCa +5X7sVxuzIo4EjreWBqctD290nkcFuCAUwkznfp4IGJL+XQl0M2Re1ZKycLLTz9Wu +Q+e5JvUzA1fn7Va2nk/vf2uuEhkLA+He1nY1pXt7AS6OH3XHkYV2ZGugJGkxM9I7 +dWFzpPcyNRN5yA4WUql9nOj7giHT313HQ1j3UPeoZ6NY7TRPQwT+iZQszHcie2Md +WSw2cH+W7TuF4MAhwc5rsvDjGmmdq5cstWqMHGBBcwKBgQD3oDKU0p5Tq7HjJV5I +XBAALdLg0+dsZPDVvpi2JY6j3TWESWEudrl2g3Kx2wJfowa8O76/9jzceX2k1nek +1r8BVgDCkfFrWN3bVDzf28h/+1ywXPepcKwrNufShdoJQYQ8nfafE4gDTO1Snej6 +81ZuwKS5Rt7Q9JHHrh3G/15RjwKBgQDg3PjYCoXlyLPuK/Co4JJ1phZIdAucVN0v +56g862C8nK/FtgdHhZLoU797PnqIxQeE/E+URSwQP/lvokMAcQdM7oqJMT9sJePT +VnFLR76DfuZSJQ8dPM4C8WHF9ioGtdzmKeYtJP639T/uz2Z+CZQ3eNMrWykgDjO9 +gBnrW+zZqwKBgG9ccwLsyVk1kNVnO8Rs6qE5+mkzwxLDPm/RvFnGACT/WY75dSPx +Lqz2poEHzkR2S5QhhkJMGcjJNlEIRlwyW0ndhI/8FEdDetqlQo8mB0BPKbsCxDpG +OpdgpNbPbWPWPAMKwxt9LCDX2q7Z5yncf1Vle277ST9NjbXwPuH8fE1PAoGAEVnb +tcfyFw4KnEk1s8JIat2bAJI7xx9hRe4JNFIxT7yDb60hGKq88EJuFxN2HxGdB+z0 +Mwu3X7WgCLYrl2AhYRVTCU0MiMrPrqIP8fAiSkFDgnkrlmT3vJBlrAHXslbcKcJ3 +6WneYdGB0mqcjQMuNa2UFddd8ARIh8nXtiqMtysCgYB1BD89V8ivMACkuy5FGe8k +2kFSUI8DSVrXRPZ2mRCTho/lbIYvpIXY2qfnz+PZpyf80JdRu4zfaAhxXP2r08+z +3+bFHnI3OYBI2M6pLWf48HYJfW7UawCW4BMlisb3EiXAz1vsUWgR9I0wLa7MRfkP +YW0ZPyWOl8+eIR2BZo7dQA== +-----END PRIVATE KEY----- diff --git a/openssl/client/rootCA.srl b/openssl/client/rootCA.srl new file mode 100644 index 0000000..4fcdcff --- /dev/null +++ b/openssl/client/rootCA.srl @@ -0,0 +1 @@ +71E8845CB289C8F61BB08F3EACE102B280235CC4 diff --git a/openssl/server/cert.conf b/openssl/server/cert.conf new file mode 100644 index 0000000..9e348e3 --- /dev/null +++ b/openssl/server/cert.conf @@ -0,0 +1,8 @@ +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +subjectAltName = @alt_names + +[alt_names] +DNS.1 = localhost + diff --git a/openssl/server/csr.conf b/openssl/server/csr.conf new file mode 100644 index 0000000..2988f27 --- /dev/null +++ b/openssl/server/csr.conf @@ -0,0 +1,24 @@ +[ req ] +default_bits = 2048 +prompt = no +default_md = sha256 +req_extensions = req_ext +distinguished_name = dn + +[ dn ] +C = MY +ST = Kuala Lumpur +L = Kuala Lumpur +O = EchoVault +OU = EchoVault Dev +CN = localhost + +[ req_ext ] +subjectAltName = @alt_names + +[ alt_names ] +DNS.1 = localhost +DNS.2 = localhost +IP.1 = 192.168.1.5 +IP.2 = 192.168.1.6 + diff --git a/openssl/server/rootCA.crt b/openssl/server/rootCA.crt new file mode 100644 index 0000000..fdd02b9 --- /dev/null +++ b/openssl/server/rootCA.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUTCCAjmgAwIBAgIURMg03pNcrEiIHkO9F2NXqAPLoh0wDQYJKoZIhvcNAQEL +BQAwODESMBAGA1UEAwwJbG9jYWxob3N0MQswCQYDVQQGEwJNWTEVMBMGA1UEBwwM +S3VhbGEgTHVtcHVyMB4XDTI0MDIwMjIxMTkwNFoXDTM0MDEzMDIxMTkwNFowODES +MBAGA1UEAwwJbG9jYWxob3N0MQswCQYDVQQGEwJNWTEVMBMGA1UEBwwMS3VhbGEg +THVtcHVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv9WYvOmd7jLl +5Vry3M4C6ebwhGou1i4Eum6k9W0712mjWeOjrmruADV86hW18U+1ZYox1lVIWeDk +R3v/YB0D72Wr2NFWqjNrpkwJRd/ztRDXhMXI7q3MzQgoRxysR3qbV4lHYYnsm1FY +mh5CO1uBJCMouKh4zJ2vTmBJ2TeAwyC6bYfqKpW3xPmUD+qB21e0XNaKJ6rgQynX +/AML27h9m4v50hQHg8ju2hliCUXYwO1Z79XYLwXxskJH/fI+cz0pVzUS/44p5FCF +5AWY/pwz0IuAtaXZ56rPupZaIiEmdgg9zdArNBdnOzdV6a5LlSkllbG0EuXmXbOW +Y9JTzopaKwIDAQABo1MwUTAdBgNVHQ4EFgQUPtIcTECRJBgs8kTcH6IwcZ/XV0Uw +HwYDVR0jBBgwFoAUPtIcTECRJBgs8kTcH6IwcZ/XV0UwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEAEOjEVYpL/jroupSijqC0ynWE7CBaQT65c+A/ +DQdw2Igyy3oyi2hKCB01nYRhHSClFm6bT7HjBJ2pua7Kvi7j/YzJKu5DvF8nvHa3 +v3E6EGsHPjIhfZkMZNgKR6nTcCX2DgCdKICLA//oBzuVauSIUtwYs6uw68SuIzV7 +UUeJOKTs6BN5CLoa0yoWxdoIpAjr9UQIhIgrIiaB45enmiFhLsz/N+tOH5f28Omc +5ER/dgCRgIYVWO9A2emlstrEujr2ct0M+xQrmN+xZWhjdGpT6vJwTn6Tva9f17+t +IOws1mhm1SciG4hlpQl6d90HxU82Aol6/spxv+jKrDAhkGM+aw== +-----END CERTIFICATE----- diff --git a/openssl/server/rootCA.key b/openssl/server/rootCA.key new file mode 100644 index 0000000..49dce8f --- /dev/null +++ b/openssl/server/rootCA.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC/1Zi86Z3uMuXl +WvLczgLp5vCEai7WLgS6bqT1bTvXaaNZ46Ouau4ANXzqFbXxT7VlijHWVUhZ4ORH +e/9gHQPvZavY0VaqM2umTAlF3/O1ENeExcjurczNCChHHKxHeptXiUdhieybUVia +HkI7W4EkIyi4qHjMna9OYEnZN4DDILpth+oqlbfE+ZQP6oHbV7Rc1oonquBDKdf8 +AwvbuH2bi/nSFAeDyO7aGWIJRdjA7Vnv1dgvBfGyQkf98j5zPSlXNRL/jinkUIXk +BZj+nDPQi4C1pdnnqs+6lloiISZ2CD3N0Cs0F2c7N1XprkuVKSWVsbQS5eZds5Zj +0lPOilorAgMBAAECggEACZi3W9f6ARUFwCwFEzuxFJ9rb8xaDHff362yTd7JjBSq +SdBj+1E5F8SVO6abY/d/VRWNObIpfOmNse/HjjjVXhABgUazpa8N1xNdsWOrLucp +SOiWDS6fnLAoR6ptCeRdygrBieUa84glvQv/dzW0J6kkm9w9ssq+ntadSyMGK3ys ++qO1rgKqSqRJR2lIN53AMqQQWRPdq4i5fwszXbdqemk87uXT1YmXDV0TVZtOIabJ +Bs+EtNtBWgbgmEvqa1OYAFn/51/3r5/85Gg35JxNzATT+in7xaJNOtWGwHYkJoYy +d+rTYX7HYvtqq/u1/W84IpRGhyA3JdyPkeMRQ1MzzQKBgQDfDpextw1T5grLAjKA +v4dsBXmOqvcI2MxzBPFQv1GUEH2od1iwtpoavHFuonFhR8WGirzOfYl8dAlkJGxp +Mrq7AYOkTDg4mg9xawWiU4ejLK1vaBZj0w3cijAtlufUrCFymZzkmKmxQr75Oa/H +NMrLEbS5wP5FyxNVh15fislSZQKBgQDcKodn/NFCJ0WfG1b5bN4oN8vw+UUFLkl7 +AMaA58pCdLT2Vhq9fj7UMpKeRn34MuzRq2jMwhUE0YK/mtvxf+nADe7xbHvtBviJ +w4Xa4jFOrCx2+DeVCCchj4Zqizt1Q+GkCR0dlnUtV2WwFusxenizoC8FiGOGpIez +fkW3Z/zpTwKBgQCK05U8KXblEdcD1MFD+nC5nYqzbdrEqdJNf/UFUZ3fbogW0vjj +OzMcks5yki3I4xegDjdGuUFZsQqrRjQnIUiw3VdmaX3QVKpp57cg+aYAu+zR2tGc +nZ4R9fvYVATEC8HhhpPsfsuWpLkhenLZpBTXYJS/y8s1+xd0cwUcp893NQKBgQCQ +tBsfC5l1w14M/ukhMp6pDFMsZIkqqIt/HrlZC/9xwkcWCO22Uf11dm/LO0WcFcx0 +2hYdTgqGijVHPb8FcS7vHblIUCb7WLONyEZ34GbL8HmhD+9oMl2Vv0F3UV+Y6S6q +o5rRUYxeaqzZGZcng/lFBikhl8ziN81A+eNUcjJWHQKBgQCRXcYR19roZk8T4tCW +QHlTyhxo9SJXd4GR1oNzAl2dEQb6pFsj3S4uxqLTA5ALD6UvRuYDmW7wbVzDmybW +4veL4di6AZm4JP4/RXOytIFjFlWD32JdENI9LbNW1HyG5Xz67YVEupnrPtSgm4l+ +DsNjgbvS+ZyrJDhxfPaS1f4iLA== +-----END PRIVATE KEY----- diff --git a/openssl/server/rootCA.srl b/openssl/server/rootCA.srl new file mode 100644 index 0000000..df3df38 --- /dev/null +++ b/openssl/server/rootCA.srl @@ -0,0 +1 @@ +02CE9392E93C0EB4D8E6C8F16A169D02A5671540 diff --git a/openssl/server/server1.crt b/openssl/server/server1.crt new file mode 100644 index 0000000..2d52f7b --- /dev/null +++ b/openssl/server/server1.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDsTCCApmgAwIBAgIUAs6Tkuk8DrTY5sjxahadAqVnFT8wDQYJKoZIhvcNAQEL +BQAwODESMBAGA1UEAwwJbG9jYWxob3N0MQswCQYDVQQGEwJNWTEVMBMGA1UEBwwM +S3VhbGEgTHVtcHVyMB4XDTI0MDIwMjIxMzQzM1oXDTM0MDEzMDIxMzQzM1owezEL +MAkGA1UEBhMCTVkxFTATBgNVBAgMDEt1YWxhIEx1bXB1cjEVMBMGA1UEBwwMS3Vh +bGEgTHVtcHVyMRIwEAYDVQQKDAlFY2hvVmF1bHQxFjAUBgNVBAsMDUVjaG9WYXVs +dCBEZXYxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBALhjjr04IVgFWX7JHOzQHR7XCePb4T/HmSn5lQBP9d4jAq0yIAxl +mLr7qR3/FPVE2zZIvgz4AWPf5RAJBFCFHquMZuTAREAR20tApDwYraL2Lsdwe70C +6zLmJ64/7wIJWN03H0CUoe++7w4CXSoGi7Y2FmoGOno3yacfZNJAEekPTW2Kl6sG +WpsV2sjGqP8uFXP8SU3RuoJs9z/YSZm04/UxTv3wYycK1Qt+JhgyjDeXQ73casrT +IRUoKFXdf+YNjZBSWEhKU6kkapQbXMSfROGU/HgCqVKvi7z5ykog5ycjxAT1TDH8 +uSssajGEknlnAebFBmvKIyX9Rxzw2ebkd3MCAwEAAaNwMG4wHwYDVR0jBBgwFoAU +PtIcTECRJBgs8kTcH6IwcZ/XV0UwCQYDVR0TBAIwADALBgNVHQ8EBAMCBPAwFAYD +VR0RBA0wC4IJbG9jYWxob3N0MB0GA1UdDgQWBBTdq3Qkdmu6R21tCyLk7NO/KcSW +cjANBgkqhkiG9w0BAQsFAAOCAQEAiGPLaZgFzKdhVTgxcRzOsav7YDwz4yUy1sC5 +XYKIQJMPJ5hcNA3YfByuSvAWa8myu1LAB2RXMrprSzrBILjBWYdRSFWkOqbPGH88 +kC1FLHvFR4L9ncP4XddDtY9YX+oGC2nZT5rYTH+nikm/TxPhOutDgUuKWOKoFag0 +olW2XHgcKnG92SoSAtp1mBBYrXN8d3ZQKB84ubb4PDiqvD/TyLqfljn9bv2zSZd/ +ZtoGLYzRcJLyrQGOQM05++8vsVg1tcGpoDvij2h2A6GNX7z/wrY/v0WNaRaowmJT ++wyvtpvwYxS1CFYkt5GdDSZB66SYw73onHsOTLDK7YjV/jt2tw== +-----END CERTIFICATE----- diff --git a/openssl/server/server1.key b/openssl/server/server1.key new file mode 100644 index 0000000..f78d3ff --- /dev/null +++ b/openssl/server/server1.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4Y469OCFYBVl+ +yRzs0B0e1wnj2+E/x5kp+ZUAT/XeIwKtMiAMZZi6+6kd/xT1RNs2SL4M+AFj3+UQ +CQRQhR6rjGbkwERAEdtLQKQ8GK2i9i7HcHu9Ausy5ieuP+8CCVjdNx9AlKHvvu8O +Al0qBou2NhZqBjp6N8mnH2TSQBHpD01tiperBlqbFdrIxqj/LhVz/ElN0bqCbPc/ +2EmZtOP1MU798GMnCtULfiYYMow3l0O93GrK0yEVKChV3X/mDY2QUlhISlOpJGqU +G1zEn0ThlPx4AqlSr4u8+cpKIOcnI8QE9Uwx/LkrLGoxhJJ5ZwHmxQZryiMl/Ucc +8Nnm5HdzAgMBAAECggEANi9zk+F50v8HdL2vFVx3Ikf5LQ/BmteSCAbDJatZymSp +dbIkPuBgSJqJ8Tmzs/v/G223A+KhrfLuwo6TyQHFqI4C8rgZlmZo9i1R1iM+a4RC +7PL+OeYwre16vbcmCogqqB95vKWxDN4kLA6/yAjSZ8JvRcr8xku8o7MTEsInQUBw +sqgsXaibn0Hjo3yI+jo/PEEEkJQpsMgdJoooMxtgx1BFP6lFXI4syf70Jyw97Xfg +wz0Cs0fYLZ+bwvsV6dBG6jD6M43L/1rc2Pl61EU9cUlgGSLZupp1QRhECYbCe7Lc +clhPWhXOGs2F1KcgORZo5vfxRM8H0TZ3MbWvGv0LkQKBgQDuuTGltLYWMTLWsfDG +ebr5GMkyBFwGJMLphJniC8FvGEE1sFEIl0RL3BrLo1TbN69B0eu8khTcEUo+1Jmd +V21SsyH7my7/myrXkzQBVq1zXDV7HdF59PRAANVeLgut1F0TcjH3sCNCrEyu2/hW +y1BIm+78qAe3jIGdQNrDEeI7+QKBgQDFu7aTv0vZtA1TzJuhiZgLYAV5Ozomj/c0 +QfWDEQHakWViX3emF4LixjHdCkt8axzeYECfVIfUrJCLc+iDizUK/dYxoMjeIopM +1awWq7bAHTcvHkHKBKf71vkPeiSrbuloZHEiJmiKabeesydwFuenvcBvSF/mwE6A +5zodQbZxywKBgBRFW4bjonad9OAwOe7QlWTjiuoZXqsS4g4sOVjtgJ5rY9YoQ6lE +FwOODCRwmRsITnR7W9YmXWkWesR9DxJCQ0E7fs47rjD8PxYRJOBcONxL3yq2LHx7 +pWXt7DBUHp/DIaguETokFcpqkRRkD2FnYEjaHOANcKJQZw0wXaMk2J4ZAoGBAKX+ +PHpx6BIdleaYaLpGUQ6TkGTCdMG0r/j9ukZKO70pu+vGayJSsH0BlxCRuOb84KJK +OVXIV7MRHtMC/dmYPnI4v9yvtpDMfD+eTLZHdsZ2gEIc62vVVtQTFsiIaEpGdMk/ +ML5TcgVoVE505ZGymMx3fhmtr1x+aijKdD3lUWzbAoGAcb3yQ6CG1p3H0hhfya6P +xH+sw8I9jkPVkFsIBmogSO06pDf9Y5rn793LMDcF3ReKh0KYdxa2bc+H+PX5fd0X +b5Va1OrBKeRdAJpDLcWAwP2j0fMn6BxfOagPpi6MolwjFYxdGpmnSXA4qUkzQy8+ +MD8i0N36qGHSFas73MgXfIE= +-----END PRIVATE KEY----- diff --git a/openssl/server/server2.crt b/openssl/server/server2.crt new file mode 100644 index 0000000..6fdfa07 --- /dev/null +++ b/openssl/server/server2.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDsTCCApmgAwIBAgIUAs6Tkuk8DrTY5sjxahadAqVnFUAwDQYJKoZIhvcNAQEL +BQAwODESMBAGA1UEAwwJbG9jYWxob3N0MQswCQYDVQQGEwJNWTEVMBMGA1UEBwwM +S3VhbGEgTHVtcHVyMB4XDTI0MDIwMjIxMzkxN1oXDTM0MDEzMDIxMzkxN1owezEL +MAkGA1UEBhMCTVkxFTATBgNVBAgMDEt1YWxhIEx1bXB1cjEVMBMGA1UEBwwMS3Vh +bGEgTHVtcHVyMRIwEAYDVQQKDAlFY2hvVmF1bHQxFjAUBgNVBAsMDUVjaG9WYXVs +dCBEZXYxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBALcFy9FDehITe6wbqUSt4LiOvPWuxDwLJiqpxsCF8pBGAjIQd5Q9 +WZr4LERS3nLrT/0zRoMHwNLmrNQkkzX5HOe63Ue14VKBsuWlDtvdqFB6xXaqXTLa +YjEmL0Hnw1RVq4mgR0zgOF0Jg0AIbYejrGnIP6OBuy41+FZSmRhH9W2D15xMX9Lp +v+xcRUcy2irBUsZduS5a//aBapMk4d57P9ql2UyCka9H5TNi8wNc2UEyJX2Ctfy0 +AG1RNSxRa5wiMuCK13s6dt8GZNb06hOJ4RbC4jJGRMRsnVX3ycdnRec5nbQ2U1qm +EpwZCgrTG3IwABk33E2nzlPEFSc9CK8cqlkCAwEAAaNwMG4wHwYDVR0jBBgwFoAU +PtIcTECRJBgs8kTcH6IwcZ/XV0UwCQYDVR0TBAIwADALBgNVHQ8EBAMCBPAwFAYD +VR0RBA0wC4IJbG9jYWxob3N0MB0GA1UdDgQWBBQNv4nVf+SHAq9hztQYiAMekFZy +0TANBgkqhkiG9w0BAQsFAAOCAQEAdskk1zhea1Dk22fJPfDSiV/EiNfD5HV+Q0hT +xzmkByOcdPt0dgo8tCSGGn921rLhYN+J7dJkht9Rvo356A6QyDsTfPF/GHT4GTdg +fzbIuJZSKyRGWPQcFN/ta+zjMeyk+4OLfcj78ChGE3FwNb5aEAouip1Ocdrp/x/9 +VyxDtFxQdrhlcUOphw+IW1NKZj+5jlRr+AWd9Vv/i+KOrlS1F4bdn2QpJX0cg1cN +c6v/0XQkbvxuwPAled32ALaL8praIFxItzE0Rrj6jEyNMieeUqYjjSB68DE55VpX +VF7fSSLbJjvtlmpz4LYGlNPMZvA9nZbJnKiTeUmuPCB7rXYNrA== +-----END CERTIFICATE----- diff --git a/openssl/server/server2.key b/openssl/server/server2.key new file mode 100644 index 0000000..a88ba45 --- /dev/null +++ b/openssl/server/server2.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3BcvRQ3oSE3us +G6lEreC4jrz1rsQ8CyYqqcbAhfKQRgIyEHeUPVma+CxEUt5y60/9M0aDB8DS5qzU +JJM1+Rznut1HteFSgbLlpQ7b3ahQesV2ql0y2mIxJi9B58NUVauJoEdM4DhdCYNA +CG2Ho6xpyD+jgbsuNfhWUpkYR/Vtg9ecTF/S6b/sXEVHMtoqwVLGXbkuWv/2gWqT +JOHeez/apdlMgpGvR+UzYvMDXNlBMiV9grX8tABtUTUsUWucIjLgitd7OnbfBmTW +9OoTieEWwuIyRkTEbJ1V98nHZ0XnOZ20NlNaphKcGQoK0xtyMAAZN9xNp85TxBUn +PQivHKpZAgMBAAECggEAJAEfZeTk1D6B80sjwu+DyDrIQHqnfvpggT8R6tjO7YPg +NbIYnBBvmrVcm/pDaY8SFsjqA6fYToTzle42CYWeopWXp15H27/JDjUo1abm0CI+ +y0fbesAMVgfhfxEVU3dg/fuKWzy2ydKvv76IsYjIx6yNnGBOjtouJukr1eN+DBNw +3xw1vndn25uRLCvae5vHx2pAZykcmoN65tksS2RolulWttzXOyXLRVWw0mpzahqo +/qHjGrX0InE7xQh3rHErQ+YnLiv43NeY5PupSLCGrXRhdRSwne/NU4FfFlwzXL5b +7JreEPi2PcvudkEhOKK0aEn2YhURRblj93Lb0H+KCwKBgQDlHZorPVKWcezlQD8P +2rpf27Lssqy6rFxXbgfrRXnj2r8BbSkiEFAM2wbyZODL9rAyNyAUhKz96F7WE74P +jjHDHoGxj1CxxcleZPx8en+qyx58Yb8wLswwbX4J1o9dTgnU8DN/z1wEIiyMMN7z +I2V5CQCr75eygmY1zjLg9W0WZwKBgQDMf5ufHGnGBtT8NrTpXnKaaY9AYJsp2G9U +ck3HFGtdGvAS85UA8OfQBdEQNtkaVe79hmet9OdfH0i5I9RdnW90WndAOZa6tRfH +k/Gcrjh6+sFrdF7ll29qEowvSBPFg1cRJzz2eZJepZChWeMrsq5FW7j3t0d4s2Zl +o/tJkExBPwKBgBolea2Ljvw6PhWfclLl3DUKRm36qfmXp+YWWXMA97sIAJoyEeqg +P/JnoHBTENBV85+XaOLOjUtglEoL8LmnuYgR2C0iNMxEzQknrySpeh5MlcsOAJqI +DKdOJ38J7Exylm6lhssEJ/UUzU6mWRsYJAFfBKOacQ5fETj8shO4Dl3rAoGBAMhu +wi7fAGURSSuyyvp4kcb6c2dbyHjpI6UXK1hWkSx+PJO2nnJ/rBVdvh0wRPXlCAsA +8xmzEhtPZE3h6kGfDyBxkrQmPa/d0uLQBF3W/JC8uVsCgghxtse2SiQFdyt9oZa0 +aLIDUgzmJa2flmK8DMb6MX7J6olI/LHeWWsuvS6tAoGBAMPEzNiJwkH/0Wjl15hg +ClBEtyen99iMRpztQoc3r/fbK3oIVaEis2Udka+vcfCXfIiDS4c2eOy4WWaTxquo +T41o6EBMbOABJoWA7Zd+qJeXi9+dX7ZbePYu/vSL0lKkfiEIUIpTjzDSSmT2bGR3 +3zF/PPSNLhFoESouDSmzLxO1 +-----END PRIVATE KEY----- diff --git a/redis_benchmark.go b/redis_benchmark.go new file mode 100644 index 0000000..4454f07 --- /dev/null +++ b/redis_benchmark.go @@ -0,0 +1,141 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "strings" + "text/tabwriter" + "time" +) + +const ( + Host = "localhost" + SugarDBPort = "7480" + RedisPort = "6379" +) + +type Metrics struct { + CommandName string + RequestsPerSecond string + P50Latency string +} + +func getCommandArgs() (string, bool) { + defaultCommands := "ping,set,get,incr,lpush,rpush,lpop,rpop,sadd,hset,zpopmin,lrange,mset" + commands := flag.String("commands", defaultCommands, "Commands to run") + useLocal := flag.Bool("use_local_server", false, "Run benchamark using local SugarDB server") + flag.Parse() + fmt.Printf("Provided commands: %s\n", *commands) + if *useLocal { + fmt.Println("Using local running SugarDB server") + } + return *commands, *useLocal +} + +func runBenchmark(port string, commands string) ([]Metrics, error) { + var results []Metrics + + // Run redis-benchmark + cmd := exec.Command("redis-benchmark", "-h", Host, "-p", port, "-q", "-t", commands) + output, err := cmd.CombinedOutput() + if err != nil { + return nil, err + } + + strOutput := string(output) + fmt.Println(strOutput) + lines := strings.Split(strOutput, "\n") + for _, line := range lines { + if !strings.HasPrefix(line, "WARNING") && line != "" { + // Get command name + colonIndex := strings.Index(line, ":") + commandName := line[:colonIndex] + + // Get requests per second + reqSecIndex := strings.Index(line, " requests per second") + spaceIndex := strings.LastIndex(line[:reqSecIndex], " ") + requestsPerSecond := line[spaceIndex+1 : reqSecIndex] + + // Get p50 latency + p50Index := strings.Index(line, "p50=") + spaceAfterP50 := strings.Index(line[p50Index:], " ") + p50Latency := line[p50Index+4 : p50Index+spaceAfterP50] + + results = append(results, Metrics{ + CommandName: commandName, + RequestsPerSecond: requestsPerSecond, + P50Latency: p50Latency, + }) + } + } + + return results, nil +} + +func createDisplayTable(redisResults []Metrics, sugarDBResults []Metrics) { + if len(sugarDBResults) != len(redisResults) { + fmt.Println("Error: Number of commands in Redis and SugarDB do not match") + } + + fmt.Println("Benchmark Performance Results:") + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprint(w, "Command\tRedis (req/sec)\tRedis p50 Latency (msec)\tSugarDB (req/sec)\tSugarDB p50 Latency (msec)\t\n") + for i := 0; i < len(redisResults); i++ { + command := redisResults[i].CommandName + redisReqSec := redisResults[i].RequestsPerSecond + redisLatency := redisResults[i].P50Latency + sugarDBReqSec := sugarDBResults[i].RequestsPerSecond + sugarDBLatency := sugarDBResults[i].P50Latency + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t\n", command, redisReqSec, redisLatency, sugarDBReqSec, sugarDBLatency) + } + w.Flush() +} + +func main() { + + commands, useLocal := getCommandArgs() + + // Start a local Redis server, wait a few seconds for it to start + exec.Command("redis-server", "--port", RedisPort).Start() + time.Sleep(2 * time.Second) + + // Run benchmark on local Redis server + fmt.Println("-------Running Redis Benchmarks------") + redisResults, err := runBenchmark(RedisPort, commands) + if err != nil { + fmt.Println("Error running benchmark on Redis server:", err) + return + } + + if !useLocal { + // Run the packaged SugarDB server, wait a few seconds for it to start + exec.Command("echovault", "--bind-addr=localhost", "--data-dir=persistence").Start() + time.Sleep(5 * time.Second) + } + + // Run benchmark on SugarDB server + fmt.Println("-------Running SugarDB Benchmarks------") + sugarDBResults, err := runBenchmark(SugarDBPort, commands) + if err != nil { + fmt.Println("Error running benchmark on SugarDB server:", err) + fmt.Println("Check that the SugarDB server is running") + return + } + + // Display results in a table format + createDisplayTable(redisResults, sugarDBResults) + + // Kill the local Redis server + exec.Command("pkill", "-f", "redis-server").Run() + + if !useLocal { + // Kill the packaged SugarDB server + exec.Command("pkill", "-f", "echovault").Run() + if err := os.RemoveAll("persistence"); err != nil { // Remove persistence directory + fmt.Println("Error removing persistence directory:", err) + } + } +} diff --git a/sugardb/api_acl.go b/sugardb/api_acl.go new file mode 100644 index 0000000..6f86903 --- /dev/null +++ b/sugardb/api_acl.go @@ -0,0 +1,387 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "bytes" + "fmt" + "apigo.cc/go/sugardb/internal" + "github.com/tidwall/resp" + "strings" +) + +// ACLLoadOptions modifies the behaviour of the ACLLoad function. +// If Merge is true, the ACL configuration from the file will be merged with the in-memory ACL configuration. +// If Replace is set to true, the ACL configuration from the file will replace the in-memory ACL configuration. +// If both flags are set to true, Merge will be prioritised. +type ACLLoadOptions struct { + Merge bool + Replace bool +} + +// User is the user object passed to the ACLSetUser function to update an existing user or create a new user. +// +// Username - string - the user's username. +// +// Enabled - bool - whether the user should be enabled (i.e connections can authenticate with this user). +// +// NoPassword - bool - if true, this user can be authenticated against without a password. +// +// NoKeys - bool - if true, this user will not be allowed to access any keys. +// +// NoCommands - bool - if true, this user will not be allowed to execute any commands. +// +// ResetPass - bool - if true, all the user's configured passwords are removed and NoPassword is set to false. +// +// ResetKeys - bool - if true, the user's NoKeys flag is set to true and all their currently accessible keys are cleared. +// +// ResetChannels - bool - if true, the user will be allowed to access all PubSub channels. +// +// AddPlainPasswords - []string - the list of plaintext passwords to add to the user's passwords. +// +// RemovePlainPasswords - []string - the list of plaintext passwords to remove from the user's passwords. +// +// AddHashPasswords - []string - the list of SHA256 password hashes to add to the user's passwords. +// +// RemoveHashPasswords - []string - the list of SHA256 password hashes to add to the user's passwords. +// +// IncludeCategories - []string - the list of ACL command categories to allow this user to access, default is all. +// +// ExcludeCategories - []string - the list of ACL command categories to bar the user from accessing. The default is none. +// +// IncludeCommands - []string - the list of commands to allow the user to execute. The default is none. If you want to +// specify a subcommand, use the format "command|subcommand". +// +// ExcludeCommands - []string - the list of commands to bar the user from executing. +// The default is none. If you want to specify a subcommand, use the format "command|subcommand". +// +// IncludeReadWriteKeys - []string - the list of keys the user is allowed read and write access to. The default is all. +// This field accepts glob pattern strings. +// +// IncludeReadKeys - []string - the list of keys the user is allowed read access to. The default is all. +// This field accepts glob pattern strings. +// +// IncludeWriteKeys - []string - the list of keys the user is allowed write access to. The default is all. +// This field accepts glob pattern strings. +// +// IncludeChannels - []string - the list of PubSub channels the user is allowed to access ("Subscribe" and "Publish"). +// This field accepts glob pattern strings. +// +// ExcludeChannels - []string - the list of PubSub channels the user cannot access ("Subscribe" and "Publish"). +// This field accepts glob pattern strings. +type User struct { + Username string + Enabled bool + NoPassword bool + NoKeys bool + NoCommands bool + ResetPass bool + ResetKeys bool + ResetChannels bool + + AddPlainPasswords []string + RemovePlainPasswords []string + AddHashPasswords []string + RemoveHashPasswords []string + + IncludeCategories []string + ExcludeCategories []string + + IncludeCommands []string + ExcludeCommands []string + + IncludeReadWriteKeys []string + IncludeReadKeys []string + IncludeWriteKeys []string + + IncludeChannels []string + ExcludeChannels []string +} + +// ACLCat returns either the list of all categories or the list of commands within a specified category. +// +// Parameters: +// +// `category` - ...string - an optional string specifying the category. If more than one category is passed, +// only the first one will be used. +// +// Returns: string slice of categories loaded in SugarDB if category is not specified. Otherwise, returns string +// slice of commands within the specified category. +// +// Errors: +// +// "category not found" - when the provided category is not found in the loaded commands. +func (server *SugarDB) ACLCat(category ...string) ([]string, error) { + cmd := []string{"ACL", "CAT"} + if len(category) > 0 { + cmd = append(cmd, category[0]) + } + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// ACLUsers returns a string slice containing the usernames of all the loaded users in the ACL module. +func (server *SugarDB) ACLUsers() ([]string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"ACL", "USERS"}), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// ACLSetUser modifies or creates a new user. If the user with the specified username exists, the ACL user will be modified. +// Otherwise, a new User is created. +// +// Parameters: +// +// `user` - User - The user object to add/update. +// +// Returns: true if the user is successfully created/updated. +func (server *SugarDB) ACLSetUser(user User) (bool, error) { + cmd := []string{"ACL", "SETUSER", user.Username} + + if user.Enabled { + cmd = append(cmd, "on") + } else { + cmd = append(cmd, "off") + } + + if user.NoPassword { + cmd = append(cmd, "nopass") + } + + if user.NoKeys { + cmd = append(cmd, "nokeys") + } + + if user.NoCommands { + cmd = append(cmd, "nocommands") + } + + if user.ResetPass { + cmd = append(cmd, "resetpass") + } + + if user.ResetKeys { + cmd = append(cmd, "resetkeys") + } + + if user.ResetChannels { + cmd = append(cmd, "resetchannels") + } + + for _, password := range user.AddPlainPasswords { + cmd = append(cmd, fmt.Sprintf(">%s", password)) + } + + for _, password := range user.RemovePlainPasswords { + cmd = append(cmd, fmt.Sprintf("<%s", password)) + } + + for _, password := range user.AddHashPasswords { + cmd = append(cmd, fmt.Sprintf("#%s", password)) + } + + for _, password := range user.RemoveHashPasswords { + cmd = append(cmd, fmt.Sprintf("!%s", password)) + } + + for _, category := range user.IncludeCategories { + cmd = append(cmd, fmt.Sprintf("+@%s", category)) + } + + for _, category := range user.ExcludeCategories { + cmd = append(cmd, fmt.Sprintf("-@%s", category)) + } + + for _, command := range user.IncludeCommands { + cmd = append(cmd, fmt.Sprintf("+%s", command)) + } + + for _, command := range user.ExcludeCommands { + cmd = append(cmd, fmt.Sprintf("-%s", command)) + } + + for _, key := range user.IncludeReadWriteKeys { + cmd = append(cmd, fmt.Sprintf("%s~%s", "%RW", key)) + } + + for _, key := range user.IncludeReadKeys { + cmd = append(cmd, fmt.Sprintf("%s~%s", "%R", key)) + } + + for _, key := range user.IncludeWriteKeys { + cmd = append(cmd, fmt.Sprintf("%s~%s", "%W", key)) + } + + for _, channel := range user.IncludeChannels { + cmd = append(cmd, fmt.Sprintf("+&%s", channel)) + } + + for _, channel := range user.ExcludeChannels { + cmd = append(cmd, fmt.Sprintf("-&%s", channel)) + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return false, err + } + + s, err := internal.ParseStringResponse(b) + return strings.EqualFold(s, "ok"), err +} + +// ACLGetUser gets the ACL configuration of the name with the given username. +// +// Parameters: +// +// `username` - string - the username whose ACL rules you'd like to retrieve. +// +// Returns: A map[string][]string map where each key is the rule category and each value is a string slice of relevant values. +// The map returned has the following structure: +// +// "username" - string slice containing the user's username. +// +// "flags" - string slices containing the following values: "on" if the user is enabled, otherwise "off", +// "nokeys" if the user is not allowed to access any keys (and NoKeys is true), +// "nopass" if the user has no passwords (and NoPass is true). +// +// "categories" - string slice af ACL command categories associated with the user. +// If the user is allowed to access all categories, it will contain "+@*". +// For each category the user is allowed to access, the slice will contain "+@". +// If the user is not allowed to access any categories, it will contain "-@*". +// For each category the user is not allowed to access, the slice will contain "-@". +// +// "commands" - string slice af commands associated with the user. +// If the user is allowed to execute all commands, it will contain "+all". +// For each command the user is allowed to execute, the slice will contain "+". +// If the user is not allowed to execute any commands, it will contain "-all". +// For each command the user is not allowed to execute, the slice will contain "-". +// +// "keys" - string slice af keys associated with the user. +// If the user is allowed read/write access all keys, the slice will contain "%RW~*". +// For each key glob pattern the user has read/write access to, the slice will contain "%RW~". +// If the user is allowed read access to all keys, the slice will contain "%R~*". +// For each key glob pattern the user has read access to, the slice will contain "%R~". +// If the user is allowed write access to all keys, the slice will contain "%W~*". +// For each key glob pattern the user has write access to, the slice will contain "%W~". +// +// "channels" - string slice af pubsub channels associated with the user. +// If the user is allowed to access all channels, the slice will contain "+&*". +// For each channel the user is allowed to access, the slice will contain "+&". +// If the user is not allowed to access any channels, the slice will contain "-&*". +// For each channel the user is not allowed to access, the slice will contain "-&". +// +// Errors: +// +// "user not found" - if the user requested does not exist in the ACL rules. +func (server *SugarDB) ACLGetUser(username string) (map[string][]string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"ACL", "GETUSER", username}), nil, false, true) + if err != nil { + return nil, err + } + + r := resp.NewReader(bytes.NewReader(b)) + v, _, err := r.ReadValue() + if err != nil { + return nil, err + } + + arr := v.Array() + + result := make(map[string][]string) + + for i := 0; i < len(arr); i += 2 { + key := arr[i].String() + value := arr[i+1].Array() + + result[key] = make([]string, len(value)) + + for j := 0; j < len(value); j++ { + result[key][j] = value[j].String() + } + } + + return result, nil +} + +// ACLDelUser deletes all the users with the specified usernames. +// +// Parameters: +// +// `usernames` - ...string - A string of usernames to delete from the ACL module. +// +// Returns: true if the deletion is successful. +func (server *SugarDB) ACLDelUser(usernames ...string) (bool, error) { + cmd := append([]string{"ACL", "DELUSER"}, usernames...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return false, err + } + s, err := internal.ParseStringResponse(b) + return strings.EqualFold(s, "ok"), err +} + +// ACLList lists all the currently loaded ACL users and their rules. +func (server *SugarDB) ACLList() ([]string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"ACL", "LIST"}), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// ACLLoad loads the ACL configuration from the configured ACL file. The load function can either merge the loaded +// config with the in-memory config, or replace the in-memory config with the loaded config entirely. +// +// Parameters: +// +// `options` - ACLLoadOptions - modifies the load behaviour. +// +// Returns: true if the load is successful. +func (server *SugarDB) ACLLoad(options ACLLoadOptions) (bool, error) { + cmd := []string{"ACL", "LOAD"} + switch { + case options.Merge: + cmd = append(cmd, "MERGE") + case options.Replace: + cmd = append(cmd, "REPLACE") + default: + cmd = append(cmd, "REPLACE") + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return false, err + } + + s, err := internal.ParseStringResponse(b) + return strings.EqualFold(s, "ok"), err +} + +// ACLSave saves the current ACL configuration to the configured ACL file. +// +// Returns: true if the save is successful. +func (server *SugarDB) ACLSave() (bool, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"ACL", "SAVE"}), nil, false, true) + if err != nil { + return false, err + } + s, err := internal.ParseStringResponse(b) + return strings.EqualFold(s, "ok"), err +} diff --git a/sugardb/api_acl_test.go b/sugardb/api_acl_test.go new file mode 100644 index 0000000..98e6923 --- /dev/null +++ b/sugardb/api_acl_test.go @@ -0,0 +1,662 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "apigo.cc/go/sugardb/internal/constants" + "os" + "path" + "slices" + "strings" + "testing" + "time" +) + +func generateInitialTestUsers() []User { + return []User{ + { + // User with both hash password and plaintext password. + Username: "with_password_user", + Enabled: true, + IncludeCategories: []string{"*"}, + IncludeCommands: []string{"*"}, + AddPlainPasswords: []string{"password2"}, + AddHashPasswords: []string{generateSHA256Password("password3")}, + }, + { + // User with NoPassword option. + Username: "no_password_user", + Enabled: true, + NoPassword: true, + AddPlainPasswords: []string{"password4"}, + }, + { + // Disabled user. + Username: "disabled_user", + Enabled: false, + AddPlainPasswords: []string{"password5"}, + }, + } +} + +// compareSlices compare the elements in 2 slices, it checks if every element is s1 is contained in s2 +// and vice versa. It essentially does a deep equality comparison. +// This is done manually rather than using slices.Equal because it would be ideal to throw an error +// specifying exactly which items are missing in either slice. +func compareSlices[T comparable](res, expected []T) error { + if len(res) != len(expected) { + return fmt.Errorf("expected slice of length %d, got slice of length %d", len(expected), len(res)) + } + // Check whether all elements in res are contained in expected + for _, r := range res { + if !slices.Contains(expected, r) { + return fmt.Errorf("got response item %+v, but it's not contained in expected slices", r) + } + } + // Check whether all elements in expected are contained in res + for _, e := range expected { + if !slices.Contains(res, e) { + return fmt.Errorf("expected element %+v, not found in res slice", e) + } + } + return nil +} + +// compareUsers compares 2 users and checks if all their fields are equal +func compareUsers(user1, user2 map[string][]string) error { + // Compare flags + if user1["username"][0] != user2["username"][0] { + return fmt.Errorf("mismatched usernames \"%s\", and \"%s\"", user1["username"][0], user2["username"][0]) + } + + // Check if both users are enabled. + if slices.Contains(user1["flags"], "on") != slices.Contains(user2["flags"], "on") { + return fmt.Errorf("mismatched enabled flag \"%+v\", and \"%+v\"", + slices.Contains(user1["flags"], "on"), slices.Contains(user2["flags"], "on")) + } + + // Check if "nokeys" is present + if slices.Contains(user1["flags"], "nokeys") != slices.Contains(user2["flags"], "nokeys") { + return fmt.Errorf("mismatched nokeys flag \"%+v\", and \"%+v\"", + slices.Contains(user1["flags"], "nokeys"), slices.Contains(user2["flags"], "nokeys")) + } + + // Check if "nopass" is present + if slices.Contains(user1["flags"], "nopass") != slices.Contains(user1["flags"], "nopass") { + return fmt.Errorf("mismatched nopassword flag \"%+v\", and \"%+v\"", + slices.Contains(user1["flags"], "nopass"), slices.Contains(user1["flags"], "nopass")) + } + + // Compare permissions + permissions := [][][]string{ + {user1["categories"], user2["categories"]}, + {user1["commands"], user2["commands"]}, + {user1["keys"], user2["keys"]}, + {user1["channels"], user2["channels"]}, + } + for _, p := range permissions { + if err := compareSlices(p[0], p[1]); err != nil { + return err + } + } + + return nil +} + +func generateSHA256Password(plain string) string { + h := sha256.New() + h.Write([]byte(plain)) + return hex.EncodeToString(h.Sum(nil)) +} + +func TestSugarDB_ACLCat(t *testing.T) { + server := createSugarDB() + + getCategoryCommands := func(category string) []string { + var commands []string + for _, command := range server.commands { + if slices.Contains(command.Categories, category) && (command.SubCommands == nil || len(command.SubCommands) == 0) { + commands = append(commands, strings.ToLower(command.Command)) + continue + } + for _, subcommand := range command.SubCommands { + if slices.Contains(subcommand.Categories, category) { + commands = append(commands, strings.ToLower(fmt.Sprintf("%s|%s", command.Command, subcommand.Command))) + } + } + } + return commands + } + + tests := []struct { + name string + args []string + want []string + wantErr bool + }{ + { + name: "1. Get all ACL categories loaded on the server", + args: make([]string, 0), + want: []string{ + constants.AdminCategory, constants.ConnectionCategory, constants.DangerousCategory, + constants.HashCategory, constants.FastCategory, constants.KeyspaceCategory, constants.ListCategory, + constants.PubSubCategory, constants.ReadCategory, constants.WriteCategory, constants.SetCategory, + constants.SortedSetCategory, constants.SlowCategory, constants.StringCategory, + }, + wantErr: false, + }, + { + name: "2. Get all commands within the admin category", + args: []string{constants.AdminCategory}, + want: getCategoryCommands(constants.AdminCategory), + wantErr: false, + }, + { + name: "3. Get all commands within the connection category", + args: []string{constants.ConnectionCategory}, + want: getCategoryCommands(constants.ConnectionCategory), + wantErr: false, + }, + { + name: "4. Get all the commands within the dangerous category", + args: []string{constants.DangerousCategory}, + want: getCategoryCommands(constants.DangerousCategory), + wantErr: false, + }, + { + name: "5. Get all the commands within the hash category", + args: []string{constants.HashCategory}, + want: getCategoryCommands(constants.HashCategory), + wantErr: false, + }, + { + name: "6. Get all the commands within the fast category", + args: []string{constants.FastCategory}, + want: getCategoryCommands(constants.FastCategory), + wantErr: false, + }, + { + name: "7. Get all the commands within the keyspace category", + args: []string{constants.KeyspaceCategory}, + want: getCategoryCommands(constants.KeyspaceCategory), + wantErr: false, + }, + { + name: "8. Get all the commands within the list category", + args: []string{constants.ListCategory}, + want: getCategoryCommands(constants.ListCategory), + wantErr: false, + }, + { + name: "9. Get all the commands within the pubsub category", + args: []string{constants.PubSubCategory}, + want: getCategoryCommands(constants.PubSubCategory), + wantErr: false, + }, + { + name: "10. Get all the commands within the read category", + args: []string{constants.ReadCategory}, + want: getCategoryCommands(constants.ReadCategory), + wantErr: false, + }, + { + name: "11. Get all the commands within the write category", + args: []string{constants.WriteCategory}, + want: getCategoryCommands(constants.WriteCategory), + wantErr: false, + }, + { + name: "12. Get all the commands within the set category", + args: []string{constants.SetCategory}, + want: getCategoryCommands(constants.SetCategory), + wantErr: false, + }, + { + name: "13. Get all the commands within the sortedset category", + args: []string{constants.SortedSetCategory}, + want: getCategoryCommands(constants.SortedSetCategory), + wantErr: false, + }, + { + name: "14. Get all the commands within the slow category", + args: []string{constants.SlowCategory}, + want: getCategoryCommands(constants.SlowCategory), + wantErr: false, + }, + { + name: "15. Get all the commands within the string category", + args: []string{constants.StringCategory}, + want: getCategoryCommands(constants.StringCategory), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := server.ACLCat(tt.args...) + if (err != nil) != tt.wantErr { + t.Errorf("ACLCat() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("ACLCat() got length = %d, want length %d", len(got), len(tt.want)) + } + for _, item := range got { + if !slices.Contains(tt.want, item) { + t.Errorf("ACLCat() got unexpected element = %s, want %v", item, tt.want) + } + } + }) + } +} + +func TestSugarDB_ACLUsers(t *testing.T) { + server := createSugarDB() + + // Set Users + users := []User{ + { + Username: "user1", + Enabled: true, + NoPassword: true, + NoKeys: true, + NoCommands: true, + AddPlainPasswords: []string{}, + AddHashPasswords: []string{}, + IncludeCategories: []string{}, + IncludeReadWriteKeys: []string{}, + IncludeReadKeys: []string{}, + IncludeWriteKeys: []string{}, + IncludeChannels: []string{}, + ExcludeChannels: []string{}, + }, + { + Username: "user2", + Enabled: true, + NoPassword: false, + NoKeys: false, + NoCommands: false, + AddPlainPasswords: []string{"password1", "password2"}, + AddHashPasswords: []string{ + func() string { + h := sha256.New() + h.Write([]byte("password1")) + return string(h.Sum(nil)) + }(), + }, + IncludeCategories: []string{constants.FastCategory, constants.SlowCategory, constants.HashCategory}, + ExcludeCategories: []string{constants.AdminCategory, constants.DangerousCategory}, + IncludeCommands: []string{"*"}, + ExcludeCommands: []string{"acl|load", "acl|save"}, + IncludeReadWriteKeys: []string{"user2-profile-*"}, + IncludeReadKeys: []string{"user2-privileges-*"}, + IncludeWriteKeys: []string{"write-key"}, + IncludeChannels: []string{"posts-*"}, + ExcludeChannels: []string{"actions-*"}, + }, + } + + for _, user := range users { + ok, err := server.ACLSetUser(user) + if err != nil { + t.Errorf("ACLSetUser() err = %v", err) + } + if !ok { + t.Errorf("ACLSetUser() ok = %v", ok) + } + } + + // Get users + aclUsers, err := server.ACLUsers() + if err != nil { + t.Errorf("ACLUsers() err = %v", err) + } + if len(aclUsers) != len(users)+1 { + t.Errorf("ACLUsers() got length %d, want %d", len(aclUsers), len(users)+1) + } + for _, username := range aclUsers { + if !slices.Contains([]string{"default", "user1", "user2"}, username) { + t.Errorf("ACLUsers() unexpected username = %s", username) + } + } + + // Get specific user. + user, err := server.ACLGetUser("user2") + if err != nil { + t.Errorf("ACLGetUser() err = %v", err) + } + if user == nil { + t.Errorf("ACLGetUser() user is nil") + } + + // Delete user + ok, err := server.ACLDelUser("user1") + if err != nil { + t.Errorf("ACLDelUser() err = %v", err) + } + if !ok { + t.Errorf("ACLDelUser() could not delete user user1") + } + aclUsers, err = server.ACLUsers() + if err != nil { + t.Errorf("ACLDelUser() err = %v", err) + } + if slices.Contains(aclUsers, "user1") { + t.Errorf("ACLDelUser() unexpected username user1") + } + + // Get list of currently loaded ACL rules. + list, err := server.ACLList() + if err != nil { + t.Errorf("ACLList() err = %v", err) + } + if len(list) != 2 { + t.Errorf("ACLList() got list length %d, want %d", len(list), 2) + } +} + +func TestSugarDB_ACLConfig(t *testing.T) { + t.Run("Test_HandleSave", func(t *testing.T) { + baseDir := path.Join(".", "testdata", "save") + t.Cleanup(func() { + _ = os.RemoveAll(baseDir) + }) + + tests := []struct { + name string + path string + want []string // Response from ACL List command. + }{ + { + name: "1. Save ACL config to .json file", + path: path.Join(baseDir, "json_test.json"), + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + }, + }, + { + name: "2. Save ACL config to .yaml file", + path: path.Join(baseDir, "yaml_test.yaml"), + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + }, + }, + { + name: "3. Save ACL config to .yml file", + path: path.Join(baseDir, "yml_test.yml"), + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Create new server instance + conf := DefaultConfig() + conf.DataDir = "" + conf.AclConfig = test.path + server := createSugarDBWithConfig(conf) + // Add the initial test users to the ACL module. + for _, user := range generateInitialTestUsers() { + if _, err := server.ACLSetUser(user); err != nil { + t.Error(err) + return + } + } + + ok, err := server.ACLSave() + if err != nil { + t.Error(err) + return + } + if !ok { + t.Errorf("expected ok to be true, got false") + } + + // Shutdown the mock server + server.ShutDown() + + // Restart server + server = createSugarDBWithConfig(conf) + + // Get users rules list. + list, err := server.ACLList() + + // Check if ACL LIST returns the expected list of users. + var resStr []string + for i := 0; i < len(list); i++ { + resStr = strings.Split(list[i], " ") + if !slices.ContainsFunc(test.want, func(s string) bool { + expectedUserSlice := strings.Split(s, " ") + return compareSlices(resStr, expectedUserSlice) == nil + }) { + t.Errorf("could not find the following user in expected slice: %+v", resStr) + return + } + } + }) + } + }) + + t.Run("Test_HandleLoad", func(t *testing.T) { + baseDir := path.Join(".", "testdata", "load") + t.Cleanup(func() { + _ = os.RemoveAll(baseDir) + }) + + tests := []struct { + name string + path string + users []User // Add users after server startup. + loadFunc func(server *SugarDB) (bool, error) // Function to load users from ACL config. + want []string + }{ + { + name: "1. Load config from the .json file", + path: path.Join(baseDir, "json_test.json"), + users: []User{ + {Username: "user1", Enabled: true}, + }, + loadFunc: func(server *SugarDB) (bool, error) { + return server.ACLLoad(ACLLoadOptions{}) + }, + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + "user1 on +@all +all %RW~* +&*", + }, + }, + { + name: "2. Load users from the .yaml file", + path: path.Join(baseDir, "yaml_test.yaml"), + users: []User{ + {Username: "user1", Enabled: true}, + }, + loadFunc: func(server *SugarDB) (bool, error) { + return server.ACLLoad(ACLLoadOptions{}) + }, + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + "user1 on +@all +all %RW~* +&*", + }, + }, + { + name: "3. Load users from the .yml file", + path: path.Join(baseDir, "yml_test.yml"), + users: []User{ + {Username: "user1", Enabled: true}, + }, + loadFunc: func(server *SugarDB) (bool, error) { + return server.ACLLoad(ACLLoadOptions{}) + }, + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + "user1 on +@all +all %RW~* +&*", + }, + }, + { + name: "4. Merge loaded users", + path: path.Join(baseDir, "merge.yml"), + users: []User{ + { // Disable user1. + Username: "user1", + Enabled: false, + }, + { // Update with_password_user. This should be merged with the existing user. + Username: "with_password_user", + AddPlainPasswords: []string{"password3", "password4"}, + IncludeReadWriteKeys: []string{"key1", "key2"}, + IncludeWriteKeys: []string{"key3", "key4"}, + IncludeReadKeys: []string{"key5", "key6"}, + IncludeChannels: []string{"channel[12]"}, + ExcludeChannels: []string{"channel[34]"}, + }, + }, + loadFunc: func(server *SugarDB) (bool, error) { + return server.ACLLoad(ACLLoadOptions{Merge: true, Replace: false}) + }, + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf(`with_password_user on >password2 >password3 >password4 #%s +@all +all %s~key1 %s~key2 %s~key5 %s~key6 %s~key3 %s~key4 +&channel[12] -&channel[34]`, + generateSHA256Password("password3"), "%RW", "%RW", "%R", "%R", "%W", "%W"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + "user1 off +@all +all %RW~* +&*", + }, + }, + { + name: "5. Replace loaded users", + path: path.Join(baseDir, "replace.yml"), + users: []User{ + { // Disable user1. + Username: "user1", + Enabled: false, + }, + { // Update with_password_user. This should be merged with the existing user. + Username: "with_password_user", + AddPlainPasswords: []string{"password3", "password4"}, + IncludeReadWriteKeys: []string{"key1", "key2"}, + IncludeWriteKeys: []string{"key3", "key4"}, + IncludeReadKeys: []string{"key5", "key6"}, + IncludeChannels: []string{"channel[12]"}, + ExcludeChannels: []string{"channel[34]"}, + }, + }, + loadFunc: func(server *SugarDB) (bool, error) { + return server.ACLLoad(ACLLoadOptions{Replace: true, Merge: false}) + }, + want: []string{ + "default on +@all +all %RW~* +&*", + fmt.Sprintf("with_password_user on >password2 #%s +@all +all %s~* +&*", + generateSHA256Password("password3"), "%RW"), + "no_password_user on nopass +@all +all %RW~* +&*", + "disabled_user off >password5 +@all +all %RW~* +&*", + "user1 off +@all +all %RW~* +&*", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Create server. + conf := DefaultConfig() + conf.DataDir = "" + conf.AclConfig = test.path + server := createSugarDBWithConfig(conf) + // Add the initial test users to the ACL module. + for _, user := range generateInitialTestUsers() { + if _, err := server.ACLSetUser(user); err != nil { + t.Error(err) + return + } + } + + // Save the current users to the ACL config file. + if _, err := server.ACLSave(); err != nil { + t.Error(err) + return + } + + ticker := time.NewTicker(200 * time.Millisecond) + <-ticker.C + + // Add some users to the ACL. + for _, user := range test.users { + if _, err := server.ACLSetUser(user); err != nil { + t.Error(err) + return + } + } + + // Load the users from the ACL config file. + ok, err := test.loadFunc(server) + if err != nil { + t.Error(err) + return + } + if !ok { + t.Errorf("expected ok to be true, got false") + return + } + + // Get ACL List + list, err := server.ACLList() + if err != nil { + t.Error(err) + return + } + + // Check if ACL LIST returns the expected list of users. + var resStr []string + for i := 0; i < len(list); i++ { + resStr = strings.Split(list[i], " ") + if !slices.ContainsFunc(test.want, func(s string) bool { + expectedUserSlice := strings.Split(s, " ") + return compareSlices(resStr, expectedUserSlice) == nil + }) { + t.Errorf("could not find the following user in expected slice: %+v", resStr) + return + } + } + }) + } + }) +} diff --git a/sugardb/api_admin.go b/sugardb/api_admin.go new file mode 100644 index 0000000..aaba1a5 --- /dev/null +++ b/sugardb/api_admin.go @@ -0,0 +1,397 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "context" + "fmt" + "apigo.cc/go/sugardb/internal" + "slices" + "strings" +) + +// CommandListOptions modifies the result from the CommandList command. +// +// ACLCAT filters the results by the provided category. Has the highest priority. +// +// PATTERN filters the result that match the given glob pattern. Has the second-highest priority. +// +// MODULE filters the result by the provided module. Has the lowest priority. +type CommandListOptions struct { + ACLCAT string + PATTERN string + MODULE string +} + +// CommandKeyExtractionFuncResult specifies the keys accessed by the associated command or subcommand. +// ReadKeys is a string slice containing the keys that the commands read from. +// WriteKeys is a string slice containing the keys that the command writes to. +// +// These keys will typically be extracted from the command slice, but they can also be hardcoded. +type CommandKeyExtractionFuncResult struct { + ReadKeys []string + WriteKeys []string +} + +// CommandKeyExtractionFunc if the function that extracts the keys accessed by the command or subcommand. +type CommandKeyExtractionFunc func(cmd []string) (CommandKeyExtractionFuncResult, error) + +// CommandHandlerFunc is the handler function for the command or subcommand. +// +// This function must return a byte slice containing a valid RESP2 response, or an error. +type CommandHandlerFunc func(params CommandHandlerFuncParams) ([]byte, error) + +// CommandHandlerFuncParams contains the helper parameters passed to the command's handler by SugarDB. +// +// Command is the string slice command containing the command that triggered this handler. +// +// KeysExist returns a map that specifies whether the key exists in the store. +// +// GetValues returns a map with the values held at the specified keys. When a key does not exist in the store the +// associated value will be nil. +// +// SetValues sets the keys given with their associated values. +type CommandHandlerFuncParams struct { + Context context.Context + Command []string + KeysExist func(ctx context.Context, keys []string) map[string]bool + GetValues func(ctx context.Context, keys []string) map[string]interface{} + SetValues func(ctx context.Context, entries map[string]interface{}) error +} + +// CommandOptions provides the specification of the command to be added to the SugarDB instance. +// +// Command is the keyword used to trigger this command (e.g. LPUSH, ZADD, ACL ...). +// +// Module is a string that classifies a group of commands. +// +// Categories is a string slice of all the categories that this command belongs to. +// +// Description is a string describing the command, can include an example of how to trigger the command. +// +// SubCommand is a slice of subcommands for this command. +// +// Sync is a boolean value that determines whether this command should be synced across a replication cluster. +// If subcommands are specified, each subcommand will override this value for its own execution. +// +// KeyExtractionFunc is a function that extracts the keys from the command if the command accesses any keys. +// the extracted keys are used by the ACL layer to determine whether a TCP client is authorized to execute this command. +// If subcommands are specified, this function is discarded and each subcommands must implement its own KeyExtractionFunc. +// +// HandlerFunc is the command handler. This function must return a valid RESP2 response as it the command will be +// available to RESP clients. If subcommands are specified, this function is discarded and each subcommand must implement +// its own HandlerFunc. +type CommandOptions struct { + Command string + Module string + Categories []string + Description string + SubCommand []SubCommandOptions + Sync bool + KeyExtractionFunc CommandKeyExtractionFunc + HandlerFunc CommandHandlerFunc +} + +// SubCommandOptions provides the specification of a subcommand within CommandOptions. +// +// Command is the keyword used to trigger this subcommand (e.g. "CAT" for the subcommand "ACL CAT"). +// +// Module is a string that classifies a group of commands/subcommands. +// +// Categories is a string slice of all the categories that this subcommand belongs to. +// +// Description is a string describing the subcommand, can include an example of how to trigger the subcommand. +// +// Sync is a boolean value that determines whether this subcommand should be synced across a replication cluster. +// This value overrides the Sync value set by the parent command. It's possible to have some synced and un-synced +// subcommands with the same parent command regardless of the parent's Sync value. +// +// KeyExtractionFunc is a function that extracts the keys from the subcommand if it accesses any keys. +// +// HandlerFunc is the subcommand handler. This function must return a valid RESP2 response as it will be +// available to RESP clients. +type SubCommandOptions struct { + Command string + Module string + Categories []string + Description string + Sync bool + KeyExtractionFunc CommandKeyExtractionFunc + HandlerFunc CommandHandlerFunc +} + +// CommandList returns the list of commands currently loaded in the SugarDB instance. +// +// Parameters: +// +// `options` - CommandListOptions. +// +// Returns: a string slice of all the loaded commands. SubCommands are represented as "command|subcommand". +func (server *SugarDB) CommandList(options ...CommandListOptions) ([]string, error) { + cmd := []string{"COMMAND", "LIST"} + + if len(options) > 0 { + switch { + case options[0].ACLCAT != "": + cmd = append(cmd, []string{"FILTERBY", "ACLCAT", options[0].ACLCAT}...) + case options[0].PATTERN != "": + cmd = append(cmd, []string{"FILTERBY", "PATTERN", options[0].PATTERN}...) + case options[0].MODULE != "": + cmd = append(cmd, []string{"FILTERBY", "MODULE", options[0].MODULE}...) + } + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + + return internal.ParseStringArrayResponse(b) +} + +// CommandCount returns the number of commands currently loaded in the SugarDB instance. +// +// Returns: integer representing the count of all available commands. +func (server *SugarDB) CommandCount() (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"COMMAND", "COUNT"}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// Save triggers a new snapshot. +// +// Returns: true if the save was started. The OK response does not confirm that the save was successfully synced to +// file. Only that the background process has started. +func (server *SugarDB) Save() (bool, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"SAVE"}), nil, false, true) + if err != nil { + return false, err + } + res, err := internal.ParseStringResponse(b) + return strings.EqualFold(res, "ok"), err +} + +// LastSave returns the unix epoch milliseconds timestamp of the last save. +func (server *SugarDB) LastSave() (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"LASTSAVE"}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// RewriteAOF triggers a compaction of the AOF file. +func (server *SugarDB) RewriteAOF() (string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"REWRITEAOF"}), nil, false, true) + if err != nil { + return "", err + } + return internal.ParseStringResponse(b) +} + +// AddCommand adds a new command to SugarDB. The added command can be executed using the ExecuteCommand method. +// +// Parameters: +// +// `command` - CommandOptions. +// +// Errors: +// +// "command already exists" - If a command with the same command name as the passed command already exists. +func (server *SugarDB) AddCommand(command CommandOptions) error { + server.commandsRWMut.Lock() + defer server.commandsRWMut.Unlock() + // Check if command already exists + for _, c := range server.commands { + if strings.EqualFold(c.Command, command.Command) { + return fmt.Errorf("command %s already exists", command.Command) + } + } + + if command.SubCommand == nil || len(command.SubCommand) == 0 { + // Add command with no subcommands + server.commands = append(server.commands, internal.Command{ + Command: command.Command, + Module: strings.ToLower(command.Module), // Convert module to lower case for uniformity + Categories: func() []string { + // Convert all the categories to lower case for uniformity + cats := make([]string, len(command.Categories)) + for i, cat := range command.Categories { + cats[i] = strings.ToLower(cat) + } + return cats + }(), + Description: command.Description, + Sync: command.Sync, + KeyExtractionFunc: internal.KeyExtractionFunc(func(cmd []string) (internal.KeyExtractionFuncResult, error) { + accessKeys, err := command.KeyExtractionFunc(cmd) + if err != nil { + return internal.KeyExtractionFuncResult{}, err + } + return internal.KeyExtractionFuncResult{ + Channels: []string{}, + ReadKeys: accessKeys.ReadKeys, + WriteKeys: accessKeys.WriteKeys, + }, nil + }), + HandlerFunc: internal.HandlerFunc(func(params internal.HandlerFuncParams) ([]byte, error) { + return command.HandlerFunc(CommandHandlerFuncParams{ + Context: params.Context, + Command: params.Command, + KeysExist: params.KeysExist, + GetValues: params.GetValues, + SetValues: params.SetValues, + }) + }), + }) + return nil + } + + // Add command with subcommands + newCommand := internal.Command{ + Command: command.Command, + Module: command.Module, + Categories: func() []string { + // Convert all the categories to lower case for uniformity + cats := make([]string, len(command.Categories)) + for j, cat := range command.Categories { + cats[j] = strings.ToLower(cat) + } + return cats + }(), + Description: command.Description, + Sync: command.Sync, + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + return internal.KeyExtractionFuncResult{}, nil + }, + HandlerFunc: func(param internal.HandlerFuncParams) ([]byte, error) { return nil, nil }, + SubCommands: make([]internal.SubCommand, len(command.SubCommand)), + } + + for i, sc := range command.SubCommand { + // Skip the subcommand if it already exists in newCommand + if slices.ContainsFunc(newCommand.SubCommands, func(subcommand internal.SubCommand) bool { + return strings.EqualFold(subcommand.Command, sc.Command) + }) { + continue + } + newCommand.SubCommands[i] = internal.SubCommand{ + Command: sc.Command, + Module: strings.ToLower(command.Module), + Categories: func() []string { + // Convert all the categories to lower case for uniformity + cats := make([]string, len(sc.Categories)) + for j, cat := range sc.Categories { + cats[j] = strings.ToLower(cat) + } + return cats + }(), + Description: sc.Description, + Sync: sc.Sync, + KeyExtractionFunc: internal.KeyExtractionFunc(func(cmd []string) (internal.KeyExtractionFuncResult, error) { + accessKeys, err := sc.KeyExtractionFunc(cmd) + if err != nil { + return internal.KeyExtractionFuncResult{}, err + } + return internal.KeyExtractionFuncResult{ + Channels: []string{}, + ReadKeys: accessKeys.ReadKeys, + WriteKeys: accessKeys.WriteKeys, + }, nil + }), + HandlerFunc: internal.HandlerFunc(func(params internal.HandlerFuncParams) ([]byte, error) { + return sc.HandlerFunc(CommandHandlerFuncParams{ + Context: params.Context, + Command: params.Command, + KeysExist: params.KeysExist, + GetValues: params.GetValues, + SetValues: params.SetValues, + }) + }), + } + } + + server.commands = append(server.commands, newCommand) + + return nil +} + +// ExecuteCommand executes the command passed to it. If 1 string is passed, SugarDB will try to +// execute the command. If 2 strings are passed, SugarDB will attempt to execute the subcommand of the command. +// If more than 2 strings are provided, all additional strings will be ignored. +// +// This method returns the raw RESP response from the command handler. You will have to parse the RESP response if +// you want to use the return value from the handler. +// +// This method does not work with handlers that manipulate the client connection directly (i.e SUBSCRIBE, PSUBSCRIBE). +// If you'd like to (p)subscribe or (p)unsubscribe, use the (P)SUBSCRIBE and (P)UNSUBSCRIBE methods instead. +// +// Parameters: +// +// `command` - ...string. +// +// Returns: []byte - Raw RESP response returned by the command handler. +// +// Errors: +// +// All errors from the command handler are forwarded to the caller. Other errors returned include: +// +// "command not supported" - If the command does not exist. +// +// "command not supported" - If the command exists but the subcommand does not exist for that command. +func (server *SugarDB) ExecuteCommand(command ...string) ([]byte, error) { + return server.handleCommand(server.context, internal.EncodeCommand(command), nil, false, true) +} + +// RemoveCommand removes the specified command or subcommand from SugarDB. +// When commands are removed, they will no longer be available for both the embedded instance and for TCP clients. +// +// Note: If a command is removed, the API wrapper for the command will also be unusable. +// For example, calling RemoveCommand("LPUSH") will cause the LPUSH method to always return a +// "command LPUSH not supported" error so use this method with caution. +// +// If one string is passed, the command matching that string is removed along will all of its subcommand if it has any. +// If two strings are passed, only the subcommand of the specified command is removed. +// If more than 2 strings are passed, all additional strings are ignored. +// +// Parameters: +// +// `command` - ...string. +func (server *SugarDB) RemoveCommand(command ...string) { + server.commandsRWMut.Lock() + defer server.commandsRWMut.Unlock() + + switch len(command) { + case 1: + // Remove command + server.commands = slices.DeleteFunc(server.commands, func(c internal.Command) bool { + return strings.EqualFold(c.Command, command[0]) + }) + case 2: + // Remove subcommand + for i := 0; i < len(server.commands); i++ { + if !strings.EqualFold(server.commands[i].Command, command[0]) { + continue + } + if server.commands[i].SubCommands != nil && len(server.commands[i].SubCommands) > 0 { + server.commands[i].SubCommands = slices.DeleteFunc(server.commands[i].SubCommands, func(sc internal.SubCommand) bool { + return strings.EqualFold(sc.Command, command[1]) + }) + } + } + } +} diff --git a/sugardb/api_admin_test.go b/sugardb/api_admin_test.go new file mode 100644 index 0000000..bd5af76 --- /dev/null +++ b/sugardb/api_admin_test.go @@ -0,0 +1,683 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "bytes" + "errors" + "fmt" + "apigo.cc/go/sugardb/internal/clock" + "apigo.cc/go/sugardb/internal/constants" + "github.com/tidwall/resp" + "os" + "path" + "reflect" + "slices" + "strconv" + "strings" + "testing" + "time" +) + +func TestSugarDB_AddCommand(t *testing.T) { + type args struct { + command CommandOptions + } + type scenarios struct { + name string + command []string + wantRes int + wantErr error + } + tests := []struct { + name string + args args + scenarios []scenarios + wantErr bool + }{ + { + name: "1 Add command without subcommands", + wantErr: false, + args: args{ + command: CommandOptions{ + Command: "CommandOne", + Module: "test-module", + Description: `(CommandOne write-key read-key ) +Test command to handle successful addition of a single command without subcommands. +The value passed must be an integer.`, + Categories: []string{}, + Sync: false, + KeyExtractionFunc: func(cmd []string) (CommandKeyExtractionFuncResult, error) { + if len(cmd) != 4 { + return CommandKeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return CommandKeyExtractionFuncResult{ + WriteKeys: cmd[1:2], + ReadKeys: cmd[2:3], + }, nil + }, + HandlerFunc: func(params CommandHandlerFuncParams) ([]byte, error) { + if len(params.Command) != 4 { + return nil, errors.New(constants.WrongArgsResponse) + } + value := params.Command[3] + i, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return nil, errors.New("value must be an integer") + } + return []byte(fmt.Sprintf(":%d\r\n", i)), nil + }, + }, + }, + scenarios: []scenarios{ + { + name: "1 Successfully execute the command and return the expected integer.", + command: []string{"CommandOne", "write-key1", "read-key1", "1111"}, + wantRes: 1111, + wantErr: nil, + }, + { + name: "2 Get error due to command being too long", + command: []string{"CommandOne", "write-key1", "read-key1", "1111", "2222"}, + wantRes: 0, + wantErr: errors.New(constants.WrongArgsResponse), + }, + { + name: "3 Get error due to command being too short", + command: []string{"CommandOne", "write-key1", "read-key1"}, + wantRes: 0, + wantErr: errors.New(constants.WrongArgsResponse), + }, + { + name: "4 Get error due to value not being an integer", + command: []string{"CommandOne", "write-key1", "read-key1", "string"}, + wantRes: 0, + wantErr: errors.New("value must be an integer"), + }, + }, + }, + { + name: "2 Add command with subcommands", + wantErr: false, + args: args{ + command: CommandOptions{ + Command: "CommandTwo", + SubCommand: []SubCommandOptions{ + { + Command: "SubCommandOne", + Module: "test-module", + Description: `(CommandTwo SubCommandOne write-key read-key ) +Test command to handle successful addition of a single command with subcommands. +The value passed must be an integer.`, + Categories: []string{}, + Sync: false, + KeyExtractionFunc: func(cmd []string) (CommandKeyExtractionFuncResult, error) { + if len(cmd) != 5 { + return CommandKeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) + } + return CommandKeyExtractionFuncResult{ + WriteKeys: cmd[2:3], + ReadKeys: cmd[3:4], + }, nil + }, + HandlerFunc: func(params CommandHandlerFuncParams) ([]byte, error) { + if len(params.Command) != 5 { + return nil, errors.New(constants.WrongArgsResponse) + } + value := params.Command[4] + i, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return nil, errors.New("value must be an integer") + } + return []byte(fmt.Sprintf(":%d\r\n", i)), nil + }, + }, + }, + }, + }, + scenarios: []scenarios{ + { + name: "1 Successfully execute the command and return the expected integer.", + command: []string{"CommandTwo", "SubCommandOne", "write-key1", "read-key1", "1111"}, + wantRes: 1111, + wantErr: nil, + }, + { + name: "2 Get error due to command being too long", + command: []string{"CommandTwo", "SubCommandOne", "write-key1", "read-key1", "1111", "2222"}, + wantRes: 0, + wantErr: errors.New(constants.WrongArgsResponse), + }, + { + name: "3 Get error due to command being too short", + command: []string{"CommandTwo", "SubCommandOne", "write-key1", "read-key1"}, + wantRes: 0, + wantErr: errors.New(constants.WrongArgsResponse), + }, + { + name: "4 Get error due to value not being an integer", + command: []string{"CommandTwo", "SubCommandOne", "write-key1", "read-key1", "string"}, + wantRes: 0, + wantErr: errors.New("value must be an integer"), + }, + }, + }, + } + for _, tt := range tests { + server := createSugarDB() + t.Run(tt.name, func(t *testing.T) { + if err := server.AddCommand(tt.args.command); (err != nil) != tt.wantErr { + t.Errorf("AddCommand() error = %v, wantErr %v", err, tt.wantErr) + } + for _, scenario := range tt.scenarios { + b, err := server.ExecuteCommand(scenario.command...) + if scenario.wantErr != nil { + if scenario.wantErr.Error() != err.Error() { + t.Errorf("AddCommand() error = %v, wantErr %v", err, scenario.wantErr) + } + continue + } + r := resp.NewReader(bytes.NewReader(b)) + v, _, _ := r.ReadValue() + if v.Integer() != scenario.wantRes { + t.Errorf("AddCommand() res = %v, wantRes %v", resp.BytesValue(b).Integer(), scenario.wantRes) + } + } + }) + } +} + +func TestSugarDB_ExecuteCommand(t *testing.T) { + type args struct { + key string + presetValue []string + command []string + } + tests := []struct { + name string + args args + wantRes int + wantErr error + }{ + { + name: "1 Execute LPUSH command and get expected result", + args: args{ + key: "key1", + presetValue: []string{"1", "2", "3"}, + command: []string{"LPUSH", "key1", "4", "5", "6", "7", "8", "9", "10"}, + }, + wantRes: 10, + wantErr: nil, + }, + { + name: "2 Expect error when trying to execute non-existent command", + args: args{ + key: "key2", + presetValue: nil, + command: []string{"NON-EXISTENT", "key1", "key2"}, + }, + wantRes: 0, + wantErr: errors.New("command NON-EXISTENT not supported"), + }, + } + for _, tt := range tests { + server := createSugarDB() + t.Run(tt.name, func(t *testing.T) { + if tt.args.presetValue != nil { + _, _ = server.LPush(tt.args.key, tt.args.presetValue...) + } + b, err := server.ExecuteCommand(tt.args.command...) + if tt.wantErr != nil { + if err.Error() != tt.wantErr.Error() { + t.Errorf("ExecuteCommand() error = %v, wantErr %v", err, tt.wantErr) + } + } + r := resp.NewReader(bytes.NewReader(b)) + v, _, _ := r.ReadValue() + if v.Integer() != tt.wantRes { + t.Errorf("ExecuteCommand() response = %d, wantRes %d", v.Integer(), tt.wantRes) + } + }) + } +} + +func TestSugarDB_RemoveCommand(t *testing.T) { + type args struct { + removeCommand []string + executeCommand []string + } + tests := []struct { + name string + args args + wantErr error + }{ + { + name: "1 Remove command and expect error when the command is called", + args: args{ + removeCommand: []string{"LPUSH"}, + executeCommand: []string{"LPUSH", "key", "item"}, + }, + wantErr: errors.New("command LPUSH not supported"), + }, + { + name: "2 Remove sub-command and expect error when the subcommand is called", + args: args{ + removeCommand: []string{"ACL", "CAT"}, + executeCommand: []string{"ACL", "CAT"}, + }, + wantErr: errors.New("command ACL CAT not supported"), + }, + { + name: "3 Remove sub-command and expect successful response from calling another subcommand", + args: args{ + removeCommand: []string{"ACL", "WHOAMI"}, + executeCommand: []string{"ACL", "DELUSER", "user-one"}, + }, + wantErr: nil, + }, + } + for _, tt := range tests { + server := createSugarDB() + t.Run(tt.name, func(t *testing.T) { + server.RemoveCommand(tt.args.removeCommand...) + _, err := server.ExecuteCommand(tt.args.executeCommand...) + if tt.wantErr != nil { + if err.Error() != tt.wantErr.Error() { + t.Errorf("RemoveCommand() error = %v, wantErr %v", err, tt.wantErr) + } + } + }) + } +} + +func TestSugarDB_Plugins(t *testing.T) { + t.Cleanup(func() { + _ = os.RemoveAll("./testdata/modules") + }) + + server := createSugarDB() + + tests := []struct { + name string + path string + expect bool + args []string + cmd []string + want string + wantErr error + }{ + { + name: "1. Test shared object plugin MODULE.SET", + path: path.Join(".", "testdata", "modules", "module_set", "module_set.so"), + expect: true, + args: []string{}, + cmd: []string{"MODULE.SET", "key1", "15"}, + want: "OK", + wantErr: nil, + }, + { + name: "2. Test shared object plugin MODULE.GET", + path: path.Join(".", "testdata", "modules", "module_get", "module_get.so"), + expect: true, + args: []string{"10"}, + cmd: []string{"MODULE.GET", "key1"}, + want: "150", + wantErr: nil, + }, + { + name: "3. Test Non existent module.", + path: path.Join(".", "testdata", "modules", "non_existent", "module_non_existent.so"), + expect: false, + args: []string{}, + cmd: []string{"NONEXISTENT", "key", "value"}, + want: "", + wantErr: fmt.Errorf("load module: module %s not found", + path.Join(".", "testdata", "modules", "non_existent", "module_non_existent.so")), + }, + { + name: "4. Test LUA module that handles hash values", + path: path.Join("..", "internal", "volumes", "modules", "lua", "hash.lua"), + expect: true, + args: []string{}, + cmd: []string{"LUA.HASH", "LUA.HASH_KEY_1"}, + want: "OK", + wantErr: nil, + }, + { + name: "5. Test LUA module that handles set values", + path: path.Join("..", "internal", "volumes", "modules", "lua", "set.lua"), + expect: true, + args: []string{}, + cmd: []string{"LUA.SET", "LUA.SET_KEY_1", "LUA.SET_KEY_2", "LUA.SET_KEY_3"}, + want: "OK", + wantErr: nil, + }, + { + name: "6. Test LUA module that handles zset values", + path: path.Join("..", "internal", "volumes", "modules", "lua", "zset.lua"), + expect: true, + args: []string{}, + cmd: []string{"LUA.ZSET", "LUA.ZSET_KEY_1", "LUA.ZSET_KEY_2", "LUA.ZSET_KEY_3"}, + want: "OK", + wantErr: nil, + }, + { + name: "6. Test LUA module that handles list values", + path: path.Join("..", "internal", "volumes", "modules", "lua", "list.lua"), + expect: true, + args: []string{}, + cmd: []string{"LUA.LIST", "LUA.LIST_KEY_1"}, + want: "OK", + wantErr: nil, + }, + { + name: "8. Test LUA module that handles primitive types", + path: path.Join("..", "internal", "volumes", "modules", "lua", "example.lua"), + expect: true, + args: []string{}, + cmd: []string{"LUA.EXAMPLE"}, + want: "OK", + wantErr: nil, + }, + { + name: "9. Test JS module that handles primitive types", + path: path.Join("..", "internal", "volumes", "modules", "js", "example.js"), + expect: true, + args: []string{}, + cmd: []string{"JS.EXAMPLE"}, + want: "OK", + wantErr: nil, + }, + { + name: "10. Test JS module that handles hashes", + path: path.Join("..", "internal", "volumes", "modules", "js", "hash.js"), + expect: true, + args: []string{}, + cmd: []string{"JS.HASH", "JS_HASH_KEY1"}, + want: "OK", + wantErr: nil, + }, + { + name: "11. Test JS module that handles sets", + path: path.Join("..", "internal", "volumes", "modules", "js", "set.js"), + expect: true, + args: []string{}, + cmd: []string{"JS.SET", "JS_SET_KEY1", "member1"}, + want: "OK", + wantErr: nil, + }, + { + name: "12. Test JS module that handles sorted sets", + path: path.Join("..", "internal", "volumes", "modules", "js", "zset.js"), + expect: true, + args: []string{}, + cmd: []string{"JS.ZSET", "JS_ZSET_KEY1", "member1", "2.142"}, + want: "OK", + wantErr: nil, + }, + { + name: "13. Test JS module that handles lists", + path: path.Join("..", "internal", "volumes", "modules", "js", "list.js"), + expect: true, + args: []string{}, + cmd: []string{"JS.LIST", "JS_LIST_KEY1"}, + want: "OK", + wantErr: nil, + }, + } + + for _, test := range tests { + // Load module + err := server.LoadModule(test.path, test.args...) + if err != nil { + if test.wantErr == nil || err.Error() != test.wantErr.Error() { + t.Error(fmt.Errorf("%s: %v", test.name, err)) + return + } + continue + } + // Execute command and check expected response + res, err := server.ExecuteCommand(test.cmd...) + if err != nil { + t.Error(fmt.Errorf("%s: %v", test.name, err)) + } + rv, _, err := resp.NewReader(bytes.NewReader(res)).ReadValue() + if err != nil { + t.Error(err) + } + if test.wantErr != nil { + if test.wantErr.Error() != rv.Error().Error() { + t.Errorf("expected error \"%s\", got \"%s\"", test.wantErr.Error(), rv.Error().Error()) + } + return + } + if rv.String() != test.want { + t.Errorf("expected response \"%s\", got \"%s\"", test.want, rv.String()) + } + } + + // Module list should contain all the modules above + modules := server.ListModules() + for _, test := range tests { + // Skip the module if it's not expected + if !test.expect { + continue + } + // Check if module is loaded + if !slices.Contains(modules, test.path) { + t.Errorf("expected modules list to contain module \"%s\" but did not find it", test.path) + } + // Unload the module + server.UnloadModule(test.path) + } + + // Make sure the modules are no longer loaded + modules = server.ListModules() + for _, test := range tests { + if slices.Contains(modules, test.path) { + t.Errorf("expected modules list to not contain module \"%s\" but found it", test.path) + } + } +} + +func TestSugarDB_CommandList(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + options interface{} + want []string + wantErr bool + }{ + { + name: "1. Get all present commands when no options are passed", + options: nil, + want: func() []string { + var commands []string + for _, command := range server.commands { + if command.SubCommands == nil || len(command.SubCommands) == 0 { + commands = append(commands, strings.ToLower(command.Command)) + continue + } + for _, subcommand := range command.SubCommands { + commands = append(commands, strings.ToLower(fmt.Sprintf("%s %s", command.Command, subcommand.Command))) + } + } + return commands + }(), + wantErr: false, + }, + { + name: "2. Get commands filtered by hash ACL category", + options: CommandListOptions{ACLCAT: constants.HashCategory}, + want: func() []string { + var commands []string + for _, command := range server.commands { + if slices.Contains(command.Categories, constants.HashCategory) { + commands = append(commands, strings.ToLower(command.Command)) + } + } + return commands + }(), + wantErr: false, + }, + { + name: "3. Get commands filtered by pattern", + options: CommandListOptions{PATTERN: "z*"}, + want: func() []string { + var commands []string + for _, command := range server.commands { + if strings.EqualFold(command.Module, constants.SortedSetModule) { + commands = append(commands, strings.ToLower(command.Command)) + } + } + return commands + }(), + wantErr: false, + }, + { + name: "4. Get commands filtered by module", + options: CommandListOptions{MODULE: constants.ListModule}, + want: func() []string { + var commands []string + for _, command := range server.commands { + if strings.EqualFold(command.Module, constants.ListModule) { + commands = append(commands, strings.ToLower(command.Command)) + } + } + return commands + }(), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got []string + var err error + if tt.options == nil { + got, err = server.CommandList() + } else { + got, err = server.CommandList(tt.options.(CommandListOptions)) + } + if (err != nil) != tt.wantErr { + t.Errorf("CommandList() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("CommandList() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_CommandCount(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + want int + wantErr bool + }{ + { + name: "1. Get the count of all commands/subcommands on the server", + want: func() int { + var commands []string + for _, command := range server.commands { + if command.SubCommands == nil || len(command.SubCommands) == 0 { + commands = append(commands, strings.ToLower(command.Command)) + continue + } + for _, subcommand := range command.SubCommands { + commands = append(commands, strings.ToLower(fmt.Sprintf("%s %s", command.Command, subcommand.Command))) + } + } + return len(commands) + }(), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := server.CommandCount() + if (err != nil) != tt.wantErr { + t.Errorf("CommandCount() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("CommandCount() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_Save(t *testing.T) { + conf := DefaultConfig() + conf.DataDir = path.Join(".", "testdata", "data") + conf.EvictionPolicy = constants.NoEviction + server := createSugarDBWithConfig(conf) + + tests := []struct { + name string + want bool + wantErr bool + }{ + { + name: "1. Return true response when save process is started", + want: true, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := server.Save() + if (err != nil) != tt.wantErr { + t.Errorf("Save() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Save() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_LastSave(t *testing.T) { + server := createSugarDB() + server.setLatestSnapshot(clock.NewClock().Now().Add(5 * time.Minute).UnixMilli()) + + tests := []struct { + name string + want int + wantErr bool + }{ + { + name: "1. Get latest snapshot time milliseconds", + want: int(clock.NewClock().Now().Add(5 * time.Minute).UnixMilli()), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := server.LastSave() + if (err != nil) != tt.wantErr { + t.Errorf("LastSave() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("LastSave() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/sugardb/api_connection.go b/sugardb/api_connection.go new file mode 100644 index 0000000..acc789c --- /dev/null +++ b/sugardb/api_connection.go @@ -0,0 +1,70 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "errors" + "slices" +) + +// SetProtocol sets the RESP protocol that's expected from responses to embedded API calls. +// This command does not affect the RESP protocol expected by any of the TCP clients. +// +// Parameters: +// +// `protocol` - int - The RESP version (either 2 or 3). +// +// Errors: +// +// "protocol must be either 2 or 3" - When the provided protocol is not either 2 or 3. +func (server *SugarDB) SetProtocol(protocol int) error { + if !slices.Contains([]int{2, 3}, protocol) { + return errors.New("protocol must be either 2 or 3") + } + server.connInfo.mut.Lock() + defer server.connInfo.mut.Unlock() + server.connInfo.embedded.Protocol = protocol + return nil +} + +// SelectDB sets the logical database to use for all embedded API calls. +// All subsequent calls after this call will use the new logical database. +// This does not affect the databases used by any of the TCP clients. +// +// Parameters: +// +// `database` - int - The Database index. +// +// Errors: +// +// "database index must be 0 or higher" - When the database index is less than 0. +func (server *SugarDB) SelectDB(database int) error { + if database < 0 { + return errors.New("database index must be 0 or higher") + } + // If the database index does not exist, create the new database. + server.storeLock.Lock() + if server.store[database] == nil { + server.createDatabase(database) + } + server.storeLock.Unlock() + + // Set the DB. + server.connInfo.mut.Lock() + defer server.connInfo.mut.Unlock() + server.connInfo.embedded.Database = database + + return nil +} diff --git a/sugardb/api_connection_test.go b/sugardb/api_connection_test.go new file mode 100644 index 0000000..7f6ec96 --- /dev/null +++ b/sugardb/api_connection_test.go @@ -0,0 +1,285 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "bufio" + "bytes" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" + "apigo.cc/go/sugardb/internal/modules/connection" + "github.com/tidwall/resp" + "reflect" + "testing" +) + +func TestSugarDB_Hello(t *testing.T) { + t.Parallel() + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + conf := DefaultConfig() + conf.Port = uint16(port) + conf.RequirePass = false + + mockServer := createSugarDBWithConfig(conf) + if err != nil { + t.Error(err) + return + } + go func() { + mockServer.Start() + }() + t.Cleanup(func() { + mockServer.ShutDown() + }) + + tests := []struct { + name string + command []resp.Value + wantRes []byte + }{ + { + name: "1. Hello", + command: []resp.Value{resp.StringValue("HELLO")}, + wantRes: connection.BuildHelloResponse( + internal.ServerInfo{ + Server: "sugardb", + Version: constants.Version, + Id: "", + Mode: "standalone", + Role: "master", + Modules: mockServer.ListModules(), + }, + internal.ConnectionInfo{ + Id: 1, + Name: "", + Protocol: 2, + Database: 0, + }, + ), + }, + { + name: "2. Hello 2", + command: []resp.Value{resp.StringValue("HELLO"), resp.StringValue("2")}, + wantRes: connection.BuildHelloResponse( + internal.ServerInfo{ + Server: "sugardb", + Version: constants.Version, + Id: "", + Mode: "standalone", + Role: "master", + Modules: mockServer.ListModules(), + }, + internal.ConnectionInfo{ + Id: 2, + Name: "", + Protocol: 2, + Database: 0, + }, + ), + }, + { + name: "3. Hello 3", + command: []resp.Value{resp.StringValue("HELLO"), resp.StringValue("3")}, + wantRes: connection.BuildHelloResponse( + internal.ServerInfo{ + Server: "sugardb", + Version: constants.Version, + Id: "", + Mode: "standalone", + Role: "master", + Modules: mockServer.ListModules(), + }, + internal.ConnectionInfo{ + Id: 3, + Name: "", + Protocol: 3, + Database: 0, + }, + ), + }, + } + + for i := 0; i < len(tests); i++ { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + client := resp.NewConn(conn) + + if err = client.WriteArray(tests[i].command); err != nil { + t.Error(err) + return + } + + buf := bufio.NewReader(conn) + res, err := internal.ReadMessage(buf) + if err != nil { + t.Error(err) + return + } + + if !bytes.Equal(tests[i].wantRes, res) { + t.Errorf("expected byte resposne:\n%s, \n\ngot:\n%s", string(tests[i].wantRes), string(res)) + return + } + + // Close connection + _ = conn.Close() + } +} + +func TestSugarDB_SelectDB(t *testing.T) { + t.Parallel() + tests := []struct { + name string + presetValues map[int]map[string]string + database int + want map[int][]string + wantErr bool + }{ + { + name: "1. Change database and read new values", + presetValues: map[int]map[string]string{ + 0: {"key1": "value-01", "key2": "value-02", "key3": "value-03"}, + 1: {"key1": "value-11", "key2": "value-12", "key3": "value-13"}, + }, + database: 1, + want: map[int][]string{ + 0: {"value-01", "value-02", "value-03"}, + 1: {"value-11", "value-12", "value-13"}, + }, + wantErr: false, + }, + { + name: "2. Error when database parameter is < 0", + presetValues: map[int]map[string]string{ + 0: {"key1": "value-01", "key2": "value-02", "key3": "value-03"}, + }, + database: -1, + want: map[int][]string{ + 0: {"value-01", "value-02", "value-03"}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := createSugarDB() + + if tt.presetValues != nil { + for db, data := range tt.presetValues { + _ = server.SelectDB(db) + if _, err := server.MSet(data); err != nil { + t.Errorf("SelectDB() error = %v", err) + return + } + } + _ = server.SelectDB(0) + } + + // Check the values for DB 0 + values, err := server.MGet("key1", "key2", "key3") + if err != nil { + t.Errorf("SelectDB() error = %v", err) + return + } + + if !reflect.DeepEqual(values, tt.want[0]) { + t.Errorf("SelectDB() result-0 = %v, want-0 %v", values, tt.want[0]) + return + } + + err = server.SelectDB(tt.database) + if tt.wantErr { + if err == nil { + t.Errorf("SelectDB() error = %v, wantErr %v", err, tt.wantErr) + return + } + return + } + if err != nil { + t.Errorf("SelectDB() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Check the values the new DB + values, err = server.MGet("key1", "key2", "key3") + if err != nil { + t.Errorf("SelectDB() error = %v", err) + return + } + + if !reflect.DeepEqual(values, tt.want[1]) { + t.Errorf("SelectDB() result-1 = %v, want-1 %v", values, tt.want[1]) + return + } + }) + } +} + +func TestSugarDB_SetProtocol(t *testing.T) { + t.Parallel() + server := createSugarDB() + tests := []struct { + name string + protocol int + wantErr bool + }{ + { + name: "1. Change protocol to 2", + protocol: 2, + wantErr: false, + }, + { + name: "2. Change protocol to 3", + protocol: 3, + wantErr: false, + }, + { + name: "3. Return error when protocol is neither 2 or 3", + protocol: 4, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := server.SetProtocol(tt.protocol) + if tt.wantErr { + if err == nil { + t.Errorf("SetProtocol() error = %v, wantErr %v", err, tt.wantErr) + return + } + return + } + if err != nil { + t.Errorf("SetProtocol() error = %v, wantErr %v", err, tt.wantErr) + return + } + // Check if the protocol has been changed + if server.connInfo.embedded.Protocol != tt.protocol { + t.Errorf("SetProtocol() protocol = %v, wantProtocol %v", + server.connInfo.embedded.Protocol, tt.protocol) + } + }) + } +} diff --git a/sugardb/api_generic.go b/sugardb/api_generic.go new file mode 100644 index 0000000..47dea26 --- /dev/null +++ b/sugardb/api_generic.go @@ -0,0 +1,824 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "fmt" + "strconv" + "strings" + + "apigo.cc/go/sugardb/internal" +) + +// SetWriteOption constants +type SetWriteOpt string + +const ( + SETNX SetWriteOpt = "NX" + SETXX SetWriteOpt = "XX" +) + +// SetWriteOption modifies the behavior of Set. +// +// SETNX - Only set if the key does not exist. +// +// SETXX - Only set if the key exists. +type SetWriteOption interface { + IsSetWriteOpt() SetWriteOpt +} + +func (w SetWriteOpt) IsSetWriteOpt() SetWriteOpt { return w } + +// SetExOption constants +type SetExOpt string + +const ( + SETEX SetExOpt = "EX" + SETPX SetExOpt = "PX" + SETEXAT SetExOpt = "EXAT" + SETPXAT SetExOpt = "PXAT" +) + +// SetExOption modifies the behavior of Set. +// +// SETEX - Expire the key after the specified number of seconds (positive integer). +// +// SETPX - Expire the key after the specified number of milliseconds (positive integer). +// +// SETEXAT - Expire at the exact time in unix seconds (positive integer). +// +// SETPXAT - Expire at the exact time in unix milliseconds (positive integer). +type SetExOption interface { + IsSetExOpt() SetExOpt +} + +func (x SetExOpt) IsSetExOpt() SetExOpt { return x } + +// SETOptions is a struct wrapper for all optional parameters of the Set command. +// +// `WriteOpt` - SetWriteOption - One of SETNX or SETXX. +// +// `ExpireOpt` - SetExOption - One of SETEX, SETPX, SETEXAT, or SETPXAT. +// +// `ExpireTime` - int - Time in seconds or milliseconds depending on what ExpireOpt was provided. +// +// `GET` - bool - Whether to return previous value if there was one. +type SETOptions struct { + WriteOpt SetWriteOption + ExpireOpt SetExOption + ExpireTime int + Get bool +} + +// ExpireOptions constants +type ExOpt string + +const ( + NX ExOpt = "NX" + XX ExOpt = "XX" + LT ExOpt = "LT" + GT ExOpt = "GT" +) + +// ExpireOptions modifies the behavior of Expire, PExpire, ExpireAt, PExpireAt. +// +// NX - Only set the expiry time if the key has no associated expiry. +// +// XX - Only set the expiry time if the key already has an expiry time. +// +// GT - Only set the expiry time if the new expiry time is greater than the current one. +// +// LT - Only set the expiry time if the new expiry time is less than the current one. +// +// NX, GT, and LT are mutually exclusive. XX can additionally be passed in with either GT or LT. +// +// Hash only: NX, XX, GT, and LT are all mutually exclusive. +type ExpireOptions interface { + IsExOpt() ExOpt +} + +func (x ExOpt) IsExOpt() ExOpt { return x } + +// GetExOption constants +type GetExOpt string + +const ( + EX GetExOpt = "EX" + PX GetExOpt = "PX" + EXAT GetExOpt = "EXAT" + PXAT GetExOpt = "PXAT" + PERSIST GetExOpt = "PERSIST" +) + +// GetExOption modifies the behavior of GetEx. +// +// EX - Set the specified expire time, in seconds. +// +// PX - Set the specified expire time, in milliseconds. +// +// EXAT - Set the specified Unix time at which the key will expire, in seconds. +// +// PXAT - Set the specified Unix time at which the key will expire, in milliseconds. +// +// PERSIST - Remove the time to live associated with the key. +type GetExOption interface { + isGetExOpt() GetExOpt +} + +func (x GetExOpt) isGetExOpt() GetExOpt { return x } + +// COPYOptions is a struct wrapper for all optional parameters of the Copy command. +// +// `Database` - string - Logical database index +// +// `Replace` - bool - Whether to replace the destination key if it exists +type COPYOptions struct { + Database string + Replace bool +} + +// Set creates or modifies the value at the given key. +// +// Parameters: +// +// `key` - string - the key to create or update. +// +// `value` - string - the value to place at the key. +// +// Returns: true if the set is successful, If the "Get" flag in SetOptions is set to true, the previous value is returned. +// +// Errors: +// +// "key does not exist"" - when the XX flag is set to true and the key does not exist. +// +// "key already exists" - when the NX flag is set to true and the key already exists. +func (server *SugarDB) Set(key, value string, options SETOptions) (string, bool, error) { + cmd := []string{"SET", key, value} + + if options.WriteOpt != nil { + cmd = append(cmd, fmt.Sprint(options.WriteOpt)) + } + + if options.ExpireOpt != nil { + cmd = append(cmd, []string{fmt.Sprint(options.ExpireOpt), strconv.Itoa(options.ExpireTime)}...) + } + + if options.Get { + cmd = append(cmd, "GET") + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return "", false, err + } + + previousValue, err := internal.ParseStringResponse(b) + if err != nil { + return "", false, err + } + if !options.Get { + previousValue = "" + } + + return previousValue, true, nil +} + +// MSet set multiple values at multiple keys with one command. Existing keys are overwritten and non-existent +// keys are created. +// +// Parameters: +// +// `kvPairs` - map[string]string - a map representing all the keys and values to be set. +// +// Returns: true if the set is successful. +// +// Errors: +// +// "key already exists" - when the NX flag is set to true and the key already exists. +func (server *SugarDB) MSet(kvPairs map[string]string) (bool, error) { + cmd := []string{"MSET"} + + for k, v := range kvPairs { + cmd = append(cmd, []string{k, v}...) + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return false, err + } + + s, err := internal.ParseStringResponse(b) + if err != nil { + return false, err + } + + return strings.EqualFold(s, "ok"), nil +} + +// Get retrieves the value at the provided key. +// +// Parameters: +// +// `key` - string - the key whose value should be retrieved. +// +// Returns: A string representing the value at the specified key. If the value does not exist, an empty +// string is returned. +func (server *SugarDB) Get(key string) (string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"GET", key}), nil, false, true) + if err != nil { + return "", err + } + return internal.ParseStringResponse(b) +} + +// MGet get multiple values from the list of provided keys. The index of each value corresponds to the index of its key +// in the parameter slice. Values that do not exist will be an empty string. +// +// Parameters: +// +// `keys` - []string - a string slice of all the keys. +// +// Returns: a string slice of all the values. +func (server *SugarDB) MGet(keys ...string) ([]string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand(append([]string{"MGET"}, keys...)), nil, false, true) + if err != nil { + return []string{}, err + } + return internal.ParseStringArrayResponse(b) +} + +// Del removes the given keys from the store. +// +// Parameters: +// +// `keys` - []string - the keys to delete from the store. +// +// Returns: The number of keys that were successfully deleted. +func (server *SugarDB) Del(keys ...string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand(append([]string{"DEL"}, keys...)), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// Persist removes the expiry associated with a key and makes it permanent. +// Has no effect on a key that is already persistent. +// +// Parameters: +// +// `key` - string - the key to persist. +// +// Returns: true if the keys is successfully persisted. +func (server *SugarDB) Persist(key string) (bool, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"PERSIST", key}), nil, false, true) + if err != nil { + return false, err + } + return internal.ParseBooleanResponse(b) +} + +// ExpireTime return the current key's expiry time in unix epoch seconds. +// +// Parameters: +// +// `key` - string. +// +// Returns: -2 if the keys does not exist, -1 if the key exists but has no expiry time, seconds if the key has an expiry. +func (server *SugarDB) ExpireTime(key string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"EXPIRETIME", key}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// PExpireTime return the current key's expiry time in unix epoch milliseconds. +// +// Parameters: +// +// `key` - string. +// +// Returns: -2 if the keys does not exist, -1 if the key exists but has no expiry time, seconds if the key has an expiry. +func (server *SugarDB) PExpireTime(key string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"PEXPIRETIME", key}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// TTL return the current key's expiry time from now in seconds. +// +// Parameters: +// +// `key` - string. +// +// Returns: -2 if the keys does not exist, -1 if the key exists but has no expiry time, seconds if the key has an expiry. +func (server *SugarDB) TTL(key string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"TTL", key}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// PTTL return the current key's expiry time from now in milliseconds. +// +// Parameters: +// +// `key` - string. +// +// Returns: -2 if the keys does not exist, -1 if the key exists but has no expiry time, seconds if the key has an expiry. +func (server *SugarDB) PTTL(key string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"PTTL", key}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// Expire set the given key's expiry in seconds from now. +// This command turns a persistent key into a volatile one. +// +// Parameters: +// +// `key` - string. +// +// `seconds` - int - number of seconds from now. +// +// `options` - ExpireOptions - One of NX, GT, LT. XX can be passed with GT OR LT optionally. +// +// Returns: true if the key's expiry was successfully updated. +func (server *SugarDB) Expire(key string, seconds int, options ...ExpireOptions) (bool, error) { + cmd := []string{"EXPIRE", key, strconv.Itoa(seconds)} + + for _, opt := range options { + if opt != nil { + cmd = append(cmd, fmt.Sprint(opt)) + } + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return false, err + } + + return internal.ParseBooleanResponse(b) +} + +// PExpire set the given key's expiry in milliseconds from now. +// This command turns a persistent key into a volatile one. +// +// Parameters: +// +// `key` - string. +// +// `milliseconds` - int - number of milliseconds from now. +// +// `options` - PExpireOptions +// +// Returns: true if the key's expiry was successfully updated. +func (server *SugarDB) PExpire(key string, milliseconds int, options ...ExpireOptions) (bool, error) { + cmd := []string{"PEXPIRE", key, strconv.Itoa(milliseconds)} + + for _, opt := range options { + if opt != nil { + cmd = append(cmd, fmt.Sprint(opt)) + } + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return false, err + } + + return internal.ParseBooleanResponse(b) +} + +// ExpireAt sets the given key's expiry in unix epoch seconds. +// This command turns a persistent key into a volatile one. +// +// Parameters: +// +// `key` - string. +// +// `unixSeconds` - int - number of seconds from now. +// +// `options` - ExpireAtOptions +// +// Returns: true if the key's expiry was successfully updated. +func (server *SugarDB) ExpireAt(key string, unixSeconds int, options ...ExpireOptions) (int, error) { + cmd := []string{"EXPIREAT", key, strconv.Itoa(unixSeconds)} + + for _, opt := range options { + if opt != nil { + cmd = append(cmd, fmt.Sprint(opt)) + } + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + + return internal.ParseIntegerResponse(b) +} + +// PExpireAt set the given key's expiry in unix epoch milliseconds. +// This command turns a persistent key into a volatile one. +// +// Parameters: +// +// `key` - string. +// +// `unixMilliseconds` - int - number of seconds from now. +// +// `options` - PExpireAtOptions +// +// Returns: true if the key's expiry was successfully updated. +func (server *SugarDB) PExpireAt(key string, unixMilliseconds int, options ...ExpireOptions) (int, error) { + cmd := []string{"PEXPIREAT", key, strconv.Itoa(unixMilliseconds)} + + for _, opt := range options { + if opt != nil { + cmd = append(cmd, fmt.Sprint(opt)) + } + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + + return internal.ParseIntegerResponse(b) +} + +// Incr increments the value at the given key if it's an integer. +// If the key does not exist, it's created with an initial value of 0 before incrementing. +// +// Parameters: +// +// `key` - string +// +// Returns: The new value as an integer. +func (server *SugarDB) Incr(key string) (int, error) { + // Construct the command + cmd := []string{"INCR", key} + + // Execute the command + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + + // Parse the integer response + return internal.ParseIntegerResponse(b) +} + +// Decr decrements the value at the given key if it's an integer. +// If the key does not exist, it's created with an initial value of 0 before incrementing. +// +// Parameters: +// +// `key` - string +// +// Returns: The new value as an integer. +func (server *SugarDB) Decr(key string) (int, error) { + // Construct the command + cmd := []string{"DECR", key} + + // Execute the command + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + + // Parse the integer response + return internal.ParseIntegerResponse(b) +} + +// IncrBy increments the integer value of the specified key by the given increment. +// If the key does not exist, it is created with an initial value of 0 before incrementing. +// If the value stored at the key is not an integer, an error is returned. +// +// Parameters: +// +// `key` - string - The key whose value is to be incremented. +// +// `increment` - int - The amount by which to increment the key's value. This can be a positive or negative integer. +// +// Returns: The new value of the key after the increment operation as an integer. +func (server *SugarDB) IncrBy(key string, value string) (int, error) { + // Construct the command + cmd := []string{"INCRBY", key, value} + // Execute the command + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + // Parse the integer response + return internal.ParseIntegerResponse(b) +} + +// IncrByFloat increments the floating-point value of the specified key by the given increment. +// If the key does not exist, it is created with an initial value of 0 before incrementing. +// If the value stored at the key is not a float, an error is returned. +// +// Parameters: +// +// `key` - string - The key whose value is to be incremented. +// +// `increment` - float64 - The amount by which to increment the key's value. This can be a positive or negative float. +// +// Returns: The new value of the key after the increment operation as a float64. +func (server *SugarDB) IncrByFloat(key string, value string) (float64, error) { + // Construct the command + cmd := []string{"INCRBYFLOAT", key, value} + // Execute the command + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + // Parse the float response + return internal.ParseFloatResponse(b) +} + +// DecrBy decrements the integer value of the specified key by the given increment. +// If the key does not exist, it is created with an initial value of 0 before decrementing. +// If the value stored at the key is not an integer, an error is returned. +// +// Parameters: +// +// `key` - string - The key whose value is to be decremented. +// +// `increment` - int - The amount by which to decrement the key's value. This can be a positive or negative integer. +// +// Returns: The new value of the key after the decrement operation as an integer. +func (server *SugarDB) DecrBy(key string, value string) (int, error) { + // Construct the command + cmd := []string{"DECRBY", key, value} + // Execute the command + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + // Parse the integer response + return internal.ParseIntegerResponse(b) +} + +// Rename renames the key from oldKey to newKey. +// If the oldKey does not exist, an error is returned. +// +// Parameters: +// +// `oldKey` - string - The key to be renamed. +// +// `newKey` - string - The new name for the key. +// +// Returns: A string indicating the success of the operation. +func (server *SugarDB) Rename(oldKey string, newKey string) (string, error) { + // Construct the command + cmd := []string{"RENAME", oldKey, newKey} + // Execute the command + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return "", err + } + // Parse the simple string response + return internal.ParseStringResponse(b) +} + +// RenameNX renames the specified key with the new name only if the new name does not already exist. +// +// Parameters: +// +// `oldKey` - string - The key to be renamed. +// +// `newKey` - string - The new name for the key. +// +// Returns: A string indicating the success of the operation. +func (server *SugarDB) RenameNX(oldKey string, newKey string) (string, error) { + // Construct the command + cmd := []string{"RENAMENX", oldKey, newKey} + // Execute the command + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return "", err + } + // Parse the simple string response + return internal.ParseStringResponse(b) +} + +// RandomKey returns a random key from the current active database. +// If no keys present in db returns an empty string. +func (server *SugarDB) RandomKey() (string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"RANDOMKEY"}), nil, false, true) + if err != nil { + return "", err + } + return internal.ParseStringResponse(b) +} + +// DBSize returns the number of keys in the currently-selected database. +// Returns: An integer number of keys +func (server *SugarDB) DBSize() (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"DBSIZE"}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// GetDel retrieves the value at the provided key and deletes that key. +// +// Parameters: +// +// `key` - string - the key whose value should be retrieved and then deleted. +// +// Returns: A string representing the value at the specified key. If the value does not exist, an empty +// string is returned. +func (server *SugarDB) GetDel(key string) (string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"GETDEL", key}), nil, false, true) + if err != nil { + return "", err + } + return internal.ParseStringResponse(b) +} + +// GetEx retrieves the value of the provided key and optionally sets its expiration +// +// Parameters: +// +// `key` - string - the key whose value should be retrieved and expiry set. +// +// `option` - GetExOption - one of EX, PX, EXAT, PXAT, PERSIST. Can be nil. +// +// `unixtime` - int - Number of seconds or milliseconds from now. +// +// Returns: A string representing the value at the specified key. If the value does not exist, an empty string is returned. +func (server *SugarDB) GetEx(key string, option GetExOption, unixtime int) (string, error) { + + cmd := make([]string, 2) + + cmd[0] = "GETEX" + cmd[1] = key + + if option != nil { + opt := fmt.Sprint(option) + cmd = append(cmd, opt) + } + + if unixtime != 0 { + cmd = append(cmd, strconv.Itoa(unixtime)) + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return "", err + } + return internal.ParseStringResponse(b) +} + +// Touch Alters the last access time or access count of the key(s) depending on whether LFU or LRU strategy was used. +// A key is ignored if it does not exist. +// +// Parameters: +// +// `keys` - ...string - the keys whose access time or access count should be incremented based on eviction policy. +// +// Returns: An integer representing the number of keys successfully touched. If a key doesn't exist it is simply ignored. +func (server *SugarDB) Touch(keys ...string) (int, error) { + cmd := make([]string, len(keys)+1) + cmd[0] = "TOUCH" + for i, k := range keys { + cmd[i+1] = k + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return -1, err + } + return internal.ParseIntegerResponse(b) +} + +// ObjectFreq retrieves the access frequency count of an object stored at . +// The command is only available when the maxmemory-policy configuration directive is set to one of the LFU policies. +// +// Parameters: +// +// `key` - string - the key whose access frequency should be retrieved. +// +// Returns: An integer representing the access frequency. If the key doesn't exist -1 and an error is returned. +func (server *SugarDB) ObjectFreq(key string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"OBJECTFREQ", key}), nil, false, true) + if err != nil { + return -1, err + } + return internal.ParseIntegerResponse(b) +} + +// ObjectIdleTime retrieves the time in seconds since the last access to the value stored at . +// The command is only available when the maxmemory-policy configuration directive is set to one of the LRU policies. +// +// Parameters: +// +// `key` - string - the key whose last access time should be retrieved. +// +// Returns: A float64 representing the seconds since the key was last accessed. If the key doesn't exist -1 and an error is returned. +func (server *SugarDB) ObjectIdleTime(key string) (float64, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"OBJECTIDLETIME", key}), nil, false, true) + if err != nil { + return -1, err + } + return internal.ParseFloatResponse(b) +} + +// Type returns the string representation of the type of the value stored at key. +// The different types that can be returned are: string, integer, float, list, set, zset, and hash. +// +// Parameters: +// +// `key` - string - the key whose type should be returned +// +// Returns: A string representation of the type of the value stored at key, if the key doesn't exist an empty string and error is returned +func (server *SugarDB) Type(key string) (string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"TYPE", key}), nil, false, true) + if err != nil { + return "", err + } + return internal.ParseStringResponse(b) +} + +// Copy copies a value of a source key to destination key. +// +// Parameters: +// +// `source` - string - the source key from which data is to be copied +// +// `destination` - string - the destination key where data should be copied +// +// Returns: 1 if the copy is successful. 0 if the copy is unsuccessful +func (server *SugarDB) Copy(sourceKey, destinationKey string, options COPYOptions) (int, error) { + cmd := []string{"COPY", sourceKey, destinationKey} + + if options.Database != "" { + cmd = append(cmd, "db", options.Database) + } + + if options.Replace { + cmd = append(cmd, "replace") + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// Move key from currently selected database to specified destination database and return 1. +// When key already exists in the destination database, or it does not exist in the source database, it does nothing and returns 0. +// +// Parameters: +// +// `key` - string - the key that should be moved. +// +// `destinationDB` - int - the database the key should be moved to. +// +// Returns: 1 if successful, 0 if unsuccessful. +func (server *SugarDB) Move(key string, destinationDB int) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"Move", key, strconv.Itoa(destinationDB)}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// Exists returns the number of keys that exist from the provided list of keys. +// Note: Duplicate keys in the argument list are each counted separately. +// +// Parameters: +// +// `keys` - ...string - the keys whose existence should be checked. +// +// Returns: An integer representing the number of keys that exist. +func (server *SugarDB) Exists(keys ...string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand(append([]string{"EXISTS"}, keys...)), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} diff --git a/sugardb/api_generic_test.go b/sugardb/api_generic_test.go new file mode 100644 index 0000000..a3ea795 --- /dev/null +++ b/sugardb/api_generic_test.go @@ -0,0 +1,2095 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "context" + "reflect" + "slices" + "strings" + "testing" + "time" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/clock" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" +) + +func TestSugarDB_DEL(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]internal.KeyData + keys []string + want int + wantErr bool + }{ + { + name: "Delete several keys and return deleted count", + keys: []string{"key1", "key2", "key3", "key4", "key5"}, + presetValues: map[string]internal.KeyData{ + "key1": {Value: "value1", ExpireAt: time.Time{}}, + "key2": {Value: "value2", ExpireAt: time.Time{}}, + "key3": {Value: "value3", ExpireAt: time.Time{}}, + "key4": {Value: "value4", ExpireAt: time.Time{}}, + }, + want: 4, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + got, err := server.Del(tt.keys...) + if (err != nil) != tt.wantErr { + t.Errorf("DEL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("DEL() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_EXPIRE(t *testing.T) { + mockClock := clock.NewClock() + + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]internal.KeyData + cmd string + key string + time int + expireOpts ExpireOptions + want bool + wantErr bool + }{ + { + name: "Set new expire by seconds", + cmd: "EXPIRE", + key: "key1", + time: 100, + expireOpts: nil, + presetValues: map[string]internal.KeyData{ + "key1": {Value: "value1", ExpireAt: time.Time{}}, + }, + want: true, + wantErr: false, + }, + { + name: "Set new expire by milliseconds", + cmd: "PEXPIRE", + key: "key2", + time: 1000, + expireOpts: nil, + presetValues: map[string]internal.KeyData{ + "key2": {Value: "value2", ExpireAt: time.Time{}}, + }, + want: true, + wantErr: false, + }, + { + name: "Set new expire only when key does not have an expiry time with NX flag", + cmd: "EXPIRE", + key: "key3", + time: 1000, + expireOpts: NX, + presetValues: map[string]internal.KeyData{ + "key3": {Value: "value3", ExpireAt: time.Time{}}, + }, + want: true, + wantErr: false, + }, + { + name: "Return false when NX flag is provided and key already has an expiry time", + cmd: "EXPIRE", + key: "key4", + time: 1000, + expireOpts: NX, + presetValues: map[string]internal.KeyData{ + "key4": {Value: "value4", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + want: false, + wantErr: false, + }, + { + name: "Set new expire time from now key only when the key already has an expiry time with XX flag", + cmd: "EXPIRE", + key: "key5", + time: 1000, + expireOpts: XX, + presetValues: map[string]internal.KeyData{ + "key5": {Value: "value5", ExpireAt: mockClock.Now().Add(30 * time.Second)}, + }, + want: true, + wantErr: false, + }, + { + name: "Return false when key does not have an expiry and the XX flag is provided", + cmd: "EXPIRE", + time: 1000, + expireOpts: XX, + key: "key6", + presetValues: map[string]internal.KeyData{ + "key6": {Value: "value6", ExpireAt: time.Time{}}, + }, + want: false, + wantErr: false, + }, + { + name: "Set expiry time when the provided time is after the current expiry time when GT flag is provided", + cmd: "EXPIRE", + key: "key7", + time: 100000, + expireOpts: GT, + presetValues: map[string]internal.KeyData{ + "key7": {Value: "value7", ExpireAt: mockClock.Now().Add(30 * time.Second)}, + }, + want: true, + wantErr: false, + }, + { + name: "Return false when GT flag is passed and current expiry time is greater than provided time", + cmd: "EXPIRE", + key: "key8", + time: 1000, + expireOpts: GT, + presetValues: map[string]internal.KeyData{ + "key8": {Value: "value8", ExpireAt: mockClock.Now().Add(3000 * time.Second)}, + }, + want: false, + wantErr: false, + }, + { + name: "Return false when GT flag is passed and key does not have an expiry time", + cmd: "EXPIRE", + key: "key9", + time: 1000, + expireOpts: GT, + presetValues: map[string]internal.KeyData{ + "key9": {Value: "value9", ExpireAt: time.Time{}}, + }, + want: false, + wantErr: false, + }, + { + name: "Set expiry time when the provided time is before the current expiry time when LT flag is provided", + cmd: "EXPIRE", + key: "key10", + time: 1000, + expireOpts: LT, + presetValues: map[string]internal.KeyData{ + "key10": {Value: "value10", ExpireAt: mockClock.Now().Add(3000 * time.Second)}, + }, + want: true, + wantErr: false, + }, + { + name: "Return false when LT flag is passed and current expiry time is less than provided time", + cmd: "EXPIRE", + key: "key11", + time: 50000, + expireOpts: LT, + presetValues: map[string]internal.KeyData{ + "key11": {Value: "value11", ExpireAt: mockClock.Now().Add(30 * time.Second)}, + }, + want: false, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + var got bool + var err error + if strings.EqualFold(tt.cmd, "PEXPIRE") { + got, err = server.PExpire(tt.key, tt.time, tt.expireOpts) + } else { + got, err = server.Expire(tt.key, tt.time, tt.expireOpts) + } + if (err != nil) != tt.wantErr { + t.Errorf("(P)EXPIRE() error = %v, wantErr %v, key %s", err, tt.wantErr, tt.key) + return + } + if got != tt.want { + t.Errorf("(P)EXPIRE() got = %v, want %v, key %s", got, tt.want, tt.key) + } + }) + } +} + +func TestSugarDB_EXPIREAT(t *testing.T) { + mockClock := clock.NewClock() + + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]internal.KeyData + cmd string + key string + time int + expireAtOpts ExpireOptions + want int + wantErr bool + }{ + { + name: "Set new expire by unix seconds", + cmd: "EXPIREAT", + key: "key1", + expireAtOpts: nil, + time: int(mockClock.Now().Add(1000 * time.Second).Unix()), + presetValues: map[string]internal.KeyData{ + "key1": {Value: "value1", ExpireAt: time.Time{}}, + }, + want: 1, + wantErr: false, + }, + { + name: "Set new expire by milliseconds", + cmd: "PEXPIREAT", + key: "key2", + expireAtOpts: nil, + time: int(mockClock.Now().Add(1000 * time.Second).UnixMilli()), + presetValues: map[string]internal.KeyData{ + "key2": {Value: "value2", ExpireAt: time.Time{}}, + }, + want: 1, + wantErr: false, + }, + { // 3. + name: "Set new expire only when key does not have an expiry time with NX flag", + cmd: "EXPIREAT", + key: "key3", + time: int(mockClock.Now().Add(1000 * time.Second).Unix()), + expireAtOpts: NX, + presetValues: map[string]internal.KeyData{ + "key3": {Value: "value3", ExpireAt: time.Time{}}, + }, + want: 1, + wantErr: false, + }, + { + name: "Return 0, when NX flag is provided and key already has an expiry time", + cmd: "EXPIREAT", + time: int(mockClock.Now().Add(1000 * time.Second).Unix()), + expireAtOpts: NX, + key: "key4", + presetValues: map[string]internal.KeyData{ + "key4": {Value: "value4", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + want: 0, + wantErr: false, + }, + { + name: "Set new expire time from now key only when the key already has an expiry time with XX flag", + cmd: "EXPIREAT", + time: int(mockClock.Now().Add(1000 * time.Second).Unix()), + key: "key5", + expireAtOpts: XX, + presetValues: map[string]internal.KeyData{ + "key5": {Value: "value5", ExpireAt: mockClock.Now().Add(30 * time.Second)}, + }, + want: 1, + wantErr: false, + }, + { + name: "Return 0 when key does not have an expiry and the XX flag is provided", + cmd: "EXPIREAT", + key: "key6", + time: int(mockClock.Now().Add(1000 * time.Second).Unix()), + expireAtOpts: XX, + presetValues: map[string]internal.KeyData{ + "key6": {Value: "value6", ExpireAt: time.Time{}}, + }, + want: 0, + wantErr: false, + }, + { + name: "Set expiry time when the provided time is after the current expiry time when GT flag is provided", + cmd: "EXPIREAT", + key: "key7", + time: int(mockClock.Now().Add(1000 * time.Second).Unix()), + expireAtOpts: GT, + presetValues: map[string]internal.KeyData{ + "key7": {Value: "value7", ExpireAt: mockClock.Now().Add(30 * time.Second)}, + }, + want: 1, + wantErr: false, + }, + { + name: "Return 0 when GT flag is passed and current expiry time is greater than provided time", + cmd: "EXPIREAT", + key: "key8", + time: int(mockClock.Now().Add(1000 * time.Second).Unix()), + expireAtOpts: GT, + presetValues: map[string]internal.KeyData{ + "key8": {Value: "value8", ExpireAt: mockClock.Now().Add(3000 * time.Second)}, + }, + want: 0, + wantErr: false, + }, + { + name: "Return 0 when GT flag is passed and key does not have an expiry time", + cmd: "EXPIREAT", + key: "key9", + time: int(mockClock.Now().Add(1000 * time.Second).Unix()), + expireAtOpts: GT, + presetValues: map[string]internal.KeyData{ + "key9": {Value: "value9", ExpireAt: time.Time{}}, + }, + want: 0, + }, + { + name: "Set expiry time when the provided time is before the current expiry time when LT flag is provided", + cmd: "EXPIREAT", + key: "key10", + time: int(mockClock.Now().Add(1000 * time.Second).Unix()), + expireAtOpts: LT, + presetValues: map[string]internal.KeyData{ + "key10": {Value: "value10", ExpireAt: mockClock.Now().Add(3000 * time.Second)}, + }, + want: 1, + wantErr: false, + }, + { + name: "Return 0 when LT flag is passed and current expiry time is less than provided time", + cmd: "EXPIREAT", + key: "key11", + time: int(mockClock.Now().Add(3000 * time.Second).Unix()), + expireAtOpts: LT, + presetValues: map[string]internal.KeyData{ + "key11": {Value: "value11", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + want: 0, + wantErr: false, + }, + { + name: "Return 0 when LT flag is passed and key does not have an expiry time", + cmd: "EXPIREAT", + key: "key12", + time: int(mockClock.Now().Add(1000 * time.Second).Unix()), + expireAtOpts: LT, + presetValues: map[string]internal.KeyData{ + "key12": {Value: "value12", ExpireAt: time.Time{}}, + }, + want: 1, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + var got int + var err error + if strings.EqualFold(tt.cmd, "PEXPIREAT") { + got, err = server.PExpireAt(tt.key, tt.time, tt.expireAtOpts) + } else { + got, err = server.ExpireAt(tt.key, tt.time, tt.expireAtOpts) + } + if (err != nil) != tt.wantErr { + t.Errorf("(P)EXPIREAT() error = %v, wantErr %v, KEY %s", err, tt.wantErr, tt.key) + return + } + if got != tt.want { + t.Errorf("(P)EXPIREAT() got = %v, want %v, KEY %s", got, tt.want, tt.key) + } + }) + } +} + +func TestSugarDB_EXPIRETIME(t *testing.T) { + mockClock := clock.NewClock() + + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]internal.KeyData + key string + expiretimeFunc func(key string) (int, error) + want int + wantErr bool + }{ + { + name: "Return expire time in seconds", + key: "key1", + presetValues: map[string]internal.KeyData{ + "key1": {Value: "value1", ExpireAt: mockClock.Now().Add(100 * time.Second)}, + }, + expiretimeFunc: server.ExpireTime, + want: int(mockClock.Now().Add(100 * time.Second).Unix()), + wantErr: false, + }, + { + name: "Return expire time in milliseconds", + key: "key2", + presetValues: map[string]internal.KeyData{ + "key2": {Value: "value2", ExpireAt: mockClock.Now().Add(4096 * time.Millisecond)}, + }, + expiretimeFunc: server.PExpireTime, + want: int(mockClock.Now().Add(4096 * time.Millisecond).UnixMilli()), + wantErr: false, + }, + { + name: "If the key is non-volatile, return -1", + key: "key3", + presetValues: map[string]internal.KeyData{ + "key3": {Value: "value3", ExpireAt: time.Time{}}, + }, + expiretimeFunc: server.PExpireTime, + want: -1, + wantErr: false, + }, + { + name: "If the key is non-existent return -2", + presetValues: nil, + expiretimeFunc: server.PExpireTime, + want: -2, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + got, err := tt.expiretimeFunc(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("(P)EXPIRETIME() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("(P)EXPIRETIME() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_GET(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + want string + wantErr bool + }{ + { + name: "Return string from existing key", + presetValue: "value1", + key: "key1", + want: "value1", + wantErr: false, + }, + { + name: "Return empty string if the key does not exist", + presetValue: nil, + key: "key2", + want: "", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.Get(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("GET() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GET() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_MGET(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]interface{} + keys []string + want []string + wantErr bool + }{ + { + name: "Get all values in the same order the keys were provided in", + presetValues: map[string]interface{}{"key1": "value1", "key2": "value2", "key3": "value3", "key4": "value4"}, + keys: []string{"key1", "key4", "key2", "key3", "key1"}, + want: []string{"value1", "value4", "value2", "value3", "value1"}, + wantErr: false, + }, + { + name: "Return empty strings for non-existent keys", + presetValues: map[string]interface{}{"key5": "value5", "key6": "value6", "key7": "value7"}, + keys: []string{"key5", "key6", "non-existent", "non-existent", "key7", "non-existent"}, + want: []string{"value5", "value6", "", "", "value7", ""}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.MGet(tt.keys...) + if (err != nil) != tt.wantErr { + t.Errorf("MGET() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("MGET() got = %v, want %v", got, tt.want) + } + for _, g := range got { + if !slices.Contains(tt.want, g) { + t.Errorf("MGET() got = %v, want %v", got, tt.want) + } + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("MGET() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_SET(t *testing.T) { + mockClock := clock.NewClock() + + server := createSugarDB() + + SetOptions := func(W SetWriteOption, EX SetExOption, EXTIME int, GET bool) SETOptions { + return SETOptions{ + WriteOpt: W, + ExpireOpt: EX, + ExpireTime: EXTIME, + Get: GET, + } + } + + tests := []struct { + name string + presetValues map[string]internal.KeyData + key string + value string + options SETOptions + wantOk bool + wantPrev string + wantErr bool + }{ + { + name: "Set normal value", + presetValues: nil, + key: "key1", + value: "value1", + options: SetOptions(nil, nil, 0, false), + wantOk: true, + wantPrev: "", + wantErr: false, + }, + { + name: "Only set the value if the key does not exist", + presetValues: nil, + key: "key2", + value: "value2", + options: SetOptions(SETNX, nil, 0, false), + wantOk: true, + wantPrev: "", + wantErr: false, + }, + { + name: "Throw error when value already exists with NX flag passed", + presetValues: map[string]internal.KeyData{ + "key3": { + Value: "preset-value3", + ExpireAt: time.Time{}, + }, + }, + key: "key3", + value: "value3", + options: SetOptions(SETNX, nil, 0, false), + wantOk: false, + wantPrev: "", + wantErr: true, + }, + { + name: "Set new key value when key exists with XX flag passed", + presetValues: map[string]internal.KeyData{ + "key4": { + Value: "preset-value4", + ExpireAt: time.Time{}, + }, + }, + key: "key4", + value: "value4", + options: SetOptions(SETXX, nil, 0, false), + wantOk: true, + wantPrev: "", + wantErr: false, + }, + { + name: "Return error when setting non-existent key with XX flag", + presetValues: nil, + key: "key5", + value: "value5", + options: SetOptions(SETXX, nil, 0, false), + wantOk: false, + wantPrev: "", + wantErr: true, + }, + { + name: "Set expiry time on the key to 100 seconds from now", + presetValues: nil, + key: "key6", + value: "value6", + options: SetOptions(nil, SETEX, 100, false), + wantOk: true, + wantPrev: "", + wantErr: false, + }, + { + name: "Set expiry time on the key in unix milliseconds", + presetValues: nil, + key: "key7", + value: "value7", + options: SetOptions(nil, SETPX, 4096, false), + wantOk: true, + wantPrev: "", + wantErr: false, + }, + { + name: "Set exact expiry time in seconds from unix epoch", + presetValues: nil, + key: "key8", + value: "value8", + options: SetOptions(nil, SETEXAT, int(mockClock.Now().Add(200*time.Second).Unix()), false), + wantOk: true, + wantPrev: "", + wantErr: false, + }, + { + name: "Set exact expiry time in milliseconds from unix epoch", + key: "key9", + value: "value9", + options: SetOptions(nil, SETPXAT, int(mockClock.Now().Add(4096*time.Millisecond).UnixMilli()), false), + presetValues: nil, + wantOk: true, + wantPrev: "", + wantErr: false, + }, + { + name: "Get the previous value when GET flag is passed", + presetValues: map[string]internal.KeyData{ + "key10": { + Value: "previous-value", + ExpireAt: time.Time{}, + }, + }, + key: "key10", + value: "value10", + options: SetOptions(nil, SETEX, 1000, true), + wantOk: true, + wantPrev: "previous-value", + wantErr: false, + }, + { + name: "Return nil when GET value is passed and no previous value exists", + presetValues: nil, + key: "key11", + value: "value11", + options: SetOptions(nil, SETEX, 1000, true), + wantOk: true, + wantPrev: "", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + previousValue, ok, err := server.Set( + tt.key, + tt.value, + tt.options, + ) + if (err != nil) != tt.wantErr { + t.Errorf("SET() error = %v, wantErr %v", err, tt.wantErr) + return + } + if ok != tt.wantOk { + t.Errorf("SET() ok got = %v, want %v", ok, tt.wantOk) + } + if previousValue != tt.wantPrev { + t.Errorf("SET() previous value got = %v, want %v", previousValue, tt.wantPrev) + } + }) + } +} + +func TestSugarDB_MSET(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + kvPairs map[string]string + want bool + wantErr bool + }{ + { + name: "Set multiple keys", + kvPairs: map[string]string{"key1": "value1", "key2": "10", "key3": "3.142"}, + want: true, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := server.MSet(tt.kvPairs) + if (err != nil) != tt.wantErr { + t.Errorf("MSET() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("MSET() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_PERSIST(t *testing.T) { + mockClock := clock.NewClock() + + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]internal.KeyData + key string + want bool + wantErr bool + }{ + { + name: "Successfully persist a volatile key", + key: "key1", + presetValues: map[string]internal.KeyData{ + "key1": {Value: "value1", ExpireAt: mockClock.Now().Add(1000 * time.Second)}, + }, + want: true, + wantErr: false, + }, + { + name: "Return false when trying to persist a non-existent key", + key: "key2", + presetValues: nil, + want: false, + wantErr: false, + }, + { + name: "Return false when trying to persist a non-volatile key", + key: "key3", + presetValues: map[string]internal.KeyData{ + "key3": {Value: "value3", ExpireAt: time.Time{}}, + }, + want: false, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + got, err := server.Persist(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("PERSIST() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("PERSIST() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_TTL(t *testing.T) { + mockClock := clock.NewClock() + + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]internal.KeyData + key string + ttlFunc func(key string) (int, error) + want int + wantErr bool + }{ + { + name: "Return TTL time in seconds", + key: "key1", + presetValues: map[string]internal.KeyData{ + "key1": {Value: "value1", ExpireAt: mockClock.Now().Add(100 * time.Second)}, + }, + ttlFunc: server.TTL, + want: 100, + wantErr: false, + }, + { + name: "Return TTL time in milliseconds", + key: "key2", + ttlFunc: server.PTTL, + presetValues: map[string]internal.KeyData{ + "key2": {Value: "value2", ExpireAt: mockClock.Now().Add(4096 * time.Millisecond)}, + }, + want: 4096, + wantErr: false, + }, + { + name: "If the key is non-volatile, return -1", + key: "key3", + ttlFunc: server.TTL, + presetValues: map[string]internal.KeyData{ + "key3": {Value: "value3", ExpireAt: time.Time{}}, + }, + want: -1, + wantErr: false, + }, + { + name: "If the key is non-existent return -2", + key: "key4", + ttlFunc: server.TTL, + presetValues: nil, + want: -2, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + got, err := tt.ttlFunc(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("TTL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("TTL() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_INCR(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + key string + presetValues map[string]internal.KeyData + want int + wantErr bool + }{ + { + name: "1. Increment non-existent key", + key: "IncrKey1", + presetValues: nil, + want: 1, + wantErr: false, + }, + { + name: "2. Increment existing key with integer value", + key: "IncrKey2", + presetValues: map[string]internal.KeyData{ + "IncrKey2": {Value: "5"}, + }, + want: 6, + wantErr: false, + }, + { + name: "3. Increment existing key with non-integer value", + key: "IncrKey3", + presetValues: map[string]internal.KeyData{ + "IncrKey3": {Value: "not_an_int"}, + }, + want: 0, + wantErr: true, + }, + { + name: "4. Increment existing key with int64 value", + key: "IncrKey4", + presetValues: map[string]internal.KeyData{ + "IncrKey4": {Value: int64(10)}, + }, + want: 11, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + got, err := server.Incr(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("INCR() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("INCR() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_DECR(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + key string + presetValues map[string]internal.KeyData + want int + wantErr bool + }{ + { + name: "1. Decrement non-existent key", + key: "DecrKey1", + presetValues: nil, + want: -1, + wantErr: false, + }, + { + name: "2. Decrement existing key with integer value", + key: "DecrKey2", + presetValues: map[string]internal.KeyData{ + "DecrKey2": {Value: "5"}, + }, + want: 4, + wantErr: false, + }, + { + name: "3. Decrement existing key with non-integer value", + key: "DecrKey3", + presetValues: map[string]internal.KeyData{ + "DecrKey3": {Value: "not_an_int"}, + }, + want: 0, + wantErr: true, + }, + { + name: "4. Decrement existing key with int64 value", + key: "DecrKey4", + presetValues: map[string]internal.KeyData{ + "DecrKey4": {Value: int64(10)}, + }, + want: 9, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + got, err := server.Decr(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("DECR() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("DECR() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_INCRBY(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + key string + increment string + presetValues map[string]internal.KeyData + want int + wantErr bool + }{ + { + name: "1. Increment non-existent key by 4", + key: "IncrByKey1", + increment: "4", + presetValues: nil, + want: 4, + wantErr: false, + }, + { + name: "2. Increment existing key with integer value by 3", + key: "IncrByKey2", + increment: "3", + presetValues: map[string]internal.KeyData{ + "IncrByKey2": {Value: "5"}, + }, + want: 8, + wantErr: false, + }, + { + name: "3. Increment existing key with non-integer value by 2", + key: "IncrByKey3", + increment: "2", + presetValues: map[string]internal.KeyData{ + "IncrByKey3": {Value: "not_an_int"}, + }, + want: 0, + wantErr: true, + }, + { + name: "4. Increment existing key with int64 value by 7", + key: "IncrByKey4", + increment: "7", + presetValues: map[string]internal.KeyData{ + "IncrByKey4": {Value: int64(10)}, + }, + want: 17, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + got, err := server.IncrBy(tt.key, tt.increment) + if (err != nil) != tt.wantErr { + t.Errorf("IncrBy() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("IncrBy() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_INCRBYFLOAT(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + key string + increment string + presetValues map[string]internal.KeyData + want float64 + wantErr bool + }{ + { + name: "1. Increment non-existent key by 2.5", + key: "IncrByFloatKey1", + increment: "2.5", + presetValues: nil, + want: 2.5, + wantErr: false, + }, + { + name: "2. Increment existing key with integer value by 1.2", + key: "IncrByFloatKey2", + increment: "1.2", + presetValues: map[string]internal.KeyData{ + "IncrByFloatKey2": {Value: "5"}, + }, + want: 6.2, + wantErr: false, + }, + { + name: "3. Increment existing key with float value by 0.7", + key: "IncrByFloatKey4", + increment: "0.7", + presetValues: map[string]internal.KeyData{ + "IncrByFloatKey4": {Value: "10.0"}, + }, + want: 10.7, + wantErr: false, + }, + { + name: "4. Increment existing key with scientific notation value by 200", + key: "IncrByFloatKey5", + increment: "200", + presetValues: map[string]internal.KeyData{ + "IncrByFloatKey5": {Value: "5.0e3"}, + }, + want: 5200, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + got, err := server.IncrByFloat(tt.key, tt.increment) + if (err != nil) != tt.wantErr { + t.Errorf("IncrByFloat() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err == nil && got != tt.want { + t.Errorf("IncrByFloat() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_DECRBY(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + key string + decrement string + presetValues map[string]internal.KeyData + want int + wantErr bool + }{ + { + name: "1. Decrement non-existent key by 4", + key: "DecrByKey1", + decrement: "4", + presetValues: nil, + want: -4, + wantErr: false, + }, + { + name: "2. Decrement existing key with integer value by 3", + key: "DecrByKey2", + decrement: "3", + presetValues: map[string]internal.KeyData{ + "DecrByKey2": {Value: "-5"}, + }, + want: -8, + wantErr: false, + }, + { + name: "3. Decrement existing key with non-integer value by 2", + key: "DecrByKey3", + decrement: "2", + presetValues: map[string]internal.KeyData{ + "DecrByKey3": {Value: "not_an_int"}, + }, + want: 0, + wantErr: true, + }, + { + name: "4. Decrement existing key with int64 value by 7", + key: "DecrByKey4", + decrement: "7", + presetValues: map[string]internal.KeyData{ + "DecrByKey4": {Value: int64(10)}}, + want: 3, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + got, err := server.DecrBy(tt.key, tt.decrement) + if (err != nil) != tt.wantErr { + t.Errorf("DecrBy() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("DecrBy() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_Rename(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + oldKey string + newKey string + presetValues map[string]internal.KeyData + want string + wantErr bool + }{ + { + name: "1. Rename existing key", + oldKey: "oldKey1", + newKey: "newKey1", + presetValues: map[string]internal.KeyData{"oldKey1": {Value: "value1"}}, + want: "OK", + wantErr: false, + }, + { + name: "2. Rename non-existent key", + oldKey: "oldKey2", + newKey: "newKey2", + presetValues: nil, + want: "", + wantErr: true, + }, + { + name: "3. Rename to existing key", + oldKey: "oldKey3", + newKey: "newKey4", + presetValues: map[string]internal.KeyData{"oldKey3": {Value: "value3"}, "newKey4": {Value: "value4"}}, + want: "OK", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + got, err := server.Rename(tt.oldKey, tt.newKey) + if (err != nil) != tt.wantErr { + t.Errorf("Rename() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Rename() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_Renamenx(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + oldKey string + newKey string + presetValues map[string]internal.KeyData + want string + wantErr bool + }{ + { + name: "1. Rename existing key", + oldKey: "oldKey1", + newKey: "newKey1", + presetValues: map[string]internal.KeyData{"oldKey1": {Value: "value1"}}, + want: "OK", + wantErr: false, + }, + { + name: "2. Rename non-existent key", + oldKey: "oldKey2", + newKey: "newKey2", + presetValues: nil, + want: "", + wantErr: true, + }, + { + name: "3. Rename to existing key", + oldKey: "oldKey3", + newKey: "newKey4", + presetValues: map[string]internal.KeyData{"oldKey3": {Value: "value3"}, "newKey4": {Value: "value4"}}, + want: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, d := range tt.presetValues { + presetKeyData(server, context.Background(), k, d) + } + } + got, err := server.RenameNX(tt.oldKey, tt.newKey) + if (err != nil) != tt.wantErr { + t.Errorf("Rename() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Rename() got = %v, want %v", got, tt.want) + } + }) + } +} +func TestSugarDB_RANDOMKEY(t *testing.T) { + server := createSugarDB() + + // test without keys + got, err := server.RandomKey() + if err != nil { + t.Error(err) + return + } + if got != "" { + t.Errorf("RANDOMKEY error, expected emtpy string (%v), got (%v)", []byte(""), []byte(got)) + } + + // test with keys + testkeys := []string{"key1", "key2", "key3"} + for _, k := range testkeys { + err := presetValue(server, context.Background(), k, "") + if err != nil { + t.Error(err) + return + } + } + + actual, err := server.RandomKey() + if err != nil { + t.Error(err) + return + } + if !strings.Contains(actual, "key") { + t.Errorf("RANDOMKEY error, expected one of %v, got %s", testkeys, got) + } + +} + +func TestSugarDB_Exists(t *testing.T) { + server := createSugarDB() + + // Test with no keys + keys := []string{"key1", "key2", "key3"} + existsCount, err := server.Exists(keys...) + if err != nil { + t.Error(err) + return + } + if existsCount != 0 { + t.Errorf("EXISTS error, expected 0, got %d", existsCount) + } + + // Test with some keys + for _, k := range keys { + err := presetValue(server, context.Background(), k, "") + if err != nil { + t.Error(err) + return + } + } + + existsCount, err = server.Exists(keys...) + if err != nil { + t.Error(err) + return + } + if existsCount != len(keys) { + t.Errorf("EXISTS error, expected %d, got %d", len(keys), existsCount) + } +} + +func TestSugarDB_DBSize(t *testing.T) { + server := createSugarDB() + got, err := server.DBSize() + if err != nil { + t.Error(err) + return + } + if got != 0 { + t.Errorf("DBSIZE error, expected 0, got %d", got) + } + + // test with keys + testkeys := []string{"1", "2", "3"} + for _, k := range testkeys { + err := presetValue(server, context.Background(), k, "") + if err != nil { + t.Error(err) + return + } + } + + got, err = server.DBSize() + if err != nil { + t.Error(err) + return + } + if got != len(testkeys) { + t.Errorf("DBSIZE error, expected %d, got %d", len(testkeys), got) + } +} + +func TestSugarDB_GETDEL(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + want string + wantErr bool + }{ + { + name: "Return string from existing key", + presetValue: "value1", + key: "key1", + want: "value1", + wantErr: false, + }, + { + name: "Return empty string if the key does not exist", + presetValue: nil, + key: "key2", + want: "", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + // Check value received + got, err := server.GetDel(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("GETDEL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GETDEL() got = %v, want %v", got, tt.want) + } + // Check key was deleted + if tt.presetValue != nil { + got, err := server.Get(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("GETDEL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != "" { + t.Errorf("GETDEL() got = %v, want empty string", got) + } + } + }) + } +} + +func TestSugarDB_GETEX(t *testing.T) { + mockClock := clock.NewClock() + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + getExOpt GetExOption + getExOptTime int + key string + want string + wantEx int + wantErr bool + }{ + { + name: "1. Return string from existing key, no expire options", + presetValue: "value1", + getExOpt: nil, + key: "key1", + want: "value1", + wantEx: -1, + wantErr: false, + }, + { + name: "2. Return empty string if the key does not exist", + presetValue: nil, + getExOpt: EX, + getExOptTime: int(mockClock.Now().Add(100 * time.Second).Unix()), + key: "key2", + want: "", + wantEx: 0, + wantErr: false, + }, + { + name: "3. Return key set expiry with EX", + presetValue: "value3", + getExOpt: EX, + getExOptTime: 100, + key: "key3", + want: "value3", + wantEx: 100, + wantErr: false, + }, + { + name: "4. Return key set expiry with PX", + presetValue: "value4", + getExOpt: PX, + getExOptTime: 100000, + key: "key4", + want: "value4", + wantEx: 100, + wantErr: false, + }, + { + name: "5. Return key set expiry with EXAT", + presetValue: "value5", + getExOpt: EXAT, + getExOptTime: int(mockClock.Now().Add(100 * time.Second).Unix()), + key: "key5", + want: "value5", + wantEx: 100, + wantErr: false, + }, + { + name: "6. Return key set expiry with PXAT", + presetValue: "value6", + getExOpt: PXAT, + getExOptTime: int(mockClock.Now().Add(100 * time.Second).UnixMilli()), + key: "key6", + want: "value6", + wantEx: 100, + wantErr: false, + }, + { + name: "7. Return key passing PERSIST", + presetValue: "value7", + getExOpt: PERSIST, + key: "key7", + want: "value7", + wantEx: -1, + wantErr: false, + }, + { + name: "8. Return key passing PERSIST, and include a UNIXTIME", + presetValue: "value8", + getExOpt: PERSIST, + getExOptTime: int(mockClock.Now().Add(100 * time.Second).Unix()), + key: "key8", + want: "value8", + wantEx: -1, + wantErr: false, + }, + { + name: "9. Return key and attempt to set expiry with EX without providing UNIXTIME", + presetValue: "value9", + getExOpt: EX, + key: "key9", + want: "value9", + wantEx: -1, + wantErr: false, + }, + { + name: "10. Return key and attempt to set expiry with PXAT without providing UNIXTIME", + presetValue: "value10", + getExOpt: PXAT, + key: "key10", + want: "value10", + wantEx: -1, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + // Check value received + got, err := server.GetEx(tt.key, tt.getExOpt, tt.getExOptTime) + if (err != nil) != tt.wantErr { + t.Errorf("GETEX() GET error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GETEX() GET - got = %v, want %v", got, tt.want) + } + // Check expiry was set + if tt.presetValue != nil { + actual, err := server.TTL(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("GETEX() EXPIRY error = %v, wantErr %v", err, tt.wantErr) + return + } + if actual != tt.wantEx { + t.Errorf("GETEX() EXPIRY - got = %v, want %v", actual, tt.wantEx) + } + } + }) + } +} + +// Tests Touch and OBJECTFREQ commands +func TestSugarDB_LFU_TOUCH(t *testing.T) { + + duration := time.Duration(30) * time.Second + + server := createSugarDBWithConfig(config.Config{ + DataDir: "", + EvictionPolicy: constants.AllKeysLFU, + EvictionInterval: duration, + MaxMemory: 4000000, + }) + + tests := []struct { + name string + keys []string + setKeys []bool + want int + wantErrs []bool + }{ + { + name: "1. Touch key that exists.", + keys: []string{"Key1"}, + setKeys: []bool{true}, + want: 1, + wantErrs: []bool{false}, + }, + { + name: "2. Touch key that doesn't exist.", + keys: []string{"Key2"}, + setKeys: []bool{false}, + want: 0, + wantErrs: []bool{true}, + }, + { + name: "3. Touch multiple keys that all exist.", + keys: []string{"Key3", "Key3.1"}, + setKeys: []bool{true, true}, + want: 2, + wantErrs: []bool{false, false}, + }, + { + name: "4. Touch multiple keys, some don't exist.", + keys: []string{"Key4", "Key4.9"}, + setKeys: []bool{true, false}, + want: 1, + wantErrs: []bool{false, true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Preset values + for i, key := range tt.keys { + if tt.setKeys[i] { + err := presetValue(server, context.Background(), key, "___") + if err != nil { + t.Error(err) + return + } + } + } + + // Touch keys + got, err := server.Touch(tt.keys...) + if err != nil { + t.Errorf("TOUCH() error - %v", err) + } + + if got != tt.want { + t.Errorf("TOUCH() got %v, want %v, using keys %v setKeys %v", got, tt.want, tt.keys, tt.setKeys) + } + + // Another touch to help testing object freq + got, err = server.Touch(tt.keys...) + if err != nil { + t.Errorf("TOUCH() error - %v", err) + } + + if got != tt.want { + t.Errorf("TOUCH() got %v, want %v, using keys %v setKeys %v", got, tt.want, tt.keys, tt.setKeys) + } + + // Wait to avoid race + ticker := time.NewTicker(200 * time.Millisecond) + <-ticker.C + ticker.Stop() + + // Objectfreq + for i, key := range tt.keys { + actual, err := server.ObjectFreq(key) + if (err != nil) != tt.wantErrs[i] { + t.Errorf("OBJECTFREQ() error: %v, wanted error: %v", err, tt.wantErrs[i]) + } + if !tt.wantErrs[i] && actual != 3 { + t.Errorf("OBJECTFREQ() error - expected 3 got %v for key %v", actual, key) + } + + // Check error for object idletime + _, err = server.ObjectIdleTime(key) + if err == nil { + t.Errorf("OBJECTIDLETIME() error - expected error when used on server with lfu eviction policy but got none.") + } + + } + + }) + } +} + +// Tests Touch and OBJECTIDLETIME commands +func TestSugarDB_LRU_TOUCH(t *testing.T) { + + duration := time.Duration(30) * time.Second + + server := createSugarDBWithConfig(config.Config{ + DataDir: "", + EvictionPolicy: constants.AllKeysLRU, + EvictionInterval: duration, + MaxMemory: 4000000, + }) + + tests := []struct { + name string + keys []string + setKeys []bool + want int + wantErrs []bool + }{ + { + name: "1. Touch key that exists.", + keys: []string{"Key1"}, + setKeys: []bool{true}, + want: 1, + wantErrs: []bool{false}, + }, + { + name: "2. Touch key that doesn't exist.", + keys: []string{"Key2"}, + setKeys: []bool{false}, + want: 0, + wantErrs: []bool{true}, + }, + { + name: "3. Touch multiple keys that all exist.", + keys: []string{"Key3", "Key3.1"}, + setKeys: []bool{true, true}, + want: 2, + wantErrs: []bool{false, false}, + }, + { + name: "4. Touch multiple keys, some don't exist.", + keys: []string{"Key4", "Key4.9"}, + setKeys: []bool{true, false}, + want: 1, + wantErrs: []bool{false, true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Preset values + for i, key := range tt.keys { + if tt.setKeys[i] { + err := presetValue(server, context.Background(), key, "___") + if err != nil { + t.Error(err) + return + } + } + } + + // Touch keys + got, err := server.Touch(tt.keys...) + if err != nil { + t.Errorf("TOUCH() error - %v", err) + } + + if got != tt.want { + t.Errorf("TOUCH() got %v, want %v, using keys %v setKeys %v", got, tt.want, tt.keys, tt.setKeys) + } + + // Sleep to more easily test Object idle time + ticker := time.NewTicker(200 * time.Millisecond) + <-ticker.C + ticker.Stop() + + // Objectidletime + for i, key := range tt.keys { + actual, err := server.ObjectIdleTime(key) + if (err != nil) != tt.wantErrs[i] { + t.Errorf("OBJECTIDLETIME() error: %v, wanted error: %v", err, tt.wantErrs[i]) + } + if !tt.wantErrs[i] && actual < 0.2 { + t.Errorf("OBJECTIDLETIME() error - expected 0.2 got %v", actual) + } + + // Check error for object freq + _, err = server.ObjectFreq(key) + if err == nil { + t.Errorf("OBJECTFREQ() error - expected error when used on server with lru eviction policy but got none.") + } + + } + + }) + } +} + +func TestSugarDB_TYPE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + want string + wantErr bool + }{ + { + name: "Return string from existing key", + presetValue: "value1", + key: "key1", + want: "string", + wantErr: false, + }, + { + name: "Return empty string if the key does not exist", + presetValue: nil, + key: "key2", + want: "", + wantErr: true, + }, + { + name: "Return string from existing key", + presetValue: 10, + key: "key3", + want: "integer", + wantErr: false, + }, + { + name: "Return string from existing key", + presetValue: 10.1, + key: "key4", + want: "float", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.Type(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("GET() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GET() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_COPY(t *testing.T) { + server := createSugarDB() + + CopyOptions := func(DB string, R bool) COPYOptions { + return COPYOptions{ + Database: DB, + Replace: R, + } + } + + tests := []struct { + name string + sourceKeyPresetValue interface{} + sourcekey string + destKeyPresetValue interface{} + destinationKey string + options COPYOptions + expectedValue string + want int + wantErr bool + }{ + { + name: "Copy Value into non existing key", + sourceKeyPresetValue: "value1", + sourcekey: "skey1", + destKeyPresetValue: nil, + destinationKey: "dkey1", + options: CopyOptions("0", false), + expectedValue: "value1", + want: 1, + wantErr: false, + }, + { + name: "Copy Value into existing key without replace option", + sourceKeyPresetValue: "value2", + sourcekey: "skey2", + destKeyPresetValue: "dValue2", + destinationKey: "dkey2", + options: CopyOptions("0", false), + expectedValue: "dValue2", + want: 0, + wantErr: false, + }, + { + name: "Copy Value into existing key with replace option", + sourceKeyPresetValue: "value3", + sourcekey: "skey3", + destKeyPresetValue: "dValue3", + destinationKey: "dkey3", + options: CopyOptions("0", true), + expectedValue: "value3", + want: 1, + wantErr: false, + }, + { + name: "Copy Value into different database", + sourceKeyPresetValue: "value4", + sourcekey: "skey4", + destKeyPresetValue: nil, + destinationKey: "dkey4", + options: CopyOptions("1", false), + expectedValue: "value4", + want: 1, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.sourceKeyPresetValue != nil { + err := presetValue(server, context.Background(), tt.sourcekey, tt.sourceKeyPresetValue) + if err != nil { + t.Error(err) + return + } + } + if tt.destKeyPresetValue != nil { + err := presetValue(server, context.Background(), tt.destinationKey, tt.destKeyPresetValue) + if err != nil { + t.Error(err) + return + } + } + + got, err := server.Copy(tt.sourcekey, tt.destinationKey, tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("COPY() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("COPY() got = %v, want %v", got, tt.want) + } + + val, err := getValue(server, context.Background(), tt.destinationKey, tt.options.Database) + if err != nil { + t.Error(err) + return + } + + if val != tt.expectedValue { + t.Errorf("COPY() value in destionation key: %v, should be: %v", val, tt.expectedValue) + } + }) + } +} + +func TestSugarDB_MOVE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + want int + }{ + { + name: "1. Move key successfully", + presetValue: "value1", + key: "key1", + want: 1, + }, + { + name: "2. Attempt to move key, unsuccessful", + presetValue: nil, + key: "key2", + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Log(tt.name) + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + + got, err := server.Move(tt.key, 1) + if err != nil { + t.Error(err) + } + + if got != tt.want { + t.Errorf("MOVE() got %v, want %v", got, tt.want) + } + }) + } +} diff --git a/sugardb/api_hash.go b/sugardb/api_hash.go new file mode 100644 index 0000000..3f94260 --- /dev/null +++ b/sugardb/api_hash.go @@ -0,0 +1,425 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "fmt" + "strconv" + + "apigo.cc/go/sugardb/internal" +) + +// HRandFieldOptions modifies the behaviour of the HRandField function. +// +// Count determines the number of random fields to return. If set to 0, an empty slice will be returned. +// +// WithValues determines whether the returned map should contain the values as well as the fields. +type HRandFieldOptions struct { + Count uint + WithValues bool +} + +// HSet creates or modifies a hash map with the values provided. If the hash map does not exist it will be created. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// `fieldValuePairs` - map[string]string - a hash used to update or create the hash. Existing fields will be updated +// with the new values. Non-existent fields will be created. +// +// Returns: The number of fields that were updated/created. +// +// Errors: +// +// "value at is not a hash" - when the provided key exists but is not a hash. +func (server *SugarDB) HSet(key string, fieldValuePairs map[string]string) (int, error) { + cmd := []string{"HSET", key} + + for k, v := range fieldValuePairs { + cmd = append(cmd, []string{k, v}...) + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + + return internal.ParseIntegerResponse(b) +} + +// HSetNX modifies an existing hash map with the values provided. This function will only be successful if the +// hash map already exists. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// `fieldValuePairs` - map[string]string - a hash used to update the hash. Existing fields will be updated +// with the new values. Non-existent fields will be created. +// +// Returns: The number of fields that were updated/created. +// +// Errors: +// +// "value at is not a hash" - when the provided key does not exist or is not a hash. +func (server *SugarDB) HSetNX(key string, fieldValuePairs map[string]string) (int, error) { + cmd := []string{"HSETNX", key} + + for k, v := range fieldValuePairs { + cmd = append(cmd, []string{k, v}...) + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + + return internal.ParseIntegerResponse(b) +} + +// HGet retrieves the values corresponding to the provided fields. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// `fields` - ...string - the list of fields to fetch. +// +// Returns: A string slice of the values corresponding to the fields in the same order the fields were provided. +// +// Errors: +// +// "value at is not a hash" - when the provided key does not exist or is not a hash. +func (server *SugarDB) HGet(key string, fields ...string) ([]string, error) { + b, err := server.handleCommand( + server.context, + internal.EncodeCommand(append([]string{"HGET", key}, fields...)), + nil, + false, + true, + ) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// HMGet retrieves the values corresponding to the provided fields. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// `fields` - ...string - the list of fields to fetch. +// +// Returns: A string slice of the values corresponding to the fields in the same order the fields were provided. +// +// Errors: +// +// "value at is not a hash" - when the provided key does not exist or is not a hash. +func (server *SugarDB) HMGet(key string, fields ...string) ([]string, error) { + b, err := server.handleCommand( + server.context, + internal.EncodeCommand(append([]string{"HMGET", key}, fields...)), + nil, + false, + true, + ) + if err != nil { + return nil, err + } + + return internal.ParseStringArrayResponse(b) +} + +// HStrLen returns the length of the values held at the specified fields of a hash map. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// `fields` - ...string - the list of fields to whose values lengths will be checked. +// +// Returns: and integer slice representing the lengths of the strings at the corresponding fields index. +// Non-existent fields will have length 0. If the key does not exist, an empty slice is returned. +// +// Errors: +// +// "value at is not a hash" - when the provided key does not exist or is not a hash. +func (server *SugarDB) HStrLen(key string, fields ...string) ([]int, error) { + cmd := append([]string{"HSTRLEN", key}, fields...) + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + + return internal.ParseIntegerArrayResponse(b) +} + +// HVals returns all the values in a hash map. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// Returns: a string slice with all the values of the hash map. If the key does not exist, an empty slice is returned. +// +// Errors: +// +// "value at is not a hash" - when the provided key does not exist or is not a hash. +func (server *SugarDB) HVals(key string) ([]string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"HVALS", key}), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// HRandField returns a random list of fields from the hash map. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// `options` - HRandFieldOptions +// +// Returns: a string slice containing random fields of the hash map. If the key does not exist, an empty slice is returned. +// +// Errors: +// +// "value at is not a hash" - when the provided key does not exist or is not a hash. +func (server *SugarDB) HRandField(key string, options HRandFieldOptions) ([]string, error) { + cmd := []string{"HRANDFIELD", key} + + if options.Count == 0 { + cmd = append(cmd, strconv.Itoa(1)) + } else { + cmd = append(cmd, strconv.Itoa(int(options.Count))) + } + + if options.WithValues { + cmd = append(cmd, "WITHVALUES") + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + + return internal.ParseStringArrayResponse(b) +} + +// HLen returns the length of the hash map. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// Returns: an integer representing the length of the hash map. If the key does not exist, 0 is returned. +// +// Errors: +// +// "value at is not a hash" - when the provided key does not exist or is not a hash. +func (server *SugarDB) HLen(key string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"HLEN", key}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// HKeys returns all the keys in a hash map. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// Returns: a string slice with all the keys of the hash map. If the key does not exist, an empty slice is returned. +// +// Errors: +// +// "value at is not a hash" - when the provided key does not exist or is not a hash. +func (server *SugarDB) HKeys(key string) ([]string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"HKEYS", key}), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// HIncrBy increment the value of the hash map at the given field by an integer. If the hash map does not exist, +// a new hash map is created with the field and increment as the value. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// `field` - string - the field of the value to increment. +// +// Returns: a float representing the new value of the field. +// +// Errors: +// +// "value at is not a hash" - when the provided key does not exist or is not a hash. +// +// "value at field is not a number" - when the field holds a value that is not a number. +func (server *SugarDB) HIncrBy(key, field string, increment int) (float64, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"HINCRBY", key, field, strconv.Itoa(increment)}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseFloatResponse(b) +} + +// HIncrByFloat behaves like HIncrBy but with a float increment instead of an integer increment. +func (server *SugarDB) HIncrByFloat(key, field string, increment float64) (float64, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"HINCRBYFLOAT", key, field, strconv.FormatFloat(increment, 'f', -1, 64)}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseFloatResponse(b) +} + +// HGetAll returns a flattened slice of all keys and values in a hash map. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// Returns: a flattened string slice where every second element is a value preceded by its corresponding key. If the +// key does not exist, an empty slice is returned. +// +// Errors: +// +// "value at is not a hash" - when the provided key does not exist or is not a hash. +func (server *SugarDB) HGetAll(key string) ([]string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"HGETALL", key}), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// HExists checks if a field exists in a hash map. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// `field` - string - the field to check. +// +// Returns: a boolean representing whether the field exists in the hash map. Returns 0 if the hash map does not exist. +// +// Errors: +// +// "value at is not a hash" - when the provided key does not exist or is not a hash. +func (server *SugarDB) HExists(key, field string) (bool, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"HEXISTS", key, field}), nil, false, true) + if err != nil { + return false, err + } + return internal.ParseBooleanResponse(b) +} + +// HDel delete 1 or more fields from a hash map. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// `fields` - ...string - a list of fields to delete. +// +// Returns: an integer representing the number of fields deleted. +// +// Errors: +// +// "value at is not a hash" - when the provided key does not exist or is not a hash. +func (server *SugarDB) HDel(key string, fields ...string) (int, error) { + cmd := append([]string{"HDEL", key}, fields...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// HExpire sets the expiration for the provided field(s) in a hash map. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// `seconds` - int - number of seconds until expiration. +// +// `ExOpt` - ExpireOptions - One of NX, XX, GT, LT. +// +// `fields` - ...string - a list of fields to set expiration of. +// +// Returns: an integer array representing the outcome of the commmand for each field. +// - Integer reply: -2 if no such field exists in the provided hash key, or the provided key does not exist. +// - Integer reply: 0 if the specified NX | XX | GT | LT condition has not been met. +// - Integer reply: 1 if the expiration time was set/updated. +// - Integer reply: 2 when HEXPIRE/HPEXPIRE is called with 0 seconds +// +// Errors: +// +// "value of key is not a hash" - when the provided key is not a hash. +func (server *SugarDB) HExpire(key string, seconds int, ExOpt ExpireOptions, fields ...string) ([]int, error) { + secs := fmt.Sprintf("%v", seconds) + cmd := []string{"HEXPIRE", key, secs} + if ExOpt != nil { + ExpireOption := fmt.Sprintf("%v", ExOpt) + cmd = append(cmd, ExpireOption) + } + + numFields := fmt.Sprintf("%v", len(fields)) + fieldsArray := append([]string{"FIELDS", numFields}, fields...) + + cmd = append(cmd, fieldsArray...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseIntegerArrayResponse(b) +} + +// HTTL gets the expiration for the provided field(s) in a hash map. +// +// Parameters: +// +// `key` - string - the key to the hash map. +// +// `fields` - ...string - a list of fields to get TTL for. +// +// Returns: an integer array representing the outcome of the commmand for each field. +// - Integer reply: the TTL in seconds. +// - Integer reply: -2 if no such field exists in the provided hash key, or the provided key does not exist. +// - Integer reply: -1 if the field exists but has no associated expiration set. +// +// Errors: +// +// "value of key is not a hash" - when the provided key is not a hash. +func (server *SugarDB) HTTL(key string, fields ...string) ([]int, error) { + numFields := fmt.Sprintf("%v", len(fields)) + + cmd := append([]string{"HTTL", key, "FIELDS", numFields}, fields...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseIntegerArrayResponse(b) +} diff --git a/sugardb/api_hash_test.go b/sugardb/api_hash_test.go new file mode 100644 index 0000000..06836e1 --- /dev/null +++ b/sugardb/api_hash_test.go @@ -0,0 +1,1142 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "context" + "reflect" + "slices" + "testing" + "time" + + "apigo.cc/go/sugardb/internal/modules/hash" +) + +func TestSugarDB_HDEL(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + fields []string + want int + wantErr bool + }{ + { + name: "Return count of deleted fields in the specified hash", + key: "key1", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 123456789}, + "field3": {Value: 3.142}, + "field7": {Value: "value7"}, + }, + fields: []string{"field1", "field2", "field3", "field4", "field5", "field6"}, + want: 3, + wantErr: false, + }, + { + name: "0 response when passing delete fields that are non-existent on valid hash", + key: "key2", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: "value2"}, + "field3": {Value: "value3"}, + }, + fields: []string{"field4", "field5", "field6"}, + want: 0, + wantErr: false, + }, + { + name: "0 response when trying to call HDEL on non-existent key", + key: "key3", + presetValue: nil, + fields: []string{"field1"}, + want: 0, + wantErr: false, + }, + { + name: "Trying to get lengths on a non hash map returns error", + presetValue: "Default value", + key: "key5", + fields: []string{"field1"}, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.HDel(tt.key, tt.fields...) + if (err != nil) != tt.wantErr { + t.Errorf("HDEL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("HDEL() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_HEXISTS(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + field string + want bool + wantErr bool + }{ + { + name: "Return 1 if the field exists in the hash", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 123456789}, + "field3": {Value: 3.142}, + }, + key: "key1", + field: "field1", + want: true, + wantErr: false, + }, + { + name: "False response when trying to call HEXISTS on non-existent key", + presetValue: hash.Hash{}, + key: "key2", + field: "field1", + want: false, + wantErr: false, + }, + { + name: "Trying to get lengths on a non hash map returns error", + presetValue: "Default value", + key: "key5", + field: "field1", + want: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.HExists(tt.key, tt.field) + if (err != nil) != tt.wantErr { + t.Errorf("HEXISTS() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("HEXISTS() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_HGETALL(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + want []string + wantErr bool + }{ + { + name: "Return an array containing all the fields and values of the hash", + key: "key1", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 123456789}, + "field3": {Value: 3.142}, + }, + want: []string{"field1", "value1", "field2", "123456789", "field3", "3.142"}, + wantErr: false, + }, + { + name: "Empty array response when trying to call HGETALL on non-existent key", + key: "key2", + presetValue: hash.Hash{}, + want: []string{}, + wantErr: false, + }, + { + name: "Trying to get lengths on a non hash map returns error", + key: "key5", + presetValue: "Default value", + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.HGetAll(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("HGETALL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("HGETALL() got = %v, want %v", got, tt.want) + return + } + for _, g := range got { + if !slices.Contains(tt.want, g) { + t.Errorf("HGETALL() got = %v, want %v", got, tt.want) + return + } + } + }) + } +} + +func TestSugarDB_HINCRBY(t *testing.T) { + server := createSugarDB() + + const ( + HINCRBY = "HINCRBY" + HINCRBYFLOAT = "HINCRBYFLOAT" + ) + + tests := []struct { + name string + presetValue interface{} + incr_type string + key string + field string + increment_int int + increment_float float64 + want float64 + wantErr bool + }{ + { + name: "Increment by integer on non-existent hash should create a new one", + presetValue: nil, + incr_type: HINCRBY, + key: "key1", + field: "field1", + increment_int: 1, + want: 1, + wantErr: false, + }, + { + name: "Increment by float on non-existent hash should create one", + presetValue: nil, + incr_type: HINCRBYFLOAT, + key: "key2", + field: "field1", + increment_float: 3.142, + want: 3.142, + wantErr: false, + }, + { + name: "Increment by integer on existing hash", + presetValue: hash.Hash{"field1": {Value: 1}}, + incr_type: HINCRBY, + key: "key3", + field: "field1", + increment_int: 10, + want: 11, + wantErr: false, + }, + { + name: "Increment by float on an existing hash", + presetValue: hash.Hash{"field1": {Value: 3.142}}, + incr_type: HINCRBYFLOAT, + key: "key4", + field: "field1", + increment_float: 3.142, + want: 6.284, + wantErr: false, + }, + { + name: "Error when trying to increment on a key that is not a hash", + presetValue: "Default value", + incr_type: HINCRBY, + key: "key9", + field: "field1", + increment_int: 3, + want: 0, + wantErr: true, + }, + { + name: "Error when trying to increment a hash field that is not a number", + presetValue: hash.Hash{"field1": {Value: "value1"}}, + incr_type: HINCRBY, + key: "key10", + field: "field1", + increment_int: 1, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + var got float64 + var err error + if tt.incr_type == HINCRBY { + got, err = server.HIncrBy(tt.key, tt.field, tt.increment_int) + if (err != nil) != tt.wantErr { + t.Errorf("HINCRBY() error = %v, wantErr %v", err, tt.wantErr) + return + } + } + if tt.incr_type == HINCRBYFLOAT { + got, err = server.HIncrByFloat(tt.key, tt.field, tt.increment_float) + if (err != nil) != tt.wantErr { + t.Errorf("HINCRBYFLOAT() error = %v, wantErr %v", err, tt.wantErr) + return + } + } + if got != tt.want { + t.Errorf("HINCRBY/HINCRBYFLOAT() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_HKEYS(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + want []string + wantErr bool + }{ + { + name: "Return an array containing all the keys of the hash", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 123456789}, + "field3": {Value: 3.142}, + }, + key: "key1", + want: []string{"field1", "field2", "field3"}, + wantErr: false, + }, + { + name: "Empty array response when trying to call HKEYS on non-existent key", + presetValue: hash.Hash{}, + key: "key2", + want: []string{}, + wantErr: false, + }, + { + name: "Trying to get lengths on a non hash map returns error", + presetValue: "Default value", + key: "key3", + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.HKeys(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("HKEYS() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("HKEYS() got = %v, want %v", got, tt.want) + } + for _, g := range got { + if !slices.Contains(tt.want, g) { + t.Errorf("HKEYS() got = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestSugarDB_HLEN(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + want int + wantErr bool + }{ + { + name: "Return the correct length of the hash", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 123456789}, + "field3": {Value: 3.142}, + }, + key: "key1", + want: 3, + wantErr: false, + }, + { + name: "0 Response when trying to call HLEN on non-existent key", + presetValue: nil, + key: "key2", + want: 0, + wantErr: false, + }, + { + name: "Trying to get lengths on a non hash map returns error", + presetValue: "Default value", + key: "key5", + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.HLen(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("HLEN() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("HLEN() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_HRANDFIELD(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + options HRandFieldOptions + wantCount int + want []string + wantErr bool + }{ + { + name: "Get a random field", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 123456789}, + "field3": {Value: 3.142}, + }, + key: "key1", + options: HRandFieldOptions{Count: 1}, + wantCount: 1, + want: []string{"field1", "field2", "field3"}, + wantErr: false, + }, + { + name: "Get a random field with a value", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 123456789}, + "field3": {Value: 3.142}, + }, + key: "key2", + options: HRandFieldOptions{WithValues: true, Count: 1}, + wantCount: 2, + want: []string{"field1", "value1", "field2", "123456789", "field3", "3.142"}, + wantErr: false, + }, + { + name: "Get several random fields", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 123456789}, + "field3": {Value: 3.142}, + "field4": {Value: "value4"}, + "field5": {Value: "value6"}, + }, + key: "key3", + options: HRandFieldOptions{Count: 3}, + wantCount: 3, + want: []string{"field1", "field2", "field3", "field4", "field5"}, + wantErr: false, + }, + { + name: "Get several random fields with their corresponding values", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 123456789}, + "field3": {Value: 3.142}, + "field4": {Value: "value4"}, + "field5": {Value: "value5"}, + }, + key: "key4", + options: HRandFieldOptions{WithValues: true, Count: 3}, + wantCount: 6, + want: []string{ + "field1", "value1", "field2", "123456789", "field3", + "3.142", "field4", "value4", "field5", "value5", + }, + wantErr: false, + }, + { + name: "Get the entire hash", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 123456789}, + "field3": {Value: 3.142}, + "field4": {Value: "value4"}, + "field5": {Value: "value5"}, + }, + key: "key5", + options: HRandFieldOptions{Count: 5}, + wantCount: 5, + want: []string{"field1", "field2", "field3", "field4", "field5"}, + wantErr: false, + }, + { + name: "Get the entire hash with values", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 123456789}, + "field3": {Value: 3.142}, + "field4": {Value: "value4"}, + "field5": {Value: "value5"}, + }, + key: "key5", + options: HRandFieldOptions{WithValues: true, Count: 5}, + wantCount: 10, + want: []string{ + "field1", "value1", "field2", "123456789", "field3", + "3.142", "field4", "value4", "field5", "value5", + }, + wantErr: false, + }, + { + name: "Trying to get random field on a non hash map returns error", + presetValue: "Default value", + key: "key12", + options: HRandFieldOptions{}, + wantCount: 0, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.HRandField(tt.key, tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("HRANDFIELD() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != tt.wantCount { + t.Errorf("HRANDFIELD() got = %v, want %v", got, tt.want) + } + for _, g := range got { + if !slices.Contains(tt.want, g) { + t.Errorf("HRANDFIELD() got = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestSugarDB_HSET(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + hsetFunc func(key string, pairs map[string]string) (int, error) + key string + fieldValuePairs map[string]string + want int + wantErr bool + }{ + { + name: "HSETNX set field on non-existent hash map", + key: "key1", + presetValue: nil, + hsetFunc: server.HSetNX, + fieldValuePairs: map[string]string{"field1": "value1"}, + want: 1, + wantErr: false, + }, + { + name: "HSETNX set field on existing hash map", + key: "key2", + presetValue: hash.Hash{"field1": {Value: "value1"}}, + hsetFunc: server.HSetNX, + fieldValuePairs: map[string]string{"field2": "value2"}, + want: 1, + wantErr: false, + }, + { + name: "HSETNX skips operation when setting on existing field", + key: "key3", + presetValue: hash.Hash{"field1": {Value: "value1"}}, + hsetFunc: server.HSetNX, + fieldValuePairs: map[string]string{"field1": "value1"}, + want: 0, + wantErr: false, + }, + { + name: "Regular HSET command on non-existent hash map", + key: "key4", + presetValue: nil, + fieldValuePairs: map[string]string{"field1": "value1", "field2": "value2"}, + hsetFunc: server.HSet, + want: 2, + wantErr: false, + }, + { + name: "Regular HSET update on existing hash map", + key: "key5", + presetValue: hash.Hash{"field1": {Value: "value1"}, "field2": {Value: "value2"}}, + fieldValuePairs: map[string]string{"field1": "value1-new", "field2": "value2-ne2", "field3": "value3"}, + hsetFunc: server.HSet, + want: 3, + wantErr: false, + }, + { + name: "HSET overwrites when the target key is not a map", + key: "key6", + presetValue: "Default preset value", + fieldValuePairs: map[string]string{"field1": "value1"}, + hsetFunc: server.HSet, + want: 1, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := tt.hsetFunc(tt.key, tt.fieldValuePairs) + if (err != nil) != tt.wantErr { + t.Errorf("HSET() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("HSET() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_HSTRLEN(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + fields []string + want []int + wantErr bool + }{ + { + // Return lengths of field values. + // If the key does not exist, its length should be 0. + name: "1. Return lengths of field values", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 123456789}, + "field3": {Value: 3.142}, + }, + key: "key1", + fields: []string{"field1", "field2", "field3", "field4"}, + want: []int{len("value1"), len("123456789"), len("3.142"), 0}, + wantErr: false, + }, + { + name: "2. Response when trying to get HSTRLEN non-existent key", + presetValue: hash.Hash{}, + key: "key2", + fields: []string{"field1"}, + want: []int{0}, + wantErr: false, + }, + { + name: "3. Command too short", + key: "key3", + presetValue: hash.Hash{}, + fields: []string{}, + want: nil, + wantErr: true, + }, + { + name: "4. Trying to get lengths on a non hash map returns error", + key: "key4", + presetValue: "Default value", + fields: []string{"field1"}, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Log(tt.name) + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.HStrLen(tt.key, tt.fields...) + if (err != nil) != tt.wantErr { + t.Errorf("HSTRLEN() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("HSTRLEN() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_HVALS(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + want []string + wantErr bool + }{ + { + name: "Return all the values from a hash", + key: "key1", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 123456789}, + "field3": {Value: 3.142}, + }, + want: []string{"value1", "123456789", "3.142"}, + wantErr: false, + }, + { + name: "Empty array response when trying to get HSTRLEN non-existent key", + key: "key2", + presetValue: nil, + want: []string{}, + wantErr: false, + }, + { + name: "Trying to get lengths on a non hash map returns error", + key: "key5", + presetValue: "Default value", + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.HVals(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("HVALS() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("HVALS() got = %v, want %v", got, tt.want) + } + for _, g := range got { + if !slices.Contains(tt.want, g) { + t.Errorf("HVALS() got = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestSugarDB_HGet(t *testing.T) { + server := createSugarDB() + tests := []struct { + name string + presetValue interface{} + key string + fields []string + want []string + wantErr bool + }{ + { + name: "1. Get values from existing hash.", + key: "HgetKey1", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 365}, + "field3": {Value: 3.142}, + }, + fields: []string{"field1", "field2", "field3", "field4"}, + want: []string{"value1", "365", "3.142", ""}, + wantErr: false, + }, + { + name: "2. Return empty slice when attempting to get from non-existed key", + presetValue: nil, + key: "HgetKey2", + fields: []string{"field1"}, + want: []string{}, + wantErr: false, + }, + { + name: "3. Error when trying to get from a value that is not a hash map", + presetValue: "Default Value", + key: "HgetKey3", + fields: []string{"field1"}, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.HGet(tt.key, tt.fields...) + if (err != nil) != tt.wantErr { + t.Errorf("HGet() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("HGet() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_HMGet(t *testing.T) { + server := createSugarDB() + tests := []struct { + name string + presetValue interface{} + key string + fields []string + want []string + wantErr bool + }{ + { + name: "1. Get values from existing hash.", + key: "HgetKey1", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 365}, + "field3": {Value: 3.142}, + }, + fields: []string{"field1", "field2", "field3", "field4"}, + want: []string{"value1", "365", "3.142", ""}, + wantErr: false, + }, + { + name: "2. Return empty slice when attempting to get from non-existed key", + presetValue: nil, + key: "HgetKey2", + fields: []string{"field1"}, + want: []string{}, + wantErr: false, + }, + { + name: "3. Error when trying to get from a value that is not a hash map", + presetValue: "Default Value", + key: "HgetKey3", + fields: []string{"field1"}, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.HGet(tt.key, tt.fields...) + if (err != nil) != tt.wantErr { + t.Errorf("HMGet() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("HMGet() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_HExpire(t *testing.T) { + server := createSugarDB() + tests := []struct { + name string + presetValue interface{} + key string + fields []string + expireOption ExpireOptions + want []int + wantErr bool + }{ + { + name: "1. Set Expiration from existing hash.", + key: "HExpireKey1", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 365}, + "field3": {Value: 3.142}, + }, + fields: []string{"field1", "field2", "field3"}, + want: []int{1, 1, 1}, + wantErr: false, + }, + { + name: "2. Return -2 when attempting to get from non-existed key", + presetValue: nil, + key: "HExpireKey2", + fields: []string{"field1"}, + want: []int{-2}, + wantErr: false, + }, + { + name: "3. Error when trying to get from a value that is not a hash map", + presetValue: "Default Value", + key: "HExpireKey3", + fields: []string{"field1"}, + want: nil, + wantErr: true, + }, + { + name: "4. Set Expiration with option NX.", + key: "HExpireKey4", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 365}, + "field3": {Value: 3.142}, + }, + fields: []string{"field1", "field2", "field3"}, + expireOption: NX, + want: []int{1, 1, 1}, + wantErr: false, + }, + { + name: "5. Set Expiration with option XX.", + key: "HExpireKey5", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 365}, + "field3": {Value: 3.142}, + }, + fields: []string{"field1", "field2", "field3"}, + expireOption: XX, + want: []int{0, 0, 0}, + wantErr: false, + }, + { + name: "6. Set Expiration with option GT.", + key: "HExpireKey6", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 365}, + "field3": {Value: 3.142}, + }, + fields: []string{"field1", "field2", "field3"}, + expireOption: GT, + want: []int{0, 0, 0}, + wantErr: false, + }, + { + name: "7. Set Expiration with option LT.", + key: "HExpireKey7", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 365}, + "field3": {Value: 3.142}, + }, + fields: []string{"field1", "field2", "field3"}, + expireOption: LT, + want: []int{1, 1, 1}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.HExpire(tt.key, 5, tt.expireOption, tt.fields...) + if (err != nil) != tt.wantErr { + t.Errorf("HExpire() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("HExpire() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_HTTL(t *testing.T) { + server := createSugarDB() + tests := []struct { + name string + presetValue interface{} + key string + fields []string + want []int + wantErr bool + }{ + { + name: "1. Get TTL for one field when expireTime is set.", + key: "HExpireKey1", + presetValue: hash.Hash{ + "field1": {Value: "value1", ExpireAt: server.clock.Now().Add(time.Duration(500) * time.Second)}, + }, + fields: []string{"field1"}, + want: []int{500}, + wantErr: false, + }, + { + name: "2. Get TTL for multiple fields when expireTime is set.", + presetValue: hash.Hash{ + "field1": {Value: "value1", ExpireAt: server.clock.Now().Add(time.Duration(500) * time.Second)}, + "field2": {Value: "value2", ExpireAt: server.clock.Now().Add(time.Duration(500) * time.Second)}, + "field3": {Value: "value3", ExpireAt: server.clock.Now().Add(time.Duration(500) * time.Second)}, + }, + key: "HExpireKey2", + fields: []string{"field1", "field2", "field3"}, + want: []int{500, 500, 500}, + wantErr: false, + }, + { + name: "3. Get TTL for one field when expireTime is not set.", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + }, + key: "HExpireKey3", + fields: []string{"field1"}, + want: []int{-1}, + wantErr: false, + }, + { + name: "4. Get TTL for multiple fields when expireTime is not set.", + key: "HExpireKey4", + presetValue: hash.Hash{ + "field1": {Value: "value1"}, + "field2": {Value: 365}, + "field3": {Value: 3.142}, + }, + fields: []string{"field1", "field2", "field3"}, + want: []int{-1, -1, -1}, + wantErr: false, + }, + { + name: "5. Try to get TTL for key that doesn't exist.", + key: "HExpireKey5", + presetValue: nil, + fields: []string{"field1"}, + want: []int{-2}, + wantErr: false, + }, + { + name: "6. Try to get TTL for key that isn't a hash.", + key: "HExpireKey6", + presetValue: "not a hash", + fields: []string{"field1", "field2", "field3"}, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.HTTL(tt.key, tt.fields...) + if (err != nil) != tt.wantErr { + t.Errorf("HExpire() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("HExpire() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/sugardb/api_list.go b/sugardb/api_list.go new file mode 100644 index 0000000..ba7cba5 --- /dev/null +++ b/sugardb/api_list.go @@ -0,0 +1,322 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "apigo.cc/go/sugardb/internal" + "strconv" + "strings" +) + +// LLen returns the length of the list. +// +// Parameters: +// +// `key` - string - the key to the list. +// +// Returns: The length of the list as an integer. Returns 0 if the key does not exist. +// +// Errors: +// +// "LLen command on non-list item" - when the provided key exists but is not a list. +func (server *SugarDB) LLen(key string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"LLEN", key}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// LRange returns the elements within the index range provided. +// +// Parameters: +// +// `key` - string - the key to the list. +// +// `start` - int - the start index. If start index is less than end index, the returned sub-list will be reversed. +// +// `end` - int - the end index. When -1 is passed for end index, the function will return the list from start +// index to the end of the list. +// +// Returns: A string slice containing the elements within the given indices. +// +// Errors: +// +// "LRange command on non-list item" - when the provided key exists but is not a list. +func (server *SugarDB) LRange(key string, start, end int) ([]string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"LRANGE", key, strconv.Itoa(start), strconv.Itoa(end)}), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// LIndex retrieves the element at the provided index from the list without removing it. +// +// Parameters: +// +// `key` - string - the key to the list. +// +// `index` - int - the index to retrieve from. +// +// Returns: The element at the given index as a string. +// +// Errors: +// +// "LIndex command on non-list item" - when the provided key exists but is not a list. +func (server *SugarDB) LIndex(key string, index uint) (string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"LINDEX", key, strconv.Itoa(int(index))}), nil, false, true) + if err != nil { + return "", err + } + return internal.ParseStringResponse(b) +} + +// LSet updates the value at the given index of a list. +// +// Parameters: +// +// `key` - string - the key to the list. +// +// `index` - int - the index to retrieve from. +// +// `value` - string - the new value to place at the given index. +// +// Returns: true if the update is successful. +// +// Errors: +// +// "LSet command on non-list item" - when the provided key exists but is not a list. +// +// "index must be within list range" - when the index is not within the list boundary. +func (server *SugarDB) LSet(key string, index int, value string) (bool, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"LSET", key, strconv.Itoa(index), value}), nil, false, true) + if err != nil { + return false, err + } + s, err := internal.ParseStringResponse(b) + return strings.EqualFold(s, "ok"), err +} + +// LTrim work similarly to LRange but instead of returning the new list, it replaces the original list with the +// trimmed list. +// +// Returns: true if the trim is successful. +func (server *SugarDB) LTrim(key string, start int, end int) (bool, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"LTRIM", key, strconv.Itoa(start), strconv.Itoa(end)}), nil, false, true) + if err != nil { + return false, err + } + s, err := internal.ParseStringResponse(b) + return strings.EqualFold(s, "ok"), err +} + +// LRem removes 'count' instances of the specified element from the list. +// +// Parameters: +// +// `key` - string - the key to the list. +// +// `count` - int - the number of instances of the element to remove. +// +// `value` - string - the element to remove. +// +// Returns: An integer representing the number of elements removed. +// +// Errors: +// +// "LRem command on non-list item" - when the provided key exists but is not a list. +func (server *SugarDB) LRem(key string, count int, value string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{ + "LREM", key, strconv.Itoa(count), value}), + nil, + false, + true, + ) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// LMove moves an element from one list to another. +// +// Parameters: +// +// `source` - string - the key to the source list. +// +// `destination` - string - the key to the destination list. +// +// `whereFrom` - string - either "LEFT" or "RIGHT". If "LEFT", the element is removed from the beginning of the source list. +// If "RIGHT", the element is removed from the end of the source list. +// +// `whereTo` - string - either "LEFT" or "RIGHT". If "LEFT", the element is added to the beginning of the destination list. +// If "RIGHT", the element is added to the end of the destination list. +// +// Returns: true if the removal was successful. +// +// Errors: +// +// "both source and destination must be lists" - when either source or destination are not lists. +// +// "wherefrom and whereto arguments must be either LEFT or RIGHT" - if whereFrom or whereTo are not either "LEFT" or "RIGHT". +func (server *SugarDB) LMove(source, destination, whereFrom, whereTo string) (bool, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"LMOVE", source, destination, whereFrom, whereTo}), nil, false, true) + if err != nil { + return false, err + } + s, err := internal.ParseStringResponse(b) + return strings.EqualFold(s, "ok"), err +} + +// LPop pops an element from the start of the list and return it. +// +// Parameters: +// +// `key` - string - the key to the list. +// +// Returns: A string slice containing the popped elements. +// +// Errors: +// +// "LPOP command on non-list item" - when the provided key is not a list. +func (server *SugarDB) LPop(key string, count uint) ([]string, error) { + b, err := server.handleCommand( + server.context, + internal.EncodeCommand([]string{"LPOP", key, strconv.Itoa(int(count))}), + nil, + false, + true, + ) + if err != nil { + return []string{}, err + } + return internal.ParseStringArrayResponse(b) +} + +// RPop pops an element from the end of the list and return it. +// +// Parameters: +// +// `key` - string - the key to the list. +// +// Returns: A string slice containing the popped elements. +// +// Errors: +// +// "RPOP command on non-list item" - when the provided key is not a list. +func (server *SugarDB) RPop(key string, count uint) ([]string, error) { + b, err := server.handleCommand( + server.context, + internal.EncodeCommand([]string{"RPOP", key, strconv.Itoa(int(count))}), + nil, + false, + true, + ) + if err != nil { + return []string{}, err + } + return internal.ParseStringArrayResponse(b) +} + +// LPush pushed 1 or more values to the beginning of a list. If the list does not exist, a new list is created +// wth the passed elements as its members. +// +// Parameters: +// +// `key` - string - the key to the list. +// +// `values` - ...string - the list of elements to add to push to the beginning of the list. +// +// Returns: An integer with the length of the new list. +// +// Errors: +// +// "LPush command on non-list item" - when the provided key is not a list. +func (server *SugarDB) LPush(key string, values ...string) (int, error) { + cmd := append([]string{"LPUSH", key}, values...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// LPushX pushed 1 or more values to the beginning of an existing list. The command only succeeds on a pre-existing list. +// +// Parameters: +// +// `key` - string - the key to the list. +// +// `values` - ...string - the list of elements to add to push to the beginning of the list. +// +// Returns: An integer with the length of the new list. +// +// Errors: +// +// "LPushX command on non-list item" - when the provided key is not a list or doesn't exist. +func (server *SugarDB) LPushX(key string, values ...string) (int, error) { + cmd := append([]string{"LPUSHX", key}, values...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// RPush pushed 1 or more values to the end of a list. If the list does not exist, a new list is created +// wth the passed elements as its members. +// +// Parameters: +// +// `key` - string - the key to the list. +// +// `values` - ...string - the list of elements to add to push to the end of the list. +// +// Returns: An integer with the length of the new list. +// +// Errors: +// +// "RPush command on non-list item" - when the provided key is not a list. +func (server *SugarDB) RPush(key string, values ...string) (int, error) { + cmd := append([]string{"RPUSH", key}, values...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// RPushX pushed 1 or more values to the end of an existing list. The command only succeeds on a pre-existing list. +// +// Parameters: +// +// `key` - string - the key to the list. +// +// `values` - ...string - the list of elements to add to push to the end of the list. +// +// Returns: An integer with the length of the new list. +// +// Errors: +// +// "RPushX command on non-list item" - when the provided key is not a list or doesn't exist. +func (server *SugarDB) RPushX(key string, values ...string) (int, error) { + cmd := append([]string{"RPUSHX", key}, values...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} diff --git a/sugardb/api_list_test.go b/sugardb/api_list_test.go new file mode 100644 index 0000000..d75d5ee --- /dev/null +++ b/sugardb/api_list_test.go @@ -0,0 +1,938 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "context" + "reflect" + "testing" +) + +func TestSugarDB_LLEN(t *testing.T) { + server := createSugarDB() + + tests := []struct { + preset bool + presetValue interface{} + name string + key string + want int + wantErr bool + }{ + { + name: "1. If key exists and is a list, return the lists length", + preset: true, + key: "key1", + presetValue: []string{"value1", "value2", "value3", "value4"}, + want: 4, + wantErr: false, + }, + { + name: "2. If key does not exist, return 0", + preset: false, + key: "key2", + presetValue: nil, + want: 0, + wantErr: false, + }, + { + preset: true, + key: "key5", + name: "3. Trying to get lengths on a non-list returns error", + presetValue: "Default value", + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.LLen(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("LLEN() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("LLEN() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_LINDEX(t *testing.T) { + server := createSugarDB() + + tests := []struct { + preset bool + presetValue interface{} + key string + index uint + name string + want string + wantErr bool + }{ + { + name: "1. Return last element within range", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4"}, + key: "key1", + index: 3, + want: "value4", + wantErr: false, + }, + { + name: "2. Return first element within range", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4"}, + key: "key2", + index: 0, + want: "value1", + wantErr: false, + }, + { + name: "3. Return middle element within range", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4"}, + key: "key3", + index: 1, + want: "value2", + wantErr: false, + }, + { + name: "4. If key does not exist, return error", + preset: false, + presetValue: nil, + key: "key4", + index: 0, + want: "", + wantErr: false, + }, + { + name: "5. Trying to get element by index on a non-list returns error", + preset: true, + presetValue: "Default value", + key: "key5", + index: 0, + want: "", + wantErr: true, + }, + { + name: "6. Trying to get index out of range index beyond last index", + preset: true, + presetValue: []string{"value1", "value2", "value3"}, + key: "key6", + index: 3, + want: "", + wantErr: false, + }, + } + for _, tt := range tests { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + t.Run(tt.name, func(t *testing.T) { + got, err := server.LIndex(tt.key, tt.index) + if (err != nil) != tt.wantErr { + t.Errorf("LINDEX() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("LINDEX() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_LMOVE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue map[string]interface{} + source string + destination string + whereFrom string + whereTo string + want bool + wantErr bool + }{ + { + name: "1. Move element from LEFT of left list to LEFT of right list", + preset: true, + presetValue: map[string]interface{}{ + "source1": []string{"one", "two", "three"}, + "destination1": []string{"one", "two", "three"}, + }, + source: "source1", + destination: "destination1", + whereFrom: "LEFT", + whereTo: "LEFT", + want: true, + wantErr: false, + }, + { + name: "2. Move element from LEFT of left list to RIGHT of right list", + preset: true, + presetValue: map[string]interface{}{ + "source2": []string{"one", "two", "three"}, + "destination2": []string{"one", "two", "three"}, + }, + source: "source2", + destination: "destination2", + whereFrom: "LEFT", + whereTo: "RIGHT", + want: true, + wantErr: false, + }, + { + name: "3. Move element from RIGHT of left list to LEFT of right list", + preset: true, + presetValue: map[string]interface{}{ + "source3": []string{"one", "two", "three"}, + "destination3": []string{"one", "two", "three"}, + }, + source: "source3", + destination: "destination3", + whereFrom: "RIGHT", + whereTo: "LEFT", + want: true, + wantErr: false, + }, + { + name: "4. Move element from RIGHT of left list to RIGHT of right list", + preset: true, + presetValue: map[string]interface{}{ + "source4": []string{"one", "two", "three"}, + "destination4": []string{"one", "two", "three"}, + }, + source: "source4", + destination: "destination4", + whereFrom: "RIGHT", + whereTo: "RIGHT", + want: true, + wantErr: false, + }, + { + name: "5. Throw error when the right list is non-existent", + preset: true, + presetValue: map[string]interface{}{ + "source5": []string{"one", "two", "three"}, + }, + source: "source5", + destination: "destination5", + whereFrom: "LEFT", + whereTo: "LEFT", + want: false, + wantErr: true, + }, + { + name: "6. Throw error when right list in not a list", + preset: true, + presetValue: map[string]interface{}{ + "source6": []string{"one", "two", "tree"}, + "destination6": "Default value", + }, + source: "source6", + destination: "destination6", + whereFrom: "LEFT", + whereTo: "LEFT", + want: false, + wantErr: true, + }, + { + name: "7. Throw error when left list is non-existent", + preset: true, + presetValue: map[string]interface{}{ + "destination7": []string{"one", "two", "three"}, + }, + source: "source7", + destination: "destination7", + whereFrom: "LEFT", + whereTo: "LEFT", + want: false, + wantErr: true, + }, + { + name: "8. Throw error when left list is not a list", + preset: true, + presetValue: map[string]interface{}{ + "source8": "Default value", + "destination8": []string{"one", "two", "three"}, + }, + source: "source8", + destination: "destination8", + whereFrom: "LEFT", + whereTo: "LEFT", + want: false, + wantErr: true, + }, + { + name: "9. Throw error when WHEREFROM argument is not LEFT/RIGHT", + preset: false, + presetValue: map[string]interface{}{}, + source: "source9", + destination: "destination9", + whereFrom: "LEFT", + whereTo: "LEFT", + want: false, + wantErr: true, + }, + { + name: "10. Throw error when WHERETO argument is not LEFT/RIGHT", + preset: false, + presetValue: map[string]interface{}{}, + source: "source10", + destination: "destination10", + whereFrom: "LEFT", + whereTo: "LEFT", + want: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + for k, v := range tt.presetValue { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.LMove(tt.source, tt.destination, tt.whereFrom, tt.whereTo) + if (err != nil) != tt.wantErr { + t.Errorf("LMOVE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("LMOVE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_POP(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + count uint + popFunc func(key string, count uint) ([]string, error) + want []string + wantErr bool + }{ + { + name: "1. LPOP returns last element and removed first element from the list", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4"}, + key: "key1", + count: 1, + popFunc: server.LPop, + want: []string{"value1"}, + wantErr: false, + }, + { + name: "2. RPOP returns last element and removed last element from the list", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4"}, + key: "key2", + count: 1, + popFunc: server.RPop, + want: []string{"value4"}, + wantErr: false, + }, + { + name: "3. Trying to execute LPOP from a non-list item return an error", + preset: true, + key: "key3", + count: 1, + presetValue: "Default value", + popFunc: server.LPop, + want: []string{}, + wantErr: true, + }, + { + name: "4. Trying to execute RPOP from a non-list item return an error", + preset: true, + presetValue: "Default value", + key: "key6", + count: 1, + popFunc: server.RPop, + want: []string{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := tt.popFunc(tt.key, tt.count) + if (err != nil) != tt.wantErr { + t.Errorf("POP() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("POP() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_LPUSH(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + key string + values []string + presetValue interface{} + lpushFunc func(key string, values ...string) (int, error) + want int + wantErr bool + }{ + { + name: "1. LPUSHX to existing list prepends the element to the list", + preset: true, + presetValue: []string{"1", "2", "4", "5"}, + key: "key1", + values: []string{"value1", "value2"}, + lpushFunc: server.LPushX, + want: 6, + wantErr: false, + }, + { + name: "2. LPUSH on existing list prepends the elements to the list", + preset: true, + presetValue: []string{"1", "2", "4", "5"}, + key: "key2", + values: []string{"value1", "value2"}, + lpushFunc: server.LPush, + want: 6, + wantErr: false, + }, + { + name: "3. LPUSH on non-existent list creates the list", + preset: false, + presetValue: nil, + key: "key3", + values: []string{"value1", "value2"}, + lpushFunc: server.LPush, + want: 2, + wantErr: false, + }, + { + name: "4. LPUSHX command returns error on non-existent list", + preset: false, + presetValue: nil, + key: "key4", + values: []string{"value1", "value2"}, + lpushFunc: server.LPushX, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := tt.lpushFunc(tt.key, tt.values...) + if (err != nil) != tt.wantErr { + t.Errorf("LPUSH() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("LPUSH() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_RPUSH(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + key string + values []string + presetValue interface{} + rpushFunc func(key string, values ...string) (int, error) + want int + wantErr bool + }{ + { + name: "1. RPUSH on non-existent list creates the list", + preset: false, + presetValue: nil, + key: "key1", + values: []string{"value1", "value2"}, + rpushFunc: server.RPush, + want: 2, + wantErr: false, + }, + { + name: "2. RPUSHX command returns error on non-existent list", + preset: false, + presetValue: nil, + key: "key2", + values: []string{"value1", "value2"}, + rpushFunc: server.RPushX, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := tt.rpushFunc(tt.key, tt.values...) + if (err != nil) != tt.wantErr { + t.Errorf("RPUSH() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("RPUSH() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_LRANGE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + start int + end int + want []string + wantErr bool + }{ + { + // Return sub-list within range. + // Both start and end indices are positive. + // End index is greater than start index. + name: "1. Return sub-list within range.", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8"}, + key: "key1", + start: 3, + end: 6, + want: []string{"value4", "value5", "value6", "value7"}, + wantErr: false, + }, + { + name: "2. Return sub-list from start index to the end of the list when end index is -1", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8"}, + key: "key2", + start: 3, + end: -1, + want: []string{"value4", "value5", "value6", "value7", "value8"}, + wantErr: false, + }, + { + name: "3. Return empty list when the end index is less than start index", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8"}, + key: "key3", + start: 3, + end: 0, + want: []string{}, + wantErr: false, + }, + { + name: "4. If key does not exist, return empty list", + preset: false, + presetValue: nil, + key: "key4", + start: 0, + end: 2, + want: []string{}, + wantErr: false, + }, + + { + name: "5. Error when executing command on non-list command", + preset: true, + presetValue: "Default value", + key: "key5", + start: 0, + end: 3, + want: nil, + wantErr: true, + }, + { + name: "6. Start index calculated from end of list when start index is less than 0", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4"}, + key: "key6", + start: -3, + end: 3, + want: []string{"value2", "value3", "value4"}, + wantErr: false, + }, + { + name: "7. Empty list when start index is higher than the length of the list", + preset: true, + presetValue: []string{"value1", "value2", "value3"}, + key: "key7", + start: 10, + end: 11, + want: []string{}, + wantErr: false, + }, + { + name: "8. One element when start and end indices are equal", + preset: true, + presetValue: []string{"value1", "value2", "value3"}, + key: "key8", + start: 1, + end: 1, + want: []string{"value2"}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.LRange(tt.key, tt.start, tt.end) + if (err != nil) != tt.wantErr { + t.Errorf("LRANGE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("LRANGE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_LREM(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + count int + value string + want int + wantErr bool + }{ + { + name: "1. Remove the first 3 elements that appear in the list", + preset: true, + presetValue: []string{"1", "2", "4", "4", "5", "6", "7", "4", "8", "4", "9", "10", "5", "4"}, + key: "key1", + count: 3, + value: "4", + want: 3, + wantErr: false, + }, + { + name: "2. Remove the last 3 elements that appear in the list", + preset: true, + presetValue: []string{"1", "2", "4", "4", "5", "6", "7", "4", "8", "4", "9", "10", "5", "4"}, + key: "key2", + count: -3, + value: "4", + want: 3, + wantErr: false, + }, + { + name: "3. Throw error on non-list item", + preset: true, + presetValue: "Default value", + key: "LremKey8", + count: 0, + value: "value1", + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + t.Run(tt.name, func(t *testing.T) { + got, err := server.LRem(tt.key, tt.count, tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("LREM() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("LREM() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_LSET(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + index int + value string + want bool + wantErr bool + }{ + { + name: "1. Return last element within range", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4"}, + key: "key1", + index: 3, + value: "new-value", + want: true, + wantErr: false, + }, + { + name: "2. Return first element within range", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4"}, + key: "key2", + index: 0, + value: "new-value", + want: true, + wantErr: false, + }, + { + name: "3. Return middle element within range", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4"}, + key: "key3", + index: 1, + value: "new-value", + want: true, + wantErr: false, + }, + { + name: "4. If key does not exist, return error", + preset: false, + presetValue: nil, + key: "key4", + index: 0, + value: "element", + want: false, + wantErr: true, + }, + { + name: "5. Trying to get element by index on a non-list returns error", + preset: true, + presetValue: "Default value", + key: "key5", + index: 0, + value: "element", + want: false, + wantErr: true, + }, + { + name: "6. Trying to get index out of range index beyond last index", + preset: true, + presetValue: []string{"value1", "value2", "value3"}, + key: "key6", + index: 3, + value: "element", + want: false, + wantErr: true, + }, + { + name: "7. Trying to get index out of range with negative index", + preset: true, + presetValue: []string{"value1", "value2", "value3"}, + key: "key7", + index: -4, + value: "element", + want: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.LSet(tt.key, tt.index, tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("LSET() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("LSET() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_LTRIM(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + start int + end int + want bool + wantErr bool + }{ + { + // Return trim within range. + // Both start and end indices are positive. + // End index is greater than start index. + name: "1. Return trim within range", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8"}, + key: "key1", + start: 3, + end: 6, + want: true, + wantErr: false, + }, + { + name: "2. Return element from start index to end index when end index is greater than length of the list", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8"}, + key: "key2", + start: 5, + end: -1, + want: true, + wantErr: false, + }, + { + name: "3. Return false when end index is smaller than start index.", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4"}, + key: "key3", + start: 3, + end: 1, + want: true, + wantErr: false, + }, + { + name: "4. If key does not exist, return true", + preset: false, + presetValue: nil, + key: "key4", + start: 0, + end: 2, + want: true, + wantErr: false, + }, + { + name: "5. Trying to get element by index on a non-list returns error", + preset: true, + presetValue: "Default value", + key: "key5", + start: 0, + end: 3, + want: false, + wantErr: true, + }, + { + name: "6. Trim from the end when start index is less than 0", + preset: true, + presetValue: []string{"value1", "value2", "value3", "value4"}, + key: "key6", + start: -3, + end: 3, + want: true, + wantErr: false, + }, + { + name: "7. Return true when start index is higher than the length of the list", + preset: true, + presetValue: []string{"value1", "value2", "value3"}, + key: "key7", + start: 10, + end: 11, + want: true, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.LTrim(tt.key, tt.start, tt.end) + if (err != nil) != tt.wantErr { + t.Errorf("LTRIM() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("LTRIM() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/sugardb/api_pubsub.go b/sugardb/api_pubsub.go new file mode 100644 index 0000000..2a04973 --- /dev/null +++ b/sugardb/api_pubsub.go @@ -0,0 +1,253 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "bytes" + "errors" + "apigo.cc/go/sugardb/internal" + "github.com/tidwall/resp" + "net" + "strings" + "sync" +) + +type conn struct { + readConn *net.Conn + writeConn *net.Conn +} + +var connections sync.Map + +// ReadPubSubMessage is returned by the Subscribe and PSubscribe functions. +// +// This function is lazy, therefore it needs to be invoked in order to read the next message. +// When the message is read, the function returns a string slice with 3 elements. +// Index 0 holds the event type which in this case will be "message". Index 1 holds the channel name. +// Index 2 holds the actual message. +type ReadPubSubMessage func() []string + +func establishConnections(tag string) (*net.Conn, *net.Conn, error) { + var readConn *net.Conn + var writeConn *net.Conn + + if _, ok := connections.Load(tag); !ok { + // If connection with this name does not exist, create new connection. + rc, wc := net.Pipe() + readConn = &rc + writeConn = &wc + connections.Store(tag, conn{ + readConn: &rc, + writeConn: &wc, + }) + } else { + // Reuse existing connection. + c, ok := connections.Load(tag) + if !ok { + return nil, nil, errors.New("could not establish connection") + } + readConn = c.(conn).readConn + writeConn = c.(conn).writeConn + } + + return readConn, writeConn, nil +} + +// Subscribe subscribes the caller to the list of provided channels. +// +// Parameters: +// +// `tag` - string - The tag used to identify this subscription instance. +// +// `channels` - ...string - The list of channels to subscribe to. +// +// Returns: ReadPubSubMessage function which reads the next message sent to the subscription instance. +// This function is blocking. +func (server *SugarDB) Subscribe(tag string, channels ...string) (ReadPubSubMessage, error) { + readConn, writeConn, err := establishConnections(tag) + if err != nil { + return func() []string { + return []string{} + }, err + } + + // Subscribe connection to the provided channels. + cmd := append([]string{"SUBSCRIBE"}, channels...) + go func() { + _, _ = server.handleCommand(server.context, internal.EncodeCommand(cmd), writeConn, false, true) + }() + + return func() []string { + r := resp.NewConn(*readConn) + v, _, _ := r.ReadValue() + + res := make([]string, len(v.Array())) + for i := 0; i < len(res); i++ { + res[i] = v.Array()[i].String() + } + + return res + }, nil +} + +// Unsubscribe unsubscribes the caller from the given channels. +// +// Parameters: +// +// `tag` - string - The tag used to identify this subscription instance. +// +// `channels` - ...string - The list of channels to unsubscribe from. +func (server *SugarDB) Unsubscribe(tag string, channels ...string) { + c, ok := connections.Load(tag) + if !ok { + return + } + cmd := append([]string{"UNSUBSCRIBE"}, channels...) + _, _ = server.handleCommand(server.context, internal.EncodeCommand(cmd), c.(conn).writeConn, false, true) +} + +// PSubscribe subscribes the caller to the list of provided glob patterns. +// +// Parameters: +// +// `tag` - string - The tag used to identify this subscription instance. +// +// `patterns` - ...string - The list of glob patterns to subscribe to. +// +// Returns: ReadPubSubMessage function which reads the next message sent to the subscription instance. +// This function is blocking. +func (server *SugarDB) PSubscribe(tag string, patterns ...string) (ReadPubSubMessage, error) { + readConn, writeConn, err := establishConnections(tag) + if err != nil { + return func() []string { + return []string{} + }, err + } + + // Subscribe connection to the provided channels + cmd := append([]string{"PSUBSCRIBE"}, patterns...) + go func() { + _, _ = server.handleCommand(server.context, internal.EncodeCommand(cmd), writeConn, false, true) + }() + + return func() []string { + r := resp.NewConn(*readConn) + v, _, _ := r.ReadValue() + + res := make([]string, len(v.Array())) + for i := 0; i < len(res); i++ { + res[i] = v.Array()[i].String() + } + + return res + }, nil +} + +// PUnsubscribe unsubscribes the caller from the given glob patterns. +// +// Parameters: +// +// `tag` - string - The tag used to identify this subscription instance. +// +// `patterns` - ...string - The list of glob patterns to unsubscribe from. +func (server *SugarDB) PUnsubscribe(tag string, patterns ...string) { + c, ok := connections.Load(tag) + if !ok { + return + } + cmd := append([]string{"PUNSUBSCRIBE"}, patterns...) + _, _ = server.handleCommand(server.context, internal.EncodeCommand(cmd), c.(conn).writeConn, false, true) +} + +// Publish publishes a message to the given channel. +// +// Parameters: +// +// `channel` - string - The channel to publish the message to. +// +// `message` - string - The message to publish to the specified channel. +// +// Returns: true when the publish is successful. This does not indicate whether each subscriber has received the message, +// only that the message has been published. +func (server *SugarDB) Publish(channel, message string) (bool, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"PUBLISH", channel, message}), nil, false, true) + if err != nil { + return false, err + } + s, err := internal.ParseStringResponse(b) + return strings.EqualFold(s, "ok"), err +} + +// PubSubChannels returns the list of channels & patterns that match the glob pattern provided. +// +// Parameters: +// +// `pattern` - string - The glob pattern used to match the channel names. +// +// Returns: A string slice of all the active channels and patterns (i.e. channels and patterns that have 1 or more subscribers). +func (server *SugarDB) PubSubChannels(pattern string) ([]string, error) { + cmd := []string{"PUBSUB", "CHANNELS"} + if pattern != "" { + cmd = append(cmd, pattern) + } + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// PubSubNumPat returns the list of active patterns. +// +// Returns: An integer representing the number of all the active patterns (i.e. patterns that have 1 or more subscribers). +func (server *SugarDB) PubSubNumPat() (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"PUBSUB", "NUMPAT"}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// PubSubNumSub returns the number of subscribers for each of the specified channels. +// +// Parameters: +// +// `channels` - ...string - The list of channels whose number of subscribers is to be checked. +// +// Returns: A map of map[string]int where the key is the channel name and the value is the number of subscribers. +func (server *SugarDB) PubSubNumSub(channels ...string) (map[string]int, error) { + cmd := append([]string{"PUBSUB", "NUMSUB"}, channels...) + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + + r := resp.NewReader(bytes.NewReader(b)) + v, _, err := r.ReadValue() + if err != nil { + return nil, err + } + + arr := v.Array() + + result := make(map[string]int, len(arr)) + for _, entry := range arr { + e := entry.Array() + result[e[0].String()] = e[1].Integer() + } + + return result, nil +} diff --git a/sugardb/api_pubsub_test.go b/sugardb/api_pubsub_test.go new file mode 100644 index 0000000..10abf67 --- /dev/null +++ b/sugardb/api_pubsub_test.go @@ -0,0 +1,290 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "fmt" + "reflect" + "slices" + "testing" +) + +func Test_Subscribe(t *testing.T) { + server := createSugarDB() + + // Subscribe to channels. + tag := "tag" + channels := []string{"channel1", "channel2"} + readMessage, err := server.Subscribe(tag, channels...) + if err != nil { + t.Errorf("SUBSCRIBE() error = %v", err) + } + + for i := 0; i < len(channels); i++ { + message := readMessage() + // Check that we've received the subscribe messages. + if message[0] != "subscribe" { + t.Errorf("SUBSCRIBE() expected index 0 for message at %d to be \"subscribe\", got %s", i, message[0]) + } + if !slices.Contains(channels, message[1]) { + t.Errorf("SUBSCRIBE() unexpected string \"%s\" at index 1 for message %d", message[1], i) + } + } + + // Publish some messages to the channels. + for _, channel := range channels { + ok, err := server.Publish(channel, fmt.Sprintf("message for %s", channel)) + if err != nil { + t.Errorf("PUBLISH() err = %v", err) + } + if !ok { + t.Errorf("PUBLISH() could not publish message to channel %s", channel) + } + } + + // Read messages from the channels + for i := 0; i < len(channels); i++ { + message := readMessage() + // Check that we've received the messages. + if message[0] != "message" { + t.Errorf("SUBSCRIBE() expected index 0 for message at %d to be \"message\", got %s", i, message[0]) + } + if !slices.Contains(channels, message[1]) { + t.Errorf("SUBSCRIBE() unexpected string \"%s\" at index 1 for message %d", message[1], i) + } + if !slices.Contains([]string{"message for channel1", "message for channel2"}, message[2]) { + t.Errorf("SUBSCRIBE() unexpected string \"%s\" at index 1 for message %d", message[1], i) + } + } + + // Unsubscribe from channels + server.Unsubscribe(tag, channels...) +} + +func TestSugarDB_PSubscribe(t *testing.T) { + server := createSugarDB() + + // Subscribe to channels. + tag := "tag" + patterns := []string{"channel[12]", "pattern[12]"} + readMessage, err := server.PSubscribe(tag, patterns...) + if err != nil { + t.Errorf("PSubscribe() error = %v", err) + } + + for i := 0; i < len(patterns); i++ { + message := readMessage() + // Check that we've received the subscribe messages. + if message[0] != "psubscribe" { + t.Errorf("PSUBSCRIBE() expected index 0 for message at %d to be \"psubscribe\", got %s", i, message[0]) + } + if !slices.Contains(patterns, message[1]) { + t.Errorf("PSUBSCRIBE() unexpected string \"%s\" at index 1 for message %d", message[1], i) + } + } + + // Publish some messages to the channels. + for _, channel := range []string{"channel1", "channel2", "pattern1", "pattern2"} { + ok, err := server.Publish(channel, fmt.Sprintf("message for %s", channel)) + if err != nil { + t.Errorf("PUBLISH() err = %v", err) + } + if !ok { + t.Errorf("PUBLISH() could not publish message to channel %s", channel) + } + } + + // Read messages from the channels + for i := 0; i < len(patterns)*2; i++ { + message := readMessage() + // Check that we've received the messages. + if message[0] != "message" { + t.Errorf("SUBSCRIBE() expected index 0 for message at %d to be \"message\", got %s", i, message[0]) + } + if !slices.Contains(patterns, message[1]) { + t.Errorf("SUBSCRIBE() unexpected string \"%s\" at index 1 for message %d", message[1], i) + } + if !slices.Contains([]string{ + "message for channel1", "message for channel2", "message for pattern1", "message for pattern2"}, message[2]) { + t.Errorf("SUBSCRIBE() unexpected string \"%s\" at index 1 for message %d", message[2], i) + } + } + + // Unsubscribe from channels + server.PUnsubscribe(tag, patterns...) +} + +func TestSugarDB_PubSubChannels(t *testing.T) { + server := createSugarDB() + tests := []struct { + name string + tag string + channels []string + patterns []string + pattern string + want []string + wantErr bool + }{ + { + name: "1. Get number of active channels", + tag: "tag", + channels: []string{"channel1", "channel2", "channel3", "channel4"}, + patterns: []string{"channel[56]"}, + pattern: "channel[123456]", + want: []string{"channel1", "channel2", "channel3", "channel4"}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Subscribe to channels + readChannelMessages, err := server.Subscribe(tt.tag, tt.channels...) + if err != nil { + t.Errorf("PubSubChannels() error = %v", err) + } + + for i := 0; i < len(tt.channels); i++ { + readChannelMessages() + } + // Subscribe to patterns + readPatternMessages, err := server.PSubscribe(tt.tag, tt.patterns...) + if err != nil { + t.Errorf("PubSubChannels() error = %v", err) + } + + for i := 0; i < len(tt.patterns); i++ { + readPatternMessages() + } + got, err := server.PubSubChannels(tt.pattern) + if (err != nil) != tt.wantErr { + t.Errorf("PubSubChannels() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("PubSubChannels() got response length %d, want %d", len(got), len(tt.want)) + } + for _, item := range got { + if !slices.Contains(tt.want, item) { + t.Errorf("PubSubChannels() unexpected item \"%s\", in response", item) + } + } + }) + } +} + +func TestSugarDB_PubSubNumPat(t *testing.T) { + server := createSugarDB() + tests := []struct { + name string + tag string + patterns []string + want int + wantErr bool + }{ + { + name: "1. Get number of active patterns on the server", + tag: "tag", + patterns: []string{"channel[56]", "channel[78]"}, + want: 2, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Subscribe to patterns + readPatternMessages, err := server.PSubscribe(tt.tag, tt.patterns...) + if err != nil { + t.Errorf("PubSubNumPat() error = %v", err) + } + for i := 0; i < len(tt.patterns); i++ { + readPatternMessages() + } + got, err := server.PubSubNumPat() + if (err != nil) != tt.wantErr { + t.Errorf("PubSubNumPat() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("PubSubNumPat() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_PubSubNumSub(t *testing.T) { + server := createSugarDB() + tests := []struct { + name string + subscriptions map[string]struct { + channels []string + patterns []string + } + channels []string + want map[string]int + wantErr bool + }{ + { + name: "Get number of subscriptions for the given channels", + subscriptions: map[string]struct { + channels []string + patterns []string + }{ + "tag1": { + channels: []string{"channel1", "channel2"}, + patterns: []string{"channel[34]"}, + }, + "tag2": { + channels: []string{"channel2", "channel3"}, + patterns: []string{"channel[23]"}, + }, + "tag3": { + channels: []string{"channel2", "channel4"}, + patterns: []string{}, + }, + }, + channels: []string{"channel1", "channel2", "channel3", "channel4", "channel5"}, + want: map[string]int{"channel1": 1, "channel2": 3, "channel3": 1, "channel4": 1, "channel5": 0}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for tag, subs := range tt.subscriptions { + readPat, err := server.PSubscribe(tag, subs.patterns...) + if err != nil { + t.Errorf("PubSubNumSub() error = %v", err) + } + for _, _ = range subs.patterns { + readPat() + } + readChan, err := server.Subscribe(tag, subs.channels...) + if err != nil { + t.Errorf("PubSubNumSub() error = %v", err) + } + for _, _ = range subs.channels { + readChan() + } + } + got, err := server.PubSubNumSub(tt.channels...) + if (err != nil) != tt.wantErr { + t.Errorf("PubSubNumSub() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("PubSubNumSub() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/sugardb/api_set.go b/sugardb/api_set.go new file mode 100644 index 0000000..2a633bc --- /dev/null +++ b/sugardb/api_set.go @@ -0,0 +1,348 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "apigo.cc/go/sugardb/internal" + "strconv" +) + +// SAdd adds member(s) to a set. If the set does not exist, a new sorted set is created with the +// member(s). +// +// Parameters: +// +// `key` - string - the key to update. +// +// `members` - ...string - a list of members to add to the set. +// +// Returns: The number of members added. +// +// Errors: +// +// "value at is not a set" - when the provided key exists but is not a set. +func (server *SugarDB) SAdd(key string, members ...string) (int, error) { + cmd := append([]string{"SADD", key}, members...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// SCard Returns the cardinality of the set. +// +// Parameters: +// +// `key` - string - the key to update. +// +// Returns: The cardinality of a set. Returns 0 if the key does not exist. +// +// Errors: +// +// "value at is not a set" - when the provided key exists but is not a set. +func (server *SugarDB) SCard(key string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"SCARD", key}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// SDiff Calculates the difference between the provided sets. Keys that don't exist or that are not sets +// will be skipped. +// +// Parameters: +// +// `keys` - ...string - the keys of the sets from which to calculate the difference. +// +// Returns: A string slice representing the elements resulting from calculating the difference. +// +// Errors: +// +// "value at is not a set" - when the provided key exists but is not a set. +// +// "key for base set does not exist" - if the first key is not a set. +func (server *SugarDB) SDiff(keys ...string) ([]string, error) { + cmd := append([]string{"SDIFF"}, keys...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// SDiffStore works like SDiff but instead of returning the resulting set elements, the resulting set is stored +// at the 'destination' key. +// +// Returns: an integer representing the cardinality of the new set. +func (server *SugarDB) SDiffStore(destination string, keys ...string) (int, error) { + cmd := append([]string{"SDIFFSTORE", destination}, keys...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// SInter Calculates the intersection between the provided sets. If any of the keys does not exist, +// then there is no intersection. +// +// Parameters: +// +// `keys` - ...string - the keys of the sets from which to calculate the intersection. +// +// Returns: A string slice representing the elements resulting from calculating the intersection. +// +// Errors: +// +// "value at is not a set" - when the provided key exists but is not a set. +// +// "not enough sets in the keys provided" - when only one of the provided keys is a valid set. +func (server *SugarDB) SInter(keys ...string) ([]string, error) { + cmd := append([]string{"SINTER"}, keys...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// SInterCard Calculates the cardinality of the intersection between the sets provided. +// +// Parameters: +// +// `keys` - []string - The keys of the sets from which to calculate the intersection. +// +// `limit` - int - When limit is > 0, the intersection calculation will be terminated as soon as the limit is reached. +// +// Returns: The cardinality of the calculated intersection. +// +// Errors: +// +// "value at is not a set" - when the provided key exists but is not a set. +// +// "not enough sets in the keys provided" - when only one of the provided keys is a valid set. +func (server *SugarDB) SInterCard(keys []string, limit uint) (int, error) { + cmd := append([]string{"SINTERCARD"}, keys...) + if limit > 0 { + cmd = append(cmd, []string{"LIMIT", strconv.Itoa(int(limit))}...) + } + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// SInterStore works the same as SInter but instead of returning the elements in the resulting set, it is stored +// at the 'destination' key and the cardinality of the resulting set is returned. +func (server *SugarDB) SInterStore(destination string, keys ...string) (int, error) { + cmd := append([]string{"SINTERSTORE", destination}, keys...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// SisMember Returns if member is contained in the set. +// +// Parameters: +// +// `key` - string - The key of the set. +// +// `member` - string - The member whose membership status will be checked. +// +// Returns: true if the member exists in the set, false otherwise. +// +// Errors: +// +// "value at is not a set" - when the provided key exists but is not a set. +func (server *SugarDB) SisMember(key, member string) (bool, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"SISMEMBER", key, member}), nil, false, true) + if err != nil { + return false, err + } + return internal.ParseBooleanResponse(b) +} + +// SMembers Returns all the members of the specified set. +// +// Parameters: +// +// `key` - string - The key of the set. +// +// Returns: A string slice of all the members in the set. +// +// Errors: +// +// "value at is not a set" - when the provided key exists but is not a set. +func (server *SugarDB) SMembers(key string) ([]string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"SMEMBERS", key}), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// SMisMember Returns the membership status of all the specified members. +// +// Parameters: +// +// `key` - string - The key of the set. +// +// `members` - ...string - The members whose membership in the set will be checked. +// +// Returns: A boolean slices with true/false based on whether the member in the corresponding index is +// present in the set. +// +// Errors: +// +// "value at is not a set" - when the provided key exists but is not a set. +func (server *SugarDB) SMisMember(key string, members ...string) ([]bool, error) { + cmd := append([]string{"SMISMEMBER", key}, members...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseBooleanArrayResponse(b) +} + +// SMove Move the specified member from 'source' set to 'destination' set. +// +// Parameters: +// +// `source` - string - The key of the set to remove the element from. +// +// `destination` - string - The key of the set to move the element to. +// +// `member` - string - The member to move from the source set to destination set. +// +// Returns: true if the member was successfully moved, false otherwise. +// +// Errors: +// +// "value at is not a set" - when the provided key exists but is not a set. +// +// "source is not a set" - when the source key does not hold a set. +// +// "destination is not a set" - when the destination key does not hold a set. +func (server *SugarDB) SMove(source, destination, member string) (bool, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"SMOVE", source, destination, member}), nil, false, true) + if err != nil { + return false, err + } + return internal.ParseBooleanResponse(b) +} + +// SPop Pop one or more elements from the set. +// +// Parameters: +// +// `key` - string - The key of the set. +// +// `count` - uint - number of elements to pop. +// +// Returns: A string slice containing all the popped elements. If the key does not exist, an empty array is returned. +// +// Errors: +// +// "value at is not a set" - when the provided key exists but is not a set. +func (server *SugarDB) SPop(key string, count uint) ([]string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"SPOP", key, strconv.Itoa(int(count))}), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// SRandMember Returns one or more random members from the set without removing them. +// +// Parameters: +// +// `key` - string - The key of the set. +// +// `count` - int - number of elements to return. If count is negative, repeated elements are allowed. +// If the count is positive, all returned elements will be distinct. +// +// Returns: A string slice containing the random elements. If the key does not exist, an empty array is returned. +// +// Errors: +// +// "value at is not a set" - when the provided key exists but is not a set. +func (server *SugarDB) SRandMember(key string, count int) ([]string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"SRANDMEMBER", key, strconv.Itoa(count)}), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// SRem Remove one or more members from a set. +// +// Parameters: +// +// `key` - string - The key of the set. +// +// `members` - ...string - List of members to remove. If the key does not exist, 0 is returned. +// +// Returns: The number of elements successfully removed. +// +// Errors: +// +// "value at is not a set" - when the provided key exists but is not a set. +func (server *SugarDB) SRem(key string, members ...string) (int, error) { + cmd := append([]string{"SREM", key}, members...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// SUnion Calculates the union between the provided sets. Keys that don't exist or that are not sets +// will be skipped. +// +// Parameters: +// +// `keys` - ...string - the keys of the sets from which to calculate the union. +// +// Returns: A string slice representing the elements resulting from calculating the union. +// +// Errors: +// +// "value at is not a set" - when the provided key exists but is not a set. +func (server *SugarDB) SUnion(keys ...string) ([]string, error) { + cmd := append([]string{"SUNION"}, keys...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseStringArrayResponse(b) +} + +// SUnionStore store works like SUnion but instead of returning the resulting elements, it stores the resulting +// set at the 'destination' key. The return value is an integer representing the cardinality of the new set. +// +// Returns: an integer representing the cardinality of the new union set. +func (server *SugarDB) SUnionStore(destination string, keys ...string) (int, error) { + cmd := append([]string{"SUNIONSTORE", destination}, keys...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} diff --git a/sugardb/api_set_test.go b/sugardb/api_set_test.go new file mode 100644 index 0000000..250b127 --- /dev/null +++ b/sugardb/api_set_test.go @@ -0,0 +1,1211 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "context" + "apigo.cc/go/sugardb/internal/modules/set" + "reflect" + "slices" + "testing" +) + +func TestSugarDB_SADD(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + members []string + want int + wantErr bool + }{ + { + name: "Create new set on a non-existent key, return count of added elements", + presetValue: nil, + key: "key1", + members: []string{"one", "two", "three", "four"}, + want: 4, + wantErr: false, + }, + { + name: "Add members to an exiting set, skip members that already exist in the set, return added count", + presetValue: set.NewSet([]string{"one", "two", "three", "four"}), + key: "key2", + members: []string{"three", "four", "five", "six", "seven"}, + want: 3, + wantErr: false, + }, + { + name: "Throw error when trying to add to a key that does not hold a set", + presetValue: "Default value", + key: "key3", + members: []string{"member"}, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.SAdd(tt.key, tt.members...) + if (err != nil) != tt.wantErr { + t.Errorf("SADD() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SADD() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_SCARD(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + want int + wantErr bool + }{ + { + name: "Get cardinality of valid set", + presetValue: set.NewSet([]string{"one", "two", "three", "four"}), + key: "key1", + want: 4, + wantErr: false, + }, + { + name: "Return 0 when trying to get cardinality on non-existent key", + presetValue: nil, + key: "key2", + want: 0, + wantErr: false, + }, + { + name: "Throw error when trying to get cardinality of a value that is not a set", + presetValue: "Default value", + key: "key3", + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.SCard(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("SCARD() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SCARD() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_SDIFF(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]interface{} + keys []string + want []string + wantErr bool + }{ + { + name: "Get the difference between 2 sets", + presetValues: map[string]interface{}{ + "key1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "key2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + keys: []string{"key1", "key2"}, + want: []string{"one", "two"}, + wantErr: false, + }, + { + name: "Get the difference between 3 sets", + presetValues: map[string]interface{}{ + "key3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key4": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "key5": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + keys: []string{"key3", "key4", "key5"}, + want: []string{"three", "four", "five", "six"}, + wantErr: false, + }, + { + name: "Return base set element if base set is the only valid set", + presetValues: map[string]interface{}{ + "key6": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key7": "Default value", + "key8": 123456789, + }, + keys: []string{"key6", "key7", "key8"}, + want: []string{"one", "two", "three", "four", "five", "six", "seven", "eight"}, + wantErr: false, + }, + { + name: "Throw error when base set is not a set", + presetValues: map[string]interface{}{ + "key9": "Default value", + "key10": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "key11": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + keys: []string{"key9", "key10", "key11"}, + want: nil, + wantErr: true, + }, + { + name: "Throw error when base set is non-existent", + presetValues: map[string]interface{}{ + "key12": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "key13": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + keys: []string{"non-existent", "key7", "key8"}, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.SDiff(tt.keys...) + if (err != nil) != tt.wantErr { + t.Errorf("SDIFF() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("SDIFF() got = %v, want %v", got, tt.want) + } + for _, g := range got { + if !slices.Contains(tt.want, g) { + t.Errorf("SDIFF() got = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestSugarDB_SDIFFSTORE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]interface{} + destination string + keys []string + want int + wantErr bool + }{ + { + name: "Get the difference between 2 sets", + presetValues: map[string]interface{}{ + "key1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "key2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + destination: "destination1", + keys: []string{"key1", "key2"}, + want: 2, + wantErr: false, + }, + { + name: "Get the difference between 3 sets", + presetValues: map[string]interface{}{ + "key3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key4": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "key5": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + destination: "destination2", + keys: []string{"key3", "key4", "key5"}, + want: 4, + wantErr: false, + }, + { + name: "Return base set element if base set is the only valid set", + presetValues: map[string]interface{}{ + "key6": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key7": "Default value", + "key8": 123456789, + }, + destination: "destination3", + keys: []string{"key6", "key7", "key8"}, + want: 8, + wantErr: false, + }, + { + name: "Throw error when base set is not a set", + presetValues: map[string]interface{}{ + "key9": "Default value", + "key10": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "key11": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + destination: "destination4", + keys: []string{"key9", "key10", "key11"}, + want: 0, + wantErr: true, + }, + { + name: " Throw error when base set is non-existent", + destination: "destination5", + presetValues: map[string]interface{}{ + "key12": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "key13": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + keys: []string{"non-existent", "key7", "key8"}, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.SDiffStore(tt.destination, tt.keys...) + if (err != nil) != tt.wantErr { + t.Errorf("SDIFFSTORE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SDIFFSTORE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_SINTER(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]interface{} + keys []string + want []string + wantErr bool + }{ + { + name: "Get the intersection between 2 sets", + presetValues: map[string]interface{}{ + "key1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "key2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + keys: []string{"key1", "key2"}, + want: []string{"three", "four", "five"}, + wantErr: false, + }, + { + name: "Get the intersection between 3 sets", + presetValues: map[string]interface{}{ + "key3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key4": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven", "eight"}), + "key5": set.NewSet([]string{"one", "eight", "nine", "ten", "twelve"}), + }, + keys: []string{"key3", "key4", "key5"}, + want: []string{"one", "eight"}, + wantErr: false, + }, + { + name: "Throw an error if any of the provided keys are not sets", + presetValues: map[string]interface{}{ + "key6": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key7": "Default value", + "key8": set.NewSet([]string{"one"}), + }, + keys: []string{"key6", "key7", "key8"}, + want: nil, + wantErr: true, + }, + { + name: "Throw error when base set is not a set", + presetValues: map[string]interface{}{ + "key9": "Default value", + "key10": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "key11": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + keys: []string{"key9", "key10", "key11"}, + want: nil, + wantErr: true, + }, + { + name: "If any of the keys does not exist, return an empty array", + presetValues: map[string]interface{}{ + "key12": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "key13": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + keys: []string{"non-existent", "key12", "key13"}, + want: []string{}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.SInter(tt.keys...) + if (err != nil) != tt.wantErr { + t.Errorf("SINTER() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("SINTER() got = %v, want %v", got, tt.want) + } + for _, g := range got { + if !slices.Contains(tt.want, g) { + t.Errorf("SINTER() got = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestSugarDB_SINTERCARD(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]interface{} + keys []string + limit uint + want int + wantErr bool + }{ + { + name: "Get the full intersect cardinality between 2 sets", + presetValues: map[string]interface{}{ + "key1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "key2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + keys: []string{"key1", "key2"}, + limit: 0, + want: 3, + wantErr: false, + }, + { + name: "Get an intersect cardinality between 2 sets with a limit", + presetValues: map[string]interface{}{ + "key3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"}), + "key4": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve"}), + }, + keys: []string{"key3", "key4"}, + limit: 3, + want: 3, + wantErr: false, + }, + { + name: "Get the full intersect cardinality between 3 sets", + presetValues: map[string]interface{}{ + "key5": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key6": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven", "eight"}), + "key7": set.NewSet([]string{"one", "seven", "eight", "nine", "ten", "twelve"}), + }, + keys: []string{"key5", "key6", "key7"}, + limit: 0, + want: 2, + wantErr: false, + }, + { + name: "Get the intersection of 3 sets with a limit", + presetValues: map[string]interface{}{ + "key8": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key9": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven", "eight"}), + "key10": set.NewSet([]string{"one", "two", "seven", "eight", "nine", "ten", "twelve"}), + }, + keys: []string{"key8", "key9", "key10"}, + limit: 2, + want: 2, + wantErr: false, + }, + { + name: "Return error if any of the keys is non-existent", + presetValues: map[string]interface{}{ + "key11": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key13": set.NewSet([]string{"one"}), + }, + keys: []string{"key11", "key12", "key13"}, + limit: 0, + want: 0, + wantErr: false, + }, + { + name: "Throw error when one of the keys is not a valid set", + presetValues: map[string]interface{}{ + "key14": "Default value", + "key15": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "key16": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + keys: []string{"key14", "key15", "key16"}, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.SInterCard(tt.keys, tt.limit) + if (err != nil) != tt.wantErr { + t.Errorf("SINTERCARD() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SINTERCARD() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_SINTERSTORE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]interface{} + destination string + keys []string + want int + wantErr bool + }{ + { + name: "Get the intersection between 2 sets and store it at the destination", + presetValues: map[string]interface{}{ + "key1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "key2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + destination: "destination1", + keys: []string{"key1", "key2"}, + want: 3, + wantErr: false, + }, + { + name: "Get the intersection between 3 sets and store it at the destination key", + presetValues: map[string]interface{}{ + "key3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key4": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven", "eight"}), + "key5": set.NewSet([]string{"one", "seven", "eight", "nine", "ten", "twelve"}), + }, + destination: "destination2", + keys: []string{"key3", "key4", "key5"}, + want: 2, + wantErr: false, + }, + { + name: "Throw error when any of the keys is not a set", + presetValues: map[string]interface{}{ + "key6": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key7": "Default value", + "key8": set.NewSet([]string{"one"}), + }, + destination: "destination3", + keys: []string{"key6", "key7", "key8"}, + want: 0, + wantErr: true, + }, + { + name: "Throw error when base set is not a set", + presetValues: map[string]interface{}{ + "key9": "Default value", + "key10": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "key11": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + destination: "destination4", + keys: []string{"key9", "key10", "key11"}, + want: 0, + wantErr: true, + }, + { + name: "Return an empty intersection if one of the keys does not exist", + destination: "destination5", + presetValues: map[string]interface{}{ + "key12": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "key13": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + keys: []string{"non-existent", "key12", "key13"}, + want: 0, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.SInterStore(tt.destination, tt.keys...) + if (err != nil) != tt.wantErr { + t.Errorf("SINTERSTORE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SINTERSTORE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_SISMEMBER(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + member string + want bool + wantErr bool + }{ + { + name: "Return true when element is a member of the set", + presetValue: set.NewSet([]string{"one", "two", "three", "four"}), + key: "key1", + member: "three", + want: true, + wantErr: false, + }, + { + name: "Return false when element is not a member of the set", + presetValue: set.NewSet([]string{"one", "two", "three", "four"}), + key: "key2", + member: "five", + want: false, + wantErr: false, + }, + { + name: "Throw error when trying to assert membership when the key does not hold a valid set", + presetValue: "Default value", + key: "key3", + member: "one", + want: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.SisMember(tt.key, tt.member) + if (err != nil) != tt.wantErr { + t.Errorf("SISMEMBER() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SISMEMBER() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_SMEMBERS(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + want []string + wantErr bool + }{ + { + name: "Return all the members of the set", + key: "key1", + presetValue: set.NewSet([]string{"one", "two", "three", "four", "five"}), + want: []string{"one", "two", "three", "four", "five"}, + wantErr: false, + }, + { + name: "If the key does not exist, return an empty array", + key: "key2", + presetValue: nil, + want: []string{}, + wantErr: false, + }, + { + name: "Throw error when the provided key is not a set", + key: "key3", + presetValue: "Default value", + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.SMembers(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("SMEMBERS() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("SMEMBERS() got = %v, want %v", got, tt.want) + } + for _, g := range got { + if !slices.Contains(tt.want, g) { + t.Errorf("SMEMBERS() got = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestSugarDB_SMISMEMBER(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + members []string + want []bool + wantErr bool + }{ + { + // Return set membership status for multiple elements (true for present and false for absent). + // The placement of the membership status flag should be consistent with the order the elements + // are in within the original command + name: "Return set membership status for multiple elements", + presetValue: set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven"}), + key: "key1", + members: []string{"three", "four", "five", "six", "eight", "nine", "seven"}, + want: []bool{true, true, true, true, false, false, true}, + wantErr: false, + }, + { + name: "If the set key does not exist, return an array of zeroes as long as the list of members", + presetValue: nil, + key: "key2", + members: []string{"one", "two", "three", "four"}, + want: []bool{false, false, false, false}, + wantErr: false, + }, + { + name: "Throw error when trying to assert membership when the key does not hold a valid set", + presetValue: "Default value", + key: "key3", + members: []string{"one"}, + want: nil, + wantErr: true, + }, + { + name: "Throw error for empty member slice", + presetValue: nil, + key: "key4", + members: []string{}, + want: nil, + wantErr: true, + }, + { + name: "Throw error for nil member slice", + presetValue: nil, + key: "key4", + members: nil, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.SMisMember(tt.key, tt.members...) + if (err != nil) != tt.wantErr { + t.Errorf("SMISMEMBER() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("SMISMEMBER() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_SMOVE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]interface{} + source string + destination string + member string + want bool + wantErr bool + }{ + { + name: "Return true after a successful move of a member from source set to destination set", + presetValues: map[string]interface{}{ + "source1": set.NewSet([]string{"one", "two", "three", "four"}), + "destination1": set.NewSet([]string{"five", "six", "seven", "eight"}), + }, + source: "source1", + destination: "destination1", + member: "four", + want: true, + wantErr: false, + }, + { + name: "Return false when trying to move a member from source set to destination set when it doesn't exist in source", + presetValues: map[string]interface{}{ + "source2": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "destination2": set.NewSet([]string{"five", "six", "seven", "eight"}), + }, + source: "source2", + destination: "destination2", + member: "six", + want: false, + wantErr: false, + }, + { + name: "Return error when the source key is not a set", + presetValues: map[string]interface{}{ + "source3": "Default value", + "destination3": set.NewSet([]string{"five", "six", "seven", "eight"}), + }, + source: "source3", + destination: "destination3", + member: "five", + want: false, + wantErr: true, + }, + { + name: "Return error when the destination key is not a set", + presetValues: map[string]interface{}{ + "source4": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "destination4": "Default value", + }, + source: "source4", + destination: "destination4", + member: "five", + want: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.SMove(tt.source, tt.destination, tt.member) + if (err != nil) != tt.wantErr { + t.Errorf("SMOVE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SMOVE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_SPOP(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + count uint + want []string + wantErr bool + }{ + { + name: "Return multiple popped elements and modify the set", + key: "key1", + presetValue: set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + count: 3, + want: []string{"one", "two", "three", "four", "five", "six", "seven", "eight"}, + wantErr: false, + }, + { + name: "Return error when the source key is not a set", + key: "key2", + presetValue: "Default value", + count: 1, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.SPop(tt.key, tt.count) + if (err != nil) != tt.wantErr { + t.Errorf("SPOP() error = %v, wantErr %v", err, tt.wantErr) + return + } + for _, g := range got { + if !slices.Contains(tt.want, g) { + t.Errorf("SPOP() got = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestSugarDB_SRANDMEMBER(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + count int + wantCount int + wantErr bool + }{ + { + // Return multiple random elements without removing them + // Count is positive, do not allow repeated elements + name: "Return multiple random elements without removing them", + key: "key1", + presetValue: set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + count: 3, + wantCount: 3, + wantErr: false, + }, + { + // Return multiple random elements without removing them + // Count is negative, so allow repeated numbers + name: "Return multiple random elements without removing them", + key: "key2", + presetValue: set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + count: -5, + wantCount: 5, + wantErr: false, + }, + { + name: "Return error when the source key is not a set", + key: "key3", + presetValue: "Default value", + count: 1, + wantCount: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.SRandMember(tt.key, tt.count) + if (err != nil) != tt.wantErr { + t.Errorf("SRANDMEMBER() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != tt.wantCount { + t.Errorf("SRANDMEMBER() got = %v, want %v", len(got), tt.wantCount) + } + if tt.count > 0 { + s := set.NewSet(got) + if s.Cardinality() != len(got) { + t.Errorf("SRANDMEMBER - UNIQUE () got = %v, want %v", len(got), s.Cardinality()) + } + } + }) + } +} + +func TestSugarDB_SREM(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + members []string + want int + wantErr bool + }{ + { + name: "Remove multiple elements and return the number of elements removed", + key: "key1", + presetValue: set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + members: []string{"one", "two", "three", "nine"}, + want: 3, + wantErr: false, + }, + { + name: "If key does not exist, return 0", + key: "key2", + presetValue: nil, + members: []string{"one", "two", "three", "nine"}, + want: 0, + wantErr: false, + }, + { + name: "Return error when the source key is not a set", + key: "key3", + presetValue: "Default value", + members: []string{"one"}, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.SRem(tt.key, tt.members...) + if (err != nil) != tt.wantErr { + t.Errorf("SREM() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SREM() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_SUNION(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]interface{} + keys []string + want []string + wantErr bool + }{ + { + name: "Get the union between 2 sets", + presetValues: map[string]interface{}{ + "key1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "key2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + keys: []string{"key1", "key2"}, + want: []string{"one", "two", "three", "four", "five", "six", "seven", "eight"}, + wantErr: false, + }, + { + name: "Get the union between 3 sets", + presetValues: map[string]interface{}{ + "key3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key4": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven", "eight"}), + "key5": set.NewSet([]string{"one", "eight", "nine", "ten", "twelve"}), + }, + keys: []string{"key3", "key4", "key5"}, + want: []string{ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", + "ten", "eleven", "twelve", "thirty-six", + }, + wantErr: false, + }, + { + name: "Throw an error if any of the provided keys are not sets", + presetValues: map[string]interface{}{ + "key6": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key7": "Default value", + "key8": set.NewSet([]string{"one"}), + }, + keys: []string{"key6", "key7", "key8"}, + want: nil, + wantErr: true, + }, + { + name: "Throw error any of the keys does not hold a set", + presetValues: map[string]interface{}{ + "key9": "Default value", + "key10": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven"}), + "key11": set.NewSet([]string{"seven", "eight", "nine", "ten", "twelve"}), + }, + keys: []string{"key9", "key10", "key11"}, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.SUnion(tt.keys...) + if (err != nil) != tt.wantErr { + t.Errorf("SUNION() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("SUNION() got = %v, want %v", got, tt.want) + } + for _, g := range got { + if !slices.Contains(tt.want, g) { + t.Errorf("SUNION() got = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestSugarDB_SUNIONSTORE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValues map[string]interface{} + destination string + keys []string + want int + wantErr bool + }{ + { + name: "Get the intersection between 2 sets and store it at the destination", + presetValues: map[string]interface{}{ + "key1": set.NewSet([]string{"one", "two", "three", "four", "five"}), + "key2": set.NewSet([]string{"three", "four", "five", "six", "seven", "eight"}), + }, + destination: "destination1", + keys: []string{"key1", "key2"}, + want: 8, + wantErr: false, + }, + { + name: "Get the intersection between 3 sets and store it at the destination key", + presetValues: map[string]interface{}{ + "key3": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key4": set.NewSet([]string{"one", "two", "thirty-six", "twelve", "eleven", "eight"}), + "key5": set.NewSet([]string{"one", "seven", "eight", "nine", "ten", "twelve"}), + }, + destination: "destination2", + keys: []string{"key3", "key4", "key5"}, + want: 13, + wantErr: false, + }, + { + name: "Throw error when any of the keys is not a set", + presetValues: map[string]interface{}{ + "key6": set.NewSet([]string{"one", "two", "three", "four", "five", "six", "seven", "eight"}), + "key7": "Default value", + "key8": set.NewSet([]string{"one"}), + }, + destination: "destination3", + keys: []string{"key6", "key7", "key8"}, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValues != nil { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.SUnionStore(tt.destination, tt.keys...) + if (err != nil) != tt.wantErr { + t.Errorf("SUNIONSTORE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SUNIONSTORE() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/sugardb/api_sorted_set.go b/sugardb/api_sorted_set.go new file mode 100644 index 0000000..76268dd --- /dev/null +++ b/sugardb/api_sorted_set.go @@ -0,0 +1,1021 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "apigo.cc/go/sugardb/internal" + "strconv" +) + +// ZAddOptions allows you to modify the effects of the ZAdd command. +// +// "NX" only adds the member if it currently does not exist in the sorted set. This flag is mutually exclusive with the +// "GT" and "LT" flags. The "NX" flag takes higher priority than the "XX" flag. +// +// "XX" only updates the scores of members that exist in the sorted set. +// +// "GT"" only updates the score if the new score is greater than the current score. The "GT" flat is higher priority +// than the "LT" flag. +// +// "LT" only updates the score if the new score is less than the current score. +// +// "CH" modifies the result to return total number of members changed + added, instead of only new members added. When +// this flag is set to true, only the number of members that have been updated will be returned. +// +// "INCR" modifies the command to act like ZIncrBy, only one score/member pair can be specified in this mode. When this flag +// is provided, only one member/score pair is allowed. +type ZAddOptions struct { + NX bool + XX bool + GT bool + LT bool + CH bool + INCR bool +} + +// ZInterOptions allows you to modify the result of the ZInter* and ZUnion* family of commands +// +// Weights is a slice of float64 that determines the weights of each sorted set in the aggregation command. +// each weight will be each weight will be applied to the sorted set at the corresponding index. +// The weight value is multiplied by each member of corresponding sorted set. +// +// Aggregate determines how the scores are combined AFTER the weights are applied. There are 3 possible vales, +// "MIN" will select the minimum score element to place in the resulting sorted set. +// "MAX" will select the maximum score element to place in the resulting sorted set. +// "SUM" will add all the scores to place in the resulting sorted set. +// +// WithScores determines whether to return the scores of the resulting set. +type ZInterOptions struct { + Weights []float64 + Aggregate string + WithScores bool +} +type ZInterStoreOptions ZInterOptions +type ZUnionOptions ZInterOptions +type ZUnionStoreOptions ZInterOptions + +// ZMPopOptions allows you to modify the result of the ZMPop command. +// +// Min instructs SugarDB to pop the minimum score elements. Min is higher priority than Max. +// +// Max instructs SugarDB to pop the maximum score elements. +// +// Count specifies the number of elements to pop. +type ZMPopOptions struct { + Min bool + Max bool + Count uint +} + +// ZRangeOptions allows you to modify the effects of the ZRange* family of commands. +// +// WithScores specifies whether to return the associated scores. +// +// ByScore compares the elements by score within the numerical ranges specified. ByScore is higher priority than ByLex. +// +// ByLex returns the elements within the lexicographical ranges specified. +// +// Rev reverses the result from the previous filters. +// +// Offset specifies the offset to from which to start the ZRange process. +// +// Count specifies the number of elements to return. +type ZRangeOptions struct { + WithScores bool + ByScore bool + ByLex bool + Rev bool + Offset uint + Count uint +} +type ZRangeStoreOptions ZRangeOptions + +func buildMemberScoreMap(arr [][]string, withscores bool) (map[string]float64, error) { + result := make(map[string]float64, len(arr)) + for _, entry := range arr { + if withscores { + score, err := strconv.ParseFloat(entry[1], 64) + if err != nil { + return nil, err + } + result[entry[0]] = score + continue + } + result[entry[0]] = 0 + } + return result, nil +} + +// ZAdd adds member(s) to a sorted set. If the sorted set does not exist, a new sorted set is created with the +// member(s). +// +// Parameters: +// +// `key` - string - the key to update. +// +// `members` - map[string]float64 - a map of the members to add. The key is the string and the value is a float64 score. +// +// `options` - ZAddOptions +// +// Returns: The number of members added, or the number of members updated in the "CH" flag is true. +// +// Errors: +// +// "GT/LT flags not allowed if NX flag is provided" - when GT/LT flags are provided alongside NX flag. +// +// "cannot pass more than one score/member pair when INCR flag is provided" - when INCR flag is provided with more than +// one member-score pair. +// +// "value at is not a sorted set" - when the provided key exists but is not a sorted set +func (server *SugarDB) ZAdd(key string, members map[string]float64, options ZAddOptions) (int, error) { + cmd := []string{"ZADD", key} + + switch { + case options.NX: + cmd = append(cmd, "NX") + case options.XX: + cmd = append(cmd, "XX") + } + + switch { + case options.GT: + cmd = append(cmd, "GT") + case options.LT: + cmd = append(cmd, "LT") + } + + if options.CH { + cmd = append(cmd, "CH") + } + + if options.INCR { + cmd = append(cmd, "INCR") + } + + for member, score := range members { + cmd = append(cmd, []string{strconv.FormatFloat(score, 'f', -1, 64), member}...) + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + + return internal.ParseIntegerResponse(b) +} + +// ZCard returns the cardinality of the sorted set. +// +// Parameters: +// +// `key` - string - the key of the sorted set. +// +// Returns: The cardinality of the sorted set. Returns 0 if the keys does not exist. +// +// Errors: +// +// "value at is not a sorted set" - when the provided key exists but is not a sorted set +func (server *SugarDB) ZCard(key string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"ZCARD", key}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// ZCount returns the number of elements in the sorted set key with scores in the range of min and max. +// +// Parameters: +// +// `key` - string - the key of the sorted set. +// +// `min` - float64 - the minimum score boundary. +// +// `max` - float64 - the maximum score boundary. +// +// Returns: The number of members with scores in the given range. Returns 0 if the keys does not exist. +// +// Errors: +// +// "value at is not a sorted set" - when the provided key exists but is not a sorted set +func (server *SugarDB) ZCount(key string, min, max float64) (int, error) { + cmd := []string{ + "ZCOUNT", + key, + strconv.FormatFloat(min, 'f', -1, 64), + strconv.FormatFloat(max, 'f', -1, 64), + } + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// ZDiff Calculates the difference between the sorted sets and returns the resulting sorted set. +// All keys that are non-existed are skipped. +// +// Parameters: +// +// `withscores` - bool - whether to return the results with scores or not. If false, all the returned scores +// will be 0. +// +// `keys` - []string - the keys to the sorted sets to be used in calculating the difference. +// +// Returns: A map representing the resulting sorted set where the key is the member and the value is a float64 score. +// +// Errors: +// +// "value at is not a sorted set" - when the provided key exists but is not a sorted set. +func (server *SugarDB) ZDiff(withscores bool, keys ...string) (map[string]float64, error) { + cmd := append([]string{"ZDIFF"}, keys...) + if withscores { + cmd = append(cmd, "WITHSCORES") + } + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + + arr, err := internal.ParseNestedStringArrayResponse(b) + if err != nil { + return nil, err + } + + return buildMemberScoreMap(arr, withscores) +} + +// ZDiffStore Calculates the difference between the sorted sets and stores the resulting sorted set at 'destination'. +// Non-existent keys will be skipped. +// +// Parameters: +// +// `destination` - string - the destination key at which to store the resulting sorted set. +// +// `keys` - []string - the keys to the sorted sets to be used in calculating the difference. +// +// Returns: The cardinality of the new sorted set. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZDiffStore(destination string, keys ...string) (int, error) { + cmd := append([]string{"ZDIFFSTORE", destination}, keys...) + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// ZInter Calculates the intersection between the sorted sets and returns the resulting sorted set. +// if any of the keys provided are non-existent, an empty map is returned. +// +// Parameters: +// +// `keys` - []string - the keys to the sorted sets to be used in calculating the intersection. +// +// `options` - ZInterOptions +// +// Returns: A map representing the resulting sorted set where the key is the member and the value is a float64 score. +// +// Errors: +// +// "value at is not a sorted set" - when the provided key exists but is not a sorted set. +func (server *SugarDB) ZInter(keys []string, options ZInterOptions) (map[string]float64, error) { + cmd := append([]string{"ZINTER"}, keys...) + + if len(options.Weights) > 0 { + cmd = append(cmd, "WEIGHTS") + for i := 0; i < len(options.Weights); i++ { + cmd = append(cmd, strconv.FormatFloat(options.Weights[i], 'f', -1, 64)) + } + } + + if options.Aggregate != "" { + cmd = append(cmd, []string{"AGGREGATE", options.Aggregate}...) + } + + if options.WithScores { + cmd = append(cmd, "WITHSCORES") + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + + arr, err := internal.ParseNestedStringArrayResponse(b) + if err != nil { + return nil, err + } + + return buildMemberScoreMap(arr, options.WithScores) +} + +// ZInterStore Calculates the intersection between the sorted sets and stores the resulting sorted set at 'destination'. +// If any of the keys does not exist, the operation is abandoned. +// +// Parameters: +// +// `destination` - string - the destination key at which to store the resulting sorted set. +// +// `keys` - []string - the keys to the sorted sets to be used in calculating the intersection. +// +// `options` - ZInterStoreOptions +// +// Returns: The cardinality of the new sorted set. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZInterStore(destination string, keys []string, options ZInterStoreOptions) (int, error) { + cmd := append([]string{"ZINTERSTORE", destination}, keys...) + + if len(options.Weights) > 0 { + cmd = append(cmd, "WEIGHTS") + for _, weight := range options.Weights { + cmd = append(cmd, strconv.FormatFloat(weight, 'f', -1, 64)) + } + } + + if options.Aggregate != "" { + cmd = append(cmd, []string{"AGGREGATE", options.Aggregate}...) + } + + if options.WithScores { + cmd = append(cmd, "WITHSCORES") + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + + return internal.ParseIntegerResponse(b) +} + +// ZUnion Calculates the union between the sorted sets and returns the resulting sorted set. +// if any of the keys provided are non-existent, an error is returned. +// +// Parameters: +// +// `keys` - []string - the keys to the sorted sets to be used in calculating the union. +// +// `options` - ZUnionOptions +// +// Returns: A map representing the resulting sorted set where the key is the member and the value is a float64 score. +// +// Errors: +// +// "value at is not a sorted set" - when the provided key exists but is not a sorted set. +func (server *SugarDB) ZUnion(keys []string, options ZUnionOptions) (map[string]float64, error) { + cmd := append([]string{"ZUNION"}, keys...) + + if len(options.Weights) > 0 { + cmd = append(cmd, "WEIGHTS") + for _, weight := range options.Weights { + cmd = append(cmd, strconv.FormatFloat(weight, 'f', -1, 64)) + } + } + + if options.Aggregate != "" { + cmd = append(cmd, []string{"AGGREGATE", options.Aggregate}...) + } + + if options.WithScores { + cmd = append(cmd, "WITHSCORES") + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + + arr, err := internal.ParseNestedStringArrayResponse(b) + if err != nil { + return nil, err + } + + return buildMemberScoreMap(arr, options.WithScores) +} + +// ZUnionStore Calculates the union between the sorted sets and stores the resulting sorted set at 'destination'. +// Non-existent keys will be skipped. +// +// Parameters: +// +// `destination` - string - the destination key at which to store the resulting sorted set. +// +// `keys` - []string - the keys to the sorted sets to be used in calculating the union. +// +// `options` - ZUnionStoreOptions +// +// Returns: The cardinality of the new sorted set. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZUnionStore(destination string, keys []string, options ZUnionStoreOptions) (int, error) { + cmd := append([]string{"ZUNIONSTORE", destination}, keys...) + + if len(options.Weights) > 0 { + cmd = append(cmd, "WEIGHTS") + for _, weight := range options.Weights { + cmd = append(cmd, strconv.FormatFloat(float64(weight), 'f', -1, 64)) + } + } + + if options.Aggregate != "" { + cmd = append(cmd, []string{"AGGREGATE", options.Aggregate}...) + } + + if options.WithScores { + cmd = append(cmd, "WITHSCORES") + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + + return internal.ParseIntegerResponse(b) +} + +// ZIncrBy Increments the score of the specified sorted set's member by the increment. If the member does not exist, it is created. +// If the key does not exist, it is created with new sorted set and the member added with the increment as its score. +// +// Parameters: +// +// `key` - string - the keys to the sorted set. +// +// `increment` - float64 - the increment to apply to the member's score. +// +// `member` - string - the member to increment. +// +// Returns: The cardinality of the new sorted set. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZIncrBy(key string, increment float64, member string) (float64, error) { + cmd := []string{"ZINCRBY", key, strconv.FormatFloat(increment, 'f', -1, 64), member} + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + f, err := internal.ParseFloatResponse(b) + if err != nil { + return 0, err + } + return f, nil +} + +// ZMPop Pop a 'count' elements from multiple sorted sets. MIN or MAX determines whether to pop elements with the lowest +// or highest scores respectively. +// +// Parameters: +// +// `keys` - []string - the keys to the sorted sets to pop from. +// +// `options` - ZMPopOptions +// +// Returns: A 2-dimensional slice where each slice contains the member and score at the 0 and 1 indices respectively. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZMPop(keys []string, options ZMPopOptions) ([][]string, error) { + cmd := append([]string{"ZMPOP"}, keys...) + + switch { + case options.Min: + cmd = append(cmd, "MIN") + case options.Max: + cmd = append(cmd, "MAX") + default: + cmd = append(cmd, "MIN") + } + + switch { + case options.Count != 0: + cmd = append(cmd, []string{"COUNT", strconv.Itoa(int(options.Count))}...) + default: + cmd = append(cmd, []string{"COUNT", strconv.Itoa(1)}...) + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + + return internal.ParseNestedStringArrayResponse(b) +} + +// ZMScore Returns the associated scores of the specified member in the sorted set. +// +// Parameters: +// +// `key` - string - The keys to the sorted set. +// +// `members` - ...string - Them members whose scores will be returned. +// +// Returns: A slice of interface{} with the result scores. For existing members, the entry will be represented by a string. +// For non-existent members, the score will be nil. You will have to format the string score into a float64 if you +// would like to use it as a float64. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZMScore(key string, members ...string) ([]interface{}, error) { + cmd := []string{"ZMSCORE", key} + for _, member := range members { + cmd = append(cmd, member) + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + + arr, err := internal.ParseStringArrayResponse(b) + if err != nil { + return nil, err + } + + scores := make([]interface{}, len(arr)) + for i, e := range arr { + if e == "" { + scores[i] = nil + continue + } + score, err := strconv.ParseFloat(e, 64) + if err != nil { + return nil, err + } + scores[i] = score + } + + return scores, nil +} + +// ZLexCount returns the number of elements in the sorted set within the lexicographical range between min and max. +// This function only returns a non-zero value if all the members have the same score. +// +// Parameters: +// +// `key` - string - the key of the sorted set. +// +// `min` - string - the minimum lex boundary. +// +// `max` - string - the maximum lex boundary. +// +// Returns: The number of members within the given lexicographical range. +// Returns 0 if the keys does not exist or all the members don't have the same score. +// +// Errors: +// +// "value at is not a sorted set" - when the provided key exists but is not a sorted set +func (server *SugarDB) ZLexCount(key, min, max string) (int, error) { + cmd := []string{"ZLEXCOUNT", key, min, max} + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// ZPopMax Removes and returns 'count' number of members in the sorted set with the highest scores. Default count is 1. +// +// Parameters: +// +// `key` - string - The keys to the sorted set. +// +// `count` - uint - The number of max elements to pop. If a count of 0 is provided, it will be ignored +// and 1 element will be popped instead. +// +// Returns: A 2-dimensional slice where each slice contains a member and its score at the 0 and 1 indices respectively. +// The returned scores are strings. If you'd like to use them as float64 or another numeric type, you will have to +// format them. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZPopMax(key string, count uint) ([][]string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"ZPOPMAX", key, strconv.Itoa(int(count))}), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseNestedStringArrayResponse(b) +} + +// ZPopMin Removes and returns 'count' number of members in the sorted set with the lowest scores. Default count is 1. +// +// Parameters: +// +// `key` - string - The keys to the sorted set. +// +// `count` - uint - The number of min elements to pop. If a count of 0 is provided, it will be ignored +// and 1 element will be popped instead. +// +// Returns: A 2-dimensional slice where each slice contains a member and its score at the 0 and 1 indices respectively. +// The returned scores are strings. If you'd like to use them as float64 or another numeric type, you will have to +// format them. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZPopMin(key string, count uint) ([][]string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"ZPOPMIN", key, strconv.Itoa(int(count))}), nil, false, true) + if err != nil { + return nil, err + } + return internal.ParseNestedStringArrayResponse(b) +} + +// ZRandMember Returns a list of length equivalent to 'count' containing random members of the sorted set. +// If count is negative, repeated elements are allowed. If count is positive, the returned elements will be distinct. +// The default count is 1. If a count of 0 is passed, it will be ignored. +// +// Parameters: +// +// `key` - string - The keys to the sorted set. +// +// `count` - int - The number of random members to return. If the absolute value of count is greater than the +// sorted set's cardinality, the whole sorted set will be returned. +// +// `withscores` - bool - Whether to return the members' associated scores. If this is false, the returned scores will +// be 0. +// +// Returns: A 2-dimensional slice where each slice contains a member and its score at the 0 and 1 indices respectively. +// The returned scores are strings. If you'd like to use them as float64 or another numeric type, you will have to +// format them. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZRandMember(key string, count int, withscores bool) ([][]string, error) { + cmd := []string{"ZRANDMEMBER", key} + if count != 0 { + cmd = append(cmd, strconv.Itoa(count)) + } + if withscores { + cmd = append(cmd, "WITHSCORES") + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + + return internal.ParseNestedStringArrayResponse(b) +} + +// ZRank Returns the rank of the specified member in the sorted set. The rank is derived from organising the members +// in descending order of score. +// +// Parameters: +// +// `key` - string - The keys to the sorted set. +// +// `member` - string - The member whose rank will be returned. +// +// `withscores` - bool - Whether to return the member associated scores. If this is false, the returned score will +// be 0. +// +// Returns: A map of map[string]float64 where the key is the member and the value is the score. +// If the member does not exist in the sorted set, an empty map is returned. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZRank(key string, member string, withscores bool) (map[int]float64, error) { + cmd := []string{"ZRANK", key, member} + if withscores { + cmd = append(cmd, "WITHSCORES") + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + + arr, err := internal.ParseStringArrayResponse(b) + + if len(arr) == 0 { + return map[int]float64{}, nil + } + + s, err := strconv.Atoi(arr[0]) + if err != nil { + return nil, err + } + + res := map[int]float64{s: 0} + + if withscores { + f, err := strconv.ParseFloat(arr[1], 64) + if err != nil { + return nil, err + } + res[s] = f + } + + return res, nil +} + +// ZRevRank works the same as ZRank but derives the member's rank based on ascending order of +// the members' scores. +func (server *SugarDB) ZRevRank(key string, member string, withscores bool) (map[int]float64, error) { + cmd := []string{"ZREVRANK", key, member} + if withscores { + cmd = append(cmd, "WITHSCORES") + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + + arr, err := internal.ParseStringArrayResponse(b) + + if len(arr) == 0 { + return map[int]float64{}, nil + } + + s, err := strconv.Atoi(arr[0]) + if err != nil { + return nil, err + } + + res := map[int]float64{s: 0} + + if withscores { + f, err := strconv.ParseFloat(arr[1], 64) + if err != nil { + return nil, err + } + res[s] = f + } + + return res, nil +} + +// ZScore Returns the score of the member in the sorted set. +// +// Parameters: +// +// `key` - string - The keys to the sorted set. +// +// `member` - string - The member whose rank will be returned. +// +// Returns: An interface representing the score of the member. If the member does not exist in the sorted set, nil is +// returned. Otherwise, a float64 is returned. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZScore(key string, member string) (interface{}, error) { + cmd := []string{"ZSCORE", key, member} + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + + isNil, err := internal.ParseNilResponse(b) + if err != nil { + return nil, err + } + + if isNil { + return nil, nil + } + + score, err := internal.ParseFloatResponse(b) + if err != nil { + return 0, err + } + + return score, nil +} + +// ZRem Removes the listed members from the sorted set. +// +// Parameters: +// +// `key` - string - The keys to the sorted set. +// +// `members` - ...string - The members to remove. +// +// Returns: The number of elements that were successfully removed. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZRem(key string, members ...string) (int, error) { + cmd := []string{"ZREM", key} + for _, member := range members { + cmd = append(cmd, member) + } + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// ZRemRangeByScore Removes the elements whose scores are in the range between min and max. +// +// Parameters: +// +// `key` - string - The keys to the sorted set. +// +// `min` - float64 - The minimum score boundary. +// +// `max` - float64 - The maximum score boundary. +// +// Returns: The number of elements that were successfully removed. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZRemRangeByScore(key string, min float64, max float64) (int, error) { + cmd := []string{ + "ZREMRANGEBYSCORE", + key, + strconv.FormatFloat(min, 'f', -1, 64), + strconv.FormatFloat(max, 'f', -1, 64), + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + + return internal.ParseIntegerResponse(b) +} + +// ZRemRangeByLex Removes the elements that are lexicographically between min and max. +// +// Parameters: +// +// `key` - string - The keys to the sorted set. +// +// `min` - string - The minimum lexicographic boundary. +// +// `max` - string - The maximum lexicographic boundary. +// +// Returns: The number of elements that were successfully removed. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZRemRangeByLex(key, min, max string) (int, error) { + b, err := server.handleCommand( + server.context, internal.EncodeCommand([]string{"ZREMRANGEBYLEX", key, min, max}), + nil, + false, + true, + ) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// ZRemRangeByRank Removes the elements that are ranked between min and max. +// +// Parameters: +// +// `key` - string - The keys to the sorted set. +// +// `min` - int - The minimum rank boundary. +// +// `max` - int - The maximum rank boundary. +// +// Returns: The number of elements that were successfully removed. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZRemRangeByRank(key string, min, max int) (int, error) { + b, err := server.handleCommand( + server.context, internal.EncodeCommand([]string{"ZREMRANGEBYRANK", key, strconv.Itoa(min), strconv.Itoa(max)}), + nil, + false, + true, + ) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// ZRange Returns the range of elements in the sorted set. +// +// Parameters: +// +// `key` - string - The keys to the sorted set. +// +// `start` - string - The minimum boundary. +// +// `stop` - string - The maximum boundary. +// +// `options` - ZRangeOptions +// +// Returns: A map of map[string]float64 where the key is the member and the value is its score. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZRange(key, start, stop string, options ZRangeOptions) (map[string]float64, error) { + cmd := []string{"ZRANGE", key, start, stop} + + switch { + case options.ByScore: + cmd = append(cmd, "BYSCORE") + case options.ByLex: + cmd = append(cmd, "BYLEX") + case options.Rev: + cmd = append(cmd, "REV") + default: + cmd = append(cmd, "BYSCORE") + } + + if options.WithScores { + cmd = append(cmd, "WITHSCORES") + } + + if options.Offset != 0 && options.Count != 0 { + cmd = append(cmd, []string{"LIMIT", strconv.Itoa(int(options.Offset)), strconv.Itoa(int(options.Count))}...) + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return nil, err + } + + arr, err := internal.ParseNestedStringArrayResponse(b) + if err != nil { + return nil, err + } + + return buildMemberScoreMap(arr, options.WithScores) +} + +// ZRangeStore Works like ZRange but stores the result in at the 'destination' key. +// +// Parameters: +// +// `destination` - string - The key at which to store the new sorted set +// +// `key` - string - The keys to the sorted set. +// +// `start` - string - The minimum boundary. +// +// `stop` - string - The maximum boundary. +// +// `options` - ZRangeStoreOptions +// +// Returns: The cardinality of the new sorted set. +// +// Errors: +// +// "value at is not a sorted set" - when a key exists but is not a sorted set. +func (server *SugarDB) ZRangeStore(destination, source, start, stop string, options ZRangeStoreOptions) (int, error) { + cmd := []string{"ZRANGESTORE", destination, source, start, stop} + + switch { + case options.ByScore: + cmd = append(cmd, "BYSCORE") + case options.ByLex: + cmd = append(cmd, "BYLEX") + case options.Rev: + cmd = append(cmd, "REV") + default: + cmd = append(cmd, "BYSCORE") + } + + if options.Offset != 0 && options.Count != 0 { + cmd = append(cmd, []string{"LIMIT", strconv.Itoa(int(options.Offset)), strconv.Itoa(int(options.Count))}...) + } + + b, err := server.handleCommand(server.context, internal.EncodeCommand(cmd), nil, false, true) + if err != nil { + return 0, err + } + + return internal.ParseIntegerResponse(b) +} diff --git a/sugardb/api_sorted_set_test.go b/sugardb/api_sorted_set_test.go new file mode 100644 index 0000000..f838c85 --- /dev/null +++ b/sugardb/api_sorted_set_test.go @@ -0,0 +1,3590 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "context" + "apigo.cc/go/sugardb/internal" + ss "apigo.cc/go/sugardb/internal/modules/sorted_set" + "math" + "reflect" + "strconv" + "testing" +) + +func TestSugarDB_ZADD(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue *ss.SortedSet + key string + entries map[string]float64 + options ZAddOptions + want int + wantErr bool + }{ + { + name: "Create new sorted set and return the cardinality of the new sorted set", + preset: false, + presetValue: nil, + key: "key1", + entries: map[string]float64{ + "member1": 5.5, + "member2": 67.77, + "member3": 10, + "member4": math.Inf(-1), + "member5": math.Inf(1), + }, + options: ZAddOptions{}, + want: 5, + wantErr: false, + }, + { + name: "Only add the elements that do not currently exist in the sorted set when NX flag is provided", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "member1", Score: ss.Score(5.5)}, + {Value: "member2", Score: ss.Score(67.77)}, + {Value: "member3", Score: ss.Score(10)}, + }), + key: "key2", + entries: map[string]float64{ + "member1": 5.5, + "member4": 67.77, + "member5": 10, + }, + options: ZAddOptions{NX: true}, + want: 2, + wantErr: false, + }, + { + name: "Do not add any elements when providing existing members with NX flag", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "member1", Score: ss.Score(5.5)}, + {Value: "member2", Score: ss.Score(67.77)}, + {Value: "member3", Score: ss.Score(10)}, + }), + key: "key3", + entries: map[string]float64{ + "member1": 5.5, + "member2": 67.77, + "member3": 10, + }, + options: ZAddOptions{NX: true}, + want: 0, + wantErr: false, + }, + { + name: "Successfully add elements to an existing set when XX flag is provided with existing elements", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "member1", Score: ss.Score(5.5)}, + {Value: "member2", Score: ss.Score(67.77)}, + {Value: "member3", Score: ss.Score(10)}, + }), + key: "key4", + entries: map[string]float64{ + "member1": 55, + "member2": 1005, + "member3": 15, + "member4": 99.75, + }, + options: ZAddOptions{XX: true, CH: true}, + want: 3, + wantErr: false, + }, + { + name: "Fail to add element when providing XX flag with elements that do not exist in the sorted set", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "member1", Score: ss.Score(5.5)}, + {Value: "member2", Score: ss.Score(67.77)}, + {Value: "member3", Score: ss.Score(10)}, + }), + key: "key5", + entries: map[string]float64{ + "member4": 5.5, + "member5": 100.5, + "member6": 15, + }, + options: ZAddOptions{XX: true}, + want: 0, + wantErr: false, + }, + { + // Only update the elements where provided score is greater than current score if GT flag + // Return only the new elements added by default + name: "Only update the elements where provided score is greater than current score if GT flag", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "member1", Score: ss.Score(5.5)}, + {Value: "member2", Score: ss.Score(67.77)}, + {Value: "member3", Score: ss.Score(10)}, + }), + key: "key6", + entries: map[string]float64{ + "member1": 7.5, + "member4": 100.5, + "member5": 15, + }, + options: ZAddOptions{XX: true, CH: true, GT: true}, + want: 1, + wantErr: false, + }, + { + // Only update the elements where provided score is less than current score if LT flag is provided + // Return only the new elements added by default. + name: "Only update the elements where provided score is less than current score if LT flag is provided", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "member1", Score: ss.Score(5.5)}, + {Value: "member2", Score: ss.Score(67.77)}, + {Value: "member3", Score: ss.Score(10)}, + }), + key: "key7", + entries: map[string]float64{ + "member1": 3.5, + "member4": 100.5, + "member5": 15, + }, + options: ZAddOptions{XX: true, LT: true}, + want: 0, + wantErr: false, + }, + { + name: "Return all the elements that were updated AND added when CH flag is provided", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "member1", Score: ss.Score(5.5)}, + {Value: "member2", Score: ss.Score(67.77)}, + {Value: "member3", Score: ss.Score(10)}, + }), + key: "key8", + entries: map[string]float64{ + "member1": 3.5, + "member4": 100.5, + "member5": 15, + }, + options: ZAddOptions{XX: true, LT: true, CH: true}, + want: 1, + wantErr: false, + }, + { + name: "Increment the member by score", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "member1", Score: ss.Score(5.5)}, + {Value: "member2", Score: ss.Score(67.77)}, + {Value: "member3", Score: ss.Score(10)}, + }), + key: "key9", + entries: map[string]float64{ + "member3": 5.5, + }, + options: ZAddOptions{INCR: true}, + want: 0, + wantErr: false, + }, + { + name: "Fail when GT/LT flag is provided alongside NX flag", + preset: false, + presetValue: nil, + key: "key10", + entries: map[string]float64{ + "member1": 3.5, + "member5": 15, + }, + options: ZAddOptions{NX: true, LT: true, CH: true}, + want: 0, + wantErr: true, + }, + { + name: "Throw error when INCR flag is passed with more than one score/member pair", + preset: false, + presetValue: nil, + key: "key11", + entries: map[string]float64{ + "member1": 10.5, + "member2": 12.5, + }, + options: ZAddOptions{INCR: true}, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZAdd(tt.key, tt.entries, tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("ZADD() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ZADD() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZCARD(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + want int + wantErr bool + }{ + { + name: "Get cardinality of valid sorted set", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "member1", Score: ss.Score(5.5)}, + {Value: "member2", Score: ss.Score(67.77)}, + {Value: "member3", Score: ss.Score(10)}, + }), + key: "key1", + want: 3, + wantErr: false, + }, + { + name: "Return 0 when trying to get cardinality from non-existent key", + preset: false, + presetValue: nil, + key: "key2", + want: 0, + wantErr: false, + }, + { + name: "Return error when not a sorted set", + preset: true, + presetValue: "Default value", + key: "key3", + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZCard(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("ZCARD() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ZCARD() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZCOUNT(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + min float64 + max float64 + want int + wantErr bool + }{ + { + name: "Get entire count using infinity boundaries", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "member1", Score: ss.Score(5.5)}, + {Value: "member2", Score: ss.Score(67.77)}, + {Value: "member3", Score: ss.Score(10)}, + {Value: "member4", Score: ss.Score(1083.13)}, + {Value: "member5", Score: ss.Score(11)}, + {Value: "member6", Score: ss.Score(math.Inf(-1))}, + {Value: "member7", Score: ss.Score(math.Inf(1))}, + }), + key: "key1", + min: math.Inf(-1), + max: math.Inf(1), + want: 7, + wantErr: false, + }, + { + name: "Get count of sub-set from -inf to limit", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "member1", Score: ss.Score(5.5)}, + {Value: "member2", Score: ss.Score(67.77)}, + {Value: "member3", Score: ss.Score(10)}, + {Value: "member4", Score: ss.Score(1083.13)}, + {Value: "member5", Score: ss.Score(11)}, + {Value: "member6", Score: ss.Score(math.Inf(-1))}, + {Value: "member7", Score: ss.Score(math.Inf(1))}, + }), + key: "key2", + min: math.Inf(-1), + max: 90, + want: 5, + wantErr: false, + }, + { + name: "Get count of sub-set from bottom boundary to +inf limit", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "member1", Score: ss.Score(5.5)}, + {Value: "member2", Score: ss.Score(67.77)}, + {Value: "member3", Score: ss.Score(10)}, + {Value: "member4", Score: ss.Score(1083.13)}, + {Value: "member5", Score: ss.Score(11)}, + {Value: "member6", Score: ss.Score(math.Inf(-1))}, + {Value: "member7", Score: ss.Score(math.Inf(1))}, + }), + key: "key3", + min: 1000, + max: math.Inf(1), + want: 2, + wantErr: false, + }, + { + name: "Throw error when value at the key is not a sorted set", + preset: true, + presetValue: "Default value", + key: "key4", + min: 1, + max: 10, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZCount(tt.key, tt.min, tt.max) + if (err != nil) != tt.wantErr { + t.Errorf("ZCOUNT() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ZCOUNT() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZDIFF(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValues map[string]interface{} + withscores bool + keys []string + want map[string]float64 + wantErr bool + }{ + { + name: "Get the difference between 2 sorted sets without scores", + preset: true, + presetValues: map[string]interface{}{ + "key1": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, + {Value: "two", Score: 2}, + {Value: "three", Score: 3}, + {Value: "four", Score: 4}, + }), + "key2": ss.NewSortedSet([]ss.MemberParam{ + {Value: "three", Score: 3}, + {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, + {Value: "eight", Score: 8}, + }), + }, + withscores: false, + keys: []string{"key1", "key2"}, + want: map[string]float64{"one": 0, "two": 0}, + wantErr: false, + }, + { + name: "Get the difference between 2 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key3": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, + {Value: "two", Score: 2}, + {Value: "three", Score: 3}, + {Value: "four", Score: 4}, + }), + "key4": ss.NewSortedSet([]ss.MemberParam{ + {Value: "three", Score: 3}, + {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, + {Value: "eight", Score: 8}, + }), + }, + withscores: true, + keys: []string{"key3", "key4"}, + want: map[string]float64{"one": 1, "two": 2}, + wantErr: false, + }, + { + name: "Get the difference between 3 sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key5": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key6": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "key7": ss.NewSortedSet([]ss.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + withscores: true, + keys: []string{"key5", "key6", "key7"}, + want: map[string]float64{"three": 3, "four": 4, "five": 5, "six": 6}, + wantErr: false, + }, + { + name: "Return sorted set if only one key exists and is a sorted set", + preset: true, + presetValues: map[string]interface{}{ + "key8": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + withscores: true, + keys: []string{"key8", "non-existent-key-1", "non-existent-key-2", "non-existent-key-3"}, + want: map[string]float64{ + "one": 1, "two": 2, "three": 3, "four": 4, + "five": 5, "six": 6, "seven": 7, "eight": 8, + }, + wantErr: false, + }, + { + name: "Throw error when one of the keys is not a sorted set", + preset: true, + presetValues: map[string]interface{}{ + "key9": "Default value", + "key10": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "key11": ss.NewSortedSet([]ss.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + withscores: false, + keys: []string{"key9", "key10", "key11"}, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.ZDiff(tt.withscores, tt.keys...) + if (err != nil) != tt.wantErr { + t.Errorf("ZDIFF() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ZDIFF() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZDIFFSTORE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValues map[string]interface{} + destination string + keys []string + want int + wantErr bool + }{ + { + name: "Get the difference between 2 sorted sets", + preset: true, + presetValues: map[string]interface{}{ + "key1": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + "key2": ss.NewSortedSet([]ss.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "destination1", + keys: []string{"key1", "key2"}, + want: 2, + wantErr: false, + }, + { + name: "Get the difference between 3 sorted sets", + preset: true, + presetValues: map[string]interface{}{ + "key3": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key4": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "key5": ss.NewSortedSet([]ss.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "destination2", + keys: []string{"key3", "key4", "key5"}, + want: 4, + wantErr: false, + }, + { + name: "Return base sorted set element if base set is the only existing key provided and is a valid sorted set", + preset: true, + presetValues: map[string]interface{}{ + "key6": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "destination3", + keys: []string{"key6", "non-existent-key-1", "non-existent-key-2"}, + want: 8, + wantErr: false, + }, + { + name: "Throw error when base sorted set is not a set", + preset: true, + presetValues: map[string]interface{}{ + "key7": "Default value", + "key8": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "key9": ss.NewSortedSet([]ss.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "destination4", + keys: []string{"key7", "key8", "key9"}, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.ZDiffStore(tt.destination, tt.keys...) + if (err != nil) != tt.wantErr { + t.Errorf("ZDIFFSTORE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ZDIFFSTORE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZINCRBY(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + increment float64 + member string + want float64 + wantErr bool + }{ + { + name: "Successfully increment by int. Return the new score", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "key1", + increment: 5, + member: "one", + want: 6, + wantErr: false, + }, + { + name: "Successfully increment by float. Return new score", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "key2", + increment: 346.785, + member: "one", + want: 347.785, + }, + { + name: "Increment on non-existent sorted set will create the set with the member and increment as its score", + preset: false, + presetValue: nil, + key: "key3", + increment: 346.785, + member: "one", + want: 346.785, + wantErr: false, + }, + { // 4. + name: "Increment score to +inf", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "key4", + increment: math.Inf(1), + member: "one", + want: math.Inf(1), + wantErr: false, + }, + { + name: "Increment score to -inf", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "key5", + increment: math.Inf(-1), + member: "one", + want: math.Inf(-1), + wantErr: false, + }, + { + name: "Incrementing score by negative increment should lower the score", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "key6", + increment: -2.5, + member: "five", + want: 2.5, + wantErr: false, + }, + { + name: "Return error when attempting to increment on a value that is not a valid sorted set", + preset: true, + presetValue: "Default value", + key: "key7", + increment: -2.5, + member: "five", + want: 0, + wantErr: true, + }, + { + name: "Return error when trying to increment a member that already has score -inf", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: ss.Score(math.Inf(-1))}, + }), + key: "key8", + increment: 2.5, + member: "one", + want: 0, + wantErr: true, + }, + { + name: "Return error when trying to increment a member that already has score +inf", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: ss.Score(math.Inf(1))}, + }), + key: "key9", + increment: 2.5, + member: "one", + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZIncrBy(tt.key, tt.increment, tt.member) + if (err != nil) != tt.wantErr { + t.Errorf("ZINCRBY() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ZINCRBY() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZINTER(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValues map[string]interface{} + keys []string + options ZInterOptions + want map[string]float64 + wantErr bool + }{ + { + name: "Get the intersection between 2 sorted sets", + preset: true, + presetValues: map[string]interface{}{ + "key1": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + "key2": ss.NewSortedSet([]ss.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + keys: []string{"key1", "key2"}, + options: ZInterOptions{}, + want: map[string]float64{"three": 0, "four": 0, "five": 0}, + wantErr: false, + }, + { + // Get the intersection between 3 sorted sets with scores. + // By default, the SUM aggregate will be used. + name: "Get the intersection between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key3": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key4": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 8}, + }), + "key5": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + keys: []string{"key3", "key4", "key5"}, + options: ZInterOptions{WithScores: true}, + want: map[string]float64{"one": 3, "eight": 24}, + wantErr: false, + }, + { + // Get the intersection between 3 sorted sets with scores. + // Use MIN aggregate. + name: "Get the intersection between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key6": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key7": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key8": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + keys: []string{"key6", "key7", "key8"}, + options: ZInterOptions{Aggregate: "MIN", WithScores: true}, + want: map[string]float64{"one": 1, "eight": 8}, + wantErr: false, + }, + { + // Get the intersection between 3 sorted sets with scores. + // Use MAX aggregate. + preset: true, + presetValues: map[string]interface{}{ + "key9": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key10": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key11": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + keys: []string{"key9", "key10", "key11"}, + options: ZInterOptions{WithScores: true, Aggregate: "MAX"}, + want: map[string]float64{"one": 1000, "eight": 800}, + wantErr: false, + }, + { + // Get the intersection between 3 sorted sets with scores. + // Use SUM aggregate with weights modifier. + name: "Get the intersection between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key12": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key13": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key14": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + keys: []string{"key12", "key13", "key14"}, + options: ZInterOptions{WithScores: true, Aggregate: "SUM", Weights: []float64{1, 5, 3}}, + want: map[string]float64{"one": 3105, "eight": 2808}, + wantErr: false, + }, + { + // Get the intersection between 3 sorted sets with scores. + // Use MAX aggregate with added weights. + name: "Get the intersection between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key15": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key16": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key17": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + keys: []string{"key15", "key16", "key17"}, + options: ZInterOptions{WithScores: true, Aggregate: "MAX", Weights: []float64{1, 5, 3}}, + want: map[string]float64{"one": 3000, "eight": 2400}, + wantErr: false, + }, + { + // Get the intersection between 3 sorted sets with scores. + // Use MIN aggregate with added weights. + name: "Get the intersection between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key18": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key19": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key20": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + keys: []string{"key18", "key19", "key20"}, + options: ZInterOptions{WithScores: true, Aggregate: "MIN", Weights: []float64{1, 5, 3}}, + want: map[string]float64{"one": 5, "eight": 8}, + wantErr: false, + }, + { + name: "Throw an error if there are more weights than keys", + preset: true, + presetValues: map[string]interface{}{ + "key21": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key22": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + keys: []string{"key21", "key22"}, + options: ZInterOptions{Weights: []float64{1, 2, 3}}, + want: nil, + wantErr: true, + }, + { + name: "Throw an error if there are fewer weights than keys", + preset: true, + presetValues: map[string]interface{}{ + "key23": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key24": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + }), + "key25": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + keys: []string{"key23", "key24", "key25"}, + options: ZInterOptions{Weights: []float64{5, 4}}, + want: nil, + wantErr: true, + }, + { + name: "Throw an error if there are no keys provided", + preset: true, + presetValues: map[string]interface{}{ + "key26": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + "key27": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + "key28": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + keys: []string{}, + options: ZInterOptions{}, + want: nil, + wantErr: true, + }, + { + name: "Throw an error if any of the provided keys are not sorted sets", + preset: true, + presetValues: map[string]interface{}{ + "key29": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key30": "Default value", + "key31": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + keys: []string{"key29", "key30", "key31"}, + options: ZInterOptions{}, + want: nil, + wantErr: true, + }, + { + name: "If any of the keys does not exist, return an empty array", + preset: true, + presetValues: map[string]interface{}{ + "key32": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "key33": ss.NewSortedSet([]ss.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + keys: []string{"non-existent", "key32", "key33"}, + options: ZInterOptions{}, + want: map[string]float64{}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.ZInter(tt.keys, tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("ZINTER() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ZINTER() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZINTERSTORE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValues map[string]interface{} + destination string + keys []string + options ZInterStoreOptions + want int + wantErr bool + }{ + { + name: "Get the intersection between 2 sorted sets", + preset: true, + presetValues: map[string]interface{}{ + "key1": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + "key2": ss.NewSortedSet([]ss.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "destination1", + keys: []string{"key1", "key2"}, + options: ZInterStoreOptions{}, + want: 3, + wantErr: false, + }, + { + // Get the intersection between 3 sorted sets with scores. + // By default, the SUM aggregate will be used. + name: "Get the intersection between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key3": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key4": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 8}, + }), + "key5": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "destination2", + keys: []string{"key3", "key4", "key5"}, + options: ZInterStoreOptions{WithScores: true}, + want: 2, + wantErr: false, + }, + { + // Get the intersection between 3 sorted sets with scores. + // Use MIN aggregate. + name: "Get the intersection between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key6": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key7": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key8": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "destination3", + keys: []string{"key6", "key7", "key8"}, + options: ZInterStoreOptions{WithScores: true, Aggregate: "MIN"}, + want: 2, + wantErr: false, + }, + { + // Get the intersection between 3 sorted sets with scores. + // Use MAX aggregate. + name: "Get the intersection between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key9": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key10": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key11": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "destination4", + keys: []string{"key9", "key10", "key11"}, + options: ZInterStoreOptions{WithScores: true, Aggregate: "MAX"}, + want: 2, + wantErr: false, + }, + { + // Get the intersection between 3 sorted sets with scores. + // Use SUM aggregate with weights modifier. + name: "Get the intersection between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key12": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key13": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key14": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "destination5", + keys: []string{"key12", "key13", "key14"}, + options: ZInterStoreOptions{WithScores: true, Aggregate: "SUM", Weights: []float64{1, 5, 3}}, + want: 2, + wantErr: false, + }, + { + // Get the intersection between 3 sorted sets with scores. + // Use MAX aggregate with added weights. + name: "Get the intersection between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key15": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key16": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key17": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "destination6", + keys: []string{"key15", "key16", "key17"}, + options: ZInterStoreOptions{WithScores: true, Aggregate: "MAX", Weights: []float64{1, 5, 3}}, + want: 2, + wantErr: false, + }, + { + // Get the intersection between 3 sorted sets with scores. + // Use MIN aggregate with added weights. + name: "Get the intersection between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key18": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key19": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key20": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "destination7", + keys: []string{"key18", "key19", "key20"}, + options: ZInterStoreOptions{WithScores: true, Aggregate: "MIN", Weights: []float64{1, 5, 3}}, + want: 2, + wantErr: false, + }, + { + name: "Throw an error if there are more weights than keys", + preset: true, + presetValues: map[string]interface{}{ + "key21": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key22": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + destination: "destination8", + keys: []string{"key21", "key22"}, + options: ZInterStoreOptions{Weights: []float64{1, 2, 3}}, + want: 0, + wantErr: true, + }, + { + name: "Throw an error if there are fewer weights than keys", + preset: true, + presetValues: map[string]interface{}{ + "key23": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key24": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + }), + "key25": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + destination: "destination9", + keys: []string{"key23", "key24"}, + options: ZInterStoreOptions{Weights: []float64{5}}, + want: 0, + wantErr: true, + }, + { + name: "Throw an error if there are no keys provided", + preset: true, + presetValues: map[string]interface{}{ + "key26": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + "key27": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + "key28": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + destination: "destination10", + keys: []string{}, + options: ZInterStoreOptions{Weights: []float64{5, 4}}, + want: 0, + wantErr: true, + }, + { + name: "Throw an error if any of the provided keys are not sorted sets", + preset: true, + presetValues: map[string]interface{}{ + "key29": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key30": "Default value", + "key31": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + destination: "destination11", + keys: []string{"key29", "key30", "key31"}, + options: ZInterStoreOptions{}, + want: 0, + wantErr: true, + }, + { + name: "If any of the keys does not exist, return an empty array", + preset: true, + presetValues: map[string]interface{}{ + "key32": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "key33": ss.NewSortedSet([]ss.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "destination12", + keys: []string{"non-existent", "key32", "key33"}, + options: ZInterStoreOptions{}, + want: 0, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.ZInterStore(tt.destination, tt.keys, tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("ZINTERSTORE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ZINTERSTORE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZLEXCOUNT(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + min string + max string + want int + wantErr bool + }{ + { + name: "Get entire count using infinity boundaries", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "e", Score: ss.Score(1)}, + {Value: "f", Score: ss.Score(1)}, + {Value: "g", Score: ss.Score(1)}, + {Value: "h", Score: ss.Score(1)}, + {Value: "i", Score: ss.Score(1)}, + {Value: "j", Score: ss.Score(1)}, + {Value: "k", Score: ss.Score(1)}, + }), + key: "key1", + min: "f", + max: "j", + want: 5, + wantErr: false, + }, + { + name: "Return 0 when the members do not have the same score", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "a", Score: ss.Score(5.5)}, + {Value: "b", Score: ss.Score(67.77)}, + {Value: "c", Score: ss.Score(10)}, + {Value: "d", Score: ss.Score(1083.13)}, + {Value: "e", Score: ss.Score(11)}, + {Value: "f", Score: ss.Score(math.Inf(-1))}, + {Value: "g", Score: ss.Score(math.Inf(1))}, + }), + key: "key2", + min: "a", + max: "b", + want: 0, + wantErr: false, + }, + { + name: "Return 0 when the key does not exist", + preset: false, + presetValue: nil, + key: "key3", + min: "a", + max: "z", + want: 0, + wantErr: false, + }, + { + name: "Return error when the value at the key is not a sorted set", + preset: true, + presetValue: "Default value", + key: "key4", + min: "a", + max: "z", + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZLexCount(tt.key, tt.min, tt.max) + if (err != nil) != tt.wantErr { + t.Errorf("ZLEXCOUNT() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ZLEXCOUNT() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZMPOP(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValues map[string]interface{} + keys []string + options ZMPopOptions + want [][]string + wantErr bool + }{ + { + name: "Successfully pop one min element by default", + preset: true, + presetValues: map[string]interface{}{ + "key1": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + }, + keys: []string{"key1"}, + options: ZMPopOptions{}, + want: [][]string{ + {"one", "1"}, + }, + wantErr: false, + }, + { + name: "Successfully pop one min element by specifying MIN", + preset: true, + presetValues: map[string]interface{}{ + "key2": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + }, + keys: []string{"key2"}, + options: ZMPopOptions{Min: true}, + want: [][]string{ + {"one", "1"}, + }, + wantErr: false, + }, + { + name: "Successfully pop one max element by specifying MAX modifier", + preset: true, + presetValues: map[string]interface{}{ + "key3": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + }, + keys: []string{"key3"}, + options: ZMPopOptions{Max: true}, + want: [][]string{ + {"five", "5"}, + }, + wantErr: false, + }, + { + name: "Successfully pop multiple min elements", + preset: true, + presetValues: map[string]interface{}{ + "key4": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + }), + }, + keys: []string{"key4"}, + options: ZMPopOptions{Min: true, Count: 5}, + want: [][]string{ + {"one", "1"}, {"two", "2"}, {"three", "3"}, + {"four", "4"}, {"five", "5"}, + }, + wantErr: false, + }, + { + name: "Successfully pop multiple max elements", + preset: true, + presetValues: map[string]interface{}{ + "key5": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + }), + }, + keys: []string{"key5"}, + options: ZMPopOptions{Max: true, Count: 5}, + want: [][]string{{"two", "2"}, {"three", "3"}, {"four", "4"}, {"five", "5"}, {"six", "6"}}, + wantErr: false, + }, + { + name: "Successfully pop elements from the first set which is non-empty", + preset: true, + presetValues: map[string]interface{}{ + "key6": ss.NewSortedSet([]ss.MemberParam{}), + "key7": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + }), + }, + keys: []string{"key6", "key7"}, + options: ZMPopOptions{Max: true, Count: 5}, + want: [][]string{{"two", "2"}, {"three", "3"}, {"four", "4"}, {"five", "5"}, {"six", "6"}}, + wantErr: false, + }, + { + name: "Skip the non-set items and pop elements from the first non-empty sorted set found", + preset: true, + presetValues: map[string]interface{}{ + "key8": "Default value", + "key9": 56, + "key10": ss.NewSortedSet([]ss.MemberParam{}), + "key11": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + }), + }, + keys: []string{"key8", "key9", "key10", "key11"}, + options: ZMPopOptions{Min: true, Count: 5}, + want: [][]string{{"one", "1"}, {"two", "2"}, {"three", "3"}, {"four", "4"}, {"five", "5"}}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.ZMPop(tt.keys, tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("ZMPOP() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !internal.CompareNestedStringArrays(got, tt.want) { + t.Errorf("ZMPOP() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZMSCORE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + members []string + want []interface{} + wantErr bool + }{ + { // Return multiple scores from the sorted set. + // Return nil for elements that do not exist in the sorted set. + name: "Return multiple scores from the sorted set", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1.1}, {Value: "two", Score: 245}, + {Value: "three", Score: 3}, {Value: "four", Score: 4.055}, + {Value: "five", Score: 5}, + }), + key: "key1", + members: []string{"one", "none", "two", "one", "three", "four", "none", "five"}, + want: []interface{}{"1.1", nil, "245", "1.1", "3", "4.055", nil, "5"}, + wantErr: false, + }, + { + name: "If key does not exist, return empty array", + preset: false, + presetValue: nil, + key: "key2", + members: []string{"one", "two", "three", "four"}, + want: []interface{}{}, + wantErr: false, + }, + { + name: "Throw error when trying to find scores from elements that are not sorted sets", + preset: true, + presetValue: "Default value", + key: "key3", + members: []string{"one", "two", "three"}, + want: []interface{}{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZMScore(tt.key, tt.members...) + if (err != nil) != tt.wantErr { + t.Errorf("ZMSCORE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("ZMSCORE() got length = %v, want length %v", len(got), len(tt.want)) + return + } + for i := 0; i < len(got); i++ { + if got[i] == nil && tt.want[i] == nil { + continue + } + if (got[i] == nil) != (tt.want[i] == nil) { + t.Errorf("ZMSCORE() got[%d] = %v, want[%d] %v", i, got, i, tt.want) + } + wantf, _ := strconv.ParseFloat(tt.want[i].(string), 64) + if got[i] != wantf { + t.Errorf("ZMSCORE() got[%d] = %v, want[%d] %v", i, got[i], i, wantf) + } + } + }) + } +} + +func TestSugarDB_ZPOP(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + count uint + popFunc func(key string, count uint) ([][]string, error) + want [][]string + wantErr bool + }{ + { + name: "Successfully pop one min element", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "key1", + count: 1, + popFunc: server.ZPopMin, + want: [][]string{ + {"one", "1"}, + }, + wantErr: false, + }, + { + name: "Successfully pop one max element", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "key2", + count: 1, + popFunc: server.ZPopMax, + want: [][]string{{"five", "5"}}, + wantErr: false, + }, + { + name: "Successfully pop multiple min elements", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + }), + popFunc: server.ZPopMin, + key: "key3", + count: 5, + want: [][]string{ + {"one", "1"}, {"two", "2"}, {"three", "3"}, + {"four", "4"}, {"five", "5"}, + }, + wantErr: false, + }, + { + name: "Successfully pop multiple max elements", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + }), + popFunc: server.ZPopMax, + key: "key4", + count: 5, + want: [][]string{{"two", "2"}, {"three", "3"}, {"four", "4"}, {"five", "5"}, {"six", "6"}}, + wantErr: false, + }, + { + name: "Throw an error when trying to pop from an element that's not a sorted set", + preset: true, + presetValue: "Default value", + popFunc: server.ZPopMin, + key: "key5", + count: 1, + want: [][]string{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := tt.popFunc(tt.key, tt.count) + if (err != nil) != tt.wantErr { + t.Errorf("ZPOPMAX() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !internal.CompareNestedStringArrays(got, tt.want) { + t.Errorf("ZPOPMAX() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZRANDMEMBER(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + count int + withscores bool + want int + wantErr bool + }{ + { // Return multiple random elements without removing them. + // Count is positive, do not allow repeated elements. + name: "Return multiple random elements without removing them", + preset: true, + key: "key1", + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + count: 3, + withscores: false, + want: 3, + wantErr: false, + }, + { + // Return multiple random elements and their scores without removing them. + // Count is negative, so allow repeated numbers. + name: "Return multiple random elements and their scores without removing them", + preset: true, + key: "key2", + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + count: -5, + withscores: true, + want: 5, + wantErr: false, + }, + { + name: "Return error when the source key is not a sorted set", + preset: true, + key: "key3", + presetValue: "Default value", + count: 1, + withscores: false, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZRandMember(tt.key, tt.count, tt.withscores) + if (err != nil) != tt.wantErr { + t.Errorf("ZRANDMEMBER() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != tt.want { + t.Errorf("ZRANDMEMBER() got = %v, want %v", len(got), tt.want) + } + }) + } +} + +func TestSugarDB_ZRANGE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + start string + stop string + options ZRangeOptions + want map[string]float64 + wantErr bool + }{ + { + name: "Get elements withing score range without score", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + key: "key1", + start: "3", + stop: "7", + options: ZRangeOptions{ByScore: true}, + want: map[string]float64{"three": 0, "four": 0, "five": 0, "six": 0, "seven": 0}, + wantErr: false, + }, + { + name: "Get elements within score range with score", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + key: "key2", + start: "3", + stop: "7", + options: ZRangeOptions{ByScore: true, WithScores: true}, + want: map[string]float64{"three": 3, "four": 4, "five": 5, "six": 6, "seven": 7}, + wantErr: false, + }, + { + // Get elements within score range with offset and limit. + // Offset and limit are in where we start and stop counting in the original sorted set (NOT THE RESULT). + name: "Get elements within score range with offset and limit", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + key: "key3", + start: "3", + stop: "7", + options: ZRangeOptions{WithScores: true, ByScore: true, Offset: 2, Count: 4}, + want: map[string]float64{"three": 3, "four": 4, "five": 5}, + wantErr: false, + }, + { + name: "Get elements within lex range without score", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "a", Score: 1}, {Value: "e", Score: 1}, + {Value: "b", Score: 1}, {Value: "f", Score: 1}, + {Value: "c", Score: 1}, {Value: "g", Score: 1}, + {Value: "d", Score: 1}, {Value: "h", Score: 1}, + }), + key: "key4", + start: "c", + stop: "g", + options: ZRangeOptions{ByLex: true}, + want: map[string]float64{"c": 0, "d": 0, "e": 0, "f": 0, "g": 0}, + wantErr: false, + }, + { + name: "Get elements within lex range with score", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "a", Score: 1}, {Value: "e", Score: 1}, + {Value: "b", Score: 1}, {Value: "f", Score: 1}, + {Value: "c", Score: 1}, {Value: "g", Score: 1}, + {Value: "d", Score: 1}, {Value: "h", Score: 1}, + }), + key: "key5", + start: "a", + stop: "f", + options: ZRangeOptions{ByLex: true, WithScores: true}, + want: map[string]float64{"a": 1, "b": 1, "c": 1, "d": 1, "e": 1, "f": 1}, + wantErr: false, + }, + { + // Get elements within lex range with offset and limit. + // Offset and limit are in where we start and stop counting in the original sorted set (NOT THE RESULT). + name: "Get elements within lex range with offset and limit", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 1}, + {Value: "c", Score: 1}, {Value: "d", Score: 1}, + {Value: "e", Score: 1}, {Value: "f", Score: 1}, + {Value: "g", Score: 1}, {Value: "h", Score: 1}, + }), + key: "key6", + start: "a", + stop: "h", + options: ZRangeOptions{WithScores: true, ByLex: true, Offset: 2, Count: 4}, + want: map[string]float64{"c": 1, "d": 1, "e": 1}, + wantErr: false, + }, + { + name: "Return an empty map when we use BYLEX while elements have different scores", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 5}, + {Value: "c", Score: 2}, {Value: "d", Score: 6}, + {Value: "e", Score: 3}, {Value: "f", Score: 7}, + {Value: "g", Score: 4}, {Value: "h", Score: 8}, + }), + key: "key7", + start: "a", + stop: "h", + options: ZRangeOptions{WithScores: true, ByLex: true, Offset: 2, Count: 4}, + want: map[string]float64{}, + wantErr: false, + }, + { + name: "Throw error when the key does not hold a sorted set", + preset: true, + presetValue: "Default value", + key: "key10", + start: "a", + stop: "h", + options: ZRangeOptions{WithScores: true, ByLex: true, Offset: 2, Count: 4}, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZRange(tt.key, tt.start, tt.stop, tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("ZRANGE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ZRANGE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZRANGESTORE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValues map[string]interface{} + destination string + source string + start string + stop string + options ZRangeStoreOptions + want int + wantErr bool + }{ + { + name: "Get elements within score range without score", + preset: true, + presetValues: map[string]interface{}{ + "key1": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "destination1", + source: "key1", + start: "3", + stop: "7", + options: ZRangeStoreOptions{ByScore: true}, + want: 5, + wantErr: false, + }, + { + name: "Get elements within score range with score", + preset: true, + presetValues: map[string]interface{}{ + "key2": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "destination2", + source: "key2", + start: "3", + stop: "7", + options: ZRangeStoreOptions{WithScores: true, ByScore: true}, + want: 5, + wantErr: false, + }, + { + // Get elements within score range with offset and limit. + // Offset and limit are in where we start and stop counting in the original sorted set (NOT THE RESULT). + name: "Get elements within score range with offset and limit", + preset: true, + presetValues: map[string]interface{}{ + "key3": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "destination3", + source: "key3", + start: "3", + stop: "7", + options: ZRangeStoreOptions{ByScore: true, WithScores: true, Offset: 2, Count: 4}, + want: 3, + wantErr: false, + }, + { + name: "Get elements within lex range without score", + preset: true, + presetValues: map[string]interface{}{ + "key4": ss.NewSortedSet([]ss.MemberParam{ + {Value: "a", Score: 1}, {Value: "e", Score: 1}, + {Value: "b", Score: 1}, {Value: "f", Score: 1}, + {Value: "c", Score: 1}, {Value: "g", Score: 1}, + {Value: "d", Score: 1}, {Value: "h", Score: 1}, + }), + }, + destination: "destination4", + source: "key4", + start: "c", + stop: "g", + options: ZRangeStoreOptions{ByLex: true}, + want: 5, + wantErr: false, + }, + { + name: "Get elements within lex range with score", + preset: true, + presetValues: map[string]interface{}{ + "key5": ss.NewSortedSet([]ss.MemberParam{ + {Value: "a", Score: 1}, {Value: "e", Score: 1}, + {Value: "b", Score: 1}, {Value: "f", Score: 1}, + {Value: "c", Score: 1}, {Value: "g", Score: 1}, + {Value: "d", Score: 1}, {Value: "h", Score: 1}, + }), + }, + destination: "destination5", + source: "key5", + start: "a", + stop: "f", + options: ZRangeStoreOptions{ByLex: true, WithScores: true}, + want: 6, + wantErr: false, + }, + { + // Get elements within lex range with offset and limit. + // Offset and limit are in where we start and stop counting in the original sorted set (NOT THE RESULT). + name: "Get elements within lex range with offset and limit", + preset: true, + presetValues: map[string]interface{}{ + "key6": ss.NewSortedSet([]ss.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 1}, + {Value: "c", Score: 1}, {Value: "d", Score: 1}, + {Value: "e", Score: 1}, {Value: "f", Score: 1}, + {Value: "g", Score: 1}, {Value: "h", Score: 1}, + }), + }, + destination: "destination6", + source: "key6", + start: "a", + stop: "h", + options: ZRangeStoreOptions{WithScores: true, ByLex: true, Offset: 2, Count: 4}, + want: 3, + wantErr: false, + }, + { + // Get elements within lex range with offset and limit + reverse the results. + // Offset and limit are in where we start and stop counting in the original sorted set (NOT THE RESULT). + // REV reverses the original set before getting the range. + name: "Get elements within lex range with offset and limit + reverse the results", + preset: true, + presetValues: map[string]interface{}{ + "key7": ss.NewSortedSet([]ss.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 1}, + {Value: "c", Score: 1}, {Value: "d", Score: 1}, + {Value: "e", Score: 1}, {Value: "f", Score: 1}, + {Value: "g", Score: 1}, {Value: "h", Score: 1}, + }), + }, + destination: "destination7", + source: "key7", + start: "a", + stop: "h", + options: ZRangeStoreOptions{WithScores: true, ByLex: true, Offset: 2, Count: 4}, + want: 3, + wantErr: false, + }, + { + name: "Return an empty slice when we use BYLEX while elements have different scores", + preset: true, + presetValues: map[string]interface{}{ + "key8": ss.NewSortedSet([]ss.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 5}, + {Value: "c", Score: 2}, {Value: "d", Score: 6}, + {Value: "e", Score: 3}, {Value: "f", Score: 7}, + {Value: "g", Score: 4}, {Value: "h", Score: 8}, + }), + }, + destination: "destination8", + source: "key8", + start: "a", + stop: "h", + options: ZRangeStoreOptions{WithScores: true, ByLex: true, Offset: 2, Count: 4}, + want: 0, + wantErr: false, + }, + { + name: "Throw error when the key does not hold a sorted set", + preset: true, + presetValues: map[string]interface{}{ + "key9": "Default value", + }, + destination: "destination9", + source: "key9", + start: "a", + stop: "h", + options: ZRangeStoreOptions{WithScores: true, ByLex: true, Offset: 2, Count: 4}, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.ZRangeStore(tt.destination, tt.source, tt.start, tt.stop, tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("ZRANGESTORE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ZRANGESTORE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZRANK(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + member string + withscores bool + want map[int]float64 + wantErr bool + }{ + { + name: "1. Return element's rank from a sorted set", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "key1", + member: "four", + withscores: false, + want: map[int]float64{3: 0}, + wantErr: false, + }, + { + name: "2. Return element's rank from a sorted set with its score", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100.1}, {Value: "two", Score: 245}, + {Value: "three", Score: 305.43}, {Value: "four", Score: 411.055}, + {Value: "five", Score: 500}, + }), + key: "key2", + member: "four", + withscores: true, + want: map[int]float64{3: 411.055}, + wantErr: false, + }, + { + name: "3. If key does not exist, return nil value", + preset: false, + presetValue: nil, + key: "key3", + member: "one", + withscores: false, + want: map[int]float64{}, + wantErr: false, + }, + { + name: "4. If key exists and is a sorted set, but the member does not exist, return nil", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1.1}, {Value: "two", Score: 245}, + {Value: "three", Score: 3}, {Value: "four", Score: 4.055}, + {Value: "five", Score: 5}, + }), + key: "key4", + member: "non-existent", + withscores: false, + want: map[int]float64{}, + wantErr: false, + }, + { + name: "5. Throw error when trying to find scores from elements that are not sorted sets", + preset: true, + presetValue: "Default value", + key: "key5", + member: "one", + withscores: false, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZRank(tt.key, tt.member, tt.withscores) + if (err != nil) != tt.wantErr { + t.Errorf("ZRANK() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ZRANK() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZREM(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + members []string + want int + wantErr bool + }{ + { + // Successfully remove multiple elements from sorted set, skipping non-existent members. + // Return deleted count. + name: "Successfully remove multiple elements from sorted set, skipping non-existent members", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + key: "key1", + members: []string{"three", "four", "five", "none", "six", "none", "seven"}, + want: 5, + wantErr: false, + }, + { + name: "If key does not exist, return 0", + preset: false, + presetValue: nil, + key: "key2", + members: []string{"member"}, + want: 0, + wantErr: false, + }, + { + name: "Return error key is not a sorted set", + preset: true, + presetValue: "Default value", + key: "key3", + members: []string{"member"}, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZRem(tt.key, tt.members...) + if (err != nil) != tt.wantErr { + t.Errorf("ZREM() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ZREM() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZREMRANGEBYSCORE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + min float64 + max float64 + want int + wantErr bool + }{ + { + name: "Successfully remove multiple elements with scores inside the provided range", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + key: "key1", + min: 3, + max: 7, + want: 5, + wantErr: false, + }, + { + name: "If key does not exist, return 0", + preset: false, + key: "key2", + min: 2, + max: 4, + want: 0, + wantErr: false, + }, + { + name: "Return error key is not a sorted set", + preset: true, + presetValue: "Default value", + key: "key3", + min: 2, + max: 4, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZRemRangeByScore(tt.key, tt.min, tt.max) + if (err != nil) != tt.wantErr { + t.Errorf("ZREMRANGEBYSCORE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ZREMRANGEBYSCORE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZSCORE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + member string + want interface{} + wantErr bool + }{ + { + name: "Return score from a sorted set", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1.1}, {Value: "two", Score: 245}, + {Value: "three", Score: 3}, {Value: "four", Score: 4.055}, + {Value: "five", Score: 5}, + }), + key: "key1", + member: "four", + want: 4.055, + wantErr: false, + }, + { + name: "If key does not exist, return nil value", + preset: false, + presetValue: nil, + key: "key2", + member: "one", + want: nil, + wantErr: false, + }, + { + name: "If key exists and is a sorted set, but the member does not exist, return nil", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1.1}, {Value: "two", Score: 245}, + {Value: "three", Score: 3}, {Value: "four", Score: 4.055}, + {Value: "five", Score: 5}, + }), + key: "key3", + member: "non-existent", + want: nil, + wantErr: false, + }, + { + name: "Throw error when trying to find scores from elements that are not sorted sets", + preset: true, + presetValue: "Default value", + key: "key4", + member: "one", + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZScore(tt.key, tt.member) + if (err != nil) != tt.wantErr { + t.Errorf("ZSCORE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ZSCORE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZUNION(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValues map[string]interface{} + keys []string + options ZUnionOptions + want map[string]float64 + wantErr bool + }{ + { + name: "Get the union between 2 sorted sets", + preset: true, + presetValues: map[string]interface{}{ + "key1": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + "key2": ss.NewSortedSet([]ss.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + keys: []string{"key1", "key2"}, + options: ZUnionOptions{}, + want: map[string]float64{ + "one": 0, "two": 0, "three": 0, "four": 0, + "five": 0, "six": 0, "seven": 0, "eight": 0, + }, + wantErr: false, + }, + { + // Get the union between 3 sorted sets with scores. + // By default, the SUM aggregate will be used. + name: "Get the union between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key3": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key4": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 8}, + }), + "key5": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 36}, + }), + }, + keys: []string{"key3", "key4", "key5"}, + options: ZUnionOptions{WithScores: true}, + want: map[string]float64{ + "one": 3, "two": 4, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 24, "nine": 9, + "ten": 10, "eleven": 11, "twelve": 24, "thirty-six": 72, + }, + wantErr: false, + }, + { + // Get the union between 3 sorted sets with scores. + // Use MIN aggregate. + name: "Get the union between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key6": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key7": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key8": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 72}, + }), + }, + keys: []string{"key6", "key7", "key8"}, + options: ZUnionOptions{WithScores: true, Aggregate: "MIN"}, + want: map[string]float64{ + "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, + "ten": 10, "eleven": 11, "twelve": 12, "thirty-six": 36, + }, + wantErr: false, + }, + { + // Get the union between 3 sorted sets with scores. + // Use MAX aggregate. + name: "Get the union between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key9": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key10": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key11": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 72}, + }), + }, + keys: []string{"key9", "key10", "key11"}, + options: ZUnionOptions{WithScores: true, Aggregate: "MAX"}, + want: map[string]float64{ + "one": 1000, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 800, "nine": 9, + "ten": 10, "eleven": 11, "twelve": 12, "thirty-six": 72, + }, + wantErr: false, + }, + { + // Get the union between 3 sorted sets with scores. + // Use SUM aggregate with weights modifier. + name: "Get the union between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key12": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key13": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key14": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + keys: []string{"key12", "key13", "key14"}, + options: ZUnionOptions{WithScores: true, Aggregate: "SUM", Weights: []float64{1, 2, 3}}, + want: map[string]float64{ + "one": 3102, "two": 6, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 2568, + "nine": 27, "ten": 30, "eleven": 22, "twelve": 60, "thirty-six": 72, + }, + wantErr: false, + }, + { + // Get the union between 3 sorted sets with scores. + // Use MAX aggregate with added weights. + name: "Get the union between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key15": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key16": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key17": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + keys: []string{"key15", "key16", "key17"}, + options: ZUnionOptions{WithScores: true, Aggregate: "MAX", Weights: []float64{1, 2, 3}}, + want: map[string]float64{ + "one": 3000, "two": 4, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 2400, + "nine": 27, "ten": 30, "eleven": 22, "twelve": 36, "thirty-six": 72, + }, + wantErr: false, + }, + { + // Get the union between 3 sorted sets with scores. + // Use MIN aggregate with added weights. + name: "Get the union between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key18": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key19": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key20": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + keys: []string{"key18", "key19", "key20"}, + options: ZUnionOptions{WithScores: true, Aggregate: "MIN", Weights: []float64{1, 2, 3}}, + want: map[string]float64{ + "one": 2, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 27, + "ten": 30, "eleven": 22, "twelve": 24, "thirty-six": 72, + }, + wantErr: false, + }, + { + name: "Throw an error if there are more weights than keys", + preset: true, + presetValues: map[string]interface{}{ + "key21": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key22": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + keys: []string{"key21", "key22"}, + options: ZUnionOptions{Weights: []float64{1, 2, 3}}, + want: nil, + wantErr: true, + }, + { + name: "Throw an error if there are fewer weights than keys", + preset: true, + presetValues: map[string]interface{}{ + "key23": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key24": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + }), + "key25": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + keys: []string{"key23", "key24", "key25"}, + options: ZUnionOptions{Weights: []float64{5, 4}}, + want: nil, + wantErr: true, + }, + { + name: "Throw an error if there are no keys provided", + preset: true, + presetValues: map[string]interface{}{ + "key26": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + "key27": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + "key28": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + keys: []string{}, + options: ZUnionOptions{Weights: []float64{5, 4}}, + want: nil, + wantErr: true, + }, + { + name: "Throw an error if any of the provided keys are not sorted sets", + preset: true, + presetValues: map[string]interface{}{ + "key29": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key30": "Default value", + "key31": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + keys: []string{"key29", "key30", "key31"}, + options: ZUnionOptions{}, + want: nil, + wantErr: true, + }, + { + name: "If any of the keys does not exist, skip it", + preset: true, + presetValues: map[string]interface{}{ + "key32": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "key33": ss.NewSortedSet([]ss.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + keys: []string{"non-existent", "key32", "key33"}, + options: ZUnionOptions{}, + want: map[string]float64{ + "one": 0, "two": 0, "thirty-six": 0, "twelve": 0, "eleven": 0, + "seven": 0, "eight": 0, "nine": 0, "ten": 0, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.ZUnion(tt.keys, tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("ZUNION() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ZUNION() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZUNIONSTORE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValues map[string]interface{} + destination string + keys []string + options ZUnionStoreOptions + want int + wantErr bool + }{ + { + name: "Get the union between 2 sorted sets", + preset: true, + presetValues: map[string]interface{}{ + "key1": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + "key2": ss.NewSortedSet([]ss.MemberParam{ + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + }, + destination: "destination1", + keys: []string{"key1", "key2"}, + options: ZUnionStoreOptions{}, + want: 8, + wantErr: false, + }, + { + // Get the union between 3 sorted sets with scores. + // By default, the SUM aggregate will be used. + name: "Get the union between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key3": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key4": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 8}, + }), + "key5": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 36}, + }), + }, + destination: "destination2", + keys: []string{"key3", "key4", "key5"}, + options: ZUnionStoreOptions{WithScores: true}, + want: 13, + wantErr: false, + }, + { + // Get the union between 3 sorted sets with scores. + // Use MIN aggregate. + name: "Get the union between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key6": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key7": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key8": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 72}, + }), + }, + destination: "destination3", + keys: []string{"key6", "key7", "key8"}, + options: ZUnionStoreOptions{WithScores: true, Aggregate: "MIN"}, + want: 13, + wantErr: false, + }, + { + // Get the union between 3 sorted sets with scores. + // Use MAX aggregate. + name: "Get the union between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key9": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key10": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key11": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, {Value: "thirty-six", Score: 72}, + }), + }, + destination: "destination4", + keys: []string{"key9", "key10", "key11"}, + options: ZUnionStoreOptions{WithScores: true, Aggregate: "MAX"}, + want: 13, + wantErr: false, + }, + { + // Get the union between 3 sorted sets with scores. + // Use SUM aggregate with weights modifier. + name: "Get the union between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key12": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key13": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key14": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "destination5", + keys: []string{"key12", "key13", "key14"}, + options: ZUnionStoreOptions{WithScores: true, Aggregate: "SUM", Weights: []float64{1, 2, 3}}, + want: 13, + wantErr: false, + }, + { + // Get the union between 3 sorted sets with scores. + // Use MAX aggregate with added weights. + name: "Get the union between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key15": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key16": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key17": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "destination6", + keys: []string{"key15", "key16", "key17"}, + options: ZUnionStoreOptions{WithScores: true, Aggregate: "MAX", Weights: []float64{1, 2, 3}}, + want: 13, + wantErr: false, + }, + { + // Get the union between 3 sorted sets with scores. + // Use MIN aggregate with added weights. + name: "Get the union between 3 sorted sets with scores", + preset: true, + presetValues: map[string]interface{}{ + "key18": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key19": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, {Value: "eight", Score: 80}, + }), + "key20": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1000}, {Value: "eight", Score: 800}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "destination7", + keys: []string{"destination7", "key18", "key19", "key20"}, + options: ZUnionStoreOptions{WithScores: true, Aggregate: "MIN", Weights: []float64{1, 2, 3}}, + want: 13, + wantErr: false, + }, + { + name: "Throw an error if there are more weights than keys", + preset: true, + presetValues: map[string]interface{}{ + "key21": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key22": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + destination: "destination8", + keys: []string{"key21", "key22"}, + options: ZUnionStoreOptions{Weights: []float64{1, 2, 3}}, + want: 0, + wantErr: true, + }, + { + name: "Throw an error if there are fewer weights than keys", + preset: true, + presetValues: map[string]interface{}{ + "key23": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key24": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + }), + "key25": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + destination: "destination9", + keys: []string{"key23", "key24", "key25"}, + options: ZUnionStoreOptions{Weights: []float64{5, 4}}, + want: 0, + wantErr: true, + }, + { + name: "Throw an error if any of the provided keys are not sorted sets", + preset: true, + presetValues: map[string]interface{}{ + "key29": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + }), + "key30": "Default value", + "key31": ss.NewSortedSet([]ss.MemberParam{{Value: "one", Score: 1}}), + }, + destination: "destination11", + keys: []string{"key29", "key30", "key31"}, + options: ZUnionStoreOptions{}, + want: 0, + wantErr: true, + }, + { + name: "If any of the keys does not exist, skip it", + preset: true, + presetValues: map[string]interface{}{ + "key32": ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "thirty-six", Score: 36}, {Value: "twelve", Score: 12}, + {Value: "eleven", Score: 11}, + }), + "key33": ss.NewSortedSet([]ss.MemberParam{ + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + {Value: "twelve", Score: 12}, + }), + }, + destination: "destination12", + keys: []string{"non-existent", "key32", "key33"}, + want: 9, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + for k, v := range tt.presetValues { + err := presetValue(server, context.Background(), k, v) + if err != nil { + t.Error(err) + return + } + } + } + got, err := server.ZUnionStore(tt.destination, tt.keys, tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("ZUNIONSTORE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ZUNIONSTORE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZRevRank(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + preset bool + presetValue interface{} + key string + member string + withscores bool + want map[int]float64 + wantErr bool + }{ + { + name: "1. Return element's rank from a sorted set", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, + }), + key: "key1", + member: "four", + withscores: false, + want: map[int]float64{1: 0}, + wantErr: false, + }, + { + name: "2. Return element's rank from a sorted set with its score", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 100.1}, {Value: "two", Score: 245}, + {Value: "three", Score: 305.43}, {Value: "four", Score: 411.055}, + {Value: "five", Score: 500}, + }), + key: "key2", + member: "four", + withscores: true, + want: map[int]float64{1: 411.055}, + wantErr: false, + }, + { + name: "3. If key does not exist, return empty map", + preset: false, + presetValue: nil, + key: "key3", + member: "one", + withscores: false, + want: map[int]float64{}, + wantErr: false, + }, + { + name: "4. If key exists and is a sorted set, but the member does not exist, return nil", + preset: true, + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1.1}, {Value: "two", Score: 245}, + {Value: "three", Score: 3}, {Value: "four", Score: 4.055}, + {Value: "five", Score: 5}, + }), + key: "key4", + member: "non-existent", + withscores: false, + want: map[int]float64{}, + wantErr: false, + }, + { + name: "5. Throw error when trying to find scores from elements that are not sorted sets", + preset: true, + presetValue: "Default value", + key: "key5", + member: "one", + withscores: false, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preset { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZRevRank(tt.key, tt.member, tt.withscores) + if (err != nil) != tt.wantErr { + t.Errorf("ZREVRANK() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ZREVRANK() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZRemRangeByLex(t *testing.T) { + server := createSugarDB() + tests := []struct { + name string + key string + presetValue interface{} + min string + max string + want int + wantErr bool + }{ + { + name: "1. Successfully remove multiple elements with scores inside the provided range", + key: "ZremRangeByLexKey1", + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 1}, + {Value: "c", Score: 1}, {Value: "d", Score: 1}, + {Value: "e", Score: 1}, {Value: "f", Score: 1}, + {Value: "g", Score: 1}, {Value: "h", Score: 1}, + {Value: "i", Score: 1}, {Value: "j", Score: 1}, + }), + min: "a", + max: "d", + want: 4, + wantErr: false, + }, + { + name: "2. Return 0 if the members do not have the same score", + key: "ZremRangeByLexKey2", + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "a", Score: 1}, {Value: "b", Score: 2}, + {Value: "c", Score: 3}, {Value: "d", Score: 4}, + {Value: "e", Score: 5}, {Value: "f", Score: 6}, + {Value: "g", Score: 7}, {Value: "h", Score: 8}, + {Value: "i", Score: 9}, {Value: "j", Score: 10}, + }), + min: "d", + max: "g", + want: 0, + wantErr: false, + }, + { + name: "3. If key does not exist, return 0", + key: "ZremRangeByLexKey3", + presetValue: nil, + min: "2", + max: "4", + want: 0, + wantErr: false, + }, + { + name: "4. Return error key is not a sorted set", + key: "ZremRangeByLexKey4", + presetValue: "Default value", + min: "a", + max: "d", + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZRemRangeByLex(tt.key, tt.min, tt.max) + if (err != nil) != tt.wantErr { + t.Errorf("ZRemRangeByLex() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ZRemRangeByLex() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_ZRemRangeByRank(t *testing.T) { + server := createSugarDB() + tests := []struct { + name string + key string + presetValue interface{} + min int + max int + want int + wantErr bool + }{ + { + name: "1. Successfully remove multiple elements within range", + key: "ZremRangeByRankKey1", + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + min: 0, + max: 5, + want: 6, + wantErr: false, + }, + { + name: "2. Establish boundaries from the end of the set when negative boundaries are provided", + key: "ZremRangeByRankKey2", + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + min: -6, + max: -3, + want: 4, + wantErr: false, + }, + { + name: "3. If key does not exist, return 0", + key: "ZremRangeByRankKey3", + presetValue: nil, + min: 2, + max: 4, + want: 0, + wantErr: false, + }, + { + name: "4. Return error key is not a sorted set", + presetValue: "Default value", + key: "ZremRangeByRankKey3", + min: 4, + max: 4, + want: 0, + wantErr: true, + }, + { + name: "5. Return error when start index is out of bounds", + key: "ZremRangeByRankKey5", + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + min: -12, + max: 5, + want: 0, + wantErr: true, + }, + { + name: "6. Return error when end index is out of bounds", + key: "ZremRangeByRankKey6", + presetValue: ss.NewSortedSet([]ss.MemberParam{ + {Value: "one", Score: 1}, {Value: "two", Score: 2}, + {Value: "three", Score: 3}, {Value: "four", Score: 4}, + {Value: "five", Score: 5}, {Value: "six", Score: 6}, + {Value: "seven", Score: 7}, {Value: "eight", Score: 8}, + {Value: "nine", Score: 9}, {Value: "ten", Score: 10}, + }), + min: 0, + max: 11, + want: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.ZRemRangeByRank(tt.key, tt.min, tt.max) + if (err != nil) != tt.wantErr { + t.Errorf("ZRemRangeByRank() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ZRemRangeByRank() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/sugardb/api_string.go b/sugardb/api_string.go new file mode 100644 index 0000000..4377639 --- /dev/null +++ b/sugardb/api_string.go @@ -0,0 +1,95 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "strconv" + + "apigo.cc/go/sugardb/internal" +) + +// SetRange replaces a portion of the string at the provided key starting at the offset with a new string. +// If the string does not exist, a new string is created. +// +// Returns: The length of the new string as an integers. +// +// Errors: +// +// - "value at key is not a string" when the key provided does not hold a string. +func (server *SugarDB) SetRange(key string, offset int, new string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"SETRANGE", key, strconv.Itoa(offset), new}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// StrLen returns the length of the string at the provided key. +// +// Returns: The length of the string as an integer. +// +// Errors: +// +// - "value at key is not a string" - when the value at the keys is not a string. +func (server *SugarDB) StrLen(key string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"STRLEN", key}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} + +// SubStr returns a substring from the string at the key. +// The start and end indices are integers that specify the lower and upper bound respectively. +// +// Returns: The substring from the start index to the end index. +// +// Errors: +// +// - "key does not exist" - when the key does not exist. +// +// - "value at key is not a string" - when the value at the keys is not a string. +func (server *SugarDB) SubStr(key string, start, end int) (string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"SUBSTR", key, strconv.Itoa(start), strconv.Itoa(end)}), nil, false, true) + if err != nil { + return "", err + } + return internal.ParseStringResponse(b) +} + +// GetRange works the same as SubStr. +func (server *SugarDB) GetRange(key string, start, end int) (string, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"GETRANGE", key, strconv.Itoa(start), strconv.Itoa(end)}), nil, false, true) + if err != nil { + return "", err + } + return internal.ParseStringResponse(b) +} + +// Append concatenates the string at the key with the value provided +// If the key does not exists it functions like a SET command +// +// Returns: The lenght of the new concatenated value at key +// +// Errors: +// +// - "value at key is not a string" - when the value at the keys is not a string. +func (server *SugarDB) Append(key string, value string) (int, error) { + b, err := server.handleCommand(server.context, internal.EncodeCommand([]string{"APPEND", key, value}), nil, false, true) + if err != nil { + return 0, err + } + return internal.ParseIntegerResponse(b) +} diff --git a/sugardb/api_string_test.go b/sugardb/api_string_test.go new file mode 100644 index 0000000..6888fad --- /dev/null +++ b/sugardb/api_string_test.go @@ -0,0 +1,367 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "context" + "testing" +) + +func TestSugarDB_SUBSTR(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + substrFunc func(key string, start int, end int) (string, error) + key string + start int + end int + want string + wantErr bool + }{ + { + name: "Return substring within the range of the string", + key: "key1", + substrFunc: server.SubStr, + presetValue: "Test String One", + start: 5, + end: 10, + want: "String", + wantErr: false, + }, + { + name: "Return substring at the end of the string with exact end index", + key: "key2", + substrFunc: server.SubStr, + presetValue: "Test String Two", + start: 12, + end: 14, + want: "Two", + wantErr: false, + }, + { + name: "Return substring at the end of the string with end index greater than length", + key: "key3", + substrFunc: server.SubStr, + presetValue: "Test String Three", + start: 12, + end: 75, + want: "Three", + }, + { + name: "Return the substring at the start of the string with 0 start index", + key: "key4", + substrFunc: server.SubStr, + presetValue: "Test String Four", + start: 0, + end: 3, + want: "Test", + wantErr: false, + }, + { + // Return the substring with negative start index. + // Substring should begin abs(start) from the end of the string when start is negative. + name: "Return the substring with negative start index", + key: "key5", + substrFunc: server.SubStr, + presetValue: "Test String Five", + start: -11, + end: 10, + want: "String", + wantErr: false, + }, + { + // Return reverse substring with end index smaller than start index. + // When end index is smaller than start index, the 2 indices are reversed. + name: "Return reverse substring with end index smaller than start index", + key: "key6", + substrFunc: server.SubStr, + presetValue: "Test String Six", + start: 4, + end: 0, + want: "tseT", + }, + { + name: "Return substring within the range of the string", + key: "key7", + substrFunc: server.GetRange, + presetValue: "Test String One", + start: 5, + end: 10, + want: "String", + wantErr: false, + }, + { + name: "Return substring at the end of the string with exact end index", + key: "key8", + substrFunc: server.GetRange, + presetValue: "Test String Two", + start: 12, + end: 14, + want: "Two", + wantErr: false, + }, + { + name: "Return substring at the end of the string with end index greater than length", + key: "key9", + substrFunc: server.GetRange, + presetValue: "Test String Three", + start: 12, + end: 75, + want: "Three", + }, + { + name: "Return the substring at the start of the string with 0 start index", + key: "key10", + substrFunc: server.GetRange, + presetValue: "Test String Four", + start: 0, + end: 3, + want: "Test", + wantErr: false, + }, + { + // Return the substring with negative start index. + // Substring should begin abs(start) from the end of the string when start is negative. + name: "Return the substring with negative start index", + key: "key11", + substrFunc: server.GetRange, + presetValue: "Test String Five", + start: -11, + end: 10, + want: "String", + wantErr: false, + }, + { + // Return reverse substring with end index smaller than start index. + // When end index is smaller than start index, the 2 indices are reversed. + name: "Return reverse substring with end index smaller than start index", + key: "key12", + substrFunc: server.GetRange, + presetValue: "Test String Six", + start: 4, + end: 0, + want: "tseT", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := tt.substrFunc(tt.key, tt.start, tt.end) + if (err != nil) != tt.wantErr { + t.Errorf("GETRANGE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GETRANGE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_SETRANGE(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + offset int + new string + want int + wantErr bool + }{ + { + name: "Test that SETRANGE on non-existent string creates new string", + key: "key1", + presetValue: "", + offset: 10, + new: "New String Value", + want: len("New String Value"), + wantErr: false, + }, + { + name: "Test SETRANGE with an offset that leads to a longer resulting string", + key: "key2", + presetValue: "Original String Value", + offset: 16, + new: "Portion Replaced With This New String", + want: len("Original String Portion Replaced With This New String"), + wantErr: false, + }, + { + name: "SETRANGE with negative offset prepends the string", + key: "key3", + presetValue: "This is a preset value", + offset: -10, + new: "Prepended ", + want: len("Prepended This is a preset value"), + wantErr: false, + }, + { + name: "SETRANGE with offset that embeds new string inside the old string", + key: "key4", + presetValue: "This is a preset value", + offset: 0, + new: "That", + want: len("That is a preset value"), + wantErr: false, + }, + { + name: "SETRANGE with offset longer than original lengths appends the string", + key: "key5", + presetValue: "This is a preset value", + offset: 100, + new: " Appended", + want: len("This is a preset value Appended"), + wantErr: false, + }, + { + name: "SETRANGE with offset on the last character replaces last character with new string", + key: "key6", + presetValue: "This is a preset value", + offset: len("This is a preset value") - 1, + new: " replaced", + want: len("This is a preset valu replaced"), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.SetRange(tt.key, tt.offset, tt.new) + if (err != nil) != tt.wantErr { + t.Errorf("SETRANGE() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SETRANGE() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_STRLEN(t *testing.T) { + server := createSugarDB() + + tests := []struct { + name string + presetValue interface{} + key string + want int + wantErr bool + }{ + { + name: "Return the correct string length for an existing string", + key: "key1", + presetValue: "Test String", + want: len("Test String"), + wantErr: false, + }, + { + name: "If the string does not exist, return 0", + key: "key2", + presetValue: "", + want: 0, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.StrLen(tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("STRLEN() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("STRLEN() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSugarDB_APPEND(t *testing.T) { + server := createSugarDB() + tests := []struct { + name string + presetValue interface{} + key string + value string + want int + wantErr bool + }{ + { + name: "Test APPEND with no preset value", + key: "key1", + value: "Hello ", + want: 6, + wantErr: false, + }, + { + name: "Test APPEND with preset value", + presetValue: "Hello ", + key: "key2", + value: "World", + want: 11, + wantErr: false, + }, + { + name: "Test APPEND with integer preset value", + key: "key3", + presetValue: 10, + value: "Hello ", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.presetValue != nil { + err := presetValue(server, context.Background(), tt.key, tt.presetValue) + if err != nil { + t.Error(err) + return + } + } + got, err := server.Append(tt.key, tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("APPEND() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("APPEND() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/sugardb/cluster.go b/sugardb/cluster.go new file mode 100644 index 0000000..6c6214b --- /dev/null +++ b/sugardb/cluster.go @@ -0,0 +1,104 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "context" + "encoding/json" + "fmt" + "apigo.cc/go/sugardb/internal" + "time" +) + +func (server *SugarDB) isInCluster() bool { + return server.config.BootstrapCluster || server.config.JoinAddr != "" +} + +func (server *SugarDB) raftApplyDeleteKey(ctx context.Context, key string) error { + serverId, _ := ctx.Value(internal.ContextServerID("ServerID")).(string) + protocol, _ := ctx.Value("Protocol").(int) + database, _ := ctx.Value("Database").(int) + + deleteKeyRequest := internal.ApplyRequest{ + Type: "delete-key", + ServerID: serverId, + ConnectionID: "nil", + Protocol: protocol, + Database: database, + Key: key, + } + + b, err := json.Marshal(deleteKeyRequest) + if err != nil { + return fmt.Errorf("could not parse delete key request for key: %s", key) + } + + applyFuture := server.raft.Apply(b, 500*time.Millisecond) + + if err = applyFuture.Error(); err != nil { + return err + } + + r, ok := applyFuture.Response().(internal.ApplyResponse) + + if !ok { + return fmt.Errorf("unprocessable entity %v", r) + } + + if r.Error != nil { + return r.Error + } + + return nil +} + +func (server *SugarDB) raftApplyCommand(ctx context.Context, cmd []string) ([]byte, error) { + serverId, _ := ctx.Value(internal.ContextServerID("ServerID")).(string) + connectionId, _ := ctx.Value(internal.ContextConnID("ConnectionID")).(string) + protocol, _ := ctx.Value("Protocol").(int) + database, _ := ctx.Value("Database").(int) + + applyRequest := internal.ApplyRequest{ + Type: "command", + ServerID: serverId, + ConnectionID: connectionId, + Protocol: protocol, + Database: database, + CMD: cmd, + } + + b, err := json.Marshal(applyRequest) + if err != nil { + return nil, fmt.Errorf("could not parse command request for commad: %+v", cmd) + } + + applyFuture := server.raft.Apply(b, 500*time.Millisecond) + + if err = applyFuture.Error(); err != nil { + return nil, err + } + + r, ok := applyFuture.Response().(internal.ApplyResponse) + + if !ok { + return nil, fmt.Errorf("unprocessable entity %v", r) + } + + if r.Error != nil { + return nil, r.Error + } + + return r.Response, nil +} diff --git a/sugardb/config.go b/sugardb/config.go new file mode 100644 index 0000000..24cde1c --- /dev/null +++ b/sugardb/config.go @@ -0,0 +1,352 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "context" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" + "time" +) + +// DefaultConfig returns the default configuration. +// This should be used when using SugarDB as an embedded library. +func DefaultConfig() config.Config { + return config.DefaultConfig() +} + +func (server *SugarDB) GetServerInfo() internal.ServerInfo { + return internal.ServerInfo{ + Server: "sugardb", + Version: constants.Version, + Id: server.config.ServerID, + Mode: func() string { + if server.isInCluster() { + return "cluster" + } + return "standalone" + }(), + Role: func() string { + if !server.isInCluster() { + return "master" + } + if server.raft.IsRaftLeader() { + return "master" + } + return "replica" + }(), + Modules: server.ListModules(), + MemoryUsed: server.memUsed, + MaxMemory: server.config.MaxMemory, + } +} + +// WithTLS is an option to the NewSugarDB function that allows you to pass a +// custom TLS to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithTLS(b ...bool) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + if len(b) > 0 { + sugardb.config.TLS = b[0] + } else { + sugardb.config.TLS = true + } + } +} + +// WithContext is an options that for the NewSugarDB function that allows you to +// configure a custom context object to be used in SugarDB. +// If you don't provide this option, SugarDB will create its own internal context object. +func WithContext(ctx context.Context) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.context = ctx + } +} + +// WithConfig is an option for the NewSugarDB function that allows you to pass a +// custom configuration to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithConfig(config config.Config) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config = config + } +} + +// WithMTLS is an option to the NewSugarDB function that allows you to pass a +// custom MTLS to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithMTLS(b ...bool) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + if len(b) > 0 { + sugardb.config.MTLS = b[0] + } else { + sugardb.config.MTLS = true + } + } +} + +// CertKeyPair defines the paths to the cert and key pair files respectively. +type CertKeyPair struct { + Cert string + Key string +} + +// WithCertKeyPairs is an option to the NewSugarDB function that allows you to pass a +// custom CertKeyPairs to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithCertKeyPairs(certKeyPairs []CertKeyPair) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + for _, pair := range certKeyPairs { + sugardb.config.CertKeyPairs = append(sugardb.config.CertKeyPairs, []string{pair.Cert, pair.Key}) + } + } +} + +// WithClientCAs is an option to the NewSugarDB function that allows you to pass a +// custom ClientCAs to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithClientCAs(clientCAs []string) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.ClientCAs = clientCAs + } +} + +// WithPort is an option to the NewSugarDB function that allows you to pass a +// custom Port to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithPort(port uint16) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.Port = port + } +} + +// WithServerID is an option to the NewSugarDB function that allows you to pass a +// custom ServerID to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithServerID(serverID string) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.ServerID = serverID + } +} + +// WithJoinAddr is an option to the NewSugarDB function that allows you to pass a +// custom JoinAddr to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithJoinAddr(joinAddr string) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.JoinAddr = joinAddr + } +} + +// WithBindAddr is an option to the NewSugarDB function that allows you to pass a +// custom BindAddr to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithBindAddr(bindAddr string) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.BindAddr = bindAddr + } +} + +// WithDataDir is an option to the NewSugarDB function that allows you to pass a +// custom DataDir to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithDataDir(dataDir string) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.DataDir = dataDir + } +} + +// WithBootstrapCluster is an option to the NewSugarDB function that allows you to pass a +// custom BootstrapCluster to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithBootstrapCluster(b ...bool) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + if len(b) > 0 { + sugardb.config.BootstrapCluster = b[0] + } else { + sugardb.config.BootstrapCluster = true + } + } +} + +// WithAclConfig is an option to the NewSugarDB function that allows you to pass a +// custom AclConfig to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithAclConfig(aclConfig string) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.AclConfig = aclConfig + } +} + +// WithForwardCommand is an option to the NewSugarDB function that allows you to pass a +// custom ForwardCommand to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithForwardCommand(b ...bool) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + if len(b) > 0 { + sugardb.config.ForwardCommand = b[0] + } else { + sugardb.config.ForwardCommand = true + } + } +} + +// WithRequirePass is an option to the NewSugarDB function that allows you to pass a +// custom RequirePass to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithRequirePass(b ...bool) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + if len(b) > 0 { + sugardb.config.RequirePass = b[0] + } else { + sugardb.config.RequirePass = true + } + } +} + +// WithPassword is an option to the NewSugarDB function that allows you to pass a +// custom Password to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithPassword(password string) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.Password = password + } +} + +// WithSnapShotThreshold is an option to the NewSugarDB function that allows you to pass a +// custom SnapShotThreshold to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithSnapShotThreshold(snapShotThreshold uint64) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.SnapShotThreshold = snapShotThreshold + } +} + +// WithSnapshotInterval is an option to the NewSugarDB function that allows you to pass a +// custom SnapshotInterval to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithSnapshotInterval(snapshotInterval time.Duration) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.SnapshotInterval = snapshotInterval + } +} + +// WithRestoreSnapshot is an option to the NewSugarDB function that allows you to pass a +// custom RestoreSnapshot to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithRestoreSnapshot(b ...bool) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + if len(b) > 0 { + sugardb.config.RestoreSnapshot = b[0] + } else { + sugardb.config.RestoreSnapshot = true + } + } +} + +// WithRestoreAOF is an option to the NewSugarDB function that allows you to pass a +// custom RestoreAOF to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithRestoreAOF(b ...bool) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + if len(b) > 0 { + sugardb.config.RestoreAOF = b[0] + } else { + sugardb.config.RestoreAOF = true + } + } +} + +// WithAOFSyncStrategy is an option to the NewSugarDB function that allows you to pass a +// custom AOFSyncStrategy to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithAOFSyncStrategy(aOFSyncStrategy string) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.AOFSyncStrategy = aOFSyncStrategy + } +} + +// WithMaxMemory is an option to the NewSugarDB function that allows you to pass a +// custom MaxMemory to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithMaxMemory(maxMemory uint64) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.MaxMemory = maxMemory + } +} + +// WithEvictionPolicy is an option to the NewSugarDB function that allows you to pass a +// custom EvictionPolicy to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithEvictionPolicy(evictionPolicy string) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.EvictionPolicy = evictionPolicy + } +} + +// WithEvictionSample is an option to the NewSugarDB function that allows you to pass a +// custom EvictionSample to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithEvictionSample(evictionSample uint) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.EvictionSample = evictionSample + } +} + +// WithEvictionInterval is an option to the NewSugarDB function that allows you to pass a +// custom EvictionInterval to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithEvictionInterval(evictionInterval time.Duration) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.EvictionInterval = evictionInterval + } +} + +// WithModules is an option to the NewSugarDB function that allows you to pass a +// custom Modules to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithModules(modules []string) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.Modules = modules + } +} + +// WithDiscoveryPort is an option to the NewSugarDB function that allows you to pass a +// custom DiscoveryPort to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithDiscoveryPort(discoveryPort uint16) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.DiscoveryPort = discoveryPort + } +} + +// WithRaftBindAddr is an option to the NewSugarDB function that allows you to pass a +// custom RaftBindAddr to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithRaftBindAddr(raftBindAddr string) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.RaftBindAddr = raftBindAddr + } +} + +// WithRaftBindPort is an option to the NewSugarDB function that allows you to pass a +// custom RaftBindPort to SugarDB. +// If not specified, SugarDB will use the default configuration from config.DefaultConfig(). +func WithRaftBindPort(raftBindPort uint16) func(sugardb *SugarDB) { + return func(sugardb *SugarDB) { + sugardb.config.RaftBindPort = raftBindPort + } +} diff --git a/sugardb/keyspace.go b/sugardb/keyspace.go new file mode 100644 index 0000000..bf5a1fc --- /dev/null +++ b/sugardb/keyspace.go @@ -0,0 +1,823 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "container/heap" + "context" + "errors" + "fmt" + "log" + "math/rand" + "reflect" + "runtime" + "slices" + "strings" + "sync" + "time" + "unsafe" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/constants" + "apigo.cc/go/sugardb/internal/eviction" + "apigo.cc/go/sugardb/internal/modules/hash" +) + +// SwapDBs swaps every TCP client connection from database1 over to database2. +// It also swaps every TCP client connection from database2 over to database1. +// This only affects TCP connections, it does not swap the logical database currently +// being used by the embedded API. +func (server *SugarDB) SwapDBs(database1, database2 int) { + // If the databases are the same, skip the swap. + if database1 == database2 { + return + } + + // If any of the databases does not exist, create them. + server.storeLock.Lock() + for _, database := range []int{database1, database2} { + if server.store[database] == nil { + server.createDatabase(database) + } + } + server.storeLock.Unlock() + + // Swap the connections for each database. + server.connInfo.mut.Lock() + defer server.connInfo.mut.Unlock() + for connection, info := range server.connInfo.tcpClients { + switch info.Database { + case database1: + server.connInfo.tcpClients[connection] = internal.ConnectionInfo{ + Id: info.Id, + Name: info.Name, + Protocol: info.Protocol, + Database: database2, + } + case database2: + server.connInfo.tcpClients[connection] = internal.ConnectionInfo{ + Id: info.Id, + Name: info.Name, + Protocol: info.Protocol, + Database: database1, + } + } + } +} + +// Flush flushes all the data from the database at the specified index. +// When -1 is passed, all the logical databases are cleared. +func (server *SugarDB) Flush(database int) { + server.storeLock.Lock() + defer server.storeLock.Unlock() + + server.keysWithExpiry.rwMutex.Lock() + defer server.keysWithExpiry.rwMutex.Unlock() + + if database == -1 { + for db, _ := range server.store { + // Clear db store. + clear(server.store[db]) + // Clear db volatile key tracker. + clear(server.keysWithExpiry.keys[db]) + // Clear db LFU cache. + server.lfuCache.cache[db].Mutex.Lock() + server.lfuCache.cache[db].Flush() + server.lfuCache.cache[db].Mutex.Unlock() + // Clear db LRU cache. + server.lruCache.cache[db].Mutex.Lock() + server.lruCache.cache[db].Flush() + server.lruCache.cache[db].Mutex.Unlock() + } + return + } + + // Clear db store. + clear(server.store[database]) + // Clear db volatile key tracker. + clear(server.keysWithExpiry.keys[database]) + // Clear db LFU cache. + server.lfuCache.cache[database].Mutex.Lock() + server.lfuCache.cache[database].Flush() + server.lfuCache.cache[database].Mutex.Unlock() + // Clear db LRU cache. + server.lruCache.cache[database].Mutex.Lock() + server.lruCache.cache[database].Flush() + server.lruCache.cache[database].Mutex.Unlock() +} + +func (server *SugarDB) keysExist(ctx context.Context, keys []string) map[string]bool { + server.storeLock.RLock() + defer server.storeLock.RUnlock() + + database := ctx.Value("Database").(int) + + exists := make(map[string]bool, len(keys)) + + for _, key := range keys { + _, ok := server.store[database][key] + exists[key] = ok + } + + return exists +} + +func (server *SugarDB) getExpiry(ctx context.Context, key string) time.Time { + server.storeLock.RLock() + defer server.storeLock.RUnlock() + + database := ctx.Value("Database").(int) + + entry, ok := server.store[database][key] + if !ok { + return time.Time{} + } + + return entry.ExpireAt +} + +func (server *SugarDB) getHashExpiry(ctx context.Context, key string, field string) time.Time { + server.storeLock.RLock() + defer server.storeLock.RUnlock() + + database := ctx.Value("Database").(int) + + entry, ok := server.store[database][key] + if !ok { + return time.Time{} + } + + hash := entry.Value.(hash.Hash) + + return hash[field].ExpireAt +} + +func (server *SugarDB) getValues(ctx context.Context, keys []string) map[string]interface{} { + server.storeLock.Lock() + defer server.storeLock.Unlock() + + database := ctx.Value("Database").(int) + + values := make(map[string]interface{}, len(keys)) + + for _, key := range keys { + entry, ok := server.store[database][key] + if !ok { + values[key] = nil + continue + } + + if entry.ExpireAt != (time.Time{}) && entry.ExpireAt.Before(server.clock.Now()) { + if !server.isInCluster() { + // If in standalone mode, delete the key directly. + err := server.deleteKey(ctx, key) + if err != nil { + log.Printf("keyExists: %+v\n", err) + } + } else if server.isInCluster() && server.raft.IsRaftLeader() { + // If we're in a raft cluster, and we're the leader, send command to delete the key in the cluster. + err := server.raftApplyDeleteKey(ctx, key) + if err != nil { + log.Printf("keyExists: %+v\n", err) + } + } else if server.isInCluster() && !server.raft.IsRaftLeader() { + // Forward message to leader to initiate key deletion. + // This is always called regardless of ForwardCommand config value + // because we always want to remove expired keys. + server.memberList.ForwardDeleteKey(ctx, key) + } + values[key] = nil + continue + } + + values[key] = entry.Value + } + + // Asynchronously update the keys in the cache. + go func(ctx context.Context, keys []string) { + if _, err := server.updateKeysInCache(ctx, keys); err != nil { + log.Printf("getValues error: %+v\n", err) + } + }(ctx, keys) + + return values +} + +func (server *SugarDB) setValues(ctx context.Context, entries map[string]interface{}) error { + server.storeLock.Lock() + defer server.storeLock.Unlock() + + if internal.IsMaxMemoryExceeded(server.memUsed, server.config.MaxMemory) && server.config.EvictionPolicy == constants.NoEviction { + + return errors.New("max memory reached, key value not set") + } + + database := ctx.Value("Database").(int) + + // If database does not exist, create it. + if server.store[database] == nil { + server.createDatabase(database) + } + + for key, value := range entries { + expireAt := time.Time{} + if _, ok := server.store[database][key]; ok { + expireAt = server.store[database][key].ExpireAt + } + server.store[database][key] = internal.KeyData{ + Value: value, + ExpireAt: expireAt, + } + data := server.store[database][key] + mem, err := data.GetMem() + if err != nil { + return err + } + server.memUsed += mem + server.memUsed += int64(unsafe.Sizeof(key)) + server.memUsed += int64(len(key)) + + if !server.isInCluster() { + server.snapshotEngine.IncrementChangeCount() + } + } + + // Asynchronously update the keys in the cache. + go func(ctx context.Context, entries map[string]interface{}) { + for key, _ := range entries { + _, err := server.updateKeysInCache(ctx, []string{key}) + if err != nil { + log.Printf("setValues error: %+v\n", err) + } + } + }(ctx, entries) + + return nil +} + +func (server *SugarDB) setExpiry(ctx context.Context, key string, expireAt time.Time, touch bool) { + server.storeLock.Lock() + defer server.storeLock.Unlock() + + database := ctx.Value("Database").(int) + + server.store[database][key] = internal.KeyData{ + Value: server.store[database][key].Value, + ExpireAt: expireAt, + } + + // If the slice of keys associated with expiry time does not contain the current key, add the key. + server.keysWithExpiry.rwMutex.Lock() + if !slices.Contains(server.keysWithExpiry.keys[database], key) { + server.keysWithExpiry.keys[database] = append(server.keysWithExpiry.keys[database], key) + } + server.keysWithExpiry.rwMutex.Unlock() + + // If touch is true, update the keys status in the cache. + if touch { + go func(ctx context.Context, key string) { + _, err := server.updateKeysInCache(ctx, []string{key}) + if err != nil { + log.Printf("setExpiry error: %+v\n", err) + } + }(ctx, key) + } +} + +func (server *SugarDB) setHashExpiry(ctx context.Context, key string, field string, expireAt time.Time) error { + server.storeLock.Lock() + defer server.storeLock.Unlock() + + database := ctx.Value("Database").(int) + + hashmap, ok := server.store[database][key].Value.(hash.Hash) + if !ok { + return fmt.Errorf("setHashExpiry can only be used on keys whose value is a Hash") + } + hashmap[field] = hash.HashValue{ + Value: hashmap[field].Value, + ExpireAt: expireAt, + } + + server.keysWithExpiry.rwMutex.Lock() + if !slices.Contains(server.keysWithExpiry.keys[database], key) { + server.keysWithExpiry.keys[database] = append(server.keysWithExpiry.keys[database], key) + } + server.keysWithExpiry.rwMutex.Unlock() + + return nil +} + +func (server *SugarDB) deleteKey(ctx context.Context, key string) error { + database := ctx.Value("Database").(int) + + // Deduct memory usage in tracker. + data := server.store[database][key] + mem, err := data.GetMem() + if err != nil { + return err + } + server.memUsed -= mem + server.memUsed -= int64(unsafe.Sizeof(key)) + server.memUsed -= int64(len(key)) + + // Delete the key from keyLocks and store. + delete(server.store[database], key) + + // Remove key from slice of keys associated with expiry. + server.keysWithExpiry.rwMutex.Lock() + defer server.keysWithExpiry.rwMutex.Unlock() + server.keysWithExpiry.keys[database] = slices.DeleteFunc(server.keysWithExpiry.keys[database], func(k string) bool { + return k == key + }) + + // Remove the key from the cache associated with the database. + switch { + case slices.Contains([]string{constants.AllKeysLFU, constants.VolatileLFU}, server.config.EvictionPolicy): + server.lfuCache.cache[database].Delete(key) + case slices.Contains([]string{constants.AllKeysLRU, constants.VolatileLRU}, server.config.EvictionPolicy): + server.lruCache.cache[database].Delete(key) + } + + log.Printf("deleted key %s\n", key) + + return nil +} + +func (server *SugarDB) createDatabase(database int) { + // Create database store. + server.store[database] = make(map[string]internal.KeyData) + + // Set volatile keys tracker for database. + server.keysWithExpiry.rwMutex.Lock() + defer server.keysWithExpiry.rwMutex.Unlock() + server.keysWithExpiry.keys[database] = make([]string, 0) + + // Create database LFU cache. + server.lfuCache.mutex.Lock() + defer server.lfuCache.mutex.Unlock() + server.lfuCache.cache[database] = eviction.NewCacheLFU() + + // Create database LRU cache. + server.lruCache.mutex.Lock() + defer server.lruCache.mutex.Unlock() + server.lruCache.cache[database] = eviction.NewCacheLRU() +} + +func (server *SugarDB) getState() map[int]map[string]interface{} { + // Wait unit there's no state mutation or copy in progress before starting a new copy process. + for { + if !server.stateCopyInProgress.Load() && !server.stateMutationInProgress.Load() { + server.stateCopyInProgress.Store(true) + break + } + } + data := make(map[int]map[string]interface{}) + for db, store := range server.store { + data[db] = make(map[string]interface{}) + for k, v := range store { + data[db][k] = v + } + } + server.stateCopyInProgress.Store(false) + return data +} + +// updateKeysInCache updates either the key access count or the most recent access time in the cache +// depending on whether an LFU or LRU strategy was used. +func (server *SugarDB) updateKeysInCache(ctx context.Context, keys []string) (int64, error) { + database := ctx.Value("Database").(int) + var touchCounter int64 + + // Only update cache when in standalone mode or when raft leader. + if server.isInCluster() || (server.isInCluster() && !server.raft.IsRaftLeader()) { + return touchCounter, nil + } + // If max memory is 0, there's no max so no need to update caches. + if server.config.MaxMemory == 0 { + return touchCounter, nil + } + + server.storeLock.Lock() + defer server.storeLock.Unlock() + + for _, key := range keys { + // Verify key exists + if _, ok := server.store[database][key]; !ok { + continue + } + + touchCounter++ + + switch strings.ToLower(server.config.EvictionPolicy) { + case constants.AllKeysLFU: + server.lfuCache.cache[database].Mutex.Lock() + server.lfuCache.cache[database].Update(key) + server.lfuCache.cache[database].Mutex.Unlock() + case constants.AllKeysLRU: + server.lruCache.cache[database].Mutex.Lock() + server.lruCache.cache[database].Update(key) + server.lruCache.cache[database].Mutex.Unlock() + case constants.VolatileLFU: + server.lfuCache.cache[database].Mutex.Lock() + if server.store[database][key].ExpireAt != (time.Time{}) { + server.lfuCache.cache[database].Update(key) + } + server.lfuCache.cache[database].Mutex.Unlock() + case constants.VolatileLRU: + server.lruCache.cache[database].Mutex.Lock() + if server.store[database][key].ExpireAt != (time.Time{}) { + server.lruCache.cache[database].Update(key) + } + server.lruCache.cache[database].Mutex.Unlock() + } + } + + wg := sync.WaitGroup{} + errChan := make(chan error) + doneChan := make(chan struct{}) + + for db, _ := range server.store { + wg.Add(1) + ctx := context.WithValue(ctx, "Database", db) + go func(ctx context.Context, database int, wg *sync.WaitGroup, errChan *chan error) { + if err := server.adjustMemoryUsage(ctx); err != nil { + *errChan <- fmt.Errorf("adjustMemoryUsage database %d, error: %v", database, err) + } + wg.Done() + }(ctx, db, &wg, &errChan) + } + + go func() { + wg.Wait() + doneChan <- struct{}{} + }() + + select { + case err := <-errChan: + return touchCounter, fmt.Errorf("adjustMemoryUsage error: %+v", err) + case <-doneChan: + } + + return touchCounter, nil +} + +// adjustMemoryUsage should only be called from standalone echovault or from raft cluster leader. +func (server *SugarDB) adjustMemoryUsage(ctx context.Context) error { + // If max memory is 0, there's no need to adjust memory usage. + if server.config.MaxMemory == 0 { + return nil + } + + database := ctx.Value("Database").(int) + + // Check if memory usage is above max-memory. + // If it is, pop items from the cache until we get under the limit. + // If we're using less memory than the max-memory, there's no need to evict. + if uint64(server.memUsed) < server.config.MaxMemory { + return nil + } + // Force a garbage collection first before we start evicting keys. + runtime.GC() + if uint64(server.memUsed) < server.config.MaxMemory { + return nil + } + + // We've done a GC, but we're still at or above the max memory limit. + // Start a loop that evicts keys until either the heap is empty or + // we're below the max memory limit. + + log.Printf("Memory used: %v, Max Memory: %v", server.GetServerInfo().MemoryUsed, server.GetServerInfo().MaxMemory) + switch { + case slices.Contains([]string{constants.AllKeysLFU, constants.VolatileLFU}, strings.ToLower(server.config.EvictionPolicy)): + // Remove keys from LFU cache until we're below the max memory limit or + // until the LFU cache is empty. + server.lfuCache.cache[database].Mutex.Lock() + defer server.lfuCache.cache[database].Mutex.Unlock() + for { + // Return if cache is empty + if server.lfuCache.cache[database].Len() == 0 { + return fmt.Errorf("adjustMemoryUsage -> LFU cache empty") + } + + key := heap.Pop(server.lfuCache.cache[database]).(string) + if !server.isInCluster() { + // If in standalone mode, directly delete the key + if err := server.deleteKey(ctx, key); err != nil { + + log.Printf("Evicting key %v from database %v \n", key, database) + return fmt.Errorf("adjustMemoryUsage -> LFU cache eviction: %+v", err) + } + } else if server.isInCluster() && server.raft.IsRaftLeader() { + // If in raft cluster, send command to delete key from cluster + if err := server.raftApplyDeleteKey(ctx, key); err != nil { + + return fmt.Errorf("adjustMemoryUsage -> LFU cache eviction: %+v", err) + } + } + // Run garbage collection + runtime.GC() + // Return if we're below max memory + if uint64(server.memUsed) < server.config.MaxMemory { + return nil + } + } + case slices.Contains([]string{constants.AllKeysLRU, constants.VolatileLRU}, strings.ToLower(server.config.EvictionPolicy)): + // Remove keys from th LRU cache until we're below the max memory limit or + // until the LRU cache is empty. + server.lruCache.cache[database].Mutex.Lock() + defer server.lruCache.cache[database].Mutex.Unlock() + for { + // Return if cache is empty + if server.lruCache.cache[database].Len() == 0 { + return fmt.Errorf("adjustMemoryUsage -> LRU cache empty") + } + + key := heap.Pop(server.lruCache.cache[database]).(string) + if !server.isInCluster() { + // If in standalone mode, directly delete the key. + if err := server.deleteKey(ctx, key); err != nil { + log.Printf("Evicting key %v from database %v \n", key, database) + return fmt.Errorf("adjustMemoryUsage -> LRU cache eviction: %+v", err) + } + } else if server.isInCluster() && server.raft.IsRaftLeader() { + // If in cluster mode and the node is a cluster leader, + // send command to delete the key from the cluster. + if err := server.raftApplyDeleteKey(ctx, key); err != nil { + return fmt.Errorf("adjustMemoryUsage -> LRU cache eviction: %+v", err) + } + } + + // Run garbage collection + runtime.GC() + // Return if we're below max memory + if uint64(server.memUsed) < server.config.MaxMemory { + return nil + } + } + case slices.Contains([]string{constants.AllKeysRandom}, strings.ToLower(server.config.EvictionPolicy)): + // Remove random keys until we're below the max memory limit + // or there are no more keys remaining. + for { + // If there are no keys, return error + if len(server.store) == 0 { + err := errors.New("no keys to evict") + return fmt.Errorf("adjustMemoryUsage -> all keys random: %+v", err) + } + // Get random key in the database + idx := rand.Intn(len(server.store)) + for db, data := range server.store { + if db == database { + for key, _ := range data { + if idx == 0 { + if !server.isInCluster() { + // If in standalone mode, directly delete the key + if err := server.deleteKey(ctx, key); err != nil { + log.Printf("Evicting key %v from database %v \n", key, db) + + return fmt.Errorf("adjustMemoryUsage -> all keys random: %+v", err) + } + } else if server.isInCluster() && server.raft.IsRaftLeader() { + if err := server.raftApplyDeleteKey(ctx, key); err != nil { + + return fmt.Errorf("adjustMemoryUsage -> all keys random: %+v", err) + } + } + // Run garbage collection + runtime.GC() + // Return if we're below max memory + if uint64(server.memUsed) < server.config.MaxMemory { + return nil + } + } + idx-- + } + } + } + } + case slices.Contains([]string{constants.VolatileRandom}, strings.ToLower(server.config.EvictionPolicy)): + // Remove random keys with an associated expiry time until we're below the max memory limit + // or there are no more keys with expiry time. + for { + // Get random volatile key + server.keysWithExpiry.rwMutex.RLock() + idx := rand.Intn(len(server.keysWithExpiry.keys)) + key := server.keysWithExpiry.keys[database][idx] + server.keysWithExpiry.rwMutex.RUnlock() + + if !server.isInCluster() { + // If in standalone mode, directly delete the key + if err := server.deleteKey(ctx, key); err != nil { + log.Printf("Evicting key %v from database %v \n", key, database) + + return fmt.Errorf("adjustMemoryUsage -> volatile keys random: %+v", err) + } + } else if server.isInCluster() && server.raft.IsRaftLeader() { + if err := server.raftApplyDeleteKey(ctx, key); err != nil { + + return fmt.Errorf("adjustMemoryUsage -> volatile keys randome: %+v", err) + } + } + + // Run garbage collection + runtime.GC() + // Return if we're below max memory + if uint64(server.memUsed) < server.config.MaxMemory { + return nil + } + } + default: + return nil + } +} + +// evictKeysWithExpiredTTL is a function that samples keys with an associated TTL +// and evicts keys that are currently expired. +// This function will sample 20 keys from the list of keys with an associated TTL, +// if the key is expired, it will be evicted. +// This function is only executed in standalone mode or by the raft cluster leader. +func (server *SugarDB) evictKeysWithExpiredTTL(ctx context.Context) error { + // Only execute this if we're in standalone mode, or raft cluster leader. + if server.isInCluster() && !server.raft.IsRaftLeader() { + return nil + } + + server.keysWithExpiry.rwMutex.RLock() + + database := ctx.Value("Database").(int) + + // Sample size should be the configured sample size, or the size of the keys with expiry, + // whichever one is smaller. + sampleSize := int(server.config.EvictionSample) + if len(server.keysWithExpiry.keys[database]) < sampleSize { + sampleSize = len(server.keysWithExpiry.keys) + } + keys := make([]string, sampleSize) + + deletedCount := 0 + thresholdPercentage := 20 + + var idx int + var key string + for i := 0; i < len(keys); i++ { + for { + // Retry retrieval of a random key until we find a key that is not already in the list of sampled keys. + idx = rand.Intn(len(server.keysWithExpiry.keys)) + key = server.keysWithExpiry.keys[database][idx] + if !slices.Contains(keys, key) { + keys[i] = key + break + } + } + } + server.keysWithExpiry.rwMutex.RUnlock() + + // Loop through the keys and delete them if they're expired + server.storeLock.Lock() + defer server.storeLock.Unlock() + for _, k := range keys { + + // handle keys within a hash type value + value := server.store[database][k].Value + t := reflect.TypeOf(value) + if t.Kind() == reflect.Map { + + hashkey, ok := server.store[database][k].Value.(hash.Hash) + if !ok { + return fmt.Errorf("Hash value should contain type HashValue, but type %s was found.", t.Elem().Name()) + } + + for k, v := range hashkey { + if v.ExpireAt.Before(time.Now()) { + delete(hashkey, k) + } + } + + } + + // Check if key is expired, move on if it's not + ExpireTime := server.store[database][k].ExpireAt + if ExpireTime.Before(time.Now()) { + continue + } + + // Delete the expired key + deletedCount += 1 + if !server.isInCluster() { + if err := server.deleteKey(ctx, k); err != nil { + return fmt.Errorf("evictKeysWithExpiredTTL -> standalone delete: %+v", err) + } + } else if server.isInCluster() && server.raft.IsRaftLeader() { + if err := server.raftApplyDeleteKey(ctx, k); err != nil { + return fmt.Errorf("evictKeysWithExpiredTTL -> cluster delete: %+v", err) + } + } + } + + // If sampleSize is 0, there's no need to calculate deleted percentage. + if sampleSize == 0 { + return nil + } + + log.Printf("%d keys sampled, %d keys deleted\n", sampleSize, deletedCount) + + // If the deleted percentage is over 20% of the sample size, execute the function again immediately. + if (deletedCount/sampleSize)*100 >= thresholdPercentage { + log.Printf("deletion ratio (%d percent) reached threshold (%d percent), sampling again\n", + (deletedCount/sampleSize)*100, thresholdPercentage) + return server.evictKeysWithExpiredTTL(ctx) + } + + return nil +} + +func (server *SugarDB) randomKey(ctx context.Context) string { + server.storeLock.RLock() + defer server.storeLock.RUnlock() + + database := ctx.Value("Database").(int) + + _max := len(server.store[database]) + if _max == 0 { + return "" + } + + randnum := rand.Intn(_max) + i := 0 + var randkey string + + for key, _ := range server.store[database] { + if i == randnum { + randkey = key + break + } else { + i++ + } + + } + + return randkey +} + +func (server *SugarDB) dbSize(ctx context.Context) int { + server.storeLock.RLock() + defer server.storeLock.RUnlock() + + database := ctx.Value("Database").(int) + return len(server.store[database]) +} + +func (server *SugarDB) getObjectFreq(ctx context.Context, key string) (int, error) { + database := ctx.Value("Database").(int) + + var freq int + var err error + if server.lfuCache.cache != nil { + server.lfuCache.cache[database].Mutex.Lock() + freq, err = server.lfuCache.cache[database].GetCount(key) + server.lfuCache.cache[database].Mutex.Unlock() + } else { + return -1, errors.New("error: eviction policy must be a type of LFU") + } + + if err != nil { + return -1, err + } + + return freq, nil +} + +func (server *SugarDB) getObjectIdleTime(ctx context.Context, key string) (float64, error) { + database := ctx.Value("Database").(int) + + var accessTime int64 + var err error + if server.lruCache.cache != nil { + server.lruCache.cache[database].Mutex.Lock() + accessTime, err = server.lruCache.cache[database].GetTime(key) + server.lruCache.cache[database].Mutex.Unlock() + } else { + return -1, errors.New("error: eviction policy must be a type of LRU") + } + + if err != nil { + return -1, err + } + + lastAccess := time.UnixMilli(accessTime) + secs := time.Now().Sub(lastAccess).Seconds() + + return secs, nil +} diff --git a/sugardb/modules.go b/sugardb/modules.go new file mode 100644 index 0000000..aaef458 --- /dev/null +++ b/sugardb/modules.go @@ -0,0 +1,229 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strings" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/clock" + "apigo.cc/go/sugardb/internal/constants" +) + +func (server *SugarDB) getCommand(cmd string) (internal.Command, error) { + server.commandsRWMut.RLock() + defer server.commandsRWMut.RUnlock() + for _, command := range server.commands { + if strings.EqualFold(command.Command, cmd) { + return command, nil + } + } + return internal.Command{}, fmt.Errorf("command %s not supported", cmd) +} + +func (server *SugarDB) getHandlerFuncParams(ctx context.Context, cmd []string, conn *net.Conn) internal.HandlerFuncParams { + return internal.HandlerFuncParams{ + Context: ctx, + Command: cmd, + Connection: conn, + KeysExist: server.keysExist, + GetExpiry: server.getExpiry, + GetHashExpiry: server.getHashExpiry, + GetValues: server.getValues, + SetValues: server.setValues, + SetExpiry: server.setExpiry, + SetHashExpiry: server.setHashExpiry, + TakeSnapshot: server.takeSnapshot, + GetLatestSnapshotTime: server.getLatestSnapshotTime, + RewriteAOF: server.rewriteAOF, + LoadModule: server.LoadModule, + UnloadModule: server.UnloadModule, + ListModules: server.ListModules, + GetPubSub: server.getPubSub, + GetACL: server.getACL, + GetAllCommands: server.getCommands, + GetClock: server.getClock, + Flush: server.Flush, + RandomKey: server.randomKey, + DBSize: server.dbSize, + TouchKey: server.updateKeysInCache, + GetObjectFrequency: server.getObjectFreq, + GetObjectIdleTime: server.getObjectIdleTime, + SwapDBs: server.SwapDBs, + GetServerInfo: server.GetServerInfo, + AddScript: server.AddScript, + DeleteKey: func(ctx context.Context, key string) error { + server.storeLock.Lock() + defer server.storeLock.Unlock() + return server.deleteKey(ctx, key) + }, + GetConnectionInfo: func(conn *net.Conn) internal.ConnectionInfo { + server.connInfo.mut.RLock() + defer server.connInfo.mut.RUnlock() + return server.connInfo.tcpClients[conn] + }, + SetConnectionInfo: func(conn *net.Conn, clientname string, protocol int, database int) { + server.connInfo.mut.Lock() + defer server.connInfo.mut.Unlock() + + info := server.connInfo.tcpClients[conn] + + // Set protocol. + info.Protocol = protocol + + // Set connection name. + if clientname != "" { + info.Name = clientname + } + + // If the database index does not exist, create the new database. + server.storeLock.Lock() + if server.store[database] == nil { + server.createDatabase(database) + } + server.storeLock.Unlock() + + // Set database index for the current connection. + info.Database = database + + server.connInfo.tcpClients[conn] = info + }, + } +} + +func (server *SugarDB) handleCommand(ctx context.Context, message []byte, conn *net.Conn, replay bool, embedded bool) ([]byte, error) { + // Prepare context before processing the command. + server.connInfo.mut.RLock() + if embedded && !replay { + // The call is triggered via the embedded API. + // Add embedded connection info to the context of the request. + ctx = context.WithValue(ctx, "ConnectionName", server.connInfo.embedded.Name) + ctx = context.WithValue(ctx, "Protocol", server.connInfo.embedded.Protocol) + ctx = context.WithValue(ctx, "Database", server.connInfo.embedded.Database) + } else if conn != nil { + // The call is triggered by a TCP connection. + // Add TCP connection info to the context of the request. + ctx = context.WithValue(ctx, "ConnectionName", server.connInfo.tcpClients[conn].Name) + ctx = context.WithValue(ctx, "Protocol", server.connInfo.tcpClients[conn].Protocol) + ctx = context.WithValue(ctx, "Database", server.connInfo.tcpClients[conn].Database) + } + server.connInfo.mut.RUnlock() + + cmd, err := internal.Decode(message) + if err != nil { + return nil, err + } + + if len(cmd) == 0 { + return nil, errors.New("empty command") + } + + // If quit command is passed, EOF error. + if strings.EqualFold(cmd[0], "quit") { + return nil, io.EOF + } + + command, err := server.getCommand(cmd[0]) + if err != nil { + return nil, err + } + + synchronize := command.Sync + handler := command.HandlerFunc + + sc, err := internal.GetSubCommand(command, cmd) + if err != nil { + return nil, err + } + subCommand, ok := sc.(internal.SubCommand) + if ok { + synchronize = subCommand.Sync + handler = subCommand.HandlerFunc + } + + if conn != nil && server.acl != nil && !embedded { + // Authorize connection if it's provided and if ACL module is present and the embedded parameter is false. + // Skip the authorization if the command is being executed from embedded mode. + if err = server.acl.AuthorizeConnection(conn, cmd, command, subCommand); err != nil { + return nil, err + } + } + + // If the command is a write command, wait for state copy to finish. + if internal.IsWriteCommand(command, subCommand) { + for { + if !server.stateCopyInProgress.Load() { + server.stateMutationInProgress.Store(true) + break + } + } + } + + if !server.isInCluster() || !synchronize { + res, err := handler(server.getHandlerFuncParams(ctx, cmd, conn)) + if err != nil { + return nil, err + } + + if internal.IsWriteCommand(command, subCommand) && !replay { + server.connInfo.mut.RLock() + server.aofEngine.LogCommand(server.connInfo.tcpClients[conn].Database, message) + server.connInfo.mut.RUnlock() + } + + server.stateMutationInProgress.Store(false) + + return res, err + } + + // Handle other commands that need to be synced across the cluster + if server.raft.IsRaftLeader() { + var res []byte + res, err = server.raftApplyCommand(ctx, cmd) + if err != nil { + return nil, err + } + return res, err + } + + // Forward message to leader and return immediate OK response + if server.config.ForwardCommand { + server.memberList.ForwardDataMutation(ctx, message) + return []byte(constants.OkResponse), nil + } + + return nil, errors.New("not cluster leader, cannot carry out command") +} + +func (server *SugarDB) getCommands() []internal.Command { + return server.commands +} + +func (server *SugarDB) getACL() interface{} { + return server.acl +} + +func (server *SugarDB) getPubSub() interface{} { + return server.pubSub +} + +func (server *SugarDB) getClock() clock.Clock { + return server.clock +} diff --git a/sugardb/plugin.go b/sugardb/plugin.go new file mode 100644 index 0000000..7e31994 --- /dev/null +++ b/sugardb/plugin.go @@ -0,0 +1,295 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "context" + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "io/fs" + "os" + "plugin" + "slices" + "strings" + "sync" +) + +func (server *SugarDB) AddScript(engine string, scriptType string, content string, args []string) error { + return nil +} + +func (server *SugarDB) AddScriptCommand( + path string, + args []string, +) error { + // Extract the engine from the script file extension + var engine string + if strings.HasSuffix(path, ".lua") { + engine = "lua" + } else if strings.HasSuffix(path, ".js") { + engine = "js" + } + + // Check if the engine is supported + supportedEngines := []string{"lua", "js"} + if !slices.Contains(supportedEngines, strings.ToLower(engine)) { + return fmt.Errorf("engine %s not supported, only %v engines are supported", engine, supportedEngines) + } + + // Initialise VM for the command depending on the engine. + var vm any + var commandName string + var categories []string + var description string + var synchronize bool + var commandType string + var err error + + switch strings.ToLower(engine) { + case "lua": + vm, commandName, categories, description, synchronize, commandType, err = generateLuaCommandInfo(path) + case "js": + vm, commandName, categories, description, synchronize, commandType, err = generateJSCommandInfo(path) + } + + if err != nil { + return err + } + + // Save the script's VM to the server's list of VMs. + server.scriptVMs.Store(commandName, struct { + vm any + lock *sync.Mutex + }{ + vm: vm, + // lock is the script mutex for the commands. + // This mutex will be locked everytime the command is executed because + // the script's VM is not thread safe. + lock: &sync.Mutex{}, + }) + + // Build the command: + command := internal.Command{ + Command: commandName, + Module: path, + Categories: categories, + Description: description, + Sync: synchronize, + Type: commandType, + KeyExtractionFunc: func(engine string, args []string) internal.KeyExtractionFunc { + // Wrapper for the key function + return func(cmd []string) (internal.KeyExtractionFuncResult, error) { + switch strings.ToLower(engine) { + default: + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: make([]string, 0), + WriteKeys: make([]string, 0), + }, nil + case "lua": + return server.luaKeyExtractionFunc(cmd, args) + case "js": + return server.jsKeyExtractionFunc(cmd, args) + } + } + }(engine, args), + HandlerFunc: func(engine string, args []string) internal.HandlerFunc { + // Wrapper that generates handler function + return func(params internal.HandlerFuncParams) ([]byte, error) { + switch strings.ToLower(engine) { + default: + return nil, fmt.Errorf("command %s handler not implemented", commandName) + case "lua": + return server.luaHandlerFunc(commandName, args, params) + case "js": + return server.jsHandlerFunc(commandName, args, params) + } + } + }(engine, args), + } + + // Add the commands to the list of commands. + server.commands = append(server.commands, command) + + return nil +} + +// LoadModule loads an external module into SugarDB ar runtime. +// +// Parameters: +// +// `path` - string - The full path to the .so plugin to be loaded. +// +// `args` - ...string - A list of args that will be passed unmodified to the plugins command's +// KeyExtractionFunc and HandlerFunc +func (server *SugarDB) LoadModule(path string, args ...string) error { + server.commandsRWMut.Lock() + defer server.commandsRWMut.Unlock() + + for _, suffix := range []string{".lua", ".js"} { + if strings.HasSuffix(path, suffix) { + return server.AddScriptCommand(path, args) + } + } + + if _, err := os.Stat(path); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("load module: module %s not found", path) + } + return fmt.Errorf("load module: %v", err) + } + + p, err := plugin.Open(path) + if err != nil { + return fmt.Errorf("plugin open: %v", err) + } + + commandSymbol, err := p.Lookup("Command") + if err != nil { + return err + } + command, ok := commandSymbol.(*string) + if !ok { + return errors.New("command symbol is not a string") + } + + categoriesSymbol, err := p.Lookup("Categories") + if err != nil { + return err + } + categories, ok := categoriesSymbol.(*[]string) + if !ok { + return errors.New("categories symbol not a string slice") + } + + descriptionSymbol, err := p.Lookup("Description") + if err != nil { + return err + } + description, ok := descriptionSymbol.(*string) + if !ok { + return errors.New("description symbol is no a string") + } + + syncSymbol, err := p.Lookup("Sync") + if err != nil { + return err + } + synchronize, ok := syncSymbol.(*bool) + if !ok { + return errors.New("sync symbol is not a bool") + } + + keyExtractionFuncSymbol, err := p.Lookup("KeyExtractionFunc") + if err != nil { + return fmt.Errorf("key extraction func symbol: %v", err) + } + keyExtractionFunc, ok := keyExtractionFuncSymbol.(func(cmd []string, args ...string) ([]string, []string, error)) + if !ok { + return errors.New("key extraction function has unexpected signature") + } + + handlerFuncSymbol, err := p.Lookup("HandlerFunc") + if err != nil { + return fmt.Errorf("handler func symbol: %v", err) + } + handlerFunc, ok := handlerFuncSymbol.(func( + ctx context.Context, + command []string, + keysExist func(ctx context.Context, key []string) map[string]bool, + getValues func(ctx context.Context, key []string) map[string]interface{}, + setValues func(ctx context.Context, entries map[string]interface{}) error, + args ...string, + ) ([]byte, error)) + if !ok { + return errors.New("handler function has unexpected signature") + } + + // Remove the currently loaded version of this module and replace it with the new one + server.commands = slices.DeleteFunc(server.commands, func(command internal.Command) bool { + return strings.EqualFold(command.Module, path) + }) + + // Add the new command + server.commands = append(server.commands, internal.Command{ + Command: *command, + Module: path, + Categories: func() []string { + // Convert all the categories to lower case for uniformity + cats := make([]string, len(*categories)) + for i, cat := range *categories { + cats[i] = strings.ToLower(cat) + } + return cats + }(), + Description: *description, + Sync: *synchronize, + SubCommands: make([]internal.SubCommand, 0), + KeyExtractionFunc: func(cmd []string) (internal.KeyExtractionFuncResult, error) { + readKeys, writeKeys, err := keyExtractionFunc(cmd, args...) + if err != nil { + return internal.KeyExtractionFuncResult{}, err + } + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: readKeys, + WriteKeys: writeKeys, + }, nil + }, + HandlerFunc: func(params internal.HandlerFuncParams) ([]byte, error) { + return handlerFunc( + params.Context, + params.Command, + params.KeysExist, + params.GetValues, + params.SetValues, + args..., + ) + }, + }) + + return nil +} + +// UnloadModule unloads the provided module +// +// Parameters: +// +// `module` - string - module name as displayed by the ListModules method. +func (server *SugarDB) UnloadModule(module string) { + server.commandsRWMut.Lock() + defer server.commandsRWMut.Unlock() + server.commands = slices.DeleteFunc(server.commands, func(command internal.Command) bool { + return strings.EqualFold(command.Module, module) + }) +} + +// ListModules lists the currently loaded modules +// +// Returns: a string slice representing all the currently loaded modules. +func (server *SugarDB) ListModules() []string { + server.commandsRWMut.RLock() + defer server.commandsRWMut.RUnlock() + var modules []string + for _, command := range server.commands { + if !slices.ContainsFunc(modules, func(module string) bool { + return strings.EqualFold(module, command.Module) + }) { + modules = append(modules, strings.ToLower(command.Module)) + } + } + return modules +} diff --git a/sugardb/plugin_javascript.go b/sugardb/plugin_javascript.go new file mode 100644 index 0000000..d260136 --- /dev/null +++ b/sugardb/plugin_javascript.go @@ -0,0 +1,908 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/modules/hash" + "apigo.cc/go/sugardb/internal/modules/set" + "apigo.cc/go/sugardb/internal/modules/sorted_set" + "github.com/robertkrimen/otto" + "math" + "os" + "reflect" + "slices" + "strings" + "sync" + "sync/atomic" +) + +var ( + objectRegistry sync.Map + idCounter uint64 +) + +func registerObject(object interface{}) string { + id := fmt.Sprintf("id-%d", atomic.AddUint64(&idCounter, 1)) + objectRegistry.Store(id, object) + return id +} + +func getObjectById(id string) (interface{}, bool) { + return objectRegistry.Load(id) +} + +func clearObjectRegistry() { + atomic.StoreUint64(&idCounter, 0) + objectRegistry.Clear() +} + +func generateJSCommandInfo(path string) (*otto.Otto, string, []string, string, bool, string, error) { + // Initialize the Otto vm + vm := otto.New() + + // Load JS file + content, err := os.ReadFile(path) + if err != nil { + return nil, "", nil, "", false, "", fmt.Errorf("could not load javascript script file %s: %v", path, err) + } + if _, err = vm.Run(content); err != nil { + return nil, "", nil, "", false, "", fmt.Errorf("could not run javascript script file %s: %v", path, err) + } + + // Register hash data type + _ = vm.Set("Hash", func(call otto.FunctionCall) otto.Value { + // Initialize hash + h := hash.Hash{} + // If an object is passed then initialize the default values of the hash + if len(call.ArgumentList) > 0 { + args := call.Argument(0).Object() + for _, key := range args.Keys() { + value, _ := args.Get(key) + v, _ := value.ToString() + h[key] = hash.HashValue{Value: v} + } + } + + obj, _ := call.Otto.Object(`({})`) + buildHashObject(obj, h) + return obj.Value() + }) + + // Register set data type + _ = vm.Set("Set", func(call otto.FunctionCall) otto.Value { + // Initialize set + s := set.NewSet([]string{}) + // If an array is passed add the values to the set + if len(call.ArgumentList) > 0 { + args := call.Argument(0).Object() + var elems []string + for _, key := range args.Keys() { + value, _ := args.Get(key) + v, _ := value.ToString() + elems = append(elems, v) + } + s.Add(elems) + } + + obj, _ := call.Otto.Object(`({})`) + buildSetObject(obj, s) + return obj.Value() + }) + + // Register sorted set member data type + _ = vm.Set("ZMember", func(call otto.FunctionCall) otto.Value { + obj, _ := call.Otto.Object(`({})`) + + m := &sorted_set.MemberParam{} + if len(call.ArgumentList) != 1 { + panicWithFunctionCall(call, "expected an object with score and value properties") + } + arg := call.Argument(0).Object() + // Validate the object + if err = validateMemberParamObject(arg); err != nil { + panicWithFunctionCall(call, err.Error()) + } + // Get the value + value, _ := arg.Get("value") + m.Value = sorted_set.Value(value.String()) + // Get the score + s, _ := arg.Get("score") + score, _ := s.ToFloat() + m.Score = sorted_set.Score(score) + // Build the Otto member param object + buildMemberParamObject(obj, m) + return obj.Value() + }) + + // Register sorted set data type + _ = vm.Set("ZSet", func(call otto.FunctionCall) otto.Value { + // If default args are passed when initializing sorted set, add them to the member params + var params []sorted_set.MemberParam + for _, arg := range call.ArgumentList { + if !arg.IsObject() { + panicWithFunctionCall(call, "zset constructor args must be sorted set members") + } + id, _ := arg.Object().Get("__id") + o, exists := getObjectById(id.String()) + if !exists { + panicWithFunctionCall(call, "unknown object passed to zset constructor") + } + p, ok := o.(*sorted_set.MemberParam) + if !ok { + panicWithFunctionCall(call, "unknown object passed to createZSet function") + } + params = append(params, *p) + } + ss := sorted_set.NewSortedSet(params) + + obj, _ := call.Otto.Object(`({})`) + buildSortedSetObject(obj, ss) + return obj.Value() + }) + + // Get the command name + v, err := vm.Get("command") + if err != nil { + return nil, "", nil, "", false, "", fmt.Errorf("could not get javascript command %s: %v", path, err) + } + command, err := v.ToString() + if err != nil || len(command) <= 0 { + return nil, "", nil, "", false, "", fmt.Errorf("javascript command not found %s: %v", path, err) + } + + // Get the categories + v, err = vm.Get("categories") + if err != nil { + return nil, "", nil, "", false, "", fmt.Errorf("could not get javascript command categories %s: %v", path, err) + } + isArray, _ := vm.Run(`Array.isArray(categories)`) + if ok, _ := isArray.ToBoolean(); !ok { + return nil, "", nil, "", false, "", fmt.Errorf("javascript command categories is not an array %s: %v", path, err) + } + c, _ := v.Export() + categories := c.([]string) + + // Get the description + v, err = vm.Get("description") + if err != nil { + return nil, "", nil, "", false, "", fmt.Errorf("could not get javascript command description %s: %v", path, err) + } + description, err := v.ToString() + if err != nil || len(description) <= 0 { + return nil, "", nil, "", false, "", fmt.Errorf("javascript command description not found %s: %v", path, err) + } + + // Get the sync policy + v, err = vm.Get("sync") + if err != nil { + return nil, "", nil, "", false, "", fmt.Errorf("could not get javascript command sync policy %s: %v", path, err) + } + if !v.IsBoolean() { + return nil, "", nil, "", false, "", fmt.Errorf("javascript command sync policy is not a boolean %s: %v", path, err) + } + synchronize, _ := v.ToBoolean() + + // Set command type + commandType := "JS_SCRIPT" + + return vm, strings.ToLower(command), categories, description, synchronize, commandType, nil +} + +// jsKeyExtractionFunc executes the extraction function defined in the script and returns the result or error. +func (server *SugarDB) jsKeyExtractionFunc(cmd []string, args []string) (internal.KeyExtractionFuncResult, error) { + // Lock the script before executing the key extraction function. + script, ok := server.scriptVMs.Load(strings.ToLower(cmd[0])) + if !ok { + return internal.KeyExtractionFuncResult{}, fmt.Errorf("no lock found for script command %s", cmd[0]) + } + machine := script.(struct { + vm any + lock *sync.Mutex + }) + machine.lock.Lock() + defer machine.lock.Unlock() + + vm := machine.vm.(*otto.Otto) + + f, _ := vm.Get("keyExtractionFunc") + if !f.IsFunction() { + return internal.KeyExtractionFuncResult{}, errors.New("keyExtractionFunc is not a function") + } + v, err := f.Call(f, cmd, args) + if err != nil { + return internal.KeyExtractionFuncResult{}, err + } + if !v.IsObject() { + return internal.KeyExtractionFuncResult{}, errors.New("keyExtractionFunc return type is not an object") + } + data := v.Object() + + rk, _ := data.Get("readKeys") + rkv, _ := rk.Export() + readKeys, ok := rkv.([]string) + if !ok { + if _, ok = rkv.([]interface{}); !ok { + return internal.KeyExtractionFuncResult{}, fmt.Errorf("readKeys for command %s is not an array", cmd[0]) + } + readKeys = []string{} + } + + wk, _ := data.Get("writeKeys") + wkv, _ := wk.Export() + writeKeys, ok := wkv.([]string) + if !ok { + if _, ok = wkv.([]interface{}); !ok { + return internal.KeyExtractionFuncResult{}, fmt.Errorf("writeKeys for command %s is not an array", cmd[0]) + } + writeKeys = []string{} + } + + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: readKeys, + WriteKeys: writeKeys, + }, nil +} + +// jsHandlerFunc executes the extraction function defined in the script nad returns the RESP response or error. +func (server *SugarDB) jsHandlerFunc(command string, args []string, params internal.HandlerFuncParams) ([]byte, error) { + // Lock the script before executing the key extraction function. + script, ok := server.scriptVMs.Load(strings.ToLower(command)) + if !ok { + return nil, fmt.Errorf("no lock found for script command %s", command) + } + machine := script.(struct { + vm any + lock *sync.Mutex + }) + machine.lock.Lock() + defer machine.lock.Unlock() + + vm := machine.vm.(*otto.Otto) + + f, _ := vm.Get("handlerFunc") + if !f.IsFunction() { + return nil, errors.New("handlerFunc is not a function") + } + v, err := f.Call( + f, + + // Build context + func() otto.Value { + obj, _ := vm.Object(`({})`) + _ = obj.Set("protocol", params.Context.Value("Protocol").(int)) + _ = obj.Set("database", params.Context.Value("Database").(int)) + return obj.Value() + }(), + + // Command + params.Command, + + // Build keysExist function + func(keys []string) otto.Value { + obj, _ := vm.Object(`({})`) + exists := server.keysExist(params.Context, keys) + for key, value := range exists { + _ = obj.Set(key, value) + } + return obj.Value() + }, + + // Build getValues function + func(keys []string) otto.Value { + obj, _ := vm.Object(`({})`) + values := server.getValues(params.Context, keys) + for key, value := range values { + switch value.(type) { + default: + _ = obj.Set(key, value) + case nil: + _ = obj.Set(key, otto.NullValue()) + case []string: + l, _ := vm.Object(`([])`) + for i, elem := range value.([]string) { + _ = l.Set(fmt.Sprintf("%d", i), elem) + } + _ = obj.Set(key, l.Value()) + case hash.Hash: + h, _ := vm.Object(`({})`) + buildHashObject(h, value.(hash.Hash)) + _ = obj.Set(key, h.Value()) + case *set.Set: + s, _ := vm.Object(`({})`) + buildSetObject(s, value.(*set.Set)) + _ = obj.Set(key, s.Value()) + case *sorted_set.SortedSet: + ss, _ := vm.Object(`({})`) + buildSortedSetObject(ss, value.(*sorted_set.SortedSet)) + _ = obj.Set(key, ss.Value()) + } + } + return obj.Value() + }, + + // Build setValues function + func(entries map[string]interface{}) { + values := make(map[string]interface{}) + for key, entry := range entries { + switch entry.(type) { + default: + panicInHandler(fmt.Sprintf("unknown type %s on key %s", reflect.TypeOf(entry).String(), key)) + case nil: + values[key] = nil + case string: + values[key] = internal.AdaptType(entry.(string)) + case int64: + values[key] = int(entry.(int64)) + case float64: + values[key] = entry.(float64) + case []string: + values[key] = entry.([]string) + case map[string]interface{}: + value, ok := entry.(map[string]interface{}) + if !ok || value["__id"] == nil { + panicInHandler(fmt.Sprintf("unknown object on key %s", key)) + } + obj, exists := getObjectById(value["__id"].(string)) + if !exists { + panicInHandler( + fmt.Sprintf( + "could not find object of id %s in the object registry on key %s", + value["__id"].(string), + key, + ), + ) + } + switch obj.(type) { + default: + panicInHandler(fmt.Sprintf("unknown type on key %s for command %s\n", key, command)) + case hash.Hash: + values[key] = obj.(hash.Hash) + case *set.Set: + values[key] = obj.(*set.Set) + case *sorted_set.SortedSet: + values[key] = obj.(*sorted_set.SortedSet) + } + } + } + if err := server.setValues(params.Context, values); err != nil { + panicInHandler(err.Error()) + } + }, + + // Args + args, + ) + if err != nil { + return nil, err + } + res, err := v.ToString() + + clearObjectRegistry() + + return []byte(res), err +} + +func buildHashObject(obj *otto.Object, h hash.Hash) { + _ = obj.Set("__type", "hash") + _ = obj.Set("__id", registerObject(h)) + _ = obj.Set("set", func(call otto.FunctionCall) otto.Value { + args := call.Argument(0).Object() + for _, key := range args.Keys() { + value, _ := args.Get(key) + v, _ := value.ToString() + h[key] = hash.HashValue{Value: v} + } + // Return changed count using the set data type + count, _ := otto.ToValue(set.NewSet(args.Keys()).Cardinality()) + return count + }) + _ = obj.Set("setnx", func(call otto.FunctionCall) otto.Value { + count := 0 + args := call.Argument(0).Object() + for _, key := range args.Keys() { + if _, exists := h[key]; exists { + continue + } + count += 1 + value, _ := args.Get(key) + v, _ := value.ToString() + h[key] = hash.HashValue{Value: v} + } + c, _ := otto.ToValue(count) + return c + }) + _ = obj.Set("get", func(call otto.FunctionCall) otto.Value { + result, _ := call.Otto.Object(`({})`) + for _, arg := range call.ArgumentList { + key, _ := arg.ToString() + value, _ := otto.ToValue(h[key].Value) + _ = result.Set(key, value) + } + return result.Value() + }) + _ = obj.Set("len", func(call otto.FunctionCall) otto.Value { + length, _ := otto.ToValue(len(h)) + return length + }) + _ = obj.Set("all", func(call otto.FunctionCall) otto.Value { + result, _ := call.Otto.Object(`({})`) + for key, value := range h { + v, _ := otto.ToValue(value.Value) + _ = result.Set(key, v) + } + return result.Value() + }) + _ = obj.Set("exists", func(call otto.FunctionCall) otto.Value { + result, _ := call.Otto.Object(`({})`) + for _, arg := range call.ArgumentList { + key, _ := arg.ToString() + _, ok := h[key] + exists, _ := call.Otto.ToValue(ok) + _ = result.Set(key, exists) + } + return result.Value() + }) + _ = obj.Set("del", func(call otto.FunctionCall) otto.Value { + count := 0 + for _, arg := range call.ArgumentList { + key, _ := arg.ToString() + if _, exists := h[key]; exists { + count += 1 + delete(h, key) + } + } + result, _ := otto.ToValue(count) + return result + }) +} + +func buildSetObject(obj *otto.Object, s *set.Set) { + _ = obj.Set("__type", "set") + _ = obj.Set("__id", registerObject(s)) + _ = obj.Set("add", func(call otto.FunctionCall) otto.Value { + args := call.Argument(0).Object() + if args == nil { + panicWithFunctionCall(call, "set add method argument not an object") + } + var elems []string + for _, key := range args.Keys() { + value, _ := args.Get(key) + v, _ := value.ToString() + elems = append(elems, v) + } + count := s.Add(elems) + result, _ := otto.ToValue(count) + return result + }) + _ = obj.Set("pop", func(call otto.FunctionCall) otto.Value { + count, _ := call.Argument(0).ToInteger() + popped := s.Pop(int(count)) + result, _ := call.Otto.Object(`([])`) + _ = result.Set("length", len(popped)) + for i, p := range popped { + _ = result.Set(fmt.Sprintf("%d", i), p) + } + return result.Value() + }) + _ = obj.Set("contains", func(call otto.FunctionCall) otto.Value { + value, _ := call.Argument(0).ToString() + result, _ := otto.ToValue(s.Contains(value)) + return result + }) + _ = obj.Set("cardinality", func(call otto.FunctionCall) otto.Value { + result, _ := otto.ToValue(s.Cardinality()) + return result + }) + _ = obj.Set("remove", func(call otto.FunctionCall) otto.Value { + args := call.Argument(0).Object() + if args == nil { + panicWithFunctionCall(call, "set remove method argument not an object") + } + var elems []string + for _, key := range args.Keys() { + value, _ := args.Get(key) + v, _ := value.ToString() + elems = append(elems, v) + } + result, _ := otto.ToValue(s.Remove(elems)) + return result + }) + _ = obj.Set("all", func(call otto.FunctionCall) otto.Value { + all := s.GetAll() + result, _ := call.Otto.Object(`([])`) + _ = result.Set("length", len(all)) + for i, e := range all { + _ = result.Set(fmt.Sprintf("%d", i), e) + } + return result.Value() + }) + _ = obj.Set("random", func(call otto.FunctionCall) otto.Value { + count, _ := call.Argument(0).ToInteger() + random := s.GetRandom(int(count)) + result, _ := call.Otto.Object(`([])`) + _ = result.Set("length", len(random)) + for i, r := range random { + _ = result.Set(fmt.Sprintf("%d", i), r) + } + return result.Value() + }) + _ = obj.Set("move", func(call otto.FunctionCall) otto.Value { + arg := call.Argument(0).Object() + elem := call.Argument(1).String() + id, _ := arg.Get("__id") + o, exists := getObjectById(id.String()) + if !exists { + panicWithFunctionCall(call, "move target set does not exist") + } + switch o.(type) { + default: + panicWithFunctionCall(call, "move target is not a set") + case *set.Set: + moved := s.Move(o.(*set.Set), elem) == 1 + result, _ := otto.ToValue(moved) + return result + } + return otto.NullValue() + }) + _ = obj.Set("subtract", func(call otto.FunctionCall) otto.Value { + extractSets := func(call otto.FunctionCall) ([]*set.Set, error) { + var sets []*set.Set + if len(call.ArgumentList) > 1 { + return sets, fmt.Errorf("set subtract method expects 1 arg, got %d", len(call.ArgumentList)) + } + arg1 := call.Argument(0).Object() + if arg1.Class() != "Array" { + return sets, errors.New("set subtract method expects the first argument to be an array") + } + for _, key := range arg1.Keys() { + // Check if the array element is a valid MemberParam type. + argMember, _ := arg1.Get(key) + if !argMember.IsObject() { + panicWithFunctionCall(call, "set subtract method first arg must be an array of valid sets") + } + // Get the member param from the object registry + argMemberObj := argMember.Object() + id, _ := argMemberObj.Get("__id") + o, exists := getObjectById(id.String()) + if !exists { + panicWithFunctionCall(call, "set subtract method first arg must be an array of valid sets") + } + m, ok := o.(*set.Set) + if !ok { + panicWithFunctionCall(call, "set subtract method first arg must be an array of valid sets") + } + sets = append(sets, m) + } + return sets, nil + } + sets, err := extractSets(call) + if err != nil { + panicWithFunctionCall(call, err.Error()) + } + diff := s.Subtract(sets) + result, _ := call.Otto.Object(`({})`) + buildSetObject(result, diff) + return result.Value() + }) +} + +func buildMemberParamObject(obj *otto.Object, m *sorted_set.MemberParam) { + _ = obj.Set("__type", "zmember") + _ = obj.Set("__id", registerObject(m)) + _ = obj.Set("value", func(call otto.FunctionCall) otto.Value { + switch len(call.ArgumentList) { + case 0: + // If no value is passed, then return the current value + v, _ := otto.ToValue(m.Value) + return v + case 1: + // If a value is passed, then set the value + v := call.Argument(0).String() + if len(v) <= 0 { + panicWithFunctionCall(call, "zset member value must be a non-empty string") + } + m.Value = sorted_set.Value(v) + default: + panicWithFunctionCall( + call, + fmt.Sprintf( + "expected either 0 or 1 args for value method of zmember, got %d", + len(call.ArgumentList), + ), + ) + } + return otto.NullValue() + }) + _ = obj.Set("score", func(call otto.FunctionCall) otto.Value { + switch len(call.ArgumentList) { + case 0: + s, _ := otto.ToValue(m.Score) + return s + case 1: + s, _ := call.Argument(0).ToFloat() + if math.IsNaN(s) { + panicWithFunctionCall(call, "zset member score must be a valid number") + } + m.Score = sorted_set.Score(s) + default: + panicWithFunctionCall( + call, + fmt.Sprintf( + "expected either 0 or 1 args for score method of zmember, got %d", + len(call.ArgumentList), + ), + ) + } + return otto.NullValue() + }) +} + +func validateMemberParamObject(obj *otto.Object) error { + value, _ := obj.Get("value") + if slices.Contains([]otto.Value{otto.UndefinedValue(), otto.NullValue()}, value) || + len(value.String()) == 0 { + return errors.New("zset member value must be a non-empty string") + } + s, _ := obj.Get("score") + if slices.Contains([]otto.Value{otto.UndefinedValue(), otto.NullValue()}, s) { + return errors.New("zset member must have a score") + } + score, _ := s.ToFloat() + if math.IsNaN(score) { + return errors.New("zset member score must be a valid number") + } + return nil +} + +func buildSortedSetObject(obj *otto.Object, ss *sorted_set.SortedSet) { + // Function to extract member param arguments for "add" and "update" methods. + extractMembers := func(call otto.FunctionCall) ([]sorted_set.MemberParam, error) { + var members []sorted_set.MemberParam + if !call.Argument(0).IsObject() { + return members, errors.New("zset add or update method expects the first argument to be an array") + } + arg1 := call.Argument(0).Object() + if arg1.Class() != "Array" { + return members, errors.New("zset add or update method expects the first argument to be an array") + } + for _, key := range arg1.Keys() { + // Check if the array element is a valid MemberParam type. + argMember, _ := arg1.Get(key) + if !argMember.IsObject() { + panicWithFunctionCall(call, "zset add or update method first arg must be an array of valid zmembers") + } + // Get the member param from the object registry + argMemberObj := argMember.Object() + id, _ := argMemberObj.Get("__id") + o, exists := getObjectById(id.String()) + if !exists { + panicWithFunctionCall(call, "zset add or update method first arg must be an array of valid zmembers") + } + m, ok := o.(*sorted_set.MemberParam) + if !ok { + panicWithFunctionCall(call, "zset add or update method first arg must be an array of valid zmembers") + } + members = append(members, *m) + } + return members, nil + } + + // Function to build and verify the update policy for "add" and "update" methods + type updateModifiers struct { + updatePolicy interface{} + comparison interface{} + changed interface{} + incr interface{} + } + extractUpdateModifiers := func(call otto.FunctionCall) (updateModifiers, error) { + modifiers := updateModifiers{updatePolicy: nil, comparison: nil, changed: nil, incr: nil} + if len(call.ArgumentList) < 2 { + return modifiers, nil + } + if !call.Argument(1).IsObject() { + return modifiers, errors.New("zset add or update method second arg must be an object") + } + arg2 := call.Argument(1).Object() + acceptedKeys := []string{"exists", "comparison", "changed", "incr"} + for _, key := range arg2.Keys() { + if !slices.Contains(acceptedKeys, key) { + return modifiers, fmt.Errorf( + "zset add or update method second arg unknown key '%s', expected %+v", key, acceptedKeys) + } + v, _ := arg2.Get(key) + switch key { + case "exists": + if !v.IsBoolean() { + return modifiers, errors.New("zset add or update method second arg 'exists' key should be a boolean") + } + exists, _ := v.ToBoolean() + if exists { + modifiers.updatePolicy = "xx" + } else { + modifiers.updatePolicy = "nx" + } + case "comparison": + modifiers.comparison = v.String() + case "changed": + if !v.IsBoolean() { + return modifiers, errors.New("zset add or update method second arg 'changed' key should be a boolean") + } + changed, _ := v.ToBoolean() + modifiers.changed = changed + case "incr": + if !v.IsBoolean() { + return modifiers, errors.New("zset add or update method second arg 'incr' key should be a boolean") + } + incr, _ := v.ToBoolean() + modifiers.incr = incr + } + } + return modifiers, nil + } + + _ = obj.Set("__type", "zset") + _ = obj.Set("__id", registerObject(ss)) + _ = obj.Set("add", func(call otto.FunctionCall) otto.Value { + if len(call.ArgumentList) < 1 || len(call.ArgumentList) > 2 { + panicWithFunctionCall(call, fmt.Sprintf("zset add method expects 1 or 2 args, got %d", len(call.ArgumentList))) + } + // Extract the member params from the first arg + members, err := extractMembers(call) + if err != nil { + panicWithFunctionCall(call, err.Error()) + } + // Extract the modifiers in the second arg, if they are passed. + modifiers, err := extractUpdateModifiers(call) + if err != nil { + panicWithFunctionCall(call, err.Error()) + } + count, err := ss.AddOrUpdate( + members, + modifiers.updatePolicy, + modifiers.comparison, + modifiers.changed, + modifiers.incr, + ) + if err != nil { + panicWithFunctionCall(call, err.Error()) + } + v, _ := call.Otto.ToValue(count) + return v + }) + _ = obj.Set("update", func(call otto.FunctionCall) otto.Value { + if len(call.ArgumentList) < 1 || len(call.ArgumentList) > 2 { + panicWithFunctionCall(call, fmt.Sprintf("zset update method expects 1 or 2 args, got %d", len(call.ArgumentList))) + } + // Extract the member params from the first arg + members, err := extractMembers(call) + if err != nil { + panicWithFunctionCall(call, err.Error()) + } + // Extract the modifiers in the second arg, if they are passed. + modifiers, err := extractUpdateModifiers(call) + if err != nil { + panicWithFunctionCall(call, err.Error()) + } + count, err := ss.AddOrUpdate( + members, + modifiers.updatePolicy, + modifiers.comparison, + modifiers.changed, + modifiers.incr, + ) + if err != nil { + panicWithFunctionCall(call, err.Error()) + } + v, _ := call.Otto.ToValue(count) + return v + }) + _ = obj.Set("remove", func(call otto.FunctionCall) otto.Value { + if len(call.ArgumentList) != 1 { + panicWithFunctionCall(call, fmt.Sprintf("zset remove method expects 1 ard, got %d", len(call.ArgumentList))) + } + value := sorted_set.Value(call.Argument(0).String()) + v, _ := call.Otto.ToValue(ss.Remove(value)) + return v + }) + _ = obj.Set("cardinality", func(call otto.FunctionCall) otto.Value { + value, _ := otto.ToValue(ss.Cardinality()) + return value + }) + _ = obj.Set("contains", func(call otto.FunctionCall) otto.Value { + if len(call.ArgumentList) != 1 { + panicWithFunctionCall(call, fmt.Sprintf("zset contains method expects 1 arg, got %d", len(call.ArgumentList))) + } + v, _ := otto.ToValue(ss.Contains(sorted_set.Value(call.Argument(0).String()))) + return v + }) + _ = obj.Set("random", func(call otto.FunctionCall) otto.Value { + if len(call.ArgumentList) != 1 { + panicWithFunctionCall(call, fmt.Sprintf("zset random method expects 1 arg, got %d", len(call.ArgumentList))) + } + count, _ := call.Argument(0).ToInteger() + var paramValues []otto.Value + for _, p := range ss.GetRandom(int(count)) { + m, _ := call.Otto.Object(`({})`) + buildMemberParamObject(m, &p) + paramValues = append(paramValues, m.Value()) + } + p, _ := call.Otto.ToValue(paramValues) + return p + }) + _ = obj.Set("all", func(call otto.FunctionCall) otto.Value { + var paramValues []otto.Value + for _, p := range ss.GetAll() { + m, _ := call.Otto.Object(`({})`) + buildMemberParamObject(m, &p) + paramValues = append(paramValues, m.Value()) + } + p, _ := call.Otto.ToValue(paramValues) + return p + }) + _ = obj.Set("subtract", func(call otto.FunctionCall) otto.Value { + extractZSets := func(call otto.FunctionCall) ([]*sorted_set.SortedSet, error) { + var zsets []*sorted_set.SortedSet + if len(call.ArgumentList) > 1 { + return zsets, fmt.Errorf("zset subtract method expects 1 arg, got %d", len(call.ArgumentList)) + } + arg1 := call.Argument(0).Object() + if arg1.Class() != "Array" { + return zsets, errors.New("zset subtract method expects the first argument to be an array") + } + for _, key := range arg1.Keys() { + // Check if the array element is a valid MemberParam type. + argMember, _ := arg1.Get(key) + if !argMember.IsObject() { + panicWithFunctionCall(call, "zset subtract method first arg must be an array of valid zsets") + } + // Get the member param from the object registry + argMemberObj := argMember.Object() + id, _ := argMemberObj.Get("__id") + o, exists := getObjectById(id.String()) + if !exists { + panicWithFunctionCall(call, "zset subtract method first arg must be an array of valid zsets") + } + m, ok := o.(*sorted_set.SortedSet) + if !ok { + panicWithFunctionCall(call, "zset subtract method first arg must be an array of valid zsets") + } + zsets = append(zsets, m) + } + return zsets, nil + } + zsets, err := extractZSets(call) + if err != nil { + panicWithFunctionCall(call, err.Error()) + } + diff := ss.Subtract(zsets) + result, _ := call.Otto.Object(`({})`) + buildSortedSetObject(result, diff) + return result.Value() + }) +} + +func panicWithFunctionCall(call otto.FunctionCall, message string) { + err, _ := call.Otto.ToValue(message) + panic(err) +} + +func panicInHandler(message string) { + value, _ := otto.ToValue(message) + panic(value) +} diff --git a/sugardb/plugin_lua.go b/sugardb/plugin_lua.go new file mode 100644 index 0000000..d5fb58d --- /dev/null +++ b/sugardb/plugin_lua.go @@ -0,0 +1,946 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/modules/hash" + "apigo.cc/go/sugardb/internal/modules/set" + "apigo.cc/go/sugardb/internal/modules/sorted_set" + lua "github.com/yuin/gopher-lua" + "strings" + "sync" +) + +func generateLuaCommandInfo(path string) (*lua.LState, string, []string, string, bool, string, error) { + L := lua.NewState() + + // Load lua file + if err := L.DoFile(path); err != nil { + return nil, "", nil, "", false, "", fmt.Errorf("could not load lua script file %s: %v", path, err) + } + + // Register hash data type + hashMetaTable := L.NewTypeMetatable("hash") + L.SetGlobal("hash", hashMetaTable) + // Static methods + L.SetField(hashMetaTable, "new", L.NewFunction(func(state *lua.LState) int { + ud := state.NewUserData() + ud.Value = hash.Hash{} + state.SetMetatable(ud, state.GetTypeMetatable("hash")) + state.Push(ud) + return 1 + })) + // Hash methods + L.SetField(hashMetaTable, "__index", L.SetFuncs(L.NewTable(), map[string]lua.LGFunction{ + "set": func(state *lua.LState) int { + // Implement set method, set key/value pair + h := checkHash(state, 1) + var count int + tbl := state.CheckTable(2) + tbl.ForEach(func(index lua.LValue, pair lua.LValue) { + // Check if the pair is a table + p, ok := pair.(*lua.LTable) + if !ok { + state.ArgError(2, "expected a table containing key/value pairs") + return + } + p.ForEach(func(field lua.LValue, value lua.LValue) { + // Check if field is a string + if _, ok = field.(lua.LString); !ok { + state.ArgError(2, "expected all hash fields to be strings") + return + } + // If the field exists, update it. Otherwise, add it to the hash. + v, err := luaTypeToNativeType(value) + if err != nil { + state.ArgError(2, err.Error()) + return + } + if _, exists := h[field.String()]; !exists { + h[field.String()] = hash.HashValue{Value: v} + } else { + hashValue := h[field.String()] + hashValue.Value = v + h[field.String()] = hashValue + } + count += 1 + }) + }) + state.Push(lua.LNumber(count)) + return 1 + }, + "setnx": func(state *lua.LState) int { + // Implement set methods to set key/value pairs only when the key does not exist in the hash. + h := checkHash(state, 1) + var count int + tbl := state.CheckTable(2) + tbl.ForEach(func(index lua.LValue, pair lua.LValue) { + // Check if the pair is a table + p, ok := pair.(*lua.LTable) + if !ok { + state.ArgError(2, "expected a table containing key/value pairs") + return + } + p.ForEach(func(field lua.LValue, value lua.LValue) { + // Check if field is a string + if _, ok = field.(lua.LString); !ok { + state.ArgError(2, "expected all table fields to be strings") + } + v, err := luaTypeToNativeType(value) + if err != nil { + state.ArgError(2, err.Error()) + return + } + // If the field does not exist, add it. + if _, exists := h[field.String()]; !exists { + h[field.String()] = hash.HashValue{Value: v} + count += 1 + } + }) + }) + state.Push(lua.LNumber(count)) + return 1 + }, + "get": func(state *lua.LState) int { + // Implement get method, return multiple key/value pairs + h := checkHash(state, 1) + result := state.NewTable() + args := state.CheckTable(2) + args.ForEach(func(index lua.LValue, field lua.LValue) { + if _, ok := index.(lua.LNumber); !ok { + state.ArgError(2, "expected key to be a number") + return + } + if _, ok := field.(lua.LString); !ok { + state.ArgError(2, "expected field to be a string") + return + } + var value lua.LValue + if _, exists := h[field.String()]; exists { + value = nativeTypeToLuaType(state, h[field.String()].Value) + } else { + value = lua.LNil + } + result.RawSet(field, value) + }) + state.Push(result) + return 1 + }, + "len": func(state *lua.LState) int { + // Implement method len, returns the length of the hash + h := checkHash(state, 1) + state.Push(lua.LNumber(len(h))) + return 1 + }, + "all": func(state *lua.LState) int { + // Implement method all, returns all key/value pairs in the hash + h := checkHash(state, 1) + result := state.NewTable() + for field, hashValue := range h { + result.RawSetString(field, lua.LString(hashValue.Value.(string))) + } + state.Push(result) + return 1 + }, + "exists": func(state *lua.LState) int { + // Checks if the value exists in the hash + h := checkHash(state, 1) + result := state.NewTable() + args := state.CheckTable(2) + args.ForEach(func(index lua.LValue, field lua.LValue) { + if _, ok := index.(lua.LNumber); !ok { + state.ArgError(2, "expected table key to be number") + return + } + if _, ok := field.(lua.LString); !ok { + state.ArgError(2, "expected field to be a string") + return + } + _, exists := h[field.String()] + result.RawSet(field, lua.LBool(exists)) + }) + state.Push(result) + return 1 + }, + "del": func(state *lua.LState) int { + // Delete multiple fields from a hash, return the number of deleted fields + h := checkHash(state, 1) + var count int + args := state.CheckTable(2) + args.ForEach(func(index lua.LValue, field lua.LValue) { + if _, ok := index.(lua.LNumber); !ok { + state.ArgError(2, "expected table key to be index") + return + } + if _, ok := field.(lua.LString); !ok { + state.ArgError(2, "expected field value to be a string") + return + } + if _, exists := h[field.String()]; exists { + delete(h, field.String()) + count += 1 + } + }) + state.Push(lua.LNumber(count)) + return 1 + }, + })) + + // Register set data type + setMetaTable := L.NewTypeMetatable("set") + L.SetGlobal("set", setMetaTable) + // Static methods + L.SetField(setMetaTable, "new", L.NewFunction(func(state *lua.LState) int { + // Create set + s := set.NewSet([]string{}) + // If the default values are passed, add them to the set. + if state.GetTop() == 1 { + elems := state.CheckTable(1) + elems.ForEach(func(key lua.LValue, value lua.LValue) { + s.Add([]string{value.String()}) + }) + state.Pop(1) + } + // Push the set to the stack + ud := state.NewUserData() + ud.Value = s + state.SetMetatable(ud, state.GetTypeMetatable("set")) + state.Push(ud) + return 1 + })) + // Set methods + L.SetField(setMetaTable, "__index", L.SetFuncs(L.NewTable(), map[string]lua.LGFunction{ + "add": func(state *lua.LState) int { + s := checkSet(state, 1) + // Extract the elements from the args + var elems []string + tbl := state.CheckTable(2) + tbl.ForEach(func(key lua.LValue, value lua.LValue) { + elems = append(elems, value.String()) + }) + // Add the elements to the set + state.Push(lua.LNumber(s.Add(elems))) + return 1 + }, + "pop": func(state *lua.LState) int { + s := checkSet(state, 1) + count := state.CheckNumber(2) + // Create the table of popped elements + popped := state.NewTable() + for i, elem := range s.Pop(int(count)) { + popped.RawSetInt(i+1, lua.LString(elem)) + } + // Return popped elements + state.Push(popped) + return 1 + }, + "contains": func(state *lua.LState) int { + s := checkSet(state, 1) + state.Push(lua.LBool(s.Contains(state.CheckString(2)))) + return 1 + }, + "cardinality": func(state *lua.LState) int { + s := checkSet(state, 1) + state.Push(lua.LNumber(s.Cardinality())) + return 1 + }, + "remove": func(state *lua.LState) int { + s := checkSet(state, 1) + // Extract elements to be removed + var elems []string + tbl := state.CheckTable(2) + tbl.ForEach(func(key lua.LValue, value lua.LValue) { + elems = append(elems, value.String()) + }) + // Remove the elements and return the removed count + state.Push(lua.LNumber(s.Remove(elems))) + return 1 + }, + "move": func(state *lua.LState) int { + s1 := checkSet(state, 1) + s2 := checkSet(state, 2) + elem := state.CheckString(3) + moved := s1.Move(s2, elem) + state.Push(lua.LBool(moved == 1)) + return 1 + }, + "subtract": func(state *lua.LState) int { + s1 := checkSet(state, 1) + var sets []*set.Set + // Extract sets to subtract + tbl := state.CheckTable(2) + tbl.ForEach(func(key lua.LValue, value lua.LValue) { + ud, ok := value.(*lua.LUserData) + if !ok { + state.ArgError(2, "table must only contain sets") + return + } + s, ok := ud.Value.(*set.Set) + if !ok { + state.ArgError(2, "table must only contain sets") + return + } + sets = append(sets, s) + }) + // Return the resulting set + ud := state.NewUserData() + ud.Value = s1.Subtract(sets) + state.SetMetatable(ud, state.GetTypeMetatable("set")) + state.Push(ud) + return 1 + }, + "all": func(state *lua.LState) int { + s := checkSet(state, 1) + // Build table of all the elements in the set + elems := state.NewTable() + for i, e := range s.GetAll() { + elems.RawSetInt(i+1, lua.LString(e)) + } + // Return all the set's elements + state.Push(elems) + return 1 + }, + "random": func(state *lua.LState) int { + s := checkSet(state, 1) + count := state.CheckNumber(2) + // Build table of random elements + elems := state.NewTable() + for i, e := range s.GetRandom(int(count)) { + elems.RawSetInt(i+1, lua.LString(e)) + } + // Return random elements + state.Push(elems) + return 1 + }, + })) + + // Register sorted set member data type + sortedSetMemberMetaTable := L.NewTypeMetatable("zmember") + L.SetGlobal("zmember", sortedSetMemberMetaTable) + // Static methods + L.SetField(sortedSetMemberMetaTable, "new", L.NewFunction(func(state *lua.LState) int { + // Create sorted set member param + param := &sorted_set.MemberParam{} + // Make sure a value table is passed + if state.GetTop() != 1 { + state.ArgError(1, "expected table containing value and score to be passed") + } + // Set the passed values in params + arg := state.CheckTable(1) + arg.ForEach(func(key lua.LValue, value lua.LValue) { + switch strings.ToLower(key.String()) { + case "score": + if score, ok := value.(lua.LNumber); ok { + param.Score = sorted_set.Score(score) + return + } + state.ArgError(1, "score is not a number") + case "value": + param.Value = sorted_set.Value(value.String()) + default: + state.ArgError(1, fmt.Sprintf("unexpected key '%s' in zmember table", key.String())) + } + }) + // Check if value is not empty + if param.Value == "" { + state.ArgError(1, fmt.Sprintf("value is empty string")) + } + // Push the param to the stack and return + ud := state.NewUserData() + ud.Value = param + state.SetMetatable(ud, state.GetTypeMetatable("zmember")) + state.Push(ud) + return 1 + })) + // Sorted set member methods + L.SetField(sortedSetMemberMetaTable, "__index", L.SetFuncs(L.NewTable(), map[string]lua.LGFunction{ + "value": func(state *lua.LState) int { + m := checkSortedSetMember(state, 1) + if state.GetTop() == 2 { + m.Value = sorted_set.Value(state.CheckString(2)) + return 0 + } + L.Push(lua.LString(m.Value)) + return 1 + }, + "score": func(state *lua.LState) int { + m := checkSortedSetMember(state, 1) + if state.GetTop() == 2 { + m.Score = sorted_set.Score(state.CheckNumber(2)) + return 0 + } + L.Push(lua.LNumber(m.Score)) + return 1 + }, + })) + + // Register sorted set data type + sortedSetMetaTable := L.NewTypeMetatable("zset") + L.SetGlobal("zset", sortedSetMetaTable) + // Static methods + L.SetField(sortedSetMetaTable, "new", L.NewFunction(func(state *lua.LState) int { + // If default values are passed, add them to the set + var members []sorted_set.MemberParam + if state.GetTop() == 1 { + params := state.CheckTable(1) + params.ForEach(func(key lua.LValue, value lua.LValue) { + d, ok := value.(*lua.LUserData) + if !ok { + state.ArgError(1, "expected user data") + } + if m, ok := d.Value.(*sorted_set.MemberParam); ok { + members = append(members, sorted_set.MemberParam{Value: m.Value, Score: m.Score}) + return + } + state.ArgError(1, fmt.Sprintf("expected member param, got %s", value.Type().String())) + }) + } + // Create the sorted set + ss := sorted_set.NewSortedSet(members) + ud := state.NewUserData() + ud.Value = ss + state.SetMetatable(ud, state.GetTypeMetatable("zset")) + state.Push(ud) + return 1 + })) + // Sorted set methods + L.SetField(sortedSetMetaTable, "__index", L.SetFuncs(L.NewTable(), map[string]lua.LGFunction{ + "add": func(state *lua.LState) int { + ss := checkSortedSet(state, 1) + + // Extract member params + paramArgs := state.CheckTable(2) + var params []sorted_set.MemberParam + paramArgs.ForEach(func(key lua.LValue, value lua.LValue) { + ud, ok := value.(*lua.LUserData) + if !ok { + state.ArgError(2, "expected zmember") + } + if m, ok := ud.Value.(*sorted_set.MemberParam); ok { + params = append(params, sorted_set.MemberParam{Value: m.Value, Score: m.Score}) + return + } + state.ArgError(2, "expected zmember to be sorted set member param") + }) + + // Extract the update options + var updatePolicy interface{} = nil + var comparison interface{} = nil + var changed interface{} = nil + var incr interface{} = nil + if state.GetTop() == 3 { + optsArgs := state.CheckTable(3) + optsArgs.ForEach(func(key lua.LValue, value lua.LValue) { + switch key.String() { + default: + state.ArgError(3, fmt.Sprintf("unknown option '%s'", key.String())) + case "exists": + if value == lua.LTrue { + updatePolicy = "xx" + } else { + updatePolicy = "nx" + } + case "comparison": + comparison = value.String() + case "changed": + if value == lua.LTrue { + changed = "ch" + } + case "incr": + if value == lua.LTrue { + incr = "incr" + } + } + }) + } + + ch, err := ss.AddOrUpdate(params, updatePolicy, comparison, changed, incr) + if err != nil { + state.ArgError(3, err.Error()) + } + L.Push(lua.LNumber(ch)) + return 1 + }, + "update": func(state *lua.LState) int { + ss := checkSortedSet(state, 1) + + // Extract member params + paramArgs := state.CheckTable(2) + var params []sorted_set.MemberParam + paramArgs.ForEach(func(key lua.LValue, value lua.LValue) { + ud, ok := value.(*lua.LUserData) + if !ok { + state.ArgError(2, "expected zmember") + } + if m, ok := ud.Value.(*sorted_set.MemberParam); ok { + params = append(params, sorted_set.MemberParam{Value: m.Value, Score: m.Score}) + return + } + state.ArgError(2, "expected zmember to be sorted set member param") + }) + + // Extract the update options + var updatePolicy interface{} = nil + var comparison interface{} = nil + var changed interface{} = nil + var incr interface{} = nil + if state.GetTop() == 3 { + optsArgs := state.CheckTable(3) + optsArgs.ForEach(func(key lua.LValue, value lua.LValue) { + switch key.String() { + default: + state.ArgError(3, fmt.Sprintf("unknown option '%s'", key.String())) + case "exists": + if value == lua.LTrue { + updatePolicy = "xx" + } else { + updatePolicy = "nx" + } + case "comparison": + comparison = value.String() + case "changed": + if value == lua.LTrue { + changed = "ch" + } + case "incr": + if value == lua.LTrue { + incr = "incr" + } + } + }) + } + + ch, err := ss.AddOrUpdate(params, updatePolicy, comparison, changed, incr) + if err != nil { + state.ArgError(3, err.Error()) + } + L.Push(lua.LNumber(ch)) + return 1 + }, + "remove": func(state *lua.LState) int { + ss := checkSortedSet(state, 1) + L.Push(lua.LBool(ss.Remove(sorted_set.Value(state.CheckString(2))))) + return 1 + }, + "cardinality": func(state *lua.LState) int { + state.Push(lua.LNumber(checkSortedSet(state, 1).Cardinality())) + return 1 + }, + "contains": func(state *lua.LState) int { + ss := checkSortedSet(state, 1) + L.Push(lua.LBool(ss.Contains(sorted_set.Value(state.Get(-2).String())))) + return 1 + }, + "random": func(state *lua.LState) int { + ss := checkSortedSet(state, 1) + count := 1 + // If a count is passed, use that + if state.GetTop() == 2 { + count = state.CheckInt(2) + } + // Build members table + random := state.NewTable() + members := ss.GetRandom(count) + for i, member := range members { + ud := state.NewUserData() + ud.Value = sorted_set.MemberParam{Value: member.Value, Score: member.Score} + state.SetMetatable(ud, state.GetTypeMetatable("zmember")) + random.RawSetInt(i+1, ud) + } + // Push the table to the stack + state.Push(random) + return 1 + }, + "all": func(state *lua.LState) int { + ss := checkSortedSet(state, 1) + // Build members table + members := state.NewTable() + for i, member := range ss.GetAll() { + ud := state.NewUserData() + ud.Value = &sorted_set.MemberParam{Value: member.Value, Score: member.Score} + state.SetMetatable(ud, state.GetTypeMetatable("zmember")) + members.RawSetInt(i+1, ud) + } + // Push members table to stack and return + state.Push(members) + return 1 + }, + "subtract": func(state *lua.LState) int { + ss := checkSortedSet(state, 1) + // Get the sorted sets from the args + var others []*sorted_set.SortedSet + arg := state.CheckTable(2) + arg.ForEach(func(key lua.LValue, value lua.LValue) { + ud, ok := value.(*lua.LUserData) + if !ok { + state.ArgError(2, "expected user data") + } + zset, ok := ud.Value.(*sorted_set.SortedSet) + if !ok { + state.ArgError(2, fmt.Sprintf("expected zset at key '%s'", key.String())) + } + others = append(others, zset) + }) + // Calculate result + result := ss.Subtract(others) + // Push result to the stack and return + ud := state.NewUserData() + ud.Value = result + state.SetMetatable(ud, state.GetTypeMetatable("zset")) + L.Push(ud) + return 1 + }, + })) + + // Get the command name + cn := L.GetGlobal("command") + if _, ok := cn.(lua.LString); !ok { + return nil, "", nil, "", false, "", errors.New("command name does not exist or is not a string") + } + + // Get the categories + c := L.GetGlobal("categories") + var categories []string + if _, ok := c.(*lua.LTable); !ok { + return nil, "", nil, "", false, "", errors.New("categories does not exist or is not an array") + } + for i := 0; i < c.(*lua.LTable).Len(); i++ { + categories = append(categories, c.(*lua.LTable).RawGetInt(i+1).String()) + } + + // Get the description + d := L.GetGlobal("description") + if _, ok := d.(lua.LString); !ok { + return nil, "", nil, "", false, "", errors.New("description does not exist or is not a string") + } + + // Get the sync + synchronize := L.GetGlobal("sync") == lua.LTrue + + // Set command type + commandType := "LUA_SCRIPT" + + return L, strings.ToLower(cn.String()), categories, d.String(), synchronize, commandType, nil +} + +// luaKeyExtractionFunc executes the extraction function defined in the script and returns the result or error. +func (server *SugarDB) luaKeyExtractionFunc(cmd []string, args []string) (internal.KeyExtractionFuncResult, error) { + // Lock the script before executing the key extraction function + script, ok := server.scriptVMs.Load(strings.ToLower(cmd[0])) + if !ok { + return internal.KeyExtractionFuncResult{}, fmt.Errorf("no lock found for script command %s", cmd[0]) + } + machine := script.(struct { + vm any + lock *sync.Mutex + }) + machine.lock.Lock() + defer machine.lock.Unlock() + + L := machine.vm.(*lua.LState) + // Create command table to pass to the Lua function + command := L.NewTable() + for i, s := range cmd { + command.RawSetInt(i+1, lua.LString(s)) + } + // Create args table to pass to the Lua function + funcArgs := L.NewTable() + for i, s := range args { + funcArgs.RawSetInt(i+1, lua.LString(s)) + } + + // Call the Lua key extraction function + var err error + _ = L.CallByParam(lua.P{ + Fn: L.GetGlobal("keyExtractionFunc"), + NRet: 1, + Protect: true, + Handler: L.NewFunction(func(state *lua.LState) int { + err = errors.New(state.Get(-1).String()) + state.Pop(1) + return 0 + }), + }, command, funcArgs) + // Check if error was thrown + if err != nil { + return internal.KeyExtractionFuncResult{}, err + } + defer L.Pop(1) + if keys, ok := L.Get(-1).(*lua.LTable); ok { + // If the returned value is a table, get the keys from the table + return internal.KeyExtractionFuncResult{ + Channels: make([]string, 0), + ReadKeys: func() []string { + table := keys.RawGetString("readKeys").(*lua.LTable) + var k []string + for i := 1; i <= table.Len(); i++ { + k = append(k, table.RawGetInt(i).String()) + } + return k + }(), + WriteKeys: func() []string { + table := keys.RawGetString("writeKeys").(*lua.LTable) + var k []string + for i := 1; i <= table.Len(); i++ { + k = append(k, table.RawGetInt(i).String()) + } + return k + }(), + }, nil + } else { + // If the returned value is not a table, return error + return internal.KeyExtractionFuncResult{}, + fmt.Errorf("key extraction must return a table, got %s", L.Get(-1).Type()) + } +} + +// luaHandlerFunc executes the extraction function defined in the script nad returns the RESP response or error. +func (server *SugarDB) luaHandlerFunc(command string, args []string, params internal.HandlerFuncParams) ([]byte, error) { + // Lock this script's execution key before executing the handler. + script, ok := server.scriptVMs.Load(command) + if !ok { + return nil, fmt.Errorf("no lock found for script command %s", command) + } + machine := script.(struct { + vm any + lock *sync.Mutex + }) + machine.lock.Lock() + defer machine.lock.Unlock() + + L := machine.vm.(*lua.LState) + // Lua table context + ctx := L.NewTable() + ctx.RawSetString("protocol", lua.LNumber(params.Context.Value("Protocol").(int))) + ctx.RawSetString("database", lua.LNumber(params.Context.Value("Database").(int))) + // Command that triggered the handler (Array) + cmd := L.NewTable() + for i, s := range params.Command { + cmd.RawSetInt(i+1, lua.LString(s)) + } + // Function that checks if keys exist + keysExist := L.NewFunction(func(state *lua.LState) int { + // Get the keys array and pop it from the stack. + v := state.CheckTable(1) + state.Pop(1) + // Extract the keys from the keys array passed from the lua script. + var keys []string + for i := 1; i <= v.Len(); i++ { + keys = append(keys, v.RawGetInt(i).String()) + } + // Call the keysExist method to check if the key exists in the store. + exist := server.keysExist(params.Context, keys) + // Build the response table that specifies if each key exists. + res := state.NewTable() + for key, exists := range exist { + res.RawSetString(key, lua.LBool(exists)) + } + // Push the response to the stack. + state.Push(res) + return 1 + }) + // Function that gets values from keys + getValues := L.NewFunction(func(state *lua.LState) int { + // Get the keys array and pop it from the stack. + v := state.CheckTable(1) + state.Pop(1) + // Extract the keys from the keys array passed from the lua script. + var keys []string + for i := 1; i <= v.Len(); i++ { + keys = append(keys, v.RawGetInt(i).String()) + } + // Call the getValues method to get the values for each of the keys. + values := server.getValues(params.Context, keys) + // Build the response table that contains each key/value pair. + res := state.NewTable() + for key, value := range values { + // Actually parse the value and set it in the response as the appropriate LValue. + res.RawSetString(key, nativeTypeToLuaType(state, value)) + } + // Push the value to the stack + state.Push(res) + return 1 + }) + // Function that sets values on keys + setValues := L.NewFunction(func(state *lua.LState) int { + // Get the key/value table. + v := state.CheckTable(1) + // Get values passed from the Lua script and add. + values := make(map[string]interface{}) + var err error + v.ForEach(func(key lua.LValue, value lua.LValue) { + // Actually parse the value and set it in the response as the appropriate LValue. + values[key.String()], err = luaTypeToNativeType(value) + if err != nil { + state.ArgError(1, err.Error()) + } + }) + if err = server.setValues(params.Context, values); err != nil { + state.ArgError(1, err.Error()) + } + // pop key/value table from the stack + state.Pop(1) + return 0 + }) + // Args (Array) + funcArgs := L.NewTable() + for i, s := range args { + funcArgs.RawSetInt(i+1, lua.LString(s)) + } + + // Call the lua handler function + var err error + _ = L.CallByParam(lua.P{ + Fn: L.GetGlobal("handlerFunc"), + NRet: 1, + Protect: true, + Handler: L.NewFunction(func(state *lua.LState) int { + err = errors.New(state.Get(-1).String()) + state.Pop(1) + return 0 + }), + }, ctx, cmd, keysExist, getValues, setValues, funcArgs) + if err != nil { + return nil, err + } + // Get and pop the 2 values at the top of the stack, checking whether an error is returned. + defer L.Pop(1) + return []byte(L.Get(-1).String()), nil +} + +func checkHash(L *lua.LState, n int) hash.Hash { + ud := L.CheckUserData(n) + if v, ok := ud.Value.(hash.Hash); ok { + return v + } + L.ArgError(n, "hash expected") + return nil +} + +func checkSet(L *lua.LState, n int) *set.Set { + ud := L.CheckUserData(n) + if v, ok := ud.Value.(*set.Set); ok { + return v + } + L.ArgError(n, "set expected") + return nil +} + +func checkSortedSetMember(L *lua.LState, n int) *sorted_set.MemberParam { + ud := L.CheckUserData(n) + if v, ok := ud.Value.(*sorted_set.MemberParam); ok { + return v + } + L.ArgError(n, "zmember expected") + return nil +} + +func checkSortedSet(L *lua.LState, n int) *sorted_set.SortedSet { + ud := L.CheckUserData(n) + if v, ok := ud.Value.(*sorted_set.SortedSet); ok { + return v + } + L.ArgError(n, "zset expected") + return nil +} + +func checkArray(table *lua.LTable) ([]string, error) { + list := make([]string, table.Len()) + var err error = nil + table.ForEach(func(key lua.LValue, value lua.LValue) { + // Check if key is integer + idx, ok := key.(lua.LNumber) + if !ok { + err = fmt.Errorf("expected list keys to be integers, got %s", key.Type()) + return + } + // Check if value is string + val, ok := value.(lua.LString) + if !ok { + err = fmt.Errorf("expect all list values to be strings, got %s", key.Type()) + return + } + if int(idx)-1 >= len(list) { + err = fmt.Errorf("index %d greater than list capacity %d", int(idx), len(list)) + return + } + list[int(idx)-1] = val.String() + }) + return list, err +} + +func luaTypeToNativeType(value lua.LValue) (interface{}, error) { + switch value.Type() { + case lua.LTNil: + return nil, nil + case lua.LTString: + return value.String(), nil + case lua.LTNumber: + return internal.AdaptType(value.String()), nil + case lua.LTTable: + return checkArray(value.(*lua.LTable)) + case lua.LTUserData: + switch value.(*lua.LUserData).Value.(type) { + default: + return nil, errors.New("unknown user data") + case hash.Hash: + return value.(*lua.LUserData).Value.(hash.Hash), nil + case *set.Set: + return value.(*lua.LUserData).Value.(*set.Set), nil + case *sorted_set.SortedSet: + return value.(*lua.LUserData).Value.(*sorted_set.SortedSet), nil + } + default: + return nil, fmt.Errorf("unknown type %s", value.Type()) + } +} + +func nativeTypeToLuaType(L *lua.LState, value interface{}) lua.LValue { + switch value.(type) { + case string: + return lua.LString(value.(string)) + case float32: + return lua.LNumber(value.(float32)) + case float64: + return lua.LNumber(value.(float64)) + case int, int64: + return lua.LNumber(value.(int)) + case []string: + tbl := L.NewTable() + for i, element := range value.([]string) { + tbl.RawSetInt(i+1, lua.LString(element)) + } + return tbl + case hash.Hash: + ud := L.NewUserData() + ud.Value = value.(hash.Hash) + L.SetMetatable(ud, L.GetTypeMetatable("hash")) + return ud + case *set.Set: + ud := L.NewUserData() + ud.Value = value.(*set.Set) + L.SetMetatable(ud, L.GetTypeMetatable("set")) + return ud + case *sorted_set.SortedSet: + ud := L.NewUserData() + ud.Value = value.(*sorted_set.SortedSet) + L.SetMetatable(ud, L.GetTypeMetatable("zset")) + return ud + } + return nil +} diff --git a/sugardb/sugardb.go b/sugardb/sugardb.go new file mode 100644 index 0000000..41630ce --- /dev/null +++ b/sugardb/sugardb.go @@ -0,0 +1,693 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/aof" + "apigo.cc/go/sugardb/internal/clock" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" + "apigo.cc/go/sugardb/internal/eviction" + "apigo.cc/go/sugardb/internal/memberlist" + "apigo.cc/go/sugardb/internal/modules/acl" + "apigo.cc/go/sugardb/internal/modules/admin" + "apigo.cc/go/sugardb/internal/modules/connection" + "apigo.cc/go/sugardb/internal/modules/generic" + "apigo.cc/go/sugardb/internal/modules/hash" + "apigo.cc/go/sugardb/internal/modules/list" + "apigo.cc/go/sugardb/internal/modules/pubsub" + "apigo.cc/go/sugardb/internal/modules/set" + "apigo.cc/go/sugardb/internal/modules/sorted_set" + str "apigo.cc/go/sugardb/internal/modules/string" + "apigo.cc/go/sugardb/internal/raft" + "apigo.cc/go/sugardb/internal/snapshot" + lua "github.com/yuin/gopher-lua" + "io" + "log" + "net" + "os" + "slices" + "sync" + "sync/atomic" + "time" +) + +type SugarDB struct { + // clock is an implementation of a time interface that allows mocking of time functions during testing. + clock clock.Clock + + // config holds the SugarDB configuration variables. + config config.Config + + // The current index for the latest connection id. + // This number is incremented everytime there's a new connection and + // the new number is the new connection's ID. + connId atomic.Uint64 + + // connInfo holds the connection information for embedded and TCP clients. + // It keeps track of the protocol and database that each client is operating on. + connInfo struct { + mut *sync.RWMutex // RWMutex for the connInfo object. + tcpClients map[*net.Conn]internal.ConnectionInfo // Map that holds connection information for each TCP client. + embedded internal.ConnectionInfo // Information for the embedded connection. + } + + // Global read-write mutex for entire store. + storeLock *sync.RWMutex + + // Data store to hold the keys and their associated data, expiry time, etc. + // The int key on the outer map represents the database index. + // Each database has a map that has a string key and the key data (value and expiry time). + store map[int]map[string]internal.KeyData + + // memUsed tracks the memory usage of the data in the store. + memUsed int64 + + // Holds all the keys that are currently associated with an expiry. + keysWithExpiry struct { + // Mutex as only one process should be able to update this list at a time. + rwMutex sync.RWMutex + // A map holding a string slice of the volatile keys for each database. + keys map[int][]string + } + // LFU cache used when eviction policy is allkeys-lfu or volatile-lfu. + lfuCache struct { + // Mutex as only one goroutine can edit the LFU cache at a time. + mutex *sync.Mutex + // LFU cache for each database represented by a min heap. + cache map[int]*eviction.CacheLFU + } + // LRU cache used when eviction policy is allkeys-lru or volatile-lru. + lruCache struct { + // Mutex as only one goroutine can edit the LRU at a time. + mutex *sync.Mutex + // LRU cache represented by a max heap. + cache map[int]*eviction.CacheLRU + } + + commandsRWMut sync.RWMutex // Mutex used for modifying/reading the list of commands in the instance. + commands []internal.Command // Holds the list of all commands supported by SugarDB. + // Each commands that's added using a script (lua,js), will have a lock associated with the command. + // Only one goroutine will be able to trigger a script-associated command at a time. This is because the VM state + // for each of the commands is not thread safe. + // This map's shape is map[string]struct{vm: any, lock: sync.Mutex} with the string key being the command name. + scriptVMs sync.Map + + raft *raft.Raft // The raft replication layer for SugarDB. + memberList *memberlist.MemberList // The memberlist layer for SugarDB. + + context context.Context + + acl *acl.ACL + pubSub *pubsub.PubSub + + snapshotInProgress atomic.Bool // Atomic boolean that's true when actively taking a snapshot. + rewriteAOFInProgress atomic.Bool // Atomic boolean that's true when actively rewriting AOF file is in progress. + stateCopyInProgress atomic.Bool // Atomic boolean that's true when actively copying state for snapshotting or preamble generation. + stateMutationInProgress atomic.Bool // Atomic boolean that is set to true when state mutation is in progress. + latestSnapshotMilliseconds atomic.Int64 // Unix epoch in milliseconds. + snapshotEngine *snapshot.Engine // Snapshot engine for standalone mode. + aofEngine *aof.Engine // AOF engine for standalone mode. + + listener atomic.Value // Holds the TCP listener. + quit chan struct{} // Channel that signals the closing of all client connections. + stopTTL chan struct{} // Channel that signals the TTL sampling goroutine to stop execution. +} + +// NewSugarDB creates a new SugarDB instance. +// This functions accepts the WithContext, WithConfig and WithCommands options. +func NewSugarDB(options ...func(sugarDB *SugarDB)) (*SugarDB, error) { + sugarDB := &SugarDB{ + clock: clock.NewClock(), + context: context.Background(), + config: config.DefaultConfig(), + connInfo: struct { + mut *sync.RWMutex + tcpClients map[*net.Conn]internal.ConnectionInfo + embedded internal.ConnectionInfo + }{ + mut: &sync.RWMutex{}, + tcpClients: make(map[*net.Conn]internal.ConnectionInfo), + embedded: internal.ConnectionInfo{ + Id: 0, + Name: "embedded", + Protocol: 2, + Database: 0, + }, + }, + storeLock: &sync.RWMutex{}, + store: make(map[int]map[string]internal.KeyData), + memUsed: 0, + keysWithExpiry: struct { + rwMutex sync.RWMutex + keys map[int][]string + }{ + rwMutex: sync.RWMutex{}, + keys: make(map[int][]string), + }, + commandsRWMut: sync.RWMutex{}, + commands: func() []internal.Command { + var commands []internal.Command + commands = append(commands, acl.Commands()...) + commands = append(commands, admin.Commands()...) + commands = append(commands, connection.Commands()...) + commands = append(commands, generic.Commands()...) + commands = append(commands, hash.Commands()...) + commands = append(commands, list.Commands()...) + commands = append(commands, pubsub.Commands()...) + commands = append(commands, set.Commands()...) + commands = append(commands, sorted_set.Commands()...) + commands = append(commands, str.Commands()...) + return commands + }(), + quit: make(chan struct{}), + stopTTL: make(chan struct{}), + } + + for _, option := range options { + option(sugarDB) + } + + sugarDB.context = context.WithValue( + sugarDB.context, "ServerID", + internal.ContextServerID(sugarDB.config.ServerID), + ) + + // Load .so modules from config + for _, path := range sugarDB.config.Modules { + if err := sugarDB.LoadModule(path); err != nil { + log.Printf("%s %v\n", path, err) + continue + } + log.Printf("loaded plugin %s\n", path) + } + + // Set up ACL module + sugarDB.acl = acl.NewACL(sugarDB.config) + + // Set up Pub/Sub module + sugarDB.pubSub = pubsub.NewPubSub() + + if sugarDB.isInCluster() { + sugarDB.raft = raft.NewRaft(raft.Opts{ + Config: sugarDB.config, + GetCommand: sugarDB.getCommand, + SetValues: sugarDB.setValues, + SetExpiry: sugarDB.setExpiry, + StartSnapshot: sugarDB.startSnapshot, + FinishSnapshot: sugarDB.finishSnapshot, + SetLatestSnapshotTime: sugarDB.setLatestSnapshot, + GetHandlerFuncParams: sugarDB.getHandlerFuncParams, + DeleteKey: func(ctx context.Context, key string) error { + sugarDB.storeLock.Lock() + defer sugarDB.storeLock.Unlock() + return sugarDB.deleteKey(ctx, key) + }, + GetState: func() map[int]map[string]internal.KeyData { + state := make(map[int]map[string]internal.KeyData) + for database, store := range sugarDB.getState() { + for k, v := range store { + if data, ok := v.(internal.KeyData); ok { + state[database][k] = data + } + } + } + return state + }, + }) + sugarDB.memberList = memberlist.NewMemberList(memberlist.Opts{ + Config: sugarDB.config, + HasJoinedCluster: sugarDB.raft.HasJoinedCluster, + AddVoter: sugarDB.raft.AddVoter, + RemoveRaftServer: sugarDB.raft.RemoveServer, + IsRaftLeader: sugarDB.raft.IsRaftLeader, + ApplyMutate: sugarDB.raftApplyCommand, + ApplyDeleteKey: sugarDB.raftApplyDeleteKey, + }) + } else { + // Set up standalone snapshot engine + sugarDB.snapshotEngine = snapshot.NewSnapshotEngine( + snapshot.WithClock(sugarDB.clock), + snapshot.WithDirectory(sugarDB.config.DataDir), + snapshot.WithThreshold(sugarDB.config.SnapShotThreshold), + snapshot.WithInterval(sugarDB.config.SnapshotInterval), + snapshot.WithStartSnapshotFunc(sugarDB.startSnapshot), + snapshot.WithFinishSnapshotFunc(sugarDB.finishSnapshot), + snapshot.WithSetLatestSnapshotTimeFunc(sugarDB.setLatestSnapshot), + snapshot.WithGetLatestSnapshotTimeFunc(sugarDB.getLatestSnapshotTime), + snapshot.WithGetStateFunc(func() map[int]map[string]internal.KeyData { + state := make(map[int]map[string]internal.KeyData) + for database, data := range sugarDB.getState() { + state[database] = make(map[string]internal.KeyData) + for key, value := range data { + if keyData, ok := value.(internal.KeyData); ok { + state[database][key] = keyData + } + } + } + return state + }), + snapshot.WithSetKeyDataFunc(func(database int, key string, data internal.KeyData) { + ctx := context.WithValue(context.Background(), "Database", database) + if err := sugarDB.setValues(ctx, map[string]interface{}{key: data.Value}); err != nil { + log.Println(err) + } + sugarDB.setExpiry(ctx, key, data.ExpireAt, false) + }), + ) + + // Set up standalone AOF engine + aofEngine, err := aof.NewAOFEngine( + aof.WithClock(sugarDB.clock), + aof.WithDirectory(sugarDB.config.DataDir), + aof.WithStrategy(sugarDB.config.AOFSyncStrategy), + aof.WithStartRewriteFunc(sugarDB.startRewriteAOF), + aof.WithFinishRewriteFunc(sugarDB.finishRewriteAOF), + aof.WithGetStateFunc(func() map[int]map[string]internal.KeyData { + state := make(map[int]map[string]internal.KeyData) + for database, data := range sugarDB.getState() { + state[database] = make(map[string]internal.KeyData) + for key, value := range data { + if keyData, ok := value.(internal.KeyData); ok { + state[database][key] = keyData + } + } + } + return state + }), + aof.WithSetKeyDataFunc(func(database int, key string, value internal.KeyData) { + ctx := context.WithValue(context.Background(), "Database", database) + if err := sugarDB.setValues(ctx, map[string]interface{}{key: value.Value}); err != nil { + log.Println(err) + } + sugarDB.setExpiry(ctx, key, value.ExpireAt, false) + }), + aof.WithHandleCommandFunc(func(database int, command []byte) { + ctx := context.WithValue(context.Background(), "Protocol", 2) + ctx = context.WithValue(ctx, "Database", database) + _, err := sugarDB.handleCommand(ctx, command, nil, true, false) + if err != nil { + log.Println(err) + } + }), + ) + if err != nil { + return nil, err + } + sugarDB.aofEngine = aofEngine + } + + // If eviction policy is not noeviction, start a goroutine to evict keys at the configured interval. + if sugarDB.config.EvictionPolicy != constants.NoEviction { + go func() { + ticker := time.NewTicker(sugarDB.config.EvictionInterval) + defer func() { + ticker.Stop() + }() + for { + select { + case <-ticker.C: + // Run key eviction for each database that has volatile keys. + wg := sync.WaitGroup{} + for database, _ := range sugarDB.keysWithExpiry.keys { + wg.Add(1) + ctx := context.WithValue(context.Background(), "Database", database) + go func(ctx context.Context, wg *sync.WaitGroup) { + if err := sugarDB.evictKeysWithExpiredTTL(ctx); err != nil { + log.Printf("evict with ttl: %v\n", err) + } + wg.Done() + }(ctx, &wg) + } + wg.Wait() + case <-sugarDB.stopTTL: + break + } + } + }() + } + + if sugarDB.config.TLS && len(sugarDB.config.CertKeyPairs) <= 0 { + return nil, errors.New("must provide certificate and key file paths for TLS mode") + } + + if sugarDB.isInCluster() { + // Initialise raft and memberlist + sugarDB.raft.RaftInit(sugarDB.context) + sugarDB.memberList.MemberListInit(sugarDB.context) + // Initialise caches + sugarDB.initialiseCaches() + } + + if !sugarDB.isInCluster() { + sugarDB.initialiseCaches() + // Restore from AOF by default if it's enabled + if sugarDB.config.RestoreAOF { + err := sugarDB.aofEngine.Restore() + if err != nil { + log.Println(err) + } + } + + // Restore from snapshot if snapshot restore is enabled and AOF restore is disabled + if sugarDB.config.RestoreSnapshot && !sugarDB.config.RestoreAOF { + err := sugarDB.snapshotEngine.Restore() + if err != nil { + log.Println(err) + } + } + } + + return sugarDB, nil +} + +func (server *SugarDB) startTCP() { + conf := server.config + + listenConfig := net.ListenConfig{ + KeepAlive: 200 * time.Millisecond, + } + + listener, err := listenConfig.Listen( + server.context, + "tcp", + fmt.Sprintf("%s:%d", conf.BindAddr, conf.Port), + ) + if err != nil { + log.Printf("listener error: %v", err) + return + } + + if !conf.TLS { + // TCP + log.Printf("Starting TCP server at Address %s, Port %d...\n", conf.BindAddr, conf.Port) + } + + if conf.TLS || conf.MTLS { + // TLS + if conf.MTLS { + log.Printf("Starting mTLS server at Address %s, Port %d...\n", conf.BindAddr, conf.Port) + } else { + log.Printf("Starting TLS server at Address %s, Port %d...\n", conf.BindAddr, conf.Port) + } + + var certificates []tls.Certificate + for _, certKeyPair := range conf.CertKeyPairs { + c, err := tls.LoadX509KeyPair(certKeyPair[0], certKeyPair[1]) + if err != nil { + log.Printf("load cert key pair: %v\n", err) + return + } + certificates = append(certificates, c) + } + + clientAuth := tls.NoClientCert + clientCerts := x509.NewCertPool() + + if conf.MTLS { + clientAuth = tls.RequireAndVerifyClientCert + for _, c := range conf.ClientCAs { + ca, err := os.Open(c) + if err != nil { + log.Printf("client cert open: %v\n", err) + return + } + certBytes, err := io.ReadAll(ca) + if err != nil { + log.Printf("client cert read: %v\n", err) + } + if ok := clientCerts.AppendCertsFromPEM(certBytes); !ok { + log.Printf("client cert append: %v\n", err) + } + } + } + + listener = tls.NewListener(listener, &tls.Config{ + Certificates: certificates, + ClientAuth: clientAuth, + ClientCAs: clientCerts, + }) + } + + server.listener.Store(listener) + + // Listen to connection. + for { + select { + case <-server.quit: + return + default: + conn, err := listener.Accept() + if err != nil { + log.Printf("listener error: %v\n", err) + return + } + // Read loop for connection + go server.handleConnection(conn) + } + } +} + +func (server *SugarDB) handleConnection(conn net.Conn) { + // If ACL module is loaded, register the connection with the ACL + if server.acl != nil { + server.acl.RegisterConnection(&conn) + } + + w, r := io.Writer(conn), io.Reader(conn) + + // Generate connection ID + cid := server.connId.Add(1) + ctx := context.WithValue(server.context, internal.ContextConnID("ConnectionID"), + fmt.Sprintf("%s-%d", server.context.Value(internal.ContextServerID("ServerID")), cid)) + + // Set the default connection information + server.connInfo.mut.Lock() + server.connInfo.tcpClients[&conn] = internal.ConnectionInfo{ + Id: cid, + Name: "", + Protocol: 2, + Database: 0, + } + server.connInfo.mut.Unlock() + + defer func() { + log.Printf("closing connection %d...", cid) + if err := conn.Close(); err != nil { + log.Println(err) + } + }() + + for { + message, err := internal.ReadMessage(r) + + if err != nil && errors.Is(err, io.EOF) { + // Connection closed + log.Println(err) + break + } + + if err != nil { + log.Println(err) + break + } + + res, err := server.handleCommand(ctx, message, &conn, false, false) + if err != nil && errors.Is(err, io.EOF) { + break + } + if err != nil { + log.Println(err) + if _, err = w.Write([]byte(fmt.Sprintf("-Error %s\r\n", err.Error()))); err != nil { + log.Println(err) + } + continue + } + + chunkSize := 1024 + + // If the length of the response is 0, return nothing to the client. + if len(res) == 0 { + continue + } + + if len(res) <= chunkSize { + _, _ = w.Write(res) + continue + } + + // If the response is large, send it in chunks. + startIndex := 0 + for { + // If the current start index is less than chunkSize from length, return the remaining bytes. + if len(res)-1-startIndex < chunkSize { + _, err = w.Write(res[startIndex:]) + if err != nil { + log.Println(err) + } + break + } + n, _ := w.Write(res[startIndex : startIndex+chunkSize]) + if n < chunkSize { + break + } + startIndex += chunkSize + } + } +} + +// Start starts the SugarDB instance's TCP listener. +// This allows the instance to accept connections handle client commands over TCP. +// +// You can still use command functions like Set if you're embedding SugarDB in your application. +// However, if you'd like to also accept TCP request on the same instance, you must call this function. +func (server *SugarDB) Start() { + server.startTCP() +} + +// takeSnapshot triggers a snapshot when called. +func (server *SugarDB) takeSnapshot() error { + if server.snapshotInProgress.Load() { + return errors.New("snapshot already in progress") + } + + go func() { + if server.isInCluster() { + // Handle snapshot in cluster mode + if err := server.raft.TakeSnapshot(); err != nil { + log.Println(err) + } + return + } + // Handle snapshot in standalone mode + if err := server.snapshotEngine.TakeSnapshot(); err != nil { + log.Println(err) + } + }() + + return nil +} + +func (server *SugarDB) startSnapshot() { + server.snapshotInProgress.Store(true) +} + +func (server *SugarDB) finishSnapshot() { + server.snapshotInProgress.Store(false) +} + +func (server *SugarDB) setLatestSnapshot(msec int64) { + server.latestSnapshotMilliseconds.Store(msec) +} + +// getLatestSnapshotTime returns the latest snapshot time in unix epoch milliseconds. +func (server *SugarDB) getLatestSnapshotTime() int64 { + return server.latestSnapshotMilliseconds.Load() +} + +func (server *SugarDB) startRewriteAOF() { + server.rewriteAOFInProgress.Store(true) +} + +func (server *SugarDB) finishRewriteAOF() { + server.rewriteAOFInProgress.Store(false) +} + +// rewriteAOF triggers an AOF compaction when running in standalone mode. +func (server *SugarDB) rewriteAOF() error { + if server.rewriteAOFInProgress.Load() { + return errors.New("aof rewrite in progress") + } + if err := server.aofEngine.RewriteLog(); err != nil { + return err + } + return nil +} + +// ShutDown gracefully shuts down the SugarDB instance. +// This function shuts down the memberlist and raft layers. +func (server *SugarDB) ShutDown() { + if server.listener.Load() != nil { + go func() { server.quit <- struct{}{} }() + go func() { server.stopTTL <- struct{}{} }() + + log.Println("closing tcp listener...") + if err := server.listener.Load().(net.Listener).Close(); err != nil { + log.Printf("listener close: %v\n", err) + } + } + + // Shutdown all script VMs + log.Println("shutting down script vms...") + server.commandsRWMut.Lock() + for _, command := range server.commands { + if slices.Contains([]string{"LUA_SCRIPT", "JS_SCRIPT"}, command.Type) { + v, ok := server.scriptVMs.Load(command.Command) + if !ok { + continue + } + machine := v.(struct { + vm any + lock *sync.Mutex + }) + machine.lock.Lock() + switch command.Type { + case "LUA_SCRIPT": + machine.vm.(*lua.LState).Close() + } + machine.lock.Unlock() + } + } + server.commandsRWMut.Unlock() + + if !server.isInCluster() { + // Server is not in cluster, run standalone-only shutdown processes. + server.aofEngine.Close() + } else { + // Server is in cluster, run cluster-only shutdown processes. + server.raft.RaftShutdown() + server.memberList.MemberListShutdown() + } +} + +func (server *SugarDB) initialiseCaches() { + // Set up LFU cache. + server.lfuCache = struct { + mutex *sync.Mutex + cache map[int]*eviction.CacheLFU + }{ + mutex: &sync.Mutex{}, + cache: make(map[int]*eviction.CacheLFU), + } + // set up LRU cache. + server.lruCache = struct { + mutex *sync.Mutex + cache map[int]*eviction.CacheLRU + }{ + mutex: &sync.Mutex{}, + cache: make(map[int]*eviction.CacheLRU), + } + // Initialise caches for each preloaded database. + for database, _ := range server.store { + server.lfuCache.cache[database] = eviction.NewCacheLFU() + server.lruCache.cache[database] = eviction.NewCacheLRU() + } +} diff --git a/sugardb/sugardb_test.go b/sugardb/sugardb_test.go new file mode 100644 index 0000000..ee29ec1 --- /dev/null +++ b/sugardb/sugardb_test.go @@ -0,0 +1,1182 @@ +// Copyright 2024 Kelvin Clement Mwinuka +// +// 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. + +package sugardb + +import ( + "bufio" + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/clock" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" + "github.com/go-test/deep" + "github.com/tidwall/resp" + "io" + "math" + "net" + "os" + "path" + "strings" + "sync" + "testing" + "time" +) + +type ClientServerPair struct { + dataDir string + serverId string + bindAddr string + port int + discoveryPort int + bootstrapCluster bool + forwardCommand bool + joinAddr string + raw net.Conn + client *resp.Conn + server *SugarDB +} + +var bindLock sync.Mutex +var bindNum byte = 10 + +func getBindAddrNet(network byte) net.IP { + bindLock.Lock() + defer bindLock.Unlock() + + result := net.IPv4(127, 0, network, bindNum) + bindNum++ + if bindNum > 255 { + bindNum = 10 + } + + return result +} + +func getBindAddr() net.IP { + return getBindAddrNet(0) +} + +func setupServer( + dataDir string, + serverId string, + bootstrapCluster bool, + forwardCommand bool, + bindAddr, + joinAddr string, + port, + discoveryPort int, +) (*SugarDB, error) { + conf := DefaultConfig() + conf.DataDir = dataDir + conf.ForwardCommand = forwardCommand + conf.BindAddr = bindAddr + conf.JoinAddr = joinAddr + conf.Port = uint16(port) + conf.ServerID = serverId + conf.DiscoveryPort = uint16(discoveryPort) + conf.BootstrapCluster = bootstrapCluster + conf.EvictionPolicy = constants.NoEviction + + return NewSugarDB( + WithContext(context.Background()), + WithConfig(conf), + ) +} + +func setupNode(node *ClientServerPair, isLeader bool, errChan *chan error) { + server, err := setupServer( + node.dataDir, + node.serverId, + node.bootstrapCluster, + node.forwardCommand, + node.bindAddr, + node.joinAddr, + node.port, + node.discoveryPort, + ) + if err != nil { + *errChan <- fmt.Errorf("could not start server; %v", err) + } + + // Start the server. + go func() { + server.Start() + }() + + if isLeader { + // If node is a leader, wait until it's established itself as a leader of the raft cluster. + for { + if server.raft.IsRaftLeader() { + break + } + } + } else { + // If the node is a follower, wait until it's joined the raft cluster before moving forward. + for { + if server.raft.HasJoinedCluster() { + break + } + } + } + + // Setup client connection. + conn, err := internal.GetConnection(node.bindAddr, node.port) + if err != nil { + *errChan <- fmt.Errorf("could not open tcp connection: %v", err) + } + client := resp.NewConn(conn) + + node.raw = conn + node.client = client + node.server = server +} + +func makeCluster(size int) ([]ClientServerPair, error) { + pairs := make([]ClientServerPair, size) + + // Set up node metadata. + for i := 0; i < len(pairs); i++ { + dataDir := "" + serverId := fmt.Sprintf("SERVER-%d", i) + bindAddr := getBindAddr().String() + bootstrapCluster := i == 0 + forwardCommand := i < len(pairs)-1 // The last node will not forward commands to the cluster leader. + joinAddr := "" + if !bootstrapCluster { + joinAddr = fmt.Sprintf("%s/%s:%d", pairs[0].serverId, pairs[0].bindAddr, pairs[0].discoveryPort) + } + port, err := internal.GetFreePort() + if err != nil { + return nil, fmt.Errorf("could not get free port: %v", err) + } + discoveryPort, err := internal.GetFreePort() + if err != nil { + return nil, fmt.Errorf("could not get free memberlist port: %v", err) + } + + pairs[i] = ClientServerPair{ + dataDir: dataDir, + serverId: serverId, + bindAddr: bindAddr, + port: port, + discoveryPort: discoveryPort, + bootstrapCluster: bootstrapCluster, + forwardCommand: forwardCommand, + joinAddr: joinAddr, + } + } + + errChan := make(chan error) + doneChan := make(chan struct{}) + + // Set up nodes. + wg := sync.WaitGroup{} + for i := 0; i < len(pairs); i++ { + if i == 0 { + setupNode(&pairs[i], pairs[i].bootstrapCluster, &errChan) + continue + } + wg.Add(1) + go func(idx int) { + setupNode(&pairs[idx], pairs[idx].bootstrapCluster, &errChan) + wg.Done() + }(i) + } + go func() { + wg.Wait() + doneChan <- struct{}{} + }() + + select { + case err := <-errChan: + return nil, err + case <-doneChan: + } + + return pairs, nil +} + +func Test_Cluster(t *testing.T) { + nodes, err := makeCluster(5) + if err != nil { + t.Error(err) + return + } + + t.Cleanup(func() { + for i := len(nodes) - 1; i > -1; i-- { + _ = nodes[i].raw.Close() + nodes[i].server.ShutDown() + } + }) + + // Prepare the write data for the cluster. + tests := map[string][]struct { + key string + value string + }{ + "replication": { + {key: "key1", value: "value1"}, + {key: "key2", value: "value2"}, + {key: "key3", value: "value3"}, + }, + "deletion": { + {key: "key4", value: "value4"}, + {key: "key5", value: "value4"}, + {key: "key6", value: "value5"}, + }, + "raft-apply-delete": { + {key: "key7", value: "value7"}, + {key: "key8", value: "value8"}, + {key: "key9", value: "value9"}, + }, + "forward": { + {key: "key10", value: "value10"}, + {key: "key11", value: "value11"}, + {key: "key12", value: "value12"}, + }, + } + + t.Run("Test_Replication", func(t *testing.T) { + tests := tests["replication"] + // Write all the data to the cluster leader. + for i, test := range tests { + node := nodes[0] + if err := node.client.WriteArray([]resp.Value{ + resp.StringValue("SET"), resp.StringValue(test.key), resp.StringValue(test.value), + }); err != nil { + t.Errorf("could not write data to leader node (test %d): %v", i, err) + } + // Read response and make sure we received "ok" response. + rd, _, err := node.client.ReadValue() + if err != nil { + t.Errorf("could not read response from leader node (test %d): %v", i, err) + } + if !strings.EqualFold(rd.String(), "ok") { + t.Errorf("expected response for test %d to be \"OK\", got %s", i, rd.String()) + } + } + + // Yield + ticker := time.NewTicker(200 * time.Millisecond) + defer func() { + ticker.Stop() + }() + <-ticker.C + + // Check if the data has been replicated on a quorum (majority of the cluster). + quorum := int(math.Ceil(float64(len(nodes)/2)) + 1) + for i, test := range tests { + count := 0 + for j := 0; j < len(nodes); j++ { + node := nodes[j] + if err := node.client.WriteArray([]resp.Value{ + resp.StringValue("GET"), + resp.StringValue(test.key), + }); err != nil { + t.Errorf("could not write data to follower node %d (test %d): %v", j, i, err) + } + rd, _, err := node.client.ReadValue() + if err != nil { + t.Errorf("could not read data from follower node %d (test %d): %v", j, i, err) + } + if rd.String() == test.value { + count += 1 // If the expected value is found, increment the count. + } + } + // Fail if count is less than quorum. + if count < quorum { + t.Errorf("could not find value %s at key %s in cluster quorum", test.value, test.key) + } + } + }) + + t.Run("Test_DeleteKey", func(t *testing.T) { + tests := tests["deletion"] + // Write all the data to the cluster leader. + for i, test := range tests { + node := nodes[0] + _, ok, err := node.server.Set(test.key, test.value, SETOptions{}) + if err != nil { + t.Errorf("could not write command to leader node (test %d): %v", i, err) + } + if !ok { + t.Errorf("expected set for test %d ok = true, got ok = false", i) + } + } + + // Yield + ticker := time.NewTicker(200 * time.Millisecond) + defer func() { + ticker.Stop() + }() + <-ticker.C + + // Check if the data has been replicated on a quorum (majority of the cluster). + quorum := int(math.Ceil(float64(len(nodes)/2)) + 1) + for i, test := range tests { + count := 0 + for j := 0; j < len(nodes); j++ { + node := nodes[j] + if err := node.client.WriteArray([]resp.Value{ + resp.StringValue("GET"), + resp.StringValue(test.key), + }); err != nil { + t.Errorf("could not write command to follower node %d (test %d): %v", j, i, err) + } + rd, _, err := node.client.ReadValue() + if err != nil { + t.Errorf("could not read data from follower node %d (test %d): %v", j, i, err) + } + if rd.String() == test.value { + count += 1 // If the expected value is found, increment the count. + } + } + // Fail if count is less than quorum. + if count < quorum { + t.Errorf("could not find value %s at key %s in cluster quorum", test.value, test.key) + return + } + } + + // Delete the key on the leader node + // 1. Prepare delete command. + command := []resp.Value{resp.StringValue("DEL")} + for _, test := range tests { + command = append(command, resp.StringValue(test.key)) + } + // 2. Send delete command. + if err := nodes[0].client.WriteArray(command); err != nil { + t.Error(err) + return + } + res, _, err := nodes[0].client.ReadValue() + if err != nil { + t.Error(err) + return + } + + // 3. Check the delete count is equal to length of tests. + if res.Integer() != len(tests) { + t.Errorf("expected delete response to be %d, got %d", len(tests), res.Integer()) + } + + // Yield + ticker.Reset(200 * time.Millisecond) + <-ticker.C + + // 4. Check if the data is absent in quorum (majority of the cluster). + for i, test := range tests { + count := 0 + for j := 0; j < len(nodes); j++ { + node := nodes[j] + if err := node.client.WriteArray([]resp.Value{ + resp.StringValue("GET"), + resp.StringValue(test.key), + }); err != nil { + t.Errorf("could not write command to follower node %d (test %d): %v", j, i, err) + } + rd, _, err := node.client.ReadValue() + if err != nil { + t.Errorf("could not read data from follower node %d (test %d): %v", j, i, err) + } + if rd.IsNull() { + count += 1 // If the expected value is found, increment the count. + } + } + // 5. Fail if count is less than quorum. + if count < quorum { + t.Errorf("could not find value %s at key %s in cluster quorum", test.value, test.key) + } + } + }) + + t.Run("Test_raftApplyDeleteKey", func(t *testing.T) { + tests := tests["raft-apply-delete"] + // Write all the data to the cluster leader. + for i, test := range tests { + node := nodes[0] + _, ok, err := node.server.Set(test.key, test.value, SETOptions{}) + if err != nil { + t.Errorf("could not write command to leader node (test %d): %v", i, err) + } + if !ok { + t.Errorf("expected set for test %d ok = true, got ok = false", i) + } + } + + // Yield + ticker := time.NewTicker(200 * time.Millisecond) + defer func() { + ticker.Stop() + }() + <-ticker.C + + // Check if the data has been replicated on a quorum (majority of the cluster). + quorum := int(math.Ceil(float64(len(nodes)/2)) + 1) + for i, test := range tests { + count := 0 + for j := 0; j < len(nodes); j++ { + node := nodes[j] + if err := node.client.WriteArray([]resp.Value{ + resp.StringValue("GET"), + resp.StringValue(test.key), + }); err != nil { + t.Errorf("could not write command to follower node %d (test %d): %v", j, i, err) + } + rd, _, err := node.client.ReadValue() + if err != nil { + t.Errorf("could not read data from follower node %d (test %d): %v", j, i, err) + } + if rd.String() == test.value { + count += 1 // If the expected value is found, increment the count. + } + } + // Fail if count is less than quorum. + if count < quorum { + t.Errorf("could not find value %s at key %s in cluster quorum", test.value, test.key) + return + } + } + + // Delete the keys using raftApplyDelete method. + for _, test := range tests { + if err := nodes[0].server.raftApplyDeleteKey(nodes[0].server.context, test.key); err != nil { + t.Error(err) + } + } + + // Yield to give key deletion time to take effect across cluster. + ticker.Reset(200 * time.Millisecond) + <-ticker.C + + // Check if the data is absent in quorum (majority of the cluster). + for i, test := range tests { + count := 0 + for j := 0; j < len(nodes); j++ { + node := nodes[j] + if err := node.client.WriteArray([]resp.Value{ + resp.StringValue("GET"), + resp.StringValue(test.key), + }); err != nil { + t.Errorf("could not write command to follower node %d (test %d): %v", j, i, err) + } + rd, _, err := node.client.ReadValue() + if err != nil { + t.Errorf("could not read data from follower node %d (test %d): %v", j, i, err) + } + if rd.IsNull() { + count += 1 // If the expected value is found, increment the count. + } + } + // Fail if count is less than quorum. + if count < quorum { + t.Errorf("found value %s at key %s in cluster quorum", test.value, test.key) + } + } + }) + + t.Run("Test_ForwardCommand", func(t *testing.T) { + tests := tests["forward"] + // Write all the data a random cluster follower. + for i, test := range tests { + // Send write command to follower node. + node := nodes[1] + if err := node.client.WriteArray([]resp.Value{ + resp.StringValue("SET"), + resp.StringValue(test.key), + resp.StringValue(test.value), + }); err != nil { + t.Errorf("could not write data to follower node (test %d): %v", i, err) + } + // Read response and make sure we received "ok" response. + rd, _, err := node.client.ReadValue() + if err != nil { + t.Errorf("could not read response from follower node (test %d): %v", i, err) + } + if !strings.EqualFold(rd.String(), "ok") { + t.Errorf("expected response for test %d to be \"OK\", got %s", i, rd.String()) + } + } + + ticker := time.NewTicker(1 * time.Second) + defer func() { + ticker.Stop() + }() + <-ticker.C + + // Check if the data has been replicated on a quorum (majority of the cluster). + var forwardError error + doneChan := make(chan struct{}) + + go func() { + quorum := int(math.Ceil(float64(len(nodes)/2)) + 1) + for i := 0; i < len(tests); i++ { + test := tests[i] + count := 0 + for j := 0; j < len(nodes); j++ { + node := nodes[j] + if err := node.client.WriteArray([]resp.Value{ + resp.StringValue("GET"), + resp.StringValue(test.key), + }); err != nil { + forwardError = fmt.Errorf("could not write data to follower node %d (test %d): %v", j, i, err) + i = 0 + continue + } + rd, _, err := node.client.ReadValue() + if err != nil { + forwardError = fmt.Errorf("could not read data from follower node %d (test %d): %v", j, i, err) + i = 0 + continue + } + if rd.String() == test.value { + count += 1 // If the expected value is found, increment the count. + } + } + // Fail if count is less than quorum. + if count < quorum { + forwardError = fmt.Errorf("could not find value %s at key %s in cluster quorum", test.value, test.key) + i = 0 + continue + } + } + doneChan <- struct{}{} + }() + + ticker.Reset(5 * time.Second) + + select { + case <-ticker.C: + if forwardError != nil { + t.Errorf("timeout error: %v\n", forwardError) + } + return + case <-doneChan: + } + }) + + t.Run("Test_NotLeaderError", func(t *testing.T) { + node := nodes[len(nodes)-1] + err := node.client.WriteArray([]resp.Value{ + resp.StringValue("SET"), + resp.StringValue("key"), + resp.StringValue("value"), + }) + if err != nil { + t.Error(err) + return + } + res, _, err := node.client.ReadValue() + if err != nil { + t.Error(err) + return + } + expected := "not cluster leader, cannot carry out command" + if !strings.Contains(res.Error().Error(), expected) { + t.Errorf("expected response to contain \"%s\", got \"%s\"", expected, res.Error().Error()) + } + }) + + t.Run("Test_SnapshotRestore", func(t *testing.T) { + // TODO: Test snapshot creation and restoration on the cluster. + }) + + t.Run("Test_EvictExpiredTTL", func(t *testing.T) { + // TODO: Implement test for evicting expired keys on the cluster. + }) + + t.Run("Test_GetServerInfo", func(t *testing.T) { + nodeInfo := []internal.ServerInfo{ + { + Server: "sugardb", + Version: constants.Version, + Id: nodes[0].serverId, + Mode: "cluster", + Role: "master", + Modules: nodes[0].server.ListModules(), + MemoryUsed: nodes[0].server.memUsed, + MaxMemory: nodes[0].server.config.MaxMemory, + }, + { + Server: "sugardb", + Version: constants.Version, + Id: nodes[1].serverId, + Mode: "cluster", + Role: "replica", + Modules: nodes[1].server.ListModules(), + MemoryUsed: nodes[1].server.memUsed, + MaxMemory: nodes[1].server.config.MaxMemory, + }, + { + Server: "sugardb", + Version: constants.Version, + Id: nodes[2].serverId, + Mode: "cluster", + Role: "replica", + Modules: nodes[2].server.ListModules(), + MemoryUsed: nodes[2].server.memUsed, + MaxMemory: nodes[2].server.config.MaxMemory, + }, + { + Server: "sugardb", + Version: constants.Version, + Id: nodes[3].serverId, + Mode: "cluster", + Role: "replica", + Modules: nodes[3].server.ListModules(), + MemoryUsed: nodes[3].server.memUsed, + MaxMemory: nodes[3].server.config.MaxMemory, + }, + { + Server: "sugardb", + Version: constants.Version, + Id: nodes[4].serverId, + Mode: "cluster", + Role: "replica", + Modules: nodes[4].server.ListModules(), + MemoryUsed: nodes[4].server.memUsed, + MaxMemory: nodes[4].server.config.MaxMemory, + }, + } + for i := 0; i < len(nodes); i++ { + if diff := deep.Equal(nodes[i].server.GetServerInfo(), nodeInfo[i]); diff != nil { + t.Errorf("GetServerInfo() - node %d: %+v expected %v got %v", i, err, nodes[i].server.GetServerInfo(), nodeInfo[i]) + return + } + } + }) +} + +func Test_Standalone(t *testing.T) { + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + mockServer, err := NewSugarDB( + WithConfig(config.Config{ + BindAddr: "localhost", + Port: uint16(port), + DataDir: "", + EvictionPolicy: constants.NoEviction, + ServerID: "Server_1", + }), + ) + if err != nil { + t.Error(err) + return + } + + go func() { + mockServer.Start() + }() + + t.Cleanup(func() { + mockServer.ShutDown() + }) + + t.Run("Test_EmptyCommand", func(t *testing.T) { + conn, err := internal.GetConnection("localhost", port) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + }() + client := resp.NewConn(conn) + + if err := client.WriteArray([]resp.Value{}); err != nil { + t.Error(err) + return + } + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + expected := "empty command" + if !strings.Contains(res.Error().Error(), expected) { + t.Errorf("expcted response to contain \"%s\", got \"%s\"", expected, res.Error().Error()) + } + }) + + t.Run("Test_TLS", func(t *testing.T) { + t.Parallel() + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + conf := DefaultConfig() + conf.DataDir = "" + conf.BindAddr = "localhost" + conf.Port = uint16(port) + conf.TLS = true + conf.CertKeyPairs = [][]string{ + { + path.Join("..", "openssl", "server", "server1.crt"), + path.Join("..", "openssl", "server", "server1.key"), + }, + { + path.Join("..", "openssl", "server", "server2.crt"), + path.Join("..", "openssl", "server", "server2.key"), + }, + } + + server, err := NewSugarDB(WithConfig(conf)) + if err != nil { + t.Error(err) + return + } + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + wg.Done() + server.Start() + }() + wg.Wait() + + // Dial with ServerCAs + serverCAs := x509.NewCertPool() + f, err := os.Open(path.Join("..", "openssl", "server", "rootCA.crt")) + if err != nil { + t.Error(err) + } + cert, err := io.ReadAll(bufio.NewReader(f)) + if err != nil { + t.Error(err) + } + ok := serverCAs.AppendCertsFromPEM(cert) + if !ok { + t.Error("could not load server CA") + } + + conn, err := internal.GetTLSConnection("localhost", port, &tls.Config{ + RootCAs: serverCAs, + }) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + server.ShutDown() + }() + client := resp.NewConn(conn) + + // Test that we can set and get a value from the server. + key := "key1" + value := "value1" + err = client.WriteArray([]resp.Value{ + resp.StringValue("SET"), resp.StringValue(key), resp.StringValue(value), + }) + if err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected response OK, got \"%s\"", res.String()) + } + + err = client.WriteArray([]resp.Value{resp.StringValue("GET"), resp.StringValue(key)}) + if err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if res.String() != value { + t.Errorf("expected response at key \"%s\" to be \"%s\", got \"%s\"", key, value, res.String()) + } + }) + + t.Run("Test_MTLS", func(t *testing.T) { + t.Parallel() + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + conf := DefaultConfig() + conf.DataDir = "" + conf.BindAddr = "localhost" + conf.Port = uint16(port) + conf.TLS = true + conf.MTLS = true + conf.ClientCAs = []string{ + path.Join("..", "openssl", "client", "rootCA.crt"), + } + conf.CertKeyPairs = [][]string{ + { + path.Join("..", "openssl", "server", "server1.crt"), + path.Join("..", "openssl", "server", "server1.key"), + }, + { + path.Join("..", "openssl", "server", "server2.crt"), + path.Join("..", "openssl", "server", "server2.key"), + }, + } + + server, err := NewSugarDB(WithConfig(conf)) + if err != nil { + t.Error(err) + return + } + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + wg.Done() + server.Start() + }() + wg.Wait() + + // Dial with ServerCAs and client certificates + clientCertKeyPairs := [][]string{ + { + path.Join("..", "openssl", "client", "client1.crt"), + path.Join("..", "openssl", "client", "client1.key"), + }, + { + path.Join("..", "openssl", "client", "client2.crt"), + path.Join("..", "openssl", "client", "client2.key"), + }, + } + var certificates []tls.Certificate + for _, pair := range clientCertKeyPairs { + c, err := tls.LoadX509KeyPair(pair[0], pair[1]) + if err != nil { + t.Error(err) + } + certificates = append(certificates, c) + } + + serverCAs := x509.NewCertPool() + f, err := os.Open(path.Join("..", "openssl", "server", "rootCA.crt")) + if err != nil { + t.Error(err) + } + cert, err := io.ReadAll(bufio.NewReader(f)) + if err != nil { + t.Error(err) + } + ok := serverCAs.AppendCertsFromPEM(cert) + if !ok { + t.Error("could not load server CA") + } + + conn, err := internal.GetTLSConnection("localhost", port, &tls.Config{ + RootCAs: serverCAs, + Certificates: certificates, + }) + if err != nil { + t.Error(err) + return + } + defer func() { + _ = conn.Close() + server.ShutDown() + }() + client := resp.NewConn(conn) + + // Test that we can set and get a value from the server. + key := "key1" + value := "value1" + err = client.WriteArray([]resp.Value{ + resp.StringValue("SET"), resp.StringValue(key), resp.StringValue(value), + }) + if err != nil { + t.Error(err) + } + + res, _, err := client.ReadValue() + if err != nil { + t.Error(err) + } + + if !strings.EqualFold(res.String(), "ok") { + t.Errorf("expected response OK, got \"%s\"", res.String()) + } + + err = client.WriteArray([]resp.Value{resp.StringValue("GET"), resp.StringValue(key)}) + if err != nil { + t.Error(err) + } + + res, _, err = client.ReadValue() + if err != nil { + t.Error(err) + } + + if res.String() != value { + t.Errorf("expected response at key \"%s\" to be \"%s\", got \"%s\"", key, value, res.String()) + } + }) + + t.Run("Test_SnapshotRestore", func(t *testing.T) { + t.Parallel() + + dataDir := path.Join(".", "testdata", "test_snapshot") + t.Cleanup(func() { + _ = os.RemoveAll(dataDir) + }) + + tests := []struct { + name string + dataDir string + values map[int]map[string]string + snapshotFunc func(mockServer *SugarDB) error + lastSaveFunc func(mockServer *SugarDB) (int, error) + wantLastSave int + }{ + { + name: "1. Snapshot in embedded instance", + dataDir: path.Join(dataDir, "embedded_instance"), + values: map[int]map[string]string{ + 0: {"key5": "value-05", "key6": "value-06", "key7": "value-07", "key8": "value-08"}, + 1: {"key5": "value-15", "key6": "value-16", "key7": "value-17", "key8": "value-18"}, + }, + snapshotFunc: func(mockServer *SugarDB) error { + if _, err := mockServer.Save(); err != nil { + return err + } + return nil + }, + lastSaveFunc: func(mockServer *SugarDB) (int, error) { + return mockServer.LastSave() + }, + wantLastSave: int(clock.NewClock().Now().UnixMilli()), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + port, err := internal.GetFreePort() + if err != nil { + t.Error(err) + return + } + + conf := DefaultConfig() + conf.DataDir = test.dataDir + conf.BindAddr = "localhost" + conf.Port = uint16(port) + conf.RestoreSnapshot = true + + mockServer, err := NewSugarDB(WithConfig(conf)) + if err != nil { + t.Error(err) + return + } + defer func() { + // Shutdown + mockServer.ShutDown() + }() + + // Trigger some write commands + for database, data := range test.values { + _ = mockServer.SelectDB(database) + for key, value := range data { + if _, _, err = mockServer.Set(key, value, SETOptions{}); err != nil { + t.Error(err) + return + } + } + } + + // Function to trigger snapshot save + if err = test.snapshotFunc(mockServer); err != nil { + t.Error(err) + } + + // Yield to allow snapshot to complete sync. + ticker := time.NewTicker(20 * time.Millisecond) + <-ticker.C + ticker.Stop() + + // Restart server with the same config. This should restore the snapshot + mockServer, err = NewSugarDB(WithConfig(conf)) + if err != nil { + t.Error(err) + return + } + + // Check that all the key/value pairs have been restored into the store. + for database, data := range test.values { + _ = mockServer.SelectDB(database) + for key, value := range data { + res, err := mockServer.Get(key) + if err != nil { + t.Error(err) + return + } + if res != value { + t.Errorf("expected value at key \"%s\" to be \"%s\", got \"%s\"", key, value, res) + return + } + } + } + + // Check that the lastsave is the time the last snapshot was taken. + lastSave, err := test.lastSaveFunc(mockServer) + if err != nil { + t.Error(err) + return + } + + if lastSave != test.wantLastSave { + t.Errorf("expected lastsave to be %d, got %d", test.wantLastSave, lastSave) + } + }) + } + }) + + t.Run("Test_AOFRestore", func(t *testing.T) { + t.Parallel() + + ticker := time.NewTicker(50 * time.Millisecond) + + dataDir := path.Join(".", "testdata", "test_aof") + t.Cleanup(func() { + _ = os.RemoveAll(dataDir) + ticker.Stop() + }) + + // Prepare data for testing. + data := map[string]map[string]string{ + "before-rewrite": { + "key1": "value1", + "key2": "value2", + "key3": "value3", + "key4": "value4", + }, + "after-rewrite": { + "key3": "value3-updated", + "key4": "value4-updated", + "key5": "value5", + "key6": "value6", + }, + "expected-values": { + "key1": "value1", + "key2": "value2", + "key3": "value3-updated", + "key4": "value4-updated", + "key5": "value5", + "key6": "value6", + }, + } + + conf := DefaultConfig() + conf.RestoreAOF = true + conf.DataDir = dataDir + conf.AOFSyncStrategy = "always" + + mockServer, err := NewSugarDB(WithConfig(conf)) + if err != nil { + t.Error(err) + return + } + + // Perform write commands from "before-rewrite" + for key, value := range data["before-rewrite"] { + if _, _, err := mockServer.Set(key, value, SETOptions{}); err != nil { + t.Error(err) + return + } + } + + // Yield + <-ticker.C + + // Rewrite AOF + if _, err := mockServer.RewriteAOF(); err != nil { + t.Error(err) + return + } + + // Perform write commands from "after-rewrite" + for key, value := range data["after-rewrite"] { + if _, _, err := mockServer.Set(key, value, SETOptions{}); err != nil { + t.Error(err) + return + } + } + + // Yield + <-ticker.C + + // Shutdown the SugarDB instance + mockServer.ShutDown() + + // Start another instance of SugarDB + mockServer, err = NewSugarDB(WithConfig(conf)) + if err != nil { + t.Error(err) + return + } + + // Check if the servers contains the keys and values from "expected-values" + for key, value := range data["expected-values"] { + res, err := mockServer.Get(key) + if err != nil { + t.Error(err) + return + } + if res != value { + t.Errorf("expected value at key \"%s\" to be \"%s\", got \"%s\"", key, value, res) + return + } + } + }) + + t.Run("Test_EvictExpiredTTL", func(t *testing.T) { + // TODO: Implement test for evicting expired keys in standalone mode. + }) + + t.Run("Test_GetServerInfo", func(t *testing.T) { + wantInfo := internal.ServerInfo{ + Server: "sugardb", + Version: constants.Version, + Id: mockServer.config.ServerID, + Mode: "standalone", + Role: "master", + Modules: mockServer.ListModules(), + } + info := mockServer.GetServerInfo() + if diff := deep.Equal(wantInfo, info); diff != nil { + t.Errorf("GetServerInfo(): %+v", err) + } + }) +} diff --git a/sugardb/test_helpers.go b/sugardb/test_helpers.go new file mode 100644 index 0000000..dcd5868 --- /dev/null +++ b/sugardb/test_helpers.go @@ -0,0 +1,51 @@ +package sugardb + +import ( + "context" + "strconv" + + "apigo.cc/go/sugardb/internal" + "apigo.cc/go/sugardb/internal/config" + "apigo.cc/go/sugardb/internal/constants" +) + +func createSugarDB() *SugarDB { + ev, _ := NewSugarDB( + WithConfig(config.Config{ + DataDir: "", + EvictionPolicy: constants.NoEviction, + }), + ) + return ev +} + +func createSugarDBWithConfig(conf config.Config) *SugarDB { + ev, _ := NewSugarDB( + WithConfig(conf), + ) + return ev +} + +func presetValue(server *SugarDB, ctx context.Context, key string, value interface{}) error { + ctx = context.WithValue(ctx, "Database", 0) + if err := server.setValues(ctx, map[string]interface{}{key: value}); err != nil { + return err + } + return nil +} + +func presetKeyData(server *SugarDB, ctx context.Context, key string, data internal.KeyData) { + ctx = context.WithValue(ctx, "Database", 0) + _ = server.setValues(ctx, map[string]interface{}{key: data.Value}) + server.setExpiry(ctx, key, data.ExpireAt, false) +} + +func getValue (server *SugarDB, ctx context.Context, key string, database string) (interface{}, error) { + db, err := strconv.Atoi(database) + if err != nil { + return nil, err + } + ctx = context.WithValue(ctx, "Database", db) + + return server.getValues(ctx, []string{key})[key], err +} \ No newline at end of file diff --git a/test_env/all/Dockerfile b/test_env/all/Dockerfile new file mode 100644 index 0000000..2bd8a63 --- /dev/null +++ b/test_env/all/Dockerfile @@ -0,0 +1,8 @@ +# run docker-compose from root dir +FROM golang:latest + +WORKDIR /testspace + +COPY . ./ + +CMD make test; make test-race; diff --git a/test_env/all/docker-compose.yaml b/test_env/all/docker-compose.yaml new file mode 100644 index 0000000..4584ed6 --- /dev/null +++ b/test_env/all/docker-compose.yaml @@ -0,0 +1,12 @@ +# run from root dir +services: + test: + build: + context: ../.. + dockerfile: test_env/all/Dockerfile + container_name: sugardb_test_env_all + volumes: + - ../../coverage/coverage.out:/testspace/coverage/coverage.out + stdin_open: true + tty: true + diff --git a/test_env/run/Dockerfile b/test_env/run/Dockerfile new file mode 100644 index 0000000..d35cb4f --- /dev/null +++ b/test_env/run/Dockerfile @@ -0,0 +1,8 @@ +# run docker-compose from root dir +FROM golang:latest + +WORKDIR /testspace + +COPY . ./ + +CMD ["/bin/bash"] diff --git a/test_env/run/docker-compose.yaml b/test_env/run/docker-compose.yaml new file mode 100644 index 0000000..3b59841 --- /dev/null +++ b/test_env/run/docker-compose.yaml @@ -0,0 +1,10 @@ +# run from root dir +services: + projenv: + build: + context: ../.. + dockerfile: test_env/run/Dockerfile + container_name: sugardb_test_env_run + stdin_open: true + tty: true + diff --git a/test_env/test/Dockerfile b/test_env/test/Dockerfile new file mode 100644 index 0000000..01e9055 --- /dev/null +++ b/test_env/test/Dockerfile @@ -0,0 +1,8 @@ +# run docker-compose from root dir +FROM golang:latest + +WORKDIR /testspace + +COPY . ./ + +CMD make test; diff --git a/test_env/test/docker-compose.yaml b/test_env/test/docker-compose.yaml new file mode 100644 index 0000000..0324afb --- /dev/null +++ b/test_env/test/docker-compose.yaml @@ -0,0 +1,12 @@ +# run from root dir +services: + test: + build: + context: ../.. + dockerfile: test_env/test/Dockerfile + container_name: sugardb_test_env_test + volumes: + - ../../coverage/coverage.out:/testspace/coverage/coverage.out + stdin_open: true + tty: true + diff --git a/test_env/test_race/Dockerfile b/test_env/test_race/Dockerfile new file mode 100644 index 0000000..557a2dc --- /dev/null +++ b/test_env/test_race/Dockerfile @@ -0,0 +1,8 @@ +# run docker-compose from root dir +FROM golang:latest + +WORKDIR /testspace + +COPY . ./ + +CMD make test-race; diff --git a/test_env/test_race/docker-compose.yaml b/test_env/test_race/docker-compose.yaml new file mode 100644 index 0000000..11a83ff --- /dev/null +++ b/test_env/test_race/docker-compose.yaml @@ -0,0 +1,12 @@ +# run from root dir +services: + test: + build: + context: ../.. + dockerfile: test_env/test_race/Dockerfile + container_name: sugardb_test_env_test_race + volumes: + - ../../coverage/coverage.out:/testspace/coverage/coverage.out + stdin_open: true + tty: true +