20 KiB
| name | description | metadata | ||
|---|---|---|---|---|
| sync-config | PowerSync Sync Config — Sync Streams (new), Sync Rules (legacy), parameters, CTEs, common patterns, and migration guidance |
|
Sync Config
Load this when writing or modifying sync configuration — Sync Streams (new) or Sync Rules (legacy). Required for every PowerSync project.
Table of Contents
Sync Streams (new): Requirements · File Format · Structure · Stream Options · Common Patterns · Query Parameters · CTEs · Migration · Client Usage · Advanced Topics Sync Rules (legacy): Structure · Parameter Queries · Data Queries · Supported SQL · Common Patterns
Expert guidance on Sync Config. Sync config is divided into two sections:
- Sync Streams (new, default) - The latest implementation of Sync Config. New apps should use Sync Streams by default. Prioritize Sync Streams above Sync Rules.
- Sync Rules (legacy) - The first implementation of Sync Config. New apps should not use Sync Rules, prioritize Sync Streams over Sync Rules.
Critical warnings for fast setup:
sync-config.yamlmust begin with a top-levelconfig:block containingedition: 3.- If the app is stuck on
Syncing..., first assume sync config was never deployed or backend setup is incomplete.
Sync Streams
Sync Streams define exactly which data is synced to each client by using named, SQL-like queries and subscription parameters.
For a full overview, see Sync Streams Overview
Requirements
PowerSync Service
- Self-hosted: v1.20.0+
- Cloud: Already met
Sync Config
Must use config edition 3 in their sync config:
config:
edition: 3
PowerSync SDKs
There are minimum SDK requirements when using Sync Streams in an application. See Minimum SDK Versions for a full list for each supported PowerSync SDK.
IMPORTANT
Recent SDK versions ship only the Rust sync client, so no opt-in is needed. On older SDKs (pre Rust Client Default), explicitly enable the Rust sync client to use Sync Streams.
sync-config.yaml File Format
IMPORTANT: The sync-config.yaml file must begin with a top-level config: block specifying the edition. Without this wrapper, the PowerSync Service will reject the config. This is the most common deployment error — do not omit it.
# powersync/sync-config.yaml — required structure
config:
edition: 3 # <-- REQUIRED top-level wrapper
streams:
my_data:
auto_subscribe: true
query: SELECT * FROM my_table WHERE user_id = auth.user_id()
Minimal Example
config:
edition: 3
streams:
posts:
auto_subscribe: true
query: SELECT * FROM posts WHERE user_id = auth.user_id()
Structure
config:
edition: 3
streams:
<stream_name>:
# CTEs (optional) - define with block inside each stream
with:
<cte_name>: SELECT ... FROM ...
# Behavior options (place above query/queries)
auto_subscribe: true # Auto-subscribe clients on connect (default: false)
priority: 1 # Sync priority (optional). Lower number -> higher priority
accept_potentially_dangerous_queries: true # Silence security warnings (default: false)
# Query options (use one)
query: SELECT * FROM <table> WHERE ... # Single query
queries: # Multiple queries
- SELECT * FROM <table_a> WHERE ...
- SELECT * FROM <table_b> WHERE ...
Bucket limit: Each unique
(stream name + parameter values)combination creates one internal bucket. The default limit is 1,000 buckets per user. If a stream with subscription parameters could create many combinations, usequeries:(multiple queries inside one stream) instead of separate streams — this keeps everything in one bucket.
Stream Options
Behavior options placed above query/queries in each stream definition.
auto_subscribe (default: false)
When true, clients automatically subscribe on connect — no client-side syncStream() call needed.
Use for:
- Reference/global data all users need (e.g.
categories,app_config) - User-scoped data that should always be available offline
Do not use with streams that use subscription.parameter(). There is no mechanism to supply the parameter value at auto-subscribe time, so the subscription will produce empty results.
streams:
categories:
auto_subscribe: true
query: SELECT * FROM categories
my_orders:
auto_subscribe: true
query: SELECT * FROM orders WHERE user_id = auth.user_id()
# No auto_subscribe — requires client-supplied parameter
order_items:
query: |
SELECT * FROM order_items
WHERE order_id = subscription.parameter('order_id')
AND order_id IN (SELECT id FROM orders WHERE user_id = auth.user_id())
priority
Controls sync order. Lower number = higher priority. Valid range is 0–3; default is 3.
Use when some data must be available sooner (e.g. user profile before activity feed):
streams:
user_profile:
priority: 1
auto_subscribe: true
query: SELECT * FROM profiles WHERE user_id = auth.user_id()
activity_feed:
priority: 2
auto_subscribe: true
query: SELECT * FROM activity WHERE user_id = auth.user_id()
Priority 0 — special case: syncs regardless of pending uploads, bypassing the normal upload-consistency guarantee. Use only for append-only/CRDT workloads (e.g. Yjs collaborative editing). Misuse causes flickering or out-of-order data.
The client can also override the priority per-subscription — see Client Usage.
See Prioritized Sync for full details.
accept_potentially_dangerous_queries (default: false)
PowerSync raises a warning when a stream query uses subscription.parameter() or connection.parameter() (client-controlled values that are not signed). Set to true only after adding an AND auth.user_id() guard that scopes the client-supplied value to rows the user actually owns:
streams:
workspace_data:
accept_potentially_dangerous_queries: true
query: |
SELECT * FROM documents
WHERE workspace_id = subscription.parameter('workspace_id')
AND workspace_id IN (SELECT id FROM workspaces WHERE owner_id = auth.user_id())
The inner AND clause is what makes this safe — it prevents a client from requesting data outside their own workspaces.
Common Patterns
Global data
No filter — same data for all users. Use auto_subscribe: true so clients receive it automatically on connect.
streams:
categories:
auto_subscribe: true
query: SELECT * FROM categories
products:
auto_subscribe: true
query: SELECT * FROM products WHERE active = true
Personal data
Filter by the authenticated user using auth.user_id().
streams:
my_notes:
auto_subscribe: true
query: SELECT * FROM notes WHERE owner_id = auth.user_id()
my_orders:
auto_subscribe: true
query: SELECT * FROM orders WHERE user_id = auth.user_id()
JOIN
Use INNER JOIN to filter rows via a related table (e.g. a membership table):
streams:
team_projects:
query: |
SELECT p.*
FROM projects p
INNER JOIN team_members tm ON tm.team_id = p.team_id
WHERE tm.user_id = auth.user_id()
Subquery
Use WHERE id IN (SELECT ...) for indirect access through a related table:
streams:
org_documents:
query: |
SELECT * FROM documents
WHERE org_id IN (
SELECT org_id FROM org_members WHERE user_id = auth.user_id()
)
On-demand with subscription parameter
Client subscribes to a specific resource at runtime. Always include an auth guard.
streams:
list_todos:
accept_potentially_dangerous_queries: true
query: |
SELECT * FROM todos
WHERE list_id = subscription.parameter('list_id')
AND list_id IN (SELECT id FROM lists WHERE owner_id = auth.user_id())
See Writing Queries for JOIN, subquery, and multiple queries per stream details. See Examples & Demos for complete working app patterns.
Query Parameters
There are three kinds of query parameters. Choose based on where the value comes from and how often it changes.
Auth parameters
Values from the signed JWT — the most secure option. Use when filtering by who the user is. These cannot be tampered with by the client.
streams:
my_orders:
query: SELECT * FROM orders WHERE user_id = auth.user_id()
tenant_data:
query: SELECT * FROM records WHERE tenant_id = auth.jwt() ->> 'tenant_id'
See Auth Parameters for all available JWT claims.
Subscription parameters
The client chooses what to sync at runtime. Each subscription is independent — a user can have multiple subscriptions to the same stream with different values. Always scope with an auth guard to prevent a client from accessing data they don't own.
streams:
list_todos:
accept_potentially_dangerous_queries: true
query: |
SELECT * FROM todos
WHERE list_id = subscription.parameter('list_id')
AND list_id IN (SELECT id FROM lists WHERE owner_id = auth.user_id())
See Subscription Parameters for full reference.
Connection parameters
Apply globally across all streams for the session. Use for values that rarely change, like environment flags or feature toggles. Changing them requires reconnecting.
streams:
config:
auto_subscribe: true
query: SELECT * FROM config WHERE env = connection.parameter('environment')
See Connection Parameters for full reference.
Common Table Expressions (CTEs)
Reusable query patterns for your Sync Streams. You can create Global and Scoped CTEs.
Global
with:
user_orgs: SELECT org_id FROM org_members WHERE user_id = auth.user_id()
streams:
org_projects:
query: SELECT * FROM projects WHERE org_id IN user_orgs
org_repositories:
query: SELECT * FROM repositories WHERE org_id IN user_orgs
org_settings:
query: SELECT * FROM settings WHERE org_id IN user_orgs
Scoped
streams:
project_data:
with:
accessible_projects: |
SELECT id FROM projects
WHERE org_id IN (SELECT org_id FROM org_members WHERE user_id = auth.user_id())
queries:
- SELECT * FROM projects WHERE id IN accessible_projects
- SELECT * FROM tasks WHERE project_id IN accessible_projects
- SELECT * FROM comments WHERE project_id IN accessible_projects
CTE Limitations
This won't work
# This won't work - cte2 cannot reference cte1
with:
cte1: SELECT org_id FROM org_members WHERE user_id = auth.user_id()
cte2: SELECT id FROM projects WHERE org_id IN cte1 # Error!
For a full breakdown, see Limitations.
Migration
There are big differences between Sync Rules and Sync Streams, consider the following when migrating from Sync Rules to Sync Streams. See Sync Streams Migrations for information such as:
- How to migrate
- The tools that can make it easier
- Understanding the difference between Sync Rules and Sync Streams
- Migration examples for common scenarios
Client Usage
Client applications subscribe to Sync Streams to start syncing data. See Client-Side Usage for a full breakdown.
TTL (Time-To-Live)
Each subscription has a TTL that keeps data cached after unsubscribing. Default is 24 hours.
// Default (24h cache after unsubscribe)
const sub = await db.syncStream('todos', { list_id: 'abc' }).subscribe();
// Custom TTL in seconds
const sub = await db.syncStream('todos', { list_id: 'abc' }).subscribe({ ttl: 3600 }); // 1 hour
// Remove data immediately on unsubscribe
const sub = await db.syncStream('todos', { list_id: 'abc' }).subscribe({ ttl: 0 });
// Keep forever
const sub = await db.syncStream('todos', { list_id: 'abc' }).subscribe({ ttl: Infinity });
Priority Override
Override the stream's YAML-defined priority for a specific subscription:
const sub = await db.syncStream('todos', { list_id: 'abc' }).subscribe({ priority: 1 });
When multiple components subscribe to the same stream+parameters with different priorities, PowerSync uses the highest priority until all those subscriptions end.
There are examples available for each PowerSync Client SDK.
| SDK | Client Usage Reference URL |
|---|---|
| TypeScript/JavaScript | Client Usage |
| Dart | Client Usage |
| Kotlin | Client Usage |
| Swift | Client Usage |
| .NET | Client Usage |
Frameworks
| Framework | Client Usage Reference URL |
|---|---|
| React | Client Usage |
Advanced Topics
Reference these when the standard patterns don't cover your use case:
| Topic | When to use |
|---|---|
| Client ID | Filter or scope data by which specific client device is syncing |
| Sync Data by Time | Limit sync to a rolling time window (e.g. last 30 days) |
| Schemas and Connections | Source data from multiple database schemas or connections |
| Multiple Client Versions | Support different schema versions across app releases |
| Partitioned Tables | Sync from Postgres partitioned tables |
| Sharded Databases | Source data from multiple database shards |
Sync Rules (Legacy, use Sync Streams for new applications)
Sync rules define how data is partitioned into buckets and distributed to clients. This is considered legacy, however will still be supported. For the best experience use sync-streams.
Structure
bucket_definitions:
<bucket_name>:
parameters: SELECT ... # Which buckets user can access
data: # What data goes in each bucket
- SELECT ... WHERE column = bucket.parameter
Parameter Queries
Determine bucket access for authenticated users:
# User's own data
parameters: SELECT request.user_id() AS user_id
# From database table
parameters: SELECT org_id FROM users WHERE id = request.user_id()
# From JWT claims
parameters: SELECT request.jwt() ->> 'tenant_id' AS tenant_id
# From client parameters
parameters: SELECT request.parameters() ->> 'workspace_id' AS workspace_id
Data Queries
Define what rows go into each bucket:
data:
# Basic - all columns
- SELECT * FROM documents WHERE user_id = bucket.user_id
# Column selection
- SELECT id, name, created_at FROM projects WHERE org_id = bucket.org_id
# Transformations
- SELECT id, UPPER(status) as status FROM tasks WHERE team_id = bucket.team_id
Supported SQL Features
Functions
| Category | Functions |
|---|---|
| String | upper(), lower(), substring(), length(), hex(), base64() |
| JSON | json_extract(), ->, ->>, json_array_length(), json_valid() |
| Type | typeof(), cast() |
| Utility | ifnull(), iif(), uuid_blob() |
| Date/Time | unixepoch(), datetime() |
| Geospatial | st_asgeojson(), st_astext(), st_x(), st_y() |
Operators (non-parameter comparisons)
- Comparison:
=,!=,<,>,<=,>= - Logical:
AND,OR,NOT,IS,IS NOT - Arithmetic:
+,-,*,/,% - String:
||(concatenation) - Null:
IS NULL,IS NOT NULL
Parameter Comparisons (bucket.* or token_parameters.*)
Only = and IN supported:
# Allowed
WHERE user_id = bucket.user_id
WHERE bucket.role IN roles_array
# NOT allowed
WHERE user_id > bucket.user_id
WHERE upper(bucket.name) = column
Not Supported
- JOINs (in data queries)
- GROUP BY, HAVING
- ORDER BY, LIMIT, OFFSET, DISTINCT
- Subqueries, CTEs
- Window functions
COALESCE()- useifnull()instead
Common Patterns
Personal Data
bucket_definitions:
my_data:
parameters: SELECT request.user_id() AS user_id
data:
- SELECT * FROM notes WHERE owner_id = bucket.user_id
- SELECT * FROM settings WHERE user_id = bucket.user_id
Team/Organization
bucket_definitions:
team_data:
parameters: |
SELECT team_id FROM team_members
WHERE user_id = request.user_id()
data:
- SELECT * FROM projects WHERE team_id = bucket.team_id
- SELECT * FROM tasks WHERE team_id = bucket.team_id
Role-Based Access
bucket_definitions:
admin_data:
parameters: |
SELECT 1 AS is_admin FROM users
WHERE id = request.user_id() AND role = 'admin'
data:
- SELECT * FROM audit_logs WHERE bucket.is_admin = 1
Global Data (all users)
bucket_definitions:
global:
parameters: SELECT 'global' AS scope
data:
- SELECT * FROM app_config WHERE bucket.scope = 'global'
Multi-Tenant Organization
bucket_definitions:
org_data:
parameters: |
SELECT org_id
FROM users
WHERE id = request.user_id()
data:
- SELECT * FROM documents WHERE org_id = bucket.org_id
- SELECT * FROM folders WHERE org_id = bucket.org_id
# User's private data within org
private_data:
parameters: |
SELECT org_id, request.user_id() AS user_id
FROM users
WHERE id = request.user_id()
data:
- SELECT * FROM drafts WHERE org_id = bucket.org_id AND author_id = bucket.user_id
JWT Claims
bucket_definitions:
tenant_data:
parameters: |
SELECT request.jwt() ->> 'tenant_id' AS tenant_id
data:
- SELECT * FROM records WHERE tenant_id = bucket.tenant_id