Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,13 @@ const completedTodos = await queryOnce(

Use `queryOnce` when you need a one-shot fetch, such as in server components, API routes, or form submissions where live updates are not needed.

## Offline Support

Browser SQLite persistence and durable offline writes can be composed with
this adapter. See the [offline Supabase collections guide](docs/offline.md) for
a typechecked example, compatible dependency versions, and the limits around
Auth, RLS, Realtime recovery, retries, conflicts, and multiple tabs.

Filters, ordering, `limit`, `offset`, joins, and aggregate functions (`count`, `sum`, `avg`, `min`, `max`) are pushed to PostgREST. Operations that cannot be pushed fall back to fetching matching rows and processing them client-side.

Fallback operations include `GROUP BY`, `HAVING`, `DISTINCT`, and computed `SELECT` expressions.
Expand Down
136 changes: 136 additions & 0 deletions docs/offline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Offline Supabase Collections

TanStack's SQLite persistence and offline transaction packages can be composed
with `supabaseCollectionOptions`. They solve two separate problems:

- Browser SQLite keeps the collection cache available across reloads.
- The offline executor stores optimistic mutations in a durable outbox and
replays them after connectivity returns.

The complete, typechecked example is in
[`examples/offline-todos.ts`](../examples/offline-todos.ts).

## Compatible Versions

This repository currently resolves `@tanstack/db` 0.6.7. The persistence and
offline packages must resolve the same TanStack DB version, otherwise the app
can contain incompatible collection and transaction runtimes.

Install the compatible releases explicitly:

```sh
pnpm add @tanstack/browser-db-sqlite-persistence@0.1.11 \
@tanstack/offline-transactions@1.0.32 \
@journeyapps/wa-sqlite@1.4.1
```

The newer `@tanstack/browser-db-sqlite-persistence` 0.2.x and
`@tanstack/offline-transactions` releases require `@tanstack/db` 0.9.x. Moving
this adapter to that stack should be handled as a separate upgrade with its
own query regression testing.

## Database Setup

The example expects a table whose primary key is generated by the client:

```sql
create table public.todos (
id uuid primary key,
title text not null,
completed boolean not null default false
);

alter table public.todos enable row level security;
```

Add policies appropriate for the application and enable the table in the
`supabase_realtime` publication if Realtime reconciliation is required.

Call `createOfflineTodos(supabase)` after creating the browser Supabase client.
The returned `addTodo`, `updateTodo`, and `deleteTodo` functions must be used for
offline-capable writes. Calling `todos.insert`, `todos.update`, or
`todos.delete` directly still uses the adapter's normal online mutation
handlers.

### Vite Setup

The SQLite package exposes its OPFS implementation through a `?worker` import.
Exclude the package from Vite dependency pre-bundling so Vite transforms that
worker instead of treating its generated asset URL as an ordinary dependency:

```ts
import { defineConfig } from "vite";

export default defineConfig({
optimizeDeps: {
exclude: ["@tanstack/browser-db-sqlite-persistence"],
},
});
```

Without this setting, Vite can serve its HTML fallback for the worker asset and
the browser reports that the OPFS worker terminated unexpectedly.

## Behavior and Limits

### Auth and RLS

The outbox stores row mutations, not access tokens. Replay uses the Supabase
client's current session, so initialize Auth and restore or refresh the session
before creating the offline executor. RLS is evaluated normally when each
queued mutation reaches PostgREST.

The example treats permanent 4xx responses, including an expired session that
cannot be refreshed and RLS rejection, as non-retriable. TanStack rolls back
the optimistic change and removes that transaction from the outbox. Status 408
and 429 remain retriable.

### Idempotency and Retries

The offline executor supplies an idempotency key, but PostgREST table mutations
do not provide a general idempotency-key contract. The example therefore gives
each insert a stable client-generated UUID and replays it as an upsert on the
primary key. Repeated updates and deletes target that same primary key.

Transient errors are retried with backoff. Permanent validation, Auth, and RLS
errors are not. Applications should surface permanent failures so users know
that their optimistic edit was rolled back.

### Realtime Recovery

Realtime is not the offline transport. Events published while a client is
disconnected are not an authoritative replay log for that client. After an
outbox transaction succeeds, the example refetches the collection from
PostgREST. Realtime then continues to deliver future changes.

### Conflicts

The basic example has last-write-wins behavior for updates. An upsert makes a
retried insert safe, but it does not provide field-level conflict resolution.
Applications that must reject stale updates should add a version or
`updated_at` precondition and enforce it in a database function or custom API.
That function can also accept the executor's idempotency key when stronger
deduplication is required.

### Multiple Tabs

`BrowserCollectionCoordinator` coordinates the shared SQLite cache. The
offline transaction package separately elects one tab to own and replay the
outbox. Other tabs can use the synchronized cache, but their transaction
executor falls back to online-only mode while another tab is leader. An app
should expose the executor's leadership callback if users need to be warned
that a particular tab cannot queue writes.

Browser SQLite requires OPFS, Web Workers, and a secure browser context. The
example is browser-only and should not be initialized during server rendering.

## Recommended Follow-ups

1. Upgrade this adapter and its query dependency to TanStack DB 0.9 in a
dedicated change, then move to the latest persistence packages.
2. Add an application-level conflict policy using a version column or a
Postgres function before using offline writes for collaborative records.
3. Add UI for pending and permanently failed mutations instead of silently
relying on optimistic rollback.
4. Consider a first-party adapter helper only after the TanStack offline APIs
and the desired conflict contract have stabilized.
202 changes: 202 additions & 0 deletions examples/offline-todos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import type { SupabaseClient } from "@supabase/supabase-js"
import {
BrowserCollectionCoordinator,
createBrowserWASQLitePersistence,
openBrowserWASQLiteOPFSDatabase,
persistedCollectionOptions,
} from "@tanstack/browser-db-sqlite-persistence"
import {
type Collection,
createCollection,
type PendingMutation,
type Transaction,
} from "@tanstack/db"
import {
NonRetriableError,
startOfflineExecutor,
} from "@tanstack/offline-transactions"
import { z } from "zod"
import { supabaseCollectionOptions } from "../src/index"

export const todoSchema = z.object({
id: z.string().uuid(),
title: z.string(),
completed: z.boolean(),
})
const todoChangesSchema = todoSchema.partial()

export type Todo = z.infer<typeof todoSchema>

export type TodoMutation = Pick<
PendingMutation<Todo>,
"changes" | "key" | "modified" | "type"
>

const parseTodoMutation = (
mutation: PendingMutation<Record<string, unknown>>
): TodoMutation => ({
type: mutation.type,
key: z.string().parse(mutation.key),
modified: todoSchema.parse(mutation.modified),
changes: todoChangesSchema.parse(mutation.changes),
})

type MutationResponse = {
error: { message: string } | null
status: number
}

type OfflineTodos = {
todos: Collection<Todo, string | number>
addTodo: (variables: { title: string }) => Transaction
updateTodo: (variables: {
id: string
changes: Partial<Pick<Todo, "completed" | "title">>
}) => Transaction
deleteTodo: (id: string) => Transaction
dispose: () => Promise<void>
}

const throwMutationError = ({ error, status }: MutationResponse): void => {
if (!error) {
return
}

if (status >= 400 && status < 500 && status !== 408 && status !== 429) {
throw new NonRetriableError(error.message)
}

throw new Error(error.message)
}

/**
* Replays one queued mutation through PostgREST.
*
* Inserts use a client-generated UUID and upsert so retrying the same queued
* transaction cannot create a duplicate row. PostgREST has no generic
* idempotency-key contract for these table mutations.
*/
export const syncTodoMutation = async (
supabase: SupabaseClient,
mutation: TodoMutation
): Promise<void> => {
if (mutation.type === "insert") {
const response = await supabase
.from("todos")
.upsert(mutation.modified, { onConflict: "id" })
throwMutationError(response)
return
}

if (mutation.type === "update") {
const response = await supabase
.from("todos")
.update(mutation.changes)
.eq("id", mutation.key)
throwMutationError(response)
return
}

const response = await supabase.from("todos").delete().eq("id", mutation.key)
throwMutationError(response)
}

export const createOfflineTodos = async (
supabase: SupabaseClient
): Promise<OfflineTodos> => {
const databaseName = "supabase-todos.sqlite"
const database = await openBrowserWASQLiteOPFSDatabase({
databaseName,
})
const coordinator = new BrowserCollectionCoordinator({ dbName: databaseName })
const persistence = createBrowserWASQLitePersistence({
database,
coordinator,
})

const persistedOptions = persistedCollectionOptions<
Todo,
string | number,
typeof todoSchema
>({
...supabaseCollectionOptions({
tableName: "todos",
keys: ["id"],
schema: todoSchema,
supabase,
realtime: true,
}),
persistence,
schemaVersion: 1,
})
const todos = createCollection({
...persistedOptions,
// The 0.1 persistence package's local-only overload makes schema optional.
// Restating it preserves schema inference when wrapping a synced collection.
schema: todoSchema,
})

const syncTodos = async ({
transaction,
}: Parameters<
Parameters<typeof startOfflineExecutor>[0]["mutationFns"][string]
>[0]) => {
for (const mutation of transaction.mutations) {
await syncTodoMutation(supabase, parseTodoMutation(mutation))
}

// Realtime only carries changes published after it reconnects. Refetch
// after replay so the collection reconciles with the server first.
await todos.utils.refetch()
}

const offline = startOfflineExecutor({
collections: { todos },
mutationFns: { syncTodos },
})

await offline.waitForInit()

const addTodo = offline.createOfflineAction<{ title: string }>({
mutationFnName: "syncTodos",
onMutate: ({ title }) => {
todos.insert({
id: crypto.randomUUID(),
title,
completed: false,
})
},
})

const updateTodo = offline.createOfflineAction<{
id: string
changes: Partial<Pick<Todo, "completed" | "title">>
}>({
mutationFnName: "syncTodos",
onMutate: ({ id, changes }) => {
todos.update(id, (draft) => {
Object.assign(draft, changes)
})
},
})

const deleteTodo = offline.createOfflineAction<string>({
mutationFnName: "syncTodos",
onMutate: (id) => {
todos.delete(id)
},
})

return {
todos,
addTodo,
updateTodo,
deleteTodo,
async dispose() {
offline.dispose()
todos.cleanup()
coordinator.dispose()
await database.close?.()
},
}
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
},
"devDependencies": {
"@biomejs/biome": "2.4.7",
"@journeyapps/wa-sqlite": "1.4.1",
"@tanstack/browser-db-sqlite-persistence": "0.1.11",
"@tanstack/offline-transactions": "1.0.32",
"@types/node": "^25.0.3",
"bumpp": "^10.3.2",
"supabase": "^2.116.0",
Expand Down
Loading