How to Set Up Watermelon DB as a Storage Provider for Supabase in React Native

Customize storage for Supabase Auth sessions with Watermelon DB in React Native Expo.

Introduction

Building a local-first app in React Native can be a rewarding yet challenging experience, especially when it comes to managing data storage efficiently. In my latest side project, I aimed to integrate Supabase as the backend while leveraging Watermelon DB for local storage. During the setup, I discovered that Supabase's createClient() function requires an AsyncStorage instance for user session management. However, I was determined to avoid adding another storage library to my project. This led me to find a custom solution to integrate Watermelon DB as the storage provider for Supabase auth. In this blog, I'll walk you through the steps to achieve this setup, ensuring a smooth and efficient integration of Watermelon DB as an auth storage option in your React Native app.

Prerequisite

Before proceeding, ensure you have a React Native project set up with Supabase and your chosen authentication provider already configured. If you haven’t done this yet, you can refer to the documentation or setup guides provided by Supabase.

Since we’re using Expo for this project, the steps in this blog will be tailored to Expo’s environment. This includes managing dependencies, configuring plugins, and using Expo’s APIs for seamless integration.

Installing Watermelon DB Dependencies

Installing dependencies

To begin, include the necessary dependencies for Watermelon DB in your project

yarn add @nozbe/watermelondb
yarn add --dev @babel/plugin-proposal-decorators
# npm:
npm install @nozbe/watermelondb
npm install -D @babel/plugin-proposal-decorators

Next, install expo-build-properties. This is a config plugin that allows you to customize native build properties during prebuild. We will need this for the iOS Pod setup.

npx expo install expo-build-properties

React Native setup

  1. Add ES6 decorators support to your .babelrc file

     {
       "presets": ["module:metro-react-native-babel-preset"],
       "plugins": [["@babel/plugin-proposal-decorators", { "legacy": true }]]
     }
    
  2. iOS (Expo React Native)

    1. For iOS, you need to integrate additional CocoaPods like simdjson for specific functionalities, you can configure them in your app.json file under the "plugins" section as below:

       {
         "expo": {
           ....
           ...
           ..
           .
           "plugins": [
             ...
             ..
             [
               "expo-build-properties",
               {
                 "ios": {
                   "extraPods": [
                     {
                       "name": "simdjson",
                       "configurations": ["Debug", "Release"],
                       "path": "../node_modules/@nozbe/simdjson",
                       "modular_headers": true
                     }
                   ]
                 }
               }
             ]
           ]
         }
       }
      

      For detailed setup instructions, you can follow the official documentation for iOS setup.

  3. Android (Expo React Native)

    1. As per the official documentation, we don't need any extra setup for Android other than the Babel configuration. By default, React Native uses autolinking. However, if you are using an older version of React Native or have opted out of autolinking, you can follow the official documentation for Android setup.
  4. To build the native side of the code locally for Watermelon DB, use the following commands and run the app:

     # android
     npx expo run android
    
     # ios
     npx expo run ios
    

Setting Up Watermelon DB

Start by creating a folder named DB. Inside this folder, you will write all your WatermelonDB-related code.

