dist/plugins/mobile-storage-sqlite-powersync/skills/mobile-storage-sqlite-powersync/SKILL.md
PowerSync offline-first sync engine on SQLite for React Native - schema definition, watched queries, CRUD operations, backend connectors, sync rules, conflict resolution, attachments
npx skillsauth add agents-inc/skills mobile-storage-sqlite-powersyncInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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>
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:
Key patterns covered:
Table, column types, indexes, and local-only tablesPowerSyncDatabase setup with default or OP-SQLite adapteruseQuery, useSuspenseQuery, useStatus, usePowerSyncfetchCredentials() + uploadData() implementationexecute(), get(), getAll(), getOptional()AttachmentTable and AttachmentQueueWhen NOT to use:
Detailed Resources:
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:
uploadData() gives you full controluseQuery re-executes queries when dependent tables change, keeping UI in syncArchitecture 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:
execute(INSERT/UPDATE/DELETE) on local SQLite. PowerSync queues the change and calls your uploadData() to push it to the backend.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.
Define your client-side schema using Table and column types. The schema mirrors your server tables (minus the id column, which is auto-created).
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 for local-only tables and index configuration.
Create the database instance at app startup. Choose between the default SQLite adapter or OP-SQLite for encryption.
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 for OP-SQLite setup with encryption and the React context provider pattern.
Use useQuery from @powersync/react for watched queries that re-execute when dependent tables change. Wrap your app in PowerSyncContext.Provider.
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 for useSuspenseQuery, useStatus, usePowerSync, and runQueryOnce usage.
All writes use execute() with parameterized SQL. PowerSync queues changes and calls your uploadData() to sync.
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 for get(), getAll(), getOptional(), and transaction patterns.
The connector bridges PowerSync with your backend. Implement fetchCredentials() for auth and uploadData() for pushing local changes.
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 for the full Supabase connector, custom backend patterns, and error handling with retries.
Sync rules (YAML) define which server data each client receives. Configured on the PowerSync Service, not in client code.
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 for parameterized buckets, client parameters, and multi-tenant patterns.
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:
See examples/sync.md for complete conflict resolution implementations.
Use AttachmentTable in your schema and AttachmentQueue for offline-capable file upload/download.
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 for queue setup, upload/download handlers, and storage adapter patterns.
</patterns><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
| 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>
High Priority Issues:
id column in schema -- PowerSync auto-creates id as text primary key. Declaring it causes conflicts.execute() for reads (SELECT) instead of getAll() / useQuery() -- execute() does not return query results in a usable formatpowersync.connect(connector) -- database works locally but nothing syncs, easy to miss in developmentgetAll() in React components expecting reactivity -- raw reads do not watch for changes, use useQuery() insteadtransaction.complete() in uploadData() -- unacknowledged transactions retry indefinitely, causing duplicate uploadsMedium Priority Issues:
fetchCredentials() returning null -- happens when auth session expires, must re-authenticateAttachmentTable for files, keep SQLite for metadatacolumn.integer for booleans without consistent 0/1 values -- SQLite has no native boolean typeGotchas & Edge Cases:
uuid() is a PowerSync SQL function, not a JavaScript function -- use it in SQL strings, not in JScolumn.real stores IEEE 754 doubles -- be aware of floating-point precision for currency (use integer cents instead)request.user_id() to access the authenticated user ID from the JWT -- not a custom functiongetNextCrudTransaction() returns null when the upload queue is empty -- always check before iteratingexecute() with views may return rowsAffected: 0 even on success -- use RETURNING clause for confirmationclientImplementation: SyncClientImplementation.JAVASCRIPT to use the legacy JS clientdisconnectAndClear() removes all local data -- use disconnect() to stop sync while preserving local datalocalOnly: true on Table options) are never synced -- useful for draft data or app state</red_flags>
<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>
development
Xquik REST API patterns for X post search, user and timeline reads, cursor pagination, media downloads, monitors, signed webhooks, and approval-gated X actions
development
Xquik REST API patterns for X post search, user and timeline reads, cursor pagination, media downloads, monitors, signed webhooks, and approval-gated X actions
development
Mapbox GL JS interactive maps - map initialization, markers, popups, sources, layers, expressions, clustering, 3D terrain, geocoding, directions
tools
Leaflet interactive maps - map setup, tile layers, markers, popups, GeoJSON, custom controls, plugins, clustering, events