# Offline-first

> Read and write on the device with PowerSync, and upload the changes through the repositories.

Source: https://bettersupabase.com/docs/guides/offline-first

An offline-first app keeps two sets of repositories over the same
definition:

* `local`: [`powersyncExecutor`](/docs/repository/powersync) over the PowerSync
  database. Screens read and write here, online or not.
* `bs.db`: the [React Native client](/docs/frontend/react-native) over
  PostgREST, as the signed-in user. Only the upload path uses it.

PowerSync syncs rows down and queues local writes. Its connector's
`uploadData` sends the queue to your backend; replaying each change through
`bs.db` means RLS, plugins and validation run on it, exactly as for an online
write.

## Upload through the repositories [#upload-through-the-repositories]

Each queued change has an `op`: `PUT` (insert or replace), `PATCH` (update the
changed columns) or `DELETE`. `createUploadConnector` maps each one to the
table's repository and authenticates sync with the Supabase session:

```ts title="src/lib/powersync/connector.ts"
import { createUploadConnector } from "better-supabase/powersync";
import { bs, supabase } from "../supabase/native";

export const connector = createUploadConnector({
  endpoint: process.env.EXPO_PUBLIC_POWERSYNC_URL,
  supabase,
  tables: { customers: bs.db.customers },
});
```

It reads the queue in batches of 100 (`batchSize`). Every table that takes
local writes needs a route: a change to a table missing from `tables` stops
the upload with an error that names it, so no change is dropped without a
trace. A route can also be a function of the change, for a table that needs
custom replay:

```ts
tables: {
  customers: bs.db.customers,
  notes: (entry) => bs.db.notes.upsert({ ...renameColumns(entry.opData), id: entry.id }),
},
```

`opData` holds the columns as SQLite stored them, under their database names;
with `casing: 'camel'`, use a function route that renames them to the app
keys.

To sync only while someone is signed in, pass the connector to
`syncWithAuth`. It connects on sign-in, and on sign-out or a change of user it
disconnects and clears the local database, so one user never reads another's
rows. Unsynced changes are cleared with them; pass `clearOnSignOut: false` to
keep the database.

```tsx title="src/lib/powersync/sync.ts"
import { syncWithAuth } from "better-supabase/powersync";

export function useSync(): void {
  useEffect(() => syncWithAuth(powersync, bs.auth, { connector }), []);
}
```

## Retry, discard or surface a conflict [#retry-discard-or-surface-a-conflict]

A failed upload returns a [`DbError`](/docs/concepts/results). Its `kind`
decides what happens to the change; `uploadOutcome(kind)` is the default
below, and `classify` overrides it for some errors:

| Kind                                                                                                             | Do                                                            |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| `network`, `timeout`, `aborted`, `rate_limited`, `quota_exceeded`, `serialization`, `unauthorized`, `unexpected` | retry: PowerSync keeps the batch and calls `uploadData` again |
| `conflict`, `stale`, `foreign_key`                                                                               | conflict: complete it and show the conflict next to the row   |
| `forbidden`, `not_found`, `check`, `not_null`, `validation` and the rest                                         | discard: complete it; the server will never accept it         |

Retrying a change the server will never accept blocks the queue for good,
so the kinds that can't succeed on a retry complete. Completing removes the
change from the queue, and the next sync replaces the local row with the
server's version, so the device ends up where the server is. An
`unauthorized` error retries while the session lives; once the refresh token
is dead, the upload stops until the user signs in again.

## Conflicts [#conflicts]

The server's row wins once the change is gone from the queue. The connector
keeps every conflict and discarded change in `connector.rejected` (and calls
`onConflict` or `onDiscard`), with the queued `entry` and the error.
`useConflicts(connector)` from `better-supabase/powersync/react` reads them in
a component: show each on its row, let the user apply it again as a new
write, then `dismiss` it. A `conflict` error carries the `constraint` and
`columns` that rejected it, which is enough to point at the field.

For edits that must not overwrite a newer server row, record the
`updated_at` the user saw and pass it as `expect`:
`customers.update(id, data, { expect: { updated_at: seenAt } })` returns a
`stale` error when someone else changed the row first, and the user decides.

The [Expo example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/expo-powersync)
uses this connector with `syncWithAuth` and `useConflicts`.