Once the folder is created, let's start by defining Models. Watermelon DB uses SQLite as its default database engine, which underpins the app's local data storage. Defining models involves specifying the structure of your data in terms of tables and columns, mirroring traditional database schemas.

  1. Inside the DB folder, create a file called schema.ts . We need a model called AuthSession for our use case, which will store session data returned by Supabase once the user is authenticated.

  2. In schema.ts, define a model like this:

     import { appSchema, tableSchema } from "@nozbe/watermelondb";
    
     export default appSchema({
       version: 1,
       tables: [
         tableSchema({
           name: "auth_session",
           columns: [{ name: "session", type: "string" }],
         }),
       ],
     });
    

    What we're aiming to do here is to replicate the same pattern as if we were using AsyncStorage, where you would typically save data with AsyncStorage.setItem('my-key', value). In our setup with Watermelon DB, the sessioncolumn will store the value, and we'll use the default row ID to represent the key. By overriding the default row ID with our key, we can achieve similar key-value storage functionality within Watermelon DB. This will become super clear in a later section!

  3. After defining the schema, the next step is to define the model. Models represent tables in the database and define the structure of the data. Each model corresponds to a specific table, and the fields within a model correspond to the columns in that table. A Model class represents a type for an app. Create a models folder inside the DB folder and add a file named AuthSession.ts.

     import { Model } from "@nozbe/watermelondb";
     import { text } from "@nozbe/watermelondb/decorators";
    
     export default class AuthSession extends Model {
       static table = "auth_session";
    
       @text("session") session: string;
     }
    

    The AuthSession class extends the Model class from Watermelon DB. The static table property defines the name of the table (authsessions), which matches the table name defined in the schema. The @text("session") decorator specifies that the model has a field named session, which is a text property to store the session data.

  4. Next, create a migrations file. This file is typically used to handle changes in your database schema over time. However, for the purposes of this blog, we won't be delving into migrations. Even though we won't be using migrations in this blog, it's good practice to include a migrations file in your project to ensure your database schema can evolve smoothly as your app grows. Create a file named migrations.ts in DB folder and add the following code:

     import { schemaMigrations } from "@nozbe/watermelondb/Schema/migrations";
    
     export default schemaMigrations({
       migrations: [
         // add migration definitions here later
       ],
     });
    
  5. The final step in setting up Watermelon DB is to create the adapter and initialize the database instance. This setup will allow your app to interact with the database using the defined schema and models. In DB folder create a file index.ts and define the SQLiteAdapter and create an instance of the database using the adapter and models.

     import { Database } from "@nozbe/watermelondb";
     import SQLiteAdapter from "@nozbe/watermelondb/adapters/sqlite";
    
     import schema from "./schema";
     import migrations from "./migrations";
     import AuthSession from "./model/AuthSession";
    
     const adapter = new SQLiteAdapter({
       schema,
       migrations,
    
       jsi: true,
       onSetUpError: (error) => {
         // Database failed to load -- offer the user to reload the app or log out
         console.log("DATABASE SETUP ERROR", error);
       },
     });
    
     const database = new Database({
       adapter,
       modelClasses: [AuthSession],
     });
    
     export default database;
    

Integrating Custom Storage for Supabase Auth

Supabase's createClient method, the auth object requires a storage property for session management. By default, the official documentation suggests using AsyncStorage for this purpose. However, in this blog, we'll create a custom storage class that matches the type of storage required, using Watermelon DB.

The storage property needs to be an object that supports certain methods, such as getItem, setItem, and removeItem. We'll create a custom class to handle these methods using Watermelon DB.

