How to Set Up Watermelon DB as a Storage Provider for Supabase in React Native
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
Add ES6 decorators support to your
.babelrc
file{ "presets": ["module:metro-react-native-babel-preset"], "plugins": [["@babel/plugin-proposal-decorators", { "legacy": true }]] }
iOS (Expo React Native)
For iOS, you need to integrate additional CocoaPods like
simdjson
for specific functionalities, you can configure them in yourapp.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.
Android (Expo React Native)
- 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.
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.
Inside the
DB
folder, create a file calledschema.ts
. We need a model called AuthSession for our use case, which will store session data returned by Supabase once the user is authenticated.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, thesession
column 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!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 amodels
folder inside theDB
folder and add a file namedAuthSession.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.
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 DBfolder
and add the following code:import { schemaMigrations } from "@nozbe/watermelondb/Schema/migrations"; export default schemaMigrations({ migrations: [ // add migration definitions here later ], });
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 fileindex.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.
Create a new TypeScript file named
SupabaseClientStorage.ts
in your project's directory.Inside
SupabaseClientStorage.ts
, define theSupabaseClientStorage
singleton class. This class will implement the methods required bySupportedStorage
, 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 typeT
or aPromise
resolving to typeT
. This is particularly useful when we want to handle asynchronous operations in a consistent manner.PromisifyMethods<T>
PromisifyMethods<T>
transforms methods within an objectT
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.Next, we'll proceed to implement the async methods (
getItem
,setItem
,removeItem
) within theStorage
class. These methods will use our database instance (db
) so that SupabasecreateClient()
can effectively manage session data in our React Native application.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 thesession
field as a string in theauth_session
table.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); }
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.
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.
Subscribe to my newsletter
Read articles from AKSHAY JADHAV directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
AKSHAY JADHAV
AKSHAY JADHAV
Software Engineer