From b6f82eb6a99c5054bdba2e87f55aaca08edaedfa Mon Sep 17 00:00:00 2001 From: "Jyotirmoy Bandyopadhyaya [Bravo68]" Date: Mon, 8 May 2023 00:55:03 +0530 Subject: [PATCH] Fresh Release v0.1.2 --- .eslintrc | 0 .github/CODEOWNERS | 1 + .github/workflows/build-image-push.yaml | 50 +++ .github/workflows/testing.yaml | 19 + .gitignore | 7 + .prettierrc | 3 + Dockerfile | 13 + LICENSE | 21 + README.md | 58 +++ app/bootstrap.js | 102 +++++ app/buffer.js | 36 ++ app/main.js | 538 ++++++++++++++++++++++++ codecrafters.yml | 11 + create-container.sh | 22 + db/stockpile.db | Bin 0 -> 139 bytes package.json | 42 ++ spawn_redis_server.sh | 10 + test_local.sh | 122 ++++++ yarn.lock | 13 + 19 files changed, 1068 insertions(+) create mode 100644 .eslintrc create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/build-image-push.yaml create mode 100644 .github/workflows/testing.yaml create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/bootstrap.js create mode 100644 app/buffer.js create mode 100644 app/main.js create mode 100644 codecrafters.yml create mode 100755 create-container.sh create mode 100644 db/stockpile.db create mode 100644 package.json create mode 100755 spawn_redis_server.sh create mode 100755 test_local.sh create mode 100644 yarn.lock diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..e69de29 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..7cd9bed --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @BRAVO68WEB \ No newline at end of file diff --git a/.github/workflows/build-image-push.yaml b/.github/workflows/build-image-push.yaml new file mode 100644 index 0000000..09e9daf --- /dev/null +++ b/.github/workflows/build-image-push.yaml @@ -0,0 +1,50 @@ +name: Build + Publish Docker Image to GHCR +on: + workflow_dispatch: + push: + branches: ['master'] +env: + REGISTRY: ghcr.io + IMAGE_NAME: stockpile +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + # Fetch the code + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Prepare + id: prep + run: echo ::set-output name=version::${GITHUB_REF##*/} + + # Use QEMU for multi-architecture builds + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + with: + platforms: all + + # Login into GH container registry + - name: Log in to registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + # Build the image from default Dockerfile + - name: Build image + run: docker build . --file Dockerfile --tag $IMAGE_NAME --label "runnumber=${GITHUB_RUN_ID}" + + # Push build image + - name: Push image + run: | + IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME + IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') + VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') + [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') + [ "$VERSION" == "master" ] && VERSION=latest + echo IMAGE_ID=$IMAGE_ID + echo VERSION=$VERSION + docker tag $IMAGE_NAME $IMAGE_ID:$VERSION + docker push $IMAGE_ID:$VERSION + \ No newline at end of file diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml new file mode 100644 index 0000000..21619ac --- /dev/null +++ b/.github/workflows/testing.yaml @@ -0,0 +1,19 @@ +name: Build Checks + +on: + push: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: 16 + - run: npm install -g yarn + - run: yarn + - run: sudo apt install redis-tools + - run: chmod +x spawn_redis_server.sh test_local.sh + - run: ./spawn_redis_server.sh & + - run: ./test_local.sh \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49e3680 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +dump.rdb +**/stockpile.dump +testBox.js +node_modules +*.dat +config.json +stockpile.json \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0a02bce --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "tabWidth": 4 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b472830 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:alpine + +WORKDIR /usr/app + +COPY package.json . + +RUN yarn + +COPY . . + +EXPOSE 6379 + +ENTRYPOINT ["node"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4083244 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Jyotirmoy "BRAVO68WEB" Bandyopadhayaya + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..829d891 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Welcome to stockpile 👋 + +[![Version](https://img.shields.io/npm/v/@bravo68web/stockpile.svg)](https://www.npmjs.com/package/stockpile) +[![Documentation](https://img.shields.io/badge/documentation-yes-brightgreen.svg)](https://github.com/BRAVO68WEB/stockpile#readme) +[![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/BRAVO68WEB/stockpile/graphs/commit-activity) +[![License: MIT](https://img.shields.io/github/license/BRAVO68WEB/stockpile)](https://github.com/BRAVO68WEB/stockpile/blob/master/LICENSE) +[![Twitter: BRAVO68WEB](https://img.shields.io/twitter/follow/BRAVO68WEB.svg?style=social)](https://twitter.com/BRAVO68WEB) + +> A Tiny Redis Server ... + +Stockpile is a Tiny Redis Server, created from scratch in Node.js with only few dependencies. It is a work in progress, and is not yet ready for production use. It was initially a part of [CodeCrafter's - Create Redis Challenge](https://app.codecrafters.io/courses/redis/). But, I decided to make it a standalone project. I will be adding more features to it, and will be using it in my future projects. I will also be adding more tests to it. If you want to contribute, please feel free to do so. + +### 🏠 [Homepage](https://github.com/BRAVO68WEB/stockpile#readme) + +## Usage + +```sh +npx @bravo68web/stockpile +``` + +## Development + +```sh +git clone https://github.com/BRAVO68WEB/stockpile.git +cd stockpile +``` + +## Run tests + +```sh +node app/main.js & +./test_local.sh +``` + +## Author + +👤 **Jyotirmoy Bandyopadhayaya** + +- Website: https://itsmebravo.dev +- Twitter: [@BRAVO68WEB](https://twitter.com/BRAVO68WEB) +- Github: [@BRAVO68WEB](https://github.com/BRAVO68WEB) +- LinkedIn: [@BRAVO68WEB](https://linkedin.com/in/BRAVO68WEB) + +## 🤝 Contributing + +Contributions, issues and feature requests are welcome! + +Feel free to check [issues page](https://github.com/BRAVO68WEB/stockpile/issues). + +## Show your support + +Give a ⭐️ if this project helped you! + +## 📝 License + +Copyright © 2023 [Jyotirmoy "BRAVO68WEB" Bandyopadhayaya](https://github.com/BRAVO68WEB). + +This project is [MIT](https://github.com/BRAVO68WEB/stockpile/blob/master/LICENSE) licensed. diff --git a/app/bootstrap.js b/app/bootstrap.js new file mode 100644 index 0000000..9a2327e --- /dev/null +++ b/app/bootstrap.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +// This is the main entry point for the application. + +// CLI arguments are passed to this file, and then passed to the main application. +// Do not use any CLI framework here, as it will be difficult to maintain. + +const commandName = process.argv[2]; +const commandArgs = process.argv.slice(3); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const pkg = require("../package.json"); + +if (commandName === "help") { + console.log("Stockpile CLI"); + console.log("Version " + pkg.version); + console.log(""); + console.log("Commands:"); + console.log( + " init --name --configpath --auth --port --dumppath | Create a new config file" + ); + console.log(" start --configpath | Start Stockpile"); + console.log(" help Display this help message"); + console.log(""); + console.log("Github : ", pkg.repository.url.split("+")[1]); + console.log("Author : ", pkg.author.name); + process.exit(0); +} else if (commandName === "init") { + try { + console.log("Initializing new Stockpile config file"); + + let config = { + name: "", + configpath: os.homedir() + "/.stockpile.config.json", + auth: "", + port: 6379, + dumppath: os.homedir() + "/.stockpile.dump", + }; + + if (commandArgs.length % 2 !== 0) { + console.log("Invalid init syntax"); + return; + } + + for (const i in commandArgs) { + if (commandArgs[i] === "--name") { + config.name = commandArgs[parseInt(i) + 1]; + } else if (commandArgs[i] === "--configpath") { + config.configpath = commandArgs[parseInt(i) + 1]; + } else if (commandArgs[i] === "--auth") { + config.auth = commandArgs[parseInt(i) + 1]; + } else if (commandArgs[i] === "--port") { + config.port = commandArgs[parseInt(i) + 1]; + } else if (commandArgs[i] === "--dumppath") { + config.dumppath = commandArgs[parseInt(i) + 1]; + } + } + + fs.writeFileSync( + path.resolve(config.configpath), + JSON.stringify(config) + ); + console.log("Config file created at " + config.configpath); + process.exit(0); + } catch (err) { + console.log(err); + process.exit(1); + } +} else if (commandName === "start") { + console.log("Starting Stockpile"); + + let configpath = ""; + for (const i in commandArgs) { + if (commandArgs[i] === "--configpath") { + configpath = commandArgs[parseInt(i) + 1]; + } + } + if (configpath === "") { + console.log("No config file specified"); + return; + } + + if (!fs.existsSync(configpath)) { + console.log("Config file does not exist"); + return; + } + + const config = JSON.parse(fs.readFileSync(configpath)); + + ["name", "configpath", "port", "dumppath"].forEach((key) => { + if (!config[key]) { + console.log("Invalid config file"); + process.exit(1); + } + }); + + const Stockpile = require("./main.js"); + Stockpile(config); +} else { + console.log("Invalid command"); +} diff --git a/app/buffer.js b/app/buffer.js new file mode 100644 index 0000000..5be1986 --- /dev/null +++ b/app/buffer.js @@ -0,0 +1,36 @@ +const { BSON } = require("bson"); + +function createBinaryData(mapData) { + const data = {}; + // Add each map in the array to the data object + for (let i = 0; i < mapData.length; i++) { + data[i] = Object.fromEntries(mapData[i]); + } + // Serialize the data object with BSON + return BSON.serialize(data); +} + +function readBinaryData(buffer) { + const data = BSON.deserialize(buffer); + const mapData = []; + + // Convert each object in the data object to a Map + for (const key in data) { + const obj = data[key]; + const map = new Map(); + for (const [key, value] of Object.entries(obj)) { + if (typeof value === "object") { + map.set(key, value); + } else { + map.set(key, value); + } + } + mapData.push(map); + } + + return mapData; +} +module.exports = { + createBinaryData, + readBinaryData, +}; diff --git a/app/main.js b/app/main.js new file mode 100644 index 0000000..81002d8 --- /dev/null +++ b/app/main.js @@ -0,0 +1,538 @@ +#!/usr/bin/env node + +const net = require("net"); +const fs = require("fs"); +const path = require("path"); +const { createBinaryData, readBinaryData } = require("./buffer"); + +module.exports = (config) => { + const MAX_DATABASES = 16; + const DEFAULT_DATABASE = 0; + + const authConfig = { + enabled: config.auth ? true : false, + isAuthenticated: false, + password: config.auth ? config.auth : "", + }; + + let dataStore = new Array(MAX_DATABASES); + for (let i = 0; i < MAX_DATABASES; i++) { + dataStore[i] = new Map(); + } + + if (fs.existsSync(path.join(config.dumppath))) { + dataStore = fs.readFileSync(path.join(config.dumppath)); + dataStore = readBinaryData(dataStore); + } + + let currentDatabase = DEFAULT_DATABASE; + + const parseIncomingData = (data) => { + const lines = data.toString().split("\r\n"); + return lines; + }; + + const assignHandler = (command, connection) => { + command[2] = command[2].toUpperCase(); + if ( + authConfig.enabled && + !authConfig.isAuthenticated && + command[2] !== "AUTH" + ) { + connection.write("-ERR NOAUTH Authentication required.\r\n"); + return; + } + + if (command[2] == "AUTH") { + if (authConfig.enabled) { + if (authConfig.isAuthenticated) { + connection.write("-ERR Already authenticated\r\n"); + } else if (command[4] == authConfig.password) { + authConfig.isAuthenticated = true; + connection.write("+OK Auth Successfull\r\n"); + } else if (command[4] != authConfig.password) { + connection.write("-ERR Invalid Password\r\n"); + } + } else { + connection.write("-ERR Authentication is not enabled\r\n"); + } + } else if (command[2] == "PING") { + connection.write("+PONG\r\n"); + } else if (command[2] == "SET") { + dataStore[currentDatabase].set(command[4], command[6]); + let expireTime = null; + + for (let i = 8; i < command.length; i += 2) { + if (command[i].toUpperCase() === "PX") { + expireTime = Date.now() + parseInt(command[i + 2]); + } else if (command[i].toUpperCase() === "EX") { + expireTime = Date.now() + parseInt(command[i + 2]) * 1000; + } + } + + if (expireTime) { + dataStore[currentDatabase].set( + command[4] + "_expire", + expireTime + ); + } + connection.write("+OK\r\n"); + } else if (command[2] == "GET") { + if ( + dataStore[currentDatabase].get(command[4] + "_expire") && + dataStore[currentDatabase].get(command[4] + "_expire") < + Date.now() + ) { + connection.write("$-1\r\n"); + dataStore[currentDatabase].delete(command[4]); + dataStore[currentDatabase].delete(command[4] + "_expire"); + } else if (dataStore[currentDatabase].get(command[4])) + connection.write( + "+" + dataStore[currentDatabase].get(command[4]) + "\r\n" + ); + else connection.write("$-1\r\n"); + } else if (command[2] == "DEL") { + dataStore[currentDatabase].delete(command[4]); + connection.write(":1\r\n"); + } else if (command[2] == "ECHO") { + connection.write( + "$" + command[4].length + "\r\n" + command[4] + "\r\n" + ); + } else if (command[2] == "EXISTS") { + if (dataStore[currentDatabase].has(command[4])) + connection.write(":1\r\n"); + else connection.write(":0\r\n"); + } else if (command[2] == "KEYS") { + let pattern = command[4]; + let keys = Array.from(dataStore[currentDatabase].keys()); + let matchingKeys = keys.filter((key) => { + const regex = new RegExp(pattern.replace("*", ".*")); + return regex.test(key); + }); + let response = "*" + matchingKeys.length + "\r\n"; + for (let key of matchingKeys) { + response += "$" + key.length + "\r\n" + key + "\r\n"; + } + connection.write(response); + } else if (command[2] == "APPEND") { + let key = command[4]; + let value = command[6]; + if (dataStore[currentDatabase].has(key)) { + let newValue = dataStore[currentDatabase].get(key) + value; + dataStore[currentDatabase].set(key, newValue); + connection.write(":" + newValue.length + "\r\n"); + } else { + dataStore[currentDatabase].set(key, value); + connection.write(":" + value.length + "\r\n"); + } + } else if (command[2] == "STRLEN") { + let key = command[4]; + if (dataStore[currentDatabase].has(key)) { + let value = dataStore[currentDatabase].get(key); + connection.write(":" + value.length + "\r\n"); + } else { + connection.write(":0\r\n"); + } + } else if (command[2] == "SETNX") { + let key = command[4]; + let value = command[6]; + if (dataStore[currentDatabase].has(key)) { + connection.write(":0\r\n"); + } else { + dataStore[currentDatabase].set(key, value); + connection.write(":1\r\n"); + } + } else if (command[2] == "SETRANGE") { + let key = command[4]; + let offset = command[6]; + let value = command[8]; + if (dataStore[currentDatabase].has(key)) { + let oldValue = dataStore[currentDatabase].get(key); + let newValue = + oldValue.substring(0, offset) + + value + + oldValue.substring(offset + value.length); + dataStore[currentDatabase].set(key, newValue); + connection.write(":" + newValue.length + "\r\n"); + } else { + dataStore[currentDatabase].set(key, value); + connection.write(":" + value.length + "\r\n"); + } + } else if (command[2] == "GETRANGE") { + let key = command[4]; + let start = command[6]; + let end = command[8]; + if (dataStore[currentDatabase].has(key)) { + let value = dataStore[currentDatabase].get(key); + let newValue = value.substring(start, end + 1); + connection.write( + "$" + newValue.length + "\r\n" + newValue + "\r\n" + ); + } else { + connection.write("$-1\r\n"); + } + } else if (command[2] == "MSET") { + const keyValuePairs = command.slice(4); + for (let i = 0; i < keyValuePairs.length; ) { + dataStore[currentDatabase].set( + keyValuePairs[i], + keyValuePairs[i + 2] + ); + i += 4; + } + connection.write("+OK\r\n"); + } else if (command[2] == "MGET") { + const keys = command.slice(1); + const values = keys.map( + (key) => dataStore[currentDatabase].get(key) ?? null + ); + + if (values.includes(null)) { + connection.write("$-1\r\n"); + } else { + const response = values.reduce((acc, value) => { + return `${acc}\r\n$${value.length}\r\n${value}`; + }, `*${values.length}`); + connection.write(response); + } + } else if (command[2] == "FLUSHDB") { + dataStore[currentDatabase].clear(); + connection.write("+OK\r\n"); + } else if (command[2] == "FLUSHALL") { + dataStore = []; + for (let i = 0; i < 16; i++) { + dataStore.push(new Map()); + } + connection.write("+OK\r\n"); + } else if (command[2] == "DBSIZE") { + let size = dataStore[currentDatabase].size; + connection.write(":" + size + "\r\n"); + } else if (command[2] == "SELECT") { + let database = command[4]; + currentDatabase = database; + connection.write("+OK\r\n"); + } else if (command[2] == "RANDOMKEY") { + let keys = Array.from(dataStore[currentDatabase].keys()); + if (keys.length == 0) { + connection.write("$-1\r\n"); + return; + } + let randomKey = keys[Math.floor(Math.random() * keys.length)]; + connection.write( + "$" + randomKey.length + "\r\n" + randomKey + "\r\n" + ); + } else if (command[2] == "INCR") { + let key = command[4]; + if (dataStore[currentDatabase].has(key)) { + let value = dataStore[currentDatabase].get(key); + let newValue = parseInt(value) + 1; + dataStore[currentDatabase].set(key, newValue.toString()); + connection.write(":" + newValue + "\r\n"); + } else { + dataStore[currentDatabase].set(key, "1"); + connection.write(":1\r\n"); + } + } else if (command[2] == "INCRBY") { + let key = command[4]; + let increment = command[6]; + if (dataStore[currentDatabase].has(key)) { + let value = dataStore[currentDatabase].get(key); + let newValue = parseInt(value) + parseInt(increment); + dataStore[currentDatabase].set(key, newValue.toString()); + connection.write(":" + newValue + "\r\n"); + } else { + dataStore[currentDatabase].set(key, increment); + connection.write(":" + increment + "\r\n"); + } + } else if (command[2] == "DECR") { + let key = command[4]; + if (dataStore[currentDatabase].has(key)) { + let value = dataStore[currentDatabase].get(key); + let newValue = parseInt(value) - 1; + dataStore[currentDatabase].set(key, newValue.toString()); + connection.write(":" + newValue + "\r\n"); + } else { + dataStore[currentDatabase].set(key, "-1"); + connection.write(":-1\r\n"); + } + } else if (command[2] == "DECRBY") { + let key = command[4]; + let decrement = command[6]; + if (dataStore[currentDatabase].has(key)) { + let value = dataStore[currentDatabase].get(key); + let newValue = parseInt(value) - parseInt(decrement); + dataStore[currentDatabase].set(key, newValue.toString()); + connection.write(":" + newValue + "\r\n"); + } else { + dataStore[currentDatabase].set(key, "-" + decrement); + connection.write(":-" + decrement + "\r\n"); + } + } else if (command[2] == "EXPIRE") { + const key = command[4]; + const seconds = parseInt(command[6]); + if (dataStore[currentDatabase].has(key)) { + // Set the expiration time of the key + const timestamp = Math.floor(Date.now() / 1000) + seconds; + dataStore[currentDatabase].set(`${key}:expire`, timestamp); + connection.write(":1\r\n"); + + // Schedule the key for deletion when the expiration time has elapsed + setTimeout(() => { + if (dataStore[currentDatabase].has(key)) { + dataStore[currentDatabase].delete(key); + dataStore[currentDatabase].delete(`${key}:expire`); + } + }, seconds * 1000); + } else { + connection.write(":0\r\n"); + } + } else if (command[2] == "TTL") { + const key = command[4]; + if (dataStore[currentDatabase].has(key)) { + // Get the expiration time of the key + const expireKey = `${key}:expire`; + if (dataStore[currentDatabase].has(expireKey)) { + const timestamp = dataStore[currentDatabase].get(expireKey); + const ttl = Math.max( + timestamp - Math.floor(Date.now() / 1000), + 0 + ); + connection.write(`:${ttl}\r\n`); + } else { + connection.write(":-1\r\n"); + } + } else { + connection.write(":-2\r\n"); + } + } else if (command[2] == "EXPIREAT") { + const key = command[4]; + const timestamp = parseInt(command[6]); + if (dataStore[currentDatabase].has(key)) { + // Set the expiration time of the key + dataStore[currentDatabase].set(`${key}:expire`, timestamp); + connection.write(":1\r\n"); + } else { + connection.write(":0\r\n"); + } + } else if (command[2] == "PERSIST") { + const key = command[4]; + if (dataStore[currentDatabase].has(`${key}:expire`)) { + dataStore[currentDatabase].delete(`${key}:expire`); + connection.write(":1\r\n"); + } else { + connection.write(":0\r\n"); + } + } else if (command[2] == "HGET") { + const key = command[4]; + const field = command[6]; + if (dataStore[currentDatabase].has(key)) { + const value = dataStore[currentDatabase].get(key)[field]; + if (value !== undefined) { + connection.write(`$${value.length}\r\n${value}\r\n`); + } else { + connection.write("$-1\r\n"); + } + } else { + connection.write("$-1\r\n"); + } + } else if (command[2] == "HSET") { + const key = command[4]; + const field = command[6]; + const value = command[8]; + if (!dataStore[currentDatabase].has(key)) { + dataStore[currentDatabase].set(key, {}); + } + dataStore[currentDatabase].get(key)[field] = value; + connection.write(":1\r\n"); + } else if (command[2] == "HGETALL") { + const key = command[4]; + if (dataStore[currentDatabase].has(key)) { + const obj = dataStore[currentDatabase].get(key); + let response = "*" + Object.keys(obj).length * 2 + "\r\n"; + for (const [field, value] of Object.entries(obj)) { + response += `$${field.length}\r\n${field}\r\n`; + response += `$${value.length}\r\n${value}\r\n`; + } + connection.write(response); + } else { + connection.write("*0\r\n"); + } + } + + // HSETNX, HINCRBY, HDEL, HEXISTS, HKEYS, HLEN, HSTRLEN, HVALS + else if (command[2] == "HSETNX") { + const key = command[4]; + const field = command[6]; + const value = command[8]; + if (!dataStore[currentDatabase].has(key)) { + dataStore[currentDatabase].set(key, {}); + } + const obj = dataStore[currentDatabase].get(key); + if (obj[field] === undefined) { + obj[field] = value; + connection.write(":1\r\n"); + } else { + connection.write(":0\r\n"); + } + } else if (command[2] == "HMSET") { + const hash = command[1]; + const fieldsAndValues = command.slice(2); + const hashExists = dataStore[currentDatabase].hasOwnProperty(hash); + + if (!hashExists) { + dataStore[currentDatabase][hash] = {}; + } + + for (let i = 0; i < fieldsAndValues.length; i += 2) { + const field = fieldsAndValues[i]; + const value = fieldsAndValues[i + 1]; + dataStore[currentDatabase][hash][field] = value; + } + + connection.write("+OK\r\n"); + } else if (command[2] == "HINCRBY") { + const key = command[4]; + const field = command[6]; + const increment = Number(command[8]); + if (!dataStore[currentDatabase].has(key)) { + dataStore[currentDatabase].set(key, {}); + } + const obj = dataStore[currentDatabase].get(key); + if (obj[field] === undefined) { + obj[field] = "0"; + } + obj[field] = String(Number(obj[field]) + increment); + connection.write( + ":" + obj[field].length + "\r\n" + obj[field] + "\r\n" + ); + } else if (command[2] == "HDEL") { + const key = command[4]; + const fields = command.slice(6); + if (!dataStore[currentDatabase].has(key)) { + connection.write(":0\r\n"); + } else { + const obj = dataStore[currentDatabase].get(key); + let count = 0; + for (const field of fields) { + if (obj.hasOwnProperty(field)) { + delete obj[field]; + count += 1; + } + } + if (Object.keys(obj).length === 0) { + dataStore[currentDatabase].delete(key); + } + connection.write(":" + count + "\r\n"); + } + } else if (command[2] == "HEXISTS") { + const key = command[4]; + const field = command[6]; + if (!dataStore[currentDatabase].has(key)) { + connection.write(":0\r\n"); + } else { + const obj = dataStore[currentDatabase].get(key); + if (obj.hasOwnProperty(field)) { + connection.write(":1\r\n"); + } else { + connection.write(":0\r\n"); + } + } + } else if (command[2] == "HKEYS") { + const key = command[4]; + if (!dataStore[currentDatabase].has(key)) { + connection.write("*0\r\n"); + } else { + const obj = dataStore[currentDatabase].get(key); + const fields = Object.keys(obj); + let response = "*" + fields.length + "\r\n"; + for (const field of fields) { + response += "$" + field.length + "\r\n" + field + "\r\n"; + } + connection.write(response); + } + } else if (command[2] == "HLEN") { + const key = command[4]; + if (!dataStore[currentDatabase].has(key)) { + connection.write(":0\r\n"); + } else { + const obj = dataStore[currentDatabase].get(key); + const count = Object.keys(obj).length; + connection.write(":" + count + "\r\n"); + } + } else if (command[2] == "HSTRLEN") { + const key = command[4]; + const field = command[6]; + if (!dataStore[currentDatabase].has(key)) { + connection.write(":0\r\n"); + } else { + const obj = dataStore[currentDatabase].get(key); + if (obj.hasOwnProperty(field)) { + const value = obj[field]; + connection.write(":" + value.length + "\r\n"); + } else { + connection.write(":0\r\n"); + } + } + } else if (command[2] == "HVALS") { + const key = command[4]; + if (!dataStore[currentDatabase].has(key)) { + connection.write("*0\r\n"); + } else { + const obj = dataStore[currentDatabase].get(key); + const values = Object.values(obj); + let response = "*" + values.length + "\r\n"; + for (const value of values) { + response += "$" + value.length + "\r\n" + value + "\r\n"; + } + connection.write(response); + } + } else if (command[2] == "DUMP") { + saveDataToFile(dataStore); + connection.write("+OK Saving Current State\r\n"); + } else if (command[0].includes("health")) { + connection.write("OK !!\r\n"); + connection.end(); + } else if (command[0].includes("GET")) { + connection.write("Stockpile is Running !!\r\n"); + connection.end(); + } else { + connection.write("-ERR unknown command " + command[2] + "\r\n"); + } + }; + + let id = 0; + const server = net.createServer((connection) => { + connection.id = id; + id += 1; + connection.on("data", (data) => { + let command = parseIncomingData(data); + assignHandler(command, connection); + }); + + connection.on("end", (something) => { + console.log("Client disconnected"); + }); + + connection.on("error", (err) => { + console.log("Error in connection"); + console.log(err); + }); + }); + + server.listen(config.port, "0.0.0.0", () => { + console.log("Starting server"); + console.log("Stockpile server listening on port 6379"); + }); + + function saveDataToFile(data, filename) { + const binaryData = createBinaryData(data); + fs.writeFileSync(config.dumppath, binaryData); + } + + process.on("SIGINT", () => { + console.log("\nTaking Snapshot !!"); + saveDataToFile(dataStore); + console.log("Shutting down server"); + process.exit(0); + }); +}; diff --git a/codecrafters.yml b/codecrafters.yml new file mode 100644 index 0000000..d437fc4 --- /dev/null +++ b/codecrafters.yml @@ -0,0 +1,11 @@ +# Set this to true if you want debug logs. +# +# These can be VERY verbose, so we suggest turning them off +# unless you really need them. +debug: true + +# Use this to change the JavaScript version used to run your code +# on Codecrafters. +# +# Available versions: nodejs-16 +language_pack: nodejs-16 diff --git a/create-container.sh b/create-container.sh new file mode 100755 index 0000000..029d148 --- /dev/null +++ b/create-container.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +touch stockpile.json + +echo "Do you want to enable authentication? (y/n)" +read auth + +echo "Enter desired port" +read port + +if([ $auth = "y" ]) +then + echo "Enter password" + read password + docker run -v ./stockpile.json:/root/.stockpile.config.json stockpile app/bootstrap.js init --name "stockpile-db" --port 6379 --auth $password +fi +if([ $auth = "n" ]) +then + docker run -v ./stockpile.json:/root/.stockpile.config.json stockpile app/bootstrap.js init --name "stockpile-db" --port 6379 +fi + +docker run -d -p $port:6379 -v ./stockpile.json:/root/.stockpile.config.json -v ./db/stockpile.db:/root/.stockpile.dump stockpile app/bootstrap.js start --configpath /root/.stockpile.config.json \ No newline at end of file diff --git a/db/stockpile.db b/db/stockpile.db new file mode 100644 index 0000000000000000000000000000000000000000..54efa459cfe1b6dd9a041abac39d77fb30542a5a GIT binary patch literal 139 zcmX}kxeWjz5Cp-I5cx3t7b8h?MlV)*btI*t{G(*7*s$Zki3>M==oiN5C>^IGHLEUu C90hIw literal 0 HcmV?d00001 diff --git a/package.json b/package.json new file mode 100644 index 0000000..f8c4a28 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "@bravo68web/stockpile", + "version": "0.1.2", + "description": "A Tiny Redis Server ...", + "main": "app/main.js", + "bin": { + "stockpile": "app/main.js" + }, + "scripts": { + "dev": "nodemon app/main.js", + "test": "./test_local.sh" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/BRAVO68WEB/stockpile.git" + }, + "keywords": [ + "redis", + "node-cache", + "clone", + "tiny", + "self", + "server", + "service" + ], + "author": { + "email": "hi@b68.dev", + "name": "Jyotirmoy Bandyopadhayaya", + "url": "https://itsmebravo.dev" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/BRAVO68WEB/stockpile/issues" + }, + "homepage": "https://github.com/BRAVO68WEB/stockpile#readme", + "dependencies": { + "bson": "^5.1.0" + }, + "devDependencies": { + "prettier": "^2.8.4" + } +} diff --git a/spawn_redis_server.sh b/spawn_redis_server.sh new file mode 100755 index 0000000..3ebc25d --- /dev/null +++ b/spawn_redis_server.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# +# DON'T EDIT THIS! +# +# CodeCrafters uses this file to test your code. Don't make any changes here! +# +# DON'T EDIT THIS! +currentLoc=$(pwd) +node app/bootstrap.js init --name "stockpile-db" --port 6379 --configpath $currentLoc/stockpile.json --dumppath $currentLoc/stockpile.db +exec node app/bootstrap.js start --configpath $currentLoc/stockpile.json diff --git a/test_local.sh b/test_local.sh new file mode 100755 index 0000000..e027fd4 --- /dev/null +++ b/test_local.sh @@ -0,0 +1,122 @@ +#!/bin/bash + +echo "Test Redis PING command" +redis-cli ping + +echo "Test Redis SET and GET commands" +redis-cli set name John +redis-cli get name + +echo "Test Redis DEL command" +redis-cli del name +redis-cli get name + +echo "Test Redis ECHO command" +redis-cli echo "Hello, Redis!" + +echo "Test Redis EXISTS command" +redis-cli set name John +redis-cli exists name +redis-cli del name +redis-cli exists name + +echo "Test Redis KEYS command" +redis-cli set name John +redis-cli set age 30 +redis-cli keys "*" + +echo "Test Redis STRLEN command" +redis-cli set message "Hello, Redis!" +redis-cli strlen message + +echo "Test Redis SETNX command" +redis-cli setnx name John +redis-cli setnx name Jane + +echo "Test Redis SETRANGE and GETRANGE commands" +redis-cli set greeting "Hello, World!" +redis-cli setrange greeting 7 "Redis" +redis-cli getrange greeting 0 11 + +echo "Test Redis MSET and MGET commands" +redis-cli mset fruit1 apple fruit2 orange fruit3 banana +redis-cli mget fruit1 fruit2 fruit3 + +echo "Test Redis FLUSHDB and FLUSHALL commands" +redis-cli flushdb +redis-cli set name John +redis-cli flushall + +echo "Test Redis DBSIZE command" +redis-cli set fruit1 apple +redis-cli set fruit2 orange +redis-cli dbsize + +echo "Test Redis SELECT command" +redis-cli select 1 +redis-cli set fruit1 apple +redis-cli get fruit1 + +echo "Test Redis RANDOMKEY command" +redis-cli randomkey + +echo "Test Redis INCR and DECR commands" +redis-cli set count 10 +redis-cli incr count +redis-cli decr count + +echo "Test Redis INCRBY and DECRBY commands" +redis-cli set count 10 +redis-cli incrby count 5 +redis-cli decrby count 3 + +echo "Test Redis EXPIRE, TTL, and EXPIREAT commands" +redis-cli set name John +redis-cli expire name 10 +redis-cli ttl name +redis-cli expireat name "$(date +%s) + 10" +redis-cli persist name + +echo "Test Redis HGET, HSET, HGETALL, and HSETNX commands" +redis-cli hmset person name John age 30 +redis-cli hget person name +redis-cli hset person email john@example.com +redis-cli hgetall person +redis-cli hsetnx person name Jane + +echo "Test Redis HINCRBY command" +redis-cli hmset counter clicks 10 views 20 +redis-cli hincrby counter clicks 5 +redis-cli hget counter clicks + +echo "Test Redis HDEL command" +redis-cli hmset person name John age 30 email john@example.com +redis-cli hdel person email +redis-cli hgetall person + +echo "Test Redis HEXISTS command" +redis-cli hmset person name John age 30 +redis-cli hexists person name +redis-cli hexists person email + +echo "Test Redis HKEYS command" +redis-cli hmset person name John age 30 +redis-cli hkeys person + +echo "Test Redis HLEN command" +redis-cli hmset person name John age 30 +redis-cli hlen person + +echo "Test Redis HSTRLEN command" +redis-cli hmset person name John age 30 +redis-cli hstrlen person name + +echo "Test Redis HVALS command" +redis-cli hmset person name John age 30 +redis-cli hvals person + +echo "Dump current DB State" +redis-cli dump + +echo "Clean up by deleting all keys" +redis-cli flushall \ No newline at end of file diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..1cd611c --- /dev/null +++ b/yarn.lock @@ -0,0 +1,13 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +bson@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/bson/-/bson-5.1.0.tgz#7b15cd9aa012b8bf9d320fbaefe15cc2fb657de2" + integrity sha512-FEecNHkhYRBe7X9KDkdG12xNuz5VHGeH6mCE0B5sBmYtiR/Ux/9vUH/v4NUoBCDr6NuEhvahjoLiiRogptVW0A== + +prettier@^2.8.4: + version "2.8.4" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.4.tgz#34dd2595629bfbb79d344ac4a91ff948694463c3" + integrity sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw==