Sync

Supabase plugin

Supabase and Legend-State work very well together - all you need to do is provide a typed client and the observables will be fully typed and handle calling the correct action functions for you.

Full Example

We'll start with a full example to see what a full setup looks like, then go into specific details.

import { createClient } from '@supabase/supabase-js'
import { Database } from './database.types'
import { observable } from '@legendapp/state'
import { configureSyncedSupabase, syncedSupabase } from '@legendapp/state/sync-plugins/supabase'
import { v4 as uuidv4 } from "uuid"

const supabase = createClient<Database>(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY)

// provide a function to generate ids locally
const generateId = () => uuidv4()
configureSyncedSupabase({
    generateId
})
const uid = ''

const messages$ = observable(syncedSupabase({
    supabase,
    collection: 'messages',
    // Optional:
    // Select only id and text fields
    select: (from) => from.select('id,text'),
    // Filter by the current user
    filter: (select) => select.eq('user_id', uid),
    // Don't allow delete
    actions: ['read', 'create', 'update'],
    // Realtime filter by user_id
    realtime: { filter: `user_id=eq.${uid}` },
    // Persist data and pending changes locally
    persist: { name: 'messages', retrySync: true },
    // Sync only diffs
    changesSince: 'last-sync'
}))

// get() activates and starts syncing
const messages = messages$.get()

function addMessage(text: string) {
    const id = generateId()
    // Add keyed by id to the messages$ observable to trigger a create in Supabase
    messages$[id].set({
        id,
        text,
        created_at: null,
        updated_at: null
    })
}

function updateMessage(id: string, text: string) {
    // Just set values in the observable to trigger an update to Supabase
    messages$[id].text.set(text)
}

Set up Supabase types

The first step to getting strongly typed observables from Supabase is to follow their instructions to create a typed client.

https://supabase.com/docs/guides/api/rest/generating-types

The examples on this page will use the supabase client from the generated types:

import { createClient } from '@supabase/supabase-js'
import { Database } from './database.types'

const supabase = createClient<Database>(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY)

filter

By default it will use select() on the collection. If you want to filter the data, use the filter parameter. See the Using Filters docs for details.

const messages$ = observable(syncedSupabase({
    supabase,
    collection: 'messages',
    // Filter by the current user
    filter: (select) => select.eq('user_id', 'uid')
}))

select

By default it will use select() on the collection. If you want to be more specific, use the select parameter to customize how you want to select. See the Select docs for details.

You can also add filters here if you want.

const messages$ = observable(syncedSupabase({
    supabase,
    collection: 'messages',
    // Select only id and text fields
    select: (from) => from.select('id,text'),
    // Or select and filter together
    select: (from) => from.select('id,text').eq('user_id', 'uid')
}))

actions

By default it will support create, read, update, and delete. But you can specify which actions you want to support with the actions parameter.

const messages$ = observable(syncedSupabase({
    supabase,
    collection: 'messages',
    // Only read and create, no update or delete
    actions: ['read', 'create'],
}))

as

The shape of the observable object can be changed with the as parameter, which supports three options:

  1. object: The default, an object keyed by the row's id field.
  2. Map: A Map, which can be more efficient for accessing rows by key
  3. value: Treat the result of a query as a single value like a get

Note that array is not an option because arrays make it hard to to efficiently and correctly add, update, and remove elements by id.

const messages$ = observable(syncedSupabase({
    supabase,
    collection: 'messages',
    as: 'Map'
}))

// messages$ is an observable Map
messages$.get('messageId').text.set('hello')

Realtime

Enable realtime on the observable with the realtime option. This will update the observable immediately whenever any realtime changes come in. You can optionally set the schema and filter for the realtime listener.

See Supabase's Realtime Docs for more details about realtime filters.

const messages$ = observable(syncedSupabase({
    supabase,
    collection: 'messages',
    // Simply enable it
    realtime: true,
    // Or set options
    realtime: { schema: 'public', filter: `user_id=eq.${uid}`},
}))

