Skip to main content

Documentation Index

Fetch the complete documentation index at: https://setup.despia.com/llms.txt

Use this file to discover all available pages before exploring further.

The PowerSync integration is fully built and production-ready. The cloud sync component will become publicly available when the Despia V4 editor launches. To enable it already contact our support via the Despia live chat and share your PowerSync URL + Bundle ID.

Installation

npm install @despia/powersync
import { active, db } from '@despia/powersync';

Check for the native runtime

Returns true when the native PowerSync runtime is present, false in a standard browser. Use this to guard all database calls when your web app also runs outside Despia.
import { active } from '@despia/powersync'

if (!active()) {
    return // standard browser, no native runtime
}
active() only confirms runtime presence. It does not mean SQLite is initialized or sync is connected.

Initialize the database

Call once at startup, before any queries, migrations, or sync calls. Registers the schema and target version with native as pending state.
import { db, type PowerSyncSchema } from '@despia/powersync'

const SCHEMA_VERSION = 1

const SCHEMA: PowerSyncSchema = {
    users: {
        columns: {
            id:        'text',
            email:     'text',
            createdAt: 'text',
        },
        indexes: {
            users_by_email: ['email'],
        },
    },
    todos: {
        columns: {
            id:        'text',
            userId:    'text',
            title:     'text',
            done:      'integer',
            createdAt: 'text',
        },
        indexes: {
            todos_by_user: ['userId'],
        },
    },
}

await db.init({
    schema:        SCHEMA,
    schemaVersion: SCHEMA_VERSION,
    databaseName:  'mydb',
})
ParameterRequiredDescription
schemaYesDescribes the tables and columns native PowerSync should map. Column types must be exactly "text", "integer", or "real". Indexes are optional and map an index name to an array of column names that exist in the same table.
schemaVersionYesPositive integer matching the latest migration version for this schema. Native promotes the pending schema to active only after migrations reach this version.
databaseNameNoOptional database name. Native uses a default if omitted.
db.init() validates the schema object before calling native. If validation fails it throws a PowerSyncError with code: "invalid_schema" and a details array listing every path that failed.

Read the active schema state

Returns the schema currently active in native. Use this to determine which migrations are still pending.
const state = await db.schema()

console.log(state.schema)
console.log(state.appliedMigrationVersion)
Return fieldTypeDescription
schemaPowerSyncSchemaThe active schema installed in native.
databaseNamestringThe active database name.
schemaHashstringHash of the active schema, useful for change detection.
schemaVersionnumberThe schema version most recently passed to db.init().
appliedMigrationVersionnumberThe highest migration version that has been successfully applied.
{
  "schema": {
    "users": {
      "columns": { "id": "text", "email": "text", "createdAt": "text" },
      "indexes": { "users_by_email": ["email"] }
    }
  },
  "databaseName": "mydb",
  "schemaHash": "2d711642b726b04401627ca9fbac32f5",
  "schemaVersion": 2,
  "appliedMigrationVersion": 2
}

Run migrations

Apply pending SQL statements to the SQLite file. Native tracks the installed version and only executes statements for versions higher than the current one. Pass all pending statements for a version in a single call so native can commit or roll back the full upgrade as one transaction.
await db.migrate(1, [
    `CREATE TABLE IF NOT EXISTS users (
        id TEXT PRIMARY KEY,
        email TEXT NOT NULL,
        createdAt TEXT NOT NULL
    )`,
    'CREATE INDEX IF NOT EXISTS users_by_email ON users(email)',
])

await db.migrate(2, [
    'ALTER TABLE users ADD COLUMN displayName TEXT',
])
ParameterRequiredDescription
versionYesTarget migration version. Should match SCHEMA_VERSION. Native promotes the pending schema to active when this version matches schemaVersion.
statementsYesArray of SQL strings or BatchStatement objects to run for this version. Must be non-empty.
When your schema is at version N and the device needs migrations 1 through N, pass all pending statements in one db.migrate(N, statements) call rather than calling migrate() once per version.

Query multiple rows

Fetches all matching rows from local SQLite. Instant, no network.
type User = { id: string; email: string }

const users = await db.query<User>(
    'SELECT id, email FROM users WHERE active = ? AND role = ?',
    [1, 'admin']
)
ParameterRequiredDescription
sqlYesSQL SELECT statement.
paramsNoArray of values bound to ? placeholders in order.

Query a single row

Fetches the first matching row from local SQLite. Returns null if no row matches.
const user = await db.get<User>('SELECT * FROM users WHERE id = ?', [userId])
if (user) console.log(user.email)

Write a single row

Runs one INSERT, UPDATE, or DELETE statement.
const result = await db.execute(
    'INSERT INTO todos(id, userId, title, done, createdAt) VALUES(?, ?, ?, ?, ?)',
    ['todo_1', 'user_1', 'Buy milk', 0, new Date().toISOString()]
)
// { rowsAffected: 1 }
Return fieldTypeDescription
rowsAffectednumberNumber of rows affected by the statement.
insertIdnumberRow ID of the last inserted row. Present for INSERT statements when applicable.

Write multiple rows atomically

Runs an array of write statements as one batch. All statements succeed or none do.
await db.batch([
    {
        sql:    'INSERT INTO users(id, email, createdAt) VALUES(?, ?, ?)',
        params: ['u1', 'a@b.com', new Date().toISOString()],
    },
    {
        sql:    'INSERT INTO users(id, email, createdAt) VALUES(?, ?, ?)',
        params: ['u2', 'c@d.com', new Date().toISOString()],
    },
])
Returns { results: ExecuteResult[] }, one result per statement.

Run a transaction