To integrate a custom storage solution with Supabase in your React Native application, follow these steps to create and configure the SupabaseClientStorage singleton class.

  1. Create a new TypeScript file named SupabaseClientStorage.ts in your project's directory.

  2. Inside SupabaseClientStorage.ts, define the SupabaseClientStorage singleton class. This class will implement the methods required by SupportedStorage, a type alias for asynchronous session data operations.

     import { Database } from "@nozbe/watermelondb";
     import database from "../DB";
    
     /**
      * AnyFunction, MaybePromisify, SupportedStorage type taken from node_modules/@supabase/auth-js/src/lib/types.ts for reference 
      */
    
     type AnyFunction = (...args: any[]) => any;
     type MaybePromisify<T> = T | Promise<T>;
    
     type PromisifyMethods<T> = {
       [K in keyof T]: T[K] extends AnyFunction
         ? (...args: Parameters<T[K]>) => MaybePromisify<ReturnType<T[K]>>
         : T[K];
     };
    
     type SupportedStorage = PromisifyMethods<
       Pick<Storage, "getItem" | "setItem" | "removeItem">
     > & {
       isServer?: boolean;
     };
    
     class SupabaseClientStorage implements SupportedStorage {
       private static instance: SupabaseClientStorage | null = null; // Singleton instance of SupabaseClientStorage class
       private db: Database; // Instance of your chosen database (e.g., WatermelonDB)
       public isServer?: boolean; // Flag indicating server-side environment (optional)
    
       private constructor() {
         this.db = database;
         this.isServer = false;
       }
       // Singleton pattern to ensure only one instance exists
       public static getInstance(): SupabaseClientStorage {
         if (!SupabaseClientStorage.instance) {
           SupabaseClientStorage.instance = new SupabaseClientStorage();
         }
         return SupabaseClientStorage.instance;
       }
     }
    
     const clientAuthStorageInstance = SupabaseClientStorage.getInstance();
    
     export default clientAuthStorageInstance;
    

    Implements SupportedStorage as a singleton class, ensuring there's only one instance (getInstance() method). It initializes a database instance (db) with watermelondb instance created previously (DB/index.ts) and manages a flag (isServer) to indicate the environment. In our case, we are keeping isServer set to false.

    AnyFunction
    AnyFunction represents any function type in JavaScript that can handle various argument types and return values. It's a flexible type definition crucial for handling different operations within our application.
    MaybePromisify<T>
    MaybePromisify<T> is a utility type that allows a value of type T or a Promise resolving to type T. This is particularly useful when we want to handle asynchronous operations in a consistent manner.
    PromisifyMethods<T>
    PromisifyMethods<T> transforms methods within an object T to be asynchronous if they're functions. This ensures uniformity in how our methods interact with data, especially when dealing with asynchronous operations like fetching or updating data.
  3. Next, we'll proceed to implement the async methods (getItem, setItem, removeItem) within the Storage class. These methods will use our database instance (db) so that Supabase createClient() can effectively manage session data in our React Native application.

    1. setItem

        import AuthSession from "../DB/model/AuthSession";
      
        async setItem(key: string, value: string): Promise<void> {
           await this.db.write(async () => {
             await this.db
               .get<AuthSession>("auth_session")
               .create((record) => {
                 record._raw.id = key; // set key as row id this will help in get and remove item with passed key argument by supabase
                 record.session = value; // set value to session field
               })
               .catch((error) => {});
           });
        }
      

      Here, Supabase internally converts this value from an object to a string before passing it to your storage setItem function. This is why we added the session field as a string in the auth_session table.

    2. getItem

         getItem(key: string): MaybePromisify<string | null> {
           // from auth_session table find collection for passed key
           return this.db
             .get<AuthSession>("auth_session")
             .find(key)
             .then((result) => {
               // just return value of session
               return result.session;
             })
             .catch(() => null);
         }
      
    3. removeItem

       async removeItem(key: string): Promise<void> {
           try {
             // find a collection with key
             const session = await this.db.get<AuthSession>("auth_session").find(key);
             if (session) {
               await this.db.write(async () => {
                 // delete that collection if exist
                 await session.destroyPermanently();
               });
             }
           } catch (error) {}
         }
      

With the implementation of these three functions, our storage class for storing auth sessions is complete.

  1. Integrate SupabaseClientStorage with Supabase in your application's client configuration to manage sessions:

     import "react-native-url-polyfill/auto";
    
     import { createClient } from "@supabase/supabase-js";
     import clientAuthStorageInstance from "./ClientAuthStorage";
    
     const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL || "";
     const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || "";
    
     export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
       auth: {
         storage: clientAuthStorageInstance, // Use the singleton instance of SupabaseClientStorage 
         autoRefreshToken: true,
         persistSession: true,
         detectSessionInUrl: false,
       },
     });
    

Once this setup is in place with AuthSession persistently stored and managed locally, users will experience seamless authentication across app sessions, even after closing or reloading the app.

Bonus Content: Encrypting Stored Values

Understanding the Need for Encryption

To illustrate the importance of encryption, let’s examine how plain text values are stored in your .db file. Navigate to the documents directory where your database file is stored (Documents on iOS or equivalent on Android). You can also see this path in the terminal.

# Example path (iOS Simulator):
/Users/username/Library/Developer/CoreSimulator/Devices/device_id/data/Containers/Data/Application/app_id/Documents/

Once you find the file watermelon.db, you can open it with sqliteviewer. You'll notice that without encryption, sensitive data like session tokens or user credentials are stored in plain text.

The primary risk to local storage security arises if an unauthorized individual gains physical access to the device. In such cases, they could potentially extract sensitive data stored in plain text without encryption. Encrypting sensitive data before storing it in your .db file ensures that even if the file is accessed, the data remains unreadable without the decryption key.

