Claude Skill

mobile-storage-sqlite-powersync

PowerSync offline-first sync engine on SQLite for React Native - schema definition, watched queries, CRUD operations, backend connectors, sync rules, conflict resolution, attachments

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_mobile-storage-sqlite-powersync_skills_mobile-storage-sqlite-powersync-3a51ef5.zip · 18 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/mobile-storage-sqlite-powersync/skills/mobile-storage-sqlite-powersync
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git git clone https://github.com/agents-inc/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

SQLite + PowerSync Patterns

Quick Guide: Use @powersync/react-native for offline-first apps backed by local SQLite. Define schemas with Table and column.text/integer/real (id column is auto-created). Use PowerSyncDatabase for reads/writes, useQuery from @powersync/react for reactive watched queries. Connect to your backend via a connector implementing fetchCredentials + uploadData. Conflict resolution defaults to last-write-wins per field -- customize in uploadData. Use @powersync/op-sqlite for SQLCipher encryption.


<critical_requirements>

CRITICAL: Before Using This Skill

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST define schemas with new Table({ ... }) using column.text, column.integer, column.real -- NEVER declare an id column, PowerSync creates it automatically)

(You MUST call powersync.connect(connector) after init() to start syncing -- without it the database is local-only with no sync)

(You MUST implement both fetchCredentials() and uploadData() in your backend connector -- missing either breaks the sync loop)

(You MUST use useQuery from @powersync/react for reactive queries -- raw getAll() does NOT re-render on data changes)

</critical_requirements>


Auto-detection: PowerSync, powersync, @powersync/react-native, @powersync/react, @powersync/op-sqlite, PowerSyncDatabase, useQuery, usePowerSync, useStatus, useSuspenseQuery, PowerSyncBackendConnector, fetchCredentials, uploadData, column.text, column.integer, column.real, Schema, Table, sync rules, bucket_definitions, offline-first SQLite, watched query, CrudEntry, CrudTransaction, AttachmentQueue, AttachmentTable, local-only table

When to use:

  • Building offline-first React Native apps that sync with a cloud database
  • Storing relational data locally in SQLite with automatic cloud sync
  • Implementing reactive UIs that update when synced data changes
  • Handling CRUD operations that work offline and sync when reconnected
  • Defining sync rules (bucket definitions) for partial data replication
  • Managing file attachments with offline upload/download queues

Key patterns covered:

  • Schema definition with Table, column types, indexes, and local-only tables
  • PowerSyncDatabase setup with default or OP-SQLite adapter
  • React hooks: useQuery, useSuspenseQuery, useStatus, usePowerSync
  • Backend connector: fetchCredentials() + uploadData() implementation
  • CRUD operations via execute(), get(), getAll(), getOptional()
  • Sync rules with bucket definitions (YAML) for per-user data filtering
  • Conflict resolution strategies (last-write-wins, field-level, custom)
  • Attachment handling with AttachmentTable and AttachmentQueue
  • OP-SQLite integration for SQLCipher encryption

When NOT to use:

  • Simple key-value storage without sync (use a key-value store)
  • Apps that never go offline and always have connectivity
  • Data that does not need relational queries (use a key-value store)
  • File-only storage without structured metadata (use the filesystem)

Detailed Resources:




<decision_framework>

Decision Framework

What kind of data are you storing?
|
+-> Relational data that needs offline + cloud sync?
|   +-> YES -> PowerSync + SQLite (this skill)
|   +-> NO  -> Key-value pairs only?
|       +-> YES -> Use a key-value store (not this skill)
|       +-> NO  -> Files/media only?
|           +-> YES -> Use the filesystem
|
+-> Do you need reactive queries in React?
|   +-> YES -> Use useQuery from @powersync/react
|   +-> NO  -> Use powersync.getAll() / get() directly
|
+-> Do you need on-device encryption?
|   +-> YES -> Use @powersync/op-sqlite with SQLCipher
|   +-> NO  -> Use the default SQLite adapter
|
+-> Do you have file attachments?
|   +-> YES -> Use AttachmentTable + AttachmentQueue
|   +-> NO  -> Standard schema is sufficient
|
+-> How should conflicts be resolved?
    +-> Simple apps -> Last-write-wins (default)
    +-> Collaborative editing -> Field-level merge or CRDTs
    +-> Business-critical -> Server-side validation + conflict recording

When to Use Each Query API

Scenario API
Reactive component data useQuery() from @powersync/react
Reactive with Suspense useSuspenseQuery() from @powersync/react
One-time fetch (no reactivity) useQuery() with runQueryOnce: true
Service/utility reads powersync.getAll() / get() / getOptional()
Write operations powersync.execute()
Connection status useStatus() from @powersync/react
Database instance access usePowerSync() from @powersync/react

</decision_framework>


<red_flags>

RED FLAGS

High Priority Issues:

  • Declaring an id column in schema -- PowerSync auto-creates id as text primary key. Declaring it causes conflicts.
  • Calling execute() for reads (SELECT) instead of getAll() / useQuery() -- execute() does not return query results in a usable format
  • Forgetting powersync.connect(connector) -- database works locally but nothing syncs, easy to miss in development
  • Using getAll() in React components expecting reactivity -- raw reads do not watch for changes, use useQuery() instead
  • Missing transaction.complete() in uploadData() -- unacknowledged transactions retry indefinitely, causing duplicate uploads

Medium Priority Issues:

  • Schema table names not matching sync rule table names -- data silently fails to sync
  • Not handling fetchCredentials() returning null -- happens when auth session expires, must re-authenticate
  • Storing large blobs in SQLite columns -- use AttachmentTable for files, keep SQLite for metadata
  • Missing indexes on frequently queried columns -- sync queries can be slow with large datasets
  • Using column.integer for booleans without consistent 0/1 values -- SQLite has no native boolean type

Gotchas & Edge Cases:

  • uuid() is a PowerSync SQL function, not a JavaScript function -- use it in SQL strings, not in JS
  • column.real stores IEEE 754 doubles -- be aware of floating-point precision for currency (use integer cents instead)
  • Sync rules YAML uses request.user_id() to access the authenticated user ID from the JWT -- not a custom function
  • getNextCrudTransaction() returns null when the upload queue is empty -- always check before iterating
  • execute() with views may return rowsAffected: 0 even on success -- use RETURNING clause for confirmation
  • PowerSync supports WebSocket (default since v1.11.0) and HTTP streaming for sync -- WebSocket is recommended
  • The Rust-based sync client is enabled by default since v1.29.0 -- pass clientImplementation: SyncClientImplementation.JAVASCRIPT to use the legacy JS client
  • disconnectAndClear() removes all local data -- use disconnect() to stop sync while preserving local data
  • Local-only tables (set localOnly: true on Table options) are never synced -- useful for draft data or app state

</red_flags>


<critical_reminders>

CRITICAL REMINDERS

All code must follow project conventions in CLAUDE.md

(You MUST define schemas with new Table({ ... }) using column.text, column.integer, column.real -- NEVER declare an id column, PowerSync creates it automatically)

(You MUST call powersync.connect(connector) after init() to start syncing -- without it the database is local-only with no sync)

(You MUST implement both fetchCredentials() and uploadData() in your backend connector -- missing either breaks the sync loop)

(You MUST use useQuery from @powersync/react for reactive queries -- raw getAll() does NOT re-render on data changes)

Failure to follow these rules will cause silent sync failures, missing data, and non-reactive UIs.

</critical_reminders>