Runs a group of statements with full rollback on any failure.
await db.transaction(async (tx) => {
    await tx.execute('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, fromId])
    await tx.execute('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, toId])
})
If any statement throws, the entire transaction is rolled back. The tx object exposes the same execute API as db.execute.

Subscribe to a live query

Fires the callback immediately with the current result set, then again whenever matching data changes, including rows updated by sync.
type Todo = { id: string; title: string; done: 0 | 1 }

// Without params
const unwatch = db.watch<Todo>('SELECT * FROM todos', (rows) => {
    renderTodos(rows)
})

// With params
const unwatch = db.watch<Todo>(
    'SELECT * FROM todos WHERE done = ?',
    [0],
    (rows) => renderTodos(rows)
)

// Stop watching
unwatch()
Call the returned function to stop the subscription. Always call it when the subscribing component or screen unmounts.

Connect to cloud sync

Starts sync for the signed-in user. Pass a short-lived JWT minted by your backend. Native reads the static PowerSync app ID and instance URL from native config.
const token = await getPowerSyncToken()
await db.powersync.connect({ token })
ParameterRequiredDescription
tokenYesA short-lived JWT signed by your backend. Identifies the signed-in user to PowerSync. See PowerSync auth and tokens.
Do not call this before db.init() and migrations. Sync will fail silently or throw if no active schema exists.

Trigger a manual sync

Schedules an immediate sync cycle. Sync completes asynchronously.
await db.powersync.sync()

const status = await db.powersync.status()
console.log(status)
Treat db.powersync.sync() as a trigger, then read the result with db.powersync.status() or subscribe with db.powersync.events.status().

Read current sync state

Returns a snapshot of the sync engine state.
const status = await db.powersync.status()
Return fieldTypeDescription
connectedbooleanWhether the sync engine is connected to the PowerSync instance.
lastSynced`stringnull`ISO timestamp of the last successful sync, or null if never synced.
uploadingbooleanWhether local writes are currently being uploaded.
downloadingbooleanWhether data is currently being downloaded.
{
  "connected": true,
  "lastSynced": "2026-04-29T08:30:00.000Z",
  "uploading": false,
  "downloading": false
}

Subscribe to sync state changes

Fires the callback whenever sync state changes. Use this for sync indicators, offline banners, and last-synced timestamps. For row data, use db.watch() instead.
const unsubscribe = db.powersync.events.status((status) => {
    if (!status.connected) showOfflineBanner()
    else hideOfflineBanner()
})

// Stop listening
unsubscribe()

Upload local writes to your backend

Register a handler that native calls when it has pending CRUD writes to send to your backend. Use this when your upload runs in JavaScript.
const unsubscribe = db.powersync.events.upload(async ({ crud }) => {
    await fetch('/api/powersync-upload', {
        method:      'POST',
        credentials: 'include',
        headers:     { 'Content-Type': 'application/json' },
        body:        JSON.stringify({ crud }),
    })
})

// Stop listening when tearing down the session
unsubscribe()
If the handler resolves, the SDK tells native upload_complete and the native queue is finalized. If the handler throws, the SDK reports the error back to native and PowerSync retries later. Calling events.upload() again replaces the previous handler, so register one handler per session.

Create a search index

Creates or updates a native full-text index on a table. Resolves when the index is fully built, including asynchronous builds on large tables. Native validates that the source table and columns exist.
await db.search.index({
    name:    'postsSearch',
    table:   'posts',
    columns: ['title', 'body'],
})
ParameterRequiredDescription
nameYesUnique name for this search index.
tableYesThe SQLite table to index. Must exist.
columnsYesNon-empty array of column names to include. Each must exist in the table.
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.

Check search index build state

Returns the current native build state for a search index. Useful for showing progress while a large index builds.
const status = await db.search.index.status('postsSearch')
Also available as db.search.indexStatus('postsSearch') for compatibility.
Return fieldTypeDescription
namestringThe index name.
statePowerSyncSearchIndexStateOne of "pending", "building", "ready", or "failed".
processednumberRows processed so far during an active build.
totalnumberTotal rows to process.
percentnumberBuild progress as an integer percentage (0 to 100).
{
  "name": "postsSearch",
  "state": "building",
  "processed": 12345,
  "total": 500000,
  "percent": 2
}

Subscribe to search index build events

Subscribe to the native async build lifecycle. Useful for showing progress in the UI while db.search.index() or db.search.rebuildIndex() waits.
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} is ready`)
})

const offFailed = db.search.index.events.failed(({ name, received }) => {
    console.error(`${name} failed:`, received)
})

// Stop listening
offProgress()
offBuilt()
offFailed()
db.search.events.index.progress, db.search.events.index.built, and db.search.events.index.failed are aliases for compatibility.

List search indexes

Returns all native search indexes registered for the database.
const indexes = await db.search.indexes()
// [{ name: 'postsSearch', table: 'posts', columns: ['title', 'body'] }]

Query a search index

Queries a native search index with plain, prefix, or fuzzy matching.
type PostRow = { id: string; title: string; body: string | null }

const rows = await db.search.query<PostRow>({
    index: 'postsSearch',
    text:  'ofline sycn',
    mode:  'fuzzy',
    limit: 20,
})
ParameterRequiredDescription
indexYesName of the search index to query.
textYesSearch text. Capped at 256 characters.
modeYes"plain" for exact term matching, "prefix" for prefix matching, "fuzzy" for typo-tolerant ranking when supported by the app build.
limitNoMaximum rows to return. Capped at 1000.

Rebuild a search index

Rebuilds the index from the current table data. Resolves when the rebuild is complete. Use this after large migrations or data repairs.
await db.search.rebuildIndex({ name: 'postsSearch' })

Drop a search index

Removes a search index permanently.
await db.search.dropIndex({ name: 'postsSearch' })

PowerSync auth and tokens

db.powersync.connect({ token }) gives the native sync engine a token for the current signed-in user. It does not log the user into your app and does not create the token. Three separate pieces are involved: your app auth decides who the user is, PowerSync Client Auth verifies the token native sends to PowerSync, and PowerSync sync rules decide which rows that verified user can sync. The token identifies the user. If a row syncs into local SQLite, your app can query it. Private rows must be blocked by sync rules before they reach the device.

Token flow

Your backend signs the JWT with the key or secret configured in PowerSync Client Auth. PowerSync verifies the signature, kid, audience, expiry, and subject.

PowerSync dashboard setup

1

Open your PowerSync project

Go to powersync.com and open your project or instance.
2

Configure Client Auth

Go to Client Auth and configure the same JWT verification method your backend uses.
3

Configure sync rules

Set up sync rules or streams that use the authenticated user identity to control which rows each user can sync.
For custom JWT auth, PowerSync expects a signed JWT:
{
    "sub":    "user_123",
    "aud":    "https://your-powersync-instance-url",
    "iat":    1710000000,
    "exp":    1710000900,
    "userId": "user_123"
}
{
    "alg": "HS256",
    "kid": "your-key-id"
}
FieldPurpose
subThe application user ID. PowerSync sync rules use this as the user identity.
audMust match the PowerSync instance URL or configured audience.
iat / expToken issue and expiry times. Keep expiry short.
kidHeader key id. Must match the key configured in PowerSync Client Auth.
custom claimsOptional data such as team, role, or project claims if your sync rules need them.
For production, asymmetric JWT signing with JWKS is preferred so PowerSync can verify tokens with a public key while your backend keeps the private signing key. HS256 works for development if it exactly matches your PowerSync Client Auth configuration.

Mint a token on your backend

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 on the server. The frontend receives only a short-lived token for the authenticated user.

Fetch the token on the client

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 })

Supabase and Firebase

If your app already uses Supabase Auth or Firebase Auth, you may not need a custom token endpoint. PowerSync can verify those provider JWTs directly when Client Auth, audience, and JWKS settings match your provider.

Migrations reference

Schema describes the expected shape. Migration SQL changes the actual SQLite file. You always need both. Keep schema and migrations together in one module:
import type { PowerSyncSchema } from '@despia/powersync'

export const DATABASE_NAME  = 'mydb'
export const SCHEMA_VERSION = 2

export const CURRENT_SCHEMA: PowerSyncSchema = {
    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'] },
    },
}

export const MIGRATIONS = [
    {
        version: 1,
        statements: [
            'CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL, createdAt TEXT NOT NULL)',
            'CREATE INDEX IF NOT EXISTS users_by_email ON users(email)',
        ],
    },
    {
        version: 2,
        statements: [
            'CREATE TABLE IF NOT EXISTS posts (id TEXT PRIMARY KEY, userId TEXT NOT NULL, title TEXT NOT NULL, body TEXT, createdAt TEXT NOT NULL)',
            'CREATE INDEX IF NOT EXISTS posts_by_user ON posts(userId)',
        ],
    },
]
When the schema changes: update CURRENT_SCHEMA, increase SCHEMA_VERSION, add a migration with the new version, run all pending statements with db.migrate(SCHEMA_VERSION, pendingStatements), and only then run queries or sync that depend on the new shape.

Add a column

await db.migrate(3, [
    'ALTER TABLE users ADD COLUMN displayName TEXT',
])

Add a table

await db.migrate(4, [
    'CREATE TABLE IF NOT EXISTS comments (id TEXT PRIMARY KEY, postId TEXT NOT NULL, body TEXT NOT NULL)',
    'CREATE INDEX IF NOT EXISTS comments_by_post ON comments(postId)',
])

Rename or reshape a table

Use copy-and-swap. SQLite does not support ALTER TABLE ... RENAME COLUMN on older versions.
await db.migrate(5, [
    `CREATE TABLE users_new (
        id TEXT PRIMARY KEY,
        email TEXT NOT NULL,
        displayName TEXT,
        createdAt TEXT NOT NULL
    )`,
    'INSERT INTO users_new (id, email, displayName, createdAt) SELECT id, email, NULL, createdAt FROM users',
    'DROP TABLE users',
    'ALTER TABLE users_new RENAME TO users',
])

Errors

db.init(), db.migrate(), and other methods throw a PowerSyncError when validation fails.
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
}

Error codes

CodeSourceMeaningFix
schema_requiredSDK or nativeMissing or empty schema, or no active schema for a native operation that requires one.Call db.init({ schema, schemaVersion }), apply migrations, then retry.
invalid_schemaSDK or nativeSchema shape is malformed.Check error.details[] and fix each listed path.
invalid_optionsSDK or nativeMethod options are malformed.Pass the documented options object with valid field types.
credentials_requiredNativeSync requires a token.Call db.powersync.connect({ token }).
sync_not_configuredNativeNative PowerSync app ID or URL config is missing.Fix native PowerSync config.
sync_not_initializedNativeNative sync engine could not start or is not ready.Ensure active schema, applied migrations, credentials, and native SDK setup are complete.
migration_validation_failedNativeMigration SQL failed or the expected schema shape was not reached after migration.Fix the SQL or schema before retrying db.migrate().
database_not_initializedNativeNative SQLite database is not open.Check native runtime and database setup.
request_timeoutSDKNative did not respond within the timeout.Ensure native processes requests promptly.

Validation reasons

ReasonPath exampleExpectedExample received
missing_or_invalid_schemaschemanon-empty objectundefined, null, array
empty_schemaschemaobject with at least one tableempty object
empty_table_nameschemanon-empty table nameempty string
invalid_table_definitionschema.usersobject with columns mapstring, array, null
invalid_columnsschema.users.columnsnon-empty objectstring, array, null
empty_columnsschema.users.columnsobject with at least one columnempty object
empty_column_nameschema.users.columnsnon-empty column nameempty string
invalid_column_typeschema.users.columns.age["text", "integer", "real"]varchar, number, boolean
invalid_indexesschema.users.indexesobject mapping index names to column arraysstring, array, null
empty_index_nameschema.users.indexesnon-empty index nameempty string
invalid_index_columnsschema.users.indexes.by_emailnon-empty string arrayempty array, string, null
invalid_index_column_nameschema.users.indexes.by_emailnon-empty stringempty string, number
unknown_index_columnschema.users.indexes.by_rowexisting column namerow
invalid_schema_versionoptions.schemaVersionpositive integer0, 1.5, string
invalid_database_nameoptions.databaseNamenon-empty stringempty string, number
invalid_migration_versionversionpositive integer0, 1.5, string
invalid_migration_statementsstatementsnon-empty string array or BatchStatement[]empty array, null
invalid_migration_sqlstatements.0non-empty SQL stringempty string, number
empty_migration_sqlstatements.0.sqlnon-empty SQL stringempty string
invalid_tokenconfig.tokennon-empty stringempty string, undefined
invalid_urlconfig.urlnon-empty stringempty string, number

Fallback on failed migration

If a migration fails, do not start sync with the new schema. Fall back to the previously active schema if your app can run against it.
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((m) => m.version > appliedVersion)

        if (pendingMigrations.length > 0) {
            await db.migrate(
                SCHEMA_VERSION,
                pendingMigrations.flatMap((m) => m.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 }
        }
    }
}

Standard PowerSync flow

This is the correct call order on every app start. The active() guard is required when your web app also runs outside Despia.
import { active, db } from '@despia/powersync'
import { CURRENT_SCHEMA, SCHEMA_VERSION, DATABASE_NAME, MIGRATIONS } from './schema'

if (!active()) {
    // Running in a standard browser, skip native database calls.
    throw new Error('PowerSync requires the Despia native runtime.')
}

// 1. Register schema and target version with native.
await db.init({
    schema:        CURRENT_SCHEMA,
    schemaVersion: SCHEMA_VERSION,
    databaseName:  DATABASE_NAME,
})

// 2. Apply any pending migrations.
const activeSchema      = await db.schema().catch(() => null)
const appliedVersion    = activeSchema?.appliedMigrationVersion ?? 0
const pendingMigrations = MIGRATIONS.filter((m) => m.version > appliedVersion)

if (pendingMigrations.length > 0) {
    await db.migrate(
        SCHEMA_VERSION,
        pendingMigrations.flatMap((m) => m.statements)
    )
}

// 3. Connect sync once the user is authenticated.
if (token) {
    await db.powersync.connect({ token })
}

// 4. Register an upload handler if your backend upload runs in JS.
const unsubscribeUpload = db.powersync.events.upload(async ({ crud }) => {
    await fetch('/api/powersync-upload', {
        method:      'POST',
        credentials: 'include',
        headers:     { 'Content-Type': 'application/json' },
        body:        JSON.stringify({ crud }),
    })
})

// 5. Subscribe to live query results.
type Todo = { id: string; title: string; done: 0 | 1 }

const unwatch = db.watch<Todo>(
    'SELECT * FROM todos WHERE done = ? ORDER BY createdAt DESC',
    [0],
    (rows) => renderTodos(rows)
)

// 6. Tear down subscriptions when the session ends.
// unwatch()
// unsubscribeUpload()

TypeScript types

export type PowerSyncColumnType = 'text' | 'integer' | 'real'

export type PowerSyncTableSchema = {
    columns:  Record<string, PowerSyncColumnType>
    indexes?: Record<string, string[]>
}

export type PowerSyncSchema         = Record<string, PowerSyncTableSchema>
export type PowerSyncInitOptions    = { schema: PowerSyncSchema; schemaVersion: number; databaseName?: string }
export type PowerSyncSchemaState    = { schema: PowerSyncSchema; databaseName: string; schemaHash: string; schemaVersion: number; appliedMigrationVersion: number }
export type PowerSyncConfig         = { token: string }
export type ExecuteResult           = { rowsAffected: number; insertId?: number }
export type BatchStatement          = { sql: string; params?: unknown[] }
export type BatchResult             = { results: ExecuteResult[] }
export type SyncStatus              = { connected: boolean; lastSynced: string | null; uploading: boolean; downloading: boolean }

export type PowerSyncCrudOperation  = 'INSERT' | 'UPDATE' | 'DELETE'
export type PowerSyncCrudEntry      = { id: string; table: string; operation: PowerSyncCrudOperation; data: Record<string, unknown> }
export type PowerSyncUploadPayload  = { crud: PowerSyncCrudEntry[] }
export type PowerSyncUploadHandler  = (payload: PowerSyncUploadPayload) => void | Promise<void>

export type PowerSyncSearchMode                = 'plain' | 'prefix' | 'fuzzy'
export type PowerSyncSearchIndexState          = 'pending' | 'building' | 'ready' | 'failed'
export type PowerSyncSearchIndexOptions        = { name: string; table: string; columns: string[] }
export type PowerSyncSearchIndex               = { name: string; table: string; columns: string[] }
export type PowerSyncSearchIndexResult         = { name: string; state: PowerSyncSearchIndexState }
export type PowerSyncSearchIndexStatus         = { name: string; state: PowerSyncSearchIndexState; processed: number; total: number; percent: number }
export type PowerSyncSearchIndexProgressEvent  = { name: string; processed: number; total: number; percent: number }
export type PowerSyncSearchIndexBuiltEvent     = { name: string }
export type PowerSyncSearchIndexFailedEvent    = { name: string; received: string }
export type PowerSyncSearchQueryOptions        = { index: string; text: string; mode: PowerSyncSearchMode; limit?: number }
export type PowerSyncSearchRow<T>              = T & { _rank?: number }
export type PowerSyncSearchDropIndexOptions    = { name: string }
export type PowerSyncSearchRebuildIndexOptions = { name: string }

export type PowerSyncErrorDetail    = { path?: string; reason?: string; expected?: string | string[]; received?: string; [key: string]: unknown }
export type PowerSyncError          = Error & { code?: string; details?: PowerSyncErrorDetail[]; nativeError?: string }

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'

Resources

NPM Package

@despia/powersync

GitHub

despia-native/despia-powersync

PowerSync

Backend setup, schema config, and sync rules

Check for the native runtime

Returns true when the native PowerSync runtime is present, false in a standard browser. Use this to guard all database calls when your web app also runs outside Despia.
import { active } from '@despia/powersync'

if (!active()) {
    return // standard browser, no native runtime
}
active() only confirms runtime presence. It does not mean SQLite is initialized or sync is connected.

Initialize the database

Call once at startup, before any queries, migrations, or sync calls. Registers the schema and target version with native as pending state.
import { db, type PowerSyncSchema } from '@despia/powersync'

const SCHEMA_VERSION = 1

const SCHEMA: PowerSyncSchema = {
    users: {
        columns: {
            id:        'text',
            email:     'text',
            createdAt: 'text',
        },
        indexes: {
            users_by_email: ['email'],
        },
    },
    todos: {
        columns: {
            id:        'text',
            userId:    'text',
            title:     'text',
            done:      'integer',
            createdAt: 'text',
        },
        indexes: {
            todos_by_user: ['userId'],
        },
    },
}

await db.init({
    schema:        SCHEMA,
    schemaVersion: SCHEMA_VERSION,
    databaseName:  'mydb',
})
ParameterRequiredDescription
schemaYesDescribes the tables and columns native PowerSync should map. Column types must be exactly "text", "integer", or "real". Indexes are optional and map an index name to an array of column names that exist in the same table.
schemaVersionYesPositive integer matching the latest migration version for this schema. Native promotes the pending schema to active only after migrations reach this version.
databaseNameNoOptional database name. Native uses a default if omitted.
db.init() validates the schema object before calling native. If validation fails it throws a PowerSyncError with code: "invalid_schema" and a details array listing every path that failed.

Read the active schema state

Returns the schema currently active in native. Use this to determine which migrations are still pending.
const state = await db.schema()

console.log(state.schema)
console.log(state.appliedMigrationVersion)
Return fieldTypeDescription
schemaPowerSyncSchemaThe active schema installed in native.
databaseNamestringThe active database name.
schemaHashstringHash of the active schema, useful for change detection.
schemaVersionnumberThe schema version most recently passed to db.init().
appliedMigrationVersionnumberThe highest migration version that has been successfully applied.
{
  "schema": {
    "users": {
      "columns": { "id": "text", "email": "text", "createdAt": "text" },
      "indexes": { "users_by_email": ["email"] }
    }
  },
  "databaseName": "mydb",
  "schemaHash": "2d711642b726b04401627ca9fbac32f5",
  "schemaVersion": 2,
  "appliedMigrationVersion": 2
}

Run migrations

Apply pending SQL statements to the SQLite file. Native tracks the installed version and only executes statements for versions higher than the current one. Pass all pending statements for a version in a single call so native can commit or roll back the full upgrade as one transaction.
await db.migrate(1, [
    `CREATE TABLE IF NOT EXISTS users (
        id TEXT PRIMARY KEY,
        email TEXT NOT NULL,
        createdAt TEXT NOT NULL
    )`,
    'CREATE INDEX IF NOT EXISTS users_by_email ON users(email)',
])

await db.migrate(2, [
    'ALTER TABLE users ADD COLUMN displayName TEXT',
])
ParameterRequiredDescription
versionYesTarget migration version. Should match SCHEMA_VERSION. Native promotes the pending schema to active when this version matches schemaVersion.
statementsYesArray of SQL strings or BatchStatement objects to run for this version. Must be non-empty.
When your schema is at version N and the device needs migrations 1 through N, pass all pending statements in one db.migrate(N, statements) call rather than calling migrate() once per version.

Query multiple rows

Fetches all matching rows from local SQLite. Instant, no network.
type User = { id: string; email: string }

const users = await db.query<User>(
    'SELECT id, email FROM users WHERE active = ? AND role = ?',
    [1, 'admin']
)
ParameterRequiredDescription
sqlYesSQL SELECT statement.
paramsNoArray of values bound to ? placeholders in order.

Query a single row

Fetches the first matching row from local SQLite. Returns null if no row matches.
const user = await db.get<User>('SELECT * FROM users WHERE id = ?', [userId])
if (user) console.log(user.email)

Write a single row

Runs one INSERT, UPDATE, or DELETE statement.
const result = await db.execute(
    'INSERT INTO todos(id, userId, title, done, createdAt) VALUES(?, ?, ?, ?, ?)',
    ['todo_1', 'user_1', 'Buy milk', 0, new Date().toISOString()]
)
// { rowsAffected: 1 }
Return fieldTypeDescription
rowsAffectednumberNumber of rows affected by the statement.
insertIdnumberRow ID of the last inserted row. Present for INSERT statements when applicable.

Write multiple rows atomically

Runs an array of write statements as one batch. All statements succeed or none do.
await db.batch([
    {
        sql:    'INSERT INTO users(id, email, createdAt) VALUES(?, ?, ?)',
        params: ['u1', 'a@b.com', new Date().toISOString()],
    },
    {
        sql:    'INSERT INTO users(id, email, createdAt) VALUES(?, ?, ?)',
        params: ['u2', 'c@d.com', new Date().toISOString()],
    },
])
Returns { results: ExecuteResult[] }, one result per statement.

Run a transaction

Runs a group of statements with full rollback on any failure.
await db.transaction(async (tx) => {
    await tx.execute('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, fromId])
    await tx.execute('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, toId])
})
If any statement throws, the entire transaction is rolled back. The tx object exposes the same execute API as db.execute.

Subscribe to a live query

Fires the callback immediately with the current result set, then again whenever matching data changes, including rows updated by sync.
type Todo = { id: string; title: string; done: 0 | 1 }

// Without params
const unwatch = db.watch<Todo>('SELECT * FROM todos', (rows) => {
    renderTodos(rows)
})

// With params
const unwatch = db.watch<Todo>(
    'SELECT * FROM todos WHERE done = ?',
    [0],
    (rows) => renderTodos(rows)
)

// Stop watching
unwatch()
Call the returned function to stop the subscription. Always call it when the subscribing component or screen unmounts.

Connect to cloud sync

Starts sync for the signed-in user. Pass a short-lived JWT minted by your backend. Native reads the static PowerSync app ID and instance URL from native config.
const token = await getPowerSyncToken()
await db.powersync.connect({ token })
ParameterRequiredDescription
tokenYesA short-lived JWT signed by your backend. Identifies the signed-in user to PowerSync. See PowerSync auth and tokens.
Do not call this before db.init() and migrations. Sync will fail silently or throw if no active schema exists.

Trigger a manual sync

Schedules an immediate sync cycle. Sync completes asynchronously.
await db.powersync.sync()

const status = await db.powersync.status()
console.log(status)
Treat db.powersync.sync() as a trigger, then read the result with db.powersync.status() or subscribe with db.powersync.events.status().

Read current sync state

Returns a snapshot of the sync engine state.
const status = await db.powersync.status()
Return fieldTypeDescription
connectedbooleanWhether the sync engine is connected to the PowerSync instance.
lastSynced`stringnull`ISO timestamp of the last successful sync, or null if never synced.
uploadingbooleanWhether local writes are currently being uploaded.
downloadingbooleanWhether data is currently being downloaded.
{
  "connected": true,
  "lastSynced": "2026-04-29T08:30:00.000Z",
  "uploading": false,
  "downloading": false
}

Subscribe to sync state changes

Fires the callback whenever sync state changes. Use this for sync indicators, offline banners, and last-synced timestamps. For row data, use db.watch() instead.
const unsubscribe = db.powersync.events.status((status) => {
    if (!status.connected) showOfflineBanner()
    else hideOfflineBanner()
})

// Stop listening
unsubscribe()

Upload local writes to your backend

Register a handler that native calls when it has pending CRUD writes to send to your backend. Use this when your upload runs in JavaScript.
const unsubscribe = db.powersync.events.upload(async ({ crud }) => {
    await fetch('/api/powersync-upload', {
        method:      'POST',
        credentials: 'include',
        headers:     { 'Content-Type': 'application/json' },
        body:        JSON.stringify({ crud }),
    })
})

// Stop listening when tearing down the session
unsubscribe()
If the handler resolves, the SDK tells native upload_complete and the native queue is finalized. If the handler throws, the SDK reports the error back to native and PowerSync retries later. Calling events.upload() again replaces the previous handler, so register one handler per session.

Create a search index

Creates or updates a native full-text index on a table. Resolves when the index is fully built, including asynchronous builds on large tables. Native validates that the source table and columns exist.
await db.search.index({
    name:    'postsSearch',
    table:   'posts',
    columns: ['title', 'body'],
})
ParameterRequiredDescription
nameYesUnique name for this search index.
tableYesThe SQLite table to index. Must exist.
columnsYesNon-empty array of column names to include. Each must exist in the table.
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.

Check search index build state

Returns the current native build state for a search index. Useful for showing progress while a large index builds.
const status = await db.search.index.status('postsSearch')
Also available as db.search.indexStatus('postsSearch') for compatibility.
Return fieldTypeDescription
namestringThe index name.
statePowerSyncSearchIndexStateOne of "pending", "building", "ready", or "failed".
processednumberRows processed so far during an active build.
totalnumberTotal rows to process.
percentnumberBuild progress as an integer percentage (0 to 100).
{
  "name": "postsSearch",
  "state": "building",
  "processed": 12345,
  "total": 500000,
  "percent": 2
}

Subscribe to search index build events

Subscribe to the native async build lifecycle. Useful for showing progress in the UI while db.search.index() or db.search.rebuildIndex() waits.
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} is ready`)
})