RPC and Edge Functions

You can override any or all of the default list/create/update/delete actions with an rpc or function call. There is just one requirement: create and update need to return either full row data or nothing, because the returned data is used to update the observable with any fields changed remotely (like updated_at).

One caveat is that Supabase's edge functions are not strongly typed so the observable can't infer the type from it.

const messages$ = observable(syncedSupabase({
    supabase,
    collection: 'messages',
    // Simply enable it
    realtime: true,
    // Use an rpc function for listing
    list: () => supabase.rpc("list_messages"),
    // Use an rpc function for creating
    create: (input) => supabase.rpc("create_country", input),
    // Or use functions
    list: () => supabase.functions.invoke("list_messages"),
}))

Sync only diffs

An optional but very useful feature is the changesSince: 'last-sync' option. This can massively reduce badwidth usage when you're persisting list results since it only needs to list changes since the last query. The way this works internally is basically:

  1. Save the maximum updatedAt to the local persistence
  2. In subsequent syncs or after refresh it will list by updated_at: lastSync + 1 to get only recent changes
  3. The new changes will be merged into the observable

Enabling this on the Supabase side requires adding created_at and updated_at columns and a trigger to your table. You can run this snippet to set it up, just replace the two instances of YOUR_TABLE_NAME.

Requires soft deletes

It's not possible to list rows deleted in supabase to remove them from the local persistence, so you will have to use soft deletes described in the next session. If you don't need to delete you can remove that column from the script.

-- Add new columns to table named `created_at` and `updated_at`
ALTER TABLE YOUR_TABLE_NAME
ADD COLUMN created_at timestamptz default now(),
ADD COLUMN updated_at timestamptz default now(),
-- Add column for soft deletes, remove this if you don't need that
ADD COLUMN deleted boolean default false;

-- This will set the `created_at` column on create and `updated_at` column on every update
CREATE OR REPLACE FUNCTION handle_times()
    RETURNS trigger AS
    $$
    BEGIN
    IF (TG_OP = 'INSERT') THEN
        NEW.created_at := now();
        NEW.updated_at := now();
    ELSEIF (TG_OP = 'UPDATE') THEN
        NEW.created_at = OLD.created_at;
        NEW.updated_at = now();
    END IF;
    RETURN NEW;
    END;
    $$ language plpgsql;

CREATE TRIGGER handle_times
    BEFORE INSERT OR UPDATE ON YOUR_TABLE_NAME
    FOR EACH ROW
EXECUTE PROCEDURE handle_times();

And to enable this feature in Legend-State, use the changesSince option and set the fieldCreatedAt and fieldUpdatedAt options to match the Supabase column names.

// Sync diffs of a list
syncedSupabase({
    supabase,
    collection: 'messages',
    persist: {
        name: 'messages'
    },
    // Enable syncing only changes since last-sync
    changesSince: 'last-sync',
    fieldCreatedAt: 'created_at',
    fieldUpdatedAt: 'updated_at',
    // Optionally enable soft deletes
    fieldDeleted: 'deleted'
})

// Or you can configure this optional globally so it will apply to every instance of syncedSupabase.
configureSyncedSupabase({
    changesSince: 'last-sync',
    fieldCreatedAt: 'created_at',
    fieldUpdatedAt: 'updated_at',
    // Optionally enable soft deletes
    fieldDeleted: 'deleted'
})

Soft deletes

The delete parameter does not need to be an actual delete action in Supabase. You could also implement it as a soft delete if you prefer, just setting a deleted field to true. To do that you can provide fieldDeleted matching the field name in your table.

Then when you delete an element it will internally update the row with { deleted: true } instead of deleting it, and the list action will remove deleted elements from the observable.

// Sync diffs of a list
syncedSupabase({
    supabase,
    collection: 'messages',
    fieldDeleted: 'deleted'
})

Resource

  • Local-first Realtime Apps with Expo and Legend-State Blog - Video