CloudSync is fully compatible with PostgreSQL Row Level Security. Standard RLS policies work out of the box.
CloudSync resolves CRDT conflicts at the column level — a sync payload may contain individual column changes arriving one at a time. Before writing to the target table, CloudSync buffers all winning column values for the same primary key and flushes them as a single SQL statement. This ensures the database sees a complete row with all columns present.
When flushing a batch, CloudSync chooses the statement type based on whether the row already exists locally:
- New row:
INSERT ... ON CONFLICT DO UPDATE— all columns are present (including the ownership column), so the INSERTWITH CHECKpolicy can evaluate correctly. - Existing row:
UPDATE ... SET ... WHERE pk = ...— only the changed columns are set. The UPDATEUSINGpolicy checks the existing row, which already has the correct ownership column value.
Each primary key's flush is wrapped in its own savepoint. When RLS denies a write:
- The database raises an error inside the savepoint
- CloudSync rolls back that savepoint, releasing all resources acquired during the failed statement
- Processing continues with the next primary key
This means a single payload can contain a mix of allowed and denied rows — allowed rows commit normally, denied rows are silently skipped. The caller receives the total number of column changes processed (including denied ones) rather than an error.
Given a table with an ownership column (user_id):
CREATE TABLE documents (
id TEXT PRIMARY KEY,
user_id UUID,
title TEXT,
content TEXT
);
SELECT cloudsync_init('documents');Enable RLS and create standard policies:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "select_own" ON documents FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "insert_own" ON documents FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "update_own" ON documents FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "delete_own" ON documents FOR DELETE
USING (auth.uid() = user_id);When you authenticate PostgreSQL requests with JWTs, CloudSync also executes SET LOCAL ROLE using the JWT role claim. That means the role named in the token must already exist in PostgreSQL and must have the permissions needed to read and write the synced tables. See PostgreSQL Role Requirement.
This example shows the complete flow of syncing data between two databases where the target enforces RLS.
-- Source database (DB A) — no RLS, represents the sync server
CREATE TABLE documents (
id TEXT PRIMARY KEY, user_id UUID, title TEXT, content TEXT
);
SELECT cloudsync_init('documents');
-- Target database (DB B) — RLS enforced
CREATE TABLE documents (
id TEXT PRIMARY KEY, user_id UUID, title TEXT, content TEXT
);
SELECT cloudsync_init('documents');
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- (policies as above)User 1 creates a document on DB A:
-- On DB A
INSERT INTO documents VALUES ('doc1', 'user1-uuid', 'Hello', 'World');Apply the payload on DB B as the authenticated user:
-- On DB B (running as user1)
SET app.current_user_id = 'user1-uuid';
SET ROLE authenticated;
SELECT cloudsync_payload_apply(decode(:payload_hex, 'hex'));The insert succeeds because user_id matches auth.uid().
User 1 tries to sync a document owned by user 2:
-- On DB A
INSERT INTO documents VALUES ('doc2', 'user2-uuid', 'Secret', 'Data');-- On DB B (running as user1)
SET app.current_user_id = 'user1-uuid';
SET ROLE authenticated;
SELECT cloudsync_payload_apply(decode(:payload_hex, 'hex'));The insert is denied by RLS. The row does not appear in DB B. No error is raised to the caller — CloudSync isolates the failure via a per-PK savepoint and continues processing the remaining payload.
User 1 updates only the title of their own document:
-- On DB A
UPDATE documents SET title = 'Hello Updated' WHERE id = 'doc1';The sync payload contains only the changed column (title). CloudSync detects that the row already exists on DB B and uses a plain UPDATE statement:
UPDATE documents SET title = $2 WHERE id = $1;The UPDATE policy checks the existing row (which has the correct user_id), so it succeeds.
When a single payload contains rows for multiple users, CloudSync handles each primary key independently:
-- On DB A
INSERT INTO documents VALUES ('doc3', 'user1-uuid', 'Mine', '...');
INSERT INTO documents VALUES ('doc4', 'user2-uuid', 'Theirs', '...');-- On DB B (running as user1)
SELECT cloudsync_payload_apply(decode(:payload_hex, 'hex'));
-- doc3 is inserted (allowed), doc4 is silently skipped (denied)When using Supabase:
- auth.uid(): Returns the authenticated user's UUID from the JWT claims.
- JWT propagation: Ensure the JWT token is set before sync operations:
SELECT set_config('request.jwt.claims', '{"sub": "user-uuid", ...}', true);
- Service role bypass: The Supabase service role bypasses RLS entirely. Use the
authenticatedrole for user-context operations where RLS enforcement is desired.
Symptom: Insert operations fail during sync.
Cause: The ownership column value doesn't match the authenticated user.
Solution: Verify that:
- The JWT / session variable is set correctly before calling
cloudsync_payload_apply - The
user_idcolumn in the synced data matchesauth.uid() - RLS policies reference the correct ownership column
Symptom: cloudsync_payload_apply returns a non-zero column-change count, but SELECT on the target table shows no new rows. No error is raised to the caller.
Cause: The calling role is missing a grant on one of CloudSync's internal objects — the per-table shadow (<table>_cloudsync), a metadata table (cloudsync_settings, cloudsync_site_id, cloudsync_table_settings, cloudsync_schema_versions, app_schema_version), the cloudsync_changes view, or the cloudsync_site_id_id_seq sequence. The per-PK savepoint rolls the write back, but cloudsync_payload_apply still returns the number of column changes it processed.
Solution: Apply the full grant set from JWT Claims → Required Grants. To pinpoint which object is missing, re-run the apply as a superuser or raise log verbosity and inspect the server log for permission denied entries preceded by the cloudsync_payload_apply call.
-- Check current auth context
SELECT auth.uid();
-- Inspect a specific row's ownership
SELECT id, user_id FROM documents WHERE id = 'problematic-pk';
-- Temporarily disable RLS to inspect all data
ALTER TABLE documents DISABLE ROW LEVEL SECURITY;
-- ... inspect ...
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;