const offFailed = db.search.index.events.failed(({ name, received }) => {
    console.error(`${name} failed:`, received)
})

// Stop listening
offProgress()
offBuilt()
offFailed()
db.search.events.index.progress, db.search.events.index.built, and db.search.events.index.failed are aliases for compatibility.

List search indexes

Returns all native search indexes registered for the database.
const indexes = await db.search.indexes()
// [{ name: 'postsSearch', table: 'posts', columns: ['title', 'body'] }]

Query a search index

Queries a native search index with plain, prefix, or fuzzy matching.
type PostRow = { id: string; title: string; body: string | null }

const rows = await db.search.query<PostRow>({
    index: 'postsSearch',
    text:  'ofline sycn',
    mode:  'fuzzy',
    limit: 20,
})
ParameterRequiredDescription
indexYesName of the search index to query.
textYesSearch text. Capped at 256 characters.
modeYes"plain" for exact term matching, "prefix" for prefix matching, "fuzzy" for typo-tolerant ranking when supported by the app build.
limitNoMaximum rows to return. Capped at 1000.

Rebuild a search index

Rebuilds the index from the current table data. Resolves when the rebuild is complete. Use this after large migrations or data repairs.
await db.search.rebuildIndex({ name: 'postsSearch' })

Drop a search index

