@despia/powersync
v4.0.2
Published
Despia Native SDK for offline-first local SQLite + real-time PowerSync sync in web-native apps
Downloads
177
Maintainers
Readme
Despia PowerSync
Native PowerSync SQLite for Despia web-native apps, with optional cloud sync.
What This Package Does
@despia/powersync gives web code a typed API for the native on-device PowerSync database.
It supports two layers:
| Layer | Works offline? | Requires PowerSync cloud setup? | APIs |
| ------------------ | ----------------------- | ------------------------------- | ------------------------------------------------------------------------------------ |
| Local PowerSync DB | Yes | No | init, query, get, execute, batch, watch, migrate, schema |
| Cloud sync | Yes, sync resumes later | Yes | powersync.connect, powersync.sync, powersync.status, powersync.events.status |
Local database operations do not require a cloud token. Native opens the PowerSync database at init, so reads and writes work offline before sync is connected. PowerSync cloud sync starts only when native has:
- a pending schema and target version from
db.init({ schema, schemaVersion }) - an active local database for that schema
- credentials from
db.powersync.connect({ token })
Until credentials arrive, the database is local-only. Writes made before connect() are queued locally and upload later once sync is connected.
Installation
npm install @despia/powersyncimport { active, db } from "@despia/powersync";CDN:
import { db } from "https://cdn.jsdelivr.net/npm/@despia/powersync/+esm";UMD global:
<script src="https://cdn.jsdelivr.net/npm/@despia/powersync/dist/umd/despia-powersync.min.js"></script>
<script>
const { db } = window.powersync;
</script>Requirements
This package is for apps built with the Despia platform. It works in Despia apps and code exports on both free and paid plans, but it will not work in apps that were not built or exported through Despia.
It communicates through the DespiaScript runtime:
window.despia("powersync://method", params).
import { active } from "@despia/powersync";
if (!active()) {
console.warn("Native PowerSync bridge is not available.");
}active() only checks bridge presence. It does not mean SQLite is initialized or sync is connected.
Quick Start
import { db, type PowerSyncSchema } from "@despia/powersync";
const SCHEMA_VERSION = 1;
const SCHEMA: PowerSyncSchema = {
users: {
columns: {
email: "text",
createdAt: "text",
},
indexes: {
users_by_email: ["email"],
},
},
};
await db.init({ schema: SCHEMA, schemaVersion: SCHEMA_VERSION, databaseName: "mydb" });
await db.execute(
"INSERT INTO users(id, email, createdAt) VALUES(?, ?, ?)",
["u1", "[email protected]", new Date().toISOString()]
);
const users = await db.query<{ id: string; email: string }>(
"SELECT id, email FROM users ORDER BY email"
);Do not declare id in the schema columns. PowerSync provides the id primary
key automatically; inserts and queries should still include id values. Use
caller-provided IDs such as UUIDs rather than relying on SQLite insertId.
Add sync only when you have credentials:
await db.powersync.connect({ token: "jwt_from_your_auth_system" });If native delegates uploads to JavaScript, register db.powersync.events.upload() after db.init() and after your authenticated backend client is ready. Without that handler, local writes stay queued until an upload implementation is available.
This package is backend-agnostic. It does not include a built-in backend uploader. PowerSync queues local INSERT, UPDATE, and DELETE operations, then the native connector asks JavaScript to upload that CRUD batch. Your handler decides where it goes.
PowerSync Auth And Tokens
db.powersync.connect({ token }) gives the native PowerSync engine a token for the current signed-in app user. It does not log the user into your app, and it does not create the token.
There are three separate pieces to set up:
- Your app auth decides who the user is.
- PowerSync Client Auth verifies the token that native sends to PowerSync.
- PowerSync sync rules decide which rows that verified user can sync.
The token identifies the user. It does not contain all user data, and it does not automatically protect rows by itself. If a row is synced into local SQLite, your frontend can query it. Private data must be blocked by PowerSync/backend rules before it reaches the device.
Token Flow
Use this flow when you mint custom PowerSync JWTs:
- User signs in to your app.
- The frontend calls your backend token endpoint.
- Your backend verifies the app session.
- Your backend signs a short-lived PowerSync JWT for that user.
- The frontend calls
db.powersync.connect({ token }). - Native passes the token into the real PowerSync SDK.
- PowerSync verifies the token and applies your sync rules.
Your backend token endpoint does not call a PowerSync REST API just to create the token. It signs the JWT with the key or secret that you configured in PowerSync Client Auth. Later, the native PowerSync SDK connects to the PowerSync service with that token, and PowerSync verifies the signature, kid, audience, expiry, and subject.
PowerSync Dashboard Setup
In PowerSync, configure the auth method before expecting real sync to work:
- Open your PowerSync project or instance.
- Go to Client Auth.
- Configure the same JWT verification method your backend/provider uses.
- Configure sync rules or streams that use the authenticated user identity.
For custom JWT auth, PowerSync expects a signed JWT with fields like:
{
"sub": "user_123",
"aud": "https://your-powersync-instance-url",
"iat": 1710000000,
"exp": 1710000900,
"userId": "user_123"
}The JWT header should include the configured key id:
{
"alg": "HS256",
"kid": "your-key-id"
}Important fields:
| Field | Purpose |
| ------------- | -------------------------------------------------------------------------------- |
| sub | The application user ID. PowerSync rules use this as the user identity. |
| aud | Must match the PowerSync instance URL or configured audience. |
| iat / exp | Token issue and expiry times. Keep expiry short. |
| kid | Header key id. Must match the key configured in PowerSync Client Auth. |
| custom claims | Optional app data such as team, role, or project claims if your rules need them. |
For production, asymmetric JWT signing with JWKS is usually preferred because PowerSync can verify tokens with a public key while your backend keeps the private signing key. HS256 can work for development or simpler deployments if it exactly matches your PowerSync Client Auth configuration.
Sync Rules
PowerSync verifies who the user is from the token. Your sync rules decide what that user can sync.
A simple rule is conceptually:
SELECT * FROM todos WHERE user_id = auth.user_id()If the JWT has sub = "user_123", rules should only sync rows authorized for user_123. For team, organization, or project data, include the required claims in the token or resolve membership in your backend/PowerSync rules. Do not issue broad tokens that allow every user to sync every row unless that is truly intended.
Before shipping, test with two users and confirm user A never receives user B's private rows.
Client Code
import { db } from "@despia/powersync";
async function getPowerSyncToken() {
const response = await fetch("/api/powersync-token", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Could not get PowerSync token");
}
const data = (await response.json()) as {
token: string;
expiresAt?: string;
};
return data.token;
}
const token = await getPowerSyncToken();
await db.powersync.connect({ token });Backend Token Endpoint
This example uses HS256 because it is compact. Match the algorithm, kid, secret/key, audience, and claims to your own PowerSync Client Auth settings.
import express from "express";
import { SignJWT } from "jose";
const app = express();
app.use(express.json());
app.post("/api/powersync-token", async (req, res) => {
const user = await getUserFromSession(req);
if (!user) {
return res.status(401).json({ error: "not_authenticated" });
}
const secret = Buffer.from(process.env.POWERSYNC_JWT_SECRET!, "base64url");
const expiresAt = Math.floor(Date.now() / 1000) + 15 * 60;
const token = await new SignJWT({
userId: user.id,
})
.setProtectedHeader({
alg: "HS256",
kid: process.env.POWERSYNC_JWT_KID!,
})
.setSubject(user.id)
.setIssuer(process.env.POWERSYNC_JWT_ISSUER!)
.setAudience(process.env.POWERSYNC_URL!)
.setIssuedAt()
.setExpirationTime(expiresAt)
.sign(secret);
return res.json({
token,
expiresAt: new Date(expiresAt * 1000).toISOString(),
});
});Keep signing keys and secrets on the server. The frontend should receive only a short-lived token for the authenticated user.
Supabase And Firebase
If your app already uses Supabase Auth or Firebase Auth, you may not need a custom token endpoint. PowerSync can be configured to verify those provider JWTs directly when Client Auth, audience, and JWKS settings match your provider.
Helpful references:
- PowerSync Custom Authentication
- PowerSync Supabase Auth
- PowerSync Firebase Auth
- PowerSync Writing Data
- PowerSync Client-Side Backend Integration
Native integration note: native does not usually implement row-level authorization itself. Native passes the token into the real PowerSync SDK and reports real sync status/errors. Row-level authorization belongs in your PowerSync/backend rules.
Startup Lifecycle
Use this order on every app start:
await db.init({
schema: CURRENT_SCHEMA,
schemaVersion: SCHEMA_VERSION,
databaseName: DATABASE_NAME,
});
const activeSchema = await db.schema().catch(() => null);
const appliedVersion = activeSchema?.appliedMigrationVersion ?? 0;
const pendingMigrations = MIGRATIONS.filter(
(migration) => migration.version > appliedVersion
);
if (pendingMigrations.length > 0) {
await db.migrate(
SCHEMA_VERSION,
pendingMigrations.flatMap((migration) => migration.statements)
);
}
if (token) {
await db.powersync.connect({ token });
}Why this order matters:
init()opens the native PowerSync database offline, using the latest schema and target version.schema()returns the last active schema, if native already has one cached.migrate()is only needed for native builds or local-only tables that still use SQL migrations.db.powersync.connect()attaches cloud sync after the local database is ready.- Writes made before
connect()stay local and upload once sync is connected.
Schema Model
The schema is a JSON-compatible object. It describes table and column shape for native PowerSync setup.
type PowerSyncColumnType = "text" | "integer" | "real";
type PowerSyncSchema = Record<
string,
{
columns: Record<string, PowerSyncColumnType>;
indexes?: Record<string, string[]>;
}
>;Full schema example:
{
"users": {
"columns": {
"id": "text",
"email": "text",
"createdAt": "text"
},
"indexes": {
"users_by_email": ["email"]
}
},
"posts": {
"columns": {
"id": "text",
"userId": "text",
"title": "text",
"body": "text",
"createdAt": "text"
},
"indexes": {
"posts_by_user": ["userId"]
}
}
}Valid schema rules:
schemamust be a non-empty object.schemaVersionmust be a positive integer and should match the latest migration version for this schema.- Table names must be non-empty strings.
- Each table must have a non-empty
columnsobject. - Column names must be non-empty strings.
- Column types must be exactly
"text","integer", or"real". indexesis optional.- If present,
indexesmust be an object. - Each index must map to a non-empty string array.
- Each indexed column must exist in that table's
columns.
Migrations
For synced tables, the schema object is the source of truth. Native PowerSync creates the local synced table views from that schema, so do not add CREATE TABLE migrations for the same synced tables.
db.migrate() remains available for native builds or local-only tables that still use SQL migrations. Do not use it to create tables that also exist in PowerSyncSchema.
Local-only migration example:
await db.migrate(2, [
`CREATE TABLE IF NOT EXISTS local_drafts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
createdAt TEXT NOT NULL
)`,
"CREATE INDEX IF NOT EXISTS local_drafts_by_created ON local_drafts(createdAt)",
]);When schema changes:
- Update
CURRENT_SCHEMA. - Increase
SCHEMA_VERSION. - Add SQL migrations only for local-only tables or native builds that require them.
- Run
db.migrate(SCHEMA_VERSION, pendingStatements)only when there are pending SQL migrations. - Then run queries or sync that depend on the new shape.
schemaVersion matters because native should not use a stale schema. If your app still uses SQL migrations for local-only tables and the current version is 3, pass all pending statements to one db.migrate(3, statements) call. That lets native commit or roll back the local-only upgrade as one transaction.
Synced Table Changes
To add or change synced tables, update PowerSyncSchema and bump schemaVersion. Native schema-driven migrations are responsible for applying that shape to the PowerSync database.
Local-Only Table Changes
Use db.migrate() only for tables that are intentionally not synced.
Schema Validation Errors
db.init({ schema, schemaVersion }) validates schema and options before native is called. If validation fails, the SDK throws PowerSyncError.
type PowerSyncErrorDetail = {
path?: string;
reason?: string;
expected?: string | string[];
received?: string;
[key: string]: unknown;
};
type PowerSyncError = Error & {
code?: string;
details?: PowerSyncErrorDetail[];
nativeError?: string;
};Intentionally bad schema example:
Do not copy this into your app. This snippet is only here to show what validation errors look like.
await db.init({
schema: {
users: {
columns: {
// Bad: "id" is reserved; PowerSync adds it automatically.
id: "text",
// Bad: "varchar" is not a supported schema type.
// Use "text", "integer", or "real".
age: "varchar" as never,
},
indexes: {
// Bad: this index points to "row", but there is no "row" column.
// Indexes must reference columns that exist in the same table.
users_by_row: ["row"],
},
},
},
schemaVersion: 1,
});Thrown error shape:
{
"name": "PowerSyncError",
"code": "invalid_schema",
"message": "invalid_schema: 2 schema validation errors",
"details": [
{
"path": "schema.users.columns.age",
"reason": "invalid_column_type",
"expected": ["text", "integer", "real"],
"received": "varchar"
},
{
"path": "schema.users.indexes.users_by_row",
"reason": "unknown_index_column",
"expected": "existing column name",
"received": "row"
}
]
}Handle errors by code and details:
try {
await db.migrate(3, MIGRATION_3);
} catch (error) {
const err = error as { code?: string; details?: Array<Record<string, unknown>> };
if (err.code === "invalid_schema") {
for (const detail of err.details ?? []) {
const expected = Array.isArray(detail.expected)
? detail.expected.join(", ")
: detail.expected;
console.error(`${detail.path}: expected ${expected}, received ${detail.received}`);
}
}
throw error;
}Validation Reason Reference
The Example received column shows bad input values that trigger each error. Do not use those values in a real schema.
| Reason | Path example | Expected | Example received |
| ------------------------------ | ------------------------------- | -------------------------------------------- | ------------------------------ |
| missing_or_invalid_schema | schema | non-empty object | undefined, null, array |
| empty_schema | schema | object with at least one table | empty object |
| empty_table_name | schema | non-empty table name | empty string |
| invalid_table_definition | schema.users | object with columns map | string, array, null |
| invalid_columns | schema.users.columns | non-empty object | string, array, null |
| empty_columns | schema.users.columns | object with at least one column | empty object |
| empty_column_name | schema.users.columns | non-empty column name | empty string |
| invalid_column_type | schema.users.columns.age | ["text", "integer", "real"] | varchar, number, boolean |
| invalid_indexes | schema.users.indexes | object mapping index names to column arrays | string, array, null |
| empty_index_name | schema.users.indexes | non-empty index name | empty string |
| invalid_index_columns | schema.users.indexes.by_email | non-empty string array | empty array, string, null |
| invalid_index_column_name | schema.users.indexes.by_email | non-empty string | empty string, number |
| unknown_index_column | schema.users.indexes.by_row | existing column name | row |
| invalid_schema_version | options.schemaVersion | positive integer | 0, 1.5, string |
| invalid_database_name | options.databaseName | non-empty string | empty string, number |
| invalid_migration_version | version | positive integer | 0, 1.5, string |
| invalid_migration_statements | statements | non-empty string array or BatchStatement[] | empty array, null |
| invalid_migration_sql | statements.0 | non-empty SQL string | empty string, number |
| empty_migration_sql | statements.0.sql | non-empty SQL string | empty string |
| invalid_token | config.token | non-empty string | empty string, undefined |
| invalid_url | config.url | non-empty string | empty string, number |
Fallback Flow
If a schema upgrade fails, do not start sync with the new schema.
async function setupDatabase(token?: string) {
await db.init({
schema: CURRENT_SCHEMA,
schemaVersion: SCHEMA_VERSION,
databaseName: DATABASE_NAME,
});
try {
const activeSchema = await db.schema().catch(() => null);
const appliedVersion = activeSchema?.appliedMigrationVersion ?? 0;
const pendingMigrations = MIGRATIONS.filter(
(migration) => migration.version > appliedVersion
);
if (pendingMigrations.length > 0) {
await db.migrate(
SCHEMA_VERSION,
pendingMigrations.flatMap((migration) => migration.statements)
);
}
if (token) await db.powersync.connect({ token });
return { mode: "ready" as const };
} catch (error) {
console.error("Database setup failed:", error);
try {
const state = await db.schema();
return {
mode: "fallback" as const,
schema: state.schema,
databaseName: state.databaseName,
};
} catch {
return { mode: "blocked" as const };
}
}
}Fallback mode is only safe if your current app can run against the active schema returned by db.schema().
Loading Synced Data
PowerSync sync writes remote changes into the same local SQLite database that your app queries. Your frontend does not usually receive a separate “sync payload” from PowerSync. Instead, the app reads JSON rows from local SQLite after native sync updates the database.
Use this model:
- Initialize schema and migrations.
- Connect PowerSync with a user-scoped token.
- Native starts or resumes sync.
- Remote changes are applied to local SQLite by native PowerSync.
- Your app reads data with
query()/get(). - Your app listens for local result changes with
watch(). - Your app listens for sync connection/progress changes with
db.powersync.events.status(). - If your backend upload runs in JS, your app registers
db.powersync.events.upload()so native can hand local CRUD writes to your HTTP layer.
Initial load:
type PostRow = {
id: string;
userId: string;
title: string;
body: string | null;
createdAt: string;
};
await db.powersync.connect({ token });
await db.powersync.sync();
const posts = await db.query<PostRow>(
"SELECT id, userId, title, body, createdAt FROM posts WHERE userId = ? ORDER BY createdAt DESC",
[currentUserId]
);
console.log(posts);The returned rows are normal JSON-compatible objects:
[
{
"id": "post_1",
"userId": "user_1",
"title": "Hello",
"body": "First synced post",
"createdAt": "2026-04-29T08:30:00.000Z"
},
{
"id": "post_2",
"userId": "user_1",
"title": "Offline draft",
"body": null,
"createdAt": "2026-04-29T08:35:00.000Z"
}
]Live UI updates:
const unwatch = db.watch<PostRow>(
"SELECT id, userId, title, body, createdAt FROM posts WHERE userId = ? ORDER BY createdAt DESC",
[currentUserId],
(rows) => {
// Re-render your UI with the latest local rows.
renderPosts(rows);
}
);
// Later, when leaving the screen:
unwatch();watch() is the right API when you want to show updated data in the UI. It returns full result rows after local SQLite changes. Those changes can come from local writes, migrations, or remote PowerSync sync applying backend changes.
Sync status updates:
const unsubscribe = db.powersync.events.status((status) => {
if (status.uploading || status.downloading) {
showSyncIndicator();
}
if (status.connected && status.lastSynced) {
showLastSynced(status.lastSynced);
}
});
unsubscribe();db.powersync.events.status() is for sync state, not row data. Use it to show “syncing”, “offline”, “last synced”, or error states. Use query() and watch() to read actual app data.
Local write upload:
const unsubscribeUpload = db.powersync.events.upload(async ({ crud }) => {
const response = await fetch("/api/powersync-upload", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ crud }),
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
});
// Later, when tearing down the session:
unsubscribeUpload();The upload handler is called when native PowerSync has local CRUD rows to send to your backend. If the handler resolves, the SDK tells native upload_complete so the native queue can be finalized. If the handler throws, the SDK reports the error back to native and PowerSync retries later. Register one upload handler per app/session; calling events.upload() again replaces the previous handler.
This mirrors PowerSync's connector model. PowerSync automatically queues writes and calls an upload connector; the connector is responsible for applying the CRUD operations to the app backend. In Despia, db.powersync.events.upload() is the JavaScript-facing upload connector hook.
Supabase Web SDK example:
const unsubscribeUpload = db.powersync.events.upload(async ({ crud }) => {
for (const entry of crud) {
let result;
if (entry.op === "PUT") {
result = await supabase
.from(entry.table)
.upsert({ id: entry.id, ...entry.opData });
} else if (entry.op === "PATCH") {
result = await supabase
.from(entry.table)
.update(entry.opData)
.eq("id", entry.id);
} else if (entry.op === "DELETE") {
result = await supabase.from(entry.table).delete().eq("id", entry.id);
}
if (result?.error) throw result.error;
}
});Use the authenticated Supabase client for the current user. Downloads can work while uploads still fail if INSERT, UPDATE, or DELETE RLS policies reject the write, so always throw on result.error.
Firebase Web SDK example:
import {
deleteDoc,
doc,
setDoc,
updateDoc,
} from "firebase/firestore";
const unsubscribeUpload = db.powersync.events.upload(async ({ crud }) => {
for (const entry of crud) {
const ref = doc(firestore, entry.table, entry.id);
if (entry.op === "PUT") {
await setDoc(ref, { id: entry.id, ...entry.opData });
} else if (entry.op === "PATCH") {
await updateDoc(ref, entry.opData ?? {});
} else if (entry.op === "DELETE") {
await deleteDoc(ref);
}
}
});Search API
Search lets you register native indexes and query them with plain, prefix, or fuzzy matching.
Current native builds route synced table reads and writes through PowerSync. Search support for synced PowerSync backing tables is a native follow-up, so do not rely on search for synced tables until that build lands.
await db.search.index({
name: "postsSearch",
table: "posts",
columns: ["title", "body"],
});
const status = await db.search.index.status("postsSearch");
const indexes = await db.search.indexes();
const rows = await db.search.query<PostRow>({
index: "postsSearch",
text: "ofline sycn",
mode: "fuzzy",
limit: 20,
});Native uses SQLite FTS5 for full-text and prefix search, and native trigram/edit-distance ranking for mode: "fuzzy" when supported by the app build. Large fuzzy indexes may build asynchronously on native; db.search.index() and db.search.rebuildIndex() wait for the native build completion event before resolving.
The SDK trims search names, table names, column names, and query text before sending them to native. Search index columns must be unique, query text is capped at 256 characters, and limit is capped at 1000 per request so a UI search cannot accidentally ask native for an unbounded result set.
Runtime JSON Examples
These examples show SDK-level data shapes. They are not raw native bridge payloads.
Active Schema State
{
"schema": {
"users": {
"columns": {
"id": "text",
"email": "text",
"createdAt": "text"
},
"indexes": {
"users_by_email": ["email"]
}
}
},
"databaseName": "mydb",
"schemaHash": "2d711642b726b04401627ca9fbac32f5",
"schemaVersion": 2,
"appliedMigrationVersion": 2
}Sync Status
{
"connected": true,
"lastSynced": "2026-04-29T08:30:00.000Z",
"uploading": false,
"downloading": false
}Native Error Normalized By The SDK
{
"name": "PowerSyncError",
"code": "schema_required",
"message": "schema_required — Call db.init({ schema, schemaVersion }) at app startup before using sync features.",
"nativeError": "schema_required",
"details": [
{
"path": "schema",
"reason": "empty_schema",
"expected": "object with at least one table",
"received": "empty object"
}
]
}Invalid Options Error
{
"name": "PowerSyncError",
"code": "invalid_options",
"message": "invalid_options: db.init() databaseName must be a non-empty string when provided",
"details": [
{
"path": "options.databaseName",
"reason": "invalid_database_name",
"expected": "non-empty string",
"received": ""
}
]
}Migration Error
{
"name": "PowerSyncError",
"code": "migration_validation_failed",
"message": "migration_validation_failed — Migration failed. Fix the migration SQL and try db.migrate() again.",
"nativeError": "migration_validation_failed",
"details": [
{
"path": "schema.posts.columns.userId",
"reason": "missing_column_after_migration",
"expected": "column exists after migration",
"received": "missing"
}
]
}API Examples
db.schema()
const state = await db.schema();
console.log(state.schema);
console.log(state.databaseName);
console.log(state.appliedMigrationVersion);db.query()
const posts = await db.query<{ id: string; title: string }>(
"SELECT id, title FROM posts WHERE userId = ? ORDER BY createdAt DESC",
["user_1"]
);db.get()
const post = await db.get<{ id: string; title: string }>(
"SELECT id, title FROM posts WHERE id = ?",
["post_1"]
);
if (!post) {
console.log("Post not found");
}db.execute()
const result = await db.execute(
"INSERT INTO posts(id, userId, title, body, createdAt) VALUES(?, ?, ?, ?, ?)",
["post_1", "user_1", "Hello", "Body", new Date().toISOString()]
);
console.log(result.rowsAffected);Use caller-provided IDs for synced rows. Native PowerSync-backed builds do not expose SQLite last-insert row IDs, so insertId is always 0.
db.batch()
await db.batch([
{
sql: "INSERT INTO posts(id, userId, title, createdAt) VALUES(?, ?, ?, ?)",
params: ["post_2", "user_1", "Second", new Date().toISOString()],
},
{
sql: "INSERT INTO posts(id, userId, title, createdAt) VALUES(?, ?, ?, ?)",
params: ["post_3", "user_1", "Third", new Date().toISOString()],
},
]);db.transaction()
await db.batch([
{
sql: "UPDATE posts SET title = ? WHERE id = ?",
params: ["New title", "post_1"],
},
{
sql: "UPDATE posts SET body = ? WHERE id = ?",
params: ["New body", "post_1"],
},
]);Manual begin / commit / rollback transactions are not supported by native PowerSync-backed databases. Use db.batch() for atomic multi-statement writes.
db.watch()
const unwatch = db.watch<{ id: string; title: string }>(
"SELECT id, title FROM posts WHERE userId = ? ORDER BY createdAt DESC",
["user_1"],
(rows) => {
console.log("Rows changed:", rows);
}
);
unwatch();db.powersync.connect()
const token = await getPowerSyncTokenFromYourServer();
await db.powersync.connect({ token });Native may read static PowerSync app ID / URL values from native config. Passing only a user-scoped token is valid when native owns the static config.
db.powersync.status()
const status = await db.powersync.status();
if (status.connected) {
console.log("Connected. Last synced:", status.lastSynced);
}db.powersync.sync()
await db.powersync.sync();
const status = await db.powersync.status();
console.log(status);Sync usually completes asynchronously. Treat db.powersync.sync() as a trigger/schedule request, then read status with db.powersync.status() or db.powersync.events.status().
db.powersync.events.status()
const unsubscribe = db.powersync.events.status((status) => {
console.log("Sync status changed:", status);
});
unsubscribe();db.powersync.events.upload()
const unsubscribe = db.powersync.events.upload(async ({ crud }) => {
for (const entry of crud) {
const result = await uploadCrudEntry(entry);
if (result?.error) throw result.error;
}
});
unsubscribe();Use this when native delegates PowerSync CRUD upload to the JS app layer. The callback receives the pending CRUD batch and may return a promise. Resolve when your backend upload succeeds; throw to leave the native batch pending for retry.
If your app uploads directly to Supabase, handle PUT, PATCH, and DELETE here with the authenticated Supabase client. PATCH and DELETE should target entry.id; PATCH opData only contains changed columns.
db.search.index()
await db.search.index({
name: "postsSearch",
table: "posts",
columns: ["title", "body"],
});Creates or updates a native search index. Native validates that the source table and columns exist.
For PowerSync-synced tables, search depends on native support for the synced backing table layout. Treat search on synced tables as unavailable until that native follow-up is included in your app build.
For large source tables, native may return immediately with a background build. The SDK waits for search:index:built or search:index:failed, so await db.search.index(...) resolves when fuzzy search is ready.
db.search.index.status()
const status = await db.search.index.status("postsSearch");Returns native build status for a search index:
{
name: "postsSearch",
state: "building",
processed: 12345,
total: 500000,
percent: 2,
}db.search.indexes()
const indexes = await db.search.indexes();Returns the native search indexes currently registered for the database:
[
{
name: "postsSearch",
table: "posts",
columns: ["title", "body"],
},
];db.search.query()
const rows = await db.search.query<PostRow>({
index: "postsSearch",
text: "offline sync",
mode: "prefix",
limit: 20,
});Queries a native search index. mode can be "plain", "prefix", or "fuzzy". Fuzzy search uses native typo-tolerant ranking when supported by the app build. Call rebuildIndex() after large migrations or data repairs if your native integration uses rebuild-based fuzzy indexes.
db.search.events.index
const offProgress = db.search.index.events.progress(({ name, percent }) => {
console.log(`Indexing ${name}: ${percent}%`);
});
const offBuilt = db.search.index.events.built(({ name }) => {
console.log(`${name} ready`);
});
const offFailed = db.search.index.events.failed(({ name, received }) => {
console.error(`${name} failed:`, received);
});
offProgress();
offBuilt();
offFailed();These events mirror the native async build lifecycle. They are useful when you want to show progress while db.search.index() or db.search.rebuildIndex() is waiting. The alias db.search.events.index.* is also available for compatibility.
db.search.dropIndex() and db.search.rebuildIndex()
await db.search.rebuildIndex({ name: "postsSearch" });
await db.search.dropIndex({ name: "postsSearch" });Use rebuildIndex() after large migrations or data repairs. Use dropIndex() when removing an index permanently.
API Reference
Exports
import {
active,
db,
Database,
onEvent,
type BatchResult,
type BatchStatement,
type ExecuteResult,
type PowerSyncConfig,
type PowerSyncColumnType,
type PowerSyncCrudEntry,
type PowerSyncCrudOperation,
type PowerSyncError,
type PowerSyncErrorDetail,
type PowerSyncErrorDetails,
type PowerSyncInitOptions,
type PowerSyncSchema,
type PowerSyncSchemaState,
type PowerSyncSearchDropIndexOptions,
type PowerSyncSearchIndex,
type PowerSyncSearchIndexBuiltEvent,
type PowerSyncSearchIndexFailedEvent,
type PowerSyncSearchIndexOptions,
type PowerSyncSearchIndexProgressEvent,
type PowerSyncSearchIndexResult,
type PowerSyncSearchIndexState,
type PowerSyncSearchIndexStatus,
type PowerSyncSearchMode,
type PowerSyncSearchQueryOptions,
type PowerSyncSearchRebuildIndexOptions,
type PowerSyncSearchRow,
type PowerSyncTableSchema,
type PowerSyncUploadHandler,
type PowerSyncUploadPayload,
type SyncStatus,
} from "@despia/powersync";Methods
active(): boolean;
db.init(options: {
schema: PowerSyncSchema;
schemaVersion: number;
databaseName?: string;
}): Promise<Record<string, unknown>>;
db.schema(): Promise<PowerSyncSchemaState>;
db.query<T>(sql: string, params?: unknown[]): Promise<T[]>;
db.get<T>(sql: string, params?: unknown[]): Promise<T | null>;
db.execute(sql: string, params?: unknown[]): Promise<ExecuteResult>;
db.batch(statements: BatchStatement[]): Promise<BatchResult>;
db.transaction<T>(fn: (tx: Database) => Promise<T>): Promise<T>; // unsupported on PowerSync-backed native builds
db.watch<T>(sql: string, callback: (rows: T[]) => void): () => void;
db.watch<T>(
sql: string,
params: unknown[],
callback: (rows: T[]) => void
): () => void;
db.migrate(
version: number, // usually SCHEMA_VERSION for the full pending upgrade
statements: string[] | BatchStatement[]
): Promise<Record<string, unknown>>;
db.powersync.connect(config: PowerSyncConfig): Promise<Record<string, unknown>>;
db.powersync.sync(): Promise<Record<string, unknown>>;
db.powersync.status(): Promise<SyncStatus>;
db.powersync.events.status(callback: (status: SyncStatus) => void): () => void;
db.powersync.events.upload(
callback: (payload: PowerSyncUploadPayload) => void | Promise<void>
): () => void;
db.search.index(options: PowerSyncSearchIndexOptions): Promise<PowerSyncSearchIndexResult>;
db.search.index.status(name: string): Promise<PowerSyncSearchIndexStatus>;
db.search.index.events.progress(
callback: (event: PowerSyncSearchIndexProgressEvent) => void
): () => void;
db.search.index.events.built(
callback: (event: PowerSyncSearchIndexBuiltEvent) => void
): () => void;
db.search.index.events.failed(
callback: (event: PowerSyncSearchIndexFailedEvent) => void
): () => void;
db.search.indexes(): Promise<PowerSyncSearchIndex[]>;
db.search.indexStatus(name: string): Promise<PowerSyncSearchIndexStatus>;
db.search.dropIndex(options: PowerSyncSearchDropIndexOptions): Promise<Record<string, unknown>>;
db.search.rebuildIndex(options: PowerSyncSearchRebuildIndexOptions): Promise<PowerSyncSearchIndexResult>;
db.search.query<T>(
options: PowerSyncSearchQueryOptions
): Promise<Array<PowerSyncSearchRow<T>>>;
db.search.events.index.progress(callback): () => void; // alias
db.search.events.index.built(callback): () => void; // alias
db.search.events.index.failed(callback): () => void; // alias
onEvent<T = unknown>(event: string, callback: (payload: T) => void): () => void;Error Codes
| Code | Source | Meaning | Typical fix |
| ----------------------------- | ------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| schema_required | SDK or native | Missing/empty schema, or no active schema exists for a schema-dependent native operation. | Call db.init({ schema, schemaVersion }), apply migrations, then retry. |
| invalid_schema | SDK or native | Schema shape is malformed. | Check error.details[] and fix the listed paths. |
| invalid_options | SDK or native | Method options are malformed, or a native-backed operation is unsupported. | Pass valid options. For manual_transaction_unsupported, use db.batch(). |
| credentials_required | Native | Sync needs a token. | Call db.powersync.connect({ token }). |
| sync_not_configured | Native | Native PowerSync app ID / URL config is missing. | Fix native PowerSync config. |
| sync_not_initialized | Native | Native sync engine could not start or is not ready. | Ensure active schema, migrations, credentials, and native SDK setup are complete. |
| migration_validation_failed | Native | Migration SQL failed or expected schema shape was not reached. | Fix SQL or schema before retrying db.migrate(). |
| database_not_initialized | Native | Native SQLite database is not open. | Check native bridge/database setup. |
| timeout | Native | Native did not respond to a DespiaScript request within the timeout. | Check the native handler for long-running synchronous work. |
Notes For AI-Generated Code
Common mistakes to avoid:
- Do not call
db.powersync.connect()beforeinit()and migrations. - Do not update schema without adding a migration.
- Do not forget to increase
schemaVersionwhen the schema changes. - Do not assume
active()means the DB is initialized. - Do not pass arbitrary column types like
"string","bool","date", or"json". Use"text","integer", or"real". - Do not create indexes for columns that do not exist.
- Do not rely on
execute().insertId; synced row IDs should be generated by your app. - Do not use manual transactions on PowerSync-backed native builds. Use
db.batch(). - Do not create synced tables with SQL migrations; define synced tables in the schema.
- Do not forget
db.powersync.events.upload()when native delegates upload to JS. - Do not ignore
db.migrate()errors. - Do not start sync after failed schema validation or failed migration.
License
MIT
