Skip to content

Sync rules & buckets

Copy page

Sync rules declaratively scope which rows each authenticated client may sync. They are evaluated server-side; the client never receives out-of-scope data.

defineSyncRules((b) => ({
ruleName: b.bucket({
parameters: (actor) => /* how to discover bucket keys */,
data: (bucket) => [ /* scoped queries per bucket */ ],
}),
}));

Each rule has:

  • parameters — yields bucket column bindings for this actor (from JWT, membership table, or static mapping)
  • data — one or more table queries, each constrained to bucket keys
parameters: () => b.params({ ownerId: "owner_id" }),

The client supplies concrete bucket values via bucketsForSyncRule on createNizhalClient.

parameters: (actor) =>
b.membership({
table: "shop_members",
where: { user_id: actor.userId },
select: { shopId: "shop_id" },
}),

The server resolves membership with bound parameters — no raw SQL in app code. When membership shrinks, pull returns removedBuckets and the client purges local rows (REQ-14).

data: (bucket) => [
b.table("notes").where(b.eq("owner_id", bucket.ownerId)),
b.table("tags")
.where(b.eq("owner_id", bucket.ownerId))
.related([b.table("tag_links").where(b.eq("owner_id", bucket.ownerId))]),
],

Every query must include at least one b.eq(column, bucket.key) predicate. The builder rejects raw SQL in data queries.

defineSyncRules calls assertSyncRulesNoLeak at registration time. Violations throw SyncRuleLintError with rule name and query index.

This is not middleware — it is a typed, linted language for subset sync. If a predicate forgets to scope by bucket, the rule fails at build time (or test time), not in production with a data leak.

On pull, the server:

  1. Resolves parameters for the authenticated actor
  2. Intersects with client-requested buckets
  3. Runs each data query with bucket bindings
  4. Returns changed rows + tombstones + removedBuckets