Removes a search index permanently.
await db.search.dropIndex({ name: 'postsSearch' })

PowerSync auth and tokens

db.powersync.connect({ token }) gives the native sync engine a token for the current signed-in user. It does not log the user into your app and does not create the token. Three separate pieces are involved: your app auth decides who the user is, PowerSync Client Auth verifies the token native sends to PowerSync, and PowerSync sync rules decide which rows that verified user can sync. The token identifies the user. If a row syncs into local SQLite, your app can query it. Private rows must be blocked by sync rules before they reach the device.

Token flow

Your backend signs the JWT with the key or secret configured in PowerSync Client Auth. PowerSync verifies the signature, kid, audience, expiry, and subject.

PowerSync dashboard setup

1

Open your PowerSync project

Go to powersync.com and open your project or instance.
2

Configure Client Auth

Go to Client Auth and configure the same JWT verification method your backend uses.
3

Configure sync rules

Set up sync rules or streams that use the authenticated user identity to control which rows each user can sync.
For custom JWT auth, PowerSync expects a signed JWT:
{
    "sub":    "user_123",
    "aud":    "https://your-powersync-instance-url",
    "iat":    1710000000,
    "exp":    1710000900,
    "userId": "user_123"
}
{
    "alg": "HS256",
    "kid": "your-key-id"
}
FieldPurpose
subThe application user ID. PowerSync sync rules use this as the user identity.
audMust match the PowerSync instance URL or configured audience.
iat / expToken issue and expiry times. Keep expiry short.
kidHeader key id. Must match the key configured in PowerSync Client Auth.
custom claimsOptional data such as team, role, or project claims if your sync rules need them.
For production, asymmetric JWT signing with JWKS is preferred so PowerSync can verify tokens with a public key while your backend keeps the private signing key. HS256 works for development if it exactly matches your PowerSync Client Auth configuration.

