Sign Up

Sign Up to our social questions and Answers Engine to ask questions, answer people’s questions, and connect with other people.

Have an account? Sign In

Have an account? Sign In Now

Sign In

Login to our social questions & Answers Engine to ask questions answer people’s questions & connect with other people.

Sign Up Here

Forgot Password?

Don't have account, Sign Up Here

Forgot Password

Lost your password? Please enter your email address. You will receive a link and will create a new password via email.

Have an account? Sign In Now

Sorry, you do not have permission to ask a question, You must login to ask a question.

Forgot Password?

Need An Account, Sign Up Here

Please type your username.

Please type your E-Mail.

Please choose an appropriate title for the post.

Please choose the appropriate section so your post can be easily searched.

Please choose suitable Keywords Ex: post, video.

Browse

Need An Account, Sign Up Here

Please briefly explain why you feel this question should be reported.

Please briefly explain why you feel this answer should be reported.

Please briefly explain why you feel this user should be reported.

Sign InSign Up

Querify Question Shop: Explore Expert Solutions and Unique Q&A Merchandise

Querify Question Shop: Explore Expert Solutions and Unique Q&A Merchandise Logo Querify Question Shop: Explore Expert Solutions and Unique Q&A Merchandise Logo

Querify Question Shop: Explore Expert Solutions and Unique Q&A Merchandise Navigation

  • Home
  • About Us
  • Contact Us
Search
Ask A Question

Mobile menu

Close
Ask a Question
  • Home
  • About Us
  • Contact Us
Home/ Questions/Q 3321

Querify Question Shop: Explore Expert Solutions and Unique Q&A Merchandise Latest Questions

Author
  • 61k
Author
Asked: November 26, 20242024-11-26T01:39:08+00:00 2024-11-26T01:39:08+00:00

Simple KV storage on top of indexedDB

  • 61k

If your client-side application ever needs to persist a larger portion of data, it's no longer suitable to put it inside a localStorage entry. The first thing that comes into mind is to use indexedDB. But then you have to manage transactions, versioning, etc.

Sometimes all you need may be a straightforward key-value store, that hides those intricacies inside, like:

const kv = await openKV("kv");  await kv.set(key, value);  const data = await kv.get(key); 
Enter fullscreen mode Exit fullscreen mode

What is stopping you from building one?
Let's get to work!

Opening up

Working with indexedDB API is not especially pleasant. It's taken straight from the primal age of JavaScript. If you're looking for a more civilized API to digest – check out idb – it enhances the indexedDB API with promises and shortcuts for common operations

But in this post, we're not afraid of the tears and pain of the past

Opening up (the database)

First, we need to open a new connection request. Then attach handlers for success and error events. Everything is enclosed within a compact Promise:

const STORE_NAME = "store"; const openKVDatabase = (dbName: string) =>   new Promise<IDBDatabase>((resolve, reject) => {     const request = indexedDB.open(dbName);      request.onsuccess = () => {       resolve(request.result);     };     request.onerror = () => {       reject("indexedDB request error");     };     request.onupgradeneeded = () => {       request.result.createObjectStore(STORE_NAME, { keyPath: "key" });     };   }); 
Enter fullscreen mode Exit fullscreen mode

upgradeneeded event will be fired once the database is created (or when its version gets updated). Inside this handler, we can create our one and only store – the KV store. I've put STORE_NAME in a constant as we'll need to use it in multiple places later on

First blood methods

Let's scaffold a basic shape for get, set, and delete methods. They will correspond to indexDB objectStore operations consequently: get, put, and delete

export async function openKV<T = unknown>(dbName: string) {   const db = await openKVDatabase(dbName);    const openStore = () => {     return db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME);   };    return {     async get(key: string) {},      async set(key: string, value: T) {},      async delete(key: string) {},   }; } 
Enter fullscreen mode Exit fullscreen mode

openStore helper function opens up a new transaction and returns the handle for our KV store

Requests as promised

