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
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/mobile-storage-sqlite-powersync/skills/mobile-storage-sqlite-powersync
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
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-nativefor offline-first apps backed by local SQLite. Define schemas withTableandcolumn.text/integer/real(id column is auto-created). UsePowerSyncDatabasefor reads/writes,useQueryfrom@powersync/reactfor reactive watched queries. Connect to your backend via a connector implementingfetchCredentials+uploadData. Conflict resolution defaults to last-write-wins per field -- customize inuploadData. Use@powersync/op-sqlitefor 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,columntypes, indexes, and local-only tables PowerSyncDatabasesetup 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
AttachmentTableandAttachmentQueue - 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 - Schema, database setup, CRUD, watched queries, hooks
- examples/sync.md - Backend connector, sync rules, conflict resolution
- examples/attachments.md - Attachment queue, upload/download, storage adapters
- reference.md - API reference, setup checklist
<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
idcolumn in schema -- PowerSync auto-createsidastextprimary key. Declaring it causes conflicts. - Calling
execute()for reads (SELECT) instead ofgetAll()/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, useuseQuery()instead - Missing
transaction.complete()inuploadData()-- 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
AttachmentTablefor files, keep SQLite for metadata - Missing indexes on frequently queried columns -- sync queries can be slow with large datasets
- Using
column.integerfor 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 JScolumn.realstores 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()returnsnullwhen the upload queue is empty -- always check before iteratingexecute()with views may returnrowsAffected: 0even on success -- useRETURNINGclause 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.JAVASCRIPTto use the legacy JS client disconnectAndClear()removes all local data -- usedisconnect()to stop sync while preserving local data- Local-only tables (set
localOnly: trueon 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.
Reviews (0)
No reviews yet.
No comments yet.