Mint a token on your backend

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 on the server. The frontend receives only a short-lived token for the authenticated user.

Fetch the token on the client

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 })

Supabase and Firebase

If your app already uses Supabase Auth or Firebase Auth, you may not need a custom token endpoint. PowerSync can verify those provider JWTs directly when Client Auth, audience, and JWKS settings match your provider.

Migrations reference

Schema describes the expected shape. Migration SQL changes the actual SQLite file. You always need both. Keep schema and migrations together in one module:
import type { PowerSyncSchema } from '@despia/powersync'

export const DATABASE_NAME  = 'mydb'
export const SCHEMA_VERSION = 2

export const CURRENT_SCHEMA: PowerSyncSchema = {
    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'] },
    },
}

export const MIGRATIONS = [
    {
        version: 1,
        statements: [
            'CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL, createdAt TEXT NOT NULL)',
            'CREATE INDEX IF NOT EXISTS users_by_email ON users(email)',
        ],
    },
    {
        version: 2,
        statements: [
            'CREATE TABLE IF NOT EXISTS posts (id TEXT PRIMARY KEY, userId TEXT NOT NULL, title TEXT NOT NULL, body TEXT, createdAt TEXT NOT NULL)',
            'CREATE INDEX IF NOT EXISTS posts_by_user ON posts(userId)',
        ],
    },
]
When the schema changes: update CURRENT_SCHEMA, increase SCHEMA_VERSION, add a migration with the new version, run all pending statements with db.migrate(SCHEMA_VERSION, pendingStatements), and only then run queries or sync that depend on the new shape.

