Databases and Data Validation

Key-value Databases and Deno


Learning Objectives

  • You know of key-value databases and you know what Deno KV is.
  • You know how to use Deno KV to store and retrieve data.
  • You know how to use Deno KV in a web application.

Key-value databases are a type of database that store data in a key-value format. This means that each piece of data is stored with a key, and that the key can be used to retrieve the data. For example, if we wanted to store information of a user, we could use a unique identifier such as an email address as the key, and the user’s information as the value. When we want to retrieve the user’s information, we can use the email address to get the user’s information.

Deno comes with a built-in key-value database called Deno KV. The key-value database can be accessed using the asynchronous function Deno.openKv(), which returns an object that can be used to access the key-value database. The relevant methods for working with the key-value database are get, set, and delete — all of them are asynchronous.

Deno KV is a new feature that is available with the --unstable-kv flag.

When running applications that use Deno KV, we use the command deno run --allow-net --unstable-kv --watch, followed by the name of the file we run. For the walking skeleton, this means that the last line of the Dockerfile in the server directory would have to be as follows.

CMD [ "run", "--allow-env", "--allow-net", "--unstable-kv", "--watch", "app-run.js" ]
Storage and location

When using Deno KV locally, the data is stored in a SQLite database. The location of Deno files can be found using the command deno info. The database location is under the “Origin storage”.

When using Deno Deploy to host the application, Deno automatically utilizes a database when using Deno KV. Information about the database can be found in the Deno Deploy dashboard of the project.

Using Deno KV

Deno KV has four main methods for working with the key-value database:

  • The method get is used to retrieve a value.
  • The method set is used to set a value.
  • The method list is used to retrieve a list of values.
  • The method delete is used to remove a value.

All of the methods are asynchronous and return promises.

Retrieving a value

The get method is used for retrieving a value. It takes a list as a parameter where the values in the list act as the key. The following example outlines opening the database and retrieving the value for the key ["statistics", "visits"].

const kv = await Deno.openKv();
const visits = await kv.get(["statistics", "visits"]);

The value returned by get is an object, which contains a property value that has the actual value from the database.

Assuming that a value for the key exists, we can log the value into the console as follows.

const kv = await Deno.openKv();
const visits = await kv.get(["statistics", "visits"]);
console.log(count.value);

It is also possible that no value is found for a given key. In this case, the value within the returned object is null. Falling for a default value — say 0 — can be done with the ?? operator.

const kv = await Deno.openKv();
const visits = await kv.get(["statistics", "visits"]);
let value = visits.value ?? 0;
console.log(value);

Setting a value

The set method is used for setting a value. It takes two parameters: (1) a list where the values in the list act as the key, and (2) the value that is to be stored. The following example outlines opening the key value store and setting the value 0 for the key ["statistics", "visits"].

const kv = await Deno.openKv();
await kv.set(["statistics", "visits"], 0);

If the key-value database already contains a value for the given key pair, the value will be overridden with the new value.

Listing values

The list method is used to retrieve values that match a given prefix. The method takes an object as a parameter, where the object can contain a property prefix that is a list. The following example outlines the use of the list method. The example would retrieve all values that have a prefix my and add them to the list result.

const records = kv.list({ prefix: ["statistics"] });

const result = [];
for await (const res of records) {
  result.push(res.value);
}

console.log(result);

Note that as the list method returns an asynchronous iterator, we need to use a for await loop to iterate over the values.

Deleting a value

The delete method is used for deleting a value. Similar to get, it takes a list where the values act as the key as a parameter. The following example outlines the use of the delete method. The example would remove the value for the key ["statistics", "visits"].

const kv = await Deno.openKv();
await kv.delete(["statistics", "visits"]);
Loading Exercise...

Deno KV and Repository Pattern

When discussing the repository and CRUD patterns, we created a bookRepository.js that used Postgres.js to access a PostgreSQL database.

The repository pattern allows us to change the data access without changing the rest of the application.

The functions that the bookRepository.js file contained were create, readAll, readOne, update, and remove. Without the internal implementations, the file looked as follows.

const create = async (book) => {
  // Create a new book item
};

const readAll = async () => {
  // Retrieve all book items
};

const readOne = async (id) => {
  // Retrieve a book item with the given id
};

const update = async (id, book) => {
  // Update a book item with the given id
};

const remove = async (id) => {
  // Delete a book item with the given id
};

export { create, readAll, readOne, update, remove };

If we would wish to change the implementation to use Deno KV instead of PostgreSQL, we would have to rewrite the functions to use the key-value database. The create function would have to use the set method, the readAll function would have to use the list method, the readOne function would have to use the get method, the update function would have to use the set method, and the remove function would have to use the delete method.

