diff --git a/README.md b/README.md index 24aaaaf..88d06ee 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/offline.md b/docs/offline.md new file mode 100644 index 0000000..2d433a2 --- /dev/null +++ b/docs/offline.md @@ -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. diff --git a/examples/offline-todos.ts b/examples/offline-todos.ts new file mode 100644 index 0000000..7c3f7d0 --- /dev/null +++ b/examples/offline-todos.ts @@ -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 + +export type TodoMutation = Pick< + PendingMutation, + "changes" | "key" | "modified" | "type" +> + +const parseTodoMutation = ( + mutation: PendingMutation> +): 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 + addTodo: (variables: { title: string }) => Transaction + updateTodo: (variables: { + id: string + changes: Partial> + }) => Transaction + deleteTodo: (id: string) => Transaction + dispose: () => Promise +} + +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 => { + 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 => { + 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[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> + }>({ + mutationFnName: "syncTodos", + onMutate: ({ id, changes }) => { + todos.update(id, (draft) => { + Object.assign(draft, changes) + }) + }, + }) + + const deleteTodo = offline.createOfflineAction({ + mutationFnName: "syncTodos", + onMutate: (id) => { + todos.delete(id) + }, + }) + + return { + todos, + addTodo, + updateTodo, + deleteTodo, + async dispose() { + offline.dispose() + todos.cleanup() + coordinator.dispose() + await database.close?.() + }, + } +} diff --git a/package.json b/package.json index c2bdd4d..718fefb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f09c55f..96af63f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,15 @@ importers: '@biomejs/biome': specifier: 2.4.7 version: 2.4.7 + '@journeyapps/wa-sqlite': + specifier: 1.4.1 + version: 1.4.1 + '@tanstack/browser-db-sqlite-persistence': + specifier: 0.1.11 + version: 0.1.11(@journeyapps/wa-sqlite@1.4.1)(typescript@5.9.3) + '@tanstack/offline-transactions': + specifier: 1.0.32 + version: 1.0.32(typescript@5.9.3) '@types/node': specifier: ^25.0.3 version: 25.1.0 @@ -312,6 +321,9 @@ packages: cpu: [x64] os: [win32] + '@journeyapps/wa-sqlite@1.4.1': + resolution: {integrity: sha512-xAWys6opteBpWaKmHG1pZvBmQViEKFK/46YVEkYlWxa4F9VAG0gIjCpfIdcQvXdqZf7X3ByADGmNBcR/cJ1DqQ==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -729,16 +741,38 @@ packages: resolution: {integrity: sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==} engines: {node: '>=20.0.0'} + '@tanstack/browser-db-sqlite-persistence@0.1.11': + resolution: {integrity: sha512-pVplzJNwG2spZjDwy3dJr7fCsqAKtg0w96JhzsIqq4Ttsi/eUh9YM/AKWxEiD4szsJtwFLlpNIz5dSxEp3bbnQ==} + peerDependencies: + '@journeyapps/wa-sqlite': ^1.4.1 + typescript: '>=4.7' + '@tanstack/db-ivm@0.1.18': resolution: {integrity: sha512-+pZJiRKdoKRM5Epq9T7otD9ZJl82pRFauo7LKuJGrarjVKQ7r+QQlPe3kGdN9LEKSnuNGIWjX9OOY4M8kH4eLw==} peerDependencies: typescript: '>=4.7' + '@tanstack/db-sqlite-persistence-core@0.1.11': + resolution: {integrity: sha512-HZyJBU64VL6M4vGTmaDVi+sVOhLyZiHKxGj0UdpWokcPBtbTHrSRrpXTE8HJCoQf6gDdbybj7OG2UBXWxJzHLg==} + peerDependencies: + typescript: '>=4.7' + '@tanstack/db@0.6.7': resolution: {integrity: sha512-nCwOhNXogu3JHdkNPXX6+B8aL0F4wVe0CwLvNS7ccCQ6m9147L8qJewL4IZVyACQDsLGs5MKg91x+VUiD+MplQ==} peerDependencies: typescript: '>=4.7' + '@tanstack/offline-transactions@1.0.32': + resolution: {integrity: sha512-3DCxHwzIYwVudcRgUajSy7a3BMbv0ZgmE0kJihzdCrv7PAuXG8ZGtOaX6PIpbGh6ffIVXU/IqGJwJ1n1NkKsCg==} + peerDependencies: + '@react-native-community/netinfo': '>=11.0.0' + react-native: '>=0.70.0' + peerDependenciesMeta: + '@react-native-community/netinfo': + optional: true + react-native: + optional: true + '@tanstack/pacer-lite@0.2.1': resolution: {integrity: sha512-3PouiFjR4B6x1c969/Pl4ZIJleof1M0n6fNX8NRiC9Sqv1g06CVDlEaXUR4212ycGFyfq4q+t8Gi37Xy+z34iQ==} engines: {node: '>=18'} @@ -1442,6 +1476,8 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true + '@journeyapps/wa-sqlite@1.4.1': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1698,12 +1734,24 @@ snapshots: '@supabase/realtime-js': 2.107.0 '@supabase/storage-js': 2.107.0 + '@tanstack/browser-db-sqlite-persistence@0.1.11(@journeyapps/wa-sqlite@1.4.1)(typescript@5.9.3)': + dependencies: + '@journeyapps/wa-sqlite': 1.4.1 + '@tanstack/db-sqlite-persistence-core': 0.1.11(typescript@5.9.3) + typescript: 5.9.3 + '@tanstack/db-ivm@0.1.18(typescript@5.9.3)': dependencies: fractional-indexing: 3.2.0 sorted-btree: 1.8.1 typescript: 5.9.3 + '@tanstack/db-sqlite-persistence-core@0.1.11(typescript@5.9.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@tanstack/db': 0.6.7(typescript@5.9.3) + typescript: 5.9.3 + '@tanstack/db@0.6.7(typescript@5.9.3)': dependencies: '@standard-schema/spec': 1.1.0 @@ -1711,6 +1759,12 @@ snapshots: '@tanstack/pacer-lite': 0.2.1 typescript: 5.9.3 + '@tanstack/offline-transactions@1.0.32(typescript@5.9.3)': + dependencies: + '@tanstack/db': 0.6.7(typescript@5.9.3) + transitivePeerDependencies: + - typescript + '@tanstack/pacer-lite@0.2.1': {} '@tanstack/query-core@5.101.0': {} diff --git a/tests/offline-example.test.ts b/tests/offline-example.test.ts new file mode 100644 index 0000000..050548c --- /dev/null +++ b/tests/offline-example.test.ts @@ -0,0 +1,101 @@ +import { createClient } from "@supabase/supabase-js" +import { NonRetriableError } from "@tanstack/offline-transactions" +import { describe, expect, test, vi } from "vitest" +import { + syncTodoMutation, + type Todo, + type TodoMutation, +} from "../examples/offline-todos" +import { SUPABASE_KEY, SUPABASE_URL } from "./test.utils" + +const todo: Todo = { + id: "todo-1", + title: "Buy milk", + completed: false, +} + +const mutation = ( + value: Partial & Pick +): TodoMutation => ({ + key: todo.id, + modified: todo, + changes: {}, + ...value, +}) + +const createSupabase = (mockFetch: typeof fetch) => + createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }) + +describe("offline Supabase example", () => { + test.each([ + { + name: "upserts inserts with their stable client-generated id", + mutation: mutation({ type: "insert" }), + method: "POST", + path: "/rest/v1/todos?on_conflict=id", + body: todo, + }, + { + name: "updates a todo by primary key", + mutation: mutation({ + type: "update", + changes: { completed: true }, + }), + method: "PATCH", + path: "/rest/v1/todos?id=eq.todo-1", + body: { completed: true }, + }, + { + name: "deletes a todo by primary key", + mutation: mutation({ type: "delete" }), + method: "DELETE", + path: "/rest/v1/todos?id=eq.todo-1", + body: undefined, + }, + ])("$name", async ({ mutation: pending, method, path, body }) => { + const mockFetch = vi.fn().mockResolvedValue( + new Response("[]", { + status: 200, + headers: { "content-type": "application/json" }, + }) + ) + + await syncTodoMutation(createSupabase(mockFetch), pending) + + expect(mockFetch).toHaveBeenCalledOnce() + const [input, init] = mockFetch.mock.calls[0] ?? [] + expect( + new URL(String(input)).pathname + new URL(String(input)).search + ).toBe(path) + expect(init?.method).toBe(method) + expect(init?.body ? JSON.parse(String(init.body)) : undefined).toEqual(body) + }) + + test("marks permanent PostgREST failures as non-retriable", async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: "RLS rejected the mutation" }), { + status: 403, + headers: { "content-type": "application/json" }, + }) + ) + + await expect( + syncTodoMutation(createSupabase(mockFetch), mutation({ type: "delete" })) + ).rejects.toBeInstanceOf(NonRetriableError) + }) + + test("leaves transient PostgREST failures retriable", async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: "temporarily unavailable" }), { + status: 503, + headers: { "content-type": "application/json" }, + }) + ) + + await expect( + syncTodoMutation(createSupabase(mockFetch), mutation({ type: "update" })) + ).rejects.not.toBeInstanceOf(NonRetriableError) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 0893133..0da798f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "verbatimModuleSyntax": true, "skipLibCheck": true }, - "include": ["src", "tests"] + "include": ["src", "tests", "examples"] }