Add a column

await db.migrate(3, [
    'ALTER TABLE users ADD COLUMN displayName TEXT',
])

Add a table

await db.migrate(4, [
    'CREATE TABLE IF NOT EXISTS comments (id TEXT PRIMARY KEY, postId TEXT NOT NULL, body TEXT NOT NULL)',
    'CREATE INDEX IF NOT EXISTS comments_by_post ON comments(postId)',
])

Rename or reshape a table

Use copy-and-swap. SQLite does not support ALTER TABLE ... RENAME COLUMN on older versions.
await db.migrate(5, [
    `CREATE TABLE users_new (
        id TEXT PRIMARY KEY,
        email TEXT NOT NULL,
        displayName TEXT,
        createdAt TEXT NOT NULL
    )`,
    'INSERT INTO users_new (id, email, displayName, createdAt) SELECT id, email, NULL, createdAt FROM users',
    'DROP TABLE users',
    'ALTER TABLE users_new RENAME TO users',
])

Errors

db.init(), db.migrate(), and other methods throw a PowerSyncError when validation fails.
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
}

Error codes

CodeSourceMeaningFix
schema_requiredSDK or nativeMissing or empty schema, or no active schema for a native operation that requires one.Call db.init({ schema, schemaVersion }), apply migrations, then retry.
invalid_schemaSDK or nativeSchema shape is malformed.Check error.details[] and fix each listed path.
invalid_optionsSDK or nativeMethod options are malformed.Pass the documented options object with valid field types.
credentials_requiredNativeSync requires a token.Call db.powersync.connect({ token }).
sync_not_configuredNativeNative PowerSync app ID or URL config is missing.Fix native PowerSync config.
sync_not_initializedNativeNative sync engine could not start or is not ready.Ensure active schema, applied migrations, credentials, and native SDK setup are complete.
migration_validation_failedNativeMigration SQL failed or the expected schema shape was not reached after migration.Fix the SQL or schema before retrying db.migrate().
database_not_initializedNativeNative SQLite database is not open.Check native runtime and database setup.
request_timeoutSDKNative did not respond within the timeout.Ensure native processes requests promptly.