Files (skills)
  • examples
    • attachments.md 6.5 KB
      # SQLite + PowerSync - Attachment Handling
      
      > Offline-capable file upload/download with AttachmentQueue. See [SKILL.md](../SKILL.md) for decision guidance and red flags.
      
      **Related:** [core.md](core.md) for schema and database setup, [sync.md](sync.md) for backend connector.
      
      **Prerequisites:** `@powersync/react-native` v1.30.0+, a remote storage provider (e.g., Supabase Storage, S3).
      
      ---
      
      ## Pattern 11: Schema with AttachmentTable
      
      Add `AttachmentTable` to your schema alongside tables that reference attachments.
      
      ```typescript
      import {
        column,
        Schema,
        Table,
        AttachmentTable,
      } from "@powersync/react-native";
      
      const users = new Table({
        name: column.text,
        photo_id: column.text, // References an attachment ID
      });
      
      const messages = new Table({
        content: column.text,
        sender_id: column.text,
        attachment_id: column.text, // References an attachment ID
        created_at: column.text,
      });
      
      export const AppSchema = new Schema({
        users,
        messages,
        attachments: new AttachmentTable(), // Built-in table for attachment metadata
      });
      ```
      
      **Why good:** `AttachmentTable` provides columns for `id`, `filename`, `media_type`, `state`, `timestamp`, `size`. Your data tables reference attachments by ID.
      
      ---
      
      ## Pattern 12: AttachmentQueue Setup
      
      The `AttachmentQueue` manages the full lifecycle: save locally, queue upload, sync state, download on other devices, retry on failure.
      
      ```typescript
      import { AttachmentQueue } from "@powersync/react-native";
      import type {
        LocalStorageAdapter,
        RemoteStorageAdapter,
      } from "@powersync/react-native";
      import { powersync } from "./database";
      
      const SYNC_INTERVAL_MS = 30_000; // Retry failed uploads/downloads every 30s
      const ARCHIVED_CACHE_LIMIT = 100; // Max orphaned attachments to keep
      
      // Local storage adapter -- platform-specific file persistence
      const localStorage: LocalStorageAdapter = createLocalStorageAdapter();
      
      // Remote storage adapter -- your cloud storage provider
      const remoteStorage: RemoteStorageAdapter = {
        uploadFile: async (filename: string, localUri: string): Promise<void> => {
          // Upload file to your cloud storage (e.g., Supabase Storage, S3)
          const fileData = await readFile(localUri);
          await uploadToCloud(filename, fileData);
        },
      
        downloadFile: async (filename: string): Promise<ArrayBuffer> => {
          // Download from cloud storage
          return await downloadFromCloud(filename);
        },
      
        deleteFile: async (filename: string): Promise<void> => {
          await deleteFromCloud(filename);
        },
      };
      
      export const attachmentQueue = new AttachmentQueue({
        db: powersync,
        localStorage,
        remoteStorage,
        syncIntervalMs: SYNC_INTERVAL_MS,
        archivedCacheLimit: ARCHIVED_CACHE_LIMIT,
        downloadAttachments: true, // Automatically download synced attachments
        watchAttachments: (onUpdate) => {
          // Watch your data model for attachment references
          // This tells the queue which attachments to track
          powersync.watch(
            `SELECT photo_id as id FROM users WHERE photo_id IS NOT NULL
             UNION
             SELECT attachment_id as id FROM messages WHERE attachment_id IS NOT NULL`,
            [],
            {
              onResult: (result) =>
                onUpdate(result.rows?._array?.map((r) => r.id) ?? []),
            },
          );
        },
      });
      
      // Initialize in app bootstrap (after powersync.init())
      await attachmentQueue.init();
      ```
      
      **Why good:** Queue handles retries automatically, `watchAttachments` detects new/removed references, unreferenced attachments are auto-archived
      
      ---
      
      ## Pattern 13: Uploading an Attachment
      
      ```typescript
      import { attachmentQueue } from "./attachment-queue";
      
      async function uploadUserPhoto(
        userId: string,
        photoData: ArrayBuffer,
      ): Promise<string> {
        // saveFile creates the attachment record and queues upload
        const attachment = await attachmentQueue.saveFile({
          data: photoData,
          fileExtension: "jpg",
          mediaType: "image/jpeg",
          // updateHook runs in the same transaction -- ensures consistency
          updateHook: async (tx, attachment) => {
            await tx.execute("UPDATE users SET photo_id = ? WHERE id = ?", [
              attachment.id,
              userId,
            ]);
          },
        });
      
        return attachment.id;
      }
      ```
      
      **Why good:** `updateHook` executes in the same SQLite transaction as the attachment record creation -- the data reference and metadata are always consistent
      
      ---
      
      ## Pattern 14: Deleting an Attachment
      
      ```typescript
      async function removeUserPhoto(userId: string, photoId: string): Promise<void> {
        await attachmentQueue.deleteFile({
          id: photoId,
          updateHook: async (tx) => {
            await tx.execute("UPDATE users SET photo_id = NULL WHERE id = ?", [
              userId,
            ]);
          },
        });
      }
      
      // Alternative: simply remove the reference -- queue auto-archives unreferenced attachments
      async function removePhotoReference(userId: string): Promise<void> {
        await powersync.execute("UPDATE users SET photo_id = NULL WHERE id = ?", [
          userId,
        ]);
        // The queue's watchAttachments will detect the orphaned attachment and archive it
      }
      ```
      
      ---
      
      ## Pattern 15: Attachment States
      
      | State             | Meaning                                             |
      | ----------------- | --------------------------------------------------- |
      | `QUEUED_UPLOAD`   | File saved locally, waiting to be uploaded          |
      | `QUEUED_DOWNLOAD` | Synced from another device, waiting to download     |
      | `SYNCED`          | Exists both locally and in cloud storage            |
      | `QUEUED_DELETE`   | Marked for removal from local and cloud             |
      | `ARCHIVED`        | No longer referenced by data, candidate for cleanup |
      
      **Lifecycle:**
      
      ```
      New file:     Save -> QUEUED_UPLOAD -> (upload) -> SYNCED
      From server:  Sync -> QUEUED_DOWNLOAD -> (download) -> SYNCED
      Remove ref:   SYNCED -> ARCHIVED -> (cache eviction) -> deleted
      Delete:       SYNCED -> QUEUED_DELETE -> (delete remote) -> removed
      ```
      
      ---
      
      ## Pattern 16: Displaying Attachments
      
      ```tsx
      import { useQuery } from "@powersync/react";
      import { Image, View } from "react-native";
      
      function UserAvatar({ userId }: { userId: string }) {
        const { data } = useQuery(
          `SELECT a.local_uri, a.state
           FROM users u
           JOIN attachments a ON u.photo_id = a.id
           WHERE u.id = ?`,
          [userId],
        );
      
        const attachment = data?.[0];
      
        if (!attachment?.local_uri) {
          return <PlaceholderAvatar />;
        }
      
        return (
          <View>
            <Image source={{ uri: attachment.local_uri }} />
            {attachment.state !== "SYNCED" && <UploadingIndicator />}
          </View>
        );
      }
      
      export { UserAvatar };
      ```
      
      **Why good:** `local_uri` provides the on-device file path, `state` lets you show upload/download progress indicators, JOIN query is reactive via `useQuery`
      
    • core.md 10.2 KB
      # SQLite + PowerSync - Core Patterns
      
      > Schema definition, database setup, CRUD operations, and React hooks. See [SKILL.md](../SKILL.md) for decision guidance and red flags.
      
      **Prerequisites:** React Native 0.68+, `@powersync/react-native`, `@powersync/react`, and a PowerSync Service instance.
      
      ---
      
      ## Pattern 1: Schema Definition with Column Types
      
      ```typescript
      import { column, Schema, Table } from "@powersync/react-native";
      
      // --- Synced tables (data comes from server via sync rules) ---
      
      const LISTS_TABLE = "lists";
      const TODOS_TABLE = "todos";
      
      const lists = new Table({
        created_at: column.text,
        name: column.text,
        owner_id: column.text,
      });
      
      const todos = new Table(
        {
          list_id: column.text,
          created_at: column.text,
          completed_at: column.text,
          description: column.text,
          completed: column.integer, // 0 = false, 1 = true (SQLite has no boolean)
          priority: column.real, // Floating-point values
        },
        { indexes: { list: ["list_id"] } }, // Index for faster queries by list
      );
      
      // --- Local-only table (never synced, useful for drafts/app state) ---
      
      const drafts = new Table(
        {
          content: column.text,
          updated_at: column.text,
        },
        { localOnly: true },
      );
      
      export const AppSchema = new Schema({ todos, lists, drafts });
      
      // Derive TypeScript types from schema
      export type Database = (typeof AppSchema)["types"];
      export type TodoRecord = Database["todos"];
      export type ListRecord = Database["lists"];
      ```
      
      **Why good:** Single source of truth for types and database structure, indexes declared alongside columns, local-only tables for non-synced data, derived types stay in sync with schema
      
      ```typescript
      // BAD: Declaring an id column
      const todos = new Table({
        id: column.text, // WRONG -- PowerSync auto-creates this
        list_id: column.text,
        description: column.text,
      });
      ```
      
      **Why bad:** PowerSync automatically creates an `id` column of type `text` as the primary key. Declaring it manually causes column conflicts.
      
      **Column type reference:**
      
      | Type             | SQLite Type | Use For                             |
      | ---------------- | ----------- | ----------------------------------- |
      | `column.text`    | TEXT        | Strings, UUIDs, ISO dates, JSON     |
      | `column.integer` | INTEGER     | Numbers, booleans (0/1), timestamps |
      | `column.real`    | REAL        | Floating-point numbers              |
      
      ---
      
      ## Pattern 2: PowerSyncDatabase Setup
      
      ### Default Adapter
      
      ```typescript
      import { PowerSyncDatabase } from "@powersync/react-native";
      import { AppSchema } from "./schema";
      
      const DB_FILENAME = "app.db";
      
      // Create a single database instance -- share across the entire app
      export const powersync = new PowerSyncDatabase({
        schema: AppSchema,
        database: { dbFilename: DB_FILENAME },
      });
      ```
      
      ### OP-SQLite Adapter (with Optional SQLCipher Encryption)
      
      ```typescript
      import { PowerSyncDatabase } from "@powersync/react-native";
      import { OPSqliteOpenFactory } from "@powersync/op-sqlite";
      import { AppSchema } from "./schema";
      
      const DB_FILENAME = "app-encrypted.db";
      
      const factory = new OPSqliteOpenFactory({
        dbFilename: DB_FILENAME,
        sqliteOptions: {
          // Enable SQLCipher encryption (requires "op-sqlite": { "sqlcipher": true } in package.json)
          encryptionKey: "your-encryption-key",
        },
      });
      
      export const powersync = new PowerSyncDatabase({
        schema: AppSchema,
        database: factory,
      });
      ```
      
      **Why good:** OP-SQLite provides SQLCipher encryption and better New Architecture support, factory pattern separates adapter config from database config
      
      **OP-SQLite package.json requirement:**
      
      ```json
      {
        "op-sqlite": {
          "sqlcipher": true
        }
      }
      ```
      
      ### App Bootstrap (Init + Connect)
      
      ```typescript
      import type { PowerSyncBackendConnector } from "@powersync/react-native";
      import { powersync } from "./database";
      import { connector } from "./connector";
      
      async function initializeDatabase(): Promise<void> {
        await powersync.init();
        // connect() starts bidirectional sync -- without it, database is local-only
        await powersync.connect(connector);
      }
      
      // On logout: stop sync, optionally clear local data
      async function handleLogout(): Promise<void> {
        // disconnect() stops sync but preserves local data
        await powersync.disconnect();
        // disconnectAndClear() stops sync AND deletes all local data
        // await powersync.disconnectAndClear();
      }
      ```
      
      ### React Context Provider
      
      ```tsx
      import { PowerSyncContext } from "@powersync/react";
      import { powersync } from "./database";
      
      function App() {
        return (
          <PowerSyncContext.Provider value={powersync}>
            <Navigation />
          </PowerSyncContext.Provider>
        );
      }
      
      export { App };
      ```
      
      **Why good:** Provider gives all descendants access via `usePowerSync()` and `useQuery()` hooks
      
      ---
      
      ## Pattern 3: React Hooks
      
      ### useQuery -- Reactive Watched Queries
      
      ```tsx
      import { useQuery } from "@powersync/react";
      import { View, Text, FlatList, ActivityIndicator } from "react-native";
      import type { TodoRecord } from "./schema";
      
      function TodoList({ listId }: { listId: string }) {
        // Automatically re-executes when the todos table changes
        const {
          data: todos,
          isLoading,
          isFetching,
          error,
        } = useQuery<TodoRecord>(
          "SELECT * FROM todos WHERE list_id = ? ORDER BY created_at DESC",
          [listId],
        );
      
        if (isLoading) return <ActivityIndicator />;
        if (error) return <Text>Error: {error.message}</Text>;
      
        return (
          <View>
            {isFetching && <Text>Syncing...</Text>}
            <FlatList
              data={todos}
              renderItem={({ item }) => (
                <Text>
                  {item.description} {item.completed ? "(done)" : ""}
                </Text>
              )}
              keyExtractor={(item) => item.id}
            />
          </View>
        );
      }
      
      export { TodoList };
      ```
      
      **Why good:** `useQuery` detects dependent tables via `EXPLAIN QUERY PLAN` and re-runs when those tables change. `isLoading` covers initial load, `isFetching` covers background refreshes.
      
      ### useQuery with runQueryOnce (No Watching)
      
      ```tsx
      // For data that won't change (e.g., static reference data)
      const { data: categories } = useQuery<CategoryRecord>(
        "SELECT * FROM categories ORDER BY name",
        [],
        { runQueryOnce: true },
      );
      ```
      
      ### useSuspenseQuery -- With React Suspense
      
      ```tsx
      import { useSuspenseQuery } from "@powersync/react";
      import { Suspense } from "react";
      import { ErrorBoundary } from "./error-boundary";
      
      function TodoListSuspense({ listId }: { listId: string }) {
        // Suspends until data is available -- no isLoading/error handling needed
        const { data: todos } = useSuspenseQuery<TodoRecord>(
          "SELECT * FROM todos WHERE list_id = ?",
          [listId],
        );
      
        return <FlatList data={todos} /* ... */ />;
      }
      
      // Must wrap in Suspense + ErrorBoundary
      function TodoScreen({ listId }: { listId: string }) {
        return (
          <ErrorBoundary>
            <Suspense fallback={<ActivityIndicator />}>
              <TodoListSuspense listId={listId} />
            </Suspense>
          </ErrorBoundary>
        );
      }
      ```
      
      ### useStatus -- Connection and Sync Status
      
      ```tsx
      import { useStatus } from "@powersync/react";
      import { View, Text } from "react-native";
      
      function SyncIndicator() {
        const status = useStatus();
      
        return (
          <View>
            <Text>Connected: {status.connected ? "Yes" : "No"}</Text>
            <Text>Initial sync done: {status.hasSynced ? "Yes" : "No"}</Text>
          </View>
        );
      }
      
      export { SyncIndicator };
      ```
      
      ### usePowerSync -- Direct Database Access
      
      ```tsx
      import { usePowerSync } from "@powersync/react";
      
      function useCreateList() {
        const powersync = usePowerSync();
      
        const createList = async (name: string, ownerId: string) => {
          await powersync.execute(
            "INSERT INTO lists (id, name, created_at, owner_id) VALUES (uuid(), ?, datetime(), ?)",
            [name, ownerId],
          );
        };
      
        return { createList };
      }
      ```
      
      ---
      
      ## Pattern 4: CRUD Operations
      
      ### Write Operations (execute)
      
      ```typescript
      import type { AbstractPowerSyncDatabase } from "@powersync/react-native";
      
      // INSERT -- uuid() is a PowerSync SQL function
      async function createTodo(
        db: AbstractPowerSyncDatabase,
        listId: string,
        description: string,
      ): Promise<void> {
        await db.execute(
          "INSERT INTO todos (id, list_id, description, created_at, completed) VALUES (uuid(), ?, ?, datetime(), 0)",
          [listId, description],
        );
      }
      
      // UPDATE
      async function completeTodo(
        db: AbstractPowerSyncDatabase,
        todoId: string,
      ): Promise<void> {
        await db.execute(
          "UPDATE todos SET completed = 1, completed_at = datetime() WHERE id = ?",
          [todoId],
        );
      }
      
      // DELETE
      async function deleteTodo(
        db: AbstractPowerSyncDatabase,
        todoId: string,
      ): Promise<void> {
        await db.execute("DELETE FROM todos WHERE id = ?", [todoId]);
      }
      ```
      
      ### Read Operations (get, getAll, getOptional)
      
      ```typescript
      // getAll -- returns array, empty array if no results
      const todos = await db.getAll<TodoRecord>(
        "SELECT * FROM todos WHERE list_id = ?",
        [listId],
      );
      
      // get -- returns single row, THROWS if not found
      const todo = await db.get<TodoRecord>("SELECT * FROM todos WHERE id = ?", [
        todoId,
      ]);
      
      // getOptional -- returns single row or null
      const maybeTodo = await db.getOptional<TodoRecord>(
        "SELECT * FROM todos WHERE id = ?",
        [todoId],
      );
      ```
      
      **Why good:** Three read methods for three use cases -- `getAll` for lists, `get` when the row must exist (throws on missing), `getOptional` when the row might not exist
      
      ### Transactions
      
      ```typescript
      // Execute multiple operations atomically
      await db.writeTransaction(async (tx) => {
        await tx.execute(
          "INSERT INTO lists (id, name, created_at, owner_id) VALUES (uuid(), ?, datetime(), ?)",
          [name, ownerId],
        );
        await tx.execute(
          "INSERT INTO todos (id, list_id, description, created_at, completed) VALUES (uuid(), last_insert_rowid(), ?, datetime(), 0)",
          [firstTodoDescription],
        );
      });
      ```
      
      ---
      
      ## Pattern 5: Watched Queries with Raw API (Non-React)
      
      For use outside React components (services, background tasks):
      
      ```typescript
      // watch() returns an async iterable
      for await (const result of powersync.watch(
        "SELECT * FROM todos WHERE completed = 0",
      )) {
        console.log("Active todos:", result.rows?.length);
      }
      
      // With throttling
      for await (const result of powersync.watch(
        "SELECT COUNT(*) as count FROM todos",
        [],
        { throttleMs: 1000 }, // Re-query at most once per second
      )) {
        updateBadgeCount(result.rows?.[0]?.count ?? 0);
      }
      ```
      
      **Why good:** Works outside React, async iterable is a standard JS pattern, `throttleMs` prevents excessive re-queries during rapid changes
      
    • sync.md 9.6 KB
      # SQLite + PowerSync - Sync Patterns
      
      > Backend connectors, sync rules, and conflict resolution. See [SKILL.md](../SKILL.md) for decision guidance and red flags.
      
      **Related:** [core.md](core.md) for schema and database setup.
      
      ---
      
      ## Pattern 6: Backend Connector (Generic)
      
      The connector interface requires two methods: `fetchCredentials()` for authentication and `uploadData()` for pushing local changes to your backend.
      
      ```typescript
      import type {
        PowerSyncBackendConnector,
        PowerSyncCredentials,
        AbstractPowerSyncDatabase,
      } from "@powersync/react-native";
      
      const POWERSYNC_URL = "https://your-instance.powersync.journeyapps.com";
      
      export const connector: PowerSyncBackendConnector = {
        fetchCredentials: async (): Promise<PowerSyncCredentials> => {
          // Get auth token from your authentication provider
          const session = await getAuthSession();
      
          if (!session) {
            throw new Error("Not authenticated");
          }
      
          return {
            endpoint: POWERSYNC_URL,
            token: session.accessToken,
            expiresAt: session.expiresAt
              ? new Date(session.expiresAt * 1000)
              : undefined,
          };
        },
      
        uploadData: async (database: AbstractPowerSyncDatabase): Promise<void> => {
          const transaction = await database.getNextCrudTransaction();
          if (!transaction) return;
      
          try {
            for (const op of transaction.crud) {
              await sendOperationToBackend(op);
            }
            // Mark transaction as successfully uploaded
            await transaction.complete();
          } catch (error) {
            // Transaction will be retried after a delay (default: 5 seconds)
            throw error;
          }
        },
      };
      ```
      
      **Why good:** Clean separation of auth and upload logic, transaction-based processing, unhandled errors trigger automatic retries
      
      **Gotcha:** `fetchCredentials()` is cached by the SDK and only called when credentials expire or on initial connect. Don't put side effects here.
      
      ---
      
      ## Pattern 7: Supabase Backend Connector
      
      A complete connector for Supabase, the most common PowerSync backend.
      
      ```typescript
      import type {
        PowerSyncBackendConnector,
        PowerSyncCredentials,
        AbstractPowerSyncDatabase,
        CrudEntry,
      } from "@powersync/react-native";
      import { UpdateType } from "@powersync/react-native";
      import { createClient } from "@supabase/supabase-js";
      
      const SUPABASE_URL = "https://your-project.supabase.co";
      const SUPABASE_ANON_KEY = "your-anon-key";
      const POWERSYNC_URL = "https://your-instance.powersync.journeyapps.com";
      
      const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
      
      async function applyOperation(op: CrudEntry): Promise<void> {
        const table = supabase.from(op.table);
      
        switch (op.op) {
          case UpdateType.PUT: {
            // PUT = full row upsert (insert or replace)
            const record = { ...op.opData, id: op.id };
            const { error } = await table.upsert(record);
            if (error) throw new Error(`PUT failed on ${op.table}: ${error.message}`);
            break;
          }
          case UpdateType.PATCH: {
            // PATCH = partial update (only changed fields)
            const { error } = await table.update(op.opData).eq("id", op.id);
            if (error)
              throw new Error(`PATCH failed on ${op.table}: ${error.message}`);
            break;
          }
          case UpdateType.DELETE: {
            const { error } = await table.delete().eq("id", op.id);
            if (error)
              throw new Error(`DELETE failed on ${op.table}: ${error.message}`);
            break;
          }
        }
      }
      
      export const supabaseConnector: PowerSyncBackendConnector = {
        fetchCredentials: async (): Promise<PowerSyncCredentials> => {
          const {
            data: { session },
            error,
          } = await supabase.auth.getSession();
      
          if (error) throw new Error(`Auth failed: ${error.message}`);
          if (!session) throw new Error("No active session");
      
          return {
            endpoint: POWERSYNC_URL,
            token: session.access_token,
            expiresAt: session.expires_at
              ? new Date(session.expires_at * 1000)
              : undefined,
          };
        },
      
        uploadData: async (database: AbstractPowerSyncDatabase): Promise<void> => {
          const transaction = await database.getNextCrudTransaction();
          if (!transaction) return;
      
          try {
            for (const op of transaction.crud) {
              await applyOperation(op);
            }
            await transaction.complete();
          } catch (error) {
            // Rethrow to trigger retry
            throw error;
          }
        },
      };
      ```
      
      **Why good:** Handles all three operation types (PUT/PATCH/DELETE), maps directly to Supabase PostgREST, errors trigger retries
      
      ---
      
      ## Pattern 8: Sync Rules (Bucket Definitions)
      
      Sync rules are YAML files configured on the PowerSync Service. They define which data each client receives.
      
      ### Per-User Data
      
      ```yaml
      bucket_definitions:
        user_lists:
          # Parameters determine which buckets are created
          parameters: SELECT request.user_id() as user_id
          # Data queries select rows for each bucket
          data:
            - SELECT * FROM lists WHERE owner_id = bucket.user_id
            - SELECT * FROM todos WHERE list_id IN (
              SELECT id FROM lists WHERE owner_id = bucket.user_id
              )
      ```
      
      ### Global Data (All Clients)
      
      ```yaml
      bucket_definitions:
        global_config:
          # No parameters = global bucket, synced to everyone
          data:
            - SELECT * FROM app_config
            - SELECT * FROM categories
      ```
      
      ### Multi-Tenant (Organization-Based)
      
      ```yaml
      bucket_definitions:
        org_data:
          parameters: >
            SELECT org_id FROM org_members
            WHERE user_id = request.user_id()
          data:
            - SELECT * FROM projects WHERE org_id = bucket.org_id
            - SELECT * FROM tasks WHERE project_id IN (
              SELECT id FROM projects WHERE org_id = bucket.org_id
              )
      ```
      
      ### Client Parameters
      
      ```yaml
      bucket_definitions:
        filtered_data:
          # request.parameters() accesses client-provided params
          parameters: >
            SELECT request.parameters() ->> 'region' as region
          data:
            - SELECT * FROM stores WHERE region = bucket.region
      ```
      
      Client-side:
      
      ```typescript
      await powersync.connect(connector, {
        params: { region: "us-west" },
      });
      ```
      
      **Constraints:**
      
      - Maximum 1,000 buckets per client (default, higher on Team/Enterprise plans)
      - Table names in data queries must match client-side schema table names
      - Only a subset of SQL is supported in sync rules
      
      ---
      
      ## Pattern 9: Conflict Resolution Strategies
      
      ### Default: Last-Write-Wins (via Supabase Upsert)
      
      ```typescript
      // This is what the default Supabase connector does -- upsert replaces the row
      case UpdateType.PUT: {
        const record = { ...op.opData, id: op.id };
        await supabase.from(op.table).upsert(record);
        break;
      }
      ```
      
      **When to use:** Simple apps where the latest write should always win.
      
      ### Timestamp-Based Rejection
      
      ```typescript
      async function applyWithTimestampCheck(op: CrudEntry): Promise<void> {
        if (op.op === UpdateType.PATCH && op.opData) {
          // Fetch current server version
          const { data: serverRow } = await supabase
            .from(op.table)
            .select("updated_at")
            .eq("id", op.id)
            .single();
      
          // Reject if server is newer
          if (serverRow && op.opData.updated_at < serverRow.updated_at) {
            console.warn(`Stale update rejected for ${op.table}:${op.id}`);
            return; // Skip this operation
          }
        }
      
        await applyOperation(op);
      }
      ```
      
      **When to use:** When stale writes should be silently dropped.
      
      ### Server-Side Business Rule Validation
      
      ```typescript
      async function applyWithValidation(op: CrudEntry): Promise<void> {
        if (op.table === "orders" && op.op === UpdateType.PATCH) {
          const { data: order } = await supabase
            .from("orders")
            .select("status")
            .eq("id", op.id)
            .single();
      
          // Prevent modifying shipped orders
          if (order?.status === "shipped") {
            throw new Error(`Cannot modify shipped order ${op.id}`);
          }
        }
      
        await applyOperation(op);
      }
      ```
      
      **When to use:** Business-critical data with state machine constraints.
      
      ### Conflict Recording (User Resolution)
      
      ```typescript
      async function applyWithConflictRecording(op: CrudEntry): Promise<void> {
        if (op.op !== UpdateType.PATCH) {
          await applyOperation(op);
          return;
        }
      
        const { data: serverRow } = await supabase
          .from(op.table)
          .select("*")
          .eq("id", op.id)
          .single();
      
        // If server version differs, record conflict instead of overwriting
        if (serverRow && serverRow.updated_at !== op.opData?.updated_at) {
          await supabase.from("write_conflicts").insert({
            table_name: op.table,
            row_id: op.id,
            client_data: op.opData,
            server_data: serverRow,
            resolved: false,
          });
          return; // Don't apply -- let user resolve
        }
      
        await applyOperation(op);
      }
      ```
      
      **When to use:** High-stakes data (medical, financial) where losing information is unacceptable.
      
      ---
      
      ## Pattern 10: Upload Error Handling and Retries
      
      ```typescript
      const MAX_RETRY_OPERATIONS = 50;
      
      uploadData: async (database: AbstractPowerSyncDatabase): Promise<void> => {
        // Process in batches to avoid overwhelming the backend
        const batch = await database.getCrudBatch(MAX_RETRY_OPERATIONS);
        if (!batch) return;
      
        const failures: CrudEntry[] = [];
      
        for (const op of batch.crud) {
          try {
            await applyOperation(op);
          } catch (error) {
            console.error(`Failed to upload ${op.op} on ${op.table}:${op.id}`, error);
            failures.push(op);
          }
        }
      
        if (failures.length === 0) {
          // All operations succeeded -- mark batch complete
          await batch.complete();
        } else {
          // Some failed -- throw to trigger retry of entire batch
          throw new Error(`${failures.length} operations failed, will retry`);
        }
      },
      ```
      
      **Why good:** Batch processing limits payload size, individual error logging helps debugging, failed batch triggers automatic retry
      
      **Alternative:** Use `getNextCrudTransaction()` instead of `getCrudBatch()` when operations must be applied atomically (all-or-nothing per transaction).
      
  • reference.md 6.3 KB
    # SQLite + PowerSync Quick Reference
    
    > API reference and setup checklist. See [SKILL.md](SKILL.md) for decision frameworks, red flags, and anti-patterns.
    
    ---
    
    ## Package Overview
    
    | Package                   | Purpose                                  |
    | ------------------------- | ---------------------------------------- |
    | `@powersync/react-native` | Core SDK: database, schema, sync engine  |
    | `@powersync/react`        | React hooks: useQuery, useStatus, etc.   |
    | `@powersync/op-sqlite`    | OP-SQLite adapter with SQLCipher support |
    
    **Peer dependencies:** One SQLite adapter is required -- either `@journeyapps/react-native-quick-sqlite` (default) or `@powersync/op-sqlite` + `@op-engineering/op-sqlite`.
    
    ---
    
    ## Schema API
    
    ### Column Types
    
    | Type             | SQLite Type | Example Values            |
    | ---------------- | ----------- | ------------------------- |
    | `column.text`    | TEXT        | Strings, UUIDs, ISO dates |
    | `column.integer` | INTEGER     | Numbers, booleans (0/1)   |
    | `column.real`    | REAL        | Floating-point numbers    |
    
    **Note:** The `id` column (TEXT, primary key) is auto-created. Never declare it.
    
    ### Table Constructor
    
    ```typescript
    new Table(columns, options?)
    ```
    
    | Option      | Type                       | Purpose                       |
    | ----------- | -------------------------- | ----------------------------- |
    | `indexes`   | `Record<string, string[]>` | Named indexes on columns      |
    | `localOnly` | `boolean`                  | Never synced (default: false) |
    | `viewName`  | `string`                   | Custom SQLite view name       |
    
    ---
    
    ## PowerSyncDatabase API
    
    ### Read Methods
    
    | Method             | Returns     | Throws on empty? |
    | ------------------ | ----------- | ---------------- |
    | `getAll(sql)`      | `T[]`       | No (empty array) |
    | `get(sql)`         | `T`         | Yes              |
    | `getOptional(sql)` | `T \| null` | No               |
    
    ### Write Methods
    
    | Method                 | Purpose                            |
    | ---------------------- | ---------------------------------- |
    | `execute(sql, params)` | INSERT, UPDATE, DELETE             |
    | `writeTransaction(fn)` | Atomic multi-statement transaction |
    
    ### Watch Methods
    
    | Method                     | Purpose                                   |
    | -------------------------- | ----------------------------------------- |
    | `watch(sql, params, opts)` | Async iterable that re-queries on changes |
    
    ### Lifecycle Methods
    
    | Method                 | Purpose                          |
    | ---------------------- | -------------------------------- |
    | `init()`               | Create tables from schema        |
    | `connect(connector)`   | Start bidirectional sync         |
    | `disconnect()`         | Stop sync, preserve local data   |
    | `disconnectAndClear()` | Stop sync, delete all local data |
    
    ### Upload Queue Methods
    
    | Method                     | Returns                   | Purpose                 |
    | -------------------------- | ------------------------- | ----------------------- |
    | `getNextCrudTransaction()` | `CrudTransaction \| null` | Next atomic transaction |
    | `getCrudBatch(limit)`      | `CrudBatch \| null`       | Batch of operations     |
    
    ---
    
    ## React Hooks API (@powersync/react)
    
    | Hook                    | Returns                                  | Purpose                     |
    | ----------------------- | ---------------------------------------- | --------------------------- |
    | `useQuery(sql)`         | `{ data, isLoading, isFetching, error }` | Reactive watched query      |
    | `useSuspenseQuery(sql)` | `{ data }`                               | Watched query with Suspense |
    | `useStatus()`           | `{ connected, hasSynced }`               | Sync connection status      |
    | `usePowerSync()`        | `PowerSyncDatabase`                      | Direct database instance    |
    
    ### useQuery Options
    
    | Option         | Type      | Default | Purpose                             |
    | -------------- | --------- | ------- | ----------------------------------- |
    | `runQueryOnce` | `boolean` | `false` | Disable watching (one-time fetch)   |
    | `throttleMs`   | `number`  | -       | Minimum interval between re-queries |
    
    ---
    
    ## CrudEntry Reference
    
    | Property   | Type             | Description                      |
    | ---------- | ---------------- | -------------------------------- |
    | `id`       | `string`         | Row ID                           |
    | `table`    | `string`         | Table name                       |
    | `op`       | `UpdateType`     | `PUT`, `PATCH`, or `DELETE`      |
    | `opData`   | `Record \| null` | Changed fields (null for DELETE) |
    | `clientId` | `string`         | Unique client identifier         |
    
    ### UpdateType Enum
    
    | Value    | Meaning                              |
    | -------- | ------------------------------------ |
    | `PUT`    | Full row insert or replace           |
    | `PATCH`  | Partial update (only changed fields) |
    | `DELETE` | Row deletion                         |
    
    ---
    
    ## Sync Rules YAML Reference
    
    ```yaml
    bucket_definitions:
      bucket_name:
        # Parameter queries determine bucket creation
        parameters: SELECT request.user_id() as user_id
        # Data queries select rows for each bucket
        data:
          - SELECT * FROM table WHERE owner_id = bucket.user_id
    ```
    
    ### Built-in Functions
    
    | Function                         | Returns               | Use In       |
    | -------------------------------- | --------------------- | ------------ |
    | `request.user_id()`              | Authenticated user ID | Parameters   |
    | `request.parameters() ->> 'key'` | Client-provided param | Parameters   |
    | `bucket.param_name`              | Parameter value       | Data queries |
    
    ---
    
    ## Setup Checklist
    
    - [ ] Install `@powersync/react-native` and `@powersync/react`
    - [ ] Choose SQLite adapter (default or OP-SQLite)
    - [ ] Define schema with `Table` and `column` types (no `id` column)
    - [ ] Create `PowerSyncDatabase` instance with schema
    - [ ] Implement backend connector (`fetchCredentials` + `uploadData`)
    - [ ] Wrap app in `PowerSyncContext.Provider`
    - [ ] Call `powersync.init()` then `powersync.connect(connector)` on app start
    - [ ] Configure sync rules (bucket definitions) on PowerSync Service
    - [ ] Use `useQuery` for reactive reads, `execute` for writes
    - [ ] Test offline: disconnect network, verify reads/writes work locally
    - [ ] Test sync: reconnect, verify changes propagate to/from server
    
  • SKILL.md 18.8 KB
    ---
    name: mobile-storage-sqlite-powersync
    description: PowerSync offline-first sync engine on SQLite for React Native - schema definition, watched queries, CRUD operations, backend connectors, sync rules, conflict resolution, attachments
    ---
    
    # SQLite + PowerSync Patterns
    
    > **Quick Guide:** Use `@powersync/react-native` for offline-first apps backed by local SQLite. Define schemas with `Table` and `column.text/integer/real` (id column is auto-created). Use `PowerSyncDatabase` for reads/writes, `useQuery` from `@powersync/react` for reactive watched queries. Connect to your backend via a connector implementing `fetchCredentials` + `uploadData`. Conflict resolution defaults to last-write-wins per field -- customize in `uploadData`. Use `@powersync/op-sqlite` for SQLCipher encryption.
    
    ---
    
    <critical_requirements>
    
    ## CRITICAL: Before Using This Skill
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST define schemas with `new Table({ ... })` using `column.text`, `column.integer`, `column.real` -- NEVER declare an `id` column, PowerSync creates it automatically)**
    
    **(You MUST call `powersync.connect(connector)` after `init()` to start syncing -- without it the database is local-only with no sync)**
    
    **(You MUST implement both `fetchCredentials()` and `uploadData()` in your backend connector -- missing either breaks the sync loop)**
    
    **(You MUST use `useQuery` from `@powersync/react` for reactive queries -- raw `getAll()` does NOT re-render on data changes)**
    
    </critical_requirements>
    
    ---
    
    **Auto-detection:** PowerSync, powersync, @powersync/react-native, @powersync/react, @powersync/op-sqlite, PowerSyncDatabase, useQuery, usePowerSync, useStatus, useSuspenseQuery, PowerSyncBackendConnector, fetchCredentials, uploadData, column.text, column.integer, column.real, Schema, Table, sync rules, bucket_definitions, offline-first SQLite, watched query, CrudEntry, CrudTransaction, AttachmentQueue, AttachmentTable, local-only table
    
    **When to use:**
    
    - Building offline-first React Native apps that sync with a cloud database
    - Storing relational data locally in SQLite with automatic cloud sync
    - Implementing reactive UIs that update when synced data changes
    - Handling CRUD operations that work offline and sync when reconnected
    - Defining sync rules (bucket definitions) for partial data replication
    - Managing file attachments with offline upload/download queues
    
    **Key patterns covered:**
    
    - Schema definition with `Table`, `column` types, indexes, and local-only tables
    - `PowerSyncDatabase` setup with default or OP-SQLite adapter
    - React hooks: `useQuery`, `useSuspenseQuery`, `useStatus`, `usePowerSync`
    - Backend connector: `fetchCredentials()` + `uploadData()` implementation
    - CRUD operations via `execute()`, `get()`, `getAll()`, `getOptional()`
    - Sync rules with bucket definitions (YAML) for per-user data filtering
    - Conflict resolution strategies (last-write-wins, field-level, custom)
    - Attachment handling with `AttachmentTable` and `AttachmentQueue`
    - OP-SQLite integration for SQLCipher encryption
    
    **When NOT to use:**
    
    - Simple key-value storage without sync (use a key-value store)
    - Apps that never go offline and always have connectivity
    - Data that does not need relational queries (use a key-value store)
    - File-only storage without structured metadata (use the filesystem)
    
    **Detailed Resources:**
    
    - [examples/core.md](examples/core.md) - Schema, database setup, CRUD, watched queries, hooks
    - [examples/sync.md](examples/sync.md) - Backend connector, sync rules, conflict resolution
    - [examples/attachments.md](examples/attachments.md) - Attachment queue, upload/download, storage adapters
    - [reference.md](reference.md) - API reference, setup checklist
    
    ---
    
    <philosophy>
    
    ## Philosophy
    
    PowerSync is an **offline-first sync engine** that sits on top of SQLite. The core idea: your app reads and writes to a local SQLite database instantly (no network calls), and PowerSync handles bidirectional sync with your cloud database in the background.
    
    **Core principles:**
    
    1. **Local-first** -- all reads and writes hit local SQLite, so the app works instantly and offline
    2. **Sync is transparent** -- PowerSync streams changes from the server and uploads local mutations automatically
    3. **Schema drives everything** -- the client schema defines local tables, the server sync rules define what data each client receives
    4. **Conflict resolution is yours** -- defaults to last-write-wins, but `uploadData()` gives you full control
    5. **Watched queries for reactivity** -- `useQuery` re-executes queries when dependent tables change, keeping UI in sync
    
    **Architecture overview:**
    
    ```
    Client (React Native)              Cloud
    +-------------------+         +-------------------+
    | Local SQLite DB   | <-sync->| PowerSync Service |<--- Source DB (Postgres, etc.)
    | (PowerSyncDatabase)|        | (Sync Rules)      |
    +-------------------+         +-------------------+
    | @powersync/react  |         | Bucket Defs       |
    | (useQuery, etc.)  |         | (YAML config)     |
    +-------------------+         +-------------------+
    ```
    
    **Data flow:**
    
    - **Writes:** App calls `execute(INSERT/UPDATE/DELETE)` on local SQLite. PowerSync queues the change and calls your `uploadData()` to push it to the backend.
    - **Reads:** Sync rules on the server determine which data each client receives. The PowerSync Service streams changes to the client's local SQLite. `useQuery` watches for table changes and re-renders.
    
    **Column types:** Only three types exist -- `column.text`, `column.integer`, `column.real`. The `id` column (text, primary key) is auto-created. If a synced value doesn't match the declared type, it is cast automatically.
    
    </philosophy>
    
    ---
    
    <patterns>
    
    ## Core Patterns
    
    ### Pattern 1: Schema Definition
    
    Define your client-side schema using `Table` and `column` types. The schema mirrors your server tables (minus the `id` column, which is auto-created).
    
    ```typescript
    import { column, Schema, Table } from "@powersync/react-native";
    
    const lists = new Table({
      created_at: column.text,
      name: column.text,
      owner_id: column.text,
    });
    
    const todos = new Table(
      {
        list_id: column.text,
        created_at: column.text,
        completed_at: column.text,
        description: column.text,
        completed: column.integer,
      },
      { indexes: { list: ["list_id"] } },
    );
    
    export const AppSchema = new Schema({ todos, lists });
    
    // Derive types from schema
    export type Database = (typeof AppSchema)["types"];
    export type TodoRecord = Database["todos"];
    export type ListRecord = Database["lists"];
    ```
    
    **Why good:** Schema is source of truth for types, indexes optimize query performance, no manual `id` column needed
    
    **Gotcha:** Table names in the schema must match table names in your sync rules. Mismatches cause data to silently not sync.
    
    See [examples/core.md](examples/core.md) for local-only tables and index configuration.
    
    ---
    
    ### Pattern 2: PowerSyncDatabase Setup
    
    Create the database instance at app startup. Choose between the default SQLite adapter or OP-SQLite for encryption.
    
    ```typescript
    import { PowerSyncDatabase } from "@powersync/react-native";
    import { AppSchema } from "./schema";
    
    const DB_FILENAME = "app.db";
    
    export const powersync = new PowerSyncDatabase({
      schema: AppSchema,
      database: { dbFilename: DB_FILENAME },
    });
    
    // Initialize and connect (typically in app bootstrap)
    async function initDatabase(connector: PowerSyncBackendConnector) {
      await powersync.init();
      await powersync.connect(connector);
    }
    ```
    
    **Why good:** Single instance shared across app, `init()` creates SQLite tables from schema, `connect()` starts bidirectional sync
    
    **Gotcha:** Without `connect()`, the database works but is purely local -- no sync occurs.
    
    See [examples/core.md](examples/core.md) for OP-SQLite setup with encryption and the React context provider pattern.
    
    ---
    
    ### Pattern 3: React Hooks for Reactive Queries
    
    Use `useQuery` from `@powersync/react` for watched queries that re-execute when dependent tables change. Wrap your app in `PowerSyncContext.Provider`.
    
    ```tsx
    import { useQuery, useStatus, usePowerSync } from "@powersync/react";
    
    function TodoList({ listId }: { listId: string }) {
      const {
        data: todos,
        isLoading,
        error,
      } = useQuery<TodoRecord>(
        "SELECT * FROM todos WHERE list_id = ? ORDER BY created_at DESC",
        [listId],
      );
    
      if (isLoading) return <ActivityIndicator />;
      if (error) return <Text>Error: {error.message}</Text>;
    
      return (
        <FlatList
          data={todos}
          renderItem={({ item }) => <TodoItem todo={item} />}
          keyExtractor={(item) => item.id}
        />
      );
    }
    ```
    
    **Why good:** `useQuery` automatically re-runs when the `todos` table changes (insert, update, delete), `isLoading` and `error` handle loading/error states
    
    See [examples/core.md](examples/core.md) for `useSuspenseQuery`, `useStatus`, `usePowerSync`, and `runQueryOnce` usage.
    
    ---
    
    ### Pattern 4: CRUD Operations
    
    All writes use `execute()` with parameterized SQL. PowerSync queues changes and calls your `uploadData()` to sync.
    
    ```typescript
    import { usePowerSync } from "@powersync/react";
    
    function useTodos(listId: string) {
      const powersync = usePowerSync();
    
      const addTodo = async (description: string) => {
        await powersync.execute(
          "INSERT INTO todos (id, list_id, description, created_at, completed) VALUES (uuid(), ?, ?, datetime(), 0)",
          [listId, description],
        );
      };
    
      const toggleTodo = async (id: string, completed: boolean) => {
        const completedAt = completed ? new Date().toISOString() : null;
        await powersync.execute(
          "UPDATE todos SET completed = ?, completed_at = ? WHERE id = ?",
          [completed ? 1 : 0, completedAt, id],
        );
      };
    
      const deleteTodo = async (id: string) => {
        await powersync.execute("DELETE FROM todos WHERE id = ?", [id]);
      };
    
      return { addTodo, toggleTodo, deleteTodo };
    }
    ```
    
    **Why good:** Writes hit local SQLite instantly (no network wait), `uuid()` generates IDs client-side, parameterized queries prevent SQL injection
    
    **Gotcha:** `execute()` returns `{ rowsAffected, insertId }`. When using views, `rowsAffected` may return 0 -- use `RETURNING` clauses to confirm mutations.
    
    See [examples/core.md](examples/core.md) for `get()`, `getAll()`, `getOptional()`, and transaction patterns.
    
    ---
    
    ### Pattern 5: Backend Connector
    
    The connector bridges PowerSync with your backend. Implement `fetchCredentials()` for auth and `uploadData()` for pushing local changes.
    
    ```typescript
    import type {
      PowerSyncBackendConnector,
      PowerSyncCredentials,
    } from "@powersync/react-native";
    import type { AbstractPowerSyncDatabase } from "@powersync/react-native";
    
    export const connector: PowerSyncBackendConnector = {
      fetchCredentials: async (): Promise<PowerSyncCredentials> => {
        // Return your PowerSync instance URL and a valid JWT
        const session = await getAuthSession();
        return {
          endpoint: POWERSYNC_URL,
          token: session.accessToken,
          expiresAt: session.expiresAt,
        };
      },
    
      uploadData: async (database: AbstractPowerSyncDatabase): Promise<void> => {
        const transaction = await database.getNextCrudTransaction();
        if (!transaction) return;
    
        for (const op of transaction.crud) {
          // Send each operation to your backend API
          await applyOperation(op);
        }
        await transaction.complete();
      },
    };
    ```
    
    **Why good:** Clean separation of auth and data upload, transaction-based processing ensures atomicity, `complete()` marks the batch as synced
    
    See [examples/sync.md](examples/sync.md) for the full Supabase connector, custom backend patterns, and error handling with retries.
    
    ---
    
    ### Pattern 6: Sync Rules (Bucket Definitions)
    
    Sync rules (YAML) define which server data each client receives. Configured on the PowerSync Service, not in client code.
    
    ```yaml
    bucket_definitions:
      user_lists:
        parameters: SELECT request.user_id() as user_id
        data:
          - SELECT * FROM lists WHERE owner_id = bucket.user_id
          - SELECT * FROM todos WHERE list_id IN (
            SELECT id FROM lists WHERE owner_id = bucket.user_id
            )
    
      global_settings:
        # No parameters = global bucket, synced to all clients
        data:
          - SELECT * FROM settings
    ```
    
    **Why good:** Per-user data filtering at the server, global buckets for shared data, SQL-based rules are familiar
    
    **Gotcha:** Maximum 1,000 buckets per client (default). Table names must match client schema.
    
    See [examples/sync.md](examples/sync.md) for parameterized buckets, client parameters, and multi-tenant patterns.
    
    ---
    
    ### Pattern 7: Conflict Resolution
    
    Default behavior is **last-write-wins per field**. Customize in your `uploadData()` implementation.
    
    The key insight: PowerSync gives you full control in `uploadData()`. You choose how to handle each `CrudEntry` operation -- accept, reject, merge, or record conflicts.
    
    Common strategies:
    
    - **Last-write-wins (default):** Simply upsert each operation
    - **Timestamp-based:** Compare client vs server timestamps, reject stale writes
    - **Field-level merge:** Apply only newer field values, keep others
    - **Server-side validation:** Enforce business rules (e.g., prevent modifying shipped orders)
    - **Conflict recording:** Store both versions for manual user resolution
    
    See [examples/sync.md](examples/sync.md) for complete conflict resolution implementations.
    
    ---
    
    ### Pattern 8: Attachment Handling
    
    Use `AttachmentTable` in your schema and `AttachmentQueue` for offline-capable file upload/download.
    
    ```typescript
    import { AttachmentTable } from "@powersync/react-native";
    import { column, Schema, Table } from "@powersync/react-native";
    
    const users = new Table({
      name: column.text,
      photo_id: column.text, // References attachment ID
    });
    
    export const AppSchema = new Schema({
      users,
      attachments: new AttachmentTable(),
    });
    ```
    
    The `AttachmentQueue` manages the lifecycle: local save, queued upload, synced state, automatic download on other devices, retry on failure.
    
    See [examples/attachments.md](examples/attachments.md) for queue setup, upload/download handlers, and storage adapter patterns.
    
    </patterns>
    
    ---
    
    <decision_framework>
    
    ## Decision Framework
    
    ```
    What kind of data are you storing?
    |
    +-> Relational data that needs offline + cloud sync?
    |   +-> YES -> PowerSync + SQLite (this skill)
    |   +-> NO  -> Key-value pairs only?
    |       +-> YES -> Use a key-value store (not this skill)
    |       +-> NO  -> Files/media only?
    |           +-> YES -> Use the filesystem
    |
    +-> Do you need reactive queries in React?
    |   +-> YES -> Use useQuery from @powersync/react
    |   +-> NO  -> Use powersync.getAll() / get() directly
    |
    +-> Do you need on-device encryption?
    |   +-> YES -> Use @powersync/op-sqlite with SQLCipher
    |   +-> NO  -> Use the default SQLite adapter
    |
    +-> Do you have file attachments?
    |   +-> YES -> Use AttachmentTable + AttachmentQueue
    |   +-> NO  -> Standard schema is sufficient
    |
    +-> How should conflicts be resolved?
        +-> Simple apps -> Last-write-wins (default)
        +-> Collaborative editing -> Field-level merge or CRDTs
        +-> Business-critical -> Server-side validation + conflict recording
    ```
    
    ### When to Use Each Query API
    
    | Scenario                       | API                                              |
    | ------------------------------ | ------------------------------------------------ |
    | Reactive component data        | `useQuery()` from `@powersync/react`             |
    | Reactive with Suspense         | `useSuspenseQuery()` from `@powersync/react`     |
    | One-time fetch (no reactivity) | `useQuery()` with `runQueryOnce: true`           |
    | Service/utility reads          | `powersync.getAll()` / `get()` / `getOptional()` |
    | Write operations               | `powersync.execute()`                            |
    | Connection status              | `useStatus()` from `@powersync/react`            |
    | Database instance access       | `usePowerSync()` from `@powersync/react`         |
    
    </decision_framework>
    
    ---
    
    <red_flags>
    
    ## RED FLAGS
    
    **High Priority Issues:**
    
    - Declaring an `id` column in schema -- PowerSync auto-creates `id` as `text` primary key. Declaring it causes conflicts.
    - Calling `execute()` for reads (SELECT) instead of `getAll()` / `useQuery()` -- `execute()` does not return query results in a usable format
    - Forgetting `powersync.connect(connector)` -- database works locally but nothing syncs, easy to miss in development
    - Using `getAll()` in React components expecting reactivity -- raw reads do not watch for changes, use `useQuery()` instead
    - Missing `transaction.complete()` in `uploadData()` -- unacknowledged transactions retry indefinitely, causing duplicate uploads
    
    **Medium Priority Issues:**
    
    - Schema table names not matching sync rule table names -- data silently fails to sync
    - Not handling `fetchCredentials()` returning null -- happens when auth session expires, must re-authenticate
    - Storing large blobs in SQLite columns -- use `AttachmentTable` for files, keep SQLite for metadata
    - Missing indexes on frequently queried columns -- sync queries can be slow with large datasets
    - Using `column.integer` for booleans without consistent 0/1 values -- SQLite has no native boolean type
    
    **Gotchas & Edge Cases:**
    
    - `uuid()` is a PowerSync SQL function, not a JavaScript function -- use it in SQL strings, not in JS
    - `column.real` stores IEEE 754 doubles -- be aware of floating-point precision for currency (use integer cents instead)
    - Sync rules YAML uses `request.user_id()` to access the authenticated user ID from the JWT -- not a custom function
    - `getNextCrudTransaction()` returns `null` when the upload queue is empty -- always check before iterating
    - `execute()` with views may return `rowsAffected: 0` even on success -- use `RETURNING` clause for confirmation
    - PowerSync supports WebSocket (default since v1.11.0) and HTTP streaming for sync -- WebSocket is recommended
    - The Rust-based sync client is enabled by default since v1.29.0 -- pass `clientImplementation: SyncClientImplementation.JAVASCRIPT` to use the legacy JS client
    - `disconnectAndClear()` removes all local data -- use `disconnect()` to stop sync while preserving local data
    - Local-only tables (set `localOnly: true` on Table options) are never synced -- useful for draft data or app state
    
    </red_flags>
    
    ---
    
    <critical_reminders>
    
    ## CRITICAL REMINDERS
    
    > **All code must follow project conventions in CLAUDE.md**
    
    **(You MUST define schemas with `new Table({ ... })` using `column.text`, `column.integer`, `column.real` -- NEVER declare an `id` column, PowerSync creates it automatically)**
    
    **(You MUST call `powersync.connect(connector)` after `init()` to start syncing -- without it the database is local-only with no sync)**
    
    **(You MUST implement both `fetchCredentials()` and `uploadData()` in your backend connector -- missing either breaks the sync loop)**
    
    **(You MUST use `useQuery` from `@powersync/react` for reactive queries -- raw `getAll()` does NOT re-render on data changes)**
    
    **Failure to follow these rules will cause silent sync failures, missing data, and non-reactive UIs.**
    
    </critical_reminders>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related