Implementing Encryption for Added Security

To begin, we'll define methods within your SupabaseClientStorage class that handle encryption and decryption operations. These methods will transform sensitive data into a secure format before storing it locally and decrypt it when retrieving it for use in your application.

Install dependencies

npx expo install expo-crypto  expo-secure-store
npm install aes-js
  • expo-crypto: Provides cryptographic APIs for generating secure hashes and handling encryption.

  • expo-secure-store: Securely stores sensitive information on the device using platform-specific encryption.

  • aes-js: A JavaScript library for AES (Advanced Encryption Standard), a widely used encryption algorithm for securing data.

Implementing Encryption and Decryption Methods

We will be using CTR (short for counter) AES block cipher mode encryption.

_encrypt : This function securely stores the encryption key as a hex string in SecureStore. The value is encrypted using a strong AES algorithm and returned as a hex string to store in the database.

private async _encrypt(key: string, value: string) {
    // 128-bit key
    const encryptionKey = Crypto.getRandomValues(new Uint8Array(16));

    // convert the value to bytes (UTF-8 to Uint8Array.)
    const valueBytes = aesjs.utils.utf8.toBytes(value);
    // counter CTR
    const aesCtr = new aesjs.ModeOfOperation.ctr(encryptionKey);

    // converting encryption key to hex string and storing in secure store
    await SecureStore.setItemAsync(
      key,
      aesjs.utils.hex.fromBytes(encryptionKey)
    );

    // encrypt the value bytes
    const encryptedBytes = aesCtr.encrypt(valueBytes);
    // convert encrypted bytes to hex string
    const encryptedValue = aesjs.utils.hex.fromBytes(encryptedBytes);
    return encryptedValue;
  }

_decrypt: This function retrieves the encryption key for the given key. If it exists, it will be converted to bytes and used to decrypt the data from a hex string back to the original plain text string.

  private async _decrypt(key: string, value: string) {
    // retrive hex key from secure store
    const encryptionKey = await SecureStore.getItemAsync(key);
    if (!encryptionKey) {
      return null;
    }
    const encryptedKeyInBytes = aesjs.utils.hex.toBytes(encryptionKey);
    // counter CTR
    const aesCtr = new aesjs.ModeOfOperation.ctr(encryptedKeyInBytes);
    const decryptedBytes = aesCtr.decrypt(aesjs.utils.hex.toBytes(value));

    // Convert our bytes back into text
    var decryptedValue = aesjs.utils.utf8.fromBytes(decryptedBytes);
    return decryptedValue;
  }

We can use these two functions in our getItem, setItem, and removeItem methods to securely store session data in the local database. The final code will look like this:

  getItem(key: string): MaybePromisify<string | null> {
    return this.db
      .get<AuthSession>("auth_session")
      .find(key)
      .then(async (result) => {
        const decryptedValue = await this._decrypt(key, result.session);
        return decryptedValue;
      })
      .catch(() => null);
  }

  async setItem(key: string, value: string): Promise<void> {
    const encryptedValue = await this._encrypt(key, value);
    await this.db.write(async () => {
      await this.db
        .get<AuthSession>("auth_session")
        .create((record) => {
          record._raw.id = key;
          record.session = encryptedValue;
        })
        .catch((error) => {});
    });
  }

  async removeItem(key: string): Promise<void> {
    try {
      const session = await this.db.get<AuthSession>("auth_session").find(key);
      if (session) {
        await this.db.write(async () => {
          await session.destroyPermanently();
          await SecureStore.deleteItemAsync(key);
        });
      }
    } catch (error) {}
  }

Encrypted data in local DB

Conclusion

In conclusion, by following the steps in this guide, you can easily set up Watermelon DB, define models and schemas, and create a custom storage class to handle Supabase authentication sessions. This method avoids the need for extra storage libraries and uses the powerful features of Watermelon DB, ensuring smooth and efficient data management in your local-first React Native applications.

Additionally, we delved into enhancing data security by implementing AES encryption with expo-crypto and aes-js, ensuring sensitive data remains protected on the user's device.