diff --git a/docs/recipes/LocalFirstDataWithPowerSync.md b/docs/recipes/LocalFirstDataWithPowerSync.md new file mode 100644 index 00000000..7899bd74 --- /dev/null +++ b/docs/recipes/LocalFirstDataWithPowerSync.md @@ -0,0 +1,2206 @@ +--- +title: PowerSync and Supabase for Local-First Data Management +description: Enhance your app with PowerSync and Supabase for efficient data synchronization between your app's local database and backend +tags: + - PowerSync + - React Native + - Backend + - State management + - Database + - Data Synchronization + - Offline Support +last_update: + author: Trevor Coleman +publish_date: 2024-03-22 +--- + +# PowerSync and Supabase for Local-First Data Management + +## Introduction + +This guide helps you integrate PowerSync with Supabase in an Ignite app for efficient local-first data management. + +[PowerSync](https://www.powersync.com/) allows your app to work smoothly offline while keeping the data in sync with your backend database. + +### What is PowerSync? + +PowerSync is a service which synchronizes local data with your Postgres SQL back end. It lets your app work with a local copy of the users' data and automatically syncs changes to and from your backend. + +Because your application interacts with the data in a local instance of SQLite, it means you'll always have fast, responsive access -- no fetching, no spinners. It also means +your users will have a seamless, consistent experience even if they are offline. + +In the background, PowerSync queues any changes and syncs the local data with the server whenever an internet connection becomes available. That means the data stays up to date across all of their devices. + +### Benefits of Using PowerSync + +* **Handles Intermittent Network Connectivity**: PowerSync allows your app to remain operational even in areas with + unreliable internet access. Users can continue their tasks without interruption, with automatic syncing when the + connection is restored. +* **Enables Offline Operation**: With PowerSync, your application can fully function offline, allowing users to access + and interact with their data regardless of their internet connection status. +* **Eliminates Loading Delays**: Leveraging local data minimizes the need for loading indicators, offering a smoother, + faster experience for the user. +* **Supports Multi-device Sync**: PowerSync ensures data consistency across all of a user's devices, enabling seamless + access and transition between different platforms. + By integrating PowerSync into your Ignite project, you provide a more reliable and user-friendly experience, ensuring + your application remains functional and responsive under various network conditions. + +### Using Other Backends + +While this recipe uses Supabase for the backend, PowerSync can connect to almost any Postgres SQL backend and the process will be largely identical for other types of Postgres backends. + +The major difference is that when the time comes, you will need to implement a `PowerSyncBackendConnector` for your database in place of the `SupabaseConnector`. + +Check the [PowerSync documentation](https://docs.powersync.com/) for more information on connecting your database to PowerSync. + +## Prerequisites + +To complete this recipe you'll need: + +1. **An Ignite app using `Expo CNG` or `Bare` workflow** + + PowerSync requires native modules, so you cannot use Expo Go + + You can create a new Ignite app using the Ignite CLI:: + ```bash + npx ignite-cli@latest new PowerSyncIgnite --remove-demo --workflow=cng --yes + ``` + +2. **A Supabase Project set up and connected to a PowerSync** + +- Follow the [PowerSync + Supabase Integration Guide](https://docs.powersync.com/integration-guides/supabase-+-powersync) to get this set up -- both PowerSync and Supabase have free tiers that you can use to get started. + +3. **Configure or Disable Supabase Email Verification** + +- By default, Supabase requires email verification for new users. This should be configured for any production apps. +- For the purposes of this recipe, you can disable this in the Supabase dashboard under: + - **Authentication** > **Providers** > **Email** > **Confirm Email** + +## Installing SDK and Dependencies + +### Install necessary dependencies for PowerSync. + +First install the PowerSync SDK and its dependencies. + +```shell +npx expo install \ + @journeyapps/powersync-sdk-react-native \ + @journeyapps/react-native-quick-sqlite +``` + +PowerSync [requires polyfills](https://github.com/powersync-ja/powersync-js/blob/main/packages/powersync-sdk-react-native/README.md#install-polyfills) +to replace browser-specific APIs with their React Native equivalents. These are listed as peer-dependencies so we need +to install them ourselves. + +```shell +npx expo install \ + react-native-fetch-api \ + react-native-polyfill-globals \ + react-native-url-polyfill \ + text-encoding \ + web-streams-polyfill@^3.2.1 \ + base-64 \ + @azure/core-asynciterator-polyfill \ + react-native-get-random-values +``` + +and install `@babel/plugin-transform-async-generator-functions` as a dev dependency: + +```shell +yarn add -D @babel/plugin-transform-async-generator-functions +``` + +:::note +At the time of writing the PowerSync SDK is not compatible with `web-streams-polyfill@4.0.0`, so be sure to specify version `^3.2.1`. +::: + +:::note +These dependencies include native modules so you'll need to rebuild your app after installing. +::: + +### Install necessary dependencies for Supabase + +First we need to install the Supabase SDK. + +```shell +npx expo install @supabase/supabase-js +``` + +and we'll also need to install `@react-native-async-storage/async-storage` for persisting the Supabase session. + +```shell +npx expo install @react-native-async-storage/async-storage +``` + +### Configuring Babel and Polyfills + +#### Import polyfills at App entry + +Ensure polyfills are imported in your app's entry file, typically `App.tsx`: + +```ts +import "react-native-polyfill-globals/auto" +import "@azure/core-asynciterator-polyfill" +``` + +:::tip +In a fresh Ignite app, this would be in `app/app.tsx`. and placed at the top of the list of imports, right after +the Reactotron config. +::: + +#### Add Babel Plugin + +Update `babel.config.js` to include the `transform-async-generator-functions` plugin: + +```js +/** @type {import('@babel/core').TransformOptions['plugins']} */ +const plugins = [ + //... other plugins + // success-line + '@babel/plugin-transform-async-generator-functions', // <-- Add this + /** NOTE: This must be last in the plugins @see https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation/#babel-plugin */ + "react-native-reanimated/plugin", + ] + +``` + +### Disable the Expo Dev Client Network Inspector + +The network inspector in the Expo Dev Client can interfere with PowerSync's network requests. To disable it, edit the project's `app.json` and in the `expo.plugins` find `expo-build-properties` and add `"networkInspector": false`: + +:::tip +Leave the rest of the config as is! Only add the `networkInspector` property. +::: + +```json +{ + "expo": { + // ... + "plugins": [ + //... + ["expo-build-properties", { + // ... + "android": { + // ... + // success-line + "networkInspector": false + } + }] + //... + ], + //... + } +} +``` + +## Authenticating with Supabase + +PowerSync requires a valid session token to connect to the Supabase backend, so we'll need to set up some basic authentication. + +### Add Supabase Config Variables to Your App Config + +First add your Supabase config to your app's configuration. In ignite apps, config is kept in `app/config/config.base.ts`. + +You'll need: + +- **supabaseUrl**: Found through your Supabase dashboard under: **Project Settings** > **API** > **Project URL**. +- **supabaseAnonKey**: Found through your Supabase dashboard under: **Project Settings** > **API** > **Project API + keys**. + +```ts +// `app/config/config.base.ts`: + +// update the interface to include the new properties +export interface ConfigBaseProps { + // Existing config properties + supabaseUrl: string + supabaseAnonKey: string +} + +// Add the new properties to the config object +const BaseConfig: ConfigBaseProps = { + // Existing config values + supabaseUrl: '<>', + supabaseAnonKey: '<>', +} +``` + +:::tip +If you have different configurations for different environments, you can add these properties to `config.dev.ts` and `config.prod.ts` as needed. +::: + +### Initializing the Supabase Client + +Create `app/services/database/supabase.ts` and add the following code to initialize the Supabase client: + +```ts +// app/services/database/supabase.ts +import AsyncStorage from '@react-native-async-storage/async-storage' +import { createClient } from "@supabase/supabase-js" +import { Config } from '../../config' + +export const supabase = createClient(Config.supabaseUrl, Config.supabaseAnonKey, { + auth: { + persistSession: true, storage: AsyncStorage, + }, +}) +``` + +:::info Persisting the Supabase Session +Unlike web environments where `localStorage` is available, in React Native Supabase requires us to provide a key-value store to hold the session token. + +We're using `AsyncStorage` here for simplicity, but for encrypted storage, supabase provides an example of encrypting the session token using `expo-secure-storage` in their [React Native Auth example](https://supabase.com/docs/guides/getting-started/tutorials/with-expo-react-native?auth-store=secure-store#initialize-a-react-native-app) +::: + +### Authenticating with Supabase + +PowerSync needs a valid session token to connect to the Supabase, so we'll need a hook and context to manage our authentication state. + +This is a basic example that uses a context to manage the authentication state and a hook to access that state in your components. + +Add the following code to `app/services/database/use-auth.tsx`: + +```tsx +// app/services/database/use-auth.tsx +import { User } from "@supabase/supabase-js" +import { supabase } from "app/services/database/supabase" +import React, { createContext, PropsWithChildren, useCallback, useContext, useMemo, useState } from "react" + +type AuthContextType = { + signIn: (email: string, password: string) => void + signUp: (email: string, password: string) => void + signOut: () => void + signedIn: boolean + loading: boolean + error: string + user: User | null +} + +// We initialize the context with null to ensure that it is not used outside of the provider +const AuthContext = createContext(null) + +/** + * AuthProvider manages the authentication state and provides the necessary methods to sign in, sign up and sign out. + */ +export const AuthProvider = ({ children }: PropsWithChildren) => { + const [signedIn, setSignedIn] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const [user, setUser] = useState(null) + + + // Sign in with provided email and password + const signIn = useCallback(async (email: string, password: string) => { + setLoading(true) + setError("") + setUser(null) + try { + const { data: { session, user }, error } = await supabase.auth.signInWithPassword({ email, password }) + if (error) { + setSignedIn(false) + setError(error.message) + } else if (session && user) { + setSignedIn(true) + setUser(user) + } + } catch (error: any) { + setError(error?.message ?? "Unknown error") + } finally { + setLoading(false) + } + }, [ + setSignedIn, setLoading, setError, setUser, supabase + ]) + + // Create a new account with provided email and password + const signUp = useCallback(async (email: string, password: string) => { + setLoading(true) + setError("") + setUser(null) + try { + const { data, error } = await supabase.auth.signUp({ email, password }) + if (error) { + setSignedIn(false) + setError(error.message) + } else if (data.session) { + await supabase.auth.setSession(data.session) + setSignedIn(true) + setUser(data.user) + } + } catch (error: any) { + setUser(null) + setSignedIn(false) + setError(error?.message ?? "Unknown error") + } finally { + setLoading(false) + } + }, [ + setSignedIn, setLoading, setError, setUser, supabase + ]) + + // Sign out the current user + const signOut = useCallback(async () => { + setLoading(true) + await supabase.auth.signOut() + setError("") + setSignedIn(false) + setLoading(false) + setUser(null) + }, [ + setSignedIn, setLoading, setError, setUser, supabase + ]) + + // Always memoize context values as they can cause unnecessary re-renders if they aren't stable! + const value = useMemo(() => ({ + signIn, signOut, signUp, signedIn, loading, error, user + }), [ + signIn, signOut, signUp, signedIn, loading, error, user + ]) + return { children } +} + +export const useAuth = () => { + const context = useContext(AuthContext) + + // It's a good idea to throw an error if the context is null, as it means the hook is being used outside of the provider + if (context === null) { + throw new Error('useAuthContext must be used within a AuthProvider') + } + return context +} + +``` + +:::tip +For more information on setting up authentication with Supabase (including setting up for OAuth providers like Github, Google and Facebook), refer to the [Supabase Auth documentation](https://supabase.com/docs/guides/auth). +::: + +### Providing Auth State to Your Application + +Wrap your app with the `AuthProvider` to provide the authentication state to your app: + +```tsx +// app/app.tsx +// ...other imports +// success-line +import { AuthProvider } from "app/services/database/use-auth" + +// ... +function App(props: AppProps) { + // ... + return ( + // success-line + + + {/* ... */ } + + // success-line + + ) +} + + +``` + +### Create a Sign-In Screen + +First use the Ignite CLI to generate a new screen for signing in: + +```shell +npx ignite-cli generate screen Auth +``` + +:::note +This will: + +* create a new screen in `app/screens/AuthScreen.tsx`, +* add that screen to the `AppNavigator` in `app/navigators/AppNavigator.tsx`, and +* update the `Params` and `ScreenProps` types + ::: + +Then open `app/screens/AuthScreen.tsx` and update the `AuthScreen` component to use the `useAuth` hook to sign in. + +When the user signs in successfully, the app will automatically navigate to the `Welcome` screen. + +```tsx +// app/screens/AuthScreen.tsx +import { AppStackScreenProps } from "app/navigators" +import { Button, Screen, Text, TextField } from "app/components" +import { useAuth } from "app/services/database/use-auth" +import React, { useEffect, useState } from "react" +import { ActivityIndicator, Modal, TextStyle, View, ViewStyle } from "react-native" +import { colors, spacing } from "../theme" + +interface AuthScreenProps extends AppStackScreenProps<"Auth"> {} + +export const AuthScreen: React.FC = ({ navigation }) => { + const { signUp, signIn, loading, error, user } = useAuth() + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + + const handleSignIn = async () => { + signIn(email, password) + } + + const handleSignUp = async () => { + signUp(email, password) + } + + useEffect(() => { + if (user) { + navigation.navigate("Welcome") + } + }, [user]) + + return ( + + PowerSync + Supabase + Sign in or Create Account + + + + +