Validation reasons

ReasonPath exampleExpectedExample received
missing_or_invalid_schemaschemanon-empty objectundefined, null, array
empty_schemaschemaobject with at least one tableempty object
empty_table_nameschemanon-empty table nameempty string
invalid_table_definitionschema.usersobject with columns mapstring, array, null
invalid_columnsschema.users.columnsnon-empty objectstring, array, null
empty_columnsschema.users.columnsobject with at least one columnempty object
empty_column_nameschema.users.columnsnon-empty column nameempty string
invalid_column_typeschema.users.columns.age["text", "integer", "real"]varchar, number, boolean
invalid_indexesschema.users.indexesobject mapping index names to column arraysstring, array, null
empty_index_nameschema.users.indexesnon-empty index nameempty string
invalid_index_columnsschema.users.indexes.by_emailnon-empty string arrayempty array, string, null
invalid_index_column_nameschema.users.indexes.by_emailnon-empty stringempty string, number
unknown_index_columnschema.users.indexes.by_rowexisting column namerow
invalid_schema_versionoptions.schemaVersionpositive integer0, 1.5, string
invalid_database_nameoptions.databaseNamenon-empty stringempty string, number
invalid_migration_versionversionpositive integer0, 1.5, string
invalid_migration_statementsstatementsnon-empty string array or BatchStatement[]empty array, null
invalid_migration_sqlstatements.0non-empty SQL stringempty string, number
empty_migration_sqlstatements.0.sqlnon-empty SQL stringempty string
invalid_tokenconfig.tokennon-empty stringempty string, undefined
invalid_urlconfig.urlnon-empty stringempty string, number