In addition, we would have to do some parsing of the data, as e.g. the list method returns an asynchronous iterator, and we would have to iterate over to get the actual values.

Creating a book

The create function would have to use the set method to store the book. As Deno KV does not create identifiers automatically, we would have to set an identifier for the book ourselves — as an example, the crypto.randomUUID() function could be used to create a random identifier.

const create = async (book) => {
  const kv = await Deno.openKv();
  book.id = crypto.randomUUID();
  await kv.set(["books", book.id], book);
  return book;
};

Reading all books

To read all books, we would have to use the list method to retrieve all books that have a prefix books. As the list method returns an asynchronous iterator, we would have to iterate over the values to get the actual books.

const readAll = async () => {
  const kv = await Deno.openKv();
  const records = kv.list({ prefix: ["books"] });

  const result = [];
  for await (const record of records) {
    result.push(record.value);
  }

  return result;
};

With these two changes in place, and with the API from the chapter on repository and crud pattern we could already try creating a book and listing the books.

Now, posting a book to the API would create a book in the key-value database and return the book with the identifier.

curl -X POST -d '{"title": "The Hobbit", "year": 1937}' localhost:8000/books
{"title":"The Hobbit","year":1937,"id":"23daf1fa-ffc4-49b0-a460-adb5a15d4e47"}%

Once the book has been created, we can also as for a list of books.

curl localhost:8000/books
[{"title":"The Hobbit","year":1937,"id":"23daf1fa-ffc4-49b0-a460-adb5a15d4e47"}]%

Reading one book

To read one book, we would have to use the get method to retrieve the book with the given identifier.

const readOne = async (id) => {
  const kv = await Deno.openKv();
  const book = await kv.get(["books", id]);
  return book.value;
};

With this function in place, we could now also ask for a single book.

curl localhost:8000/books/23daf1fa-ffc4-49b0-a460-adb5a15d4e47
{"title":"The Hobbit","year":1937,"id":"23daf1fa-ffc4-49b0-a460-adb5a15d4e47"}%

Updating a book

To update a book, we would have to use the set method to update the book with the given identifier.

const update = async (id, book) => {
  const kv = await Deno.openKv();
  book.id = id;
  await kv.set(["books", id], book);
  return book;
};

Removing a book

To remove a book, we would have to use the delete method to remove the book with the given identifier. However, our earlier implementation of the delete function also returned the book that was removed. To do this, we would first have to retrieve the book and then remove it. This would work as follows.

const remove = async (id) => {
  const book = await readOne(id);
  const kv = await Deno.openKv();
  await kv.delete(["books", id]);
  return book;
};

With these changes in place, we could also update and remove a book. Updating the book would be done with the HTTP PUT method as follows.

curl -X PUT -d '{"title": "The Lord of the Rings", "year": 1954}' localhost:8000/books/23daf1fa-ffc4-49b0-a460-adb5a15d4e47
{"title":"The Lord of the Rings","year":1954,"id":"23daf1fa-ffc4-49b0-a460-adb5a15d4e47"}%

Similarly, removing a book would be done with the HTTP DELETE method.

curl -X DELETE localhost:8000/books/23daf1fa-ffc4-49b0-a460-adb5a15d4e47
{"title":"The Lord of the Rings","year":1954,"id":"23daf1fa-ffc4-49b0-a460-adb5a15d4e47"}%

Now, after the changes, the database would be empty, and asking for a list of books would return an empty list.

curl localhost:8000/books
[]%

Deno KV book repository

To summarize, the book repository that uses Deno KV would look as follows.

import postgres from "postgres";

const sql = postgres();

const create = async (book) => {
  const kv = await Deno.openKv();
  book.id = crypto.randomUUID();
  await kv.set(["books", book.id], book);
  return book;
};

const readAll = async () => {
  const kv = await Deno.openKv();
  const records = kv.list({ prefix: ["books"] });

  const result = [];
  for await (const record of records) {
    result.push(record.value);
  }

  return result;
};

const readOne = async (id) => {
  const kv = await Deno.openKv();
  const book = await kv.get(["books", id]);
  return book.value;
};

const update = async (id, book) => {
  const kv = await Deno.openKv();
  book.id = id;
  await kv.set(["books", id], book);
  return book;
};

const remove = async (id) => {
  const book = await readOne(id);
  const kv = await Deno.openKv();
  await kv.delete(["books", id]);
  return book;
};

export { create, readAll, readOne, remove, update };
Loading Exercise...