One more thing needs to be done before implementing the methods. objectStore methods return IDBRequest object. This object achieves the same goal as a promise (it's like a goofy version of it). Let's create a utility that will map them into promises – so we can await them:

function idbRequestToPromise<T>(request: IDBRequest<T>) {   return new Promise<T>((resolve, reject) => {     request.onsuccess = () => resolve(request.result);     request.onerror = () => reject(request.error);   }); } 
Enter fullscreen mode Exit fullscreen mode

Methods

  async get(key: string): Promise<T | undefined> {       const pair: Pair | undefined = await idbRequestToPromise(         openStore().get(key)       );        return pair?.value as T | undefined;     },      async set(key: string, value: T) {       const pair: Pair = { key, value };       await idbRequestToPromise(openStore().put(pair));     },      delete(key: string) {       return idbRequestToPromise(openStore().delete(key));     }, 
Enter fullscreen mode Exit fullscreen mode

The Pair type used here is just:

type Pair<T = unknown> = { key: string; value: T }; 
Enter fullscreen mode Exit fullscreen mode

You got to pump it up

As you probably noticed opening a new transaction every time we perform a single key value operation is suboptimal. Consider this snippet:

for (const item of arr) {     kv.set(item.id, item); } 
Enter fullscreen mode Exit fullscreen mode

To handle an array of 1000 items, we need to open 1000 transactions. If the operations are executed in a single task (triggered synchronously) as in the example above, grouping them into a single transaction (aka batching) could improve efficiency. Let's verify if this assumption holds true

Batching

To implement batching, we need to update the openStore function a little bit

const db = await openKVDatabase(dbName); // Create 'store' variable to share it between calls let store: IDBObjectStore | null;  const openStore = () => {   if (!store) {     store = db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME);      queueMicrotask(() => {       // Finish the transaction after the current task ends       store?.transaction?.commit();       store = null;     });   }    return store; }; 
Enter fullscreen mode Exit fullscreen mode

queueMicrotask allows running code after the current task has been executed (microtasks are run between regular tasks). Learn more here.

Testing

I used tinybench to prepare a basic test case like so:

Promise.all(arr.map((v) => kv.set(v, v))); 
Enter fullscreen mode Exit fullscreen mode

Where arr is a 1000-element array of strings

Results

Unsurprisingly there is a small improvement over the 1000 transaction version:

| 1000 transactions | batching     | | ----------------- | ------------ | | 7 (ops/sec)       | 32 (ops/sec) | 
Enter fullscreen mode Exit fullscreen mode

Transactions

Okay, so when I run queries synchronously, they will be put into a single transaction. But what about the original reason for inventing database transactions? It was to group queries together into one to ensure consistency. Check out this code:

async function inc() {     await kv.set("x", (await kv.get("x")) + 1); } 
Enter fullscreen mode Exit fullscreen mode

It would only make sense if both set and get operations would form a single transaction

Async / await tracking

Unfortunately, APIs like AsyncLocalStorage that are available in server runtimes including Node.js, Deno, and Bun, that would allow us to track async context are not (yet) available in the browsers. However, we can hook into async / await by leveraging custom Thenables and microtasks scheduling…

If you are interested in learning more about tracking asynchronous contexts, you can check out proposal-async-context – the official ECMAScript proposal that addresses this particular issue

…and then

Userland Thenables can be awaited just like Promises. The key difference if it comes to Thenables is that “then” method is always executed when used in async / await code. This allows us to intercept the async execution of the code and inject hooks before and after the continuation of the async / await block. Here's my attempt at doing that:

export class Thenable<T> {    constructor(     private promise: Promise<T>,     private hooks?: {       before?: () => void;       after?: () => void;     }   ) {}    then<U>(onFulfilled: (value: T) => U): Thenable<U> {     return new Thenable(       this.promise.then((value) => {         this.hooks?.before?.();         const result = onFulfilled(value);         queueMicrotask(() => this.hooks?.after?.());         return result;       }),       this.hooks     );   } } 
Enter fullscreen mode Exit fullscreen mode

Notice how after hook is pushed into the microtask queue. It's because calling onFulfilled will push continuation to the queue itself – this way after hook is called after the continuation microtask

Sharing current transaction

Taking up before and after hooks, we can now make the current transaction accessible from within adjacent queries. Here's the type of the transaction object that will be shared:

type Transaction = {   store: IDBObjectStore;   committed?: boolean;   lastQueried?: boolean; }; 
Enter fullscreen mode Exit fullscreen mode

commited and lastQueried flags will be used to implement auto-committing of the transaction. All queries will now be wrapped in a query function to handle sharing.

  const query = <R>(     fn: (transaction: Transaction<T>) => Promise<R>   ): Thenable<R> => {     const transaction = (currentTransaction ??= {       store: db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME),     });      // Clear current transaction after current task     queueMicrotask(() => {       currentTransaction = null;     });      const result = fn(transaction);      return new Thenable(result, {       before() {         // Resume transaction before the continuation         currentTransaction = transaction;       },       after() {         currentTransaction = null;       },      });    }; 
Enter fullscreen mode Exit fullscreen mode

And the example of usage:

    set(key: string, value: T) {       // Wrap handler with query       return query(async ({ store }) => {         const pair = { key, value };         await idbRequestToPromise(store.put(pair));       });      }, 
Enter fullscreen mode Exit fullscreen mode

Auto-commiting

After the series of queries, it would be great to handle commiting automatically. The lastQueried flag will indicate if the queries were executed last microtasks:

  const query = <R>(     fn: (transaction: Transaction<T>) => Promise<R>   ): Thenable<R> => {     const transaction: Transaction<T> = (currentTransaction ??= {       store: db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME),     });       // Running `query` will reset the flag     transaction.lastQueried = true;      queueMicrotask(() => {       currentTransaction = null;     });      const result = fn(transaction);      return new Thenable(result, {        before() {         currentTransaction = transaction;         transaction.lastQueried = false;       },        after() {         // If there were no new queries during the last microtask         if (!transaction.lastQueried && !transaction.committed) {           transaction.store.transaction.commit();           transaction.committed = true;         }         currentTransaction = null;       },     });   }; 
Enter fullscreen mode Exit fullscreen mode

Admiring the results

Look at that and think:

async function inc() {     await kv.set("x", (await kv.get("x")) + 1); } 
Enter fullscreen mode Exit fullscreen mode

The function above now forms a single ACID transaction!

Summing Up

The indexedDB is not the easiest API to work with. It's not the fastest horse in the stable either. It's probably a good idea to use some of the popular wrapper libraries like idb or Dexie.js. That will simplify and streamline the process of working with it. There is also idbkeyval – super-simple key-value store (but without automatic batching and transactions 🙊). Still, implementing your own wrapper may be great fun and will definitely help you understand better how it works

javascriptprogrammingtypescriptwebdev
  • 0 0 Answers
  • 0 Views
  • 0 Followers
  • 0
Share
  • Facebook
  • Report

Leave an answer
Cancel reply

You must login to add an answer.

Forgot Password?

Need An Account, Sign Up Here

Sidebar

Ask A Question

Stats

  • Questions 4k
  • Answers 0
  • Best Answers 0
  • Users 2k
  • Popular
  • Answers
  • Author

    ES6 - A beginners guide - Template Literals

    • 0 Answers
  • Author

    Understanding Higher Order Functions in JavaScript.

    • 0 Answers
  • Author

    Build a custom video chat app with Daily and Vue.js

    • 0 Answers

Top Members

Samantha Carter

Samantha Carter

  • 0 Questions
  • 20 Points
Begginer
Ella Lewis

Ella Lewis

  • 0 Questions
  • 20 Points
Begginer
Isaac Anderson

Isaac Anderson

  • 0 Questions
  • 20 Points
Begginer

Explore

  • Home
  • Add group
  • Groups page
  • Communities
  • Questions
    • New Questions
    • Trending Questions
    • Must read Questions
    • Hot Questions
  • Polls
  • Tags
  • Badges
  • Users
  • Help

Footer

Querify Question Shop: Explore Expert Solutions and Unique Q&A Merchandise

Querify Question Shop: Explore, ask, and connect. Join our vibrant Q&A community today!

About Us

  • About Us
  • Contact Us
  • All Users

Legal Stuff

  • Terms of Use
  • Privacy Policy
  • Cookie Policy

Help

  • Knowledge Base
  • Support

Follow

© 2022 Querify Question. All Rights Reserved

Insert/edit link

Enter the destination URL

Or link to existing content

    No search term specified. Showing recent items. Search or use up and down arrow keys to select an item.