Fallback on failed migration

If a migration fails, do not start sync with the new schema. Fall back to the previously active schema if your app can run against it.
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((m) => m.version > appliedVersion)

        if (pendingMigrations.length > 0) {
            await db.migrate(
                SCHEMA_VERSION,
                pendingMigrations.flatMap((m) => m.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 }
        }
    }
}

Standard PowerSync flow

This is the correct call order on every app start. The active() guard is required when your web app also runs outside Despia.
import { active, db } from '@despia/powersync'
import { CURRENT_SCHEMA, SCHEMA_VERSION, DATABASE_NAME, MIGRATIONS } from './schema'

if (!active()) {
    // Running in a standard browser, skip native database calls.
    throw new Error('PowerSync requires the Despia native runtime.')
}

// 1. Register schema and target version with native.
await db.init({
    schema:        CURRENT_SCHEMA,
    schemaVersion: SCHEMA_VERSION,
    databaseName:  DATABASE_NAME,
})

// 2. Apply any pending migrations.
const activeSchema      = await db.schema().catch(() => null)
const appliedVersion    = activeSchema?.appliedMigrationVersion ?? 0
const pendingMigrations = MIGRATIONS.filter((m) => m.version > appliedVersion)

if (pendingMigrations.length > 0) {
    await db.migrate(
        SCHEMA_VERSION,
        pendingMigrations.flatMap((m) => m.statements)
    )
}

// 3. Connect sync once the user is authenticated.
if (token) {
    await db.powersync.connect({ token })
}

// 4. Register an upload handler if your backend upload runs in JS.
const unsubscribeUpload = db.powersync.events.upload(async ({ crud }) => {
    await fetch('/api/powersync-upload', {
        method:      'POST',
        credentials: 'include',
        headers:     { 'Content-Type': 'application/json' },
        body:        JSON.stringify({ crud }),
    })
})

// 5. Subscribe to live query results.
type Todo = { id: string; title: string; done: 0 | 1 }

const unwatch = db.watch<Todo>(
    'SELECT * FROM todos WHERE done = ? ORDER BY createdAt DESC',
    [0],
    (rows) => renderTodos(rows)
)

// 6. Tear down subscriptions when the session ends.
// unwatch()
// unsubscribeUpload()

TypeScript types

export type PowerSyncColumnType = 'text' | 'integer' | 'real'

export type PowerSyncTableSchema = {
    columns:  Record<string, PowerSyncColumnType>
    indexes?: Record<string, string[]>
}

export type PowerSyncSchema         = Record<string, PowerSyncTableSchema>
export type PowerSyncInitOptions    = { schema: PowerSyncSchema; schemaVersion: number; databaseName?: string }
export type PowerSyncSchemaState    = { schema: PowerSyncSchema; databaseName: string; schemaHash: string; schemaVersion: number; appliedMigrationVersion: number }
export type PowerSyncConfig         = { token: string }
export type ExecuteResult           = { rowsAffected: number; insertId?: number }
export type BatchStatement          = { sql: string; params?: unknown[] }
export type BatchResult             = { results: ExecuteResult[] }
export type SyncStatus              = { connected: boolean; lastSynced: string | null; uploading: boolean; downloading: boolean }

export type PowerSyncCrudOperation  = 'INSERT' | 'UPDATE' | 'DELETE'
export type PowerSyncCrudEntry      = { id: string; table: string; operation: PowerSyncCrudOperation; data: Record<string, unknown> }
export type PowerSyncUploadPayload  = { crud: PowerSyncCrudEntry[] }
export type PowerSyncUploadHandler  = (payload: PowerSyncUploadPayload) => void | Promise<void>

export type PowerSyncSearchMode                = 'plain' | 'prefix' | 'fuzzy'
export type PowerSyncSearchIndexState          = 'pending' | 'building' | 'ready' | 'failed'
export type PowerSyncSearchIndexOptions        = { name: string; table: string; columns: string[] }
export type PowerSyncSearchIndex               = { name: string; table: string; columns: string[] }
export type PowerSyncSearchIndexResult         = { name: string; state: PowerSyncSearchIndexState }
export type PowerSyncSearchIndexStatus         = { name: string; state: PowerSyncSearchIndexState; processed: number; total: number; percent: number }
export type PowerSyncSearchIndexProgressEvent  = { name: string; processed: number; total: number; percent: number }
export type PowerSyncSearchIndexBuiltEvent     = { name: string }
export type PowerSyncSearchIndexFailedEvent    = { name: string; received: string }
export type PowerSyncSearchQueryOptions        = { index: string; text: string; mode: PowerSyncSearchMode; limit?: number }
export type PowerSyncSearchRow<T>              = T & { _rank?: number }
export type PowerSyncSearchDropIndexOptions    = { name: string }
export type PowerSyncSearchRebuildIndexOptions = { name: string }

export type PowerSyncErrorDetail    = { path?: string; reason?: string; expected?: string | string[]; received?: string; [key: string]: unknown }
export type PowerSyncError          = Error & { code?: string; details?: PowerSyncErrorDetail[]; nativeError?: string }

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'

Resources