From dd33510b42a16fe1bd541e0ec072f08f52d264db Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Fri, 6 Mar 2026 16:25:33 +0200 Subject: [PATCH 01/16] Troubleshoot the bucket limit for sync streams --- debugging/error-codes.mdx | 4 +- debugging/troubleshooting.mdx | 70 +++++++++++++++++++++++++++++++++++ sync/streams/overview.mdx | 2 +- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/debugging/error-codes.mdx b/debugging/error-codes.mdx index 533b308c..05eed7a2 100644 --- a/debugging/error-codes.mdx +++ b/debugging/error-codes.mdx @@ -324,8 +324,8 @@ This does not include auth configuration errors on the service. - **PSYNC_S2305**: Too many buckets. - - There is a limit on the number of buckets per active connection (default of 1,000). See [Limit on Number of Buckets Per Client](/sync/rules/organize-data-into-buckets#limit-on-number-of-buckets-per-client) and [Performance and Limits](/resources/performance-and-limits). + + There is a limit on the number of buckets per active connection (default of 1,000). See [Too Many Buckets (Troubleshooting)](/debugging/troubleshooting#too-many-buckets-psync_s2305) for how to diagnose and resolve this, and [Performance and Limits](/resources/performance-and-limits) for the limit details. ## PSYNC_S23xx: Sync API errors - MongoDB Storage diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index 6a2eca1e..a4c6794c 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -53,6 +53,76 @@ export const db = new PowerSyncDatabase({ }); ``` +### Too Many Buckets (`PSYNC_S2305`) + +PowerSync uses internal partitions called [buckets](/architecture/powersync-service#bucket-system) to organize and sync data efficiently. There is a [default limit of 1,000 buckets](/resources/performance-and-limits) per user/client. When this limit is exceeded, you will see a `PSYNC_S2305` error in your PowerSync Service logs. + +**How buckets are created in Sync Streams** + +The number of bucket instances a stream creates for a given user depends on what the stream's query evaluates to for that user — specifically, how many distinct rows the parameterizing parts of the query (subqueries and JOINs through intermediate tables) produce. The 1,000 limit is the total across all active streams for a single user. + +| Query pattern | Buckets per user | +|---|---| +| No parameters: `SELECT * FROM categories` | 1 global bucket, shared by all users | +| Direct auth filter only: `WHERE owner_id = auth.user_id()` | 1 per user | +| Subscription parameter: `WHERE list_id = subscription.parameter('list_id')` | 1 per unique parameter value the client subscribes with | +| Subquery returning N rows: `WHERE id IN (SELECT org_id FROM org_membership WHERE ...)` | N — one per result row of the subquery | +| INNER JOIN through an intermediate table: `SELECT t.* FROM todos JOIN lists ON todos.list_id = lists.id WHERE lists.owner_id = auth.user_id()` | N — one per row of the joined table (one per list) | + +The subquery and JOIN cases are the ones most likely to hit the limit. Both follow the same principle: when a query filters through an intermediate table — whether via a subquery or a JOIN — each row of that intermediate table creates a separate bucket. + +For example, a user belonging to 3 organizations creates 3 buckets here: + +```yaml +streams: + org_data: + query: SELECT * FROM orgs WHERE id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) +``` + +A user belonging to more than 1,000 organizations would hit the limit. The bucket count scales with the user's membership count, not with the total number of users. + +**Diagnosing which streams are contributing** + +- The `PSYNC_S2305` error log includes a breakdown showing which stream definitions are contributing the most bucket instances (top 10 by count). +- PowerSync Service checkpoint logs record the total parameter result count per connection. You can find these in your [instance logs](/maintenance-ops/monitoring-and-alerting). For example: + + ``` + New checkpoint: 800178 | write: null | buckets: 7 | param_results: 6 ["5#user_data|0[\"ef718ff3...\"]","5#user_data|1[\"1ddeddba...\"]","5#user_data|1[\"2ece823f...\"]", ...] + ``` + - `buckets` — total number of active buckets for this connection + - `param_results` — the total parameter result count across all stream definitions for this connection + - The array lists the active bucket names and the value in `[...]` is the evaluated parameter for that bucket + +- The [Sync Diagnostics Client](/tools/diagnostics-client) lets you inspect the buckets for a specific user, but note that it will not load for users who have exceeded the bucket limit since their sync connection fails before data can be retrieved. Use the instance logs and error breakdown to diagnose those cases. + +**Reducing bucket count in Sync Streams** + +- **Consolidate streams using [multiple queries per stream](/sync/streams/queries#multiple-queries-per-stream)**: Using `queries` instead of `query` groups related tables into a single stream. All queries in that stream share one bucket per unique evaluated parameter value, rather than each separate stream definition creating its own bucket. For example, if you have 5 separate streams all filtered on `auth.user_id()`, each creates 1 bucket per user — 5 buckets total. Merging them into a single stream with 5 queries creates 1 bucket per user regardless of how many queries are inside it. + +- **Query the membership table directly instead of through it**: When a subquery or JOIN through a membership table is causing N buckets, flip the query to target the membership table directly with a direct auth filter — no subquery, no JOIN. A direct `WHERE user_id = auth.user_id()` filter with no intermediate table creates 1 bucket per user, returning all matching rows inside that single bucket. + + ```yaml + # N org memberships → N buckets (filtering through org_membership as a subquery) + streams: + org_data: + query: SELECT * FROM orgs WHERE id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) + ``` + + ```yaml + # 1 bucket per user — direct filter, no intermediate table + streams: + my_org_memberships: + query: SELECT * FROM org_membership WHERE user_id = auth.user_id() + ``` + + If you need fields from `orgs` alongside each membership row, denormalize those fields onto `org_membership` so everything is available without introducing a JOIN in the stream query. Adding a JOIN back into the query would reintroduce per-row bucket creation. + +- **Use subscription parameters for on-demand loading**: Rather than syncing all related records upfront, consider having clients subscribe to specific items on demand (e.g., subscribe to one organization's data at a time via a subscription parameter). This only helps if users don't need all records active simultaneously. + +**Increasing the limit** + +The default of 1,000 can be increased upon request for [Team and Enterprise](https://www.powersync.com/pricing) customers. Note that performance degrades as bucket count increases beyond 1,000. See [Performance and Limits](/resources/performance-and-limits). + ## Tools Troubleshooting techniques depend on the type of issue: diff --git a/sync/streams/overview.mdx b/sync/streams/overview.mdx index 7a7e3729..898c677e 100644 --- a/sync/streams/overview.mdx +++ b/sync/streams/overview.mdx @@ -276,7 +276,7 @@ const sub = await db.syncStream('todos', { list_id: 'abc' }) - **Case Sensitivity**: To avoid issues across different databases and platforms, use **lowercase identifiers** for all table and column names in your Sync Streams. If your backend uses mixed case, see [Case Sensitivity](/sync/advanced/case-sensitivity) for how to handle it. -- **Bucket Limits**: PowerSync uses internal partitions called [buckets](/architecture/powersync-service#bucket-system) to efficiently sync data. There's a default [limit of 1,000 buckets](/resources/performance-and-limits) per user/client. Each unique combination of a stream and its parameters creates one bucket, so keep this in mind when designing streams that use subscription parameters. You can use [multiple queries per stream](/sync/streams/queries#multiple-queries-per-stream) to reduce bucket count. +- **Bucket Limits**: PowerSync uses internal partitions called [buckets](/architecture/powersync-service#bucket-system) to efficiently sync data. There's a default [limit of 1,000 buckets](/resources/performance-and-limits) per user/client. Each unique result returned by a stream's query creates one bucket instance — so a stream that filters through an intermediate table via a subquery or JOIN (e.g. N org memberships) creates N buckets for that user. You can use [multiple queries per stream](/sync/streams/queries#multiple-queries-per-stream) to reduce bucket count. See [Too Many Buckets](/debugging/troubleshooting#too-many-buckets-psync_s2305) in the troubleshooting guide for how to diagnose and resolve `PSYNC_S2305` errors. - **Troubleshooting**: If data isn't syncing as expected, the [Sync Diagnostics Client](/tools/diagnostics-client) helps you inspect what's happening for a specific user — you can see which buckets the user has and what data is being synced. From 667246161977663736a81f72c2db01fec14973ce Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Fri, 6 Mar 2026 16:33:58 +0200 Subject: [PATCH 02/16] reword --- debugging/troubleshooting.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index a4c6794c..d7f55f83 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -97,9 +97,9 @@ A user belonging to more than 1,000 organizations would hit the limit. The bucke **Reducing bucket count in Sync Streams** -- **Consolidate streams using [multiple queries per stream](/sync/streams/queries#multiple-queries-per-stream)**: Using `queries` instead of `query` groups related tables into a single stream. All queries in that stream share one bucket per unique evaluated parameter value, rather than each separate stream definition creating its own bucket. For example, if you have 5 separate streams all filtered on `auth.user_id()`, each creates 1 bucket per user — 5 buckets total. Merging them into a single stream with 5 queries creates 1 bucket per user regardless of how many queries are inside it. +1. **Consolidate streams using [multiple queries per stream](/sync/streams/queries#multiple-queries-per-stream)**: Using `queries` instead of `query` groups related tables into a single stream. All queries in that stream share one bucket per unique evaluated parameter value, rather than each separate stream definition creating its own bucket. For example, if you have 5 separate streams all filtered on `auth.user_id()`, each creates 1 bucket per user — 5 buckets total. Merging them into a single stream with 5 queries creates 1 bucket per user regardless of how many queries are inside it. -- **Query the membership table directly instead of through it**: When a subquery or JOIN through a membership table is causing N buckets, flip the query to target the membership table directly with a direct auth filter — no subquery, no JOIN. A direct `WHERE user_id = auth.user_id()` filter with no intermediate table creates 1 bucket per user, returning all matching rows inside that single bucket. +2. **Query the membership table directly instead of through it**: When a subquery or JOIN through a membership table is causing N buckets, flip the query to target the membership table directly with a direct auth filter — no subquery, no JOIN. A direct `WHERE user_id = auth.user_id()` filter with no intermediate table creates 1 bucket per user, returning all matching rows inside that single bucket. ```yaml # N org memberships → N buckets (filtering through org_membership as a subquery) @@ -117,7 +117,7 @@ A user belonging to more than 1,000 organizations would hit the limit. The bucke If you need fields from `orgs` alongside each membership row, denormalize those fields onto `org_membership` so everything is available without introducing a JOIN in the stream query. Adding a JOIN back into the query would reintroduce per-row bucket creation. -- **Use subscription parameters for on-demand loading**: Rather than syncing all related records upfront, consider having clients subscribe to specific items on demand (e.g., subscribe to one organization's data at a time via a subscription parameter). This only helps if users don't need all records active simultaneously. +3. **Restructure to use subscription parameters**: For subscription parameter streams, buckets are only created per active client subscription — not pre-evaluated from all possible values. So if you restructure the stream to use `subscription.parameter('org_id')` instead of a subquery that returns all org memberships, the bucket count is bounded by how many subscriptions the client has active at once rather than the user's total membership count. This requires changing both the stream definition and the client code to subscribe to specific items on demand, so it's only practical when users don't need all related records available simultaneously. **Increasing the limit** From c4874b35d79febbf199b8a1736ee72c875c3ac65 Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Mon, 9 Mar 2026 13:56:53 +0200 Subject: [PATCH 03/16] WIP - more examples --- debugging/troubleshooting.mdx | 163 ++++++++++++++++++++++++++++++---- 1 file changed, 145 insertions(+), 18 deletions(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index d7f55f83..233ab4fd 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -55,31 +55,49 @@ export const db = new PowerSyncDatabase({ ### Too Many Buckets (`PSYNC_S2305`) -PowerSync uses internal partitions called [buckets](/architecture/powersync-service#bucket-system) to organize and sync data efficiently. There is a [default limit of 1,000 buckets](/resources/performance-and-limits) per user/client. When this limit is exceeded, you will see a `PSYNC_S2305` error in your PowerSync Service logs. +PowerSync uses internal partitions called [buckets](/architecture/powersync-service#bucket-system) to organize and sync data efficiently. There is a [default limit of 1,000 buckets](/resources/performance-and-limits) per user/client. When this limit is exceeded, you will see a `PSYNC_S2305` error in your PowerSync Service API logs. **How buckets are created in Sync Streams** -The number of bucket instances a stream creates for a given user depends on what the stream's query evaluates to for that user — specifically, how many distinct rows the parameterizing parts of the query (subqueries and JOINs through intermediate tables) produce. The 1,000 limit is the total across all active streams for a single user. +The number of buckets a stream creates for a given user depends on how your query filters data. For subqueries and one-to-many JOINs, each row returned creates a bucket. For many-to-many JOINs, each row of the primary table (the one in `SELECT`) creates a bucket instead. The 1,000 limit applies to the total amount of buckets across all active streams for a single user. + +Examples below use a common schema: **regions** → **orgs** → **projects** → **tasks**, with **org_membership** (user_id, org_id) linking users to orgs. For many-to-many, **assets** ↔ **projects** via **project_assets** (asset_id, project_id). | Query pattern | Buckets per user | |---|---| -| No parameters: `SELECT * FROM categories` | 1 global bucket, shared by all users | -| Direct auth filter only: `WHERE owner_id = auth.user_id()` | 1 per user | -| Subscription parameter: `WHERE list_id = subscription.parameter('list_id')` | 1 per unique parameter value the client subscribes with | -| Subquery returning N rows: `WHERE id IN (SELECT org_id FROM org_membership WHERE ...)` | N — one per result row of the subquery | -| INNER JOIN through an intermediate table: `SELECT t.* FROM todos JOIN lists ON todos.list_id = lists.id WHERE lists.owner_id = auth.user_id()` | N — one per row of the joined table (one per list) | +| No parameters: `SELECT * FROM regions` | 1 global bucket, shared by all users | +| Direct auth filter only: `WHERE user_id = auth.user_id()` | 1 per user | +| Subscription parameter: `WHERE project_id = subscription.parameter('project_id')` | 1 per unique parameter value the client subscribes with | +| Subquery returning N rows: `WHERE id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id())` | N — one per result row of the subquery | +| INNER JOIN through an intermediate table: `SELECT tasks.* FROM tasks JOIN projects ON tasks.project_id = projects.id WHERE projects.org_id IN (...)` | N — one per row of the joined table (one per project) | +| Many-to-many JOIN: `SELECT assets.* FROM assets JOIN project_assets ON project_assets.asset_id = assets.id WHERE project_assets.project_id IN (...)` | N — one per primary table row (one per asset) | + +The **subquery** and **one-to-many JOIN** cases follow the same principle: when a query filters through an intermediate table — whether via a subquery or a JOIN — each row of that intermediate table creates a separate bucket. The subquery returns `org_id`s, so you get one bucket per org; the tasks-projects JOIN yields one bucket per project. -The subquery and JOIN cases are the ones most likely to hit the limit. Both follow the same principle: when a query filters through an intermediate table — whether via a subquery or a JOIN — each row of that intermediate table creates a separate bucket. +**Many-to-many JOINs are different** than one-to-many JOINs. With a many-to-many relationship (e.g., assets ↔ projects via a join table like `project_assets`), the join table does *not* define the bucket space. PowerSync processes each primary-table row independently and cannot group by the join table's keys. So `SELECT assets.* FROM assets INNER JOIN project_assets ...` creates one bucket per asset row, even if you intended to partition by project. The JOIN controls which rows sync, not how they are grouped. -For example, a user belonging to 3 organizations creates 3 buckets here: +**Hierarchical or chained queries** are another source of bucket growth. When multiple queries in one stream depend on each other (e.g., query B filters by IDs from query A, query C filters by IDs from query B), each level creates buckets. For example, consider: ```yaml streams: - org_data: - query: SELECT * FROM orgs WHERE id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) + org_projects_tasks: + auto_subscribe: true + with: + user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() + user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) + queries: + - SELECT * FROM orgs WHERE id IN (SELECT org_id FROM user_orgs) + - SELECT * FROM projects WHERE id IN user_projects + - SELECT * FROM tasks WHERE project_id IN user_projects ``` -A user belonging to more than 1,000 organizations would hit the limit. The bucket count scales with the user's membership count, not with the total number of users. +A stream that fetches orgs by user membership, then projects by `org_id`, then tasks by `project_id` creates: + +- One bucket per org +- One bucket per project +- One bucket per project for tasks (filtered by `project_id`) + +A user with 10 orgs and 50 projects per org therefore generates 10 + 500 + 500 = 1,010 buckets — over the limit. Trying to reduce buckets by filtering all three tables with `org_id` only works if your schema has `org_id` on every table. If tasks only have `project_id` (and projects have `org_id`), add `org_id` to the tasks table so the flattened approach can work. **Diagnosing which streams are contributing** @@ -97,27 +115,136 @@ A user belonging to more than 1,000 organizations would hit the limit. The bucke **Reducing bucket count in Sync Streams** -1. **Consolidate streams using [multiple queries per stream](/sync/streams/queries#multiple-queries-per-stream)**: Using `queries` instead of `query` groups related tables into a single stream. All queries in that stream share one bucket per unique evaluated parameter value, rather than each separate stream definition creating its own bucket. For example, if you have 5 separate streams all filtered on `auth.user_id()`, each creates 1 bucket per user — 5 buckets total. Merging them into a single stream with 5 queries creates 1 bucket per user regardless of how many queries are inside it. +1. **Consolidate streams using [multiple queries per stream](/sync/streams/queries#multiple-queries-per-stream)**: Using `queries` instead of `query` groups related tables into a single stream. All queries in that stream share one bucket per unique evaluated parameter value. -2. **Query the membership table directly instead of through it**: When a subquery or JOIN through a membership table is causing N buckets, flip the query to target the membership table directly with a direct auth filter — no subquery, no JOIN. A direct `WHERE user_id = auth.user_id()` filter with no intermediate table creates 1 bucket per user, returning all matching rows inside that single bucket. + **Before** — 5 separate streams, each with direct `auth.user_id()` filter → 5 buckets per user: + + ```yaml + streams: + user_settings: + query: SELECT * FROM settings WHERE user_id = auth.user_id() + user_prefs: + query: SELECT * FROM preferences WHERE user_id = auth.user_id() + user_org_list: + query: SELECT * FROM org_membership WHERE user_id = auth.user_id() + user_region: + query: SELECT * FROM region_members WHERE user_id = auth.user_id() + user_profile: + query: SELECT * FROM profiles WHERE user_id = auth.user_id() + ``` + + **After** — 1 stream with 5 queries → 1 bucket per user: + + ```yaml + streams: + user_data: + queries: + - SELECT * FROM settings WHERE user_id = auth.user_id() + - SELECT * FROM preferences WHERE user_id = auth.user_id() + - SELECT * FROM org_membership WHERE user_id = auth.user_id() + - SELECT * FROM region_members WHERE user_id = auth.user_id() + - SELECT * FROM profiles WHERE user_id = auth.user_id() + ``` + +2. **Query the membership table directly instead of through it**: When a subquery or JOIN through a membership table is causing N buckets, flip the query to target the membership table directly with a direct auth filter — no subquery, no JOIN. You will typically need fields from the related table (e.g., org name, address) alongside each membership row; denormalize those fields onto the membership table so everything is available without introducing a JOIN. + + **Before** — N org memberships → N buckets: ```yaml - # N org memberships → N buckets (filtering through org_membership as a subquery) streams: org_data: query: SELECT * FROM orgs WHERE id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) ``` + **After** — 1 bucket per user (with org fields denormalized onto `org_membership`): + ```yaml - # 1 bucket per user — direct filter, no intermediate table streams: my_org_memberships: query: SELECT * FROM org_membership WHERE user_id = auth.user_id() ``` - If you need fields from `orgs` alongside each membership row, denormalize those fields onto `org_membership` so everything is available without introducing a JOIN in the stream query. Adding a JOIN back into the query would reintroduce per-row bucket creation. +3. **Denormalize for hierarchical data**: When chained queries through parent-child relationships (e.g., org → project → task) create too many buckets, filter all tables with the same top-level parameter (e.g., `org_id`). This only works if child tables have that column — add `org_id` to tasks if they only have `project_id`. + + **Before** — org_projects_tasks with 3 chained queries → 10 + 500 + 500 = 1,010 buckets for 10 orgs, 50 projects each: + + ```yaml + streams: + org_projects_tasks: + with: + user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() + user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) + queries: + - SELECT * FROM orgs WHERE id IN user_orgs + - SELECT * FROM projects WHERE id IN user_projects + - SELECT * FROM tasks WHERE project_id IN user_projects + ``` + + **After** — Add `org_id` to tasks, flatten to one bucket per org → 10 buckets: + + ```yaml + streams: + org_projects_tasks: + with: + user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() + queries: + - SELECT * FROM orgs WHERE id IN user_orgs + - SELECT * FROM projects WHERE org_id IN user_orgs + - SELECT * FROM tasks WHERE org_id IN user_orgs + ``` + +4. **Many-to-many via denormalization**: For assets ↔ projects via `project_assets`, buckets follow the primary table — one per asset. Add a denormalized `project_ids` JSON array on `assets` and use `json_each()` to partition by project. + + **Before** — One bucket per asset (e.g., 2,000 assets → 2,000 buckets): + + ```yaml + streams: + assets_in_projects: + with: + user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) + query: | + SELECT assets.* FROM assets + JOIN project_assets ON project_assets.asset_id = assets.id + WHERE project_assets.project_id IN (SELECT id FROM user_projects) + ``` + + **After** — Add `project_ids` to `assets` (via triggers), partition by project → 50 buckets for 50 projects: + + ```yaml + streams: + assets_in_projects: + with: + user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) + query: | + SELECT assets.* FROM assets + INNER JOIN json_each(assets.project_ids) AS p + INNER JOIN user_projects ON p.value = user_projects.id + WHERE assets.org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) + ``` + + Alternatively, use two queries: one for `project_assets` filtered by project, one for `assets`; the client joins locally. The trade-off: the assets query may sync more rows than needed unless you can filter it further. + +5. **Restructure to use subscription parameters**: Buckets are only created per active client subscription, not from all possible values. Use `subscription.parameter('project_id')` so the count is bounded by how many subscriptions the client has active. + + **Before** — Subquery returns all user projects → 50 buckets for 50 projects: + + ```yaml + streams: + project_tasks: + with: + user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) + query: SELECT * FROM tasks WHERE project_id IN user_projects + ``` + + **After** — Client subscribes per project on demand → 1 bucket per active subscription (e.g., 3 projects open = 3 buckets): + + ```yaml + streams: + project_tasks: + query: SELECT * FROM tasks WHERE project_id = subscription.parameter('project_id') + ``` -3. **Restructure to use subscription parameters**: For subscription parameter streams, buckets are only created per active client subscription — not pre-evaluated from all possible values. So if you restructure the stream to use `subscription.parameter('org_id')` instead of a subquery that returns all org memberships, the bucket count is bounded by how many subscriptions the client has active at once rather than the user's total membership count. This requires changing both the stream definition and the client code to subscribe to specific items on demand, so it's only practical when users don't need all related records available simultaneously. + This requires client code to subscribe when the user opens a project and unsubscribe when they leave. Only practical when users don't need all related records available simultaneously. **Increasing the limit** From 97c889fdcdc85e750b5df6ea82be552bcca58028 Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Mon, 9 Mar 2026 14:20:22 +0200 Subject: [PATCH 04/16] example shorthand --- debugging/troubleshooting.mdx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index 233ab4fd..37ff9cd1 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -86,7 +86,7 @@ streams: user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) queries: - - SELECT * FROM orgs WHERE id IN (SELECT org_id FROM user_orgs) + - SELECT * FROM orgs WHERE id IN user_orgs - SELECT * FROM projects WHERE id IN user_projects - SELECT * FROM tasks WHERE project_id IN user_projects ``` @@ -205,7 +205,7 @@ A user with 10 orgs and 50 projects per org therefore generates 10 + 500 + 500 = query: | SELECT assets.* FROM assets JOIN project_assets ON project_assets.asset_id = assets.id - WHERE project_assets.project_id IN (SELECT id FROM user_projects) + WHERE project_assets.project_id IN user_projects ``` **After** — Add `project_ids` to `assets` (via triggers), partition by project → 50 buckets for 50 projects: @@ -214,12 +214,13 @@ A user with 10 orgs and 50 projects per org therefore generates 10 + 500 + 500 = streams: assets_in_projects: with: + user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) query: | SELECT assets.* FROM assets INNER JOIN json_each(assets.project_ids) AS p INNER JOIN user_projects ON p.value = user_projects.id - WHERE assets.org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) + WHERE assets.org_id IN user_orgs ``` Alternatively, use two queries: one for `project_assets` filtered by project, one for `assets`; the client joins locally. The trade-off: the assets query may sync more rows than needed unless you can filter it further. From 0af5255e4d5becd286dbcd246aa3dabf850257b4 Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Thu, 19 Mar 2026 14:53:27 +0200 Subject: [PATCH 05/16] Improve layout and polish --- debugging/troubleshooting.mdx | 263 +++++++++++++++++++--------------- 1 file changed, 149 insertions(+), 114 deletions(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index 37ff9cd1..abbb4fd7 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -61,7 +61,18 @@ PowerSync uses internal partitions called [buckets](/architecture/powersync-serv The number of buckets a stream creates for a given user depends on how your query filters data. For subqueries and one-to-many JOINs, each row returned creates a bucket. For many-to-many JOINs, each row of the primary table (the one in `SELECT`) creates a bucket instead. The 1,000 limit applies to the total amount of buckets across all active streams for a single user. -Examples below use a common schema: **regions** → **orgs** → **projects** → **tasks**, with **org_membership** (user_id, org_id) linking users to orgs. For many-to-many, **assets** ↔ **projects** via **project_assets** (asset_id, project_id). +Examples below use a common schema: + +``` +regions +|_ orgs + |_ projects + | |_ tasks + | |_ project_assets (project_assets.project_id → projects.id) + | ↔ assets (project_assets.asset_id → assets.id) + |_ org_membership (org_membership.org_id → orgs.id) + ↔ users (org_membership.user_id → users.id) +``` | Query pattern | Buckets per user | |---|---| @@ -74,7 +85,7 @@ Examples below use a common schema: **regions** → **orgs** → **projects** The **subquery** and **one-to-many JOIN** cases follow the same principle: when a query filters through an intermediate table — whether via a subquery or a JOIN — each row of that intermediate table creates a separate bucket. The subquery returns `org_id`s, so you get one bucket per org; the tasks-projects JOIN yields one bucket per project. -**Many-to-many JOINs are different** than one-to-many JOINs. With a many-to-many relationship (e.g., assets ↔ projects via a join table like `project_assets`), the join table does *not* define the bucket space. PowerSync processes each primary-table row independently and cannot group by the join table's keys. So `SELECT assets.* FROM assets INNER JOIN project_assets ...` creates one bucket per asset row, even if you intended to partition by project. The JOIN controls which rows sync, not how they are grouped. +**Many-to-many JOINs are different** than one-to-many JOINs. With a many-to-many relationship (e.g., assets ↔ projects via a join table like `project_assets`), the join table does *not* define the bucket breakdown. PowerSync processes each primary-table row independently and cannot group by the join table's keys. So `SELECT assets.* FROM assets INNER JOIN project_assets ...` creates one bucket per _asset_ row. **Hierarchical or chained queries** are another source of bucket growth. When multiple queries in one stream depend on each other (e.g., query B filters by IDs from query A, query C filters by IDs from query B), each level creates buckets. For example, consider: @@ -97,9 +108,9 @@ A stream that fetches orgs by user membership, then projects by `org_id`, then t - One bucket per project - One bucket per project for tasks (filtered by `project_id`) -A user with 10 orgs and 50 projects per org therefore generates 10 + 500 + 500 = 1,010 buckets — over the limit. Trying to reduce buckets by filtering all three tables with `org_id` only works if your schema has `org_id` on every table. If tasks only have `project_id` (and projects have `org_id`), add `org_id` to the tasks table so the flattened approach can work. +A user with 10 orgs and 50 projects per org therefore generates 10 + 500 + 500 = 1,010 buckets, which is over the limit. Trying to reduce buckets by filtering all three tables with `org_id` only works if your schema has `org_id` on every table. -**Diagnosing which streams are contributing** +#### Diagnosing which streams are contributing - The `PSYNC_S2305` error log includes a breakdown showing which stream definitions are contributing the most bucket instances (top 10 by count). - PowerSync Service checkpoint logs record the total parameter result count per connection. You can find these in your [instance logs](/maintenance-ops/monitoring-and-alerting). For example: @@ -113,141 +124,165 @@ A user with 10 orgs and 50 projects per org therefore generates 10 + 500 + 500 = - The [Sync Diagnostics Client](/tools/diagnostics-client) lets you inspect the buckets for a specific user, but note that it will not load for users who have exceeded the bucket limit since their sync connection fails before data can be retrieved. Use the instance logs and error breakdown to diagnose those cases. -**Reducing bucket count in Sync Streams** +#### Reducing bucket count in Sync Streams -1. **Consolidate streams using [multiple queries per stream](/sync/streams/queries#multiple-queries-per-stream)**: Using `queries` instead of `query` groups related tables into a single stream. All queries in that stream share one bucket per unique evaluated parameter value. + - **Before** — 5 separate streams, each with direct `auth.user_id()` filter → 5 buckets per user: + - ```yaml - streams: - user_settings: - query: SELECT * FROM settings WHERE user_id = auth.user_id() - user_prefs: - query: SELECT * FROM preferences WHERE user_id = auth.user_id() - user_org_list: - query: SELECT * FROM org_membership WHERE user_id = auth.user_id() - user_region: - query: SELECT * FROM region_members WHERE user_id = auth.user_id() - user_profile: - query: SELECT * FROM profiles WHERE user_id = auth.user_id() - ``` +Using `queries` instead of `query` groups related tables into a single stream. All queries in that stream share one bucket per unique evaluated parameter value. See [multiple queries per stream](/sync/streams/queries#multiple-queries-per-stream). - **After** — 1 stream with 5 queries → 1 bucket per user: - - ```yaml - streams: - user_data: - queries: - - SELECT * FROM settings WHERE user_id = auth.user_id() - - SELECT * FROM preferences WHERE user_id = auth.user_id() - - SELECT * FROM org_membership WHERE user_id = auth.user_id() - - SELECT * FROM region_members WHERE user_id = auth.user_id() - - SELECT * FROM profiles WHERE user_id = auth.user_id() - ``` +**Before** — 5 separate streams, each with direct `auth.user_id()` filter → 5 buckets per user: -2. **Query the membership table directly instead of through it**: When a subquery or JOIN through a membership table is causing N buckets, flip the query to target the membership table directly with a direct auth filter — no subquery, no JOIN. You will typically need fields from the related table (e.g., org name, address) alongside each membership row; denormalize those fields onto the membership table so everything is available without introducing a JOIN. +```yaml +streams: + user_settings: + query: SELECT * FROM settings WHERE user_id = auth.user_id() + user_prefs: + query: SELECT * FROM preferences WHERE user_id = auth.user_id() + user_org_list: + query: SELECT * FROM org_membership WHERE user_id = auth.user_id() + user_region: + query: SELECT * FROM region_members WHERE user_id = auth.user_id() + user_profile: + query: SELECT * FROM profiles WHERE user_id = auth.user_id() +``` - **Before** — N org memberships → N buckets: +**After** — 1 stream with 5 queries → 1 bucket per user: - ```yaml - streams: - org_data: - query: SELECT * FROM orgs WHERE id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) - ``` +```yaml +streams: + user_data: + queries: + - SELECT * FROM settings WHERE user_id = auth.user_id() + - SELECT * FROM preferences WHERE user_id = auth.user_id() + - SELECT * FROM org_membership WHERE user_id = auth.user_id() + - SELECT * FROM region_members WHERE user_id = auth.user_id() + - SELECT * FROM profiles WHERE user_id = auth.user_id() +``` - **After** — 1 bucket per user (with org fields denormalized onto `org_membership`): + - ```yaml - streams: - my_org_memberships: - query: SELECT * FROM org_membership WHERE user_id = auth.user_id() - ``` + -3. **Denormalize for hierarchical data**: When chained queries through parent-child relationships (e.g., org → project → task) create too many buckets, filter all tables with the same top-level parameter (e.g., `org_id`). This only works if child tables have that column — add `org_id` to tasks if they only have `project_id`. +When a subquery or JOIN through a membership table is causing N buckets, flip the query to target the membership table directly with a direct auth filter, with no subquery and no JOIN. You will typically need fields from the related table (e.g., org name, address) alongside each membership row; denormalize those fields onto the membership table so everything is available without introducing a JOIN. - **Before** — org_projects_tasks with 3 chained queries → 10 + 500 + 500 = 1,010 buckets for 10 orgs, 50 projects each: +**Before** — N org memberships → N buckets: - ```yaml - streams: - org_projects_tasks: - with: - user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() - user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) - queries: - - SELECT * FROM orgs WHERE id IN user_orgs - - SELECT * FROM projects WHERE id IN user_projects - - SELECT * FROM tasks WHERE project_id IN user_projects - ``` +```yaml +streams: + org_data: + query: SELECT * FROM orgs WHERE id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) +``` - **After** — Add `org_id` to tasks, flatten to one bucket per org → 10 buckets: - - ```yaml - streams: - org_projects_tasks: - with: - user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() - queries: - - SELECT * FROM orgs WHERE id IN user_orgs - - SELECT * FROM projects WHERE org_id IN user_orgs - - SELECT * FROM tasks WHERE org_id IN user_orgs - ``` +**After** — 1 bucket per user (with org fields denormalized onto `org_membership`): -4. **Many-to-many via denormalization**: For assets ↔ projects via `project_assets`, buckets follow the primary table — one per asset. Add a denormalized `project_ids` JSON array on `assets` and use `json_each()` to partition by project. +```yaml +streams: + my_org_memberships: + query: SELECT * FROM org_membership WHERE user_id = auth.user_id() +``` - **Before** — One bucket per asset (e.g., 2,000 assets → 2,000 buckets): + - ```yaml - streams: - assets_in_projects: - with: - user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) - query: | - SELECT assets.* FROM assets - JOIN project_assets ON project_assets.asset_id = assets.id - WHERE project_assets.project_id IN user_projects - ``` + - **After** — Add `project_ids` to `assets` (via triggers), partition by project → 50 buckets for 50 projects: - - ```yaml - streams: - assets_in_projects: - with: - user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() - user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) - query: | - SELECT assets.* FROM assets - INNER JOIN json_each(assets.project_ids) AS p - INNER JOIN user_projects ON p.value = user_projects.id - WHERE assets.org_id IN user_orgs - ``` +When chained queries through parent-child relationships (e.g., org → project → task) create too many buckets, filter all tables with the same top-level parameter (e.g., `org_id`). This only works if child tables have that column. If tasks only have `project_id`, add `org_id` to the tasks table. - Alternatively, use two queries: one for `project_assets` filtered by project, one for `assets`; the client joins locally. The trade-off: the assets query may sync more rows than needed unless you can filter it further. +**Before** — 3 chained queries → 10 + 500 + 500 = 1,010 buckets for 10 orgs with 50 projects each: -5. **Restructure to use subscription parameters**: Buckets are only created per active client subscription, not from all possible values. Use `subscription.parameter('project_id')` so the count is bounded by how many subscriptions the client has active. +```yaml +streams: + org_projects_tasks: + with: + user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() + user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) + queries: + - SELECT * FROM orgs WHERE id IN user_orgs + - SELECT * FROM projects WHERE id IN user_projects + - SELECT * FROM tasks WHERE project_id IN user_projects +``` - **Before** — Subquery returns all user projects → 50 buckets for 50 projects: +**After** — Add `org_id` to tasks, flatten to one bucket per org → 10 buckets: - ```yaml - streams: - project_tasks: - with: - user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) - query: SELECT * FROM tasks WHERE project_id IN user_projects - ``` +```yaml +streams: + org_projects_tasks: + with: + user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() + queries: + - SELECT * FROM orgs WHERE id IN user_orgs + - SELECT * FROM projects WHERE org_id IN user_orgs + - SELECT * FROM tasks WHERE org_id IN user_orgs +``` - **After** — Client subscribes per project on demand → 1 bucket per active subscription (e.g., 3 projects open = 3 buckets): + - ```yaml - streams: - project_tasks: - query: SELECT * FROM tasks WHERE project_id = subscription.parameter('project_id') - ``` + + +For assets ↔ projects via `project_assets`, buckets follow the primary table — one per asset. Add a denormalized `project_ids` JSON array on `assets` and use `json_each()` to partition by project instead. + +**Before** — One bucket per asset (e.g., 2,000 assets → 2,000 buckets): + +```yaml +streams: + assets_in_projects: + with: + user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) + query: | + SELECT assets.* FROM assets + JOIN project_assets ON project_assets.asset_id = assets.id + WHERE project_assets.project_id IN user_projects +``` + +**After** — Add `project_ids` to `assets` (via triggers), partition by project → 50 buckets for 50 projects: + +```yaml +streams: + assets_in_projects: + with: + user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() + user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) + query: | + SELECT assets.* FROM assets + INNER JOIN json_each(assets.project_ids) AS p + INNER JOIN user_projects ON p.value = user_projects.id + WHERE assets.org_id IN user_orgs +``` + +Alternatively, use two queries: one for `project_assets` filtered by project, one for `assets`, with the client joining locally. The trade-off is that the assets query may sync more rows than needed unless you can filter it further. + + + + + +Buckets are only created per active client subscription, not from all possible values. Use `subscription.parameter('project_id')` so the count is bounded by how many subscriptions the client has active. + +**Before** — Subquery returns all user projects → 50 buckets for 50 projects: + +```yaml +streams: + project_tasks: + with: + user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) + query: SELECT * FROM tasks WHERE project_id IN user_projects +``` + +**After** — Client subscribes per project on demand → 1 bucket per active subscription (e.g., 3 projects open = 3 buckets): + +```yaml +streams: + project_tasks: + query: SELECT * FROM tasks WHERE project_id = subscription.parameter('project_id') +``` + +This requires client code to subscribe when the user opens a project and unsubscribe when they leave. It is only practical when users don't need all related records available simultaneously. + + - This requires client code to subscribe when the user opens a project and unsubscribe when they leave. Only practical when users don't need all related records available simultaneously. + -**Increasing the limit** +#### Increasing the limit The default of 1,000 can be increased upon request for [Team and Enterprise](https://www.powersync.com/pricing) customers. Note that performance degrades as bucket count increases beyond 1,000. See [Performance and Limits](/resources/performance-and-limits). From aa9d1c436176b90be40ab75ac0835229a0a45308 Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Thu, 19 Mar 2026 14:57:10 +0200 Subject: [PATCH 06/16] Link to more up-to-date info inspecting the SQLite database --- debugging/troubleshooting.mdx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index abbb4fd7..e1094d1b 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -326,19 +326,9 @@ The JavaScript SDKs ([React Native](/client-sdks/reference/react-native-and-expo ### Inspect local SQLite Database -Another useful debugging tool as a developer is to open the SQLite file and inspect the contents. We share an example of how to do this on iOS from macOS in this video: - -Essentially, run the following to grab the SQLite file: - - -`find ~/Library/Developer/CoreSimulator/Devices -name "mydb.sqlite"` - - -`adb pull data/data/com.mydomain.app/files/mydb.sqlite` - - - -Our [Sync Diagnostics Client](/tools/diagnostics-client) and several of our [demo apps](/intro/examples) also contain a SQL console view to inspect the local database contents. Consider implementing similar functionality in your app. See a React example [here](https://github.com/powersync-ja/powersync-js/blob/main/tools/diagnostics-app/src/app/views/sql-console.tsx). +Opening the SQLite file directly is useful for verifying sync state, inspecting raw table contents, and diagnosing unexpected data. See [Understanding the SQLite Database](/maintenance-ops/client-database-diagnostics) for platform-specific instructions (Android, iOS, Web), how to merge the WAL file, and how to analyze storage usage. + +Our [Sync Diagnostics Client](/tools/diagnostics-client) and several of our [demo apps](/intro/examples) also contain a SQL console view to inspect the local database contents without pulling the file. Consider implementing similar functionality in your app. See a React example [here](https://github.com/powersync-ja/powersync-js/blob/main/tools/diagnostics-app/src/app/views/sql-console.tsx). ### Client-side Logging From f9ce9bd54fe0f2a8deb89f08f512d0ea8c616822 Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Thu, 19 Mar 2026 14:58:01 +0200 Subject: [PATCH 07/16] Heading --- debugging/troubleshooting.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index e1094d1b..2944c9db 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -57,7 +57,7 @@ export const db = new PowerSyncDatabase({ PowerSync uses internal partitions called [buckets](/architecture/powersync-service#bucket-system) to organize and sync data efficiently. There is a [default limit of 1,000 buckets](/resources/performance-and-limits) per user/client. When this limit is exceeded, you will see a `PSYNC_S2305` error in your PowerSync Service API logs. -**How buckets are created in Sync Streams** +#### How buckets are created in Sync Streams The number of buckets a stream creates for a given user depends on how your query filters data. For subqueries and one-to-many JOINs, each row returned creates a bucket. For many-to-many JOINs, each row of the primary table (the one in `SELECT`) creates a bucket instead. The 1,000 limit applies to the total amount of buckets across all active streams for a single user. From 564a7147ac996cae18a42909580cf1ff2c8e81cf Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Thu, 19 Mar 2026 15:09:46 +0200 Subject: [PATCH 08/16] Simpler example --- debugging/troubleshooting.mdx | 36 ++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index 2944c9db..5fd74151 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -87,28 +87,42 @@ The **subquery** and **one-to-many JOIN** cases follow the same principle: when **Many-to-many JOINs are different** than one-to-many JOINs. With a many-to-many relationship (e.g., assets ↔ projects via a join table like `project_assets`), the join table does *not* define the bucket breakdown. PowerSync processes each primary-table row independently and cannot group by the join table's keys. So `SELECT assets.* FROM assets INNER JOIN project_assets ...` creates one bucket per _asset_ row. -**Hierarchical or chained queries** are another source of bucket growth. When multiple queries in one stream depend on each other (e.g., query B filters by IDs from query A, query C filters by IDs from query B), each level creates buckets. For example, consider: +**Hierarchical or chained queries** are another source of bucket growth. Each query in a stream is indexed by the CTE it uses, and each unique value that CTE returns becomes a separate bucket key. When queries use different CTEs, they each create their own set of buckets and do not share. + +Note that CTEs cannot reference other CTEs — each must query source tables directly. The "chaining" here is in the domain logic (projects belong to orgs), not in the CTE definitions. Consider a user who belongs to 2 orgs, each with 3 projects (6 projects total): ```yaml streams: org_projects_tasks: auto_subscribe: true with: - user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() + user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) queries: - - SELECT * FROM orgs WHERE id IN user_orgs - - SELECT * FROM projects WHERE id IN user_projects - - SELECT * FROM tasks WHERE project_id IN user_projects + - SELECT * FROM orgs WHERE id IN user_orgs # keyed by org + - SELECT * FROM projects WHERE id IN user_projects # keyed by project + - SELECT * FROM tasks WHERE project_id IN user_projects # keyed by project ``` -A stream that fetches orgs by user membership, then projects by `org_id`, then tasks by `project_id` creates: +The CTEs evaluate to: + +``` +user_orgs → [org-A, org-B] (2 values) +user_projects → [proj-1, proj-2, proj-3, proj-4, proj-5, proj-6] (6 values) +``` + +Each query creates its own bucket namespace, even when two queries use the same CTE: + +| Query | CTE used | Bucket keys | Buckets | +|---|---|---|---| +| `orgs` | `user_orgs` | org-A, org-B | 2 | +| `projects` | `user_projects` | proj-1 … proj-6 | 6 | +| `tasks` | `user_projects` | proj-1 … proj-6 | 6 | +| | | **Total** | **14** | -- One bucket per org -- One bucket per project -- One bucket per project for tasks (filtered by `project_id`) +At scale — 10 orgs and 50 projects per org — this becomes 10 + 500 + 500 = 1,010 buckets, which exceeds the default limit. -A user with 10 orgs and 50 projects per org therefore generates 10 + 500 + 500 = 1,010 buckets, which is over the limit. Trying to reduce buckets by filtering all three tables with `org_id` only works if your schema has `org_id` on every table. +The fix is to make all queries use the same CTE so they share a single bucket namespace. In this case that means filtering every table by `org_id` directly, which requires `org_id` to exist on every table. If tasks only have `project_id`, you need to add `org_id` to the tasks table first. See "Denormalize for hierarchical data" below. #### Diagnosing which streams are contributing @@ -165,7 +179,7 @@ streams: -When a subquery or JOIN through a membership table is causing N buckets, flip the query to target the membership table directly with a direct auth filter, with no subquery and no JOIN. You will typically need fields from the related table (e.g., org name, address) alongside each membership row; denormalize those fields onto the membership table so everything is available without introducing a JOIN. +When a subquery or JOIN through a membership table is causing N buckets, update the query to target the membership table directly with a direct auth filter, with no subquery and no JOIN. You will typically need fields from the related table (e.g., org name, address) alongside each membership row; denormalize those fields onto the membership table so everything is available without introducing a JOIN. **Before** — N org memberships → N buckets: From 930e0361f4ae0b0216885be54eaa9e1ec4630729 Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Thu, 19 Mar 2026 15:20:20 +0200 Subject: [PATCH 09/16] More details on performance impact --- debugging/troubleshooting.mdx | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index 5fd74151..60f192ab 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -85,12 +85,10 @@ regions The **subquery** and **one-to-many JOIN** cases follow the same principle: when a query filters through an intermediate table — whether via a subquery or a JOIN — each row of that intermediate table creates a separate bucket. The subquery returns `org_id`s, so you get one bucket per org; the tasks-projects JOIN yields one bucket per project. -**Many-to-many JOINs are different** than one-to-many JOINs. With a many-to-many relationship (e.g., assets ↔ projects via a join table like `project_assets`), the join table does *not* define the bucket breakdown. PowerSync processes each primary-table row independently and cannot group by the join table's keys. So `SELECT assets.* FROM assets INNER JOIN project_assets ...` creates one bucket per _asset_ row. +**Many-to-many JOINs are different** than one-to-many JOINs. With a many-to-many relationship (e.g., assets ↔ projects via a join table like `project_assets`), the join table does *not* define the bucket breakdown. PowerSync processes each primary-table row independently and cannot group by the join table's keys. So `SELECT assets.* FROM assets INNER JOIN project_assets ...` creates one bucket per _asset_ row. **Hierarchical or chained queries** are another source of bucket growth. Each query in a stream is indexed by the CTE it uses, and each unique value that CTE returns becomes a separate bucket key. When queries use different CTEs, they each create their own set of buckets and do not share. -Note that CTEs cannot reference other CTEs — each must query source tables directly. The "chaining" here is in the domain logic (projects belong to orgs), not in the CTE definitions. Consider a user who belongs to 2 orgs, each with 3 projects (6 projects total): - ```yaml streams: org_projects_tasks: @@ -107,7 +105,7 @@ streams: The CTEs evaluate to: ``` -user_orgs → [org-A, org-B] (2 values) +user_orgs → [org-A, org-B] (2 values) user_projects → [proj-1, proj-2, proj-3, proj-4, proj-5, proj-6] (6 values) ``` @@ -234,7 +232,9 @@ streams: -For assets ↔ projects via `project_assets`, buckets follow the primary table — one per asset. Add a denormalized `project_ids` JSON array on `assets` and use `json_each()` to partition by project instead. +For assets ↔ projects via `project_assets`, buckets follow the primary table — one per asset. + +The solution is to add a denormalized `project_ids` JSON array column to `assets` (maintained via database triggers) and use `json_each()` to traverse it. This lets PowerSync partition by project ID instead of asset ID. **Before** — One bucket per asset (e.g., 2,000 assets → 2,000 buckets): @@ -249,22 +249,22 @@ streams: WHERE project_assets.project_id IN user_projects ``` -**After** — Add `project_ids` to `assets` (via triggers), partition by project → 50 buckets for 50 projects: +**After** — Add `project_ids` to `assets`, partition by project → 50 buckets for 50 projects: ```yaml streams: assets_in_projects: with: - user_orgs: SELECT org_id FROM org_membership WHERE user_id = auth.user_id() user_projects: SELECT id FROM projects WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) query: | SELECT assets.* FROM assets INNER JOIN json_each(assets.project_ids) AS p INNER JOIN user_projects ON p.value = user_projects.id - WHERE assets.org_id IN user_orgs ``` -Alternatively, use two queries: one for `project_assets` filtered by project, one for `assets`, with the client joining locally. The trade-off is that the assets query may sync more rows than needed unless you can filter it further. +The `INNER JOIN user_projects` ensures only assets that belong to at least one of the user's projects are synced. Bucket key is the project ID, so the bucket count matches the number of projects, not assets. + +Alternatively, use two queries in the same stream: one for `project_assets` filtered by `user_projects`, and one for `assets` with no project filter. The client joins locally. The significant trade-off is that the assets query has no way to scope to the user's projects — it syncs all assets, which may be a dealbreaker depending on data volume. @@ -298,7 +298,9 @@ This requires client code to subscribe when the user opens a project and unsubsc #### Increasing the limit -The default of 1,000 can be increased upon request for [Team and Enterprise](https://www.powersync.com/pricing) customers. Note that performance degrades as bucket count increases beyond 1,000. See [Performance and Limits](/resources/performance-and-limits). +The default of 1,000 can be increased up to 10,000 upon request for [Team and Enterprise](https://www.powersync.com/pricing) customers. For self-hosted deployments, configure `max_parameter_query_results` in the API service config. The limit applies per individual user — your PowerSync Service instance can track far more buckets in total across all users. + +Before requesting a higher limit, consider the performance implications. Incremental sync overhead scales roughly linearly with the number of buckets per user. Doubling the bucket count approximately doubles sync latency for a single operation and doubles CPU and memory usage on both the server and the client. By contrast, having many operations within a single bucket scales much more efficiently. The 1,000 default exists both to encourage sync configs that use fewer, larger buckets and to protect the service from the overhead of excessive bucket counts. We recommend increasing the limit only after exhausting the reduction strategies above. ## Tools From 8d3f6f76c72580e53ce1891f049fb7eb21bdc9f8 Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Thu, 19 Mar 2026 15:22:26 +0200 Subject: [PATCH 10/16] Wording --- debugging/troubleshooting.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index 60f192ab..1c47114b 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -300,7 +300,7 @@ This requires client code to subscribe when the user opens a project and unsubsc The default of 1,000 can be increased up to 10,000 upon request for [Team and Enterprise](https://www.powersync.com/pricing) customers. For self-hosted deployments, configure `max_parameter_query_results` in the API service config. The limit applies per individual user — your PowerSync Service instance can track far more buckets in total across all users. -Before requesting a higher limit, consider the performance implications. Incremental sync overhead scales roughly linearly with the number of buckets per user. Doubling the bucket count approximately doubles sync latency for a single operation and doubles CPU and memory usage on both the server and the client. By contrast, having many operations within a single bucket scales much more efficiently. The 1,000 default exists both to encourage sync configs that use fewer, larger buckets and to protect the service from the overhead of excessive bucket counts. We recommend increasing the limit only after exhausting the reduction strategies above. +Before requesting a higher limit, consider the performance implications. Incremental sync overhead scales roughly linearly with the number of buckets per user. Doubling the bucket count approximately doubles sync latency for a single operation and doubles CPU and memory usage on both the server and the client. By contrast, having many operations within a single bucket scales much more efficiently. The 1,000 default exists both to encourage sync configs that use fewer, larger buckets and to protect the PowerSyncService from the overhead of excessive bucket counts. We recommend increasing the limit only after exhausting the reduction strategies above. ## Tools From 6612362cc604cead896c64ba013fadab3dc847f2 Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Thu, 19 Mar 2026 15:28:16 +0200 Subject: [PATCH 11/16] Polish --- debugging/troubleshooting.mdx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index 1c47114b..795c56e3 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -120,8 +120,6 @@ Each query creates its own bucket namespace, even when two queries use the same At scale — 10 orgs and 50 projects per org — this becomes 10 + 500 + 500 = 1,010 buckets, which exceeds the default limit. -The fix is to make all queries use the same CTE so they share a single bucket namespace. In this case that means filtering every table by `org_id` directly, which requires `org_id` to exist on every table. If tasks only have `project_id`, you need to add `org_id` to the tasks table first. See "Denormalize for hierarchical data" below. - #### Diagnosing which streams are contributing - The `PSYNC_S2305` error log includes a breakdown showing which stream definitions are contributing the most bucket instances (top 10 by count). @@ -298,7 +296,7 @@ This requires client code to subscribe when the user opens a project and unsubsc #### Increasing the limit -The default of 1,000 can be increased up to 10,000 upon request for [Team and Enterprise](https://www.powersync.com/pricing) customers. For self-hosted deployments, configure `max_parameter_query_results` in the API service config. The limit applies per individual user — your PowerSync Service instance can track far more buckets in total across all users. +The default of 1,000 can be increased upon request for [Team and Enterprise](https://www.powersync.com/pricing) customers. For self-hosted deployments, configure `max_parameter_query_results` in the API service config. The limit applies per individual user — your PowerSync Service instance can track far more buckets in total across all users. Before requesting a higher limit, consider the performance implications. Incremental sync overhead scales roughly linearly with the number of buckets per user. Doubling the bucket count approximately doubles sync latency for a single operation and doubles CPU and memory usage on both the server and the client. By contrast, having many operations within a single bucket scales much more efficiently. The 1,000 default exists both to encourage sync configs that use fewer, larger buckets and to protect the PowerSyncService from the overhead of excessive bucket counts. We recommend increasing the limit only after exhausting the reduction strategies above. From 8bbe03d526e4799033eb2e4c048bdc52bae9d7c2 Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Thu, 19 Mar 2026 15:30:36 +0200 Subject: [PATCH 12/16] Polish --- debugging/troubleshooting.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index 795c56e3..69d3698e 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -89,6 +89,8 @@ The **subquery** and **one-to-many JOIN** cases follow the same principle: when **Hierarchical or chained queries** are another source of bucket growth. Each query in a stream is indexed by the CTE it uses, and each unique value that CTE returns becomes a separate bucket key. When queries use different CTEs, they each create their own set of buckets and do not share. +For example, consider the following stream: + ```yaml streams: org_projects_tasks: @@ -118,7 +120,7 @@ Each query creates its own bucket namespace, even when two queries use the same | `tasks` | `user_projects` | proj-1 … proj-6 | 6 | | | | **Total** | **14** | -At scale — 10 orgs and 50 projects per org — this becomes 10 + 500 + 500 = 1,010 buckets, which exceeds the default limit. +At scale — 10 orgs and 50 projects per org — this becomes 10 + 500 + 500 = 1,010 buckets, which exceeds the limit. #### Diagnosing which streams are contributing From 3c267252992339d0bc62345cb57c7fba12e77d61 Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Fri, 20 Mar 2026 10:06:35 +0200 Subject: [PATCH 13/16] PR feedback --- debugging/troubleshooting.mdx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index 69d3698e..a3d3924c 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -59,7 +59,7 @@ PowerSync uses internal partitions called [buckets](/architecture/powersync-serv #### How buckets are created in Sync Streams -The number of buckets a stream creates for a given user depends on how your query filters data. For subqueries and one-to-many JOINs, each row returned creates a bucket. For many-to-many JOINs, each row of the primary table (the one in `SELECT`) creates a bucket instead. The 1,000 limit applies to the total amount of buckets across all active streams for a single user. +The number of buckets a stream creates for a given user depends on how your query filters data. The general rule is: one bucket is created per unique value of the filter expression — whether a subquery result, a JOIN, an auth parameter, or a subscription parameter. The 1,000 limit applies to the total across all active streams for a single user. Examples below use a common schema: @@ -81,13 +81,17 @@ regions | Subscription parameter: `WHERE project_id = subscription.parameter('project_id')` | 1 per unique parameter value the client subscribes with | | Subquery returning N rows: `WHERE id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id())` | N — one per result row of the subquery | | INNER JOIN through an intermediate table: `SELECT tasks.* FROM tasks JOIN projects ON tasks.project_id = projects.id WHERE projects.org_id IN (...)` | N — one per row of the joined table (one per project) | -| Many-to-many JOIN: `SELECT assets.* FROM assets JOIN project_assets ON project_assets.asset_id = assets.id WHERE project_assets.project_id IN (...)` | N — one per primary table row (one per asset) | +| Many-to-many JOIN: `SELECT assets.* FROM assets JOIN project_assets ON project_assets.asset_id = assets.id WHERE project_assets.project_id IN (...)` | N — one per asset row (not per `project_assets` row) | +| JWT array parameter: `WHERE project_id IN auth.parameter('project_ids')` | N — one per value in the JWT array | +| Combined subquery + subscription parameter: `WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) AND region = subscription.parameter('region')` | N × M — one per (org\_id, region) pair | -The **subquery** and **one-to-many JOIN** cases follow the same principle: when a query filters through an intermediate table — whether via a subquery or a JOIN — each row of that intermediate table creates a separate bucket. The subquery returns `org_id`s, so you get one bucket per org; the tasks-projects JOIN yields one bucket per project. +The same general rule applies in all cases: one bucket per unique value of the filter expression for the synced (SELECT) table. For a subquery like `WHERE id IN (SELECT org_id FROM org_membership WHERE ...)`, each `org_id` returned is one bucket key. For a one-to-many JOIN like `SELECT tasks.* FROM tasks JOIN projects ON ...`, each project row in the join produces one bucket for tasks. -**Many-to-many JOINs are different** than one-to-many JOINs. With a many-to-many relationship (e.g., assets ↔ projects via a join table like `project_assets`), the join table does *not* define the bucket breakdown. PowerSync processes each primary-table row independently and cannot group by the join table's keys. So `SELECT assets.* FROM assets INNER JOIN project_assets ...` creates one bucket per _asset_ row. +For a many-to-many JOIN (e.g., `SELECT assets.* FROM assets JOIN project_assets ON project_assets.asset_id = assets.id`), the bucket key is each `assets.id` that passes the filter, not each `project_assets` row. -**Hierarchical or chained queries** are another source of bucket growth. Each query in a stream is indexed by the CTE it uses, and each unique value that CTE returns becomes a separate bucket key. When queries use different CTEs, they each create their own set of buckets and do not share. +When a query combines two independent filter expressions — such as an IN subquery returning N rows and a subscription parameter with M distinct values — the bucket count multiplies to N × M, one per unique combination. + +**Hierarchical or chained queries** are another source of bucket growth. Each query in a stream is indexed by the CTE it uses, and each unique value that CTE returns becomes a separate bucket key. Queries using different CTEs always create separate sets of buckets. Queries using the same CTE within a stream may share buckets. For example, consider the following stream: @@ -111,16 +115,16 @@ user_orgs → [org-A, org-B] (2 values) user_projects → [proj-1, proj-2, proj-3, proj-4, proj-5, proj-6] (6 values) ``` -Each query creates its own bucket namespace, even when two queries use the same CTE: +Queries using different CTEs always create separate sets of buckets. Queries using the same CTE within a stream may share buckets — the compiler can merge them into a single set: | Query | CTE used | Bucket keys | Buckets | |---|---|---|---| | `orgs` | `user_orgs` | org-A, org-B | 2 | | `projects` | `user_projects` | proj-1 … proj-6 | 6 | -| `tasks` | `user_projects` | proj-1 … proj-6 | 6 | -| | | **Total** | **14** | +| `tasks` | `user_projects` (shared with `projects`) | proj-1 … proj-6 | 0 extra | +| | | **Total** | **8** | -At scale — 10 orgs and 50 projects per org — this becomes 10 + 500 + 500 = 1,010 buckets, which exceeds the limit. +At scale — 10 orgs and 50 projects per org — this is 10 + 500 = 510 buckets. Even with same-CTE merging, having two CTEs with different cardinalities still causes bucket growth: every new level of the hierarchy multiplies the amount of buckets. #### Diagnosing which streams are contributing @@ -201,7 +205,7 @@ streams: When chained queries through parent-child relationships (e.g., org → project → task) create too many buckets, filter all tables with the same top-level parameter (e.g., `org_id`). This only works if child tables have that column. If tasks only have `project_id`, add `org_id` to the tasks table. -**Before** — 3 chained queries → 10 + 500 + 500 = 1,010 buckets for 10 orgs with 50 projects each: +**Before** — 3 chained queries → 10 + 500 = 510 buckets for 10 orgs with 50 projects each (projects and tasks share buckets since they use the same CTE, but orgs and projects use different CTEs and do not): ```yaml streams: From 457f474d600a43cc60d4d1b125f572765bcbb5a8 Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Fri, 20 Mar 2026 10:56:21 +0200 Subject: [PATCH 14/16] More intuitive order --- debugging/troubleshooting.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index a3d3924c..9514e097 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -78,12 +78,12 @@ regions |---|---| | No parameters: `SELECT * FROM regions` | 1 global bucket, shared by all users | | Direct auth filter only: `WHERE user_id = auth.user_id()` | 1 per user | +| JWT array parameter: `WHERE project_id IN auth.parameter('project_ids')` | N - one per value in the JWT array | | Subscription parameter: `WHERE project_id = subscription.parameter('project_id')` | 1 per unique parameter value the client subscribes with | | Subquery returning N rows: `WHERE id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id())` | N — one per result row of the subquery | +| Combined subquery + subscription parameter: `WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) AND region = subscription.parameter('region')` | N × M — one per (org\_id, region) pair | | INNER JOIN through an intermediate table: `SELECT tasks.* FROM tasks JOIN projects ON tasks.project_id = projects.id WHERE projects.org_id IN (...)` | N — one per row of the joined table (one per project) | | Many-to-many JOIN: `SELECT assets.* FROM assets JOIN project_assets ON project_assets.asset_id = assets.id WHERE project_assets.project_id IN (...)` | N — one per asset row (not per `project_assets` row) | -| JWT array parameter: `WHERE project_id IN auth.parameter('project_ids')` | N — one per value in the JWT array | -| Combined subquery + subscription parameter: `WHERE org_id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) AND region = subscription.parameter('region')` | N × M — one per (org\_id, region) pair | The same general rule applies in all cases: one bucket per unique value of the filter expression for the synced (SELECT) table. For a subquery like `WHERE id IN (SELECT org_id FROM org_membership WHERE ...)`, each `org_id` returned is one bucket key. For a one-to-many JOIN like `SELECT tasks.* FROM tasks JOIN projects ON ...`, each project row in the join produces one bucket for tasks. From f62300ff6f6ef1645d34653897f8d98c27163164 Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Fri, 20 Mar 2026 10:59:48 +0200 Subject: [PATCH 15/16] Wording --- debugging/troubleshooting.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index 9514e097..511194e3 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -87,7 +87,7 @@ regions The same general rule applies in all cases: one bucket per unique value of the filter expression for the synced (SELECT) table. For a subquery like `WHERE id IN (SELECT org_id FROM org_membership WHERE ...)`, each `org_id` returned is one bucket key. For a one-to-many JOIN like `SELECT tasks.* FROM tasks JOIN projects ON ...`, each project row in the join produces one bucket for tasks. -For a many-to-many JOIN (e.g., `SELECT assets.* FROM assets JOIN project_assets ON project_assets.asset_id = assets.id`), the bucket key is each `assets.id` that passes the filter, not each `project_assets` row. +For a many-to-many JOIN (e.g., `SELECT assets.* FROM assets JOIN project_assets ON project_assets.asset_id = assets.id`), the bucket key is each `assets.id` that passes the filter. When a query combines two independent filter expressions — such as an IN subquery returning N rows and a subscription parameter with M distinct values — the bucket count multiplies to N × M, one per unique combination. From ccb832ca6f842db0bf5ac30b51cc2ee61bf0291c Mon Sep 17 00:00:00 2001 From: Benita Volkmann Date: Fri, 20 Mar 2026 11:03:58 +0200 Subject: [PATCH 16/16] Style --- debugging/troubleshooting.mdx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/debugging/troubleshooting.mdx b/debugging/troubleshooting.mdx index 511194e3..e55a1cd2 100644 --- a/debugging/troubleshooting.mdx +++ b/debugging/troubleshooting.mdx @@ -148,7 +148,7 @@ At scale — 10 orgs and 50 projects per org — this is 10 + 500 = 510 buckets. Using `queries` instead of `query` groups related tables into a single stream. All queries in that stream share one bucket per unique evaluated parameter value. See [multiple queries per stream](/sync/streams/queries#multiple-queries-per-stream). -**Before** — 5 separate streams, each with direct `auth.user_id()` filter → 5 buckets per user: +**Before**: 5 separate streams, each with direct `auth.user_id()` filter → 5 buckets per user: ```yaml streams: @@ -164,7 +164,7 @@ streams: query: SELECT * FROM profiles WHERE user_id = auth.user_id() ``` -**After** — 1 stream with 5 queries → 1 bucket per user: +**After**: 1 stream with 5 queries → 1 bucket per user: ```yaml streams: @@ -183,7 +183,7 @@ streams: When a subquery or JOIN through a membership table is causing N buckets, update the query to target the membership table directly with a direct auth filter, with no subquery and no JOIN. You will typically need fields from the related table (e.g., org name, address) alongside each membership row; denormalize those fields onto the membership table so everything is available without introducing a JOIN. -**Before** — N org memberships → N buckets: +**Before**: N org memberships → N buckets: ```yaml streams: @@ -191,7 +191,7 @@ streams: query: SELECT * FROM orgs WHERE id IN (SELECT org_id FROM org_membership WHERE user_id = auth.user_id()) ``` -**After** — 1 bucket per user (with org fields denormalized onto `org_membership`): +**After**: 1 bucket per user (with org fields denormalized onto `org_membership`): ```yaml streams: @@ -205,7 +205,7 @@ streams: When chained queries through parent-child relationships (e.g., org → project → task) create too many buckets, filter all tables with the same top-level parameter (e.g., `org_id`). This only works if child tables have that column. If tasks only have `project_id`, add `org_id` to the tasks table. -**Before** — 3 chained queries → 10 + 500 = 510 buckets for 10 orgs with 50 projects each (projects and tasks share buckets since they use the same CTE, but orgs and projects use different CTEs and do not): +**Before**: 3 chained queries → 10 + 500 = 510 buckets for 10 orgs with 50 projects each (projects and tasks share buckets since they use the same CTE, but orgs and projects use different CTEs and do not): ```yaml streams: @@ -219,7 +219,7 @@ streams: - SELECT * FROM tasks WHERE project_id IN user_projects ``` -**After** — Add `org_id` to tasks, flatten to one bucket per org → 10 buckets: +**After**: Add `org_id` to tasks, flatten to one bucket per org → 10 buckets: ```yaml streams: @@ -240,7 +240,7 @@ For assets ↔ projects via `project_assets`, buckets follow the primary table The solution is to add a denormalized `project_ids` JSON array column to `assets` (maintained via database triggers) and use `json_each()` to traverse it. This lets PowerSync partition by project ID instead of asset ID. -**Before** — One bucket per asset (e.g., 2,000 assets → 2,000 buckets): +**Before**: One bucket per asset (e.g., 2,000 assets → 2,000 buckets): ```yaml streams: @@ -253,7 +253,7 @@ streams: WHERE project_assets.project_id IN user_projects ``` -**After** — Add `project_ids` to `assets`, partition by project → 50 buckets for 50 projects: +**After**: Add `project_ids` to `assets`, partition by project → 50 buckets for 50 projects: ```yaml streams: @@ -276,7 +276,7 @@ Alternatively, use two queries in the same stream: one for `project_assets` filt Buckets are only created per active client subscription, not from all possible values. Use `subscription.parameter('project_id')` so the count is bounded by how many subscriptions the client has active. -**Before** — Subquery returns all user projects → 50 buckets for 50 projects: +**Before**: Subquery returns all user projects → 50 buckets for 50 projects: ```yaml streams: @@ -286,7 +286,7 @@ streams: query: SELECT * FROM tasks WHERE project_id IN user_projects ``` -**After** — Client subscribes per project on demand → 1 bucket per active subscription (e.g., 3 projects open = 3 buckets): +**After**: Client subscribes per project on demand → 1 bucket per active subscription (e.g., 3 projects open = 3 buckets): ```yaml streams: